diff --git a/CHANGELOG.md b/CHANGELOG.md index fee61695..05eef387 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. - **cascade delete — the referrers ride along in the proposal** (#600): `kb.propose_delete(..., cascade=true)` and `vouch propose-delete --cascade`. `referenced_by()` refuses a delete while anything still points at the target, 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/cli.py b/src/vouch/cli.py index 8c14e552..1f1aebc8 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 @@ -2412,9 +2414,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( @@ -2474,6 +2518,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