lexora / eval /workspace_run.py
Abdr007's picture
Lexora — deployed tree
3fc8e60
Raw
History Blame Contribute Delete
7.94 kB
#!/usr/bin/env python3
"""Measure retrieval over an uploaded document, not over the pinned corpus.
§5 measures the law library. Nothing measured the workspace, and the interface said so
honestly — `calibrated: false` everywhere it is exposed. That is the right disclosure and
it is not a measurement, so this closes the gap: the same document a user would upload,
run through the same ingest, with hand-labelled questions.
**Labels are phrases, not ids.** The corpus set labels a question with `law_id` and
`article_no`, which are stable because the corpus is pinned. An uploaded document has no
stable ids at all — `doc_id` is a fresh uuid per ingest and section numbers move whenever
the chunker changes. So a label here names a distinctive phrase from the passage that
answers the question, and retrieval is correct when a returned passage contains it. Every
phrase is verified to exist in the extracted text before anything is scored, so a typo in
a label fails loudly rather than depressing the score.
The document is `dubai-tenancy-law-26-2007.pdf` — a real government PDF, uploaded through
the real path. Using a file this project generated would measure only its own output.
make eval-workspace
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
for candidate in (REPO_ROOT, REPO_ROOT / "apps" / "api"):
if str(candidate) not in sys.path:
sys.path.insert(0, str(candidate))
import numpy as np # noqa: E402
from app.core.embedding import embed_documents # noqa: E402
from app.core.settings import Settings # noqa: E402
from app.rag.rerank import apply_refusal_gate, rerank # noqa: E402
from app.workspace.chunker import chunk_document, coverage # noqa: E402
from app.workspace.extract import extract_bytes # noqa: E402
from app.workspace.retriever import WorkspaceRetriever # noqa: E402
DOCUMENT = REPO_ROOT / "corpus" / "pdf" / "dubai-tenancy-law-26-2007.pdf"
QUESTIONS = REPO_ROOT / "eval" / "workspace_questions.jsonl"
RESULTS = REPO_ROOT / "eval" / "results" / "workspace.json"
#: Reported at @5 to match §5, so the two tables can be read side by side.
TOP_K = 5
def normalise(text: str) -> str:
"""Whitespace-insensitive comparison: chunking rewraps lines, phrases must survive."""
return " ".join(text.split()).lower()
def load_labels() -> list[dict[str, Any]]:
with QUESTIONS.open(encoding="utf-8") as handle:
return [json.loads(line) for line in handle if line.strip()]
def verify_labels(labels: list[dict[str, Any]], document_text: str) -> None:
"""Every phrase must exist in the document. A label that cannot be right is a bug."""
haystack = normalise(document_text)
missing = [
label["id"]
for label in labels
if label["answerable"] and normalise(label["must_contain"]) not in haystack
]
if missing:
raise SystemExit(
f"labels {missing} name phrases that do not appear in {DOCUMENT.name}. "
"Fix the label — do not lower the score to match it."
)
def main() -> int:
settings = Settings()
labels = load_labels()
document = extract_bytes(DOCUMENT.read_bytes(), DOCUMENT.name, settings=settings)
verify_labels(labels, document.text)
chunks = chunk_document(document, "doc-eval", settings)
kept = coverage(document, chunks)
if kept < 1.0:
raise SystemExit(f"ingest lost {(1 - kept):.2%} of the document; fix that first")
vectors = np.asarray(embed_documents([c.text for c in chunks], settings), dtype=np.float32)
retriever = WorkspaceRetriever.build(chunks, vectors, settings)
workspace_settings = settings.model_copy(
update={"refusal_score_floor": settings.workspace_refusal_score_floor}
)
rows: list[dict[str, Any]] = []
for label in labels:
started = time.perf_counter()
retrieval = retriever.retrieve(label["question"])
top = rerank(label["question"], list(retrieval.candidates), workspace_settings)
# The jurisdiction check is off for a workspace, exactly as the API runs it.
outcome = apply_refusal_gate(
top, workspace_settings, best_dense=retrieval.best_dense, scope=None
)
elapsed = (time.perf_counter() - started) * 1000
gold_rank: int | None = None
if label["answerable"]:
needle = normalise(label["must_contain"])
for position, scored in enumerate(top, start=1):
if needle in normalise(scored.chunk.text):
gold_rank = position
break
rows.append(
{
"id": label["id"],
"answerable": label["answerable"],
"gold_rank": gold_rank,
"refused": not outcome.covered,
"best_score": outcome.best_score,
"latency_ms": round(elapsed, 1),
}
)
answerable = [r for r in rows if r["answerable"]]
unanswerable = [r for r in rows if not r["answerable"]]
def rate(predicate: Any, of: list[dict[str, Any]]) -> float:
return round(sum(1 for r in of if predicate(r)) / len(of), 4) if of else 0.0
metrics = {
"hit_rate_at_5": rate(
lambda r: r["gold_rank"] is not None and r["gold_rank"] <= TOP_K, answerable
),
"hit_rate_at_1": rate(lambda r: r["gold_rank"] == 1, answerable),
"mrr": round(
sum(0.0 if r["gold_rank"] is None else 1 / r["gold_rank"] for r in answerable)
/ len(answerable),
4,
)
if answerable
else 0.0,
# The number that matters: does it decline a question this document cannot answer?
"refusal_accuracy": rate(lambda r: r["refused"], unanswerable),
# And the cost of that: a refusal on a question the document DOES answer.
"false_refusals": sum(1 for r in answerable if r["refused"]),
}
latencies = sorted(r["latency_ms"] for r in rows)
metrics["latency_p50_ms"] = latencies[len(latencies) // 2]
metrics["latency_p95_ms"] = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))]
payload = {
"document": DOCUMENT.name,
"pages": len(document.pages),
"chunks": len(chunks),
"coverage": kept,
"questions": {"answerable": len(answerable), "unanswerable": len(unanswerable)},
"refusal_score_floor": workspace_settings.refusal_score_floor,
"metrics": metrics,
"per_question": rows,
}
RESULTS.parent.mkdir(parents=True, exist_ok=True)
RESULTS.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(
f"\n {DOCUMENT.name}{len(document.pages)} pages, {len(chunks)} chunks, "
f"coverage {kept:.2%}\n"
)
print(f" hit-rate@5 {metrics['hit_rate_at_5']:.4f} ({len(answerable)} answerable)")
print(f" hit-rate@1 {metrics['hit_rate_at_1']:.4f}")
print(f" MRR {metrics['mrr']:.4f}")
print(
f" refusal accuracy {metrics['refusal_accuracy']:.4f} "
f"({len(unanswerable)} unanswerable)"
)
print(f" false refusals {metrics['false_refusals']}")
print(
f" latency p50 {metrics['latency_p50_ms']:.0f} ms · "
f"p95 {metrics['latency_p95_ms']:.0f} ms"
)
print(f"\n written to {RESULTS.relative_to(REPO_ROOT)}\n")
for row in rows:
if row["answerable"] and row["gold_rank"] is None:
print(f" MISS {row['id']}: the labelled passage was not in the top 5")
if row["answerable"] and row["refused"]:
print(f" FALSE REFUSAL {row['id']}")
if not row["answerable"] and not row["refused"]:
print(f" ANSWERED THE UNANSWERABLE {row['id']}")
return 0
if __name__ == "__main__":
sys.exit(main())