Status drift warnings

This commit is contained in:
2026-05-02 11:54:07 +02:00
parent 06ed12b2c7
commit 010d23bc38
10 changed files with 538 additions and 8 deletions

View File

@@ -129,6 +129,18 @@ class ScanSummary:
facts: list[ObservedFact]
@dataclass(frozen=True)
class CharacteristicRebuildResult:
repository: Repository
analysis_run: AnalysisRun
dry_run: bool
confirmed: bool
cleared_approved: bool
previous_counts: dict[str, int]
previous_ids: dict[str, list[int]]
candidate_counts: dict[str, int]
@dataclass(frozen=True)
class SourceReference:
fact_id: int | None

View File

@@ -15,6 +15,7 @@ from repo_registry.core.models import (
CandidateEvidence,
CandidateFeature,
CandidateGraph,
CharacteristicRebuildResult,
ContentChunk,
ExpectationGap,
ObservedFact,
@@ -228,14 +229,13 @@ class RegistryService:
notes=f"Generated {len(candidates)} candidate ability draft(s).",
)
if trusted_auto_approve:
self.approve_candidate_graph(
self.trusted_auto_approve_candidate_graph(
repository_id,
completed_run.id,
notes=(
"Trusted auto-populate mode approved candidate graph "
"Trusted auto-populate mode reviewed candidate graph "
f"after {candidate_source} candidate generation."
),
action="trusted_auto_approve_candidate_graph",
)
log_operation(
"analysis_completed",
@@ -335,6 +335,79 @@ class RegistryService:
def candidate_graph(self, repository_id: int, analysis_run_id: int) -> CandidateGraph:
return self.store.get_candidate_graph(repository_id, analysis_run_id)
def rebuild_characteristics_from_scratch(
self,
repository_id: int,
*,
dry_run: bool = True,
confirm: bool = False,
source_path: str | None = None,
use_cached_checkout: bool = False,
use_llm_assistance: bool = True,
access_username: str | None = None,
access_password: str | None = None,
) -> CharacteristicRebuildResult:
if not dry_run and not confirm:
raise ValueError("confirmed rebuild requires confirm=True")
repository = self.store.get_repository(repository_id)
previous_counts = self._approved_counts(repository_id)
previous_ids = self._approved_ids(repository_id)
summary = self.analyze_repository(
repository_id,
source_path=source_path,
use_cached_checkout=use_cached_checkout,
use_llm_assistance=use_llm_assistance,
trusted_auto_approve=False,
access_username=access_username,
access_password=access_password,
)
if summary.analysis_run.status != "completed":
return CharacteristicRebuildResult(
repository=repository,
analysis_run=summary.analysis_run,
dry_run=dry_run,
confirmed=confirm,
cleared_approved=False,
previous_counts=previous_counts,
previous_ids=previous_ids,
candidate_counts={},
)
graph = self.store.get_candidate_graph(repository_id, summary.analysis_run.id)
candidate_counts = self._candidate_counts(graph)
cleared = False
if not dry_run:
self.store.clear_approved_characteristics(repository_id)
self.store.update_repository_status(repository_id, "analyzed")
cleared = True
action = (
"rebuild_characteristics_from_scratch"
if cleared
else "dry_run_rebuild_characteristics_from_scratch"
)
self.store.create_review_decision(
repository_id,
summary.analysis_run.id,
action=action,
notes=(
f"Previous approved counts: {previous_counts}. "
f"Previous approved IDs: {previous_ids}. "
f"New candidate counts: {candidate_counts}."
),
)
return CharacteristicRebuildResult(
repository=repository,
analysis_run=summary.analysis_run,
dry_run=dry_run,
confirmed=confirm,
cleared_approved=cleared,
previous_counts=previous_counts,
previous_ids=previous_ids,
candidate_counts=candidate_counts,
)
def approve_candidate_graph(
self,
repository_id: int,
@@ -406,6 +479,160 @@ class RegistryService:
self.store.update_repository_status(repository_id, "indexed")
return self.store.get_ability_map(repository_id)
def trusted_auto_approve_candidate_graph(
self,
repository_id: int,
analysis_run_id: int,
*,
notes: str = "",
) -> RepositoryAbilityMap:
graph = self.store.get_candidate_graph(repository_id, analysis_run_id)
approved_count = 0
skipped_count = 0
for ability in graph.abilities:
if ability.status != "candidate":
continue
candidate_capabilities = [
capability
for capability in ability.capabilities
if capability.status == "candidate"
]
safe_capabilities = [
capability
for capability in candidate_capabilities
if self._trusted_auto_approve_capability_safe(capability)
]
skipped_count += len(candidate_capabilities) - len(safe_capabilities)
if not safe_capabilities:
continue
approved_ability_id = self._ensure_approved_ability(repository_id, ability)
for capability in safe_capabilities:
self._create_approved_capability_subtree(
repository_id,
approved_ability_id,
capability,
)
self.store.mark_candidate_capability_status(
repository_id,
analysis_run_id,
capability.id,
"approved",
)
approved_count += 1
if len(safe_capabilities) == len(candidate_capabilities):
self.store.mark_candidate_ability_status(
repository_id,
analysis_run_id,
ability.id,
"approved",
)
if approved_count:
self.store.update_repository_status(repository_id, "indexed")
self.store.create_review_decision(
repository_id,
analysis_run_id,
action="trusted_auto_approve_candidate_graph",
notes=(
f"{notes} Auto-approved {approved_count} safe candidate "
f"capability(s); left {skipped_count} for review."
).strip(),
)
return self.store.get_ability_map(repository_id)
def _trusted_auto_approve_capability_safe(
self,
capability: CandidateCapability,
) -> bool:
has_source_refs = bool(capability.source_refs) or any(
feature.source_refs for feature in capability.features
)
if not has_source_refs:
return False
if capability.primary_class == "repository-structure":
return False
if capability.primary_class == "llm-integration":
return bool(
{"utility-owned", "utility-facade", "utility-adapter"}
& set(capability.attributes)
)
if capability.primary_class in {"interface", "API", "CLI", "callable", "api", "cli"}:
return capability.confidence >= 0.55
if capability.features:
return capability.confidence >= 0.55
return capability.confidence >= 0.75
def _approved_counts(self, repository_id: int) -> dict[str, int]:
ability_map = self.store.get_ability_map(repository_id)
capabilities = [
capability
for ability in ability_map.abilities
for capability in ability.capabilities
]
features = [
feature
for capability in capabilities
for feature in capability.features
]
evidence = [
item
for capability in capabilities
for item in capability.evidence
]
return {
"abilities": len(ability_map.abilities),
"capabilities": len(capabilities),
"features": len(features),
"evidence": len(evidence),
}
def _approved_ids(self, repository_id: int) -> dict[str, list[int]]:
ability_map = self.store.get_ability_map(repository_id)
capabilities = [
capability
for ability in ability_map.abilities
for capability in ability.capabilities
]
features = [
feature
for capability in capabilities
for feature in capability.features
]
evidence = [
item
for capability in capabilities
for item in capability.evidence
]
return {
"abilities": [ability.id for ability in ability_map.abilities],
"capabilities": [capability.id for capability in capabilities],
"features": [feature.id for feature in features],
"evidence": [item.id for item in evidence],
}
def _candidate_counts(self, graph: CandidateGraph) -> dict[str, int]:
capabilities = [
capability
for ability in graph.abilities
for capability in ability.capabilities
]
features = [
feature
for capability in capabilities
for feature in capability.features
]
evidence = [
item
for capability in capabilities
for item in capability.evidence
]
return {
"abilities": len(graph.abilities),
"capabilities": len(capabilities),
"features": len(features),
"evidence": len(evidence),
}
def accept_candidate_ability(
self,
repository_id: int,

View File

@@ -2226,6 +2226,14 @@ class RegistryStore:
),
)
def clear_approved_characteristics(self, repository_id: int) -> None:
self.get_repository(repository_id)
with self.connect() as connection:
connection.execute(
"DELETE FROM approved_abilities WHERE repository_id = ?",
(repository_id,),
)
def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap:
repository = self.get_repository(repository_id)
scope = self._ensure_scope(repository_id)

View File

@@ -34,6 +34,8 @@ from repo_registry.web_api.schemas import (
CandidateFeatureMerge,
CandidateGraphApproval,
CandidateGraphResponse,
CharacteristicRebuildRequest,
CharacteristicRebuildResponse,
CandidateLeafRelink,
CandidateRejection,
CapabilityGapRequest,
@@ -271,6 +273,33 @@ def create_analysis_run(
return asdict(summary)
@app.post(
"/repos/{repository_id}/characteristics/rebuild",
tags=["analysis"],
response_model=CharacteristicRebuildResponse,
)
def rebuild_characteristics_from_scratch(
repository_id: int,
payload: CharacteristicRebuildRequest,
service: RegistryService = Depends(get_service),
) -> dict[str, object]:
try:
return asdict(
service.rebuild_characteristics_from_scratch(
repository_id,
dry_run=payload.dry_run,
confirm=payload.confirm,
source_path=payload.source_path,
use_cached_checkout=payload.use_cached_checkout,
use_llm_assistance=payload.use_llm_assistance,
)
)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get(
"/repos/{repository_id}/analysis-runs",
tags=["analysis"],

View File

@@ -289,6 +289,25 @@ class AnalysisRunChangeApproval(BaseModel):
}
class CharacteristicRebuildRequest(BaseModel):
dry_run: bool = True
confirm: bool = False
source_path: str | None = None
use_cached_checkout: bool = False
use_llm_assistance: bool = True
class CharacteristicRebuildResponse(BaseModel):
repository: RepositoryResponse
analysis_run: AnalysisRunResponse
dry_run: bool
confirmed: bool
cleared_approved: bool
previous_counts: dict[str, int]
previous_ids: dict[str, list[int]]
candidate_counts: dict[str, int]
class CandidateRejection(BaseModel):
notes: str = ""

View File

@@ -721,6 +721,18 @@ def repository_detail(
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>Compare</th><th>Error</th></tr></thead>
<tbody>{run_rows or '<tr><td colspan="5" class="muted">No runs yet.</td></tr>'}</tbody>
</table>
<h2>Rebuild Characteristics</h2>
<form class="stack" method="post" action="/ui/repos/{repository_id}/characteristics/rebuild">
<label>Override source path <input name="source_path" placeholder="Optional local path"></label>
<label class="checkbox"><input type="checkbox" name="use_cached_checkout" value="1"> Use cached checkout</label>
<label class="checkbox"><input type="checkbox" name="use_llm_assistance" value="1" checked> Use LLM assistance if configured</label>
<label class="checkbox"><input type="checkbox" name="dry_run" value="1" checked> Dry run only</label>
<label class="checkbox"><input type="checkbox" name="confirm" value="1"> Confirm clearing approved characteristics when dry run is off</label>
<div class="actions">
<button class="secondary" type="submit">Rebuild</button>
<span data-pending>Rebuilding...</span>
</div>
</form>
</section>
<section class="stack">
<div class="panel">
@@ -1146,6 +1158,30 @@ def create_analysis_run_from_form(
)
@router.post("/ui/repos/{repository_id}/characteristics/rebuild")
def rebuild_characteristics_from_form(
repository_id: int,
source_path: str = Form(""),
use_cached_checkout: str | None = Form(None),
use_llm_assistance: str | None = Form(None),
dry_run: str | None = Form(None),
confirm: str | None = Form(None),
service: RegistryService = Depends(get_service),
) -> RedirectResponse:
result = service.rebuild_characteristics_from_scratch(
repository_id,
dry_run=bool(dry_run),
confirm=bool(confirm),
source_path=source_path or None,
use_cached_checkout=bool(use_cached_checkout),
use_llm_assistance=bool(use_llm_assistance),
)
return RedirectResponse(
f"/ui/repos/{repository_id}/analysis-runs/{result.analysis_run.id}",
status_code=303,
)
@router.get("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}")
def analysis_run_detail(
repository_id: int,