from __future__ import annotations from typing import Any from claridoc.models import Brief, Outline, Source, SourcePack def build_evidence_map(brief: Brief, outline: Outline, sources: SourcePack) -> dict[str, Any]: source_by_id = {source.id: source for source in sources.sources} sections: list[dict[str, Any]] = [] for section in outline.sections: evidence = [] for source_id in section.evidence_ids: source = source_by_id.get(source_id) if source is None: continue evidence.append(_source_record(source)) sections.append( { "section_id": section.id, "intent": section.intent, "title": section.title, "reader_question": section.reader_question, "decision_requirements": section.decision_requirements, "evidence": evidence, "evidence_gap": bool(section.decision_requirements and not evidence), } ) return { "schema_version": 2, "document": brief.title, "citation_style": brief.constraints.citation_style, "reader_document_contains_internal_source_ids": brief.constraints.citation_style == "source_id", "sections": sections, "sources": [_source_record(source) for source in sources.sources], } def render_provenance(brief: Brief, outline: Outline, sources: SourcePack) -> str: source_by_id = {source.id: source for source in sources.sources} lines = [ "# Evidence and decision provenance", "", "> This is an internal sidecar. It is not reader-facing article content.", "> Source IDs, repository paths, line ranges, status labels, and access dates belong here—not in `document.md`.", "", f"- Document: **{brief.title}**", f"- Citation rendering: `{brief.constraints.citation_style}`", f"- Evidence sources: **{len(sources.sources)}**", "", "## Section evidence map", "", "| Section | Decision contract | Evidence | Status / location |", "|---|---|---|---|", ] for section in outline.sections: decision = ", ".join(section.decision_requirements) if section.decision_requirements else "—" if not section.evidence_ids: lines.append(f"| {escape(section.title)} | {escape(decision)} | **GAP** | No allocated evidence |") continue for position, source_id in enumerate(section.evidence_ids): source = source_by_id.get(source_id) if source is None: lines.append(f"| {escape(section.title)} | {escape(decision)} | `{source_id}` | Unknown source |") continue section_name = section.title if position == 0 else "↳" location = _location(source) status = source.status or "unspecified" lines.append( f"| {escape(section_name)} | {escape(decision if position == 0 else '—')} | " f"`{source.id}` {escape(source.title)} | `{escape(status)}` · {escape(location)} |" ) lines.extend(["", "## Source details", ""]) for source in sources.sources: lines.extend( [ f"### `{source.id}` {source.title}", "", f"- Type: `{source.source_type}`", f"- Status: `{source.status or 'unspecified'}`", f"- Location: `{_location(source)}`", f"- Public/reference URL: `{source.url}`", f"- Claim IDs: {', '.join(f'`{item}`' for item in source.claim_ids) or '—'}", f"- Decision IDs: {', '.join(f'`{item}`' for item in source.decision_ids) or '—'}", f"- Retrieval priority: `{source.priority:.4f}`", "", ] ) return "\n".join(lines).rstrip() + "\n" def _source_record(source: Source) -> dict[str, Any]: return { "id": source.id, "title": source.title, "source_type": source.source_type, "status": source.status, "path": source.path, "heading": source.heading, "line_start": source.line_start, "line_end": source.line_end, "url": source.url, "accessed": source.accessed, "claim_ids": list(source.claim_ids), "decision_ids": list(source.decision_ids), "priority": source.priority, } def _location(source: Source) -> str: location = source.path or source.url if source.heading: location += f" — {source.heading}" if source.line_start is not None: location += f" (lines {source.line_start}-{source.line_end or source.line_start})" return location def escape(value: str) -> str: return value.replace("|", "\\|").replace("\n", " ")