Files
Nicolò Boschi 280f098202 feat(retain): inline images and files as first-class content (#4077)
Makes images and files first-class raw content in `retain`. `content` accepts an
ordered list of text/image/file blocks, the extractor reads each attachment in
the position it occupies, and every read surface hands back the attachments
behind what it returns. A plain string behaves exactly as before — text-only
retain is byte-identical, because everything new sits behind an ATTACHMENTS
block that is empty when a chunk carries none.

Blocks are flattened at the API boundary into one canonical body with atomic
placeholders, so `documents.original_text` stays plain text and content_hash
idempotency, `update_mode=append`, chunk-delta re-extraction and
`reprocess_document` keep working untouched. Bytes live in the existing
FileStorage abstraction, content-addressed by sha256.

Schema (one migration, both dialects): `attachments` for the blob,
`document_attachments` for which documents reference it, and
`memory_units.attachment_ids` for which attachments a *fact* came from — a
column rather than a third table, because those ids behave exactly like `tags`.

Provenance is per fact, not per chunk. Extraction runs one call per chunk, and a
chunk holding a screenshot also holds the prose around it, so a chunk-level edge
cited the diagram as evidence for the paragraph that never mentioned it. The
extractor is asked instead, and a fact stated in the prose carries nothing.

Extraction quality was measured against a real image-QA dataset with a raw-VLM
ceiling arm before merging: transcribing structured attachments rather than
summarizing them, and recording how each value is drawn, took the gap between
"the model can read this off the image" and "memory can answer it" from 31.3% to
10.0% on the same 40 charts. The prose-article benchmark went 75% -> 100% over
the same change, so it is not chart-specific tuning.

Also here:

* A vision slot (`HINDSIGHT_API_VLM_*`) so attachment-bearing chunks alone use a
  vision model and text-only chunks stay on a cheaper retain LLM. A vision call
  deliberately does not fail over to the retain chain's text models — that would
  reintroduce the silent omission the 422 gate exists to prevent.
* The extension retain hook can now see each attachment (media type, size, kind,
  filename) and refusing a retain reclaims its bytes, which previously stayed
  fetchable forever.
* A filename lives on the document edge, not the blob: the same PDF can be
  attached under a different name elsewhere, and content-addressing made the
  first name win for both.

Known limitations, documented rather than hidden: store-owned memory backends
get nothing (that retain path is Postgres-free and pre-dates this work), very
dense pages are sampled rather than exhausted, and the Python client's
ContentBlock is a plain dict where TypeScript gets the real union.

Breaking for Go and Rust callers: `content` is now a union, so a bare string no
longer satisfies it. Go gains a `TextContent()` helper; Rust uses
`Content::Variant0(...)`.
2026-09-04 12:48:08 +02:00

198 lines
6.8 KiB
Python

"""Run both arms of the multimodal-retain benchmark against a live server.
Two banks per article, from identical prose:
- **multimodal** — the article as callers can now send it, with each image inline
in the position it occupies;
- **text-only** — the same article with the images simply absent. This is the
honest pre-feature baseline: a caller who could not send images did not get
captions instead, they got prose that pointed at pictures nobody saw.
Both arms are then asked the same questions, every one of which is answerable
only from an image. The comparison is the measurement.
The harness speaks HTTP, so the same code drives any build.
"""
from __future__ import annotations
import asyncio
import base64
import time
from typing import Any
import httpx
from pydantic import BaseModel, Field
from .corpus import Article
from .images import ImageSpec, render
from .judge import AnswerVerdict, FactVerdict, judge_all, judge_answer, judge_fact_present
ARMS = ("multimodal", "text-only")
class QuestionResult(BaseModel):
question: str
answer: str = ""
verdict: AnswerVerdict | None = None
error: str = ""
class ArmRun(BaseModel):
"""One arm's outcome for one article."""
arm: str
bank_id: str
retained: bool = False
retain_error: str = ""
facts: list[str] = Field(default_factory=list)
image_facts: list[FactVerdict] = Field(default_factory=list)
questions: list[QuestionResult] = Field(default_factory=list)
retain_ms: int = 0
class ArticleRun(BaseModel):
article: str
arms: list[ArmRun] = Field(default_factory=list)
class BenchmarkArtifact(BaseModel):
"""Everything a run produced, so metrics can be recomputed without re-running."""
build: str
api_url: str
api_version: str = ""
runs: list[ArticleRun] = Field(default_factory=list)
class _Client:
def __init__(self, api_url: str, timeout: float = 600.0) -> None:
self._base = api_url.rstrip("/")
self._http = httpx.AsyncClient(timeout=timeout)
async def close(self) -> None:
await self._http.aclose()
async def _json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
response = await self._http.request(method, f"{self._base}{path}", json=payload)
response.raise_for_status()
return response.json() if response.content else {}
async def version(self) -> str:
return (await self._json("GET", "/version")).get("api_version", "")
async def retain(self, bank_id: str, content: str | list[dict], document_id: str) -> None:
"""Retain one article synchronously, so the bank is queryable on return."""
await self._json(
"POST",
f"/v1/default/banks/{bank_id}/memories",
{"items": [{"content": content, "document_id": document_id}], "async": False},
)
async def facts(self, bank_id: str) -> list[str]:
"""Every fact in the bank, so coverage is measured against what was stored."""
response = await self._json("GET", f"/v1/default/banks/{bank_id}/memories/list?limit=200")
return [item.get("text", "") for item in response.get("items", [])]
async def reflect(self, bank_id: str, query: str) -> str:
response = await self._json(
"POST",
f"/v1/default/banks/{bank_id}/reflect",
{"query": query},
)
return response.get("text") or ""
async def delete_bank(self, bank_id: str) -> None:
try:
await self._json("DELETE", f"/v1/default/banks/{bank_id}")
except httpx.HTTPStatusError:
# A bank that was never created is not an error worth failing a run for.
pass
def _image_block(spec: ImageSpec) -> dict:
return {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64.b64encode(render(spec)).decode(),
},
}
def _multimodal_content(article: Article) -> list[dict]:
return [
{"type": "text", "text": block} if isinstance(block, str) else _image_block(block) for block in article.body
]
async def _run_arm(client: _Client, article: Article, arm: str, run_id: str) -> ArmRun:
bank_id = f"mmbench-{run_id}-{article.name}-{arm}"
result = ArmRun(arm=arm, bank_id=bank_id)
content: str | list[dict] = _multimodal_content(article) if arm == "multimodal" else article.text_only_body()
started = time.time()
try:
await client.retain(bank_id, content, document_id=article.name)
result.retained = True
except httpx.HTTPStatusError as exc:
# A 422 here is a real finding, not a harness bug: it means the server's
# retain LLM cannot read images, and the multimodal arm cannot be run at
# all. Recorded rather than raised so the other arm still reports.
result.retain_error = f"{exc.response.status_code}: {exc.response.text[:300]}"
return result
result.retain_ms = int((time.time() - started) * 1000)
result.facts = await client.facts(bank_id)
# Mechanism: did the image-only detail reach memory as facts at all?
claims = [fact for spec in article.images for fact in spec.facts]
result.image_facts = await judge_all([judge_fact_present(claim, result.facts) for claim in claims])
# Outcome: can the bank answer questions that need those details?
for question in article.questions:
entry = QuestionResult(question=question.question)
try:
entry.answer = await client.reflect(bank_id, question.question)
except httpx.HTTPStatusError as exc:
entry.error = f"{exc.response.status_code}: {exc.response.text[:200]}"
result.questions.append(entry)
verdicts = await judge_all(
[judge_answer(q.question, q.expected, entry.answer) for q, entry in zip(article.questions, result.questions)]
)
for entry, verdict in zip(result.questions, verdicts):
entry.verdict = verdict
return result
async def run_benchmark(
api_url: str,
articles: list[Article],
*,
build: str,
run_id: str,
keep_banks: bool = False,
progress=None,
) -> BenchmarkArtifact:
client = _Client(api_url)
try:
artifact = BenchmarkArtifact(build=build, api_url=api_url, api_version=await client.version())
for article in articles:
run = ArticleRun(article=article.name)
for arm in ARMS:
if progress is not None:
progress(f"{article.name} / {arm}")
run.arms.append(await _run_arm(client, article, arm, run_id))
artifact.runs.append(run)
if not keep_banks:
await asyncio.gather(*(client.delete_bank(arm.bank_id) for run in artifact.runs for arm in run.arms))
return artifact
finally:
await client.close()