RICS / backend /core /compat_adapter.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
8.31 kB
"""Map v2 generation results to legacy SectionPayload JSON for the UI."""
from __future__ import annotations
from backend.models.report import GeneratedSection, ReferenceSource, ReportResult
from backend.models.schema import TemplateSchema
_COMPOSITION_NOTES = {
"minimum": "Mapped from past report baseline with in-place fact updates (baseline preservation).",
"medium": "Technical in-place edit on past report baseline with proofread updates.",
"maximum": "Full narrative in-place edit retaining long-form baseline scaffolding.",
}
# Section statuses whose text is a system placeholder, not survey prose.
_UNPOLISHED_STATUSES = frozenset({"NO_RAG_MATCH", "UNASSIGNED"})
def _polish_section_text(text: str, status: str) -> str:
"""Final cleanup pass on a section's draft text (config-gated, non-destructive)."""
from backend.config import settings
if not getattr(settings, "postprocess_enabled", True):
return text or ""
if status in _UNPOLISHED_STATUSES or not text or not text.strip():
return text or ""
from backend.utils.report_postprocessor import polish_report
try:
return polish_report(text)
except ValueError:
return text
def _style_payload(style_profile: object | None) -> dict | None:
if style_profile is None:
return None
if hasattr(style_profile, "to_payload"):
return style_profile.to_payload() # type: ignore[union-attr]
if isinstance(style_profile, dict):
return style_profile
return None
def interference_for_mode(mode: str, interference_level: str | None) -> str:
il = (interference_level or "medium").strip().lower()
if il in _COMPOSITION_NOTES:
return il
if mode == "enhance":
return "maximum"
if mode == "proofread":
return "medium"
return "minimum"
def bullets_to_raw_notes(
template_id: str,
bullets: list[str],
bullets_by_section: dict[str, list[str]] | None = None,
) -> str:
lines: list[str] = []
if bullets_by_section:
for code, items in bullets_by_section.items():
for item in items:
if item.strip():
lines.append(f"{code}: {item.strip()}")
elif bullets:
for item in bullets:
if item.strip():
lines.append(f"{template_id}: {item.strip()}")
return "\n\n".join(lines)
def _paragraph_index_from_chunk_id(chunk_id: str) -> int:
if ":p" not in chunk_id:
return 0
try:
return int(chunk_id.rsplit(":p", 1)[-1])
except ValueError:
return 0
def reference_sources_from_payload(
payload: dict,
schema: TemplateSchema | None = None,
) -> list[ReferenceSource]:
"""Rebuild structured provenance from legacy payload or v2 preview fields."""
stored = payload.get("reference_sources")
if stored:
return [ReferenceSource.model_validate(item) for item in stored]
out: list[ReferenceSource] = []
seen: set[tuple[str, str, int]] = set()
for item in payload.get("provenance") or []:
if not isinstance(item, dict):
continue
filename = str(item.get("filename") or item.get("doc_id") or "").strip()
if filename.startswith("reference:"):
filename = filename.split(":", 1)[-1]
if not filename:
continue
section_id = str(item.get("section_hint") or "").strip().upper()
para = _paragraph_index_from_chunk_id(str(item.get("chunk_id") or ""))
key = (filename, section_id, para)
if key in seen:
continue
seen.add(key)
section_title = ""
if schema and section_id:
sec = schema.get_section(section_id)
if sec:
section_title = sec.title
out.append(
ReferenceSource(
report_filename=filename,
section_id=section_id,
section_title=section_title,
paragraph_index=para,
)
)
return out
def payload_to_generated_section(
section_id: str,
title: str,
payload: dict,
schema: TemplateSchema | None = None,
) -> GeneratedSection:
"""Map a persisted section payload back to a GeneratedSection for DOCX export."""
ref_sources = reference_sources_from_payload(payload, schema)
# rag_sources are live-generation display strings; do NOT synthesize them
# from provenance here, so reports rebuilt from provenance-only payloads omit
# the internal source attribution footnote on export.
rag_sources = list(payload.get("rag_sources") or [])
status = str(payload.get("status") or "OK")
grounding = payload.get("grounding_passed")
if grounding is None:
grounding = status == "OK" and bool((payload.get("text") or "").strip())
citation = payload.get("citation_audit") if isinstance(payload.get("citation_audit"), dict) else {}
unmatched = list(payload.get("unmatched_observations") or citation.get("dropped_claims") or [])
ai = payload.get("ai_transparency") if isinstance(payload.get("ai_transparency"), dict) else {}
return GeneratedSection(
section_id=section_id,
title=title,
text=payload.get("text") or "",
rating_value=payload.get("rating_value"),
status=status,
notes=str(payload.get("notes") or ai.get("plain_language") or ""),
rag_sources=rag_sources,
reference_sources=ref_sources,
grounding_passed=bool(grounding),
unmatched_observations=unmatched,
)
def section_to_payload(
section: GeneratedSection,
*,
interference_level: str,
mode: str,
style_profile: object | None = None,
) -> dict:
composition_note = _COMPOSITION_NOTES.get(
interference_level,
_COMPOSITION_NOTES["medium"],
)
provenance = [
{
"doc_id": src.report_filename,
"chunk_id": f"{src.section_id}:p{src.paragraph_index or 0}",
"score": 1.0,
"filename": src.report_filename,
"snippet_preview": (
f"Section {src.section_id}"
+ (f", paragraph {src.paragraph_index}" if src.paragraph_index else "")
)[:240],
"section_hint": src.section_id,
}
for src in section.reference_sources
]
confidence = 0.92 if section.status == "OK" and section.grounding_passed else 0.55
if section.status == "NO_RAG_MATCH":
confidence = 0.2
polished_text = _polish_section_text(section.text or "", section.status)
return {
"text": polished_text,
"confidence": confidence,
"provenance": provenance,
"reference_sources": [rs.model_dump() for rs in section.reference_sources],
"rag_sources": list(section.rag_sources),
"status": section.status,
"grounding_passed": section.grounding_passed,
"unmatched_observations": list(section.unmatched_observations),
"rating_value": section.rating_value,
"cached": False,
"mode": mode if mode in ("generate", "proofread", "enhance") else "generate",
"style_profile": _style_payload(style_profile),
"interference_level": interference_level,
"composition_depth": interference_level,
"ai_transparency": {
"plain_language": section.notes or composition_note,
"composition_note": composition_note,
},
"photos": [],
"pipeline": "v2_reference_mapping",
"citation_audit": {
"confidence": confidence,
"findings": len(provenance),
"contradictions": [],
"dropped_claims": section.unmatched_observations or [],
},
"word_count": len(polished_text.split()),
}
def result_to_sections_payload(
result: ReportResult,
*,
interference_level: str,
mode: str,
style_profile: object | None = None,
) -> dict[str, dict]:
out: dict[str, dict] = {}
for section in result.sections:
if section.section_id == "UNASSIGNED":
continue
out[section.section_id] = section_to_payload(
section,
interference_level=interference_level,
mode=mode,
style_profile=style_profile,
)
return out