Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> <quote>` 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,
Expand Down
38 changes: 38 additions & 0 deletions docs/object-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
75 changes: 73 additions & 2 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@
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
from . import pins as pins_mod
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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading