Files
story/internal/extraction/event_store.go
T

235 lines
9.6 KiB
Go
Raw Normal View History

package extraction
import (
"context"
"data-terminal-cloud/internal/outbox"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"strings"
"time"
"unicode/utf8"
)
func loadPreviousStories(ctx context.Context, q queryer, tenant, subject string) ([]PreviousStory, error) {
rows, e := q.Query(ctx, `SELECT sd.public_id::text,sd.version,sd.title,sr.content,sd.event_time
FROM story_draft sd JOIN story_revision sr ON sr.id=sd.current_revision_id
WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND sd.status NOT IN ('REJECTED','SUPERSEDED')
AND sd.sensitivity='normal' AND call_story_available(sd.id) ORDER BY sd.created_at,sd.id LIMIT 1001`, tenant, subject)
if e != nil {
return nil, e
}
defer rows.Close()
v := []PreviousStory{}
size := 0
for rows.Next() {
var s PreviousStory
if e = rows.Scan(&s.ID, &s.Version, &s.Title, &s.Content, &s.EventTime); e != nil {
return nil, e
}
size += len(s.Content)
v = append(v, s)
}
if len(v) > 1000 || size > 1500000 {
return nil, errors.New("story comparison context capacity exceeded")
}
return v, rows.Err()
}
func (p *Processor) generateEventsSafely(ctx context.Context, snapshot extractionSnapshot, date string) (GenerationResult, []PreviousStory, error) {
previous, e := loadPreviousStories(ctx, p.pool, snapshot.tenantID, snapshot.subjectID)
if e != nil {
return GenerationResult{}, nil, e
}
content := numberedSubjectContent(snapshot.turns)
// Apply the same safety policy to both current speech and existing stories
// before sending either to the provider.
for _, s := range previous {
content += "\n" + s.Content
}
decision, e := p.safety.Classify(ctx, content)
if e != nil {
return GenerationResult{}, nil, e
}
if !decision.AllowOrdinaryStory {
return sensitiveGeneration(decision), previous, nil
}
in := GenerationInput{TenantID: snapshot.tenantID, Content: numberedSubjectContent(snapshot.turns), ChatDate: date, PreviousStories: previous}
r, e := p.generator.Generate(ctx, in)
if e != nil {
return r, nil, e
}
if e = normalizeEvents(&r, in); e != nil {
return r, nil, e
}
decision, e = p.safety.Classify(ctx, generatedSafetyContent(r))
if e != nil {
return r, nil, e
}
if !decision.AllowOrdinaryStory {
return sensitiveGeneration(decision), previous, nil
}
return r, previous, nil
}
func lockStoryContext(ctx context.Context, tx pgx.Tx, snapshot extractionSnapshot, previous []PreviousStory) error {
if _, e := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,77))`, snapshot.subjectID); e != nil {
return e
}
current, e := loadPreviousStories(ctx, tx, snapshot.tenantID, snapshot.subjectID)
if e != nil {
return e
}
// Compare the whole catalog, including new stories created concurrently.
a, _ := json.Marshal(previous)
b, _ := json.Marshal(current)
if string(a) != string(b) {
return outbox.Retryable(ErrConversationContentChanged)
}
return nil
}
func selectEventSources(snapshot extractionSnapshot, ids []string) ([]turn, error) {
if len(ids) == 0 {
return nil, errors.New("event has no source")
}
byID := map[string]turn{}
for _, t := range subjectTurns(snapshot.turns) {
byID[t.publicID] = t
}
v := []turn{}
seen := map[string]bool{}
for _, id := range ids {
t, ok := byID[id]
if !ok {
return nil, errors.New("event references unknown elder turn")
}
if !seen[id] {
v = append(v, t)
seen[id] = true
}
}
return v, nil
}
// All changes are revisions of the same event. A supplement never replaces
// an earlier human-written paragraph or silently changes confirmed memories.
func storeEventStories(ctx context.Context, tx pgx.Tx, conversation, call, importID string, epoch int64, snapshot extractionSnapshot, r GenerationResult, version int64, previous []PreviousStory) error {
origin := conversation
if call != "" {
origin = call
}
if importID != "" {
origin = importID
}
old := map[string]PreviousStory{}
for _, s := range previous {
old[s.ID] = s
}
for index, event := range r.Stories {
var done bool
if e := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM story_generation_event WHERE origin_id=$1::uuid AND generation_version=$2 AND event_index=$3)`, origin, version, index).Scan(&done); e != nil {
return e
}
if done {
continue
}
sources, e := selectEventSources(snapshot, event.SourceIDs)
if e != nil {
return e
}
var storyID, revisionID, baseID string
revisionNo := int64(1)
body := event.Content
if event.MatchID != "" {
existing, ok := old[event.MatchID]
if !ok {
return errors.New("unknown matched story")
}
e = tx.QueryRow(ctx, `SELECT id::text,current_revision_id::text,(SELECT revision_no FROM story_revision WHERE id=current_revision_id)+1 FROM story_draft WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND subject_id=$3::uuid AND version=$4 AND call_story_available(id) FOR UPDATE`, existing.ID, snapshot.tenantID, snapshot.subjectID, existing.Version).Scan(&storyID, &baseID, &revisionNo)
if e != nil {
return outbox.Retryable(ErrConversationContentChanged)
}
if len(event.NewDetails) == 0 {
if _, e = tx.Exec(ctx, `INSERT INTO story_generation_event VALUES($1::uuid,$2,$3,$4::uuid) ON CONFLICT DO NOTHING`, origin, version, index, storyID); e != nil {
return e
}
continue
}
body = existing.Content + "\n\n" + strings.Join(event.NewDetails, "\n")
} else {
e = tx.QueryRow(ctx, `INSERT INTO story_draft(tenant_id,subject_id,conversation_id,call_session_id,call_privacy_epoch,import_id,title,status,confidence,sensitivity,generation_version,event_index,event_time)
VALUES($1::uuid,$2::uuid,NULLIF($3,'')::uuid,NULLIF($4,'')::uuid,$5,NULLIF($6,'')::uuid,$7,'CANDIDATE',$8,'normal',$9,$10,$11) RETURNING id::text`, snapshot.tenantID, snapshot.subjectID, conversation, call, optionalEpoch(call, epoch), importID, event.Title, r.Confidence, version, index, event.EventTime).Scan(&storyID)
if e != nil {
return fmt.Errorf("create event story: %w", e)
}
}
if utf8.RuneCountInString(body) > 50000 {
return errors.New("merged story too long")
}
e = tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,model_ref,prompt_version,base_revision_id,edit_reason)
VALUES($1::uuid,$2,$3,'model',$4,$5,NULLIF($6,'')::uuid,$7) RETURNING id::text`, storyID, revisionNo, body, r.ModelRef, r.PromptVersion, baseID, "自动整理原话;仅补充新增信息").Scan(&revisionID)
if e != nil {
return e
}
if baseID != "" {
if _, e = tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role)
SELECT $1::uuid,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, revisionID, baseID); e != nil {
return e
}
}
for _, t := range sources {
_, e = tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role)
VALUES($1::uuid,NULLIF($2,'')::uuid,NULLIF($3,'')::uuid,NULLIF($4,''),$5,NULLIF($6,'')::uuid,0,$7,'subject') ON CONFLICT DO NOTHING`, revisionID, turnForOrigin(t, conversation), call, turnForOrigin(t, call), optionalEpoch(call, t.sourceRevision), turnForOrigin(t, importID), utf8.RuneCountInString(t.text))
if e != nil {
return e
}
}
_, e = tx.Exec(ctx, `UPDATE story_draft SET current_revision_id=$2::uuid,status=CASE WHEN status IN ('CONFIRMED','IN_REVIEW') THEN 'IN_REVIEW' ELSE 'CANDIDATE' END,version=version+CASE WHEN $3<>'' THEN 1 ELSE 0 END,updated_at=now() WHERE id=$1::uuid`, storyID, revisionID, baseID)
if e != nil {
return e
}
refs := []string{}
for _, t := range sources {
refs = append(refs, t.publicID)
}
sourceJSON, _ := json.Marshal(refs)
_, e = tx.Exec(ctx, `INSERT INTO fact_candidate(tenant_id,subject_id,story_id,fact_type,content,statement_basis,time_range,confidence,sensitivity,source_turn_ids,status,generation_version)
VALUES($1::uuid,$2::uuid,$3::uuid,'life_event',$4,'subject_statement','{}',$5,'normal',$6::jsonb,'CANDIDATE',$7)`, snapshot.tenantID, snapshot.subjectID, storyID, event.FactContent, r.Confidence, sourceJSON, version)
if e != nil {
return e
}
_, e = tx.Exec(ctx, `INSERT INTO story_generation_event VALUES($1::uuid,$2,$3,$4::uuid)`, origin, version, index, storyID)
if e != nil {
return e
}
}
for index, event := range r.DailyEvents {
sources, e := selectEventSources(snapshot, event.SourceIDs)
if e != nil {
return e
}
refs := []map[string]any{}
for _, t := range sources {
refs = append(refs, map[string]any{"conversation_id": conversation, "call_session_id": call, "import_id": importID, "turn_id": t.id, "public_id": t.publicID, "source_revision": t.sourceRevision})
}
raw, _ := json.Marshal(refs)
_, e = tx.Exec(ctx, `INSERT INTO chat_daily_event(tenant_id,subject_id,conversation_id,call_session_id,import_id,generation_version,event_index,event_date,title,content,source_refs,model_ref)
VALUES($1::uuid,$2::uuid,NULLIF($3,'')::uuid,NULLIF($4,'')::uuid,NULLIF($5,'')::uuid,$6,$7,$8,$9,$10,$11::jsonb,$12) ON CONFLICT DO NOTHING`, snapshot.tenantID, snapshot.subjectID, conversation, call, importID, version, index, time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006-01-02"), event.Title, event.Content, raw, r.ModelRef)
if e != nil {
return e
}
}
return nil
}
func optionalEpoch(origin string, n int64) any {
if origin == "" {
return nil
}
return n
}
func turnForOrigin(t turn, origin string) string {
if origin == "" {
return ""
}
return t.id
}