Files
tapir/docs/architecture/architecture.md
T
mathias af38999ee2 docs: add C4 architecture + sequence diagrams (Mermaid)
Context and container diagrams, sequence diagrams for the core summarize-new-video
use case and the local-first/BYO AI routing fallback, and the Clean Architecture
layering. Ports & adapters keep the engine provider- and sink-agnostic, making
standalone-vs-homelab a wiring choice (ADR-003), not two codebases.
2026-06-02 10:31:37 +00:00

6.5 KiB

Tapir — Architecture (C4 + sequences)

Diagrams describe current assumptions, not a frozen design. They are diffable Mermaid so they live in version control and render in Gitea. When a decision here changes, record it in DECISIONS.md and update the relevant diagram in the same commit.

Altitude: C4 Level 1 (Context) and Level 2 (Container), plus sequence diagrams for the two behaviours that carry the most design risk. Clean Architecture layering is described after the diagrams.


C4 L1 — System Context

Who and what Tapir talks to. (Brain and external AI are dashed — both optional.)

graph TB
    user["User<br/>(maintainer; later, trusted users)"]
    tapir["Tapir<br/>watches subscriptions,<br/>summarizes new videos"]
    yt["YouTube<br/>(Data API + captions)"]
    vimeo["Vimeo<br/>(API + text tracks)"]
    local["Local AI stack<br/>(LiteLLM / piguard alias)"]
    byo["User's BYO AI<br/>(Claude / OpenAI / Gemini)"]
    brain["brain<br/>(brain-mcp)"]

    user -->|connects accounts,<br/>reads summaries| tapir
    tapir -->|subscriptions,<br/>new-video events,<br/>captions| yt
    tapir -->|subscriptions,<br/>text tracks| vimeo
    tapir -->|summarize<br/>PRIMARY| local
    tapir -.->|summarize<br/>FALLBACK, opt-in| byo
    tapir -.->|optional sink:<br/>brain_ingest| brain

    classDef opt stroke-dasharray: 5 5;
    class byo,brain opt;

C4 L2 — Containers

Inside Tapir. The engine is provider- and sink-agnostic; everything external is an adapter behind an interface (Clean Architecture ports & adapters).

graph TB
    subgraph tapir["Tapir (Go)"]
        http["HTTP server<br/>OAuth callbacks +<br/>user-facing API"]
        watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"]
        engine["Summarization engine<br/>(use-case core)"]
        resolver["Transcript resolver<br/>(captions-first)"]
        router["AI router<br/>(llm.Router:<br/>Primary -> Fallback)"]
        store[("User store<br/>(Postgres,<br/>per-user isolated)")]

        subgraph sinks["Sink adapters (Sink interface)"]
            sink_store["Store sink<br/>(primary)"]
            sink_brain["Brain sink<br/>(HTTP brain-mcp)"]
        end

        subgraph providers["Provider adapters (VideoSource interface)"]
            p_yt["YouTube adapter"]
            p_vimeo["Vimeo adapter"]
        end
    end

    http --> store
    watcher --> p_yt
    watcher --> p_vimeo
    watcher -->|NewVideo event| engine
    engine --> resolver
    resolver --> p_yt
    resolver --> p_vimeo
    engine --> router
    engine --> sink_store
    engine -.-> sink_brain
    sink_store --> store

    classDef opt stroke-dasharray: 5 5;
    class sink_brain opt;

Interfaces that keep the engine pure (the ports):

  • VideoSource — list subscriptions, detect new videos, fetch transcript. Implemented by YouTube and Vimeo adapters.
  • Summarizer — turn a transcript + context into highlights/takeaways. Backed by the llm.Router (Primary local, Fallback BYO).
  • Sink — deliver a summary. Implemented by the store sink (primary) and brain sink (optional). New sinks add an implementation, nothing else.
  • SecretStore — fetch/store per-user OAuth tokens and BYO keys. Backed by ESO/1Password.

The engine depends only on these interfaces, never on YouTube, brain, or a concrete model. This is what makes "standalone vs homelab" a configuration of which adapters are wired, not two codebases (ADR-003).


Sequence — core use case: new video summarized

sequenceDiagram
    participant Src as VideoSource<br/>(YouTube/Vimeo)
    participant W as Watcher
    participant E as Engine
    participant R as Transcript resolver
    participant AI as AI router
    participant S as Sink(s)

    Src->>W: new upload (WebSub push / poll)
    W->>E: NewVideo{user, channel, videoID}
    E->>R: resolve transcript(videoID)
    R->>Src: fetch captions
    alt captions available
        Src-->>R: captions text
        R-->>E: Transcript{source: captions}
        E->>AI: summarize(transcript, userContext)
        AI-->>E: Summary{highlights, takeaways}
        E->>S: deliver(summary)
        S-->>E: ok
    else no transcript
        Src-->>R: none
        R-->>E: NoTranscript
        E->>S: deliver(skipped: no transcript)
    end

Sequence — AI routing (local-first, BYO fallback)

sequenceDiagram
    participant E as Engine
    participant R as llm.Router
    participant P as Primary<br/>(local: LiteLLM/piguard)
    participant F as Fallback<br/>(user BYO key)

    E->>R: summarize(transcript)
    R->>P: complete(prompt)
    alt local succeeds
        P-->>R: summary
        R-->>E: summary (fallback_used = false)
    else local fails (error/timeout/unavailable)
        P-->>R: error
        alt user has BYO configured
            R->>F: complete(prompt)
            F-->>R: summary
            R-->>E: summary (fallback_used = true)
        else no BYO
            R-->>E: error (queued for retry)
        end
    end

"Reliably" (VISION / the BYO trigger) is operationalized as: Primary returned without error within timeout. Richer quality scoring can layer on later without changing the interface. fallback_used is recorded per summary so "is the local stack good enough?" becomes a query, not a guess.


Clean Architecture layering

graph LR
    subgraph domain["Domain (entities)"]
        d["User, Subscription, Video,<br/>Transcript, Summary"]
    end
    subgraph usecase["Use cases (engine)"]
        u["SummarizeNewVideo,<br/>ConnectAccount,<br/>RouteAI"]
    end
    subgraph ports["Ports (interfaces)"]
        po["VideoSource, Summarizer,<br/>Sink, SecretStore"]
    end
    subgraph adapters["Adapters (infra)"]
        a["YouTube, Vimeo, llm.Router,<br/>Store sink, Brain sink,<br/>ESO secret store, HTTP, Postgres"]
    end

    a --> po
    po --> u
    u --> d

Dependencies point inward only. Domain knows nothing of YouTube, brain, Postgres, or any model. Adapters are swappable; tests target the use-case core through fake adapters (see the Gherkin features in docs/use-cases/).


Out of scope in these diagrams (deferred per ADRs)

  • Audio-download + speech-to-text resolver (ADR-007) — would be an additional VideoSource fallback path, drawn when built.
  • Multi-tenant isolation primitives (per-tenant Postgres role, NetworkPolicy, tenant label) — activate at Stage 1 (ADR-002); single-user Stage 0 doesn't exercise them.
  • Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).