Files
tapir/internal/usecase/engine.go
T
mathiasandClaude Opus 4.8 b4aa096ac3
CI / Lint / Test / Vet (push) Successful in 3s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
feat(usecase): implement ProcessNewVideo core path
Resolve transcript -> summarize -> deliver, or skip when no usable
transcript. Sinks fail independently: a failing sink does not abort the
others and successful deliveries are kept; per-sink errors are returned
joined. Makes the two scaffolded acceptance scenarios GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:24:18 +02:00

73 lines
2.5 KiB
Go

// Package usecase holds Tapir's application core: the engine that detects new
// videos, resolves transcripts, summarizes, and delivers to sinks. It depends
// only on internal/ports and internal/domain.
//
// This is a SCAFFOLD. Methods return ErrNotImplemented so the acceptance tests
// in test/acceptance fail RED. Implementing them to make those tests pass is
// the first build task (see the build issue). Behaviour is specified in
// docs/use-cases/*.feature.
package usecase
import (
"context"
"errors"
"fmt"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
)
// ErrNotImplemented marks scaffold methods awaiting implementation.
var ErrNotImplemented = errors.New("not implemented")
// Engine is the provider- and sink-agnostic core.
type Engine struct {
Source ports.VideoSource
AI ports.Summarizer
Sinks []ports.Sink
}
// NewEngine wires the engine from its ports.
func NewEngine(src ports.VideoSource, ai ports.Summarizer, sinks ...ports.Sink) *Engine {
return &Engine{Source: src, AI: ai, Sinks: sinks}
}
// ProcessResult reports what happened for one video.
type ProcessResult struct {
Video domain.Video
Skipped bool
Reason string // set when Skipped (e.g. "no transcript")
Summary *domain.Summary // nil when Skipped
}
// ProcessNewVideo runs the core use case for a single video:
// resolve transcript -> (summarize -> deliver) | skip.
// See docs/use-cases/summarize_new_video.feature.
func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessResult, error) {
t, err := e.Source.FetchTranscript(ctx, v)
if err != nil {
return ProcessResult{Video: v}, fmt.Errorf("fetch transcript: %w", err)
}
if !t.HasText() {
// No usable transcript: record the skip, produce no summary, deliver nothing
// (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript"}, nil
}
sum, err := e.AI.Summarize(ctx, v, t)
if err != nil {
return ProcessResult{Video: v}, fmt.Errorf("summarize: %w", err)
}
// Sinks fail independently: a failing sink must not abort the others, and
// successful deliveries are not dropped. Collect every error, return them joined.
var errs []error
for _, sink := range e.Sinks {
if err := sink.Deliver(ctx, sum); err != nil {
errs = append(errs, fmt.Errorf("deliver to %s: %w", sink.Name(), err))
}
}
return ProcessResult{Video: v, Summary: &sum}, errors.Join(errs...)
}