Files

252 lines
11 KiB
Go
Raw Permalink Normal View History

package saas
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type HomeOverviewDay struct {
Date string `json:"date"`
ConversationCount int `json:"conversation_count"`
DurationMS int64 `json:"duration_ms"`
}
type HomeOverviewConversation struct {
ID string `json:"id"`
Source string `json:"source"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
DurationMS int64 `json:"duration_ms"`
Summary string `json:"summary,omitempty"`
SummaryState string `json:"summary_state"`
SummaryKind string `json:"summary_kind,omitempty"`
}
type HomeOverview struct {
SubjectID string `json:"subject_id"`
Timezone string `json:"timezone"`
WeekStart string `json:"week_start"`
WeekEnd string `json:"week_end"`
ConversationCount int `json:"conversation_count"`
TotalDurationMS int64 `json:"total_duration_ms"`
Daily []HomeOverviewDay `json:"daily"`
LatestConversation *HomeOverviewConversation `json:"latest_conversation"`
}
// These are separate session stores, not joins against turns or memories: a
// session contributes once regardless of how many utterances or memories it has.
// DIRECT has no reliable connected_at. Its existing created_at -> ended_at
// duration is retained, but a finalized USER/ASSISTANT utterance must establish
// actual interaction so failed preparations and abandoned leases do not count.
const homeConversationsSQL = `WITH eligible AS (
SELECT c.public_id AS id,c.id AS internal_id,'conversation'::text AS source,c.status,c.started_at,c.ended_at
FROM conversation c
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
AND c.status IN ('COMPLETED','FAILED','EXPIRED') AND c.started_at IS NOT NULL
AND c.ended_at>c.started_at AND c.ended_at<=$3
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=c.subject_id AND ss.scope='SOURCE_AND_DERIVED'
AND (ss.source_refs @> jsonb_build_array(jsonb_build_object('conversation_id',c.public_id::text))
OR ss.source_refs @> jsonb_build_array(jsonb_build_object('conversation_id',c.id::text))
OR EXISTS(SELECT 1 FROM story_draft sd WHERE sd.conversation_id=c.id AND sd.subject_id=c.subject_id
AND ss.source_refs @> jsonb_build_array(jsonb_build_object('story_id',sd.public_id::text)))))
UNION ALL
SELECT c.id,c.id,'call_memory',c.call_state,c.created_at,c.ended_at
FROM call_session c
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1'
AND c.call_state IN ('ENDED','FAILED','CANCELLED') AND c.ended_at>c.created_at AND c.ended_at<=$3
AND EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker IN ('USER','ASSISTANT')
AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]'))
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=c.subject_id AND ss.scope='SOURCE_AND_DERIVED'
AND ss.source_refs @> jsonb_build_array(jsonb_build_object('session_id',c.id::text)))
), latest AS (SELECT source,id FROM eligible ORDER BY started_at DESC,id DESC,source DESC LIMIT 1)
SELECT e.id::text,e.internal_id::text,e.source,e.status,e.started_at,e.ended_at
FROM eligible e WHERE (e.started_at >= $4 AND e.started_at < $5)
OR EXISTS(SELECT 1 FROM latest l WHERE l.source=e.source AND l.id=e.id)
ORDER BY e.started_at DESC,e.id DESC,e.source DESC`
// Home calendars use the product timezone even for historical profiles that
// store another timezone. This changes presentation boundaries, not the profile.
func homeCalendarWeek(now time.Time) (time.Time, time.Time, error) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, time.Time{}, err
}
local := now.In(location)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
start = start.AddDate(0, 0, -(int(start.Weekday())+6)%7)
return start, start.AddDate(0, 0, 7), nil
}
func (s *Service) SubjectHomeOverview(ctx context.Context, principal identity.Principal, subjectPublicID string) (HomeOverview, error) {
return s.subjectHomeOverviewAt(ctx, principal, subjectPublicID, time.Now())
}
func (s *Service) subjectHomeOverviewAt(ctx context.Context, principal identity.Principal, subjectPublicID string, now time.Time) (HomeOverview, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return HomeOverview{}, err
}
defer tx.Rollback(context.Background())
a, subject, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return HomeOverview{}, err
}
start, end, err := homeCalendarWeek(now)
if err != nil {
return HomeOverview{}, err
}
location := start.Location()
result := HomeOverview{SubjectID: subject.ID, Timezone: location.String(), WeekStart: start.Format("2006-01-02"), WeekEnd: end.Format("2006-01-02"), Daily: make([]HomeOverviewDay, 7)}
dayIndexes := map[string]int{}
for i := range result.Daily {
day := start.AddDate(0, 0, i).Format("2006-01-02")
result.Daily[i].Date = day
dayIndexes[day] = i
}
rows, err := tx.Query(ctx, homeConversationsSQL, a.tenantID, a.subjectID, now, start, end)
if err != nil {
return HomeOverview{}, err
}
var latestInternalID string
for rows.Next() {
var item HomeOverviewConversation
var internalID string
if err = rows.Scan(&item.ID, &internalID, &item.Source, &item.Status, &item.StartedAt, &item.EndedAt); err != nil {
rows.Close()
return HomeOverview{}, err
}
item.DurationMS = item.EndedAt.Sub(item.StartedAt).Milliseconds()
item.SummaryState = "UNAVAILABLE"
if result.LatestConversation == nil {
result.LatestConversation = &item
latestInternalID = internalID
}
// Entire duration belongs to the local start date, including midnight
// crossings. AddDate above preserves calendar weeks through DST.
if i, ok := dayIndexes[item.StartedAt.In(location).Format("2006-01-02")]; ok {
result.Daily[i].ConversationCount++
result.Daily[i].DurationMS += item.DurationMS
result.ConversationCount++
result.TotalDurationMS += item.DurationMS
}
}
err = rows.Err()
rows.Close()
if err != nil {
return HomeOverview{}, err
}
if result.LatestConversation != nil {
grant, grantErr := callmemory.ResolveAccess(ctx, tx, principal, subjectPublicID, false)
if grantErr == nil {
err = s.homeConversationSummary(ctx, tx, a.subjectID, latestInternalID, grant, result.LatestConversation)
if err != nil {
return HomeOverview{}, err
}
} else {
var denied *callmemory.Error
if !errors.As(grantErr, &denied) || denied.Status != 403 {
return HomeOverview{}, grantErr
}
}
}
return result, tx.Commit(ctx)
}
func (s *Service) homeConversationSummary(ctx context.Context, tx pgx.Tx, subject, id string, grant callmemory.Access, item *HomeOverviewConversation) error {
if item.Source == "call_memory" {
var allowed, hasUser, hasAssistant bool
var transcript, state, lastError string
err := tx.QueryRow(ctx, `SELECT c.memory_mode<>'OFF' AND managed_call_memory_authorized(c.subject_id)
AND c.privacy_epoch=COALESCE(mus.privacy_epoch,0),c.transcript_state,COALESCE(j.state,''),COALESCE(j.last_error,''),
EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='USER' AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]')),
EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='ASSISTANT' AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]'))
FROM call_session c LEFT JOIN memory_user_state mus ON mus.subject_id=c.subject_id
LEFT JOIN call_memory_sync j ON j.session_id=c.id AND j.transcript_revision=c.transcript_revision
WHERE c.id=$1::uuid AND c.subject_id=$2::uuid`, id, subject).Scan(&allowed, &transcript, &state, &lastError, &hasUser, &hasAssistant)
if err != nil {
return err
}
if !allowed {
return nil
}
// A current, active, normal memory from this exact session is a short
// memory recap, not a synthesized whole-conversation summary.
err = tx.QueryRow(ctx, `SELECT mv.content FROM managed_memory_origin o
JOIN curated_memory cm ON cm.id=o.memory_id AND cm.subject_id=o.subject_id
JOIN memory_version mv ON mv.id=cm.current_version_id AND mv.memory_id=cm.id
WHERE o.subject_id=$1::uuid AND o.session_id=$2::uuid AND cm.status='ACTIVE' AND cm.sensitivity='normal'
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=cm.subject_id AND ss.source_refs @> mv.source_refs)
ORDER BY cm.created_at DESC,cm.public_id DESC LIMIT 1`, subject, id).Scan(&item.Summary)
if err == nil {
item.SummaryState = "READY"
item.SummaryKind = "memory"
return nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
switch {
case transcript == "DRAINING" || transcript == "COLLECTING":
item.SummaryState = "PROCESSING"
case !hasUser || !hasAssistant:
item.SummaryState = "INSUFFICIENT_TRANSCRIPT"
case lastError != "":
item.SummaryState = "SYNC_FAILED"
case state == "SYNCED" || state == "SKIPPED":
item.SummaryState = "NO_SUMMARY"
default:
item.SummaryState = "PROCESSING"
}
return nil
}
// Legacy summaries have no sensitivity classification; only a reader with
// sensitive access may see them. Metadata permission alone never exposes text.
if !grant.Sensitive {
return nil
}
var allowed, hasUser bool
var jobState string
err := tx.QueryRow(ctx, `SELECT c.retention_mode<>'none'
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.consent_type='story_extraction' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())),
EXISTS(SELECT 1 FROM conversation_turn ct WHERE ct.conversation_id=c.id AND ct.speaker='subject' AND regexp_replace(ct.final_text,'[[:space:]]','','g') NOT IN ('','[deleted]')),
COALESCE((SELECT o.status FROM outbox_event o WHERE o.aggregate_id=c.id AND o.event_type='conversation.content_extraction_requested' ORDER BY o.created_at DESC,o.id DESC LIMIT 1),'')
FROM conversation c WHERE c.id=$1::uuid AND c.subject_id=$2::uuid`, id, subject).Scan(&allowed, &hasUser, &jobState)
if err != nil {
return err
}
if !allowed {
return nil
}
var state string
err = tx.QueryRow(ctx, `SELECT content,status FROM conversation_summary WHERE subject_id=$1::uuid AND conversation_id=$2::uuid AND summary_type='conversation_summary' AND status<>'SUPERSEDED' ORDER BY generation_version DESC LIMIT 1`, subject, id).Scan(&item.Summary, &state)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if err == nil && state == "READY" {
item.SummaryState = "READY"
item.SummaryKind = "conversation_summary"
return nil
}
item.Summary = ""
switch {
case !hasUser:
item.SummaryState = "INSUFFICIENT_TRANSCRIPT"
case state == "FAILED" || jobState == "dead":
item.SummaryState = "SYNC_FAILED"
case state == "PROCESSING" || jobState == "pending" || jobState == "processing":
item.SummaryState = "PROCESSING"
default:
item.SummaryState = "NO_SUMMARY"
}
return nil
}