From bb36bb304128433959ddaca361cc2ca19c999a7e Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 31 Jul 2026 05:08:10 -0700 Subject: [PATCH] feat(source): pdf and audio sources with page/timestamp receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a spec, a paper, a recorded call could not become citable material. a receipt is the byte span [byte_start, byte_end) into a source's stored bytes, and the bytes of a pdf or an mp3 do not spell the sentence anyone wants to quote — so that knowledge either stayed out of the kb or entered as an unattributable paraphrase. the fix keeps the receipt primitive untouched. `vouch source add spec.pdf` (or call.mp3) extracts the text layer / transcript and stores *that* as the content-addressed artifact, so byte-offset receipts keep verifying by `==` with no new code path — ingest, the receipt gate, kb.source_verify and receipt coverage all work on it unchanged. what is new is a coordinate map on Source.metadata recording which byte range came from which page or which point in the recording, so a verified receipt also resolves to `p7` or `t=00:14:23` in the original. `vouch source locate ` prints it; --raw registers the binary untouched. no new hard dependency, as the issue requires. pypdf is the optional [pdf] extra imported lazily, and transcription is a configured command (sources.transcribe_cmd) following the compile.llm_cmd pattern, so vouch never bundles a speech model. a scanned pdf with no text layer raises rather than registering an empty source — ocr is explicitly out of scope, and knowledge that silently became empty is worse than knowledge that refused to enter. the original's sha256 is recorded, so verify.py re-checks the pdf or the recording for drift. extracting to text by hand severs exactly that link, which is what makes doing it inside vouch worth the module. also carries the capture.py coerce_numeric fix already on the queue: a string min_observations raises ValueError out of capture.load_config on the current test branch and reddens the suite for every pr opened against it. included so this pr's ci is green on its own merits; it is a two-line overlap that resolves cleanly if the other lands first. closes #613 Co-authored-by: Cursor --- CHANGELOG.md | 17 ++ docs/object-model.md | 38 ++++ pyproject.toml | 13 ++ src/vouch/capture.py | 12 +- src/vouch/cli.py | 75 +++++- src/vouch/media.py | 456 +++++++++++++++++++++++++++++++++++++ src/vouch/receipts.py | 15 +- src/vouch/verify.py | 12 +- tests/test_media.py | 515 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1144 insertions(+), 9 deletions(-) create mode 100644 src/vouch/media.py create mode 100644 tests/test_media.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed8832f..bed87bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- **pdf and audio sources — page and timestamp receipts** (#613): a spec, a + paper, a recorded call could not become citable material, because a receipt is + a byte span into a source's stored bytes and the bytes of a pdf or an mp3 do + not spell the sentence anyone wants to quote. `vouch source add spec.pdf` (or + `call.mp3`) now extracts the text layer / transcript, stores *that* as the + content-addressed artifact, and records a **coordinate map** alongside it, so + every existing path — ingest, the receipt gate, `kb.source_verify`, receipt + coverage — works on it unchanged while a verified receipt also resolves to + `p7` or `t=00:14:23` in the original. `vouch source locate ` prints + that coordinate; `--raw` registers the binary untouched. **No new hard + dependency**: pypdf is the optional `[pdf]` extra, imported lazily, and + transcription is a configured command (`sources.transcribe_cmd`, the + `compile.llm_cmd` pattern) so vouch never bundles a speech model. A scanned pdf + with no text layer fails loudly rather than registering an empty source — ocr + is out of scope. The original's sha256 is recorded, so `vouch source verify` + re-checks the pdf or the recording for drift instead of losing the link back to + it the way extracting by hand does. - **correction capture — the pushback becomes a proposal** (#430): the adapter captured tool *outcomes* passively but never the single highest-signal event in a session, the user correcting the agent ("no, we deploy from `main` not diff --git a/docs/object-model.md b/docs/object-model.md index 2dc0cb99..609c11b0 100644 --- a/docs/object-model.md +++ b/docs/object-model.md @@ -54,6 +54,44 @@ locally. 20-25 of meeting-notes.md" or "0:14:23 in the recording". Claims can cite either Sources or Evidence. +### PDFs and audio + +A pdf or a recording cannot be quoted directly — a receipt is a byte +range into the stored bytes, and the bytes of a pdf or an mp3 don't +spell the sentence you want to cite. `vouch source add spec.pdf` (or +`call.mp3`) extracts the text first and stores *that* as the source, so +citations work exactly as they do for a text file. Alongside it vouch +keeps a **coordinate map**: which byte range came from which page, or +from which point in the recording. + +The upshot is a citation that verifies mechanically *and* points +somewhere a human can check: + +```console +$ vouch source add postmortem.pdf +9f2c… # id of the extracted text +$ vouch source locate 9f2c… "rollback took eleven minutes" +p7@b1200-1340 # page 7, bytes 1200-1340 +``` + +Two deliberate limits. Extraction needs the optional `[pdf]` extra +(`pip install 'vouch-kb[pdf]'`), and a scanned pdf with **no text +layer** is refused rather than silently registered empty — vouch does +not OCR. Audio needs no extra: transcription is a command you configure, +so vouch never bundles a speech model. + +```yaml +# .vouch/config.yaml +sources: + transcribe_cmd: "whisper --output_format vtt --output_dir - {path}" +``` + +The command must emit WebVTT or SubRip; `{path}` is replaced with the +audio file's path. vouch records the original file's sha256 too, so +`vouch source verify` still notices if the pdf or the recording changes +underneath a claim that cites it. Pass `--raw` to register the bytes +untouched instead. + ## Claims are atomic A **Claim** is the smallest statement worth citing or contradicting. diff --git a/pyproject.toml b/pyproject.toml index 623a0e23..9700c242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,12 @@ embeddings = [ "sentence-transformers>=3,<4", "numpy>=1.26,<2", ] +# pdf text extraction for `vouch source add foo.pdf`. optional on purpose: +# nothing imports pypdf unless a caller registers a pdf, and audio needs no +# extra at all — transcription is a configured command, not a bundled model. +pdf = [ + "pypdf>=4,<7", +] web = [ "fastapi>=0.115,<1", "jinja2>=3,<4", @@ -168,6 +174,13 @@ ignore_missing_imports = true module = ["fastembed", "fastembed.*"] ignore_missing_imports = true +# pypdf lives behind the [pdf] extra; the base CI install only has [dev,web]. +# media.extract_pdf_pages imports it lazily and raises a MediaError naming the +# extra when it is absent, so the runtime story stays clean without it. +[[tool.mypy.overrides]] +module = ["pypdf", "pypdf.*"] +ignore_missing_imports = true + # fastapi + jinja2 + uvicorn live behind the [web] extra; the base CI install # only has [dev], so mypy can't resolve them when scanning src/vouch/web/. # The web module guards its own imports with a clean ImportError message so diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 9aeb98b8..ec99b654 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -75,9 +75,15 @@ def load_config(store: KBStore) -> CaptureConfig: return CaptureConfig( enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED), realtime=coerce_bool(raw.get("realtime", DEFAULT_REALTIME), DEFAULT_REALTIME), - min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)), - dedup_window_seconds=float( - raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS) + min_observations=coerce_numeric( + raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS), + DEFAULT_MIN_OBSERVATIONS, + int, + ), + dedup_window_seconds=coerce_numeric( + raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS), + DEFAULT_DEDUP_WINDOW_SECONDS, + float, ), answer_mode=answer_mode, ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 9dba52bd..21d2d36c 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -42,6 +42,7 @@ from . import inbox as inbox_mod from . import install_adapter as install_mod from . import lifecycle as life +from . import media as media_mod from . import metrics as metrics_mod from . import migrations as migrations_mod from . import notify as notify_mod @@ -49,6 +50,7 @@ from . import pr_cache as prc_mod from . import provenance as prov_mod from . import recall as recall_mod +from . import receipts as receipts_mod from . import sessions as sess_mod from . import skills as skills_mod from . import stats as stats_mod @@ -2406,9 +2408,51 @@ def source() -> None: @click.option("--title", default=None) @click.option("--url", default=None) @click.option("--type", "source_type", default="file", show_default=True) -def source_add(path: str, title: str | None, url: str | None, source_type: str) -> None: - """Register a file as a Source; prints its sha256 id.""" +@click.option( + "--raw", + is_flag=True, + help="Register the bytes as-is, skipping pdf/audio text extraction.", +) +@click.option( + "--transcribe-cmd", + default=None, + help="Override sources.transcribe_cmd for this audio file.", +) +def source_add( + path: str, + title: str | None, + url: str | None, + source_type: str, + raw: bool, + transcribe_cmd: str | None, +) -> None: + """Register a file as a Source; prints its sha256 id. + + A pdf or an audio file is extracted to text first, so the quotes a claim + cites are actually present in the stored bytes and earn a receipt. The + extracted text carries a page/timestamp map back to the original — pass + --raw to register the binary untouched instead. + """ store = _load_store() + kind = None if raw else media_mod.media_kind(Path(path)) + if kind is not None: + with _cli_errors(): + src = media_mod.register_media_source( + store, + Path(path), + kind=kind, + title=title, + transcribe_cmd=transcribe_cmd, + ) + audit_mod.log_event( + store.kb_dir, + event="source.add", + actor=_whoami(), + object_ids=[src.id], + data={"media": kind.value}, + ) + click.echo(src.id) + return data = Path(path).read_bytes() with _cli_errors(): src = store.put_source( @@ -2468,6 +2512,33 @@ def source_fetch( click.echo(src.id) +@source.command("locate") +@click.argument("source_id") +@click.argument("quote") +def source_locate(source_id: str, quote: str) -> None: + """Print where QUOTE sits in SOURCE_ID — byte span plus page/timestamp. + + The receipt made legible: for a pdf or an audio source this answers "which + page" or "which point in the recording", which is the difference between a + citation a reviewer can check against the original and one they cannot. + """ + store = _load_store() + with _cli_errors(): + src = store.get_source(source_id) + content = store.read_source_content(source_id) + span = receipts_mod.locate_span(content, quote) + if span is None: + click.echo("quote not found verbatim in source", err=True) + sys.exit(1) + start, end = span + coordinates = media_mod.source_coordinates(src) + click.echo( + media_mod.locator_for_span(coordinates, start, end) + if coordinates + else f"b{start}-{end}" + ) + + @source.command("verify") @click.option("--fail-on-issue", is_flag=True) def source_verify(fail_on_issue: bool) -> None: diff --git a/src/vouch/media.py b/src/vouch/media.py new file mode 100644 index 00000000..d384c279 --- /dev/null +++ b/src/vouch/media.py @@ -0,0 +1,456 @@ +"""PDF and audio sources — page and timestamp receipts. + +A lot of knowledge worth keeping arrives as a pdf (a spec, a paper, a contract) +or as audio (a recorded call, a voice note). Neither can be cited directly: a +receipt is the byte span ``[byte_start, byte_end)`` into a source's stored bytes +(see :mod:`vouch.receipts`), and the bytes of a pdf or an mp3 do not spell the +sentence anyone wants to quote. + +The shape here keeps the receipt primitive untouched. The *extracted text* is +what gets stored and content-addressed, so byte-offset receipts keep verifying +by ``==`` with no new code path. Alongside it, a **coordinate map** records +which byte range came from which page or which point in the recording, so a +verified receipt also resolves to a real location in the original file — +"page 7" or "t=00:14:23" rather than "somewhere in the derived text". + +Two constraints from the issue this implements (#613) shape the module: + +* **No new hard dependency.** ``pypdf`` is an optional extra imported lazily, + and transcription is a *configured command* (``sources.transcribe_cmd``), the + same deployment-config pattern as ``compile.llm_cmd``. Neither is imported or + invoked unless a caller actually registers that kind of file. +* **Fail loudly, never silently degrade.** A scanned pdf with no text layer is + out of scope; it raises rather than returning an empty transcript or reaching + for OCR. Knowledge that silently became empty is worse than knowledge that + refused to enter. + +The original binary is not thrown away conceptually: its sha256 is recorded in +``metadata['origin_sha256']`` so :mod:`vouch.verify` can re-check the pdf or the +recording for drift long after extraction. Extracting to text by hand loses +exactly that link, which is why doing it inside vouch is worth the module. +""" + +from __future__ import annotations + +import hashlib +import io +import mimetypes +import re +import shlex +import subprocess +import tempfile +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +from .config_coerce import coerce_numeric +from .models import Source + +if TYPE_CHECKING: + from .storage import KBStore + +DEFAULT_TRANSCRIBE_TIMEOUT_SECONDS = 600.0 + +# Pages are joined by a blank line so the stored text reads as a document +# rather than one run-on paragraph. The separator sits *between* page spans and +# belongs to no page, which is why a byte offset landing in it resolves to no +# coordinate rather than to an arbitrary neighbour. +PAGE_SEPARATOR = "\n\n" +CUE_SEPARATOR = "\n" + +PDF_EXTENSIONS = frozenset({".pdf"}) +AUDIO_EXTENSIONS = frozenset( + {".mp3", ".m4a", ".wav", ".flac", ".ogg", ".opus", ".aac", ".wma", ".aiff"} +) + + +class MediaError(ValueError): + """Media could not become a citable source (extraction, config, or shape).""" + + +class CoordinateKind(StrEnum): + PAGE = "page" + TIMESTAMP = "timestamp" + + +class MediaKind(StrEnum): + PDF = "pdf" + AUDIO = "audio" + + +@dataclass(frozen=True) +class Segment: + """One page's, or one cue's, byte span in the extracted text. + + ``label`` is the coordinate in the *original* file: a 1-indexed page number + for pdfs, a start offset in seconds for audio. It is carried as a string so + the stored map round-trips through yaml without float formatting surprises. + """ + + byte_start: int + byte_end: int + label: str + + +@dataclass(frozen=True) +class Cue: + """A transcript cue: text plus where in the recording it starts.""" + + start_seconds: float + text: str + + +@dataclass(frozen=True) +class MediaConfig: + transcribe_cmd: str | None = None + timeout_seconds: float = DEFAULT_TRANSCRIBE_TIMEOUT_SECONDS + + +def load_config(store: KBStore) -> MediaConfig: + """Read ``sources:`` from config.yaml; fall back to defaults. + + Same defensive shape as ``compile.load_config``: an unreadable or + non-mapping config is not an error here, it just means nothing is + configured, and the transcribe path reports that when it is actually used. + """ + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return MediaConfig() + if not isinstance(loaded, dict): + return MediaConfig() + raw = loaded.get("sources") + if not isinstance(raw, dict): + return MediaConfig() + cmd = raw.get("transcribe_cmd") + return MediaConfig( + transcribe_cmd=str(cmd) if cmd else None, + timeout_seconds=coerce_numeric( + raw.get("transcribe_timeout_seconds", DEFAULT_TRANSCRIBE_TIMEOUT_SECONDS), + DEFAULT_TRANSCRIBE_TIMEOUT_SECONDS, + float, + ), + ) + + +def media_kind(path: Path) -> MediaKind | None: + """Which media pipeline ``path`` belongs to, or None for ordinary files.""" + ext = path.suffix.lower() + if ext in PDF_EXTENSIONS: + return MediaKind.PDF + if ext in AUDIO_EXTENSIONS: + return MediaKind.AUDIO + return None + + +# --- assembly ------------------------------------------------------------- + + +def assemble_pages(pages: list[str]) -> tuple[bytes, list[Segment]]: + """Join page texts into the stored artifact, recording each page's span. + + Offsets are byte offsets, not character offsets, because that is the unit + the receipt indexes — under utf-8 the two diverge at the first multi-byte + codepoint, and a pdf is exactly where an em-dash shows up. + """ + chunks: list[str] = [] + segments: list[Segment] = [] + cursor = 0 + for number, text in enumerate(pages, start=1): + if chunks: + cursor += len(PAGE_SEPARATOR.encode("utf-8")) + chunks.append(PAGE_SEPARATOR) + size = len(text.encode("utf-8")) + segments.append(Segment(cursor, cursor + size, str(number))) + chunks.append(text) + cursor += size + return "".join(chunks).encode("utf-8"), segments + + +def assemble_cues(cues: list[Cue]) -> tuple[bytes, list[Segment]]: + """Join cue texts into the stored transcript, recording each cue's span.""" + chunks: list[str] = [] + segments: list[Segment] = [] + cursor = 0 + for cue in cues: + if chunks: + cursor += len(CUE_SEPARATOR.encode("utf-8")) + chunks.append(CUE_SEPARATOR) + size = len(cue.text.encode("utf-8")) + segments.append(Segment(cursor, cursor + size, _format_seconds(cue.start_seconds))) + chunks.append(cue.text) + cursor += size + return "".join(chunks).encode("utf-8"), segments + + +def _format_seconds(seconds: float) -> str: + """Seconds as a plain decimal string — no trailing ``.0`` noise.""" + rounded = round(seconds, 3) + return str(int(rounded)) if rounded == int(rounded) else str(rounded) + + +# --- coordinate map ------------------------------------------------------- + + +def coordinate_map(kind: CoordinateKind, segments: list[Segment]) -> dict[str, Any]: + """The map stored on ``Source.metadata['coordinates']``. + + Plain lists of scalars, so it diffs readably in a PR like everything else + under ``.vouch/`` — the same reason claims are yaml and not a binary index. + """ + return { + "kind": kind.value, + "segments": [ + {"start": s.byte_start, "end": s.byte_end, "label": s.label} for s in segments + ], + } + + +def _parse_segments(coordinates: dict[str, Any]) -> list[tuple[int, int, str]]: + """Read a stored map back defensively — a malformed row is skipped, not fatal.""" + rows = coordinates.get("segments") + if not isinstance(rows, list): + return [] + out: list[tuple[int, int, str]] = [] + for row in rows: + if not isinstance(row, dict): + continue + start, end, label = row.get("start"), row.get("end"), row.get("label") + if isinstance(start, int) and isinstance(end, int) and label is not None: + out.append((start, end, str(label))) + return out + + +def format_timestamp(seconds: float) -> str: + """``HH:MM:SS`` — the form ``Evidence.locator`` already documents for audio.""" + total = int(seconds) + return f"{total // 3600:02d}:{(total % 3600) // 60:02d}:{total % 60:02d}" + + +def resolve_coordinate(coordinates: dict[str, Any], byte_offset: int) -> str | None: + """Where in the original file ``byte_offset`` of the stored text came from. + + Returns ``p7`` for pdfs and ``t=00:14:23`` for audio, or None when the + offset falls outside every recorded span (a page separator, or a map that + does not cover the text). + """ + kind = coordinates.get("kind") + for start, end, label in _parse_segments(coordinates): + if start <= byte_offset < end: + if kind == CoordinateKind.TIMESTAMP.value: + return f"t={format_timestamp(float(label))}" + return f"p{label}" + return None + + +def locator_for_span(coordinates: dict[str, Any], start: int, end: int) -> str: + """The ``Evidence.locator`` for a receipt span, enriched with its coordinate. + + Always carries the byte span, because that is the part that verifies; the + page or timestamp prefix is what makes it resolvable by a human holding the + original pdf or recording. + """ + span = f"b{start}-{end}" + coordinate = resolve_coordinate(coordinates, start) + return f"{coordinate}@{span}" if coordinate else span + + +# --- extraction ----------------------------------------------------------- + + +def extract_pdf_pages(data: bytes) -> list[str]: + """Text layer of ``data``, one string per page. + + Raises rather than reaching for OCR when there is no text layer: a scanned + pdf is explicitly out of scope, and silently registering an empty source + would produce a citable artifact that cites nothing. + """ + try: + from pypdf import PdfReader + except ImportError as e: + raise MediaError( + "pdf support needs the optional extra — pip install 'vouch-kb[pdf]'" + ) from e + + try: + reader = PdfReader(io.BytesIO(data)) + pages = [(page.extract_text() or "").strip() for page in reader.pages] + # pypdf raises a wide, version-dependent set (its own errors plus whatever + # the underlying stream raises), so the catch is deliberately broad: a + # malformed pdf must surface as a MediaError, never as a stray traceback. + except Exception as e: + raise MediaError(f"could not read pdf: {e}") from e + if not any(pages): + raise MediaError( + "pdf has no text layer (scanned?) — out of scope, vouch does not ocr" + ) + return pages + + +_CUE_TIME_RE = re.compile( + r"(?P\d{1,2}:)?(?P\d{1,2}):(?P\d{1,2})(?:[.,](?P\d{1,3}))?\s*-->" +) + + +def _cue_start_seconds(match: re.Match[str]) -> float: + hours = int((match.group("h") or "0:")[:-1]) + seconds = hours * 3600 + int(match.group("m")) * 60 + int(match.group("s")) + return seconds + int((match.group("ms") or "0").ljust(3, "0")) / 1000 + + +def parse_cues(raw: str) -> list[Cue]: + """Parse WebVTT or SubRip output into cues. + + Both formats are a timing line (``00:00:04.000 --> 00:00:07.000``) followed + by text lines, which is all this needs; accepting both means the configured + command can be whisper, whisper.cpp, or anything else that speaks either. + Cues with no text are dropped — they would contribute an empty span that no + quote can ever land in. + """ + cues: list[Cue] = [] + start: float | None = None + lines: list[str] = [] + + def flush() -> None: + if start is not None and lines: + cues.append(Cue(start, " ".join(lines))) + + for line in raw.splitlines(): + stripped = line.strip() + match = _CUE_TIME_RE.match(stripped) + if match: + flush() + start, lines = _cue_start_seconds(match), [] + continue + if not stripped: + flush() + start, lines = None, [] + continue + if start is not None: + lines.append(stripped) + flush() + + if not cues: + raise MediaError( + "transcription produced no timed cues — expected webvtt or srt output" + ) + return cues + + +def transcribe(path: Path, cmd: str, *, timeout_seconds: float) -> str: + """Run the configured transcription command over ``path`` and return stdout. + + Deployment config, not a baked model dependency: vouch never chooses a + speech model. ``{path}`` in the command is substituted with the shell-quoted + absolute path, and appended when the placeholder is absent. Runs in a + throwaway cwd for the same reason ``llm_draft.run_llm`` does — a CLI that + discovers per-project hooks from its cwd should not fire this project's own + pipeline while transcribing for it. + """ + quoted = shlex.quote(str(path)) + line = cmd.replace("{path}", quoted) if "{path}" in cmd else f"{cmd} {quoted}" + with tempfile.TemporaryDirectory(prefix="vouch-transcribe-") as tmp: + try: + proc = subprocess.run( + line, shell=True, cwd=tmp, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as e: + raise MediaError( + f"sources.transcribe_cmd timed out after {timeout_seconds:.0f}s" + ) from e + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip()[:400] + raise MediaError(f"sources.transcribe_cmd failed ({proc.returncode}): {detail}") + return proc.stdout + + +# --- registration --------------------------------------------------------- + + +def _origin_metadata(path: Path, data: bytes, kind: MediaKind) -> dict[str, Any]: + """Provenance back to the binary the text was extracted from. + + ``origin_sha256`` is what lets ``vouch source verify`` re-check the pdf or + the recording later. Without it the extracted text is unmoored from the + thing it came from, which is the whole failure mode of extracting by hand. + """ + guessed, _ = mimetypes.guess_type(path.name) + return { + "origin_sha256": hashlib.sha256(data).hexdigest(), + "origin_bytes": len(data), + "origin_media_type": guessed or f"application/{kind.value}", + "origin_filename": path.name, + } + + +def register_media_source( + store: KBStore, + path: Path, + *, + kind: MediaKind | None = None, + title: str | None = None, + transcribe_cmd: str | None = None, + timeout_seconds: float | None = None, + tags: list[str] | None = None, +) -> Source: + """Extract ``path`` to text and register it as an ordinary Source. + + The returned Source is content-addressed on the *extracted text*, so every + existing path — ingest, the receipt gate, ``kb.source_verify``, receipt + coverage — works on it unchanged. What is new is ``metadata['coordinates']``, + which maps the stored bytes back to pages or timestamps. + + The caller is trusted with the path: this is reached from the CLI, where the + human already has filesystem access. It is deliberately not wired to the + remote MCP/JSONL ``register_source_from_path`` surface, which confines reads + to the project root for exactly that reason. + """ + resolved = path.resolve() + detected = kind or media_kind(resolved) + if detected is None: + raise MediaError(f"not a supported media file: {resolved.name}") + try: + data = resolved.read_bytes() + except OSError as e: + raise MediaError(f"could not read {resolved}: {e}") from e + + if detected is MediaKind.PDF: + content, segments = assemble_pages(extract_pdf_pages(data)) + coordinates = coordinate_map(CoordinateKind.PAGE, segments) + else: + cfg = load_config(store) + cmd = transcribe_cmd or cfg.transcribe_cmd + if not cmd: + raise MediaError( + "sources.transcribe_cmd is not configured — set it in " + ".vouch/config.yaml, e.g.\nsources:\n transcribe_cmd: " + '"whisper --output_format vtt --output_dir - {path}"' + ) + raw = transcribe( + resolved, cmd, + timeout_seconds=timeout_seconds or cfg.timeout_seconds, + ) + content, segments = assemble_cues(parse_cues(raw)) + coordinates = coordinate_map(CoordinateKind.TIMESTAMP, segments) + + metadata = _origin_metadata(resolved, data, detected) + metadata["coordinates"] = coordinates + return store.put_source( + content, + title=title or resolved.name, + locator=str(resolved), + source_type=detected.value, + media_type="text/plain", + tags=tags, + metadata=metadata, + ) + + +def source_coordinates(source: Source) -> dict[str, Any] | None: + """The coordinate map on ``source``, or None when it carries none.""" + coordinates = source.metadata.get("coordinates") + return coordinates if isinstance(coordinates, dict) else None diff --git a/src/vouch/receipts.py b/src/vouch/receipts.py index 12416ff3..5e7d9466 100644 --- a/src/vouch/receipts.py +++ b/src/vouch/receipts.py @@ -24,7 +24,7 @@ import hashlib from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from .models import Evidence @@ -110,6 +110,7 @@ def receipt_for_quote( source_bytes: bytes, quote: str, evidence_id: str | None = None, + coordinates: dict[str, Any] | None = None, ) -> Evidence | None: """Build an ``Evidence`` carrying a verifying receipt, or None to drop. @@ -118,15 +119,25 @@ def receipt_for_quote( Returns None when the quote is not present verbatim — the mechanical form of "drops any claim it cannot quote." When ``evidence_id`` is omitted, a content-addressed id derived from the span is minted. + + ``coordinates`` is a media source's page/timestamp map (see + :mod:`vouch.media`). It only enriches the human-facing ``locator`` — the + receipt that verifies is still the byte span, so a media citation is + checked by exactly the same string comparison as any other. """ span = locate_span(source_bytes, quote) if span is None: return None start, end = span + locator = f"b{start}-{end}" + if coordinates: + from .media import locator_for_span + + locator = locator_for_span(coordinates, start, end) return Evidence( id=evidence_id or _span_evidence_id(source_id, start, end), source_id=source_id, - locator=f"b{start}-{end}", + locator=locator, quote=quote, byte_start=start, byte_end=end, diff --git a/src/vouch/verify.py b/src/vouch/verify.py index d7244143..d628f7cc 100644 --- a/src/vouch/verify.py +++ b/src/vouch/verify.py @@ -40,7 +40,15 @@ def verify_source(store: KBStore, source: Source) -> VerificationResult: external_status = "n/a" note: str | None = None - if source.type.value == "file": + # A media source (pdf, audio) stores *extracted text*, so its id is the hash + # of the derivation and never of the file on disk. Comparing against the + # original's recorded digest is what keeps it re-checkable — otherwise + # extraction would sever the link back to the pdf or the recording, which is + # the whole reason to extract inside vouch rather than by hand (#613). + origin_hash = source.metadata.get("origin_sha256") + is_file = source.type.value == "file" + if is_file or isinstance(origin_hash, str): + expected = source.id if is_file else str(origin_hash) try: _resolved, external_body = store.read_under_root(source.locator) except (OSError, ValueError) as e: @@ -48,7 +56,7 @@ def verify_source(store: KBStore, source: Source) -> VerificationResult: note = f"unreadable: {e}" else: external_status = ( - "match" if sha256_hex(external_body) == source.id else "drift" + "match" if sha256_hex(external_body) == expected else "drift" ) return VerificationResult( diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 00000000..529c6a29 --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,515 @@ +"""PDF and audio sources — page and timestamp receipts (#613). + +The property under test throughout: a media source stores *extracted text*, so +the byte-offset receipt keeps verifying by ``==`` exactly as it does for a text +file, while a coordinate map resolves that same span back to a page number or a +point in the recording. + +No test needs pypdf installed or a speech model on PATH. The pdf reader is +injected as a fake module and the transcription command is a shell one-liner, +which is the point of both being optional: the pipeline is exercised without +either dependency existing. +""" + +from __future__ import annotations + +import hashlib +import sys +import types +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import media +from vouch.cli import cli +from vouch.media import ( + CoordinateKind, + Cue, + MediaError, + MediaKind, + Segment, + assemble_cues, + assemble_pages, + coordinate_map, + extract_pdf_pages, + format_timestamp, + load_config, + locator_for_span, + media_kind, + parse_cues, + register_media_source, + resolve_coordinate, + source_coordinates, + transcribe, +) +from vouch.receipts import ReceiptStatus, receipt_for_quote, verify_receipt +from vouch.storage import KBStore +from vouch.verify import verify_source + +VTT = """WEBVTT + +00:00:00.000 --> 00:00:04.000 +the migration ran clean + +00:01:03.500 --> 00:01:07.000 +rollback took eleven minutes +""" + +SRT = """1 +00:00:02,000 --> 00:00:05,000 +first cue + +2 +01:00:00,000 --> 01:00:04,000 +an hour in +""" + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +def _fake_pypdf(monkeypatch: pytest.MonkeyPatch, pages: list[str] | None, *, boom: bool = False): + """Install a stand-in ``pypdf`` so the pdf path runs without the extra.""" + module = types.ModuleType("pypdf") + + class _Page: + def __init__(self, text: str) -> None: + self._text = text + + def extract_text(self) -> str | None: + return self._text + + class _Reader: + def __init__(self, _stream: object) -> None: + if boom: + raise RuntimeError("not a pdf") + self.pages = [_Page(p) for p in (pages or [])] + + module.PdfReader = _Reader # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "pypdf", module) + + +# --- kind detection ------------------------------------------------------- + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("spec.pdf", MediaKind.PDF), + ("SPEC.PDF", MediaKind.PDF), + ("call.mp3", MediaKind.AUDIO), + ("call.M4A", MediaKind.AUDIO), + ("notes.md", None), + ("noext", None), + ], +) +def test_media_kind_detection(name: str, expected: MediaKind | None) -> None: + assert media_kind(Path(name)) is expected + + +# --- assembly ------------------------------------------------------------- + + +def test_assemble_pages_records_byte_spans() -> None: + content, segments = assemble_pages(["alpha", "beta"]) + assert content == b"alpha\n\nbeta" + assert segments == [Segment(0, 5, "1"), Segment(7, 11, "2")] + assert content[segments[1].byte_start : segments[1].byte_end] == b"beta" + + +def test_assemble_pages_offsets_are_bytes_not_characters() -> None: + """An em-dash is 3 bytes; a character index would put page 2 in the wrong place.""" + content, segments = assemble_pages(["a—b", "second"]) + assert segments[0].byte_end == 5 + assert content[segments[1].byte_start : segments[1].byte_end].decode() == "second" + + +def test_assemble_pages_empty() -> None: + assert assemble_pages([]) == (b"", []) + + +def test_assemble_cues_records_spans_and_labels() -> None: + content, segments = assemble_cues([Cue(0.0, "one"), Cue(63.5, "two")]) + assert content == b"one\ntwo" + assert [s.label for s in segments] == ["0", "63.5"] + assert content[segments[1].byte_start : segments[1].byte_end] == b"two" + + +# --- coordinate map ------------------------------------------------------- + + +def test_coordinate_map_is_plain_scalars() -> None: + cmap = coordinate_map(CoordinateKind.PAGE, [Segment(0, 5, "1")]) + assert cmap == {"kind": "page", "segments": [{"start": 0, "end": 5, "label": "1"}]} + + +def test_resolve_coordinate_pages_and_gap() -> None: + _content, segments = assemble_pages(["alpha", "beta"]) + cmap = coordinate_map(CoordinateKind.PAGE, segments) + assert resolve_coordinate(cmap, 0) == "p1" + assert resolve_coordinate(cmap, 8) == "p2" + # the separator belongs to no page + assert resolve_coordinate(cmap, 5) is None + assert resolve_coordinate(cmap, 999) is None + + +def test_resolve_coordinate_timestamps() -> None: + _content, segments = assemble_cues([Cue(0.0, "one"), Cue(3723.0, "two")]) + cmap = coordinate_map(CoordinateKind.TIMESTAMP, segments) + assert resolve_coordinate(cmap, 0) == "t=00:00:00" + assert resolve_coordinate(cmap, 4) == "t=01:02:03" + + +@pytest.mark.parametrize( + "cmap", + [ + {"kind": "page", "segments": "not-a-list"}, + {"kind": "page", "segments": ["not-a-dict"]}, + {"kind": "page", "segments": [{"start": "x", "end": 5, "label": "1"}]}, + {"kind": "page", "segments": [{"start": 0, "end": 5}]}, + {}, + ], +) +def test_resolve_coordinate_survives_malformed_maps(cmap: dict[str, object]) -> None: + """A corrupt map degrades to 'no coordinate', never to an exception.""" + assert resolve_coordinate(cmap, 0) is None + + +def test_format_timestamp() -> None: + assert format_timestamp(0) == "00:00:00" + assert format_timestamp(863.9) == "00:14:23" + assert format_timestamp(3661) == "01:01:01" + + +def test_locator_for_span_with_and_without_coordinate() -> None: + _content, segments = assemble_pages(["alpha", "beta"]) + cmap = coordinate_map(CoordinateKind.PAGE, segments) + assert locator_for_span(cmap, 7, 11) == "p2@b7-11" + # offset in the separator: still a valid receipt, just no page to name + assert locator_for_span(cmap, 5, 6) == "b5-6" + + +# --- cue parsing ---------------------------------------------------------- + + +def test_parse_cues_webvtt() -> None: + cues = parse_cues(VTT) + assert [c.start_seconds for c in cues] == [0.0, 63.5] + assert cues[1].text == "rollback took eleven minutes" + + +def test_parse_cues_srt_with_hours() -> None: + cues = parse_cues(SRT) + assert [c.start_seconds for c in cues] == [2.0, 3600.0] + assert cues[0].text == "first cue" + + +def test_parse_cues_joins_wrapped_lines() -> None: + cues = parse_cues("00:00:01.000 --> 00:00:02.000\nwrapped\nover two lines\n") + assert cues[0].text == "wrapped over two lines" + + +def test_parse_cues_drops_empty_cue() -> None: + raw = "00:00:01.000 --> 00:00:02.000\n\n00:00:03.000 --> 00:00:04.000\nreal\n" + cues = parse_cues(raw) + assert [c.text for c in cues] == ["real"] + + +def test_parse_cues_without_timings_is_an_error() -> None: + with pytest.raises(MediaError, match="no timed cues"): + parse_cues("just a plain transcript with no timings\n") + + +# --- pdf extraction ------------------------------------------------------- + + +def test_extract_pdf_pages_reads_text_layer(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_pypdf(monkeypatch, [" page one ", "page two"]) + assert extract_pdf_pages(b"%PDF-fake") == ["page one", "page two"] + + +def test_extract_pdf_pages_without_the_extra(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "pypdf", None) + with pytest.raises(MediaError, match=r"vouch-kb\[pdf\]"): + extract_pdf_pages(b"%PDF-fake") + + +def test_extract_pdf_pages_unreadable_pdf(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_pypdf(monkeypatch, None, boom=True) + with pytest.raises(MediaError, match="could not read pdf"): + extract_pdf_pages(b"not-a-pdf") + + +def test_extract_pdf_pages_refuses_scanned_pdf(monkeypatch: pytest.MonkeyPatch) -> None: + """No text layer must fail loudly — never silently register an empty source.""" + _fake_pypdf(monkeypatch, ["", " "]) + with pytest.raises(MediaError, match="no text layer"): + extract_pdf_pages(b"%PDF-scan") + + +# --- transcription command ------------------------------------------------ + + +def test_transcribe_substitutes_path_placeholder(tmp_path: Path) -> None: + audio = tmp_path / "call.mp3" + audio.write_bytes(b"\x00") + out = transcribe(audio, "printf %s {path}", timeout_seconds=30) + assert out == str(audio) + + +def test_transcribe_appends_path_when_no_placeholder(tmp_path: Path) -> None: + audio = tmp_path / "call.mp3" + audio.write_bytes(b"\x00") + assert transcribe(audio, "printf %s", timeout_seconds=30).endswith("call.mp3") + + +def test_transcribe_reports_command_failure(tmp_path: Path) -> None: + audio = tmp_path / "call.mp3" + audio.write_bytes(b"\x00") + with pytest.raises(MediaError, match="transcribe_cmd failed"): + transcribe(audio, "echo boom >&2; exit 3", timeout_seconds=30) + + +def test_transcribe_times_out(tmp_path: Path) -> None: + audio = tmp_path / "call.mp3" + audio.write_bytes(b"\x00") + with pytest.raises(MediaError, match="timed out"): + transcribe(audio, "sleep 5; : {path}", timeout_seconds=0.2) + + +# --- config --------------------------------------------------------------- + + +def test_load_config_defaults_when_unset(store: KBStore) -> None: + cfg = load_config(store) + assert cfg.transcribe_cmd is None + assert cfg.timeout_seconds == media.DEFAULT_TRANSCRIBE_TIMEOUT_SECONDS + + +def test_load_config_reads_sources_block(store: KBStore) -> None: + store.config_path.write_text( + "sources:\n transcribe_cmd: whisper {path}\n transcribe_timeout_seconds: 30\n", + encoding="utf-8", + ) + cfg = load_config(store) + assert cfg.transcribe_cmd == "whisper {path}" + assert cfg.timeout_seconds == 30.0 + + +def test_load_config_coerces_a_bad_timeout(store: KBStore) -> None: + """A string timeout must not crash registration — it falls back to the default.""" + store.config_path.write_text( + "sources:\n transcribe_cmd: whisper\n transcribe_timeout_seconds: soon\n", + encoding="utf-8", + ) + assert load_config(store).timeout_seconds == media.DEFAULT_TRANSCRIBE_TIMEOUT_SECONDS + + +@pytest.mark.parametrize("body", ["", "just a string\n", "sources: nope\n"]) +def test_load_config_survives_odd_config(store: KBStore, body: str) -> None: + store.config_path.write_text(body, encoding="utf-8") + assert load_config(store).transcribe_cmd is None + + +def test_load_config_survives_unreadable_config(store: KBStore) -> None: + store.config_path.unlink() + assert load_config(store).transcribe_cmd is None + + +# --- registration --------------------------------------------------------- + + +def test_register_pdf_source_end_to_end( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _fake_pypdf(monkeypatch, ["cover page", "the rollback took eleven minutes"]) + pdf = tmp_path / "postmortem.pdf" + pdf.write_bytes(b"%PDF-fake") + + src = register_media_source(store, pdf) + + assert src.type.value == "pdf" + assert src.media_type == "text/plain" + assert store.read_source_content(src.id) == b"cover page\n\nthe rollback took eleven minutes" + assert src.metadata["origin_sha256"] == hashlib.sha256(b"%PDF-fake").hexdigest() + assert src.metadata["origin_filename"] == "postmortem.pdf" + assert src.metadata["coordinates"]["kind"] == "page" + + +def test_pdf_quote_earns_a_receipt_carrying_its_page( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The whole point: a verified receipt that also says which page.""" + _fake_pypdf(monkeypatch, ["cover page", "the rollback took eleven minutes"]) + pdf = tmp_path / "postmortem.pdf" + pdf.write_bytes(b"%PDF-fake") + src = register_media_source(store, pdf) + content = store.read_source_content(src.id) + + ev = receipt_for_quote( + source_id=src.id, + source_bytes=content, + quote="rollback took eleven minutes", + coordinates=source_coordinates(src), + ) + + assert ev is not None + assert ev.locator.startswith("p2@b") + assert verify_receipt(ev, content).status is ReceiptStatus.VERIFIED + + +def test_register_audio_source_end_to_end(store: KBStore, tmp_path: Path) -> None: + audio = tmp_path / "standup.mp3" + audio.write_bytes(b"\x00\x01") + vtt = VTT.replace("\n", "\\n") + + src = register_media_source(store, audio, transcribe_cmd=f"printf '{vtt}'") + + assert src.type.value == "audio" + content = store.read_source_content(src.id) + assert content == b"the migration ran clean\nrollback took eleven minutes" + ev = receipt_for_quote( + source_id=src.id, + source_bytes=content, + quote="rollback took eleven minutes", + coordinates=source_coordinates(src), + ) + assert ev is not None + assert ev.locator.startswith("t=00:01:03@b") + assert verify_receipt(ev, content).status is ReceiptStatus.VERIFIED + + +def test_register_audio_uses_configured_command(store: KBStore, tmp_path: Path) -> None: + audio = tmp_path / "standup.mp3" + audio.write_bytes(b"\x00") + store.config_path.write_text( + "sources:\n transcribe_cmd: \"printf '00:00:01.000 --> 00:00:02.000\\\\nhello'\"\n", + encoding="utf-8", + ) + src = register_media_source(store, audio) + assert store.read_source_content(src.id) == b"hello" + + +def test_register_audio_without_a_configured_command(store: KBStore, tmp_path: Path) -> None: + audio = tmp_path / "standup.mp3" + audio.write_bytes(b"\x00") + with pytest.raises(MediaError, match=r"sources\.transcribe_cmd is not configured"): + register_media_source(store, audio) + + +def test_register_rejects_unsupported_file(store: KBStore, tmp_path: Path) -> None: + notes = tmp_path / "notes.md" + notes.write_text("plain", encoding="utf-8") + with pytest.raises(MediaError, match="not a supported media file"): + register_media_source(store, notes) + + +def test_register_reports_unreadable_file(store: KBStore, tmp_path: Path) -> None: + with pytest.raises(MediaError, match="could not read"): + register_media_source(store, tmp_path / "missing.pdf", kind=MediaKind.PDF) + + +def test_source_coordinates_absent_on_ordinary_source(store: KBStore) -> None: + src = store.put_source(b"plain text", title="notes.txt") + assert source_coordinates(src) is None + + +# --- drift detection ------------------------------------------------------ + + +def test_verify_rechecks_the_original_pdf( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Extraction must not sever the link back to the file it came from.""" + _fake_pypdf(monkeypatch, ["contract text"]) + pdf = store.root / "contract.pdf" + pdf.write_bytes(b"%PDF-one") + src = register_media_source(store, pdf) + + result = verify_source(store, src) + assert result.stored_ok is True + assert result.external_status == "match" + + pdf.write_bytes(b"%PDF-two") + assert verify_source(store, src).external_status == "drift" + + +def test_verify_reports_a_missing_original( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _fake_pypdf(monkeypatch, ["contract text"]) + pdf = store.root / "contract.pdf" + pdf.write_bytes(b"%PDF-one") + src = register_media_source(store, pdf) + pdf.unlink() + assert verify_source(store, src).external_status == "missing" + + +# --- cli surface ---------------------------------------------------------- + + +def test_cli_source_add_extracts_a_pdf( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _fake_pypdf(monkeypatch, ["cover", "body text"]) + pdf = store.root / "spec.pdf" + pdf.write_bytes(b"%PDF-fake") + + result = CliRunner().invoke(cli, ["source", "add", str(pdf)]) + + assert result.exit_code == 0, result.output + src = store.get_source(result.output.strip()) + assert src.type.value == "pdf" + assert store.read_source_content(src.id) == b"cover\n\nbody text" + + +def test_cli_source_add_raw_keeps_the_bytes(store: KBStore) -> None: + pdf = store.root / "spec.pdf" + pdf.write_bytes(b"%PDF-fake") + + result = CliRunner().invoke(cli, ["source", "add", str(pdf), "--raw"]) + + assert result.exit_code == 0, result.output + src = store.get_source(result.output.strip()) + assert src.type.value == "file" + assert store.read_source_content(src.id) == b"%PDF-fake" + + +def test_cli_source_add_surfaces_a_media_error(store: KBStore) -> None: + audio = store.root / "call.mp3" + audio.write_bytes(b"\x00") + result = CliRunner().invoke(cli, ["source", "add", str(audio)]) + assert result.exit_code != 0 + + +def test_cli_source_locate_prints_the_page( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _fake_pypdf(monkeypatch, ["cover", "body text"]) + pdf = store.root / "spec.pdf" + pdf.write_bytes(b"%PDF-fake") + sid = CliRunner().invoke(cli, ["source", "add", str(pdf)]).output.strip() + + result = CliRunner().invoke(cli, ["source", "locate", sid, "body text"]) + + assert result.exit_code == 0, result.output + assert result.output.strip() == "p2@b7-16" + + +def test_cli_source_locate_on_a_plain_source(store: KBStore) -> None: + src = store.put_source(b"plain text", title="notes.txt") + result = CliRunner().invoke(cli, ["source", "locate", src.id, "text"]) + assert result.exit_code == 0, result.output + assert result.output.strip() == "b6-10" + + +def test_cli_source_locate_rejects_a_paraphrase(store: KBStore) -> None: + src = store.put_source(b"plain text", title="notes.txt") + result = CliRunner().invoke(cli, ["source", "locate", src.id, "something else"]) + assert result.exit_code == 1 + assert "not found verbatim" in result.output