""" Document quality loop: holistic/panel findings → targeted multi-section rewrite. Replaces the old nuclear strategy (wipe all sections and regenerate from zero) with keep-good / rewrite-weak passes for higher pass rates before PDF/DOCX export. """ from __future__ import annotations import difflib import logging import os import re from typing import Any, Dict, List, Optional, Sequence, Tuple logger = logging.getLogger(__name__) DEFAULT_MIN_KEEP_SCORE = int(os.environ.get("QUALITY_LOOP_MIN_SECTION_CHARS", "120")) MAX_SECTIONS_PER_PASS = int(os.environ.get("QUALITY_LOOP_MAX_SECTIONS_PER_PASS", "8")) def _norm(s: str) -> str: return re.sub(r"\s+", " ", (s or "").lower().strip()) def section_title_match(label: str, candidates: Sequence[str], threshold: float = 0.45) -> Optional[str]: """Fuzzy-map free-text section label onto plan titles.""" if not label or not candidates: return None nl = _norm(label) if nl in ("ogólne", "ogolne", "całość", "calosc", "all", "general", "wniosek", "dokument"): return None best: Tuple[float, Optional[str]] = (0.0, None) for c in candidates: nc = _norm(c) if not nc: continue if nl in nc or nc in nl: return c r = difflib.SequenceMatcher(None, nl, nc).ratio() if r > best[0]: best = (r, c) return best[1] if best[0] >= threshold else None def extract_section_targets_from_holistic( report: Any, plan_titles: Sequence[str], ) -> Dict[str, List[str]]: """ Build map title -> list of fix instructions from holistic report. """ targets: Dict[str, List[str]] = {t: [] for t in plan_titles} global_notes: List[str] = [] recs = [] if hasattr(report, "key_recommendations"): recs = list(report.key_recommendations or []) elif isinstance(report, dict): recs = list(report.get("key_recommendations") or []) for rec in recs: rec_s = str(rec).strip() if not rec_s: continue matched = section_title_match(rec_s, plan_titles) # Try to find section name mentioned in recommendation if not matched: for t in plan_titles: if _norm(t) in _norm(rec_s): matched = t break if matched: targets.setdefault(matched, []).append(rec_s) else: global_notes.append(rec_s) # Category feedback → map to typical sections cat_map = { "budget_consistency": ("budżet", "harmonogram", "koszt", "finans"), "logical_flow": ("streszczenie", "opis", "cel", "logika"), "program_alignment": ("innowacyj", "uzasadn", "dopasow", "program"), "dnsh_assessment": ("środowisk", "dnsh", "zrównoważ", "klimat"), } for cat_name, keywords in cat_map.items(): cat = getattr(report, cat_name, None) if not isinstance(report, dict) else report.get(cat_name) feedback = "" flags: List[str] = [] score = 100 if cat is not None: if hasattr(cat, "feedback"): feedback = cat.feedback or "" flags = list(getattr(cat, "inconsistencies_flagged", None) or []) score = int(getattr(cat, "score", 100) or 100) elif isinstance(cat, dict): feedback = cat.get("feedback") or "" flags = list(cat.get("inconsistencies_flagged") or []) score = int(cat.get("score", 100) or 100) if score >= 70 and not flags: continue note = feedback if flags: note = (note + " " if note else "") + "; ".join(flags[:5]) if not note: continue hit = False for t in plan_titles: nt = _norm(t) if any(k in nt for k in keywords): targets.setdefault(t, []).append(f"[{cat_name}] {note}") hit = True if not hit: global_notes.append(f"[{cat_name}] {note}") # Distribute global notes to weakest sections (shortest content heuristic later) if global_notes: for t in plan_titles: targets.setdefault(t, []).extend(global_notes[:3]) # Drop empty return {k: v for k, v in targets.items() if v} def extract_section_targets_from_panel_issues( issues: Sequence[Any], plan_titles: Sequence[str], ) -> Dict[str, List[str]]: targets: Dict[str, List[str]] = {} for issue in issues or []: if isinstance(issue, dict): affected = str(issue.get("affected_section") or "Ogólne") msg = str(issue.get("message") or "") rec = str(issue.get("recommendation") or "") sev = str(issue.get("severity") or "?") line = f"[{sev}] {msg}" + (f" | Rek: {rec}" if rec else "") else: affected = str(getattr(issue, "affected_section", "Ogólne") or "Ogólne") msg = str(getattr(issue, "message", "") or "") rec = str(getattr(issue, "recommendation", "") or "") sev = str(getattr(issue, "severity", "?") or "?") line = f"[{sev}] {msg}" + (f" | Rek: {rec}" if rec else "") matched = section_title_match(affected, plan_titles) if not matched: # general → all titles get a light note (limited later) for t in plan_titles: targets.setdefault(t, []).append(line) else: targets.setdefault(matched, []).append(line) return targets def extract_section_targets_from_compliance_checklist( checklist: Any, plan_titles: Sequence[str], ) -> Dict[str, List[str]]: """Map compliance_checklist.missing_sections onto plan titles (P0 priority).""" targets: Dict[str, List[str]] = {} if not checklist: return targets if isinstance(checklist, dict): missing = list(checklist.get("missing_sections") or []) coverage = checklist.get("coverage_score") else: missing = list(getattr(checklist, "missing_sections", None) or []) coverage = getattr(checklist, "coverage_score", None) for raw in missing: label = str(raw or "").strip() if not label: continue # Strip " (pusta treść)" suffix used by regulation_checklist clean = re.sub(r"\s*\(pusta tre[sś][cć]\)\s*$", "", label, flags=re.I).strip() matched = section_title_match(clean, plan_titles) or section_title_match(label, plan_titles) note = ( f"[compliance_checklist] Brakuje wymaganej sekcji regulaminu: {label}. " f"Uzupełnij merytorykę i jawne odniesienie do regulaminu programu." ) if coverage is not None: note += f" (coverage={coverage}%)" if matched: targets.setdefault(matched, []).append(note) else: # Unmapped required section → push to first plan title + global note on all short titles for t in plan_titles: targets.setdefault(t, []).append(note) break return targets def extract_section_targets_from_citation_failures( citation_data: Any, plan_titles: Sequence[str], *, min_score: float = 0.72, ) -> Dict[str, List[str]]: """Build rewrite targets from citation / faithfulness failures (P0 priority).""" targets: Dict[str, List[str]] = {} if not citation_data: return targets # Forms: list of {section, overall_score, issues, recommendation} # or dict section->payload, or flat {overall_score, issues, section} items: List[Dict[str, Any]] = [] if isinstance(citation_data, list): items = [c for c in citation_data if isinstance(c, dict)] elif isinstance(citation_data, dict): if any(k in citation_data for k in ("overall_score", "overall_citation_score", "issues", "section")): items = [citation_data] else: for sec, payload in citation_data.items(): if isinstance(payload, dict): items.append({**payload, "section": payload.get("section") or sec}) elif isinstance(payload, (int, float)): items.append({"section": sec, "overall_score": float(payload)}) for item in items: score = item.get("overall_score") if score is None: score = item.get("overall_citation_score") try: score_f = float(score) if score is not None else None except (TypeError, ValueError): score_f = None issues = item.get("issues") or [] quality = str(item.get("quality") or item.get("citation_quality") or "") weak = ( (score_f is not None and score_f < min_score) or quality.lower() in ("poor", "low", "weak", "fail", "failed") or bool(issues) ) if not weak: continue section = str(item.get("section") or item.get("affected_section") or "Ogólne") rec = str(item.get("recommendation") or "").strip() issue_bits: List[str] = [] for iss in (issues if isinstance(issues, list) else [issues])[:4]: if isinstance(iss, list): issue_bits.extend(str(x) for x in iss[:2] if x) elif iss: issue_bits.append(str(iss)) score_txt = f"{score_f:.2f}" if score_f is not None else "?" line = ( f"[citation_failure] Ugruntowanie cytowaniami zbyt niskie (score={score_txt}" f"{', quality=' + quality if quality else ''}). " f"Dodaj twarde odniesienia do regulaminu/snapshotu i usuń nieugruntowane twierdzenia." ) if issue_bits: line += " Problemy: " + "; ".join(issue_bits[:3]) if rec: line += f" | Rek: {rec}" matched = section_title_match(section, plan_titles) if matched: targets.setdefault(matched, []).append(line) else: for t in plan_titles: targets.setdefault(t, []).append(line) return targets def extract_section_targets_from_trap_issues( trap_data: Any, plan_titles: Sequence[str], ) -> Dict[str, List[str]]: """Build rewrite targets from Kruczkowski trap detections (P0 priority).""" targets: Dict[str, List[str]] = {} if not trap_data: return targets traps: List[Any] = [] if isinstance(trap_data, list): traps = list(trap_data) elif isinstance(trap_data, dict): if trap_data.get("detected") is not None: traps = list(trap_data.get("detected") or []) # Also honor high/critical risk as a doc-level signal when no per-trap list risk = str(trap_data.get("risk_level") or trap_data.get("trap_risk") or "").lower() if not traps and risk in ("high", "critical"): traps = [{"type": "trap_risk", "message": f"trap_risk={risk}", "severity": risk}] elif trap_data.get("traps") is not None: traps = list(trap_data.get("traps") or []) else: # section -> payload map for sec, payload in trap_data.items(): if isinstance(payload, dict): detected = payload.get("detected") or payload.get("traps") or [] if isinstance(detected, list): for d in detected: if isinstance(d, dict): traps.append({**d, "section": d.get("section") or sec}) else: traps.append({"message": str(d), "section": sec}) elif payload.get("risk_level") in ("high", "critical", "medium"): traps.append({ "section": sec, "severity": payload.get("risk_level"), "message": f"trap_risk={payload.get('risk_level')}", }) elif isinstance(payload, list): for d in payload: traps.append(d if isinstance(d, dict) else {"message": str(d), "section": sec}) for trap in traps: if isinstance(trap, dict): section = str(trap.get("section") or trap.get("affected_section") or "Ogólne") ttype = str(trap.get("type") or trap.get("trap_type") or trap.get("category") or "trap") msg = str(trap.get("message") or trap.get("description") or trap.get("detail") or ttype) sev = str(trap.get("severity") or trap.get("risk_level") or "medium") rec = str(trap.get("recommendation") or "").strip() else: section = "Ogólne" ttype = "trap" msg = str(trap) sev = "medium" rec = "" line = ( f"[trap:{ttype}|{sev}] {msg}. " f"Usuń niekwalifikowalne koszty/pułapki regulaminowe i jawnie wyklucz ryzyko." ) if rec: line += f" | Rek: {rec}" matched = section_title_match(section, plan_titles) if matched: targets.setdefault(matched, []).append(line) else: for t in plan_titles: targets.setdefault(t, []).append(line) return targets def _merge_target_maps(*maps: Dict[str, List[str]]) -> Dict[str, List[str]]: """Merge title->notes maps preserving order (first maps win priority position).""" out: Dict[str, List[str]] = {} for m in maps: if not m: continue for title, notes in m.items(): bucket = out.setdefault(title, []) for n in notes or []: if n and n not in bucket: bucket.append(n) return out def build_priority_rewrite_targets( plan_titles: Sequence[str], *, compliance_checklist: Any = None, citation_failures: Any = None, trap_issues: Any = None, report: Any = None, panel_issues: Optional[Sequence[Any]] = None, source: str = "holistic", advisor_report: Any = None, ) -> Dict[str, List[str]]: """ P0 target order: advisor brief gaps → compliance → citation → traps → holistic/panel. Compliance/citation/trap notes are placed first so pick_sections_to_fix prioritizes them. """ advisor_targets: Dict[str, List[str]] = {} if advisor_report is not None: try: from agents.world_class_advisor import advisor_findings_to_rewrite_targets advisor_targets = advisor_findings_to_rewrite_targets(advisor_report, plan_titles) except Exception as e: logger.debug("[QualityLoop] advisor targets skipped: %s", e) compliance_targets = extract_section_targets_from_compliance_checklist( compliance_checklist, plan_titles ) citation_targets = extract_section_targets_from_citation_failures( citation_failures, plan_titles ) trap_targets = extract_section_targets_from_trap_issues(trap_issues, plan_titles) secondary: Dict[str, List[str]] = {} if source == "holistic" and report is not None: secondary = extract_section_targets_from_holistic(report, plan_titles) elif panel_issues is not None: secondary = extract_section_targets_from_panel_issues(panel_issues, plan_titles) elif source == "panel" and report is not None: # tolerate report-as-issues misuse secondary = extract_section_targets_from_panel_issues( getattr(report, "issues", None) or [], plan_titles ) return _merge_target_maps( advisor_targets, compliance_targets, citation_targets, trap_targets, secondary, ) def collect_grounding_signals_from_state(state: Dict[str, Any]) -> Dict[str, Any]: """Harvest checklist / citation / trap signals from generator state + external_context.""" ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {} checklist = ( state.get("compliance_checklist") or ext.get("compliance_checklist") or {} ) # Citations: prefer explicit state keys, else aggregate v5_verification from traceability citations = state.get("citation_failures") or state.get("citation_scores") or ext.get("citation_failures") traps = state.get("trap_issues") or ext.get("trap_issues") or ext.get("v5_grounding_certificate") if citations is None or traps is None: trace = state.get("traceability_data") or {} cit_list: List[Dict[str, Any]] = [] trap_map: Dict[str, Any] = {} for section_key, events in (trace.items() if isinstance(trace, dict) else []): if not isinstance(events, list): continue for ev in events: if not isinstance(ev, dict): continue if ev.get("type") != "v5_verification": continue data = ev.get("data") or {} if not isinstance(data, dict): continue sec_name = data.get("section") or section_key cit = data.get("citation") or {} if citations is None and isinstance(cit, dict): cit_list.append({ "section": sec_name, "overall_score": cit.get("overall_score"), "quality": cit.get("quality"), "issues": cit.get("issues") or [], "recommendation": cit.get("recommendation") or "", }) tr = data.get("traps") or {} if traps is None and isinstance(tr, dict): trap_map[str(sec_name)] = tr if citations is None and cit_list: citations = cit_list if traps is None and trap_map: traps = trap_map # Certificate-level trap signal if traps is None: v5c = ext.get("v5_grounding_certificate") or {} if isinstance(v5c, dict) and (v5c.get("trap_risk") or v5c.get("detected")): traps = v5c return { "compliance_checklist": checklist, "citation_failures": citations, "trap_issues": traps, } def pick_sections_to_fix( plan: Sequence[Any], generated: Dict[str, str], targets: Dict[str, List[str]], *, max_sections: int = MAX_SECTIONS_PER_PASS, ) -> List[str]: """Prefer targeted weak sections; if none, pick shortest / incomplete ones.""" titles = [] for s in plan: t = s.get("title") if isinstance(s, dict) else str(s) if t: titles.append(t) ranked: List[Tuple[int, str]] = [] for t in titles: notes = targets.get(t) or [] content = (generated or {}).get(t) or "" incomplete = 1 if ("[UZUPEŁNIĆ" in content or "[DO WERYFIKACJI" in content or len(content) < DEFAULT_MIN_KEEP_SCORE) else 0 priority = len(notes) * 10 + incomplete * 5 + max(0, 500 - len(content)) // 50 if notes or incomplete: ranked.append((priority, t)) ranked.sort(key=lambda x: x[0], reverse=True) chosen = [t for _, t in ranked[:max_sections]] if not chosen and titles: # Always fix at least top-N shortest when critic failed without mapping by_len = sorted(titles, key=lambda t: len((generated or {}).get(t) or "")) chosen = by_len[: min(3, max_sections)] return chosen def rewrite_section_with_feedback( *, title: str, section_type: str, current_content: str, instructions: List[str], program_name: str, project_description: str = "", company_context: str = "", external_context: Optional[dict] = None, regulation_boost: str = "", ) -> str: """LLM rewrite of one section with critic/audit instructions + regulation grounding.""" from agents.helpers import generate_section_light from core.llm_router import get_llm from langchain_core.messages import HumanMessage instr = "\n".join(f"- {i}" for i in (instructions or [])[:12]) if not instr: instr = "- Podnieś merytorykę, spójność z resztą wniosku i zgodność z programem." reg_block = (regulation_boost or "").strip() if reg_block: reg_block = reg_block[:6000] reg_section = f""" Kontekst regulaminowy (ŹRÓDŁO PRAWDY — cytuj reguły, nie wymyślaj): -------------------- {reg_block} -------------------- Wzmocnij ugruntowanie: jawne odniesienia do regulaminu/snapshotu, zero niekwalifikowalnych kosztów. """ else: reg_section = ( "\nBrak snapshotu regulaminu w kontekście — unikaj kategorycznych twierdzeń o " "kwalifikowalności; oznacz niepewne miejsca [DO WERYFIKACJI: regulamin].\n" ) # Prefer focused rewrite when content exists if current_content and len(current_content.strip()) > 80: llm = get_llm(task_type="writing") prompt = f"""Jesteś redaktorem wniosków unijnych. PRZEPISZ sekcję „{title}" tak, aby usunąć wskazane wady. Zachowaj język polski, styl urzędowy, Markdown. NIE wymyślaj faktów spoza kontekstu. Dla braków danych użyj [DO WERYFIKACJI: …] — nie zostawiaj pustych miejsc. Priorytet: checklist regulaminu, cytowania/ugruntowanie, pułapki Kruczkowskiego. Program: {program_name} Opis projektu (skrót): {(project_description or '')[:2500]} Dane firmy / kontekst: {(company_context or '')[:2000]} {reg_section} Wady / instrukcje poprawy: {instr} Obecna treść sekcji: -------------------- {current_content[:12000]} -------------------- Zwróć WYŁĄCZNIE poprawioną treść sekcji (bez preambuły).""" try: resp = llm.invoke([HumanMessage(content=prompt)]) content = resp.content if hasattr(resp, "content") else str(resp) if content and len(content.strip()) > 40: return content.strip() except Exception as e: logger.warning("[QualityLoop] rewrite failed for %s: %s", title, e) # Fallback: generate_section_light with feedback + regulation in context ctx_parts = [project_description or ""] if reg_block: ctx_parts.append(reg_block) ctx_parts.append(f"INSTRUKCJE POPRAWY SEKCJI:\n{instr}") ctx_parts.append(f"Poprzednia treść (do ulepszenia):\n{(current_content or '')[:4000]}") ctx = "\n\n".join(p for p in ctx_parts if p) return generate_section_light( section_type=section_type or title, context=ctx, external_context=external_context or {}, program_name=program_name, light_mode=False, ) def run_quality_expectation_step( state: Dict[str, Any], *, source: str = "holistic", report: Any = None, issues: Optional[Sequence[Any]] = None, regulation_boost: str = "", min_score: int = 70, ) -> Dict[str, Any]: """ One production expectation step: advisor evaluate → if not regulation-ready, apply targeted fixes → re-evaluate. Used by generator quality path. Returns dict with advisor_before/after, expectation ready flags, fixed state. """ from agents.world_class_advisor import evaluate_from_generator_state from core.generation.expectation_loop import ( extract_blockers, is_regulation_ready, report_to_dict, ) before = evaluate_from_generator_state(state) ready = is_regulation_ready(before, min_score=min_score) out: Dict[str, Any] = { "advisor_before": report_to_dict(before), "regulation_ready": ready, "remaining_blockers": extract_blockers(before), "fixed_sections": [], "generated_sections": dict(state.get("generated_sections") or {}), } if ready: out["stop_reason"] = "ready" out["advisor_after"] = out["advisor_before"] return out fixed = apply_targeted_section_fixes( state, source=source, report=report, issues=issues, regulation_boost=regulation_boost, advisor_report=before, ) new_state = {**state, "generated_sections": fixed.get("generated_sections") or state.get("generated_sections")} after = evaluate_from_generator_state(new_state) out["advisor_after"] = report_to_dict(after) out["regulation_ready"] = is_regulation_ready(after, min_score=min_score) out["remaining_blockers"] = extract_blockers(after) out["fixed_sections"] = list(fixed.get("fixed_sections") or []) out["generated_sections"] = fixed.get("generated_sections") or out["generated_sections"] out["targets"] = fixed.get("targets") or {} out["stop_reason"] = "ready" if out["regulation_ready"] else "needs_retry" return out def apply_targeted_section_fixes( state: Dict[str, Any], *, source: str, report: Any = None, issues: Optional[Sequence[Any]] = None, regulation_boost: str = "", advisor_report: Any = None, ) -> Dict[str, Any]: """ Returns updated generated_sections (partial rewrite) + metadata for telemetry. Rewrite targets are built in P0 order: world-class advisor → compliance → citation → traps → holistic/panel. regulation_boost (if provided or buildable) is injected into each section rewrite. """ plan = state.get("sections_plan") or [] generated = dict(state.get("generated_sections") or {}) titles = [] type_by_title: Dict[str, str] = {} for s in plan: if isinstance(s, dict): t = s.get("title") or s.get("type") or "" titles.append(t) type_by_title[t] = s.get("type") or t else: titles.append(str(s)) type_by_title[str(s)] = str(s) signals = collect_grounding_signals_from_state(state) if advisor_report is None: # Only auto-run world-class advisor when regulation brief signals exist — # avoid rewriting all sections solely for empty-brief noise. ext0 = state.get("external_context") if isinstance(state.get("external_context"), dict) else {} has_brief_signals = bool( ext0.get("advisor_brief") or ext0.get("required_sections") or ext0.get("regulation_key_rules") or ext0.get("key_rules") or ext0.get("attention_points") ) if has_brief_signals: try: from agents.world_class_advisor import evaluate_from_generator_state advisor_report = evaluate_from_generator_state(state) except Exception as e: logger.debug("[QualityLoop] advisor evaluate skipped: %s", e) advisor_report = None targets = build_priority_rewrite_targets( titles, compliance_checklist=signals.get("compliance_checklist"), citation_failures=signals.get("citation_failures"), trap_issues=signals.get("trap_issues"), report=report, panel_issues=issues, source=source, advisor_report=advisor_report, ) to_fix = pick_sections_to_fix(plan, generated, targets) program = state.get("document_type") or "wniosek dotacyjny" project_desc = state.get("project_description") or "" company_ctx = state.get("additional_context") or "" ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {} boost = (regulation_boost or state.get("regulation_boost") or "").strip() if not boost: # Lightweight inline boost from external_context (caller may pass full agent boost) snap_id = ext.get("regulation_snapshot_id") rules = ext.get("regulation_key_rules") or ext.get("key_rules") or [] if rules: boost = ( "[REGULATION SNAPSHOT v5.0 - QUALITY LOOP]:\n" + "\n".join(f"- {r}" for r in list(rules)[:8]) ) elif snap_id: boost = f"[REGULATION SNAPSHOT v5.0 - id={snap_id}]\nUżyj reguł z przypisanego snapshotu regulaminu." elif ext.get("required_sections"): boost = ( "[REGULATION CONTEXT - required_sections]:\n" + "\n".join(f"- Wymagana sekcja: {s}" for s in list(ext.get("required_sections") or [])[:12]) ) fixed: List[str] = [] for title in to_fix: notes = targets.get(title) or ["Podnieś jakość i spójność z całym wnioskiem."] new_text = rewrite_section_with_feedback( title=title, section_type=type_by_title.get(title, title), current_content=generated.get(title) or "", instructions=notes, program_name=program, project_description=project_desc, company_context=company_ctx, external_context=ext, regulation_boost=boost, ) if new_text and len(new_text.strip()) > 40: generated[title] = new_text.strip() fixed.append(title) logger.info("[QualityLoop] rewritten section '%s' (%s notes)", title, len(notes)) return { "generated_sections": generated, "fixed_sections": fixed, "targets": {k: v[:5] for k, v in targets.items() if k in to_fix}, "source": source, "regulation_boost_used": bool(boost), "priority_signals": { "checklist_missing": len( (signals.get("compliance_checklist") or {}).get("missing_sections") or [] ) if isinstance(signals.get("compliance_checklist"), dict) else 0, "citation_items": len(signals.get("citation_failures") or []) if isinstance(signals.get("citation_failures"), list) else (1 if signals.get("citation_failures") else 0), "trap_items": ( len((signals.get("trap_issues") or {}).get("detected") or []) if isinstance(signals.get("trap_issues"), dict) else len(signals.get("trap_issues") or []) if isinstance(signals.get("trap_issues"), list) else 0 ), }, } def score_document_readiness( generated: Dict[str, str], plan: Sequence[Any], *, compliance_checklist: Any = None, citation_scores: Any = None, trap_issues: Any = None, regulation_context_present: Optional[bool] = None, external_context: Optional[dict] = None, ) -> Dict[str, Any]: """Heuristic readiness 0-100 for export soft-gate decisions, with grounding fields.""" incomplete = [] ok = 0 for s in plan or []: t = s.get("title") if isinstance(s, dict) else str(s) c = (generated or {}).get(t) or "" if len(c) < DEFAULT_MIN_KEEP_SCORE or "[UZUPEŁNIĆ" in c: incomplete.append(t) else: ok += 1 total = len(plan or []) score = int(round(100 * ok / max(total, 1))) if total else 0 ext = external_context if isinstance(external_context, dict) else {} checklist = compliance_checklist if compliance_checklist is not None else ext.get("compliance_checklist") coverage: Optional[int] = None missing_sections: List[str] = [] if isinstance(checklist, dict): if checklist.get("coverage_score") is not None: try: coverage = int(checklist.get("coverage_score")) except (TypeError, ValueError): coverage = None missing_sections = list(checklist.get("missing_sections") or []) elif checklist is not None: coverage = getattr(checklist, "coverage_score", None) missing_sections = list(getattr(checklist, "missing_sections", None) or []) # Aggregate citation mean cit_raw = citation_scores if citation_scores is not None else ext.get("citation_failures") citation_values: List[float] = [] if isinstance(cit_raw, list): for item in cit_raw: if isinstance(item, dict): sc = item.get("overall_score", item.get("overall_citation_score")) if sc is not None: try: citation_values.append(float(sc)) except (TypeError, ValueError): pass elif isinstance(item, (int, float)): citation_values.append(float(item)) elif isinstance(cit_raw, dict): if "overall_score" in cit_raw or "overall_citation_score" in cit_raw: sc = cit_raw.get("overall_score", cit_raw.get("overall_citation_score")) try: citation_values.append(float(sc)) except (TypeError, ValueError): pass else: for v in cit_raw.values(): if isinstance(v, dict): sc = v.get("overall_score", v.get("overall_citation_score")) if sc is not None: try: citation_values.append(float(sc)) except (TypeError, ValueError): pass elif isinstance(v, (int, float)): citation_values.append(float(v)) citation_mean = ( round(sum(citation_values) / len(citation_values), 3) if citation_values else None ) citation_ok = citation_mean is not None and citation_mean >= 0.72 traps = trap_issues if trap_issues is not None else ( ext.get("trap_issues") or ext.get("v5_grounding_certificate") ) trap_risk = "unknown" trap_count = 0 if isinstance(traps, dict): trap_risk = str(traps.get("risk_level") or traps.get("trap_risk") or "unknown") detected = traps.get("detected") or traps.get("traps") or [] trap_count = len(detected) if isinstance(detected, list) else 0 elif isinstance(traps, list): trap_count = len(traps) trap_risk = "medium" if trap_count else "low" if regulation_context_present is None: # Infer from external_context / generated content markers if ext.get("regulation_snapshot_id") or ext.get("required_sections"): regulation_context_present = True else: blob = " ".join((generated or {}).values())[:8000] regulation_context_present = ( "[REGULATION SNAPSHOT" in blob or "Kontekst regulaminowy" in blob or bool(ext.get("v5_grounding_certificate")) ) checklist_ok = coverage is None or coverage >= 70 grounding_ok = bool(regulation_context_present) and checklist_ok and ( citation_mean is None or citation_ok ) and trap_risk not in ("high", "critical") return { "score": score, "complete_sections": ok, "total": total, "incomplete": incomplete, # Grounding fields (P0 export soft-gate) "regulation_context_present": bool(regulation_context_present), "checklist_coverage": coverage, "checklist_ok": checklist_ok, "missing_sections": missing_sections[:12], "citation_mean": citation_mean, "citation_ok": citation_ok if citation_mean is not None else None, "trap_risk": trap_risk, "trap_count": trap_count, "grounding_ok": grounding_ok, }