Expose review decision audit metadata

This commit is contained in:
2026-05-15 16:16:05 +02:00
parent 43e7f7138f
commit 5c2262bcf2
7 changed files with 140 additions and 2 deletions

View File

@@ -52,6 +52,111 @@ class ReviewDecision:
action: str
notes: str
created_at: str
reviewer_type: str = "unknown"
reviewer_id: str = ""
policy_version: str = ""
criteria_version: str = ""
criterion_ids: list[str] = field(default_factory=list)
evidence_refs: list[str] = field(default_factory=list)
rationale: str = ""
accepted_after_edits: bool = False
decision_kind: str = "other"
def enrich_review_decision(decision: ReviewDecision) -> ReviewDecision:
fields = review_decision_audit_fields(decision.action, decision.notes)
return replace_review_decision(decision, **fields)
def replace_review_decision(
decision: ReviewDecision,
**fields: object,
) -> ReviewDecision:
data = {
"id": decision.id,
"repository_id": decision.repository_id,
"analysis_run_id": decision.analysis_run_id,
"action": decision.action,
"notes": decision.notes,
"created_at": decision.created_at,
"reviewer_type": decision.reviewer_type,
"reviewer_id": decision.reviewer_id,
"policy_version": decision.policy_version,
"criteria_version": decision.criteria_version,
"criterion_ids": decision.criterion_ids,
"evidence_refs": decision.evidence_refs,
"rationale": decision.rationale,
"accepted_after_edits": decision.accepted_after_edits,
"decision_kind": decision.decision_kind,
}
data.update(fields)
return ReviewDecision(**data)
def review_decision_audit_fields(action: str, notes: str) -> dict[str, object]:
parsed = _parse_review_decision_notes(notes)
return {
"reviewer_type": _reviewer_type(action),
"reviewer_id": parsed.get("reviewer", ""),
"policy_version": parsed.get("policy_version", ""),
"criteria_version": parsed.get("criteria_version", ""),
"criterion_ids": _split_audit_list(parsed.get("criteria", "")),
"evidence_refs": _split_audit_list(parsed.get("evidence", "")),
"rationale": parsed.get("rationale", ""),
"accepted_after_edits": action.endswith("_with_edits")
or action == "agentic_approve_with_edits"
or bool(parsed.get("proposed_changes")),
"decision_kind": _decision_kind(action),
}
def _parse_review_decision_notes(notes: str) -> dict[str, str]:
parsed: dict[str, str] = {}
for part in notes.split(";"):
key, separator, value = part.strip().partition("=")
if separator and key:
parsed[key] = value.strip()
return parsed
def _split_audit_list(value: str) -> list[str]:
if not value or value == "none":
return []
return [item.strip() for item in value.split(",") if item.strip()]
def _reviewer_type(action: str) -> str:
if action.startswith("agentic_"):
return "agent"
if action == "trusted_auto_approve_candidate_graph":
return "migration"
if action.startswith("quality_gate_"):
return "deterministic-gate"
if action.startswith("approve") or action.startswith("accept"):
return "human"
if action.startswith("reject") or action.startswith("edit") or action.startswith("merge"):
return "human"
if action.startswith("relink"):
return "human"
return "migration" if action.startswith("llm_extraction") else "unknown"
def _decision_kind(action: str) -> str:
if "approve_with_edits" in action:
return "accepted_after_edits"
if "approve" in action or action.startswith("accept"):
return "accepted_as_is"
if "reject" in action:
return "rejected"
if "downgrade" in action:
return "downgraded"
if "request_human_review" in action:
return "needs_human"
if "propose_edit" in action:
return "proposed_edit"
if "relink" in action:
return "relinked"
return "other"
@dataclass(frozen=True)

View File

@@ -40,6 +40,7 @@ from repo_registry.core.models import (
ReviewDecision,
ScanSummary,
SearchResult,
enrich_review_decision,
)
from repo_registry.candidate_graph.generator import CandidateGraphGenerator
from repo_registry.candidate_graph.normalization import normalize_candidate_drafts
@@ -355,7 +356,13 @@ class RegistryService:
repository_id: int,
analysis_run_id: int | None = None,
) -> list[ReviewDecision]:
return self.store.list_review_decisions(repository_id, analysis_run_id)
return [
enrich_review_decision(decision)
for decision in self.store.list_review_decisions(
repository_id,
analysis_run_id,
)
]
def record_expectation_gap(
self,

View File

@@ -513,6 +513,15 @@ class ReviewDecisionResponse(BaseModel):
action: str
notes: str
created_at: str
reviewer_type: str = "unknown"
reviewer_id: str = ""
policy_version: str = ""
criteria_version: str = ""
criterion_ids: list[str] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
rationale: str = ""
accepted_after_edits: bool = False
decision_kind: str = "other"
class QualityCriterionResponse(BaseModel):

View File

@@ -125,6 +125,13 @@ def test_agentic_reviewer_can_approve_candidate_graph_with_rationale(tmp_path):
assert ability_map.abilities
assert graph.abilities[0].status == "approved"
assert decisions[1].action == "agentic_approve_candidate_graph"
assert decisions[1].reviewer_type == "agent"
assert decisions[1].reviewer_id == "approving-agent"
assert decisions[1].policy_version == "agentic-review-policy/test"
assert decisions[1].criteria_version == "repo-scoping-quality-criteria/v1"
assert decisions[1].criterion_ids == ["RREG-QC-004"]
assert decisions[1].evidence_refs == ["README.md", "app.py"]
assert decisions[1].decision_kind == "accepted_as_is"
assert "rationale=API source and README support" in decisions[1].notes
assert "criteria=RREG-QC-004" in decisions[1].notes
assert "evidence=README.md, app.py" in decisions[1].notes

View File

@@ -129,3 +129,5 @@ def test_export_assessment_review_decisions_include_quality_criteria_version(tmp
assert artifact["review_decisions"][0]["quality_criteria_version"] == (
"repo-scoping-quality-criteria/v1"
)
assert artifact["review_decisions"][0]["reviewer_type"] == "human"
assert artifact["review_decisions"][0]["decision_kind"] == "accepted_as_is"

View File

@@ -1038,6 +1038,8 @@ def test_api_analysis_run_loop(tmp_path):
decisions_response = client.get(f"/repos/{repository_id}/review-decisions")
assert decisions_response.status_code == 200
assert decisions_response.json()[0]["action"] == "reject_candidate_ability"
assert decisions_response.json()[0]["reviewer_type"] == "human"
assert decisions_response.json()[0]["decision_kind"] == "rejected"
run_decisions_response = client.get(
f"/repos/{repository_id}/analysis-runs/"
f"{run['analysis_run']['id']}/review-decisions"

View File

@@ -175,7 +175,7 @@ validated for rationale, criteria IDs, and evidence refs before being applied.
```task
id: RREG-WP-0014-T05
status: todo
status: in_progress
priority: high
state_hub_task_id: "0d12559a-831e-40ff-bf82-85f45b763f07"
```
@@ -193,6 +193,12 @@ Acceptance criteria:
edits/relinks".
- Existing decisions remain readable through a migration or compatibility view.
Implementation note 2026-05-15: started the audit-trail compatibility view by
enriching listed review decisions with derived reviewer type, reviewer identity,
policy version, criteria version, rationale, criterion IDs, evidence refs,
accepted-after-edits marker, and decision kind. Existing stored decisions still
use the same table and remain readable.
## T06: Add Human Override And Criteria Refinement Flow
```task