Files
story/internal/saas/content_service.go
T
2026-08-29 21:56:51 +08:00

869 lines
45 KiB
Go

package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
)
var (
ErrStorySourceRequired = errors.New("story source is required")
ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required")
ErrReviewStateConflict = errors.New("content review state does not allow this operation")
ErrFactReviewStateConflict = errors.New("fact candidate review state does not allow this operation")
)
type ConversationTurn struct {
ID string `json:"id"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
FinalText string `json:"final_text"`
Interrupted bool `json:"interrupted"`
SourceType string `json:"source_type"`
}
type ConversationTurnPage struct {
Items []ConversationTurn `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ConversationContentSummary struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
SourceTurnIDs []string `json:"source_turn_ids"`
ModelRef string `json:"model_ref"`
PromptVersion string `json:"prompt_version"`
GenerationVersion int64 `json:"generation_version"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type StorySource struct {
TurnID string `json:"turn_id"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
Text string `json:"text"`
StartOffset *int `json:"start_offset,omitempty"`
EndOffset *int `json:"end_offset,omitempty"`
}
type FactCandidate struct {
ID string `json:"id"`
FactType string `json:"fact_type"`
Content string `json:"content"`
StatementBasis string `json:"statement_basis"`
Confidence *float64 `json:"confidence,omitempty"`
Sensitivity string `json:"sensitivity"`
SourceTurnIDs []string `json:"source_turn_ids"`
Status string `json:"status"`
Version int64 `json:"version"`
}
type UpdateFactCandidateInput struct {
FactType *string
Content *string
Sensitivity *string
Status *string
ErrorType *string
Reason string
}
type Story struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
Confidence *float64 `json:"confidence,omitempty"`
Sensitivity string `json:"sensitivity"`
GenerationVersion int64 `json:"generation_version"`
RevisionNo int64 `json:"revision_no"`
AuthorType string `json:"author_type"`
ReviewReason string `json:"review_reason,omitempty"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Sources []StorySource `json:"sources"`
Facts []FactCandidate `json:"facts"`
}
type CuratedMemory struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Content string `json:"content"`
SourceRefs []map[string]any `json:"source_refs"`
Status string `json:"status"`
Sensitivity string `json:"sensitivity"`
VersionNo int64 `json:"version_no"`
Version int64 `json:"version"`
ConfirmedBy string `json:"confirmed_by"`
ConfirmedAt time.Time `json:"confirmed_at"`
ChangeReason string `json:"change_reason,omitempty"`
IndexStatus string `json:"index_status"`
LastIndexError string `json:"last_index_error,omitempty"`
ProviderIndexStatus string `json:"provider_index_status"`
ProviderIndexError string `json:"provider_index_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UpdateMemoryInput struct {
Content *string
Status *string
ErrorType *string
Reason string
}
var contentErrorTypes = map[string]bool{
"PERSON_ERROR": true, "TIME_ERROR": true, "PLACE_ERROR": true, "RELATION_ERROR": true,
"OVER_INFERENCE": true, "KEY_POINT_OMISSION": true, "DUPLICATE_CONTENT": true,
"UNNATURAL_EXPRESSION": true, "SENSITIVE_INFORMATION": true,
}
func IsContentErrorType(value string) bool { return contentErrorTypes[value] }
func (s *Service) ListConversationTurns(ctx context.Context, principal identity.Principal, conversationPublicID string, limit int, cursor string) (ConversationTurnPage, error) {
access, conversationID, err := s.resolveConversationContent(ctx, s.pool, principal, conversationPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return ConversationTurnPage{}, err
}
if s.cursor == nil {
return ConversationTurnPage{}, errors.New("pagination cursor codec is unavailable")
}
var afterSequence int64
var afterID string
if cursor != "" {
decoded, err := s.cursor.Decode(cursor)
if err != nil {
return ConversationTurnPage{}, pagination.ErrInvalidCursor
}
if _, err := fmt.Sscan(decoded.SortKey, &afterSequence); err != nil {
return ConversationTurnPage{}, pagination.ErrInvalidCursor
}
afterID = decoded.ID
}
rows, err := s.pool.Query(ctx, `
SELECT public_id::text,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type
FROM conversation_turn WHERE tenant_id=$1::uuid AND conversation_id=$2::uuid
AND ($3::bigint=0 OR (sequence_no,public_id)>($3,$4::uuid))
ORDER BY sequence_no,public_id LIMIT $5`, access.tenantID, conversationID, afterSequence, nullUUID(afterID), limit+1)
if err != nil {
return ConversationTurnPage{}, fmt.Errorf("list conversation turns: %w", err)
}
defer rows.Close()
items := make([]ConversationTurn, 0, limit+1)
for rows.Next() {
var item ConversationTurn
if err := rows.Scan(&item.ID, &item.SequenceNo, &item.Speaker, &item.StartAt, &item.EndAt, &item.FinalText, &item.Interrupted, &item.SourceType); err != nil {
return ConversationTurnPage{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return ConversationTurnPage{}, err
}
page := ConversationTurnPage{Items: items}
if len(items) > limit {
page.Items = items[:limit]
last := page.Items[len(page.Items)-1]
page.NextCursor, err = s.cursor.Encode(fmt.Sprint(last.SequenceNo), last.ID)
if err != nil {
return ConversationTurnPage{}, err
}
}
return page, nil
}
func (s *Service) ListConversationContentSummaries(ctx context.Context, principal identity.Principal, conversationPublicID string) ([]ConversationContentSummary, error) {
access, conversationID, err := s.resolveConversationContent(ctx, s.pool, principal, conversationPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `SELECT public_id::text,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,status,created_at FROM conversation_summary WHERE tenant_id=$1::uuid AND conversation_id=$2::uuid ORDER BY generation_version DESC,summary_type`, access.tenantID, conversationID)
if err != nil {
return nil, fmt.Errorf("list conversation summaries: %w", err)
}
defer rows.Close()
items := make([]ConversationContentSummary, 0)
for rows.Next() {
var item ConversationContentSummary
var sourceJSON []byte
if err := rows.Scan(&item.ID, &item.Type, &item.Content, &sourceJSON, &item.ModelRef, &item.PromptVersion, &item.GenerationVersion, &item.Status, &item.CreatedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(sourceJSON, &item.SourceTurnIDs); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Principal, subjectPublicID, status string) ([]Story, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status)
if err != nil {
return nil, fmt.Errorf("list subject stories: %w", err)
}
defer rows.Close()
items := make([]Story, 0)
for rows.Next() {
item, err := scanStory(rows)
if err != nil {
return nil, err
}
item.Sources = []StorySource{}
item.Facts = []FactCandidate{}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetStory(ctx context.Context, principal identity.Principal, storyPublicID string) (Story, error) {
_, story, err := s.resolveStory(ctx, s.pool, principal, storyPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return Story{}, err
}
if err := s.loadStoryChildren(ctx, &story); err != nil {
return Story{}, err
}
return story, nil
}
func (s *Service) UpdateFactCandidate(ctx context.Context, principal identity.Principal, storyPublicID, factPublicID, requestID string, expectedVersion int64, input UpdateFactCandidateInput) (FactCandidate, error) {
var result FactCandidate
if input.ErrorType != nil && !IsContentErrorType(*input.ErrorType) {
return result, ErrFactReviewStateConflict
}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, story, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if story.Status == "CONFIRMED" || story.Status == "REJECTED" || story.Status == "SUPERSEDED" {
return ErrFactReviewStateConflict
}
var internalID, factType, content, statementBasis, sensitivity, status string
var confidence *float64
var sourceJSON []byte
var version int64
if err := tx.QueryRow(ctx, `
SELECT fc.id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version
FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id AND sd.tenant_id=fc.tenant_id
WHERE fc.public_id=$1::uuid AND sd.public_id=$2::uuid FOR UPDATE OF fc`, factPublicID, storyPublicID).Scan(
&internalID, &factType, &content, &statementBasis, &confidence, &sensitivity, &sourceJSON, &status, &version,
); errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return fmt.Errorf("lock fact candidate: %w", err)
}
if version != expectedVersion {
return ErrVersionConflict
}
if status == "REJECTED" || status == "SUPERSEDED" || (status == "CONFIRMED" && (input.Status == nil || *input.Status != "SUPERSEDED")) {
return ErrFactReviewStateConflict
}
previousFactType, previousContent, previousSensitivity, previousStatus := factType, content, sensitivity, status
if input.FactType != nil {
factType = strings.TrimSpace(*input.FactType)
}
if input.Content != nil {
content = strings.TrimSpace(*input.Content)
}
if input.Sensitivity != nil {
sensitivity = *input.Sensitivity
}
if input.Status != nil {
status = *input.Status
}
if status != "CANDIDATE" && status != "CONFIRMED" && status != "REJECTED" && status != "SUPERSEDED" {
return ErrFactReviewStateConflict
}
if _, err := tx.Exec(ctx, `INSERT INTO fact_candidate_revision(fact_candidate_id,version_no,fact_type,content,sensitivity,status,edited_by,edit_reason)
VALUES($1::uuid,$2,$3,$4,$5,$6,$7::uuid,$8) ON CONFLICT(fact_candidate_id,version_no) DO NOTHING`, internalID, version, previousFactType, previousContent, previousSensitivity, previousStatus, access.userID, input.Reason); err != nil {
return err
}
row := tx.QueryRow(ctx, `
UPDATE fact_candidate SET fact_type=$1,content=$2,sensitivity=$3,status=$4,
reviewed_by=$5::uuid,reviewed_at=now(),review_reason=$6,version=version+1,updated_at=now()
WHERE id=$7::uuid
RETURNING public_id::text,fact_type,content,statement_basis,confidence,sensitivity,source_turn_ids,status,version`,
factType, content, sensitivity, status, access.userID, input.Reason, internalID)
if err := scanFactCandidate(row, &result); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO fact_candidate_revision(fact_candidate_id,version_no,fact_type,content,sensitivity,status,edited_by,edit_reason,error_type)
VALUES($1::uuid,$2,$3,$4,$5,$6,$7::uuid,$8,$9)`, internalID, result.Version, result.FactType, result.Content, result.Sensitivity, result.Status, access.userID, input.Reason, input.ErrorType); err != nil {
return err
}
action := reviewAction(result.Status, input.Content != nil || input.FactType != nil || input.Sensitivity != nil)
if _, err := tx.Exec(ctx, `INSERT INTO content_review_signal(tenant_id,subject_id,resource_type,resource_public_id,action,error_type,reason,model_ref,prompt_version,generation_version,actor_id)
SELECT sd.tenant_id,sd.subject_id,'fact',$1::uuid,$2,$3,$4,sr.model_ref,sr.prompt_version,sd.generation_version,$5::uuid
FROM story_draft sd JOIN story_revision sr ON sr.story_id=sd.id AND sr.revision_no=1 WHERE sd.public_id=$6::uuid`, factPublicID, action, input.ErrorType, input.Reason, access.userID, storyPublicID); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "fact_candidate.update", ResourceType: "fact_candidate", ResourceID: strPtr(factPublicID),
RequestID: requestID, Result: "success", Detail: map[string]any{"story_id": storyPublicID, "status": result.Status, "reason": input.Reason},
})
})
return result, err
}
func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal, storyPublicID, content, reason, requestID string, expectedVersion int64, errorType string) (Story, error) {
var result Story
if errorType != "" && !IsContentErrorType(errorType) {
return result, ErrReviewStateConflict
}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" {
return ErrReviewStateConflict
}
var currentRevisionID, newRevisionID string
if err := tx.QueryRow(ctx, `SELECT current_revision_id::text FROM story_draft WHERE public_id=$1::uuid`, storyPublicID).Scan(&currentRevisionID); err != nil {
return err
}
if err := tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,author_id,edit_reason,base_revision_id,error_type) SELECT id,$2,$3,'user',$4::uuid,$5,$6::uuid,NULLIF($7,'') FROM story_draft WHERE public_id=$1::uuid RETURNING id::text`, storyPublicID, current.RevisionNo+1, content, access.userID, reason, currentRevisionID, errorType).Scan(&newRevisionID); err != nil {
return fmt.Errorf("insert story revision: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, newRevisionID, currentRevisionID); err != nil {
return err
}
row := tx.QueryRow(ctx, storyUpdateReturningSQL, `IN_REVIEW`, newRevisionID, access.userID, reason, storyPublicID)
result, err = scanStory(row)
if err != nil {
return err
}
if err := insertStoryReviewSignal(ctx, tx, access, storyPublicID, "EDITED", errorType, reason); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.revise", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"revision_no": result.RevisionNo, "reason": reason}})
})
if err != nil {
return Story{}, err
}
if err := s.loadStoryChildren(ctx, &result); err != nil {
return Story{}, err
}
return result, nil
}
func (s *Service) RejectStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64, errorType string) (Story, error) {
var result Story
if errorType != "" && !IsContentErrorType(errorType) {
return result, ErrReviewStateConflict
}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" {
return ErrReviewStateConflict
}
row := tx.QueryRow(ctx, storyStatusReturningSQL, "REJECTED", access.userID, reason, storyPublicID)
result, err = scanStory(row)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE fact_candidate SET status='REJECTED',reviewed_by=$1::uuid,reviewed_at=now(),review_reason=$2,version=version+1,updated_at=now() WHERE story_id=(SELECT id FROM story_draft WHERE public_id=$3::uuid) AND status='CANDIDATE'`, access.userID, reason, storyPublicID); err != nil {
return err
}
if err := insertStoryReviewSignal(ctx, tx, access, storyPublicID, "REJECTED", errorType, reason); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.reject", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"reason": reason}})
})
if err != nil {
return Story{}, err
}
if err := s.loadStoryChildren(ctx, &result); err != nil {
return Story{}, err
}
return result, nil
}
func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64) (Story, CuratedMemory, error) {
var story Story
var memory CuratedMemory
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" {
return ErrReviewStateConflict
}
var sourceCount int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM story_source WHERE story_revision_id=(SELECT current_revision_id FROM story_draft WHERE public_id=$1::uuid)`, storyPublicID).Scan(&sourceCount); err != nil {
return err
}
if sourceCount == 0 {
return ErrStorySourceRequired
}
var consent bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, access.tenantID, access.subjectID).Scan(&consent); err != nil {
return err
}
if !consent {
return ErrPersonalizedMemoryConsentNeeded
}
storyRow := tx.QueryRow(ctx, storyStatusReturningSQL, "CONFIRMED", access.userID, reason, storyPublicID)
story, err = scanStory(storyRow)
if err != nil {
return err
}
sourceRefs := []map[string]any{{"type": "story", "story_id": storyPublicID, "revision_no": story.RevisionNo}}
sourceJSON, _ := json.Marshal(sourceRefs)
digest := sha256.Sum256([]byte(story.Content))
var memoryID, memoryVersionID string
if err := tx.QueryRow(ctx, `INSERT INTO curated_memory(tenant_id,subject_id,status,sensitivity,valid_from) VALUES($1::uuid,$2::uuid,'ACTIVE',$3,now()) RETURNING id::text,public_id::text`, access.tenantID, access.subjectID, story.Sensitivity).Scan(&memoryID, &memory.ID); err != nil {
return fmt.Errorf("create curated memory: %w", err)
}
if err := tx.QueryRow(ctx, `INSERT INTO memory_version(memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest) VALUES($1::uuid,1,$2,$3::jsonb,$4::uuid,$5,$6) RETURNING id::text`, memoryID, story.Content, sourceJSON, access.userID, reason, hex.EncodeToString(digest[:])).Scan(&memoryVersionID); err != nil {
return fmt.Errorf("create memory version: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid WHERE id=$2::uuid`, memoryVersionID, memoryID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, access.tenantID, access.subjectID, memoryID, memoryVersionID); err != nil {
return err
}
if s.providerMemory {
if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'provider','PENDING')`, access.tenantID, access.subjectID, memoryID, memoryVersionID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `UPDATE fact_candidate SET status='CONFIRMED',reviewed_by=$1::uuid,reviewed_at=now(),review_reason=$2,version=version+1,updated_at=now() WHERE story_id=(SELECT id FROM story_draft WHERE public_id=$3::uuid) AND status='CANDIDATE'`, access.userID, reason, storyPublicID); err != nil {
return err
}
if err := insertStoryReviewSignal(ctx, tx, access, storyPublicID, "CONFIRMED", "", reason); err != nil {
return err
}
dedupe := "memory-index-v1:" + memoryVersionID
payload, _ := json.Marshal(map[string]any{"memory_version_id": memoryVersionID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
if s.providerMemory {
providerDedupe := "memory-provider-sync-v1:" + memoryVersionID
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.provider_sync_requested", DeduplicationKey: &providerDedupe, Payload: payload}); err != nil {
return err
}
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.confirm", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"memory_id": memory.ID, "revision_no": story.RevisionNo}}); err != nil {
return err
}
return nil
})
if err != nil {
return Story{}, CuratedMemory{}, err
}
memory, err = s.GetMemory(ctx, principal, memory.ID)
if err != nil {
return Story{}, CuratedMemory{}, err
}
if err := s.loadStoryChildren(ctx, &story); err != nil {
return Story{}, CuratedMemory{}, err
}
return story, memory, nil
}
func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Principal, subjectPublicID, query string) ([]CuratedMemory, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, memorySelectSQL+` WHERE cm.tenant_id=$1::uuid AND cm.subject_id=$2::uuid AND cm.status<>'DELETED' AND (NULLIF($3,'') IS NULL OR strpos(lower(mv.content),lower($3))>0) ORDER BY cm.updated_at DESC,cm.public_id`, access.tenantID, access.subjectID, strings.TrimSpace(query))
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]CuratedMemory, 0)
for rows.Next() {
item, err := scanMemory(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, memoryPublicID string) (CuratedMemory, error) {
result, err := scanMemory(s.pool.QueryRow(ctx, memorySelectSQL+` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`, memoryPublicID, principal.Issuer, principal.Subject))
if errors.Is(err, pgx.ErrNoRows) {
return CuratedMemory{}, ErrAccessDenied
}
if err != nil {
return CuratedMemory{}, err
}
return result, nil
}
func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal, memoryPublicID, requestID string, expectedVersion int64, input UpdateMemoryInput) (CuratedMemory, error) {
if input.ErrorType != nil && !IsContentErrorType(*input.ErrorType) {
return CuratedMemory{}, ErrReviewStateConflict
}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var internalID, tenantID, subjectID, userID, userPublicID, role string
var currentVersionID, currentContent, currentStatus string
var currentVersionNo, resourceVersion int64
var sourceRefs []byte
err := tx.QueryRow(ctx, `
SELECT cm.id::text,cm.tenant_id::text,cm.subject_id::text,member_account.id::text,
member_account.public_id::text,hm.role,cm.current_version_id::text,mv.version_no,
mv.content,mv.source_refs,cm.status,cm.version
FROM curated_memory cm
JOIN memory_version mv ON mv.id=cm.current_version_id
JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id
JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id
JOIN household_member hm ON hm.household_id=h.id AND hm.status='active'
JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active'
WHERE cm.public_id=$1::uuid AND cm.status<>'DELETED' AND sp.status IN ('active','suspended')
AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3
FOR UPDATE OF cm`, memoryPublicID, principal.Issuer, principal.Subject).Scan(
&internalID, &tenantID, &subjectID, &userID, &userPublicID, &role, &currentVersionID,
&currentVersionNo, &currentContent, &sourceRefs, &currentStatus, &resourceVersion)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
}
if err != nil {
return fmt.Errorf("resolve memory update: %w", err)
}
if !identity.Can(identity.Role(role), identity.CapabilityStoryReview) {
return ErrAccessDenied
}
if resourceVersion != expectedVersion {
return ErrVersionConflict
}
if currentStatus == "PENDING_DELETE" {
return ErrReviewStateConflict
}
targetContent := currentContent
if input.Content != nil {
targetContent = strings.TrimSpace(*input.Content)
}
targetStatus := currentStatus
if input.Status != nil {
targetStatus = *input.Status
}
if targetContent == currentContent && targetStatus == currentStatus {
return ErrReviewStateConflict
}
if targetStatus == "ACTIVE" {
var consent bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, tenantID, subjectID).Scan(&consent); err != nil {
return err
}
if !consent {
return ErrPersonalizedMemoryConsentNeeded
}
}
newVersionID := currentVersionID
if targetContent != currentContent {
digest := sha256.Sum256([]byte(targetContent))
if err := tx.QueryRow(ctx, `
INSERT INTO memory_version(memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest,error_type)
VALUES($1::uuid,$2,$3,$4::jsonb,$5::uuid,$6,$7,$8) RETURNING id::text`,
internalID, currentVersionNo+1, targetContent, sourceRefs, userID, input.Reason, hex.EncodeToString(digest[:]), input.ErrorType).Scan(&newVersionID); err != nil {
return fmt.Errorf("create memory revision: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='memory version or status changed',updated_at=now() WHERE memory_id=$1::uuid AND status IN ('PENDING','ACTIVE')`, internalID); err != nil {
return err
}
if targetStatus == "ACTIVE" {
if newVersionID == currentVersionID {
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET status='PENDING',last_error=NULL,updated_at=now() WHERE memory_version_id=$1::uuid AND index_type='pgvector'`, newVersionID); err != nil {
return err
}
} else if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, tenantID, subjectID, internalID, newVersionID); err != nil {
return err
}
if s.providerMemory {
if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status,last_error,updated_at)
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'provider','PENDING',NULL,now())
ON CONFLICT(memory_version_id,index_type) DO UPDATE SET status='PENDING',last_error=NULL,updated_at=now()`, tenantID, subjectID, internalID, newVersionID); err != nil {
return err
}
}
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid,status=$2,version=version+1,updated_at=now() WHERE id=$3::uuid`, newVersionID, targetStatus, internalID); err != nil {
return err
}
action := "EDITED"
if targetStatus == "DISABLED" {
action = "DISABLED"
} else if targetContent == currentContent {
action = "CONFIRMED"
}
if _, err := tx.Exec(ctx, `INSERT INTO content_review_signal(tenant_id,subject_id,resource_type,resource_public_id,action,error_type,reason,model_ref,prompt_version,generation_version,actor_id)
SELECT $1::uuid,$2::uuid,'memory',$3::uuid,$4,$5,$6,sr.model_ref,sr.prompt_version,sd.generation_version,$7::uuid
FROM memory_version mv
LEFT JOIN story_draft sd ON sd.public_id::text=mv.source_refs->0->>'story_id'
LEFT JOIN story_revision sr ON sr.id=sd.current_revision_id
WHERE mv.id=$8::uuid`, tenantID, subjectID, memoryPublicID, action, input.ErrorType, input.Reason, userID, newVersionID); err != nil {
return err
}
if targetStatus == "ACTIVE" {
dedupe := fmt.Sprintf("memory-index-update-v%d:%s", resourceVersion+1, newVersionID)
payload, _ := json.Marshal(map[string]any{"memory_version_id": newVersionID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
if s.providerMemory {
providerDedupe := fmt.Sprintf("memory-provider-sync-update-v%d:%s", resourceVersion+1, newVersionID)
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.provider_sync_requested", DeduplicationKey: &providerDedupe, Payload: payload}); err != nil {
return err
}
}
} else if s.providerMemory {
providerDedupe := fmt.Sprintf("memory-provider-delete-disabled-v%d:%s", resourceVersion+1, internalID)
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.provider_delete_requested", DeduplicationKey: &providerDedupe, Payload: []byte(`{"reason":"memory disabled"}`)}); err != nil {
return err
}
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID), Action: "memory.update", ResourceType: "memory", ResourceID: strPtr(memoryPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": currentStatus, "to_status": targetStatus, "content_revised": targetContent != currentContent, "reason": input.Reason}})
})
if err != nil {
return CuratedMemory{}, err
}
return s.GetMemory(ctx, principal, memoryPublicID)
}
func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.Principal, memoryPublicID, reason, requestID string, expectedVersion int64) (CuratedMemory, error) {
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var internalID, tenantID, userPublicID, role, status string
var version int64
err := tx.QueryRow(ctx, `
SELECT cm.id::text,cm.tenant_id::text,member_account.public_id::text,hm.role,cm.status,cm.version
FROM curated_memory cm
JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id
JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id
JOIN household_member hm ON hm.household_id=h.id AND hm.status='active'
JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active'
WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3
FOR UPDATE OF cm`, memoryPublicID, principal.Issuer, principal.Subject).Scan(&internalID, &tenantID, &userPublicID, &role, &status, &version)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
}
if err != nil {
return fmt.Errorf("resolve memory deletion: %w", err)
}
if !identity.Can(identity.Role(role), identity.CapabilityStoryReview) {
return ErrAccessDenied
}
if version != expectedVersion {
return ErrVersionConflict
}
if status == "DELETED" || status == "PENDING_DELETE" {
return nil
}
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET status='PENDING_DELETE',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='memory deletion requested',updated_at=now() WHERE memory_id=$1::uuid AND status<>'DELETED'`, internalID); err != nil {
return err
}
dedupe := "memory-delete:" + internalID
payload, _ := json.Marshal(map[string]any{"reason": reason})
if s.providerMemory {
providerDedupe := "memory-provider-delete:" + internalID
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.provider_delete_requested", DeduplicationKey: &providerDedupe, Payload: payload}); err != nil {
return err
}
}
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.delete_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID), Action: "memory.delete.request", ResourceType: "memory", ResourceID: strPtr(memoryPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"reason": reason}})
})
if err != nil {
return CuratedMemory{}, err
}
return s.GetMemory(ctx, principal, memoryPublicID)
}
type contentAccess struct {
tenantID, subjectID, userID, userPublicID string
role identity.Role
}
func reviewAction(status string, edited bool) string {
if edited && status == "CANDIDATE" {
return "EDITED"
}
switch status {
case "CONFIRMED":
return "CONFIRMED"
case "REJECTED", "SUPERSEDED":
return "REJECTED"
default:
return "EDITED"
}
}
func insertStoryReviewSignal(ctx context.Context, tx pgx.Tx, access contentAccess, storyPublicID, action, errorType, reason string) error {
_, err := tx.Exec(ctx, `INSERT INTO content_review_signal(tenant_id,subject_id,resource_type,resource_public_id,action,error_type,reason,model_ref,prompt_version,generation_version,actor_id)
SELECT sd.tenant_id,sd.subject_id,'story',sd.public_id,$2,NULLIF($3,''),$4,sr.model_ref,sr.prompt_version,sd.generation_version,$5::uuid
FROM story_draft sd JOIN story_revision sr ON sr.story_id=sd.id AND sr.revision_no=1 WHERE sd.public_id=$1::uuid`, storyPublicID, action, errorType, reason, access.userID)
return err
}
func (s *Service) resolveConversationContent(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, string, error) {
sql := `SELECT c.tenant_id::text,c.subject_id::text,ua.id::text,ua.public_id::text,hm.role,c.id::text FROM conversation c JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended') JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active' WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF c`
}
var a contentAccess
var role, conversationID string
err := q.QueryRow(ctx, sql, publicID, p.Issuer, p.Subject).Scan(&a.tenantID, &a.subjectID, &a.userID, &a.userPublicID, &role, &conversationID)
if errors.Is(err, pgx.ErrNoRows) {
return contentAccess{}, "", ErrAccessDenied
}
if err != nil {
return contentAccess{}, "", err
}
a.role = identity.Role(role)
if !identity.Can(a.role, cap) {
return contentAccess{}, "", ErrAccessDenied
}
return a, conversationID, nil
}
func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, Story, error) {
sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF sd`
}
var a contentAccess
var story Story
var role, tenantID, subjectInternal string
targets := storyScanTargets(&story, &tenantID, &subjectInternal)
targets = append(targets, &a.userID, &a.userPublicID, &role)
row := q.QueryRow(ctx, strings.Replace(sql, `sd.subject_id::text AS subject_internal_id`, `sd.subject_id::text AS subject_internal_id,member_account.id::text,member_account.public_id::text,hm.role`, 1), publicID, p.Issuer, p.Subject)
err := row.Scan(targets...)
if errors.Is(err, pgx.ErrNoRows) {
return contentAccess{}, Story{}, ErrAccessDenied
}
if err != nil {
return contentAccess{}, Story{}, err
}
a.tenantID = tenantID
a.subjectID = subjectInternal
a.role = identity.Role(role)
if !identity.Can(a.role, cap) {
return contentAccess{}, Story{}, ErrAccessDenied
}
return a, story, nil
}
const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,c.public_id::text AS conversation_public_id,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id`
const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=sd.current_revision_id RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=$2::uuid RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
func storyScanTargets(s *Story, tenantID, subjectInternal *string) []any {
return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal}
}
func scanStory(row rowScanner) (Story, error) {
var s Story
var tenant, subject string
err := row.Scan(storyScanTargets(&s, &tenant, &subject)...)
return s, err
}
func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
rows, err := s.pool.Query(ctx, `SELECT ct.public_id::text,ct.sequence_no,ct.speaker,ct.final_text,ss.source_start_offset,ss.source_end_offset FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id WHERE sd.public_id=$1::uuid ORDER BY ct.sequence_no`, story.ID)
if err != nil {
return err
}
story.Sources = []StorySource{}
for rows.Next() {
var x StorySource
if err := rows.Scan(&x.TurnID, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil {
rows.Close()
return err
}
story.Sources = append(story.Sources, x)
}
rows.Close()
rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid ORDER BY fc.created_at,fc.public_id`, story.ID)
if err != nil {
return err
}
defer rows.Close()
story.Facts = []FactCandidate{}
for rows.Next() {
var x FactCandidate
if err := scanFactCandidate(rows, &x); err != nil {
return err
}
story.Facts = append(story.Facts, x)
}
return rows.Err()
}
func scanFactCandidate(row rowScanner, result *FactCandidate) error {
var source []byte
if err := row.Scan(&result.ID, &result.FactType, &result.Content, &result.StatementBasis, &result.Confidence, &result.Sensitivity, &source, &result.Status, &result.Version); err != nil {
return err
}
return json.Unmarshal(source, &result.SourceTurnIDs)
}
const memorySelectSQL = `SELECT cm.public_id::text,sp.public_id::text,mv.content,mv.source_refs,cm.status,cm.sensitivity,mv.version_no,cm.version,ua.public_id::text,mv.confirmed_at,COALESCE(mv.change_reason,''),COALESCE(mi.status,'PENDING'),COALESCE(mi.last_error,''),COALESCE(provider_mi.status,'DISABLED'),COALESCE(provider_mi.last_error,''),cm.created_at,cm.updated_at FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN memory_version mv ON mv.id=cm.current_version_id JOIN user_account ua ON ua.id=mv.confirmed_by LEFT JOIN memory_index_entry mi ON mi.memory_version_id=mv.id AND mi.index_type='pgvector' LEFT JOIN memory_index_entry provider_mi ON provider_mi.memory_version_id=mv.id AND provider_mi.index_type='provider'`
func scanMemory(row rowScanner) (CuratedMemory, error) {
var m CuratedMemory
var sourceJSON []byte
if err := row.Scan(&m.ID, &m.SubjectID, &m.Content, &sourceJSON, &m.Status, &m.Sensitivity, &m.VersionNo, &m.Version, &m.ConfirmedBy, &m.ConfirmedAt, &m.ChangeReason, &m.IndexStatus, &m.LastIndexError, &m.ProviderIndexStatus, &m.ProviderIndexError, &m.CreatedAt, &m.UpdatedAt); err != nil {
return CuratedMemory{}, err
}
if err := json.Unmarshal(sourceJSON, &m.SourceRefs); err != nil {
return CuratedMemory{}, err
}
return m, nil
}
func nullUUID(value string) any {
if value == "" {
return nil
}
return value
}