feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user