Files
document-haness/build/lib/claridoc/provenance.py
T

121 lines
4.8 KiB
Python

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", " ")