"""Approach 2: per-section LLM coverage judge (notes vs generated prose).""" from __future__ import annotations import logging from backend.evaluation.judge_llm import ( call_judge_parse, is_available, resolved_max_tokens, resolved_model, resolved_provider, resolved_reasoning_effort, unavailable_reason, ) from backend.evaluation.models import ( CoverageJudgeResponse, NoteFactJudgment, SectionEvalInput, SectionEvaluation, ) from backend.evaluation.prompts import build_coverage_messages, prompt_record logger = logging.getLogger(__name__) _VALID_STATUS = frozenset({"covered", "missing", "partial"}) def _normalize_judgments( observations: list[str], raw: CoverageJudgeResponse, ) -> tuple[list[NoteFactJudgment], list[str], int]: """Map structured judge output onto the input notes; score omitted notes missing.""" by_note: dict[str, NoteFactJudgment] = {} for item in raw.judgments: note = (item.note or "").strip() status = (item.status or "").strip().lower() if not note or status not in _VALID_STATUS: continue by_note[note.lower()] = NoteFactJudgment( note=note, status=status, # type: ignore[arg-type] evidence=(item.evidence or "").strip(), ) missing_listed = { str(f).strip().lower() for f in (raw.missing_facts or []) if str(f).strip() } judgments: list[NoteFactJudgment] = [] omitted = 0 for obs in observations: key = obs.strip() if not key: continue existing = by_note.get(key.lower()) if existing is not None: judgments.append(existing) continue omitted += 1 judgments.append( NoteFactJudgment( note=key, status="missing", evidence="omitted_by_judge" if key.lower() not in missing_listed else "", ) ) missing_facts = [j.note for j in judgments if j.status in ("missing", "partial")] return judgments, missing_facts, omitted def _rollup_coverage( judgments: list[NoteFactJudgment], ) -> tuple[int, int, int, float | None]: covered = sum(1 for j in judgments if j.status == "covered") missing = sum(1 for j in judgments if j.status == "missing") partial = sum(1 for j in judgments if j.status == "partial") total = len(judgments) rate = (covered + 0.5 * partial) / total if total else None return covered, missing, partial, rate def _coverage_prompt_payload(messages: list[dict[str, str]]) -> dict: effort = resolved_reasoning_effort() return { "coverage": prompt_record( messages, model=resolved_model(), reasoning_effort=effort, max_tokens=resolved_max_tokens(effort), provider=resolved_provider(), ) } async def score_section_coverage(inp: SectionEvalInput) -> SectionEvaluation: """Run Approach 2 coverage judge for one section.""" observations = [o.strip() for o in (inp.observations or []) if o and o.strip()] base = SectionEvaluation( section_id=inp.section_id, title=inp.title, observations=observations, generated_text=inp.generated_text or "", baseline_text=inp.baseline_text or "", ) if not observations: return base if not (inp.generated_text or "").strip(): judgments = [ NoteFactJudgment(note=o, status="missing", evidence="empty generated text") for o in observations ] covered, missing, partial, rate = _rollup_coverage(judgments) base.note_judgments = judgments base.covered_count = covered base.missing_count = missing base.partial_count = partial base.coverage_rate = rate base.missing_facts = [j.note for j in judgments] return base if not is_available(): base.error = unavailable_reason() return base messages = build_coverage_messages( section_id=inp.section_id, title=inp.title, observations=observations, generated_text=inp.generated_text, ) base.prompt = _coverage_prompt_payload(messages) try: parsed = await call_judge_parse( messages, response_format=CoverageJudgeResponse, call_label="evaluation_coverage", section_id=inp.section_id, ) except Exception as exc: # noqa: BLE001 logger.warning( "evaluation_coverage_failed section=%s err=%s", inp.section_id, exc, ) base.error = str(exc) return base if parsed is None: base.error = "empty_judge_response" return base if not parsed.judgments: base.error = "empty_judge_response" return base judgments, missing_facts, omitted = _normalize_judgments(observations, parsed) if omitted: base.error = f"judge_omitted_{omitted}_of_{len(observations)}_notes" covered, missing, partial, rate = _rollup_coverage(judgments) base.note_judgments = judgments base.covered_count = covered base.missing_count = missing base.partial_count = partial base.coverage_rate = rate base.missing_facts = missing_facts return base