675 lines
34 KiB
Go
675 lines
34 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")
|
||
|
|
)
|
||
|
|
|
||
|
|
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 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"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type UpdateMemoryInput struct {
|
||
|
|
Content *string
|
||
|
|
Status *string
|
||
|
|
Reason string
|
||
|
|
}
|
||
|
|
|
||
|
|
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) ReviseStory(ctx context.Context, principal identity.Principal, storyPublicID, content, reason, requestID string, expectedVersion int64) (Story, error) {
|
||
|
|
var result Story
|
||
|
|
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(¤tRevisionID); 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) SELECT id,$2,$3,'user',$4::uuid,$5,$6::uuid FROM story_draft WHERE public_id=$1::uuid RETURNING id::text`, storyPublicID, current.RevisionNo+1, content, access.userID, reason, currentRevisionID).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
|
||
|
|
}
|
||
|
|
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) (Story, error) {
|
||
|
|
var result Story
|
||
|
|
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
|
||
|
|
}
|
||
|
|
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 _, 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
|
||
|
|
}
|
||
|
|
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 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) {
|
||
|
|
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, ¤tVersionID,
|
||
|
|
¤tVersionNo, ¤tContent, &sourceRefs, ¤tStatus, &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)
|
||
|
|
VALUES($1::uuid,$2,$3,$4::jsonb,$5::uuid,$6,$7) RETURNING id::text`,
|
||
|
|
internalID, currentVersionNo+1, targetContent, sourceRefs, userID, input.Reason, hex.EncodeToString(digest[:])).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 _, 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
|
||
|
|
}
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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 _, 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 (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
|
||
|
|
var source []byte
|
||
|
|
if err := rows.Scan(&x.ID, &x.FactType, &x.Content, &x.StatementBasis, &x.Confidence, &x.Sensitivity, &source, &x.Status, &x.Version); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(source, &x.SourceTurnIDs); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
story.Facts = append(story.Facts, x)
|
||
|
|
}
|
||
|
|
return rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
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,''),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'`
|
||
|
|
|
||
|
|
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.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
|
||
|
|
}
|