generated from coulomb/repo-seed
Candidate support/evidence acceptance
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user