Files
tapir/internal/domain/domain.go
T
mathiasandClaude Sonnet 5.5 ae4f9fd775 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>
2026-10-01 23:22:09 +02:00

224 lines
8.2 KiB
Go

// Package domain holds Tapir's core entities. It depends on nothing outside
// the standard library — no providers, no storage, no AI. See docs/data-model.md.
package domain
import (
"errors"
"fmt"
"time"
)
// ErrVideoNotFound is returned when a video id resolves to no video (deleted,
// private, or a typo'd paste). Defined in domain so adapters and the web layer
// share one sentinel without coupling to each other.
var ErrVideoNotFound = errors.New("video not found")
// ErrChannelUnavailable is returned by a VideoSource when a channel's upload
// playlist returns HTTP 404 — the channel was deleted or made private. The runner
// stores these so the account page can surface them to the user.
type ErrChannelUnavailable struct {
ChannelID string
ChannelTitle string
}
func (e *ErrChannelUnavailable) Error() string {
return fmt.Sprintf("channel %q (%s) unavailable: playlist not found", e.ChannelTitle, e.ChannelID)
}
// Provider identifies a video platform.
type Provider string
const (
ProviderYouTube Provider = "youtube"
ProviderVimeo Provider = "vimeo"
)
// TranscriptSource records how a transcript was obtained (or that there was none).
type TranscriptSource string
const (
SourceCaptions TranscriptSource = "captions"
SourceNone TranscriptSource = "none"
// SourceRateLimited records that the caption endpoint returned HTTP 429.
// Unlike SourceNone (a permanent absence), this is a transient "retry later":
// the IP is rate-limited, not the video caption-less. It carries no text
// (HasText is false), so the engine degrades the same as SourceNone, but the
// runner persists it distinctly to retry after a backoff window.
SourceRateLimited TranscriptSource = "rate_limited"
)
// User is the Tapir-side profile. At Stage 0 there is exactly one.
type User struct {
ID string
DisplayName string
CreatedAt time.Time
}
// Subscription is a watched channel on a connected account.
type Subscription struct {
ID string
UserID string
ConnectionID string
ChannelID string
ChannelTitle string
Active bool
}
// Video is a single video seen for a user (per-user, not globally deduped —
// see docs/data-model.md and DECISIONS.md "Rejected alternatives").
type Video struct {
ID string
UserID string
SubscriptionID string
Provider Provider
ProviderVideoID string
Title string
ChannelTitle string
URL string
PublishedAt time.Time
SeenAt time.Time
// DurationSeconds is the video length in seconds, when known (fetched by the
// ADR-023 videos.list enrichment at discovery). 0 means unknown — the store
// preserves a previously-known value rather than overwriting it with 0, and
// the onboarding burst (ADR-028) treats unknown as degrade-open (kept).
DurationSeconds int
}
// Transcript is the text of a video (or a record that none was available).
type Transcript struct {
VideoID string
UserID string
Source TranscriptSource
Language string
Content string // empty when Source == SourceNone
}
// HasText reports whether the transcript carries usable text.
func (t Transcript) HasText() bool {
return t.Source != SourceNone && t.Content != ""
}
// Summary is the produced output for a video.
type Summary struct {
ID string
UserID string
VideoID string
Summary string
Highlights []string
Takeaways []string
AIProvider string // "local" | "anthropic" | "openai" | "gemini"
AIModel string
FallbackUsed bool
CreatedAt time.Time
}
// Segment is one timed span of a transcription. Times are seconds from the start
// of the audio.
type Segment struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
// Transcription is the result of transcribing user-supplied audio (ADR-033):
// the full text, its timed segments, which backend served it, and how long the
// audio was. Endpoint is recorded because the backends differ in timestamp
// granularity — which one served a transcript is a quality fact.
type Transcription struct {
Text string
Segments []Segment
Endpoint string
AudioSeconds float64
// Reconciliation is set when two engines transcribed the same audio and a local
// model chose between their disagreements (ADR-034). Nil for a single engine.
Reconciliation *Reconciliation
}
// ReconcileStatus says what became of an attempt to merge two transcripts.
type ReconcileStatus string
const (
// ReconcileDone: both engines produced text and the disagreements were resolved.
ReconcileDone ReconcileStatus = "reconciled"
// ReconcileSingle: only one engine produced text, so there was nothing to merge.
ReconcileSingle ReconcileStatus = "single_engine"
// ReconcileSkipped: both engines produced text but the model could not be used;
// the base text stands, unreconciled.
ReconcileSkipped ReconcileStatus = "skipped"
)
// ContestedSpot is a place the engines disagreed that a reader may want to check
// against the audio: one the model changed, or one it was unsure about.
type ContestedSpot struct {
Seconds float64 `json:"seconds"` // roughly where in the recording
Base string `json:"base"` // what the base engine heard
Alt string `json:"alt"` // what the other engine heard
Used string `json:"used"` // "base" or "alt": the reading now in the text
Unsure bool `json:"unsure"` // the model could not decide; the base was kept
}
// Reconciliation records how a merged transcript came about, so the page can say so
// plainly and the user can check the uncertain spots. The counts are exact; Contested
// is bounded.
type Reconciliation struct {
Status ReconcileStatus `json:"status"`
Reason string `json:"reason,omitempty"` // why single_engine or skipped
Base string `json:"base"` // engine whose text is the base
Alt string `json:"alt"`
Reconciler string `json:"reconciler"`
Disputes int `json:"disputes"` // places the engines disagreed
AutoResolved int `json:"auto_resolved"` // trivial: compounds, filler words
Adjudicated int `json:"adjudicated"` // decided by the model
Changed int `json:"changed"` // the alternative's reading was used
Unsure int `json:"unsure"` // base kept, flagged
Errors int `json:"errors"` // model calls that failed
Contested []ContestedSpot `json:"contested,omitempty"`
}
// UploadStatus is the state of an uploaded-audio transcription job.
type UploadStatus string
const (
UploadQueued UploadStatus = "queued"
UploadTranscribing UploadStatus = "transcribing"
UploadDone UploadStatus = "done"
UploadFailed UploadStatus = "failed"
)
// UploadErrorKind is the typed reason an upload failed. It is deliberately NOT a
// provider's error string: the whisper server's "failed to process audio" has had
// at least five distinct causes, so nothing may branch on it (ADR-033).
type UploadErrorKind string
const (
// UploadErrInterrupted: the process restarted mid-job. The audio is not
// retained, so the job cannot resume; the user re-uploads.
UploadErrInterrupted UploadErrorKind = "interrupted"
// UploadErrTranscriptionFailed: every configured transcription endpoint failed.
UploadErrTranscriptionFailed UploadErrorKind = "transcription_failed"
)
// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
// never stored in, or merged with, the shared public-caption Transcript (ADR-021).
// Content and Segments are empty until Status is UploadDone.
type UploadedTranscript struct {
ID string
UserID string
Title string
Status UploadStatus
Content string
Segments []Segment
Endpoint string
AudioSeconds float64
ErrorKind UploadErrorKind
// HadVideo records that the uploaded file carried a video track. Only its audio
// was used and the video was discarded; the page says so. It is set at upload
// and never cleared (ADR-033).
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
FinishedAt time.Time // zero until the job reaches done or failed
}