Files

166 lines
6.9 KiB
Go
Raw Permalink Normal View History

package extraction
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
)
const DirectStoryEvent = "call.story_extraction_requested"
type directStorySnapshot struct {
extractionSnapshot
revision, epoch int64
ready, processed, reviewed bool
}
func loadDirectStorySnapshot(ctx context.Context, q queryer, sid string) (directStorySnapshot, error) {
var v directStorySnapshot
err := q.QueryRow(ctx, `SELECT c.tenant_id::text,c.subject_id::text,c.call_state,c.transcript_revision,c.privacy_epoch,
c.ended_at IS NOT NULL AND c.transcript_state IN ('COMPLETE','PARTIAL'),call_story_authorized(c.id),
EXISTS(SELECT 1 FROM call_story_extraction e WHERE e.session_id=c.id AND e.transcript_revision=c.transcript_revision AND e.privacy_epoch=c.privacy_epoch),
EXISTS(SELECT 1 FROM story_draft sd WHERE sd.call_session_id=c.id AND (sd.status IN ('IN_REVIEW','CONFIRMED','REJECTED')
OR (sd.status='CANDIDATE' AND EXISTS(SELECT 1 FROM fact_candidate fc WHERE fc.story_id=sd.id AND (fc.version>1 OR fc.status<>'CANDIDATE')))))
FROM call_session c WHERE c.id=$1::uuid AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1'`, sid).Scan(
&v.tenantID, &v.subjectID, &v.status, &v.revision, &v.epoch, &v.ready, &v.storyExtractionAllowed, &v.processed, &v.reviewed)
if errors.Is(err, pgx.ErrNoRows) {
return v, nil
}
if err != nil {
return v, err
}
v.exists = true
if !v.storyExtractionAllowed || !v.ready || v.processed {
return v, nil
}
rows, err := q.Query(ctx, `SELECT u.utterance_id,u.text_content,u.interrupted,u.source_revision
FROM call_utterance u JOIN call_session c ON c.id=u.session_id WHERE u.session_id=$1::uuid AND u.speaker='USER'
AND u.text_content<>'[deleted]' AND length(trim(u.text_content))>0
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ms CROSS JOIN LATERAL jsonb_array_elements(ms.source_refs) r
WHERE ms.subject_id=c.subject_id AND r->>'session_id'=c.id::text AND (r->>'utterance_id' IS NULL OR r->>'utterance_id'=u.utterance_id))
ORDER BY u.source_at,u.utterance_id`, sid)
if err != nil {
return v, err
}
defer rows.Close()
for rows.Next() {
var item turn
if err = rows.Scan(&item.id, &item.text, &item.interrupted, &item.sourceRevision); err != nil {
return v, err
}
item.publicID, item.speaker = item.id, "subject"
v.turns = append(v.turns, item)
}
if err = rows.Err(); err != nil {
return v, err
}
v.digest = snapshotDigest(v.extractionSnapshot)
return v, nil
}
// HandleDirectStory shares the existing story generator and review tables, but
// never turns provider summaries or assistant speech into the elder's story.
func (p *Processor) HandleDirectStory(ctx context.Context, event outbox.Event) error {
var request struct {
GenerationVersion int64 `json:"generation_version"`
PrivacyEpoch int64 `json:"privacy_epoch"`
}
if json.Unmarshal(event.Payload, &request) != nil || request.GenerationVersion <= 0 || request.PrivacyEpoch < 0 {
return errors.New("invalid direct story extraction request")
}
snapshot, err := loadDirectStorySnapshot(ctx, p.pool, event.AggregateID)
if err != nil {
return err
}
if !snapshot.exists || !snapshot.storyExtractionAllowed || snapshot.processed || snapshot.revision != request.GenerationVersion || snapshot.epoch != request.PrivacyEpoch {
return nil
}
if !snapshot.ready {
return outbox.Retryable(ErrConversationContentNotReady)
}
var generated GenerationResult
var previous []PreviousStory
multi := false
if g, ok := p.generator.(interface{ SupportsEventStories() bool }); ok {
multi = g.SupportsEventStories()
}
outcome := "EMPTY"
if snapshot.reviewed && !multi {
outcome = "REVIEWED"
} else if len(snapshot.turns) > 0 {
generated, previous, err = p.generateEventsSafely(ctx, snapshot.extractionSnapshot, time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006-01-02"))
if err != nil {
return err
}
outcome = "NO_STORY"
if generated.Meaningful && generated.StoryWorthy && generated.AllowStory {
outcome = "STORY"
}
}
// The external model call has no open transaction. Lock privacy first, then
// the call, and re-read in a new statement after any lock wait.
tx, err := p.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(context.Background())
if outcome != "REVIEWED" && (generated.Stories != nil || generated.DailyEvents != nil) {
if err = lockStoryContext(ctx, tx, snapshot.extractionSnapshot, previous); err != nil {
return err
}
}
var locked string
if err = tx.QueryRow(ctx, `SELECT subject_id::text FROM memory_user_state WHERE subject_id=$1::uuid FOR UPDATE`, snapshot.subjectID).Scan(&locked); err != nil {
return err
}
if err = tx.QueryRow(ctx, `SELECT id::text FROM call_session WHERE id=$1::uuid FOR UPDATE`, event.AggregateID).Scan(&locked); err != nil {
return err
}
current, err := loadDirectStorySnapshot(ctx, tx, event.AggregateID)
if err != nil {
return err
}
if !current.exists || !current.storyExtractionAllowed || current.processed || current.revision != request.GenerationVersion || current.epoch != request.PrivacyEpoch {
return nil
}
if !current.ready {
return outbox.Retryable(ErrConversationContentNotReady)
}
if current.reviewed && !multi {
outcome = "REVIEWED"
} else if current.digest != snapshot.digest {
return outbox.Retryable(ErrConversationContentChanged)
}
if outcome != "REVIEWED" {
// Late final subtitles replace only untouched candidates. Human edits,
// confirmations and rejections are never overwritten by background work.
if _, err = tx.Exec(ctx, `UPDATE fact_candidate SET status='SUPERSEDED',version=version+1,updated_at=now() WHERE story_id IN
(SELECT id FROM story_draft WHERE call_session_id=$1::uuid AND status='CANDIDATE' AND generation_version<$2)`, event.AggregateID, current.revision); err != nil {
return err
}
if _, err = tx.Exec(ctx, `UPDATE story_draft SET status='SUPERSEDED',version=version+1,updated_at=now()
WHERE call_session_id=$1::uuid AND status='CANDIDATE' AND generation_version<$2`, event.AggregateID, current.revision); err != nil {
return err
}
if generated.Stories != nil || generated.DailyEvents != nil {
if err = storeEventStories(ctx, tx, "", event.AggregateID, "", current.epoch, current.extractionSnapshot, generated, current.revision, previous); err != nil {
return err
}
} else if outcome == "STORY" {
if err = storeStoryCandidate(ctx, tx, "", event.AggregateID, current.epoch, current.extractionSnapshot, generated, current.revision); err != nil {
return err
}
}
}
if _, err = tx.Exec(ctx, `INSERT INTO call_story_extraction(session_id,transcript_revision,privacy_epoch,outcome,model_ref,prompt_version)
VALUES($1::uuid,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, event.AggregateID, current.revision, current.epoch, outcome, generated.ModelRef, generated.PromptVersion); err != nil {
return fmt.Errorf("store direct story extraction result: %w", err)
}
return tx.Commit(ctx)
}