Skip to content

Latest commit

 

History

History
167 lines (119 loc) · 12.3 KB

File metadata and controls

167 lines (119 loc) · 12.3 KB

Architecture

Shape

apps/extension       MV3 — TypeScript, React 18, Vite + @crxjs/vite-plugin, Tailwind, shadcn/ui
apps/api             OPTIONAL FastAPI proxy — Python >=3.12, uv, ruff, mypy --strict
packages/core        Analysis pipeline. No DOM, no Node, no fetch. Pure TypeScript.
packages/shared      analysis.schema.json → Zod + Pydantic codegen, drift-checked in CI
packages/eval        Evaluation harness
packages/lexicons    Vendored lexicons, one PROVENANCE.md each, CI fails without one
packages/corpus      Reference-corpus derivation scripts and the derived percentile tables
docs/                This directory

pnpm workspaces. packages/core is the only place analysis logic lives; the extension is a host for it and apps/api is a second host.

Modes

Mode A — Local/BYOK. Default, no backend, what ships in the store. The extension runs the pipeline. Framing and C2PA verification are on-device. LLM calls go directly from the user's browser to the provider they chose, with their key, or to a local Ollama endpoint. Retrieval goes to whichever retrievers they configured.

Mode B — Self-hosted proxy. Optional. apps/api adds shared caching, key custody, retrieval fan-out, rate limiting, the stance model server-side, and c2pa-python for formats the JS build does not cover. Never required, never a default, never a hosted service we run.

packages/core does not know which mode it is in. It receives an AnalysisEnvironment:

interface AnalysisEnvironment {
  llm: LLMProvider | null; // null is a supported state, not an error
  retrievers: Retriever[]; // may be empty
  stance: StanceClassifier | null; // null → every Source.stance is 'unrated'
  fetcher: Fetcher; // robots-aware; the only way core touches the network
  cache: Cache;
  clock: Clock; // injected so tests are deterministic and temporal rules are testable
  budget: Budget; // token and wall-clock ceilings, enforced in core
}

Every dependency is nullable or empty-able, because every one of them is unavailable in some real user's real situation. Core's job when a dependency is missing is to produce a correct partial Analysis with the right Degradation, not to throw.

Everything in core is unit-testable against fakes with no network. That is the acceptance test for the boundary: if a test needs a network fake that models HTTP, the logic is in the wrong package.

Extension processes

MV3 gives us four execution contexts, and which code runs where is forced by platform constraints rather than taste.

Context Runs Why there
Content script Text extraction, hidden-text detection, the overlay layer, anchor resolution Only context with DOM access.
Service worker Orchestration, retrieval, fetching, LLM calls, cache Survives side-panel close; cross-origin fetch bypasses CORS given host_permissions.
Side panel page React UI and all ONNX inference WebGPU is not available in an MV3 service worker. Inference must live in a document context, and the side panel is the one we already have open.
Offscreen document Inference when the side panel is closed (auto-analysis) Only used if per-domain auto-analysis is enabled. Not in the Phase 1 build.

The stance model running in the side panel page is a Phase 1 decision, not a Phase 3 optimisation: a cross-encoder over ~40 passage pairs is 4–16 s on WASM single-threaded, and WebGPU is what makes Panel B's budget reachable. WASM remains the fallback and its slower path is what the p95 budget is measured against.

Models are bundled, not downloaded

Chrome Web Store rejects MV3 extensions that fetch ONNX or WASM at runtime as remotely hosted code. Weights and the onnxruntime-web binaries ship inside the extension package, env.localModelPath points at them, and they are listed in web_accessible_resources. See ADR-002.

Consequences: the store build carries the small tier only (embeddings + tokenisers + ort-wasm), the NLI tier is a self-hosted-build and Mode B capability, and §10's bundle budget splits into JS/CSS ≤ 2 MB gzipped and total package ≤ 50 MB.

Overlay, never DOM mutation

Highlights render in a separate overlay layer positioned from Range.getClientRects(), not by wrapping page nodes. Wrapping breaks React reconciliation and contenteditable, and on a re-render our spans would be silently destroyed or, worse, duplicated.

The overlay re-projects on scroll, resize, and a MutationObserver on the target subtree. When re-anchoring fails after a mutation, the highlight is removed and the finding stays in the panel marked as no longer locatable. A finding that points at the wrong text is worse than one that points at nothing.

Anchoring and normalisation

Anchors follow the W3C Web Annotation Data Model. quote (TextQuoteSelector) is mandatory and is the only durable selector; position and xpath are optimisations checked first and discarded when they disagree with quote.

One normalisation function is used for anchoring, quote verification, and content hashing, and it is the same function in every context:

NFKC → fold quote/apostrophe/dash variants → strip zero-width and soft hyphens →
collapse whitespace runs to a single space → trim

It returns the normalised string and an index map back to the raw offsets, so a match found in normalised space can always be reported in raw coordinates. Quote verification is exact substring matching in normalised space (ADR-008). Without the index map, Source.quoteAnchor could not point into the original document and the reader could not check us.

Data flow

selection / hotkey / context menu
        │
content script: extract text, detect hidden text, compute contentHash, build Target
        │
service worker: sanitise (THREAT-MODEL §Injection) → dispatch three panels concurrently
        │
        ├── Framing  ── side panel: lexical pass renders immediately, model pass replaces it
        ├── Provenance ── c2pa-web / exifr / pHash local; RDAP, Wikidata, Wayback over network
        └── Evidence ── segment → checkworthiness → decontextualise → decompose →
                        query plan (claim / negation / neutral / primary-site) → fan out →
                        fetch → extract → dedup → syndication → rerank → stance →
                        quote-verify → temporal → state rules

The three panels are independently loadable and independently failable. There is no join point where they are combined, and no code path that reads two of them at once — that absence is what makes an aggregate score impossible to add by accident rather than merely forbidden.

Results stream. Framing renders from the lexical path within its 200 ms budget before any model is warm. Evidence emits a claim card as soon as its questions are decomposed, then streams sources into it.

Budgets

Restated from the brief per ADR-011, because two of the originals were not reachable:

Budget Target
Panel C, 1,000 words, warm models p95 < 1.2 s
Panel C, no-model fallback path p95 < 200 ms
Bundled on-device model set (store build) ≤ 40 MB
Bundled model set (self-hosted build, with NLI) ≤ 120 MB
Extension JS + CSS ≤ 2 MB gzipped
Extension total store package ≤ 50 MB
Panel B claim card with decomposed questions visible < 4 s
Panel B first verified source < 12 s
Panel B complete, 5 claims p95 < 25 s
LLM tokens per analysis < 15 k in / < 2 k out
Memory, idle side panel < 150 MB

Enforced by automated checks; CI fails on regression. BYOK mode shows a cost estimate before running and a running session total.

Model set

Sizes measured from the Hugging Face API on 2026-08-01, not assumed.

Job Choice Size Tier
Embeddings, dedup, clustering Xenova/all-MiniLM-L6-v2 int8 23.0 MB store
POS / dependency wink-nlp + wink-eng-lite-web-model ~3 MB store
Sentiment VADER (pure JS, permissive) 0 store
NER, entity treatment wink-nlp rules + gazetteer 0 store
NLI / stance Xenova/nli-deberta-v3-xsmall quantized 87.2 MB self-hosted / Mode B

The brief's nli-deberta-v3-small is 161 MB int8 and does not fit any budget. bert-base-NER at 108.5 MB int8 buys accurate NER at the cost of the entire stance tier; wink-nlp's rule-based NER is the trade we take. Both substitutions are recorded in DEVIATIONS.md.

Every framing detector has a lexical implementation that runs before models load and is labelled implementation: "lexical" in the output, because it is coarser and the reader should know which one they are looking at.

Provider abstractions

interface LLMProvider {
  name: string;
  complete(req: StructuredRequest): Promise<StructuredResponse>; // JSON-schema-constrained only
  estimateCost(req: StructuredRequest): CostEstimate;
}
interface Retriever {
  name: string;
  search(q: Query): Promise<SearchHit[]>;
  supportsDateFilter: boolean;
  supportsSiteFilter: boolean;
}

complete has no freeform variant. Every LLM call in this project is schema-constrained, which is both an injection control and the reason the pipeline never parses prose.

Implementations: AnthropicProvider, OpenAIProvider, OllamaProvider; BraveRetriever, ExaRetriever, WikipediaRetriever, GoogleFactCheckRetriever, ArchiveOrgRetriever. Tavily is deliberately absent from the defaults — it returns pre-digested, model-summarised content, which makes quote verification vacuous (ADR-005). An ESLint rule forbids provider-name string comparisons above the interface boundary.

CORS is not a constraint: MV3 extension pages and the service worker bypass it with host_permissions. Retriever choice is driven by cost, terms of service for client-side keys, and whether the retriever hands back raw documents.

Platform seam

All UI is a plain React app with zero chrome.* calls. Every extension API call lives in apps/extension/src/platform/. Firefox is deferred to Phase 7 (no sidePanel, different sidebar_action lifecycle, no offscreen documents, event pages rather than service workers); porting is expected to mean rewriting that one directory. This is a seam, not an abstraction layer — there is one implementation and no registry.

Testing

Unit tests alongside every pure function in core. Fixture-driven integration tests for each pipeline stage, with fixtures checked in as real fetched documents rather than synthesised ones. No mocking of the code under test; the injected environment is the only seam tests use.

Three suites gate CI independently of the unit tests: the injection canary corpus (packages/eval/canaries/), the evidence-state rule tables from EVIDENCE-STATES.md §5, and the quote-verification suite, whose first test was written before its implementation.