Spaces:
Runtime error
Runtime error
| """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()) | |