269 lines
10 KiB
Go
269 lines
10 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 the model judged: it changed it,
|
|
// kept the base's reading, or could not decide. Every judged spot is stored, not only
|
|
// the ones a reader should check, because the audio is not retained and the kept ones
|
|
// are what a later evaluation needs (ADR-035). The page lists only the changed and
|
|
// uncertain ones.
|
|
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
|
|
Before string `json:"before,omitempty"` // the few words just before the spot, to find it by
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// StepState is where one phase of an upload job stands (ADR-035).
|
|
type StepState string
|
|
|
|
const (
|
|
StepPending StepState = "pending"
|
|
StepRunning StepState = "running"
|
|
StepDone StepState = "done"
|
|
StepFailed StepState = "failed"
|
|
// StepSkipped is a step that never ran because the job finished without it, for
|
|
// example the second engine's step when the first engine's text was used alone.
|
|
StepSkipped StepState = "skipped"
|
|
)
|
|
|
|
// ProgressStep is one named phase of an upload job. Total is zero for a phase with no
|
|
// count; only a phase whose work is really countable carries one (ADR-035: no
|
|
// percentage that does not come from real counts).
|
|
type ProgressStep struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
State StepState `json:"state"`
|
|
StartedAt time.Time `json:"started_at,omitzero"`
|
|
FinishedAt time.Time `json:"finished_at,omitzero"`
|
|
Done int `json:"done,omitempty"`
|
|
Total int `json:"total,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"
|
|
// UploadErrTooLong: the audio, once converted, is longer than the limit. The limit is
|
|
// first checked against the file's header, which a hostile or damaged file can lie
|
|
// about (ADR-035 review).
|
|
UploadErrTooLong UploadErrorKind = "audio_too_long"
|
|
// UploadErrIncomplete: the audio, once converted, ends far earlier than the file
|
|
// claimed: a truncated recording that would otherwise give a partial transcript with
|
|
// no warning.
|
|
UploadErrIncomplete UploadErrorKind = "audio_incomplete"
|
|
)
|
|
|
|
// 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
|
|
// Progress is the job's phases, in order. Set by Get, not by List (ADR-035).
|
|
Progress []ProgressStep
|
|
// ProgressAt is when a phase last really changed or advanced; AliveAt is the
|
|
// heartbeat. They differ on purpose: alive says the service is still reporting,
|
|
// progress says work is moving (ADR-035). Both are zero until the job starts.
|
|
ProgressAt time.Time
|
|
AliveAt time.Time
|
|
CreatedAt time.Time
|
|
FinishedAt time.Time // zero until the job reaches done or failed
|
|
}
|