166 lines
6.9 KiB
Go
166 lines
6.9 KiB
Go
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)
|
||
|
|
}
|