121 lines
4.8 KiB
Python
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", " ")
|