RICS / scripts /ragas_eval.py
StormShadow308's picture
feat: async pipeline, job queue, generation hardening, and docs
732b14f
Raw
History Blame Contribute Delete
21.6 kB
"""Real RAGAS evaluation against the live RICS report-generation pipeline.
This script exercises the *running* FastAPI server (no mocks, no synthetic
data, no stubbed retrieval) against an actual surveyor PDF that is already
ingested into the tenant's FAISS index. It:
1. Creates a fresh report on an existing tenant + ingested document so we
evaluate the current code path end-to-end.
2. Triggers ``POST /reports/{rid}/generate`` for a representative slice of
RICS sections (mix of intro, fabric, services, risks).
3. Polls ``/reports/{rid}/sections`` until the generations land in the DB.
4. Re-runs ``retrieve_tenant_evidence`` with the *same* tier-aware fan-out
the inspector loop uses, so ``contexts`` reflects what the LLM actually
saw.
5. Extracts ground-truth section text from the source PDF using a
heading-aware splitter (regex on RICS section codes).
6. Feeds (question, contexts, answer, ground_truth) into RAGAS using
``faithfulness``, ``answer_relevancy``, ``context_precision`` and
``context_recall``.
7. Saves per-section scores + aggregates as JSON under ``eval_runs/``.
Run with:
python scripts/ragas_eval.py
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import re
import sys
import time
import uuid
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import httpx # noqa: E402
from app.agentic.tools import retrieve_tenant_evidence # noqa: E402
from app.config import settings # noqa: E402
from app.templates.registry import get_template # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ragas_eval")
# Defaults (override via CLI flags; see `--help`)
DEFAULT_TENANT_ID = os.environ.get("RAGAS_TENANT_ID", "tenant_pwto0nrd")
DEFAULT_SOURCE_DOCUMENT_ID = os.environ.get("RAGAS_DOCUMENT_ID", "7b6379ea-fbeb-439a-a65f-e1a48df2b900")
DEFAULT_SOURCE_PDF_NAME = os.environ.get(
"RAGAS_SOURCE_PDF_NAME",
"Building Survey - 37 Elms Crescent London SW4 8QE.pdf",
)
DEFAULT_SURVEY_LEVEL = int(os.environ.get("RAGAS_SURVEY_LEVEL", "3") or "3")
API_BASE = os.environ.get("RAGAS_API_BASE", "http://127.0.0.1:8000")
# A representative cross-section β€” enough to surface real quality issues
# without burning $50 of OpenAI credit. Picks deliberately span:
# β€’ Narrative / non-rated (A, D)
# β€’ External fabric (E2 Roof, E4 Main walls)
# β€’ Internal fabric (F1 Roof structure, F4 Floors)
# β€’ Services (G6 Drainage)
# β€’ Risk (J1 Risks to the building)
EVAL_SECTIONS: list[str] = ["A", "D", "E2", "E4", "F1", "F4", "G6", "J1"]
# ─────────────────────────────────────────────────────────────────────────────
# Source-PDF section splitter (ground truth extraction)
# ─────────────────────────────────────────────────────────────────────────────
_RICS_HEADING_RE = re.compile(
r"""
(?:^|\n)\s* # line start
(?P<code>(?:E|F|G|H|I|J|K)\s?\d{1,2}|[A-L]) # E2 / F4 / J / D
\s+ # required whitespace
(?P<title>[A-Z][A-Za-z][A-Za-z\s,'’/&\-]{3,80}) # human title
(?:\n|\s*$) # ends at newline
""",
re.VERBOSE | re.MULTILINE,
)
def extract_pdf_text(pdf_path: Path) -> str:
"""Return raw text from the entire PDF (line-preserving)."""
import pypdf
reader = pypdf.PdfReader(str(pdf_path))
pages: list[str] = []
for p in reader.pages:
try:
pages.append(p.extract_text() or "")
except Exception: # noqa: BLE001
pages.append("")
return "\n".join(pages)
def split_pdf_into_sections(full_text: str) -> dict[str, str]:
"""Heading-aware split of full PDF text into ``{code: section_text}``.
The regex is generous (matches any ``[A-L]`` or ``[EFGHIJK]\\d{1,2}``)
so a non-RICS heading like "B7 Crescent Road" can produce noise, but
the eval tolerates this β€” we just want the text under each canonical
section heading. Section text spans up to the *next* matched heading.
"""
matches = list(_RICS_HEADING_RE.finditer(full_text))
if not matches:
return {}
sections: dict[str, str] = {}
for i, m in enumerate(matches):
code = m.group("code").replace(" ", "")
# Filter junk codes that are clearly not RICS sections
if code not in {"A", "B", "C", "D", "L"} and not re.match(r"^[EFGHIJK]\d+$", code):
continue
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(full_text)
body = full_text[start:end].strip()
# If we already grabbed this code, only keep the longest occurrence β€”
# the source PDF tends to put the most detail in the body, not in
# the contents table at the front.
if code in sections and len(body) <= len(sections[code]):
continue
sections[code] = body
return sections
# ─────────────────────────────────────────────────────────────────────────────
# Bullet extraction (input to /generate) β€” taken from the actual PDF section
# ─────────────────────────────────────────────────────────────────────────────
def bullets_from_section_text(section_text: str, max_bullets: int = 14) -> list[str]:
"""Split a PDF section block into bullet-sized lines.
Aims to mimic what a surveyor would paste into the notes box. Drops
trivial 1-2 token lines (page numbers, photo callouts) and clamps to
``max_bullets`` so the LLM call cost stays bounded.
"""
out: list[str] = []
for raw in section_text.splitlines():
line = raw.strip()
if not line:
continue
if line.lower().startswith("photo -") or re.fullmatch(r"\d{1,3}", line):
continue
if len(line.split()) < 3:
continue
# Light de-duplication β€” surveyor PDFs sometimes repeat headings.
if line in out:
continue
out.append(line)
if len(out) >= max_bullets:
break
return out
# ─────────────────────────────────────────────────────────────────────────────
# HTTP helpers (talk to the live FastAPI server)
# ─────────────────────────────────────────────────────────────────────────────
def create_report(client: httpx.Client, *, tenant_id: str, source_document_id: str, survey_level: int) -> str:
"""Create a fresh report against the pinned ingested document."""
r = client.post(
f"{API_BASE}/reports",
params={
"document_id": source_document_id,
"survey_level": survey_level,
"confirm_tier_mismatch": "true",
},
headers={"X-Tenant-ID": tenant_id},
)
r.raise_for_status()
rid = r.json()["report_id"]
log.info("created report %s on tenant=%s doc=%s", rid, tenant_id, source_document_id)
return rid
def trigger_generate(
client: httpx.Client,
*,
tenant_id: str,
report_id: str,
code: str,
bullets: list[str],
) -> None:
body = {
"template_id": code,
"bullets": bullets,
"mode": "generate",
"ai_level": 3,
"ai_percent": 50,
"force_regenerate": True,
}
r = client.post(
f"{API_BASE}/reports/{report_id}/generate",
json=body,
headers={"X-Tenant-ID": tenant_id},
timeout=30,
)
r.raise_for_status()
def poll_section(
client: httpx.Client,
*,
tenant_id: str,
report_id: str,
code: str,
timeout_s: int = 600,
interval_s: float = 10.0,
) -> dict[str, Any] | None:
"""Wait until ``report_sections`` row for ``code`` exists and is non-empty.
Generations can take 60–180 s each because the inspector tool loop runs
up to 32 rounds and the OpenAI call inside the loop is synchronous (it
blocks the FastAPI event loop), so we use a long timeout + a long
interval to avoid hammering the server while it is busy with the LLM.
"""
deadline = time.time() + timeout_s
last_text_len = 0
while time.time() < deadline:
try:
r = client.get(
f"{API_BASE}/reports/{report_id}/sections",
headers={"X-Tenant-ID": tenant_id},
timeout=httpx.Timeout(connect=10, read=120, write=30, pool=10),
)
r.raise_for_status()
data = r.json()
except httpx.ReadTimeout:
# Likely the event loop is still blocked by an active LLM round.
# Back off and retry β€” the section may already be persisted by
# the time the next request gets through.
log.info("[%s] /sections read-timeout, backing off", code)
time.sleep(interval_s)
continue
sections_map = data.get("sections") or {}
s = sections_map.get(code)
if isinstance(s, dict):
text = (s.get("text") or "").strip()
if text and len(text) > 80:
return s
last_text_len = len(text)
time.sleep(interval_s)
log.warning("section %s did not finish β€” last text len = %d", code, last_text_len)
return None
# ─────────────────────────────────────────────────────────────────────────────
# Re-run the inspector's seed retrieval to capture ``contexts`` honestly
# ─────────────────────────────────────────────────────────────────────────────
def capture_contexts(
*,
tenant_id: str,
source_document_id: str,
query: str,
level: int,
) -> list[str]:
"""Mirror the tier-aware seed retrieval the inspector loop uses.
inspector_loop.py opens with:
L1 β†’ k=12, rerank_top_n=5
L2 β†’ k=18, rerank_top_n=8
L3 β†’ k=24, rerank_top_n=10
We deliberately use the same fan-out so the captured ``contexts`` is
representative of what the LLM actually had at draft time.
"""
if level <= 1:
k, rerank = 12, 5
elif level == 2:
k, rerank = 18, 8
else:
k, rerank = 24, 10
hits = retrieve_tenant_evidence(
query=query,
tenant_id=tenant_id,
primary_document_id=source_document_id,
secondary_document_ids=None,
k=k,
rerank_top_n=rerank,
)
return [h.text for h in hits if h and h.text]
# ─────────────────────────────────────────────────────────────────────────────
# Main eval orchestration
# ─────────────────────────────────────────────────────────────────────────────
def build_query(code: str, level: int, bullets: list[str]) -> str:
"""Compose a deterministic ``question`` for RAGAS.
Strives to match how a surveyor actually phrases the section ask, so
answer_relevancy + context_precision are scored on a realistic prompt
rather than the raw RAG snippets.
"""
tpl = get_template(code, level)
title = tpl.title if tpl else code
head = bullets[0] if bullets else ""
return (
f"Write the {title} ({code}) section of a RICS Level {level} Building "
f"Survey for the property in the source notes. {head}"
).strip()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=len(EVAL_SECTIONS), help="cap eval to first N sections")
parser.add_argument("--out", type=str, default=None)
parser.add_argument("--tenant-id", type=str, default=DEFAULT_TENANT_ID)
parser.add_argument("--document-id", type=str, default=DEFAULT_SOURCE_DOCUMENT_ID)
parser.add_argument("--survey-level", type=int, default=DEFAULT_SURVEY_LEVEL, choices=[1, 2, 3])
parser.add_argument(
"--pdf-path",
type=str,
default=None,
help=(
"Optional path to the source PDF on disk to extract ground-truth references. "
"If omitted (or missing), the run still produces answer_relevancy + context_precision "
"(and skips faithfulness/context_recall which require reference text)."
),
)
parser.add_argument(
"--sections",
type=str,
default=",".join(EVAL_SECTIONS),
help="Comma-separated section codes to evaluate (default: built-in representative set).",
)
args = parser.parse_args()
if not settings.openai_api_key:
log.error("OPENAI_API_KEY is not set β€” RAGAS judge needs it. aborting.")
return 2
eval_codes = [c.strip().upper() for c in str(args.sections).split(",") if c.strip()]
eval_codes = eval_codes[: args.limit]
log.info("evaluating %d sections: %s", len(eval_codes), ", ".join(eval_codes))
tenant_id = str(args.tenant_id)
source_document_id = str(args.document_id)
survey_level = int(args.survey_level)
pdf_sections: dict[str, str] = {}
pdf_path: Path | None = None
if args.pdf_path:
pdf_path = Path(args.pdf_path)
else:
# Backwards-compatible default: expects a local Behrang corpus checkout.
pdf_path = ROOT / "Behrang RICS Documents" / DEFAULT_SOURCE_PDF_NAME
have_reference = bool(pdf_path and pdf_path.is_file())
if have_reference:
full_text = extract_pdf_text(pdf_path) # type: ignore[arg-type]
pdf_sections = split_pdf_into_sections(full_text)
log.info(
"extracted %d ground-truth sections from PDF (%d chars total)",
len(pdf_sections),
len(full_text),
)
log.info("ground-truth coverage for eval set:")
for code in eval_codes:
gt = pdf_sections.get(code, "")
log.info(" %-4s : %d chars", code, len(gt))
else:
log.warning(
"source PDF not found; running without ground-truth reference (will score only answer_relevancy + context_precision). "
"missing: %s",
pdf_path,
)
rows: list[dict[str, Any]] = []
with httpx.Client(timeout=httpx.Timeout(connect=10, read=120, write=30, pool=10)) as client:
report_id = create_report(
client,
tenant_id=tenant_id,
source_document_id=source_document_id,
survey_level=survey_level,
)
for code in eval_codes:
gt = pdf_sections.get(code, "").strip()
bullets = bullets_from_section_text(gt) if gt else []
if not bullets:
# fall back to template skeleton bullets so we still hit the
# endpoint with realistic context β€” happens for narrative
# sections like A and L where the heading detector misses.
tpl = get_template(code, survey_level)
bullets = [
f"This is the {tpl.title} section." if tpl else f"Section {code}.",
]
log.info("[%s] %d bullets, %d chars ground truth", code, len(bullets), len(gt))
trigger_generate(client, tenant_id=tenant_id, report_id=report_id, code=code, bullets=bullets)
section = poll_section(client, tenant_id=tenant_id, report_id=report_id, code=code, timeout_s=300)
if not section:
log.warning("[%s] generation never landed; skipping", code)
continue
answer = (section.get("text") or "").strip()
query = build_query(code, survey_level, bullets)
contexts = capture_contexts(
tenant_id=tenant_id,
source_document_id=source_document_id,
query=query,
level=survey_level,
)
log.info("[%s] answer=%d chars, contexts=%d", code, len(answer), len(contexts))
rows.append({
"section_code": code,
"user_input": query,
"retrieved_contexts": contexts,
"response": answer,
"reference": gt,
"bullets": bullets,
"provenance": section.get("provenance") or {},
})
if not rows:
log.error("no sections evaluated β€” aborting before RAGAS")
return 3
out_path = Path(args.out) if args.out else (
ROOT / "eval_runs" / f"ragas_run_{int(time.time())}.json"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
raw_path = out_path.with_suffix(".raw.json")
with raw_path.open("w", encoding="utf-8") as f:
json.dump(
{
"meta": {
"tenant_id": tenant_id,
"report_id": report_id,
"doc_id": source_document_id,
"pdf_path": str(pdf_path) if pdf_path else None,
"level": survey_level,
"api_base": API_BASE,
},
"rows": rows,
},
f,
indent=2,
ensure_ascii=False,
)
log.info("saved raw eval set: %s", raw_path)
# ── RAGAS scoring ───────────────────────────────────────────────────────
log.info("running RAGAS metrics …")
from datasets import Dataset
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from ragas import evaluate
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from ragas.metrics import (
answer_relevancy,
context_precision,
context_recall,
faithfulness,
)
ds_rows = [
{
"user_input": r["user_input"],
"retrieved_contexts": r["retrieved_contexts"],
"response": r["response"],
"reference": r["reference"],
}
for r in rows
if r.get("retrieved_contexts") and r.get("response")
]
ds = Dataset.from_list(ds_rows)
log.info("dataset rows: %d", len(ds))
if len(ds) == 0:
log.error("dataset is empty (no ground truth) β€” aborting RAGAS")
return 4
judge_llm = LangchainLLMWrapper(ChatOpenAI(
model="gpt-4o-mini", temperature=0.0, api_key=settings.openai_api_key
))
judge_emb = LangchainEmbeddingsWrapper(OpenAIEmbeddings(
model="text-embedding-3-small", api_key=settings.openai_api_key
))
metrics = [answer_relevancy, context_precision]
if any(r.get("reference") for r in ds_rows):
metrics = [faithfulness, answer_relevancy, context_precision, context_recall]
else:
log.warning("no reference text available; skipping faithfulness + context_recall")
result = evaluate(
dataset=ds,
metrics=metrics,
llm=judge_llm,
embeddings=judge_emb,
)
log.info("RAGAS aggregate: %s", result)
df = result.to_pandas()
summary = {
"aggregate": {col: float(df[col].mean()) for col in df.columns if df[col].dtype.kind in "fi"},
"per_section": [],
}
code_iter = iter([r["section_code"] for r in rows if r["reference"]])
for _, row in df.iterrows():
scores = {col: (float(row[col]) if isinstance(row[col], (int, float)) else None)
for col in df.columns if col not in ("user_input", "retrieved_contexts", "response", "reference")}
summary["per_section"].append({
"section_code": next(code_iter, "?"),
"scores": scores,
})
with out_path.open("w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
log.info("RAGAS scored output saved: %s", out_path)
print()
print("=" * 72)
print("RAGAS aggregate scores")
print("=" * 72)
for k, v in summary["aggregate"].items():
print(f" {k:24s} : {v:.3f}")
print()
print("Per-section:")
for entry in summary["per_section"]:
sc = entry["scores"]
line = f" {entry['section_code']:5s} " + " ".join(
f"{k}={v:.2f}" for k, v in sc.items() if v is not None
)
print(line)
return 0
if __name__ == "__main__":
sys.exit(main())