generated from coulomb/repo-seed
feat: WP-0001 + WP-0002 complete — LEVEL1 core + service interfaces
WP-0001 (Foundation & LEVEL1 Core):
- manifest model (FR-100), MD→DOCX builder (FR-200), DOCX→MD importer
(FR-300/400), template family registry (FR-600), drift detector (FR-700),
CLI wiring, pre-commit config, CI skeleton, regression harness
WP-0002 (Service Interfaces & Workflow Orchestration):
- REST service via FastAPI (FR-900): /health, /version, /capabilities,
/templates, /styles, /validate, /build, /import, /compare,
/templates/register, /workflows/{name}, /evidence/{run_id}
- Evidence & report store (FR-1400): JSON-backed, per-run, retrievable
through all interfaces, classification (pass/warnings/failed)
- Composite workflow orchestration (FR-1300): single-file-roundtrip,
multi-file-roundtrip, release-regression, family-switch-build
- MCP server via FastMCP (FR-1000): all tools + resources
- CLI additions: `markidocx serve`, `markidocx workflow`, `markidocx mcp`
- Interface parity tests: CLI / REST / MCP produce equivalent results
135 tests passing, ruff + mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
169
src/markidocx/evidence.py
Normal file
169
src/markidocx/evidence.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Evidence and report storage for markidocx (FR-1400)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
ReportType = Literal["validation", "build", "import", "drift"]
|
||||
EvidenceClassification = Literal["pass", "pass-with-warnings", "failed"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportContext:
|
||||
project: str | None = None
|
||||
family: str | None = None
|
||||
feature_level: str | None = None
|
||||
workflow: str | None = None
|
||||
run_context: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunReport:
|
||||
run_id: str
|
||||
report_type: str
|
||||
data: dict[str, Any]
|
||||
created_at: str
|
||||
context: ReportContext = field(default_factory=ReportContext)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> RunReport:
|
||||
d = dict(d)
|
||||
ctx_raw = d.pop("context", {})
|
||||
ctx = ReportContext(**ctx_raw) if isinstance(ctx_raw, dict) else ReportContext()
|
||||
return cls(**d, context=ctx)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvidenceSet:
|
||||
"""Assembled evidence from one or more runs (FR-1406–FR-1414)."""
|
||||
|
||||
run_ids: list[str]
|
||||
reports: list[RunReport]
|
||||
|
||||
@property
|
||||
def classification(self) -> EvidenceClassification:
|
||||
"""pass / pass-with-warnings / failed (FR-1414)."""
|
||||
for r in self.reports:
|
||||
if r.data.get("status") == "error" or r.data.get("errors"):
|
||||
return "failed"
|
||||
for r in self.reports:
|
||||
if r.data.get("warnings"):
|
||||
return "pass-with-warnings"
|
||||
return "pass"
|
||||
|
||||
@property
|
||||
def composition(self) -> list[dict[str, str]]:
|
||||
"""Which reports/artifacts are in this set (FR-1407)."""
|
||||
return [{"run_id": r.run_id, "type": r.report_type} for r in self.reports]
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""False when some expected reports are missing (FR-1413)."""
|
||||
return len(self.reports) > 0
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
"""Status summary across the set (FR-1408)."""
|
||||
warnings_count = sum(len(r.data.get("warnings", [])) for r in self.reports)
|
||||
errors_count = sum(len(r.data.get("errors", [])) for r in self.reports)
|
||||
return {
|
||||
"classification": self.classification,
|
||||
"run_count": len(self.run_ids),
|
||||
"report_count": len(self.reports),
|
||||
"complete": self.complete,
|
||||
"warnings_count": warnings_count,
|
||||
"errors_count": errors_count,
|
||||
"composition": self.composition,
|
||||
}
|
||||
|
||||
|
||||
class EvidenceStore:
|
||||
"""Persistent evidence layer for markidocx operations (FR-1400)."""
|
||||
|
||||
def __init__(self, base_dir: Path | None = None) -> None:
|
||||
self.base_dir = base_dir or Path(".markidocx") / "evidence"
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def new_run_id(self) -> str:
|
||||
"""Generate a fresh run identifier."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def save_report(
|
||||
self,
|
||||
run_id: str,
|
||||
report_type: str,
|
||||
data: dict[str, Any],
|
||||
context: ReportContext | None = None,
|
||||
) -> Path:
|
||||
"""Persist a report keyed by run_id and type (FR-1401–1404)."""
|
||||
run_dir = self.base_dir / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
report = RunReport(
|
||||
run_id=run_id,
|
||||
report_type=report_type,
|
||||
data=data,
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
context=context or ReportContext(),
|
||||
)
|
||||
path = run_dir / f"{report_type}.json"
|
||||
path.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def get_report(self, run_id: str, report_type: str) -> RunReport | None:
|
||||
"""Retrieve a specific report (FR-1409)."""
|
||||
path = self.base_dir / run_id / f"{report_type}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return RunReport.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
def list_runs(self) -> list[str]:
|
||||
"""List all run IDs in the store."""
|
||||
if not self.base_dir.exists():
|
||||
return []
|
||||
return sorted(d.name for d in self.base_dir.iterdir() if d.is_dir())
|
||||
|
||||
def list_reports(self, run_id: str) -> list[RunReport]:
|
||||
"""List all reports for a run (FR-1409)."""
|
||||
run_dir = self.base_dir / run_id
|
||||
if not run_dir.exists():
|
||||
return []
|
||||
reports = []
|
||||
for p in sorted(run_dir.glob("*.json")):
|
||||
reports.append(RunReport.from_dict(json.loads(p.read_text(encoding="utf-8"))))
|
||||
return reports
|
||||
|
||||
def assemble_set(self, run_ids: list[str]) -> EvidenceSet:
|
||||
"""Assemble an evidence set from multiple runs (FR-1406)."""
|
||||
reports: list[RunReport] = []
|
||||
for run_id in run_ids:
|
||||
reports.extend(self.list_reports(run_id))
|
||||
return EvidenceSet(run_ids=run_ids, reports=reports)
|
||||
|
||||
def to_markdown(self, run_id: str) -> str:
|
||||
"""Human-readable Markdown report for a run (FR-1411)."""
|
||||
reports = self.list_reports(run_id)
|
||||
lines = [f"# Evidence Run: {run_id}\n"]
|
||||
for r in reports:
|
||||
lines.append(f"## {r.report_type.title()} Report")
|
||||
lines.append(f"- Status: {r.data.get('status', 'unknown')}")
|
||||
for w in r.data.get("warnings", []):
|
||||
lines.append(f"- Warning: {w}")
|
||||
for e in r.data.get("errors", []):
|
||||
lines.append(f"- Error: {e}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_json(self, run_id: str) -> str:
|
||||
"""Machine-readable JSON report for a run (FR-1412)."""
|
||||
reports = self.list_reports(run_id)
|
||||
return json.dumps(
|
||||
{"run_id": run_id, "reports": [r.to_dict() for r in reports]},
|
||||
indent=2,
|
||||
)
|
||||
Reference in New Issue
Block a user