mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Make policy application version-aware and deterministic
This commit is contained in:
@@ -221,7 +221,7 @@ def capture_decision_trace(
|
||||
graph_store: Optional[GraphStore] = None,
|
||||
entities: Optional[List[str]] = None,
|
||||
source_documents: Optional[List[str]] = None,
|
||||
policy_ids: Optional[List[str]] = None,
|
||||
policy_ids: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]] = None,
|
||||
exceptions: Optional[List[Dict[str, Any]]] = None,
|
||||
approvals: Optional[List[Dict[str, Any]]] = None,
|
||||
precedents: Optional[List[Dict[str, str]]] = None,
|
||||
@@ -236,7 +236,9 @@ def capture_decision_trace(
|
||||
graph_store: Optional graph store used to persist full trace
|
||||
entities: Optional list of linked entities
|
||||
source_documents: Optional list of source documents
|
||||
policy_ids: Optional list of policy IDs applied to the decision
|
||||
policy_ids: Optional list of policy refs. Supports:
|
||||
- "policy_id"
|
||||
- {"policy_id": "...", "version": "..."}
|
||||
exceptions: Optional list of exception records
|
||||
approvals: Optional list of approval records
|
||||
precedents: Optional list of precedent links
|
||||
@@ -259,7 +261,7 @@ def capture_decision_trace(
|
||||
recorder = DecisionRecorder(graph_store)
|
||||
entities = _normalize_string_list(entities)
|
||||
source_documents = _normalize_string_list(source_documents)
|
||||
policy_ids = _normalize_string_list(policy_ids)
|
||||
policy_refs = _normalize_policy_refs(policy_ids)
|
||||
exceptions = _normalize_record_list(exceptions)
|
||||
approvals = _normalize_record_list(approvals)
|
||||
precedents = _normalize_precedents(precedents)
|
||||
@@ -294,10 +296,16 @@ def capture_decision_trace(
|
||||
}
|
||||
)
|
||||
|
||||
if policy_ids:
|
||||
recorder.apply_policies(decision_id, policy_ids)
|
||||
if policy_refs:
|
||||
applied_policies = recorder.apply_policies(decision_id, policy_refs)
|
||||
trace_events.append(
|
||||
{"event_type": "POLICIES_APPLIED", "payload": {"policy_ids": policy_ids}}
|
||||
{
|
||||
"event_type": "POLICIES_APPLIED",
|
||||
"payload": {
|
||||
"policy_ids": [p.get("policy_id") for p in policy_refs],
|
||||
"applied_policies": applied_policies,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if exceptions:
|
||||
@@ -481,6 +489,36 @@ def _normalize_record_list(
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_policy_refs(
|
||||
value: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]]
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Normalize policy refs to [{policy_id, version?}] for version-safe matching."""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raw_items: List[Union[str, Dict[str, str]]]
|
||||
if isinstance(value, (str, dict)):
|
||||
raw_items = [value]
|
||||
elif isinstance(value, list):
|
||||
raw_items = value
|
||||
else:
|
||||
return []
|
||||
|
||||
normalized: List[Dict[str, str]] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str) and item:
|
||||
normalized.append({"policy_id": item})
|
||||
elif isinstance(item, dict):
|
||||
policy_id = item.get("policy_id")
|
||||
if not policy_id:
|
||||
continue
|
||||
ref: Dict[str, str] = {"policy_id": str(policy_id)}
|
||||
if item.get("version") is not None and str(item.get("version")):
|
||||
ref["version"] = str(item.get("version"))
|
||||
normalized.append(ref)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_precedents(
|
||||
value: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]]
|
||||
) -> List[Dict[str, str]]:
|
||||
|
||||
@@ -179,29 +179,82 @@ class DecisionRecorder:
|
||||
self.logger.exception("Failed to link entities")
|
||||
raise
|
||||
|
||||
def apply_policies(self, decision_id: str, policy_ids: List[str]) -> None:
|
||||
def apply_policies(
|
||||
self,
|
||||
decision_id: str,
|
||||
policy_ids: List[Union[str, Dict[str, str]]],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Track policy applications for a decision.
|
||||
|
||||
Args:
|
||||
decision_id: Decision ID
|
||||
policy_ids: List of policy IDs that were applied
|
||||
policy_ids: List of policy IDs or policy refs with explicit version
|
||||
|
||||
Returns:
|
||||
Applied policy references with resolved versions
|
||||
"""
|
||||
try:
|
||||
for policy_id in policy_ids:
|
||||
# Create APPLIED_POLICY relationship
|
||||
applied: List[Dict[str, str]] = []
|
||||
|
||||
for policy_ref in policy_ids:
|
||||
if isinstance(policy_ref, dict):
|
||||
policy_id = str(policy_ref.get("policy_id", ""))
|
||||
policy_version = (
|
||||
str(policy_ref.get("version"))
|
||||
if policy_ref.get("version") is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
policy_id = str(policy_ref)
|
||||
policy_version = None
|
||||
|
||||
if not policy_id:
|
||||
continue
|
||||
|
||||
# Resolve exactly one policy node:
|
||||
# - explicit version when provided
|
||||
# - latest available version for legacy callers
|
||||
query = """
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
MERGE (d)-[:APPLIED_POLICY]->(p)
|
||||
SET d.applied_at = timestamp()
|
||||
WHERE $policy_version IS NULL OR p.version = $policy_version
|
||||
WITH d, p
|
||||
ORDER BY p.updated_at DESC, p.version DESC
|
||||
LIMIT 1
|
||||
MERGE (d)-[r:APPLIED_POLICY]->(p)
|
||||
SET r.policy_id = $policy_id,
|
||||
r.policy_version = p.version,
|
||||
d.applied_at = timestamp()
|
||||
RETURN p.policy_id as policy_id, p.version as version
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
result = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id
|
||||
"policy_id": policy_id,
|
||||
"policy_version": policy_version,
|
||||
})
|
||||
|
||||
records = (
|
||||
result.get("records", [])
|
||||
if isinstance(result, dict)
|
||||
else (result if isinstance(result, list) else [])
|
||||
)
|
||||
if records:
|
||||
record = records[0]
|
||||
applied.append(
|
||||
{
|
||||
"policy_id": str(record.get("policy_id", policy_id)),
|
||||
"version": str(record.get("version", policy_version or "")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"No policy match found for {policy_id}"
|
||||
+ (f" version {policy_version}" if policy_version else "")
|
||||
)
|
||||
|
||||
self.logger.info(f"Applied {len(policy_ids)} policies to decision {decision_id}")
|
||||
self.logger.info(f"Applied {len(applied)} policies to decision {decision_id}")
|
||||
return applied
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Failed to apply policies")
|
||||
|
||||
@@ -78,3 +78,27 @@ def test_capture_decision_trace_accepts_legacy_payload_shapes():
|
||||
|
||||
assert decision_id == decision.decision_id
|
||||
assert graph_store.execute_query.call_count > 0
|
||||
|
||||
|
||||
def test_capture_decision_trace_accepts_versioned_policy_refs():
|
||||
decision = _sample_decision()
|
||||
graph_store = Mock()
|
||||
graph_store.execute_query = Mock(
|
||||
return_value={"records": [{"policy_id": "renewal_discount_policy", "version": "3.2"}]}
|
||||
)
|
||||
|
||||
decision_id = capture_decision_trace(
|
||||
decision=decision,
|
||||
cross_system_context={"crm": {"arr": 120000}},
|
||||
graph_store=graph_store,
|
||||
policy_ids=[{"policy_id": "renewal_discount_policy", "version": "3.2"}],
|
||||
immutable_audit_log=False,
|
||||
)
|
||||
|
||||
assert decision_id == decision.decision_id
|
||||
policy_calls = [
|
||||
c for c in graph_store.execute_query.call_args_list
|
||||
if "policy_version" in c[0][1]
|
||||
]
|
||||
assert policy_calls
|
||||
assert policy_calls[0][0][1]["policy_version"] == "3.2"
|
||||
|
||||
@@ -139,11 +139,29 @@ class TestDecisionRecorder:
|
||||
"""Test applying policies to decision."""
|
||||
decision_id = "decision_001"
|
||||
policy_ids = ["policy_001", "policy_002"]
|
||||
mock_graph_store.execute_query.return_value = {"records": [{"policy_id": "policy_001", "version": "2.0"}]}
|
||||
|
||||
decision_recorder.apply_policies(decision_id, policy_ids)
|
||||
applied = decision_recorder.apply_policies(decision_id, policy_ids)
|
||||
|
||||
# Verify graph store was called for each policy
|
||||
assert mock_graph_store.execute_query.call_count == len(policy_ids)
|
||||
assert isinstance(applied, list)
|
||||
|
||||
def test_apply_policies_with_explicit_version(self, decision_recorder, mock_graph_store):
|
||||
"""Test applying a specific policy version to avoid ambiguous linking."""
|
||||
decision_id = "decision_001"
|
||||
policy_refs = [{"policy_id": "policy_001", "version": "3.2"}]
|
||||
mock_graph_store.execute_query.return_value = {
|
||||
"records": [{"policy_id": "policy_001", "version": "3.2"}]
|
||||
}
|
||||
|
||||
applied = decision_recorder.apply_policies(decision_id, policy_refs)
|
||||
|
||||
assert len(applied) == 1
|
||||
assert applied[0]["policy_id"] == "policy_001"
|
||||
assert applied[0]["version"] == "3.2"
|
||||
call = mock_graph_store.execute_query.call_args_list[0]
|
||||
assert call[0][1]["policy_version"] == "3.2"
|
||||
|
||||
def test_record_exception(self, decision_recorder, mock_graph_store):
|
||||
"""Test recording policy exception."""
|
||||
|
||||
Reference in New Issue
Block a user