diff --git a/diffgraph/contract.py b/diffgraph/contract.py index 3d473ef..e366e00 100644 --- a/diffgraph/contract.py +++ b/diffgraph/contract.py @@ -48,6 +48,72 @@ def value(self) -> dict: return deepcopy(self._value) +def enrich_with_prose( + artifact: ValidatedArtifact, + text: str, + *, + provider: str, + model: str, + confidence: float | None = None, + prompt_ref: str | None = None, +) -> ValidatedArtifact: + """Return a separately validated artifact with optional AI prose only. + + The supplied prose is deliberately limited to ``summary`` and its own + provenance. Files, symbols, and relationships are copied from the frozen + deterministic artifact, so a provider cannot add, remove, or edit graph + topology. This helper performs no provider call and never receives a + prompt or API credential. + """ + if not isinstance(artifact, ValidatedArtifact): + raise TypeError("enrich_with_prose requires a ValidatedArtifact") + if not isinstance(text, str) or not text.strip(): + raise ValueError("summary text must be a non-empty string") + if not isinstance(provider, str) or not provider.strip(): + raise ValueError("provider must be a non-empty string") + if not isinstance(model, str) or not model.strip(): + raise ValueError("model must be a non-empty string") + if confidence is not None and ( + isinstance(confidence, bool) + or not isinstance(confidence, (int, float)) + or not 0 <= confidence <= 1 + ): + raise ValueError("confidence must be a number from 0 through 1") + if prompt_ref is not None and (not isinstance(prompt_ref, str) or not prompt_ref.strip()): + raise ValueError("prompt_ref must be a non-empty string when provided") + + enriched = artifact.value + llm_evidence = {"kind": "llm_inference", "model": model} + if prompt_ref is not None: + llm_evidence["prompt_ref"] = prompt_ref + enriched["summary"] = { + "text": text, + "analysis_source": "inferred", + "confidence": confidence, + "evidence": [ + llm_evidence, + { + "kind": "structural_basis", + "file_ids": [item["id"] for item in enriched["files"]], + "symbol_ids": [item["id"] for item in enriched["symbols"]], + }, + ], + } + metadata = enriched["metadata"] + # The v2 contract has no combined tier. Retain a pre-existing backend + # classification so enrichment never hides that the artifact's data also + # left the machine through WildestAI's backend. + if metadata.get("privacy_tier") != "cloud_backend": + metadata["privacy_tier"] = "cloud_llm" + metadata["cloud_providers_used"] = sorted( + set(metadata.get("cloud_providers_used", [])) | {provider} + ) + metadata["llm_calls"] = (metadata.get("llm_calls") or 0) + 1 + metadata["llm_model"] = model + metadata["tiers_used"] = sorted(set(metadata.get("tiers_used", [])) | {"inferred"}) + return ValidatedArtifact.from_value(enriched) + + def schema_version(value: object) -> Tuple[int, int]: """Parse and compatibility-check a DiffGraph ``MAJOR.MINOR`` version.""" if not isinstance(value, str): diff --git a/tests/test_contract.py b/tests/test_contract.py index f4f15d6..56cd260 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -15,6 +15,7 @@ SUPPORTED_SCHEMA_MAJOR, DiffGraphContractError, ValidatedArtifact, + enrich_with_prose, load_schema, schema_version, validate_artifact, @@ -65,6 +66,83 @@ def test_golden_contains_only_local_structural_claims(golden_artifact): ) +def test_optional_prose_enrichment_cannot_mutate_frozen_topology(golden_artifact): + frozen = ValidatedArtifact.from_value(golden_artifact) + topology_before = { + field: frozen.value[field] + for field in ("files", "symbols", "relationships") + } + + enriched = enrich_with_prose( + frozen, + "The deterministic graph shows one modified Python symbol.", + provider="byok-openai", + model="gpt-test", + confidence=0.75, + prompt_ref="summary-v1", + ) + + validate_artifact(enriched.value) + assert frozen.value["summary"] is None + assert { + field: frozen.value[field] + for field in ("files", "symbols", "relationships") + } == topology_before + assert { + field: enriched.value[field] + for field in ("files", "symbols", "relationships") + } == topology_before + assert enriched.value["summary"] == { + "text": "The deterministic graph shows one modified Python symbol.", + "analysis_source": "inferred", + "confidence": 0.75, + "evidence": [ + {"kind": "llm_inference", "model": "gpt-test", "prompt_ref": "summary-v1"}, + { + "kind": "structural_basis", + "file_ids": [item["id"] for item in topology_before["files"]], + "symbol_ids": [item["id"] for item in topology_before["symbols"]], + }, + ], + } + assert enriched.value["metadata"]["privacy_tier"] == "cloud_llm" + assert enriched.value["metadata"]["cloud_providers_used"] == ["byok-openai"] + assert enriched.value["metadata"]["llm_calls"] == 1 + assert enriched.value["metadata"]["llm_model"] == "gpt-test" + assert enriched.value["metadata"]["tiers_used"] == ["inferred", "structural"] + + +def test_optional_prose_enrichment_preserves_cloud_backend_privacy(golden_artifact): + golden_artifact["metadata"]["privacy_tier"] = "cloud_backend" + frozen = ValidatedArtifact.from_value(golden_artifact) + + enriched = enrich_with_prose( + frozen, + "The deterministic graph has backend provenance.", + provider="byok-openai", + model="gpt-test", + ) + + assert enriched.value["metadata"]["privacy_tier"] == "cloud_backend" + assert enriched.value["metadata"]["cloud_providers_used"] == ["byok-openai"] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"text": "", "provider": "provider", "model": "model"}, "summary text"), + ({"text": "summary", "provider": "", "model": "model"}, "provider"), + ({"text": "summary", "provider": "provider", "model": ""}, "model"), + ({"text": "summary", "provider": "provider", "model": "model", "confidence": 2}, "confidence"), + ], +) +def test_optional_prose_enrichment_rejects_ambiguous_provenance(golden_artifact, kwargs, message): + frozen = ValidatedArtifact.from_value(golden_artifact) + + with pytest.raises(ValueError, match=message): + enrich_with_prose(frozen, **kwargs) + + @pytest.mark.parametrize("value", [None, 2, "2", "v2", "2.0.0", "02.0", "2.-1"]) def test_schema_version_rejects_malformed_values(value): with pytest.raises(DiffGraphContractError, match=r"MAJOR\.MINOR"):