Files
homelab/scripts/paperless-eval/eval.py
T
claude-botandClaude Opus 5 cb751cbedc
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
fix(paperless-eval): bound generation server-side; the model does not always stop
Three of the first eleven documents in a run never terminated. One reached
10269 tokens on a classification that needs ~300, at a perfectly healthy
11 tok/s — so it read as slowness and was neither slow nor a context problem.

llama-server's --n-predict defaults to -1, infinity, and paperless sets no
token budget of its own and waits. Nothing anywhere bounded the request. Four
documents cost most of an hour.

--n-predict 768 on the sidecar caps it, and a truncated response is better
evidence than a timeout: it shows what the model was doing instead of
stopping. The client timeout drops to 300 s and becomes a backstop.

Also:

- the degeneracy check fired on 'Rechnung 2025-5425541' — 8 letters in 21
  characters, below the 40% Latin threshold. Digits and punctuation are normal
  in a title; look for characters from other scripts directly instead of
  inferring them from a shortage of Latin ones.

- the ctx-size comment still carried the claim bisection disproved in #777,
  that 32768 corrupts inference. The boundary is prompt length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 14:51:56 +02:00

391 lines
15 KiB
Python

#!/usr/bin/env python3
"""Measure what Paperless suggests for documents whose labels a human already chose.
Runs both engines against the same documents:
/api/documents/<id>/suggestions/ the built-in scikit-learn classifier
/api/documents/<id>/ai_suggestions/ the LLM path, with RAG
WHAT THIS ANSWERS, AND WHAT AN EARLIER VERSION DID NOT
The two engines are not alternatives — the classifier costs 0.6 s per document
and keeps running whatever the LLM does. So the useful question is not "which
scores higher" but "what does the LLM add where the classifier is wrong", and
that cannot be recovered from two aggregate percentages: 74% and 72% could
overlap almost entirely or barely at all, and the difference between those two
worlds is the entire value of running an LLM.
So every document is written out individually, with both engines' verdicts, and
the summary is derived from that rather than counted along the way.
SCORING
Correspondent and document type are each scored twice:
offered the true label's id came back in `correspondents` /
`document_types` — what the UI actually presents
recognised also counting the true label's name appearing wordwise in
`suggested_correspondents` / `suggested_document_types`
The gap between them is one specific failure: the model names the right entity
but paperless_ai.matching cannot resolve it, because difflib runs at
MATCH_THRESHOLD = 0.8 and a correct but more verbose answer falls below it —
"Generali Deutschland Krankenversicherung AG" against the existing "Generali
AG". Reporting only the first blames the model for a matcher problem; only the
second claims a usefulness the UI does not deliver.
Document type is additionally split on whether the true type is "Sonstige",
which holds 1133 of ~3900 documents and is the one label with no semantic
content — a restaurant reservation lands there because nothing else fits. The
classifier can memorise that; a model reading the document has to infer "none
of the above". Scored separately rather than dropped, because the model does
sometimes get it right.
GROUND TRUTH
The current filing, which deserves care: over ten years conventions change, and
a disagreement with an old document is not automatically an error. Treat the
comparison between engines as the result and the absolute numbers as
indicative.
Env:
PAPERLESS_URL default http://127.0.0.1:8000
PGHOST/PGDATABASE standard libpq, for ground truth and the API token
EVAL_SAMPLE documents to score, default 50
EVAL_ENGINES comma-separated subset of classic,ai — default both
EVAL_LABEL free-text name for this configuration, recorded in the
output so results from different jobs stay distinguishable
EVAL_OUT directory for results, default /results
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
import sys
import time
import urllib.error
import urllib.request
import psycopg
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "http://127.0.0.1:8000")
SAMPLE = int(os.environ.get("EVAL_SAMPLE", "50"))
ENGINES = os.environ.get("EVAL_ENGINES", "classic,ai").split(",")
LABEL = os.environ.get("EVAL_LABEL", "unlabelled")
OUT_DIR = os.environ.get("EVAL_OUT", "/results")
# A backstop, not the real bound. Generation is capped server-side by
# --n-predict on the llama-server sidecar, which is what actually stops a model
# that will not terminate; this catches everything else — a wedged sidecar, a
# request that never reaches it.
#
# 300 s against the ~73 s a healthy document takes end to end, prompt included.
# It does work: llama-server logs `stop: cancel task` on the disconnect and
# starts the next request 0.4 s later.
REQUEST_TIMEOUT = 300
CATCHALL_TYPE = "Sonstige"
def log(msg: str) -> None:
print(f"[eval] {msg}", flush=True)
def api_token(conn) -> str:
"""A token for this run, created in the database.
The API needs one and this database is a disposable clone, so the run
issues its own against a superuser rather than depending on a token that
happened to come across in the restore.
"""
token = secrets.token_hex(20)
with conn.cursor() as cur:
cur.execute(
"select id from auth_user where is_superuser and is_active order by id limit 1",
)
row = cur.fetchone()
if row is None:
raise SystemExit("no active superuser in the restored database")
user_id = row[0]
cur.execute("delete from authtoken_token where user_id = %s", (user_id,))
cur.execute(
"insert into authtoken_token (key, created, user_id) values (%s, now(), %s)",
(token, user_id),
)
conn.commit()
return token
def sample(conn) -> list[dict]:
"""The documents to score, ordered by md5 of the id.
Deterministic, so every engine and every configuration sees the same
documents and results stay comparable across jobs. Only documents carrying
both labels are usable as ground truth.
"""
with conn.cursor() as cur:
cur.execute(
"""
select d.id, d.correspondent_id, d.document_type_id, c.name, t.name
from documents_document d
join documents_correspondent c on c.id = d.correspondent_id
join documents_documenttype t on t.id = d.document_type_id
where d.correspondent_id is not null
and d.document_type_id is not null
and length(d.content) > 400
order by md5(d.id::text)
limit %s
""",
(SAMPLE,),
)
return [
{
"id": r[0],
"correspondent_id": r[1],
"document_type_id": r[2],
"correspondent_name": r[3],
"document_type_name": r[4],
}
for r in cur.fetchall()
]
def floor(conn) -> float:
"""Always guessing the most frequent correspondent.
Without it a number like "70% correct" cannot be read: one correspondent
holds several hundred of ~3900 documents.
"""
with conn.cursor() as cur:
cur.execute(
"""
select max(c), sum(c) from (
select count(*) c from documents_document
where correspondent_id is not null group by correspondent_id
) x
""",
)
top, total = cur.fetchone()
# float() on both: psycopg returns Postgres numerics as Decimal, and
# mixing that with a float literal raises TypeError rather than coercing.
return 100.0 * float(top) / float(total) if total else 0.0
def fetch(path: str, token: str) -> tuple[dict | None, str | None, float]:
req = urllib.request.Request(
f"{PAPERLESS_URL}{path}",
headers={"Authorization": f"Token {token}"},
)
start = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
body = resp.read()
return json.loads(body), None, time.monotonic() - start
except urllib.error.HTTPError as err:
return None, f"HTTP {err.code}: {err.read()[:200]!r}", time.monotonic() - start
except Exception as err: # noqa: BLE001 — a failed document is data, not a crash
return None, f"{type(err).__name__}: {err}", time.monotonic() - start
def normalise(text: str) -> set[str]:
return {w for w in re.split(r"[^\w]+", text.lower()) if w}
def recognised(payload: dict, field: str, want_name: str) -> bool:
"""True when a suggested name contains every word of the true label's name.
Handles the observed failure directly: the model answers with a superset of
the stored name, which difflib rejects at 0.8 but a reader would accept.
"""
want = normalise(want_name)
return any(want <= normalise(name) for name in payload.get(field) or [])
def degenerate(payload: dict) -> str | None:
"""Whether this looks like broken inference rather than a wrong guess.
Past roughly 14k prompt tokens this stack stops producing language — first
empty completions, then runs of digits and CJK characters — and one such
request poisons the llama.cpp slot until the pod restarts. Nothing in the
HTTP response says so: status 200, finish_reason "stop", well-formed JSON.
Scored as a wrong answer it would silently depress a candidate's numbers,
and because the slot stays poisoned it would take the rest of the run with
it. Flagged instead, so the failure is visible as itself.
"""
title = payload.get("title") or ""
if not title.strip():
return "empty title"
# Digits, punctuation and whitespace are normal in a document title —
# "Rechnung 2025-5425541" is 8 letters in 21 characters and tripped an
# earlier version of this check. What broken inference produces is
# characters from other scripts, so look for those directly rather than
# inferring them from a shortage of Latin ones.
foreign = sum(c.isalpha() and not (c.isascii() or c in "äöüÄÖÜßáéèêíóúàçñ") for c in title)
if foreign > 0.2 * len(title):
return f"non-latin title: {title[:40]!r}"
return None
def score_document(payload: dict, doc: dict) -> dict:
offered_corr = doc["correspondent_id"] in (payload.get("correspondents") or [])
offered_type = doc["document_type_id"] in (payload.get("document_types") or [])
return {
"correspondent_offered": offered_corr,
"correspondent_recognised": offered_corr
or recognised(payload, "suggested_correspondents", doc["correspondent_name"]),
"type_offered": offered_type,
"type_recognised": offered_type
or recognised(payload, "suggested_document_types", doc["document_type_name"]),
"suggested_correspondents": payload.get("suggested_correspondents") or [],
"suggested_document_types": payload.get("suggested_document_types") or [],
"title": payload.get("title"),
}
def run(engine: str, suffix: str, docs: list[dict], token: str) -> list[dict]:
log(f"=== {engine}: {len(docs)} documents")
rows = []
for n, doc in enumerate(docs, 1):
payload, error, elapsed = fetch(
f"/api/documents/{doc['id']}/{suffix}",
token,
)
row = {
"engine": engine,
"document_id": doc["id"],
"seconds": round(elapsed, 1),
"truth_correspondent": doc["correspondent_name"],
"truth_type": doc["document_type_name"],
"catchall_type": doc["document_type_name"] == CATCHALL_TYPE,
}
if payload is None:
row["error"] = error
log(f" {doc['id']}: {error}")
else:
row.update(score_document(payload, doc))
# Only the AI path returns a title; the classifier answers with ids
# alone, so an "empty title" there is normal rather than broken.
if engine == "ai" and (broken := degenerate(payload)) is not None:
row["degenerate"] = broken
log(f" {doc['id']}: DEGENERATE — {broken}")
rows.append(row)
if n % 10 == 0:
log(f" {n}/{len(docs)}")
return rows
def pct(hits: int, total: int) -> float:
return round(100.0 * hits / total, 1) if total else 0.0
def summarise(rows: list[dict], engine: str) -> dict:
mine = [r for r in rows if r["engine"] == engine]
# Degenerate rows are excluded from the accuracy figures: they measure the
# backend giving up, not the model choosing wrongly, and leaving them in
# would quietly punish whichever candidate happened to trip the slot.
scored = [r for r in mine if "error" not in r and "degenerate" not in r]
failed = [r for r in mine if "error" in r]
broken = [r for r in mine if "degenerate" in r]
specific = [r for r in scored if not r["catchall_type"]]
catchall = [r for r in scored if r["catchall_type"]]
return {
"engine": engine,
"scored": len(scored),
"failed": len(failed),
# Non-zero means the run is suspect from that point on, not that the
# model is bad — the slot stays poisoned until the pod restarts.
"degenerate": len(broken),
"seconds_total": round(sum(r["seconds"] for r in mine)),
"correspondent_offered": pct(
sum(r["correspondent_offered"] for r in scored), len(scored)
),
"correspondent_recognised": pct(
sum(r["correspondent_recognised"] for r in scored), len(scored)
),
"type_offered": pct(sum(r["type_offered"] for r in scored), len(scored)),
"type_specific": pct(
sum(r["type_offered"] for r in specific), len(specific)
),
"type_catchall": pct(
sum(r["type_offered"] for r in catchall), len(catchall)
),
}
def complement(rows: list[dict]) -> dict | None:
"""What the LLM adds where the classifier is wrong, and what it costs.
The reason this script writes per-document rows at all. Only computed when
both engines ran, and only over documents both actually scored.
"""
by_engine = {"classic": {}, "ai": {}}
for row in rows:
usable = "error" not in row and "degenerate" not in row
if usable and row["engine"] in by_engine:
by_engine[row["engine"]][row["document_id"]] = row
both = set(by_engine["classic"]) & set(by_engine["ai"])
if not both or not by_engine["ai"]:
return None
rescued = missed = broken = agreed = 0
for doc_id in both:
classic_hit = by_engine["classic"][doc_id]["correspondent_offered"]
ai_hit = by_engine["ai"][doc_id]["correspondent_offered"]
if classic_hit and ai_hit:
agreed += 1
elif not classic_hit and ai_hit:
rescued += 1
elif classic_hit and not ai_hit:
broken += 1
else:
missed += 1
return {
"documents": len(both),
"both_right": agreed,
"llm_rescued": rescued,
"llm_only_wrong": broken,
"both_wrong": missed,
}
def main() -> int:
os.makedirs(OUT_DIR, exist_ok=True)
with psycopg.connect() as conn:
token = api_token(conn)
docs = sample(conn)
guess_floor = floor(conn)
log(f"label={LABEL} documents={len(docs)} floor={guess_floor:.1f}%")
rows: list[dict] = []
for engine, suffix in (("classic", "suggestions/"), ("ai", "ai_suggestions/")):
if engine in ENGINES:
rows.extend(run(engine, suffix, docs, token))
result = {
"label": LABEL,
"sample": len(docs),
"floor": round(guess_floor, 1),
"summaries": [summarise(rows, e) for e in ENGINES if e in ("classic", "ai")],
"complement": complement(rows),
}
with open(f"{OUT_DIR}/{LABEL}.jsonl", "w") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
with open(f"{OUT_DIR}/{LABEL}.summary.json", "w") as handle:
json.dump(result, handle, ensure_ascii=False, indent=2)
# Also to stdout, so the numbers are in the pod log and reachable via Loki
# without mounting the volume.
log(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())