This commit is contained in:
@@ -23,6 +23,7 @@ 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 {
|
||||
@@ -74,6 +75,15 @@ type FactCandidate struct {
|
||||
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"`
|
||||
@@ -95,29 +105,40 @@ type Story struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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
|
||||
Reason string
|
||||
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 {
|
||||
@@ -229,8 +250,92 @@ func (s *Service) GetStory(ctx context.Context, principal identity.Principal, st
|
||||
return story, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal, storyPublicID, content, reason, requestID string, expectedVersion int64) (Story, error) {
|
||||
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 {
|
||||
@@ -246,7 +351,7 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
|
||||
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 {
|
||||
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 {
|
||||
@@ -257,6 +362,9 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
|
||||
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 {
|
||||
@@ -268,8 +376,11 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) RejectStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64) (Story, error) {
|
||||
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 {
|
||||
@@ -289,6 +400,9 @@ func (s *Service) RejectStory(ctx context.Context, principal identity.Principal,
|
||||
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 {
|
||||
@@ -349,14 +463,28 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
|
||||
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
|
||||
}
|
||||
@@ -408,6 +536,9 @@ func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, m
|
||||
}
|
||||
|
||||
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
|
||||
@@ -469,9 +600,9 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -487,16 +618,48 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
|
||||
} 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}})
|
||||
})
|
||||
@@ -542,6 +705,12 @@ func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -558,6 +727,27 @@ type contentAccess struct {
|
||||
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 {
|
||||
@@ -641,11 +831,7 @@ func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
|
||||
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 {
|
||||
if err := scanFactCandidate(rows, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
story.Facts = append(story.Facts, x)
|
||||
@@ -653,12 +839,20 @@ func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
|
||||
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 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.CreatedAt, &m.UpdatedAt); err != nil {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user