diff --git a/src/portfolio_optimiser/provenance.py b/src/portfolio_optimiser/provenance.py new file mode 100644 index 0000000..d2210f1 --- /dev/null +++ b/src/portfolio_optimiser/provenance.py @@ -0,0 +1,57 @@ +"""First-class Pydantic provenance stamp. + +Provenance is authoritative framework data — **independent** of MAF's ``Annotation`` type, +which silently drops on the Python streaming path (#4316, research 02 Dim 2). A +``ProvenanceStamp`` is a Pydantic model that must carry at least one ``Citation`` (a +``min_length=1`` constraint), the model + role that produced the proposal, the validator's +decision, and the token usage. ``to_annotations()`` maps to MAF ``Annotation`` dicts for +DISPLAY only — never the source of truth. + +The locator type (``TextSpan``) is owned by ``retrieval.py`` (Step 5) and imported here, so +a citation's span is the same exact object the retriever produced. +""" + +from __future__ import annotations + +from typing import Literal + +from agent_framework import Annotation, TextSpanRegion +from pydantic import BaseModel, Field + +from portfolio_optimiser.retrieval import TextSpan + + +class Citation(BaseModel): + """One exact-span citation into a source document (locator owned by retrieval.py).""" + + file: str + locator: TextSpan + snippet: str + + +class ProvenanceStamp(BaseModel): + """Authoritative provenance for one proposal — at least one citation is mandatory.""" + + citations: list[Citation] = Field(min_length=1) + model: str + role: str + validator_decision: Literal["validated", "rejected"] + token_usage: int + + def to_annotations(self) -> list[Annotation]: + """Map to MAF ``Annotation`` dicts for display only (NOT the source of truth).""" + return [ + Annotation( + type="citation", + snippet=c.snippet, + annotated_regions=[ + TextSpanRegion( + type="text_span", + start_index=c.locator.start_index, + end_index=c.locator.end_index, + ) + ], + additional_properties={"file": c.file}, + ) + for c in self.citations + ] diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..08a4d71 --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,47 @@ +"""Step 6 tests — first-class Pydantic provenance stamp. + +At least one citation is mandatory (the load-bearing NFR); a valid stamp exposes the +proposal's provenance fields; the annotation adapter yields MAF citation dicts for display. +Pattern: tests/spikes/test_c_validator.py (pydantic raises + field assertions). +""" + +import pytest +from pydantic import ValidationError + +from portfolio_optimiser.provenance import Citation, ProvenanceStamp +from portfolio_optimiser.retrieval import TextSpan + + +def _stamp() -> ProvenanceStamp: + return ProvenanceStamp( + citations=[Citation(file="asphalt.txt", locator=TextSpan(0, 10), snippet="0123456789")], + model="qwen3:4b", + role="proposer", + validator_decision="validated", + token_usage=42, + ) + + +def test_zero_citations_is_rejected() -> None: + with pytest.raises(ValidationError): + ProvenanceStamp( + citations=[], + model="m", + role="proposer", + validator_decision="validated", + token_usage=1, + ) + + +def test_valid_stamp_exposes_provenance_fields() -> None: + stamp = _stamp() + assert len(stamp.citations) >= 1 + assert stamp.model and stamp.role + assert stamp.validator_decision in {"validated", "rejected"} + assert isinstance(stamp.token_usage, int) and stamp.token_usage > 0 + + +def test_to_annotations_yields_citation_dicts() -> None: + anns = _stamp().to_annotations() + assert anns and all(a["type"] == "citation" for a in anns) + assert anns[0]["annotated_regions"][0]["type"] == "text_span"