grantforge-api / backend /core /generation /quality_loop.py
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
35.2 kB
"""
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,
}