feat(store): persist and read back the reconciliation

CompleteUpload writes it (SQL NULL when there was none, never the JSON text 'null':
the page claims a second opinion only if the column is non-NULL); GetUpload reads it,
List does not. Mutation-checked.

domain.go and the store are one commit: the store tests need the new field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:22:09 +02:00
co-authored by Claude Sonnet 5.5
parent e838f6bb6d
commit ae4f9fd775
2 changed files with 26 additions and 8 deletions
@@ -52,12 +52,20 @@ func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.
if err != nil { if err != nil {
return fmt.Errorf("store: complete upload: marshal segments: %w", err) return fmt.Errorf("store: complete upload: marshal segments: %w", err)
} }
// A nil Reconciliation must be SQL NULL, not the JSON text "null": the page decides
// whether to claim a second opinion by whether this column is NULL.
var rec []byte
if t.Reconciliation != nil {
if rec, err = json.Marshal(t.Reconciliation); err != nil {
return fmt.Errorf("store: complete upload: marshal reconciliation: %w", err)
}
}
return s.updateUpload(ctx, userID, id, "complete upload", return s.updateUpload(ctx, userID, id, "complete upload",
`UPDATE uploaded_transcripts `UPDATE uploaded_transcripts
SET status = 'done', content = $3, segments = $4, endpoint = $5, SET status = 'done', content = $3, segments = $4, endpoint = $5,
audio_seconds = $6, error_kind = NULL, finished_at = NOW() audio_seconds = $6, reconciliation = $7, error_kind = NULL, finished_at = NOW()
WHERE id = $1 AND user_id = $2`, WHERE id = $1 AND user_id = $2`,
t.Text, segs, t.Endpoint, t.AudioSeconds) t.Text, segs, t.Endpoint, t.AudioSeconds, rec)
} }
// FailUpload moves an upload to failed with a typed reason. // FailUpload moves an upload to failed with a typed reason.
@@ -157,19 +165,19 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
} }
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint, const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
audio_seconds, error_kind, had_video, created_at, finished_at FROM uploaded_transcripts` audio_seconds, error_kind, had_video, reconciliation, created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript // scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads). // body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error { func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var ( var (
status, errKind, endpoint, content *string status, errKind, endpoint, content *string
segments []byte segments, reconciliation []byte
seconds *float64 seconds *float64
finished *time.Time finished *time.Time
) )
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint, if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
&seconds, &errKind, &u.HadVideo, &u.CreatedAt, &finished); err != nil { &seconds, &errKind, &u.HadVideo, &reconciliation, &u.CreatedAt, &finished); err != nil {
return err return err
} }
u.Status = domain.UploadStatus(deref(status)) u.Status = domain.UploadStatus(deref(status))
@@ -184,6 +192,13 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
if !withText { if !withText {
return nil return nil
} }
if len(reconciliation) > 0 {
var r domain.Reconciliation
if err := json.Unmarshal(reconciliation, &r); err != nil {
return fmt.Errorf("unmarshal reconciliation: %w", err)
}
u.Reconciliation = &r
}
u.Content = deref(content) u.Content = deref(content)
if len(segments) > 0 { if len(segments) > 0 {
if err := json.Unmarshal(segments, &u.Segments); err != nil { if err := json.Unmarshal(segments, &u.Segments); err != nil {
+3
View File
@@ -215,6 +215,9 @@ type UploadedTranscript struct {
// was used and the video was discarded; the page says so. It is set at upload // was used and the video was discarded; the page says so. It is set at upload
// and never cleared (ADR-033). // and never cleared (ADR-033).
HadVideo bool HadVideo bool
// Reconciliation is how a merged transcript came about, or nil for a single
// engine (ADR-034). Set by Get, not by List.
Reconciliation *Reconciliation
CreatedAt time.Time CreatedAt time.Time
FinishedAt time.Time // zero until the job reaches done or failed FinishedAt time.Time // zero until the job reaches done or failed
} }