Candidate support/evidence acceptance

This commit is contained in:
2026-04-29 17:52:42 +02:00
parent 0bb0c61f75
commit 466fd86d6d
6 changed files with 200 additions and 0 deletions

View File

@@ -379,6 +379,13 @@ class RegistryService:
type=evidence.type, type=evidence.type,
reference=evidence.reference, reference=evidence.reference,
strength=evidence.strength, strength=evidence.strength,
target_kind=evidence.target_kind,
target_id=self._approved_evidence_target_id(
evidence,
approved_capability_id,
),
reference_kind=evidence.reference_kind,
reference_id=evidence.reference_id,
source_refs=evidence.source_refs, source_refs=evidence.source_refs,
) )
@@ -520,6 +527,58 @@ class RegistryService:
) )
return self.store.get_ability_map(repository_id) return self.store.get_ability_map(repository_id)
def accept_candidate_evidence(
self,
repository_id: int,
analysis_run_id: int,
candidate_evidence_id: int,
*,
notes: str = "",
) -> RepositoryAbilityMap:
graph = self.store.get_candidate_graph(repository_id, analysis_run_id)
parent_ability, parent_capability, evidence = (
self._candidate_evidence_with_parent(graph, candidate_evidence_id)
)
if evidence.status != "candidate":
raise ValueError(
f"candidate evidence {candidate_evidence_id} is not pending"
)
approved_ability_id = self._ensure_approved_ability(repository_id, parent_ability)
approved_capability_id = self._ensure_approved_capability(
repository_id,
approved_ability_id,
parent_ability.name,
parent_capability,
)
self.store.create_evidence(
repository_id,
approved_capability_id,
type=evidence.type,
reference=evidence.reference,
strength=evidence.strength,
target_kind=evidence.target_kind,
target_id=self._approved_evidence_target_id(
evidence,
approved_capability_id,
),
reference_kind=evidence.reference_kind,
reference_id=evidence.reference_id,
source_refs=evidence.source_refs,
)
self.store.mark_candidate_evidence_status(
repository_id,
analysis_run_id,
candidate_evidence_id,
"approved",
)
self._record_candidate_acceptance(
repository_id,
analysis_run_id,
"accept_candidate_evidence",
notes or f"Accepted candidate support: {evidence.reference}",
)
return self.store.get_ability_map(repository_id)
def diff_analysis_runs( def diff_analysis_runs(
self, self,
repository_id: int, repository_id: int,
@@ -618,6 +677,13 @@ class RegistryService:
type=evidence.type, type=evidence.type,
reference=evidence.reference, reference=evidence.reference,
strength=evidence.strength, strength=evidence.strength,
target_kind=evidence.target_kind,
target_id=self._approved_evidence_target_id(
evidence,
approved_capability_id,
),
reference_kind=evidence.reference_kind,
reference_id=evidence.reference_id,
source_refs=evidence.source_refs, source_refs=evidence.source_refs,
) )
return approved_capability_id return approved_capability_id
@@ -673,6 +739,15 @@ class RegistryService:
return ability, capability return ability, capability
raise ValueError(f"candidate capability {candidate_capability_id} was not found") raise ValueError(f"candidate capability {candidate_capability_id} was not found")
def _approved_evidence_target_id(
self,
evidence: CandidateEvidence,
approved_capability_id: int,
) -> int | None:
if evidence.target_kind == "capability":
return approved_capability_id
return evidence.target_id
def _candidate_feature_with_parent( def _candidate_feature_with_parent(
self, self,
graph: CandidateGraph, graph: CandidateGraph,
@@ -685,6 +760,18 @@ class RegistryService:
return ability, capability, feature return ability, capability, feature
raise ValueError(f"candidate feature {candidate_feature_id} was not found") raise ValueError(f"candidate feature {candidate_feature_id} was not found")
def _candidate_evidence_with_parent(
self,
graph: CandidateGraph,
candidate_evidence_id: int,
) -> tuple[CandidateAbility, CandidateCapability, CandidateEvidence]:
for ability in graph.abilities:
for capability in ability.capabilities:
for evidence in capability.evidence:
if evidence.id == candidate_evidence_id:
return ability, capability, evidence
raise ValueError(f"candidate evidence {candidate_evidence_id} was not found")
def _record_candidate_acceptance( def _record_candidate_acceptance(
self, self,
repository_id: int, repository_id: int,

View File

@@ -677,6 +677,29 @@ class RegistryStore:
f"{repository_id} analysis run {analysis_run_id}" f"{repository_id} analysis run {analysis_run_id}"
) )
def mark_candidate_evidence_status(
self,
repository_id: int,
analysis_run_id: int,
candidate_evidence_id: int,
status: str,
) -> None:
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE candidate_evidence
SET status = ?
WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
""",
(status, candidate_evidence_id, repository_id, analysis_run_id),
)
if cursor.rowcount == 0:
raise NotFoundError(
"candidate evidence "
f"{candidate_evidence_id} was not found for repository "
f"{repository_id} analysis run {analysis_run_id}"
)
def _mark_candidate_children_status( def _mark_candidate_children_status(
self, self,
connection: sqlite3.Connection, connection: sqlite3.Connection,

View File

@@ -1367,6 +1367,25 @@ def reject_candidate_evidence_from_form(
) )
@router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-evidence/{candidate_evidence_id}/accept"
)
def accept_candidate_evidence_from_form(
repository_id: int,
analysis_run_id: int,
candidate_evidence_id: int,
service: RegistryService = Depends(get_service),
) -> RedirectResponse:
service.accept_candidate_evidence(
repository_id,
analysis_run_id,
candidate_evidence_id,
notes="Accepted from web UI",
)
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@router.post( @router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-abilities/{candidate_ability_id}/edit" "/candidate-abilities/{candidate_ability_id}/edit"
@@ -2376,6 +2395,10 @@ def render_candidate_support_element_actions(
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/candidate-evidence/{item_id}/reject" f"/candidate-evidence/{item_id}/reject"
) )
accept_action = (
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/candidate-evidence/{item_id}/accept"
)
relink_action = ( relink_action = (
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/candidate-evidence/{item_id}/relink" f"/candidate-evidence/{item_id}/relink"
@@ -2385,6 +2408,9 @@ def render_candidate_support_element_actions(
f"/candidate-evidence/{item_id}/merge" f"/candidate-evidence/{item_id}/merge"
) )
return f""" return f"""
<form method="post" action="{accept_action}">
<button type="submit">Accept</button>
</form>
<form method="post" action="{reject_action}"> <form method="post" action="{reject_action}">
<button class="secondary" type="submit">Remove</button> <button class="secondary" type="submit">Remove</button>
</form> </form>

View File

@@ -878,6 +878,52 @@ def test_accept_candidate_feature_promotes_parent_context_once(tmp_path):
} }
def test_accept_candidate_evidence_promotes_parent_context(tmp_path):
source = tmp_path / "repo"
source.mkdir()
(source / "README.md").write_text(
"# Support Accept\nDocuments an HTTP health interface.\n",
encoding="utf-8",
)
(source / "tests").mkdir()
(source / "tests" / "test_health.py").write_text(
"def test_health(): pass\n",
encoding="utf-8",
)
(source / "app.py").write_text(
"from fastapi import FastAPI\n"
"app = FastAPI()\n"
'@app.get("/health")\n'
"def health():\n"
" return {}\n",
encoding="utf-8",
)
service = make_service(tmp_path)
repository = service.register_repository(name="Support Accept", url=str(source))
summary = service.analyze_repository(repository.id)
graph = service.candidate_graph(repository.id, summary.analysis_run.id)
candidate_evidence = graph.abilities[0].capabilities[0].evidence[0]
ability_map = service.accept_candidate_evidence(
repository.id,
summary.analysis_run.id,
candidate_evidence.id,
)
graph_after_accept = service.candidate_graph(
repository.id,
summary.analysis_run.id,
)
approved_evidence = ability_map.abilities[0].capabilities[0].evidence[0]
assert approved_evidence.reference == candidate_evidence.reference
assert approved_evidence.target_kind == "capability"
assert graph_after_accept.abilities[0].capabilities[0].evidence[0].status == (
"approved"
)
decisions = service.list_review_decisions(repository.id, summary.analysis_run.id)
assert decisions[0].action == "accept_candidate_evidence"
def test_analysis_run_diff_keeps_approved_map_stable_until_change_approval(tmp_path): def test_analysis_run_diff_keeps_approved_map_stable_until_change_approval(tmp_path):
source = tmp_path / "repo" source = tmp_path / "repo"
source.mkdir() source.mkdir()

View File

@@ -1205,6 +1205,18 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert pending_candidate_listing.status_code == 200 assert pending_candidate_listing.status_code == 200
assert "Accept" in pending_candidate_listing.text assert "Accept" in pending_candidate_listing.text
pending_support_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={
"scope": "candidate",
"analysis_run_id": first_run_id,
"type": "supports",
},
)
assert pending_support_listing.status_code == 200
assert "Candidate Supports" in pending_support_listing.text
assert "Accept" in pending_support_listing.text
approve_response = client.post( approve_response = client.post(
f"{run_path}/candidate-graph/approve", f"{run_path}/candidate-graph/approve",
follow_redirects=False, follow_redirects=False,

View File

@@ -241,3 +241,9 @@ Implementation note 2026-04-29: the element browser now includes approved scope
and support/evidence rows. Count badges link to scope and support listings, and and support/evidence rows. Count badges link to scope and support listings, and
support rows show both the supported characteristic target and the referenced support rows show both the supported characteristic target and the referenced
source/fact/characteristic metadata. source/fact/characteristic metadata.
Implementation note 2026-04-29: candidate support/evidence can now be accepted
from the UI and service layer. Accepting support promotes the parent ability and
capability context as needed, records a review decision, marks the candidate
support approved, and maps capability support targets to the approved capability
ID.