generated from coulomb/repo-seed
service logic for facts, chunks, candidates, and current-approved-vs-target comparison
This commit is contained in:
@@ -54,6 +54,34 @@ class ReviewDecision:
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AnalysisRunDiffItem:
|
||||||
|
change_type: str
|
||||||
|
item_type: str
|
||||||
|
key: str
|
||||||
|
base: dict[str, Any] | None = None
|
||||||
|
target: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AnalysisRunDiffSection:
|
||||||
|
added: list[AnalysisRunDiffItem] = field(default_factory=list)
|
||||||
|
removed: list[AnalysisRunDiffItem] = field(default_factory=list)
|
||||||
|
changed: list[AnalysisRunDiffItem] = field(default_factory=list)
|
||||||
|
weakened: list[AnalysisRunDiffItem] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AnalysisRunDiff:
|
||||||
|
repository: Repository
|
||||||
|
base_run: AnalysisRun
|
||||||
|
target_run: AnalysisRun
|
||||||
|
facts: AnalysisRunDiffSection
|
||||||
|
chunks: AnalysisRunDiffSection
|
||||||
|
candidates: AnalysisRunDiffSection
|
||||||
|
approved_entries: AnalysisRunDiffSection
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ObservedFact:
|
class ObservedFact:
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ from dataclasses import asdict
|
|||||||
|
|
||||||
from repo_registry.core.models import (
|
from repo_registry.core.models import (
|
||||||
AbilitySummary,
|
AbilitySummary,
|
||||||
|
AnalysisRunDiff,
|
||||||
|
AnalysisRunDiffItem,
|
||||||
|
AnalysisRunDiffSection,
|
||||||
AnalysisRun,
|
AnalysisRun,
|
||||||
CapabilitySummary,
|
CapabilitySummary,
|
||||||
|
CandidateAbility,
|
||||||
|
CandidateCapability,
|
||||||
|
CandidateEvidence,
|
||||||
|
CandidateFeature,
|
||||||
CandidateGraph,
|
CandidateGraph,
|
||||||
ContentChunk,
|
ContentChunk,
|
||||||
ObservedFact,
|
ObservedFact,
|
||||||
@@ -278,6 +285,68 @@ class RegistryService:
|
|||||||
self.store.update_repository_status(repository_id, "indexed")
|
self.store.update_repository_status(repository_id, "indexed")
|
||||||
return self.store.get_ability_map(repository_id)
|
return self.store.get_ability_map(repository_id)
|
||||||
|
|
||||||
|
def diff_analysis_runs(
|
||||||
|
self,
|
||||||
|
repository_id: int,
|
||||||
|
base_analysis_run_id: int,
|
||||||
|
target_analysis_run_id: int,
|
||||||
|
) -> AnalysisRunDiff:
|
||||||
|
repository = self.store.get_repository(repository_id)
|
||||||
|
base_run = self.store.get_analysis_run(repository_id, base_analysis_run_id)
|
||||||
|
target_run = self.store.get_analysis_run(repository_id, target_analysis_run_id)
|
||||||
|
base_graph = self.store.get_candidate_graph(repository_id, base_analysis_run_id)
|
||||||
|
target_graph = self.store.get_candidate_graph(repository_id, target_analysis_run_id)
|
||||||
|
approved_map = self.store.get_ability_map(repository_id)
|
||||||
|
|
||||||
|
return AnalysisRunDiff(
|
||||||
|
repository=repository,
|
||||||
|
base_run=base_run,
|
||||||
|
target_run=target_run,
|
||||||
|
facts=self._diff_items(
|
||||||
|
self._fact_index(
|
||||||
|
self.store.list_observed_facts(repository_id, base_analysis_run_id)
|
||||||
|
),
|
||||||
|
self._fact_index(
|
||||||
|
self.store.list_observed_facts(repository_id, target_analysis_run_id)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chunks=self._diff_items(
|
||||||
|
self._chunk_index(
|
||||||
|
self.store.list_content_chunks(repository_id, base_analysis_run_id)
|
||||||
|
),
|
||||||
|
self._chunk_index(
|
||||||
|
self.store.list_content_chunks(repository_id, target_analysis_run_id)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
candidates=self._diff_items(
|
||||||
|
self._candidate_index(base_graph.abilities),
|
||||||
|
self._candidate_index(target_graph.abilities),
|
||||||
|
),
|
||||||
|
approved_entries=self._diff_items(
|
||||||
|
self._approved_index(approved_map.abilities),
|
||||||
|
self._candidate_index(target_graph.abilities),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def approve_analysis_run_changes(
|
||||||
|
self,
|
||||||
|
repository_id: int,
|
||||||
|
analysis_run_id: int,
|
||||||
|
*,
|
||||||
|
notes: str = "",
|
||||||
|
) -> RepositoryAbilityMap:
|
||||||
|
graph = self.store.get_candidate_graph(repository_id, analysis_run_id)
|
||||||
|
self.store.replace_approved_from_candidate_graph(repository_id, graph)
|
||||||
|
self.store.mark_candidate_graph_status(repository_id, analysis_run_id, "approved")
|
||||||
|
self.store.create_review_decision(
|
||||||
|
repository_id,
|
||||||
|
analysis_run_id,
|
||||||
|
action="approve_analysis_run_changes",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
self.store.update_repository_status(repository_id, "indexed")
|
||||||
|
return self.store.get_ability_map(repository_id)
|
||||||
|
|
||||||
def reject_candidate_ability(
|
def reject_candidate_ability(
|
||||||
self,
|
self,
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
@@ -975,6 +1044,263 @@ class RegistryService:
|
|||||||
return strength
|
return strength
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _diff_items(
|
||||||
|
self,
|
||||||
|
base: dict[str, dict[str, object]],
|
||||||
|
target: dict[str, dict[str, object]],
|
||||||
|
) -> AnalysisRunDiffSection:
|
||||||
|
added: list[AnalysisRunDiffItem] = []
|
||||||
|
removed: list[AnalysisRunDiffItem] = []
|
||||||
|
changed: list[AnalysisRunDiffItem] = []
|
||||||
|
weakened: list[AnalysisRunDiffItem] = []
|
||||||
|
|
||||||
|
for key in sorted(target.keys() - base.keys()):
|
||||||
|
added.append(
|
||||||
|
AnalysisRunDiffItem(
|
||||||
|
change_type="added",
|
||||||
|
item_type=str(target[key]["item_type"]),
|
||||||
|
key=key,
|
||||||
|
target=target[key],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in sorted(base.keys() - target.keys()):
|
||||||
|
removed.append(
|
||||||
|
AnalysisRunDiffItem(
|
||||||
|
change_type="removed",
|
||||||
|
item_type=str(base[key]["item_type"]),
|
||||||
|
key=key,
|
||||||
|
base=base[key],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in sorted(base.keys() & target.keys()):
|
||||||
|
if base[key] == target[key]:
|
||||||
|
continue
|
||||||
|
item = AnalysisRunDiffItem(
|
||||||
|
change_type="weakened" if self._is_weakened(base[key], target[key]) else "changed",
|
||||||
|
item_type=str(target[key]["item_type"]),
|
||||||
|
key=key,
|
||||||
|
base=base[key],
|
||||||
|
target=target[key],
|
||||||
|
)
|
||||||
|
if item.change_type == "weakened":
|
||||||
|
weakened.append(item)
|
||||||
|
else:
|
||||||
|
changed.append(item)
|
||||||
|
return AnalysisRunDiffSection(
|
||||||
|
added=added,
|
||||||
|
removed=removed,
|
||||||
|
changed=changed,
|
||||||
|
weakened=weakened,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_weakened(
|
||||||
|
self,
|
||||||
|
base: dict[str, object],
|
||||||
|
target: dict[str, object],
|
||||||
|
) -> bool:
|
||||||
|
base_confidence = base.get("confidence")
|
||||||
|
target_confidence = target.get("confidence")
|
||||||
|
if (
|
||||||
|
isinstance(base_confidence, int | float)
|
||||||
|
and isinstance(target_confidence, int | float)
|
||||||
|
and target_confidence < base_confidence
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
base_strength = base.get("strength")
|
||||||
|
target_strength = target.get("strength")
|
||||||
|
strength_order = {"weak": 1, "medium": 2, "strong": 3}
|
||||||
|
return (
|
||||||
|
isinstance(base_strength, str)
|
||||||
|
and isinstance(target_strength, str)
|
||||||
|
and strength_order.get(target_strength, 0) < strength_order.get(base_strength, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fact_index(self, facts: Sequence[ObservedFact]) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
f"fact:{fact.kind}:{fact.path}:{fact.name}": {
|
||||||
|
"item_type": "fact",
|
||||||
|
"kind": fact.kind,
|
||||||
|
"path": fact.path,
|
||||||
|
"name": fact.name,
|
||||||
|
"value": fact.value,
|
||||||
|
"metadata": fact.metadata,
|
||||||
|
}
|
||||||
|
for fact in facts
|
||||||
|
}
|
||||||
|
|
||||||
|
def _chunk_index(
|
||||||
|
self,
|
||||||
|
chunks: Sequence[ContentChunk],
|
||||||
|
) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
f"chunk:{chunk.kind}:{chunk.path}:{chunk.start_line}:{chunk.end_line}": {
|
||||||
|
"item_type": "chunk",
|
||||||
|
"kind": chunk.kind,
|
||||||
|
"path": chunk.path,
|
||||||
|
"start_line": chunk.start_line,
|
||||||
|
"end_line": chunk.end_line,
|
||||||
|
"text": chunk.text,
|
||||||
|
}
|
||||||
|
for chunk in chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
def _candidate_index(
|
||||||
|
self,
|
||||||
|
abilities: Sequence[CandidateAbility],
|
||||||
|
) -> dict[str, dict[str, object]]:
|
||||||
|
index: dict[str, dict[str, object]] = {}
|
||||||
|
for ability in abilities:
|
||||||
|
ability_key = self._entry_key("ability", ability.name)
|
||||||
|
index[ability_key] = {
|
||||||
|
"item_type": "ability",
|
||||||
|
"name": ability.name,
|
||||||
|
"description": ability.description,
|
||||||
|
"confidence": ability.confidence,
|
||||||
|
"status": ability.status,
|
||||||
|
}
|
||||||
|
for capability in ability.capabilities:
|
||||||
|
capability_key = self._entry_key(
|
||||||
|
"capability",
|
||||||
|
ability.name,
|
||||||
|
capability.name,
|
||||||
|
)
|
||||||
|
index[capability_key] = {
|
||||||
|
"item_type": "capability",
|
||||||
|
"ability_name": ability.name,
|
||||||
|
"name": capability.name,
|
||||||
|
"description": capability.description,
|
||||||
|
"inputs": capability.inputs,
|
||||||
|
"outputs": capability.outputs,
|
||||||
|
"confidence": capability.confidence,
|
||||||
|
"status": capability.status,
|
||||||
|
}
|
||||||
|
self._index_candidate_leaves(index, ability, capability)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def _index_candidate_leaves(
|
||||||
|
self,
|
||||||
|
index: dict[str, dict[str, object]],
|
||||||
|
ability: CandidateAbility,
|
||||||
|
capability: CandidateCapability,
|
||||||
|
) -> None:
|
||||||
|
for feature in capability.features:
|
||||||
|
key = self._entry_key(
|
||||||
|
"feature",
|
||||||
|
ability.name,
|
||||||
|
capability.name,
|
||||||
|
feature.name,
|
||||||
|
feature.type,
|
||||||
|
feature.location,
|
||||||
|
)
|
||||||
|
index[key] = self._feature_payload(
|
||||||
|
feature,
|
||||||
|
ability_name=ability.name,
|
||||||
|
capability_name=capability.name,
|
||||||
|
)
|
||||||
|
for evidence in capability.evidence:
|
||||||
|
key = self._entry_key(
|
||||||
|
"evidence",
|
||||||
|
ability.name,
|
||||||
|
capability.name,
|
||||||
|
evidence.type,
|
||||||
|
evidence.reference,
|
||||||
|
)
|
||||||
|
index[key] = self._evidence_payload(
|
||||||
|
evidence,
|
||||||
|
ability_name=ability.name,
|
||||||
|
capability_name=capability.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _approved_index(self, abilities) -> dict[str, dict[str, object]]:
|
||||||
|
index: dict[str, dict[str, object]] = {}
|
||||||
|
for ability in abilities:
|
||||||
|
ability_key = self._entry_key("ability", ability.name)
|
||||||
|
index[ability_key] = {
|
||||||
|
"item_type": "ability",
|
||||||
|
"name": ability.name,
|
||||||
|
"description": ability.description,
|
||||||
|
"confidence": ability.confidence,
|
||||||
|
}
|
||||||
|
for capability in ability.capabilities:
|
||||||
|
capability_key = self._entry_key(
|
||||||
|
"capability",
|
||||||
|
ability.name,
|
||||||
|
capability.name,
|
||||||
|
)
|
||||||
|
index[capability_key] = {
|
||||||
|
"item_type": "capability",
|
||||||
|
"ability_name": ability.name,
|
||||||
|
"name": capability.name,
|
||||||
|
"description": capability.description,
|
||||||
|
"inputs": capability.inputs,
|
||||||
|
"outputs": capability.outputs,
|
||||||
|
"confidence": capability.confidence,
|
||||||
|
}
|
||||||
|
for feature in capability.features:
|
||||||
|
key = self._entry_key(
|
||||||
|
"feature",
|
||||||
|
ability.name,
|
||||||
|
capability.name,
|
||||||
|
feature.name,
|
||||||
|
feature.type,
|
||||||
|
feature.location,
|
||||||
|
)
|
||||||
|
index[key] = self._feature_payload(
|
||||||
|
feature,
|
||||||
|
ability_name=ability.name,
|
||||||
|
capability_name=capability.name,
|
||||||
|
)
|
||||||
|
for evidence in capability.evidence:
|
||||||
|
key = self._entry_key(
|
||||||
|
"evidence",
|
||||||
|
ability.name,
|
||||||
|
capability.name,
|
||||||
|
evidence.type,
|
||||||
|
evidence.reference,
|
||||||
|
)
|
||||||
|
index[key] = self._evidence_payload(
|
||||||
|
evidence,
|
||||||
|
ability_name=ability.name,
|
||||||
|
capability_name=capability.name,
|
||||||
|
)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def _feature_payload(
|
||||||
|
self,
|
||||||
|
feature: CandidateFeature,
|
||||||
|
*,
|
||||||
|
ability_name: str,
|
||||||
|
capability_name: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"item_type": "feature",
|
||||||
|
"ability_name": ability_name,
|
||||||
|
"capability_name": capability_name,
|
||||||
|
"name": feature.name,
|
||||||
|
"type": feature.type,
|
||||||
|
"location": feature.location,
|
||||||
|
"confidence": feature.confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _evidence_payload(
|
||||||
|
self,
|
||||||
|
evidence: CandidateEvidence,
|
||||||
|
*,
|
||||||
|
ability_name: str,
|
||||||
|
capability_name: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"item_type": "evidence",
|
||||||
|
"ability_name": ability_name,
|
||||||
|
"capability_name": capability_name,
|
||||||
|
"type": evidence.type,
|
||||||
|
"reference": evidence.reference,
|
||||||
|
"strength": evidence.strength,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _entry_key(self, *parts: str) -> str:
|
||||||
|
return ":".join(part.strip().lower() for part in parts)
|
||||||
|
|
||||||
def _yaml_list(self, values: Sequence[str]) -> str:
|
def _yaml_list(self, values: Sequence[str]) -> str:
|
||||||
return "[" + ", ".join(self._yaml_scalar(value) for value in values) + "]"
|
return "[" + ", ".join(self._yaml_scalar(value) for value in values) + "]"
|
||||||
|
|
||||||
|
|||||||
@@ -1512,6 +1512,99 @@ class RegistryStore:
|
|||||||
row_id=evidence_id,
|
row_id=evidence_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def replace_approved_from_candidate_graph(
|
||||||
|
self,
|
||||||
|
repository_id: int,
|
||||||
|
graph: CandidateGraph,
|
||||||
|
) -> None:
|
||||||
|
if graph.repository.id != repository_id:
|
||||||
|
raise NotFoundError(
|
||||||
|
f"candidate graph for repository {graph.repository.id} does not match "
|
||||||
|
f"repository {repository_id}"
|
||||||
|
)
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM approved_abilities WHERE repository_id = ?",
|
||||||
|
(repository_id,),
|
||||||
|
)
|
||||||
|
for ability in graph.abilities:
|
||||||
|
if ability.status not in {"candidate", "approved"}:
|
||||||
|
continue
|
||||||
|
ability_cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO approved_abilities
|
||||||
|
(repository_id, name, description, confidence)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
repository_id,
|
||||||
|
ability.name,
|
||||||
|
ability.description,
|
||||||
|
ability.confidence,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
approved_ability_id = int(ability_cursor.lastrowid)
|
||||||
|
for capability in ability.capabilities:
|
||||||
|
if capability.status not in {"candidate", "approved"}:
|
||||||
|
continue
|
||||||
|
capability_cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO approved_capabilities
|
||||||
|
(repository_id, ability_id, name, description, inputs, outputs,
|
||||||
|
confidence)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
repository_id,
|
||||||
|
approved_ability_id,
|
||||||
|
capability.name,
|
||||||
|
capability.description,
|
||||||
|
json.dumps(capability.inputs),
|
||||||
|
json.dumps(capability.outputs),
|
||||||
|
capability.confidence,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
approved_capability_id = int(capability_cursor.lastrowid)
|
||||||
|
for feature in capability.features:
|
||||||
|
if feature.status not in {"candidate", "approved"}:
|
||||||
|
continue
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO approved_features
|
||||||
|
(repository_id, capability_id, name, type, location,
|
||||||
|
confidence, source_refs)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
repository_id,
|
||||||
|
approved_capability_id,
|
||||||
|
feature.name,
|
||||||
|
feature.type,
|
||||||
|
feature.location,
|
||||||
|
feature.confidence,
|
||||||
|
self._source_refs_to_json(feature.source_refs),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for evidence in capability.evidence:
|
||||||
|
if evidence.status not in {"candidate", "approved"}:
|
||||||
|
continue
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO approved_evidence
|
||||||
|
(repository_id, capability_id, type, reference, strength,
|
||||||
|
source_refs)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
repository_id,
|
||||||
|
approved_capability_id,
|
||||||
|
evidence.type,
|
||||||
|
evidence.reference,
|
||||||
|
evidence.strength,
|
||||||
|
self._source_refs_to_json(evidence.source_refs),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap:
|
def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap:
|
||||||
repository = self.get_repository(repository_id)
|
repository = self.get_repository(repository_id)
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from repo_registry.web_api.schemas import (
|
|||||||
AbilityCreate,
|
AbilityCreate,
|
||||||
AbilitySummaryResponse,
|
AbilitySummaryResponse,
|
||||||
AbilityUpdate,
|
AbilityUpdate,
|
||||||
|
AnalysisRunChangeApproval,
|
||||||
AnalysisRunCreate,
|
AnalysisRunCreate,
|
||||||
|
AnalysisRunDiffResponse,
|
||||||
AnalysisRunResponse,
|
AnalysisRunResponse,
|
||||||
CandidateAbilityMerge,
|
CandidateAbilityMerge,
|
||||||
CandidateCapabilityMerge,
|
CandidateCapabilityMerge,
|
||||||
@@ -239,6 +241,29 @@ def get_analysis_run(
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}",
|
||||||
|
tags=["review"],
|
||||||
|
response_model=AnalysisRunDiffResponse,
|
||||||
|
)
|
||||||
|
def diff_analysis_runs(
|
||||||
|
repository_id: int,
|
||||||
|
base_analysis_run_id: int,
|
||||||
|
target_analysis_run_id: int,
|
||||||
|
service: RegistryService = Depends(get_service),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return asdict(
|
||||||
|
service.diff_analysis_runs(
|
||||||
|
repository_id,
|
||||||
|
base_analysis_run_id,
|
||||||
|
target_analysis_run_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except NotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/repos/{repository_id}/review-decisions",
|
"/repos/{repository_id}/review-decisions",
|
||||||
tags=["review"],
|
tags=["review"],
|
||||||
@@ -375,6 +400,29 @@ def approve_candidate_graph(
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve",
|
||||||
|
tags=["review"],
|
||||||
|
response_model=RepositoryAbilityMapResponse,
|
||||||
|
)
|
||||||
|
def approve_analysis_run_changes(
|
||||||
|
repository_id: int,
|
||||||
|
analysis_run_id: int,
|
||||||
|
payload: AnalysisRunChangeApproval,
|
||||||
|
service: RegistryService = Depends(get_service),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return asdict(
|
||||||
|
service.approve_analysis_run_changes(
|
||||||
|
repository_id,
|
||||||
|
analysis_run_id,
|
||||||
|
notes=payload.notes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except NotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
"/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
||||||
"/candidate-abilities/{candidate_ability_id}/reject",
|
"/candidate-abilities/{candidate_ability_id}/reject",
|
||||||
|
|||||||
@@ -206,6 +206,16 @@ class CandidateGraphApproval(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisRunChangeApproval(BaseModel):
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [{"notes": "Accept target run changes after review."}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CandidateRejection(BaseModel):
|
class CandidateRejection(BaseModel):
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
@@ -506,6 +516,31 @@ class CandidateGraphResponse(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisRunDiffItemResponse(BaseModel):
|
||||||
|
change_type: str
|
||||||
|
item_type: str
|
||||||
|
key: str
|
||||||
|
base: dict[str, Any] | None = None
|
||||||
|
target: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisRunDiffSectionResponse(BaseModel):
|
||||||
|
added: list[AnalysisRunDiffItemResponse]
|
||||||
|
removed: list[AnalysisRunDiffItemResponse]
|
||||||
|
changed: list[AnalysisRunDiffItemResponse]
|
||||||
|
weakened: list[AnalysisRunDiffItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisRunDiffResponse(BaseModel):
|
||||||
|
repository: RepositoryResponse
|
||||||
|
base_run: AnalysisRunResponse
|
||||||
|
target_run: AnalysisRunResponse
|
||||||
|
facts: AnalysisRunDiffSectionResponse
|
||||||
|
chunks: AnalysisRunDiffSectionResponse
|
||||||
|
candidates: AnalysisRunDiffSectionResponse
|
||||||
|
approved_entries: AnalysisRunDiffSectionResponse
|
||||||
|
|
||||||
|
|
||||||
class EvidenceResponse(BaseModel):
|
class EvidenceResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
type: str
|
type: str
|
||||||
|
|||||||
@@ -509,6 +509,69 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path):
|
|||||||
assert decisions[0].notes == "Looks good for the first pass."
|
assert decisions[0].notes == "Looks good for the first pass."
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_run_diff_keeps_approved_map_stable_until_change_approval(tmp_path):
|
||||||
|
source = tmp_path / "repo"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "README.md").write_text("# Example\n", encoding="utf-8")
|
||||||
|
app_file = source / "app.py"
|
||||||
|
app_file.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="Example", url=str(source))
|
||||||
|
first_summary = service.analyze_repository(repository.id)
|
||||||
|
approved_before = service.approve_candidate_graph(
|
||||||
|
repository.id,
|
||||||
|
first_summary.analysis_run.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_file.write_text(
|
||||||
|
"from fastapi import FastAPI\n"
|
||||||
|
"app = FastAPI()\n"
|
||||||
|
'@app.get("/ready")\n'
|
||||||
|
"def ready():\n"
|
||||||
|
" return {}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
second_summary = service.analyze_repository(repository.id)
|
||||||
|
approved_after_analysis = service.ability_map(repository.id)
|
||||||
|
diff = service.diff_analysis_runs(
|
||||||
|
repository.id,
|
||||||
|
first_summary.analysis_run.id,
|
||||||
|
second_summary.analysis_run.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert approved_after_analysis.abilities[0].capabilities[0].features[0].name == (
|
||||||
|
approved_before.abilities[0].capabilities[0].features[0].name
|
||||||
|
)
|
||||||
|
assert any(item.item_type == "feature" for item in diff.candidates.added)
|
||||||
|
assert any(item.item_type == "feature" for item in diff.candidates.removed)
|
||||||
|
assert any(item.item_type == "feature" for item in diff.approved_entries.added)
|
||||||
|
assert any(item.item_type == "feature" for item in diff.approved_entries.removed)
|
||||||
|
|
||||||
|
approved_after_review = service.approve_analysis_run_changes(
|
||||||
|
repository.id,
|
||||||
|
second_summary.analysis_run.id,
|
||||||
|
notes="Accept route change.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert approved_after_review.abilities[0].capabilities[0].features[0].name == (
|
||||||
|
"GET /ready"
|
||||||
|
)
|
||||||
|
decisions = service.list_review_decisions(
|
||||||
|
repository.id,
|
||||||
|
second_summary.analysis_run.id,
|
||||||
|
)
|
||||||
|
assert decisions[0].action == "approve_analysis_run_changes"
|
||||||
|
assert decisions[0].notes == "Accept route change."
|
||||||
|
|
||||||
|
|
||||||
def test_reject_candidate_ability_excludes_it_from_approval(tmp_path):
|
def test_reject_candidate_ability_excludes_it_from_approval(tmp_path):
|
||||||
source = tmp_path / "repo"
|
source = tmp_path / "repo"
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ def test_openapi_groups_agent_facing_endpoints():
|
|||||||
assert components["CandidateGraphResponse"]["examples"][0]["abilities"][0][
|
assert components["CandidateGraphResponse"]["examples"][0]["abilities"][0][
|
||||||
"status"
|
"status"
|
||||||
] == "pending"
|
] == "pending"
|
||||||
|
assert (
|
||||||
|
"/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/"
|
||||||
|
"{target_analysis_run_id}"
|
||||||
|
) in schema["paths"]
|
||||||
|
assert (
|
||||||
|
"/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve"
|
||||||
|
) in schema["paths"]
|
||||||
|
|
||||||
|
|
||||||
def test_docs_endpoint_is_available():
|
def test_docs_endpoint_is_available():
|
||||||
|
|||||||
Reference in New Issue
Block a user