Improved datamodel and deterministic generation

This commit is contained in:
2026-04-30 01:29:29 +02:00
parent 973d4bbe7c
commit 26e87ab52c
14 changed files with 848 additions and 39 deletions

View File

@@ -48,8 +48,8 @@ the Custodian State Hub. Read it at the start of each session. It documents:
## Active Workplan ## Active Workplan
`workplans/RREG-WP-0002-production-hardening.md` is the current active workplan. `workplans/RREG-WP-0004-characteristic-classification-navigation.md` is the
Six tasks remain open (T01T06). Start with T01 (P0: Update Safety and Change Review) current active workplan. Start with T01 (P0: Characteristic Classification Fields)
as the highest-priority item. as the highest-priority item.
--- ---

View File

@@ -62,6 +62,8 @@ CREATE TABLE IF NOT EXISTS candidate_abilities (
analysis_run_id INTEGER NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE, analysis_run_id INTEGER NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
primary_class TEXT NOT NULL DEFAULT 'ability',
attributes TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 0.0, confidence REAL NOT NULL DEFAULT 0.0,
status TEXT NOT NULL DEFAULT 'candidate', status TEXT NOT NULL DEFAULT 'candidate',
source_refs TEXT NOT NULL DEFAULT '[]', source_refs TEXT NOT NULL DEFAULT '[]',
@@ -77,6 +79,8 @@ CREATE TABLE IF NOT EXISTS candidate_capabilities (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
inputs TEXT NOT NULL DEFAULT '[]', inputs TEXT NOT NULL DEFAULT '[]',
outputs TEXT NOT NULL DEFAULT '[]', outputs TEXT NOT NULL DEFAULT '[]',
primary_class TEXT NOT NULL DEFAULT 'capability',
attributes TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 0.0, confidence REAL NOT NULL DEFAULT 0.0,
status TEXT NOT NULL DEFAULT 'candidate', status TEXT NOT NULL DEFAULT 'candidate',
source_refs TEXT NOT NULL DEFAULT '[]', source_refs TEXT NOT NULL DEFAULT '[]',
@@ -90,6 +94,8 @@ CREATE TABLE IF NOT EXISTS candidate_features (
capability_id INTEGER NOT NULL REFERENCES candidate_capabilities(id) ON DELETE CASCADE, capability_id INTEGER NOT NULL REFERENCES candidate_capabilities(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
primary_class TEXT NOT NULL DEFAULT '',
attributes TEXT NOT NULL DEFAULT '[]',
location TEXT NOT NULL DEFAULT '', location TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0.0, confidence REAL NOT NULL DEFAULT 0.0,
status TEXT NOT NULL DEFAULT 'candidate', status TEXT NOT NULL DEFAULT 'candidate',
@@ -128,6 +134,8 @@ CREATE TABLE IF NOT EXISTS repository_scopes (
repository_id INTEGER NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE, repository_id INTEGER NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
primary_class TEXT NOT NULL DEFAULT 'ability',
attributes TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 1.0, confidence REAL NOT NULL DEFAULT 1.0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -149,6 +157,8 @@ CREATE TABLE IF NOT EXISTS approved_capabilities (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
inputs TEXT NOT NULL DEFAULT '[]', inputs TEXT NOT NULL DEFAULT '[]',
outputs TEXT NOT NULL DEFAULT '[]', outputs TEXT NOT NULL DEFAULT '[]',
primary_class TEXT NOT NULL DEFAULT 'capability',
attributes TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 1.0, confidence REAL NOT NULL DEFAULT 1.0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -159,6 +169,8 @@ CREATE TABLE IF NOT EXISTS approved_features (
capability_id INTEGER NOT NULL REFERENCES approved_capabilities(id) ON DELETE CASCADE, capability_id INTEGER NOT NULL REFERENCES approved_capabilities(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
primary_class TEXT NOT NULL DEFAULT '',
attributes TEXT NOT NULL DEFAULT '[]',
location TEXT NOT NULL DEFAULT '', location TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 1.0, confidence REAL NOT NULL DEFAULT 1.0,
source_refs TEXT NOT NULL DEFAULT '[]', source_refs TEXT NOT NULL DEFAULT '[]',

View File

@@ -21,6 +21,8 @@ class CandidateFeatureDraft:
location: str location: str
confidence: float confidence: float
source_refs: list[SourceReference] source_refs: list[SourceReference]
primary_class: str = ""
attributes: list[str] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -31,6 +33,8 @@ class CandidateCapabilityDraft:
outputs: list[str] outputs: list[str]
confidence: float confidence: float
source_refs: list[SourceReference] source_refs: list[SourceReference]
primary_class: str = "capability"
attributes: list[str] = field(default_factory=list)
features: list[CandidateFeatureDraft] = field(default_factory=list) features: list[CandidateFeatureDraft] = field(default_factory=list)
evidence: list[CandidateEvidenceDraft] = field(default_factory=list) evidence: list[CandidateEvidenceDraft] = field(default_factory=list)
@@ -41,6 +45,8 @@ class CandidateAbilityDraft:
description: str description: str
confidence: float confidence: float
source_refs: list[SourceReference] source_refs: list[SourceReference]
primary_class: str = "ability"
attributes: list[str] = field(default_factory=list)
capabilities: list[CandidateCapabilityDraft] = field(default_factory=list) capabilities: list[CandidateCapabilityDraft] = field(default_factory=list)
@@ -68,6 +74,11 @@ class CandidateGraphGenerator:
credential_configs = self._facts(facts, "credential_config") credential_configs = self._facts(facts, "credential_config")
provider_registries = self._facts(facts, "provider_registry") provider_registries = self._facts(facts, "provider_registry")
fallback_policies = self._facts(facts, "fallback_policy") fallback_policies = self._facts(facts, "fallback_policy")
ability_primary_class, ability_attributes = self._ability_classification(
repository,
facts,
chunks,
)
ability_sources = docs or manifests or languages ability_sources = docs or manifests or languages
ability = CandidateAbilityDraft( ability = CandidateAbilityDraft(
@@ -82,6 +93,8 @@ class CandidateGraphGenerator:
languages=languages, languages=languages,
), ),
source_refs=self._source_refs(ability_sources), source_refs=self._source_refs(ability_sources),
primary_class=ability_primary_class,
attributes=ability_attributes,
capabilities=[], capabilities=[],
) )
@@ -119,6 +132,12 @@ class CandidateGraphGenerator:
docs=docs, docs=docs,
), ),
source_refs=self._source_refs(manifests + frameworks + languages), source_refs=self._source_refs(manifests + frameworks + languages),
primary_class="repository-structure",
attributes=self._structure_attributes(
manifests,
frameworks,
languages,
),
evidence=self._evidence(tests, examples, docs), evidence=self._evidence(tests, examples, docs),
) )
) )
@@ -129,6 +148,8 @@ class CandidateGraphGenerator:
description=ability.description, description=ability.description,
confidence=ability.confidence, confidence=ability.confidence,
source_refs=ability.source_refs, source_refs=ability.source_refs,
primary_class=ability.primary_class,
attributes=ability.attributes,
capabilities=capabilities, capabilities=capabilities,
) )
] ]
@@ -154,6 +175,8 @@ class CandidateGraphGenerator:
docs=docs, docs=docs,
), ),
source_refs=self._source_refs(interfaces), source_refs=self._source_refs(interfaces),
primary_class="interface",
attributes=self._interface_attributes(interfaces),
features=features, features=features,
evidence=self._evidence(tests, examples, docs), evidence=self._evidence(tests, examples, docs),
) )
@@ -181,6 +204,8 @@ class CandidateGraphGenerator:
source_refs=self._source_refs( source_refs=self._source_refs(
[fact for fact in providers if fact.name == provider] [fact for fact in providers if fact.name == provider]
), ),
primary_class="integration",
attributes=["llm-provider", provider.lower()],
) )
for provider in provider_names for provider in provider_names
] ]
@@ -192,6 +217,8 @@ class CandidateGraphGenerator:
location=self._grouped_location(credentials), location=self._grouped_location(credentials),
confidence=0.7, confidence=0.7,
source_refs=self._source_refs(credentials), source_refs=self._source_refs(credentials),
primary_class="configuration",
attributes=["credential", "llm-provider"],
) )
) )
if registries: if registries:
@@ -202,6 +229,8 @@ class CandidateGraphGenerator:
location=self._grouped_location(registries), location=self._grouped_location(registries),
confidence=0.65, confidence=0.65,
source_refs=self._source_refs(registries), source_refs=self._source_refs(registries),
primary_class="backend",
attributes=["provider-registry", "llm-provider"],
) )
) )
if fallback_policies: if fallback_policies:
@@ -212,6 +241,8 @@ class CandidateGraphGenerator:
location=self._grouped_location(fallback_policies), location=self._grouped_location(fallback_policies),
confidence=0.6, confidence=0.6,
source_refs=self._source_refs(fallback_policies), source_refs=self._source_refs(fallback_policies),
primary_class="backend",
attributes=["fallback-policy", "llm-provider"],
) )
) )
return CandidateCapabilityDraft( return CandidateCapabilityDraft(
@@ -232,6 +263,13 @@ class CandidateGraphGenerator:
source_refs=self._source_refs( source_refs=self._source_refs(
providers + credentials + registries + fallback_policies providers + credentials + registries + fallback_policies
), ),
primary_class="llm-integration",
attributes=self._llm_provider_attributes(
providers,
credentials,
registries,
fallback_policies,
),
features=features, features=features,
evidence=self._evidence(tests, examples, docs), evidence=self._evidence(tests, examples, docs),
) )
@@ -256,6 +294,8 @@ class CandidateGraphGenerator:
location=fact.path, location=fact.path,
confidence=0.65 if fact.value else 0.45, confidence=0.65 if fact.value else 0.45,
source_refs=self._source_refs([fact]), source_refs=self._source_refs([fact]),
primary_class=feature_type,
attributes=self._feature_attributes(feature_type, [fact]),
) )
) )
continue continue
@@ -271,6 +311,8 @@ class CandidateGraphGenerator:
location=self._grouped_location(facts), location=self._grouped_location(facts),
confidence=self._grouped_interface_confidence(facts), confidence=self._grouped_interface_confidence(facts),
source_refs=self._source_refs(facts), source_refs=self._source_refs(facts),
primary_class=feature_type,
attributes=self._feature_attributes(feature_type, facts),
) )
) )
return features return features
@@ -357,6 +399,96 @@ class CandidateGraphGenerator:
return "API" return "API"
return "interface" return "interface"
def _ability_classification(
self,
repository: Repository,
facts: list[ObservedFact],
chunks: list[ContentChunk],
) -> tuple[str, list[str]]:
text = " ".join(
[
repository.name,
repository.description or "",
" ".join(chunk.text[:600] for chunk in chunks if chunk.kind == "documentation"),
" ".join(f"{fact.kind} {fact.name} {fact.value}" for fact in facts),
]
).lower()
attributes: list[str] = []
if any(token in text for token in ("repository", "repo", "registry")):
attributes.append("repository")
if any(token in text for token in ("ability", "capability", "feature")):
return "repository-intelligence", self._unique(attributes + ["capability-mapping"])
if any(token in text for token in ("llm", "openrouter", "claude", "model provider")):
return "ai-integration", self._unique(attributes + ["llm-provider"])
if any(fact.kind == "interface" for fact in facts):
attributes.append("interface")
return "developer-tooling", self._unique(attributes)
def _interface_attributes(self, interfaces: list[ObservedFact]) -> list[str]:
feature_types = {self._feature_type(fact) for fact in interfaces}
attributes = ["api" if item == "API" else "cli" if item == "CLI" else "callable" for item in feature_types]
return self._unique(["surface", *attributes])
def _feature_attributes(
self,
feature_type: str,
facts: list[ObservedFact],
) -> list[str]:
attributes = [feature_type]
if feature_type == "API":
attributes.extend(["surface", "http"])
elif feature_type == "CLI":
attributes.extend(["surface", "command"])
else:
attributes.append("surface")
paths = " ".join(fact.path.lower() for fact in facts)
if "test" in paths:
attributes.append("test-linked")
return self._unique(attributes)
def _structure_attributes(
self,
manifests: list[ObservedFact],
frameworks: list[ObservedFact],
languages: list[ObservedFact],
) -> list[str]:
return self._unique(
[
"manifest" if manifests else "",
*[fact.name for fact in frameworks],
*[fact.name for fact in languages],
]
)
def _llm_provider_attributes(
self,
providers: list[ObservedFact],
credentials: list[ObservedFact],
registries: list[ObservedFact],
fallback_policies: list[ObservedFact],
) -> list[str]:
return self._unique(
[
"llm-provider",
*[fact.name.lower() for fact in providers],
"credential" if credentials else "",
"provider-registry" if registries else "",
"fallback-policy" if fallback_policies else "",
]
)
def _unique(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
item = value.strip()
key = item.lower()
if not item or key in seen:
continue
seen.add(key)
result.append(item)
return result
def _interface_inputs(self, interfaces: list[ObservedFact]) -> list[str]: def _interface_inputs(self, interfaces: list[ObservedFact]) -> list[str]:
feature_types = {self._feature_type(fact) for fact in interfaces} feature_types = {self._feature_type(fact) for fact in interfaces}
inputs: list[str] = [] inputs: list[str] = []

View File

@@ -73,6 +73,8 @@ def _combine_abilities(
description=_preferred_description(left.description, right.description), description=_preferred_description(left.description, right.description),
confidence=max(left.confidence, right.confidence), confidence=max(left.confidence, right.confidence),
source_refs=_merge_source_refs(left.source_refs, right.source_refs), source_refs=_merge_source_refs(left.source_refs, right.source_refs),
primary_class=_preferred_text(left.primary_class, right.primary_class),
attributes=_merge_strings(left.attributes, right.attributes),
capabilities=_merge_capabilities(left.capabilities + right.capabilities), capabilities=_merge_capabilities(left.capabilities + right.capabilities),
) )
@@ -107,6 +109,8 @@ def _combine_capabilities(
outputs=_merge_strings(left.outputs, right.outputs), outputs=_merge_strings(left.outputs, right.outputs),
confidence=max(left.confidence, right.confidence), confidence=max(left.confidence, right.confidence),
source_refs=_merge_source_refs(left.source_refs, right.source_refs), source_refs=_merge_source_refs(left.source_refs, right.source_refs),
primary_class=_preferred_text(left.primary_class, right.primary_class),
attributes=_merge_strings(left.attributes, right.attributes),
features=_merge_features(left.features + right.features), features=_merge_features(left.features + right.features),
evidence=_merge_evidence(left.evidence + right.evidence), evidence=_merge_evidence(left.evidence + right.evidence),
) )
@@ -128,6 +132,8 @@ def _merge_features(
location=_preferred_text(existing.location, feature.location), location=_preferred_text(existing.location, feature.location),
confidence=max(existing.confidence, feature.confidence), confidence=max(existing.confidence, feature.confidence),
source_refs=_merge_source_refs(existing.source_refs, feature.source_refs), source_refs=_merge_source_refs(existing.source_refs, feature.source_refs),
primary_class=_preferred_text(existing.primary_class, feature.primary_class),
attributes=_merge_strings(existing.attributes, feature.attributes),
) )
return merged return merged

View File

@@ -161,6 +161,8 @@ class CandidateFeature:
status: str status: str
source_refs: list[SourceReference] source_refs: list[SourceReference]
confidence_label: str = "" confidence_label: str = ""
primary_class: str = ""
attributes: list[str] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -174,6 +176,8 @@ class CandidateCapability:
status: str status: str
source_refs: list[SourceReference] source_refs: list[SourceReference]
confidence_label: str = "" confidence_label: str = ""
primary_class: str = "capability"
attributes: list[str] = field(default_factory=list)
features: list[CandidateFeature] = field(default_factory=list) features: list[CandidateFeature] = field(default_factory=list)
evidence: list[CandidateEvidence] = field(default_factory=list) evidence: list[CandidateEvidence] = field(default_factory=list)
@@ -187,6 +191,8 @@ class CandidateAbility:
status: str status: str
source_refs: list[SourceReference] source_refs: list[SourceReference]
confidence_label: str = "" confidence_label: str = ""
primary_class: str = "ability"
attributes: list[str] = field(default_factory=list)
capabilities: list[CandidateCapability] = field(default_factory=list) capabilities: list[CandidateCapability] = field(default_factory=list)
@@ -228,6 +234,8 @@ class Feature:
confidence: float confidence: float
confidence_label: str = "" confidence_label: str = ""
source_refs: list[SourceReference] = field(default_factory=list) source_refs: list[SourceReference] = field(default_factory=list)
primary_class: str = ""
attributes: list[str] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -239,6 +247,8 @@ class Capability:
outputs: list[str] outputs: list[str]
confidence: float confidence: float
confidence_label: str = "" confidence_label: str = ""
primary_class: str = "capability"
attributes: list[str] = field(default_factory=list)
features: list[Feature] = field(default_factory=list) features: list[Feature] = field(default_factory=list)
evidence: list[Evidence] = field(default_factory=list) evidence: list[Evidence] = field(default_factory=list)
@@ -250,6 +260,8 @@ class Ability:
description: str description: str
confidence: float confidence: float
confidence_label: str = "" confidence_label: str = ""
primary_class: str = "ability"
attributes: list[str] = field(default_factory=list)
capabilities: list[Capability] = field(default_factory=list) capabilities: list[Capability] = field(default_factory=list)

View File

@@ -369,6 +369,8 @@ class RegistryService:
location=feature.location, location=feature.location,
confidence=feature.confidence, confidence=feature.confidence,
source_refs=feature.source_refs, source_refs=feature.source_refs,
primary_class=feature.primary_class,
attributes=feature.attributes,
) )
for evidence in capability.evidence: for evidence in capability.evidence:
if evidence.status != "candidate": if evidence.status != "candidate":
@@ -512,6 +514,8 @@ class RegistryService:
location=feature.location, location=feature.location,
confidence=feature.confidence, confidence=feature.confidence,
source_refs=feature.source_refs, source_refs=feature.source_refs,
primary_class=feature.primary_class,
attributes=feature.attributes,
) )
self.store.mark_candidate_feature_status( self.store.mark_candidate_feature_status(
repository_id, repository_id,
@@ -655,6 +659,8 @@ class RegistryService:
inputs=capability.inputs, inputs=capability.inputs,
outputs=capability.outputs, outputs=capability.outputs,
confidence=capability.confidence, confidence=capability.confidence,
primary_class=capability.primary_class,
attributes=capability.attributes,
) )
for feature in capability.features: for feature in capability.features:
if feature.status != "candidate": if feature.status != "candidate":
@@ -667,6 +673,8 @@ class RegistryService:
location=feature.location, location=feature.location,
confidence=feature.confidence, confidence=feature.confidence,
source_refs=feature.source_refs, source_refs=feature.source_refs,
primary_class=feature.primary_class,
attributes=feature.attributes,
) )
for evidence in capability.evidence: for evidence in capability.evidence:
if evidence.status != "candidate": if evidence.status != "candidate":
@@ -702,6 +710,8 @@ class RegistryService:
name=candidate_ability.name, name=candidate_ability.name,
description=candidate_ability.description, description=candidate_ability.description,
confidence=candidate_ability.confidence, confidence=candidate_ability.confidence,
primary_class=candidate_ability.primary_class,
attributes=candidate_ability.attributes,
) )
def _ensure_approved_capability( def _ensure_approved_capability(
@@ -726,6 +736,8 @@ class RegistryService:
inputs=candidate_capability.inputs, inputs=candidate_capability.inputs,
outputs=candidate_capability.outputs, outputs=candidate_capability.outputs,
confidence=candidate_capability.confidence, confidence=candidate_capability.confidence,
primary_class=candidate_capability.primary_class,
attributes=candidate_capability.attributes,
) )
def _candidate_capability_with_parent( def _candidate_capability_with_parent(
@@ -884,6 +896,8 @@ class RegistryService:
name: str, name: str,
description: str, description: str,
confidence: float, confidence: float,
primary_class: str = "ability",
attributes: Sequence[str] = (),
notes: str = "", notes: str = "",
) -> CandidateGraph: ) -> CandidateGraph:
self.store.update_candidate_ability( self.store.update_candidate_ability(
@@ -893,6 +907,8 @@ class RegistryService:
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes),
) )
self.store.create_review_decision( self.store.create_review_decision(
repository_id, repository_id,
@@ -912,6 +928,8 @@ class RegistryService:
name: str, name: str,
description: str, description: str,
confidence: float, confidence: float,
primary_class: str = "capability",
attributes: Sequence[str] = (),
notes: str = "", notes: str = "",
) -> CandidateGraph: ) -> CandidateGraph:
self.store.update_candidate_capability( self.store.update_candidate_capability(
@@ -921,6 +939,8 @@ class RegistryService:
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes),
) )
self.store.create_review_decision( self.store.create_review_decision(
repository_id, repository_id,
@@ -931,6 +951,40 @@ class RegistryService:
self.store.update_repository_status(repository_id, "reviewing") self.store.update_repository_status(repository_id, "reviewing")
return self.store.get_candidate_graph(repository_id, analysis_run_id) return self.store.get_candidate_graph(repository_id, analysis_run_id)
def edit_candidate_feature(
self,
repository_id: int,
analysis_run_id: int,
candidate_feature_id: int,
*,
name: str,
type: str,
location: str,
confidence: float,
primary_class: str | None = None,
attributes: Sequence[str] = (),
notes: str = "",
) -> CandidateGraph:
self.store.update_candidate_feature(
repository_id,
analysis_run_id,
candidate_feature_id,
name=name,
type=type,
location=location,
confidence=confidence,
primary_class=primary_class,
attributes=list(attributes),
)
self.store.create_review_decision(
repository_id,
analysis_run_id,
action="edit_candidate_feature",
notes=notes,
)
self.store.update_repository_status(repository_id, "reviewing")
return self.store.get_candidate_graph(repository_id, analysis_run_id)
def relink_candidate_capability( def relink_candidate_capability(
self, self,
repository_id: int, repository_id: int,
@@ -1106,6 +1160,8 @@ class RegistryService:
name: str, name: str,
description: str = "", description: str = "",
confidence: float = 1.0, confidence: float = 1.0,
primary_class: str = "ability",
attributes: Sequence[str] = (),
) -> int: ) -> int:
self.store.get_repository(repository_id) self.store.get_repository(repository_id)
return self.store.create_ability( return self.store.create_ability(
@@ -1113,6 +1169,8 @@ class RegistryService:
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes),
) )
def update_ability( def update_ability(
@@ -1123,6 +1181,8 @@ class RegistryService:
name: str | None = None, name: str | None = None,
description: str | None = None, description: str | None = None,
confidence: float | None = None, confidence: float | None = None,
primary_class: str | None = None,
attributes: Sequence[str] | None = None,
) -> RepositoryAbilityMap: ) -> RepositoryAbilityMap:
self.store.update_ability( self.store.update_ability(
repository_id, repository_id,
@@ -1130,6 +1190,8 @@ class RegistryService:
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes) if attributes is not None else None,
) )
return self.store.get_ability_map(repository_id) return self.store.get_ability_map(repository_id)
@@ -1151,6 +1213,8 @@ class RegistryService:
inputs: Sequence[str] = (), inputs: Sequence[str] = (),
outputs: Sequence[str] = (), outputs: Sequence[str] = (),
confidence: float = 1.0, confidence: float = 1.0,
primary_class: str = "capability",
attributes: Sequence[str] = (),
) -> int: ) -> int:
self.store.ensure_ability(repository_id, ability_id) self.store.ensure_ability(repository_id, ability_id)
return self.store.create_capability( return self.store.create_capability(
@@ -1161,6 +1225,8 @@ class RegistryService:
inputs=list(inputs), inputs=list(inputs),
outputs=list(outputs), outputs=list(outputs),
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes),
) )
def update_capability( def update_capability(
@@ -1173,6 +1239,8 @@ class RegistryService:
inputs: Sequence[str] | None = None, inputs: Sequence[str] | None = None,
outputs: Sequence[str] | None = None, outputs: Sequence[str] | None = None,
confidence: float | None = None, confidence: float | None = None,
primary_class: str | None = None,
attributes: Sequence[str] | None = None,
) -> RepositoryAbilityMap: ) -> RepositoryAbilityMap:
self.store.update_capability( self.store.update_capability(
repository_id, repository_id,
@@ -1182,6 +1250,8 @@ class RegistryService:
inputs=list(inputs) if inputs is not None else None, inputs=list(inputs) if inputs is not None else None,
outputs=list(outputs) if outputs is not None else None, outputs=list(outputs) if outputs is not None else None,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes) if attributes is not None else None,
) )
return self.store.get_ability_map(repository_id) return self.store.get_ability_map(repository_id)
@@ -1202,6 +1272,8 @@ class RegistryService:
type: str, type: str,
location: str = "", location: str = "",
confidence: float = 1.0, confidence: float = 1.0,
primary_class: str | None = None,
attributes: Sequence[str] = (),
) -> int: ) -> int:
self.store.ensure_capability(repository_id, capability_id) self.store.ensure_capability(repository_id, capability_id)
return self.store.create_feature( return self.store.create_feature(
@@ -1211,6 +1283,8 @@ class RegistryService:
type=type, type=type,
location=location, location=location,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes),
) )
def update_feature( def update_feature(
@@ -1222,6 +1296,8 @@ class RegistryService:
type: str | None = None, type: str | None = None,
location: str | None = None, location: str | None = None,
confidence: float | None = None, confidence: float | None = None,
primary_class: str | None = None,
attributes: Sequence[str] | None = None,
) -> RepositoryAbilityMap: ) -> RepositoryAbilityMap:
self.store.update_feature( self.store.update_feature(
repository_id, repository_id,
@@ -1230,6 +1306,8 @@ class RegistryService:
type=type, type=type,
location=location, location=location,
confidence=confidence, confidence=confidence,
primary_class=primary_class,
attributes=list(attributes) if attributes is not None else None,
) )
return self.store.get_ability_map(repository_id) return self.store.get_ability_map(repository_id)

View File

@@ -51,6 +51,7 @@ class RegistryStore:
self._ensure_repository_scopes_table(connection) self._ensure_repository_scopes_table(connection)
self._ensure_approved_source_ref_columns(connection) self._ensure_approved_source_ref_columns(connection)
self._ensure_evidence_relationship_columns(connection) self._ensure_evidence_relationship_columns(connection)
self._ensure_characteristic_classification_columns(connection)
self._ensure_expectation_gaps_table(connection) self._ensure_expectation_gaps_table(connection)
def connect(self) -> sqlite3.Connection: def connect(self) -> sqlite3.Connection:
@@ -108,6 +109,60 @@ class RegistryStore:
""" """
) )
def _ensure_characteristic_classification_columns(
self,
connection: sqlite3.Connection,
) -> None:
defaults = {
"candidate_abilities": "ability",
"approved_abilities": "ability",
"candidate_capabilities": "capability",
"approved_capabilities": "capability",
"candidate_features": "",
"approved_features": "",
}
for table, default_class in defaults.items():
columns = {
row["name"]
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
}
if "primary_class" not in columns:
connection.execute(
f"ALTER TABLE {table} ADD COLUMN primary_class TEXT NOT NULL DEFAULT '{default_class}'"
)
if "attributes" not in columns:
connection.execute(
f"ALTER TABLE {table} ADD COLUMN attributes TEXT NOT NULL DEFAULT '[]'"
)
for table in ("candidate_abilities", "approved_abilities"):
connection.execute(
f"""
UPDATE {table}
SET primary_class = COALESCE(NULLIF(primary_class, ''), 'ability'),
attributes = COALESCE(NULLIF(attributes, ''), '[]')
WHERE primary_class = '' OR attributes = ''
"""
)
for table in ("candidate_capabilities", "approved_capabilities"):
connection.execute(
f"""
UPDATE {table}
SET primary_class = COALESCE(NULLIF(primary_class, ''), 'capability'),
attributes = COALESCE(NULLIF(attributes, ''), '[]')
WHERE primary_class = '' OR attributes = ''
"""
)
for table in ("candidate_features", "approved_features"):
connection.execute(
f"""
UPDATE {table}
SET primary_class = COALESCE(NULLIF(primary_class, ''), type),
attributes = COALESCE(NULLIF(attributes, ''), json_array(type))
WHERE primary_class = '' OR attributes = ''
"""
)
def _ensure_content_chunks_table(self, connection: sqlite3.Connection) -> None: def _ensure_content_chunks_table(self, connection: sqlite3.Connection) -> None:
connection.execute( connection.execute(
""" """
@@ -361,14 +416,17 @@ class RegistryStore:
ability_cursor = connection.execute( ability_cursor = connection.execute(
""" """
INSERT INTO candidate_abilities INSERT INTO candidate_abilities
(repository_id, analysis_run_id, name, description, confidence, source_refs) (repository_id, analysis_run_id, name, description, primary_class,
VALUES (?, ?, ?, ?, ?, ?) attributes, confidence, source_refs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
analysis_run_id, analysis_run_id,
ability.name, ability.name,
ability.description, ability.description,
ability.primary_class or "ability",
self._attributes_to_json(ability.attributes),
ability.confidence, ability.confidence,
self._source_refs_to_json(ability.source_refs), self._source_refs_to_json(ability.source_refs),
), ),
@@ -379,8 +437,8 @@ class RegistryStore:
""" """
INSERT INTO candidate_capabilities INSERT INTO candidate_capabilities
(repository_id, analysis_run_id, ability_id, name, description, (repository_id, analysis_run_id, ability_id, name, description,
inputs, outputs, confidence, source_refs) inputs, outputs, primary_class, attributes, confidence, source_refs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
@@ -390,6 +448,8 @@ class RegistryStore:
capability.description, capability.description,
json.dumps(capability.inputs), json.dumps(capability.inputs),
json.dumps(capability.outputs), json.dumps(capability.outputs),
capability.primary_class or "capability",
self._attributes_to_json(capability.attributes),
capability.confidence, capability.confidence,
self._source_refs_to_json(capability.source_refs), self._source_refs_to_json(capability.source_refs),
), ),
@@ -400,8 +460,8 @@ class RegistryStore:
""" """
INSERT INTO candidate_features INSERT INTO candidate_features
(repository_id, analysis_run_id, capability_id, name, type, (repository_id, analysis_run_id, capability_id, name, type,
location, confidence, source_refs) primary_class, attributes, location, confidence, source_refs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
@@ -409,6 +469,10 @@ class RegistryStore:
capability_id, capability_id,
feature.name, feature.name,
feature.type, feature.type,
feature.primary_class or feature.type,
self._attributes_to_json(
feature.attributes or [feature.type]
),
feature.location, feature.location,
feature.confidence, feature.confidence,
self._source_refs_to_json(feature.source_refs), self._source_refs_to_json(feature.source_refs),
@@ -448,7 +512,8 @@ class RegistryStore:
with self.connect() as connection: with self.connect() as connection:
ability_rows = connection.execute( ability_rows = connection.execute(
""" """
SELECT id, name, description, confidence, status, source_refs SELECT id, name, description, primary_class, attributes, confidence,
status, source_refs
FROM candidate_abilities FROM candidate_abilities
WHERE repository_id = ? AND analysis_run_id = ? WHERE repository_id = ? AND analysis_run_id = ?
ORDER BY id ORDER BY id
@@ -458,7 +523,7 @@ class RegistryStore:
capability_rows = connection.execute( capability_rows = connection.execute(
""" """
SELECT id, ability_id, name, description, inputs, outputs, SELECT id, ability_id, name, description, inputs, outputs,
confidence, status, source_refs primary_class, attributes, confidence, status, source_refs
FROM candidate_capabilities FROM candidate_capabilities
WHERE repository_id = ? AND analysis_run_id = ? WHERE repository_id = ? AND analysis_run_id = ?
ORDER BY id ORDER BY id
@@ -467,8 +532,8 @@ class RegistryStore:
).fetchall() ).fetchall()
feature_rows = connection.execute( feature_rows = connection.execute(
""" """
SELECT id, capability_id, name, type, location, confidence, SELECT id, capability_id, name, type, primary_class, attributes,
status, source_refs location, confidence, status, source_refs
FROM candidate_features FROM candidate_features
WHERE repository_id = ? AND analysis_run_id = ? WHERE repository_id = ? AND analysis_run_id = ?
ORDER BY id ORDER BY id
@@ -498,6 +563,8 @@ class RegistryStore:
status=row["status"], status=row["status"],
source_refs=self._source_refs_from_json(row["source_refs"]), source_refs=self._source_refs_from_json(row["source_refs"]),
confidence_label=confidence_label(row["confidence"]), confidence_label=confidence_label(row["confidence"]),
primary_class=row["primary_class"] or row["type"],
attributes=self._attributes_from_json(row["attributes"]),
) )
) )
@@ -531,6 +598,8 @@ class RegistryStore:
status=row["status"], status=row["status"],
source_refs=self._source_refs_from_json(row["source_refs"]), source_refs=self._source_refs_from_json(row["source_refs"]),
confidence_label=confidence_label(row["confidence"]), confidence_label=confidence_label(row["confidence"]),
primary_class=row["primary_class"] or "capability",
attributes=self._attributes_from_json(row["attributes"]),
features=features_by_capability.get(row["id"], []), features=features_by_capability.get(row["id"], []),
evidence=evidence_by_capability.get(row["id"], []), evidence=evidence_by_capability.get(row["id"], []),
) )
@@ -545,6 +614,8 @@ class RegistryStore:
status=row["status"], status=row["status"],
source_refs=self._source_refs_from_json(row["source_refs"]), source_refs=self._source_refs_from_json(row["source_refs"]),
confidence_label=confidence_label(row["confidence"]), confidence_label=confidence_label(row["confidence"]),
primary_class=row["primary_class"] or "ability",
attributes=self._attributes_from_json(row["attributes"]),
capabilities=capabilities_by_ability.get(row["id"], []), capabilities=capabilities_by_ability.get(row["id"], []),
) )
for row in ability_rows for row in ability_rows
@@ -861,17 +932,22 @@ class RegistryStore:
name: str, name: str,
description: str, description: str,
confidence: float, confidence: float,
primary_class: str = "ability",
attributes: list[str] | None = None,
) -> None: ) -> None:
with self.connect() as connection: with self.connect() as connection:
cursor = connection.execute( cursor = connection.execute(
""" """
UPDATE candidate_abilities UPDATE candidate_abilities
SET name = ?, description = ?, confidence = ? SET name = ?, description = ?, primary_class = ?, attributes = ?,
confidence = ?
WHERE id = ? AND repository_id = ? AND analysis_run_id = ? WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
""", """,
( (
name, name,
description, description,
primary_class or "ability",
self._attributes_to_json(attributes or []),
confidence, confidence,
candidate_ability_id, candidate_ability_id,
repository_id, repository_id,
@@ -894,17 +970,22 @@ class RegistryStore:
name: str, name: str,
description: str, description: str,
confidence: float, confidence: float,
primary_class: str = "capability",
attributes: list[str] | None = None,
) -> None: ) -> None:
with self.connect() as connection: with self.connect() as connection:
cursor = connection.execute( cursor = connection.execute(
""" """
UPDATE candidate_capabilities UPDATE candidate_capabilities
SET name = ?, description = ?, confidence = ? SET name = ?, description = ?, primary_class = ?, attributes = ?,
confidence = ?
WHERE id = ? AND repository_id = ? AND analysis_run_id = ? WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
""", """,
( (
name, name,
description, description,
primary_class or "capability",
self._attributes_to_json(attributes or []),
confidence, confidence,
candidate_capability_id, candidate_capability_id,
repository_id, repository_id,
@@ -918,6 +999,46 @@ class RegistryStore:
f"{repository_id} analysis run {analysis_run_id}" f"{repository_id} analysis run {analysis_run_id}"
) )
def update_candidate_feature(
self,
repository_id: int,
analysis_run_id: int,
candidate_feature_id: int,
*,
name: str,
type: str,
location: str,
confidence: float,
primary_class: str | None = None,
attributes: list[str] | None = None,
) -> None:
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE candidate_features
SET name = ?, type = ?, primary_class = ?, attributes = ?,
location = ?, confidence = ?
WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
""",
(
name,
type,
primary_class or type,
self._attributes_to_json(attributes or [type]),
location,
confidence,
candidate_feature_id,
repository_id,
analysis_run_id,
),
)
if cursor.rowcount == 0:
raise NotFoundError(
"candidate feature "
f"{candidate_feature_id} was not found for repository "
f"{repository_id} analysis run {analysis_run_id}"
)
def relink_candidate_capability( def relink_candidate_capability(
self, self,
repository_id: int, repository_id: int,
@@ -1604,15 +1725,24 @@ class RegistryStore:
name: str, name: str,
description: str, description: str,
confidence: float, confidence: float,
primary_class: str = "ability",
attributes: list[str] | None = None,
) -> int: ) -> int:
with self.connect() as connection: with self.connect() as connection:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO approved_abilities INSERT INTO approved_abilities
(repository_id, name, description, confidence) (repository_id, name, description, primary_class, attributes, confidence)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
(repository_id, name, description, confidence), (
repository_id,
name,
description,
primary_class or "ability",
self._attributes_to_json(attributes or []),
confidence,
),
) )
return int(cursor.lastrowid) return int(cursor.lastrowid)
@@ -1638,6 +1768,8 @@ class RegistryStore:
name: str | None = None, name: str | None = None,
description: str | None = None, description: str | None = None,
confidence: float | None = None, confidence: float | None = None,
primary_class: str | None = None,
attributes: list[str] | None = None,
) -> None: ) -> None:
self._update_approved_row( self._update_approved_row(
table="approved_abilities", table="approved_abilities",
@@ -1648,6 +1780,12 @@ class RegistryStore:
"name": name, "name": name,
"description": description, "description": description,
"confidence": confidence, "confidence": confidence,
"primary_class": primary_class,
"attributes": (
self._attributes_to_json(attributes)
if attributes is not None
else None
),
}, },
) )
@@ -1669,13 +1807,16 @@ class RegistryStore:
inputs: list[str], inputs: list[str],
outputs: list[str], outputs: list[str],
confidence: float, confidence: float,
primary_class: str = "capability",
attributes: list[str] | None = None,
) -> int: ) -> int:
with self.connect() as connection: with self.connect() as connection:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO approved_capabilities INSERT INTO approved_capabilities
(repository_id, ability_id, name, description, inputs, outputs, confidence) (repository_id, ability_id, name, description, inputs, outputs,
VALUES (?, ?, ?, ?, ?, ?, ?) primary_class, attributes, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
@@ -1684,6 +1825,8 @@ class RegistryStore:
description, description,
json.dumps(inputs), json.dumps(inputs),
json.dumps(outputs), json.dumps(outputs),
primary_class or "capability",
self._attributes_to_json(attributes or []),
confidence, confidence,
), ),
) )
@@ -1713,6 +1856,8 @@ class RegistryStore:
inputs: list[str] | None = None, inputs: list[str] | None = None,
outputs: list[str] | None = None, outputs: list[str] | None = None,
confidence: float | None = None, confidence: float | None = None,
primary_class: str | None = None,
attributes: list[str] | None = None,
) -> None: ) -> None:
self._update_approved_row( self._update_approved_row(
table="approved_capabilities", table="approved_capabilities",
@@ -1725,6 +1870,12 @@ class RegistryStore:
"inputs": json.dumps(inputs) if inputs is not None else None, "inputs": json.dumps(inputs) if inputs is not None else None,
"outputs": json.dumps(outputs) if outputs is not None else None, "outputs": json.dumps(outputs) if outputs is not None else None,
"confidence": confidence, "confidence": confidence,
"primary_class": primary_class,
"attributes": (
self._attributes_to_json(attributes)
if attributes is not None
else None
),
}, },
) )
@@ -1746,19 +1897,24 @@ class RegistryStore:
location: str, location: str,
confidence: float, confidence: float,
source_refs: list[SourceReference] | None = None, source_refs: list[SourceReference] | None = None,
primary_class: str | None = None,
attributes: list[str] | None = None,
) -> int: ) -> int:
with self.connect() as connection: with self.connect() as connection:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO approved_features INSERT INTO approved_features
(repository_id, capability_id, name, type, location, confidence, source_refs) (repository_id, capability_id, name, type, primary_class, attributes,
VALUES (?, ?, ?, ?, ?, ?, ?) location, confidence, source_refs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
capability_id, capability_id,
name, name,
type, type,
primary_class or type,
self._attributes_to_json(attributes or [type]),
location, location,
confidence, confidence,
self._source_refs_to_json(source_refs or []), self._source_refs_to_json(source_refs or []),
@@ -1775,6 +1931,8 @@ class RegistryStore:
type: str | None = None, type: str | None = None,
location: str | None = None, location: str | None = None,
confidence: float | None = None, confidence: float | None = None,
primary_class: str | None = None,
attributes: list[str] | None = None,
) -> None: ) -> None:
self._update_approved_row( self._update_approved_row(
table="approved_features", table="approved_features",
@@ -1784,6 +1942,12 @@ class RegistryStore:
values={ values={
"name": name, "name": name,
"type": type, "type": type,
"primary_class": primary_class,
"attributes": (
self._attributes_to_json(attributes)
if attributes is not None
else None
),
"location": location, "location": location,
"confidence": confidence, "confidence": confidence,
}, },
@@ -1968,13 +2132,15 @@ class RegistryStore:
ability_cursor = connection.execute( ability_cursor = connection.execute(
""" """
INSERT INTO approved_abilities INSERT INTO approved_abilities
(repository_id, name, description, confidence) (repository_id, name, description, primary_class, attributes, confidence)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
ability.name, ability.name,
ability.description, ability.description,
ability.primary_class or "ability",
self._attributes_to_json(ability.attributes),
ability.confidence, ability.confidence,
), ),
) )
@@ -1986,8 +2152,8 @@ class RegistryStore:
""" """
INSERT INTO approved_capabilities INSERT INTO approved_capabilities
(repository_id, ability_id, name, description, inputs, outputs, (repository_id, ability_id, name, description, inputs, outputs,
confidence) primary_class, attributes, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
@@ -1996,6 +2162,8 @@ class RegistryStore:
capability.description, capability.description,
json.dumps(capability.inputs), json.dumps(capability.inputs),
json.dumps(capability.outputs), json.dumps(capability.outputs),
capability.primary_class or "capability",
self._attributes_to_json(capability.attributes),
capability.confidence, capability.confidence,
), ),
) )
@@ -2006,15 +2174,19 @@ class RegistryStore:
connection.execute( connection.execute(
""" """
INSERT INTO approved_features INSERT INTO approved_features
(repository_id, capability_id, name, type, location, (repository_id, capability_id, name, type, primary_class,
confidence, source_refs) attributes, location, confidence, source_refs)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
repository_id, repository_id,
approved_capability_id, approved_capability_id,
feature.name, feature.name,
feature.type, feature.type,
feature.primary_class or feature.type,
self._attributes_to_json(
feature.attributes or [feature.type]
),
feature.location, feature.location,
feature.confidence, feature.confidence,
self._source_refs_to_json(feature.source_refs), self._source_refs_to_json(feature.source_refs),
@@ -2051,7 +2223,7 @@ class RegistryStore:
with self.connect() as connection: with self.connect() as connection:
ability_rows = connection.execute( ability_rows = connection.execute(
""" """
SELECT id, name, description, confidence SELECT id, name, description, primary_class, attributes, confidence
FROM approved_abilities FROM approved_abilities
WHERE repository_id = ? WHERE repository_id = ?
ORDER BY id ORDER BY id
@@ -2060,7 +2232,8 @@ class RegistryStore:
).fetchall() ).fetchall()
capability_rows = connection.execute( capability_rows = connection.execute(
""" """
SELECT id, ability_id, name, description, inputs, outputs, confidence SELECT id, ability_id, name, description, inputs, outputs,
primary_class, attributes, confidence
FROM approved_capabilities FROM approved_capabilities
WHERE repository_id = ? WHERE repository_id = ?
ORDER BY id ORDER BY id
@@ -2069,7 +2242,8 @@ class RegistryStore:
).fetchall() ).fetchall()
feature_rows = connection.execute( feature_rows = connection.execute(
""" """
SELECT id, capability_id, name, type, location, confidence, source_refs SELECT id, capability_id, name, type, primary_class, attributes,
location, confidence, source_refs
FROM approved_features FROM approved_features
WHERE repository_id = ? WHERE repository_id = ?
ORDER BY id ORDER BY id
@@ -2098,6 +2272,8 @@ class RegistryStore:
confidence=row["confidence"], confidence=row["confidence"],
confidence_label=confidence_label(row["confidence"]), confidence_label=confidence_label(row["confidence"]),
source_refs=self._source_refs_from_json(row["source_refs"]), source_refs=self._source_refs_from_json(row["source_refs"]),
primary_class=row["primary_class"] or row["type"],
attributes=self._attributes_from_json(row["attributes"]),
) )
) )
@@ -2128,6 +2304,8 @@ class RegistryStore:
outputs=json.loads(row["outputs"]), outputs=json.loads(row["outputs"]),
confidence=row["confidence"], confidence=row["confidence"],
confidence_label=confidence_label(row["confidence"]), confidence_label=confidence_label(row["confidence"]),
primary_class=row["primary_class"] or "capability",
attributes=self._attributes_from_json(row["attributes"]),
features=features_by_capability.get(row["id"], []), features=features_by_capability.get(row["id"], []),
evidence=evidence_by_capability.get(row["id"], []), evidence=evidence_by_capability.get(row["id"], []),
) )
@@ -2140,6 +2318,8 @@ class RegistryStore:
description=row["description"], description=row["description"],
confidence=row["confidence"], confidence=row["confidence"],
confidence_label=confidence_label(row["confidence"]), confidence_label=confidence_label(row["confidence"]),
primary_class=row["primary_class"] or "ability",
attributes=self._attributes_from_json(row["attributes"]),
capabilities=capabilities_by_ability.get(row["id"], []), capabilities=capabilities_by_ability.get(row["id"], []),
) )
for row in ability_rows for row in ability_rows
@@ -2578,6 +2758,17 @@ class RegistryStore:
] ]
) )
def _attributes_to_json(self, attributes: list[str]) -> str:
return json.dumps([item.strip() for item in attributes if item.strip()])
def _attributes_from_json(self, value: str) -> list[str]:
if not value:
return []
parsed = json.loads(value)
if not isinstance(parsed, list):
return []
return [str(item) for item in parsed if str(item).strip()]
def _source_refs_from_json(self, value: str) -> list[SourceReference]: def _source_refs_from_json(self, value: str) -> list[SourceReference]:
return [ return [
SourceReference( SourceReference(

View File

@@ -495,6 +495,8 @@ class CandidateFeatureResponse(BaseModel):
id: int id: int
name: str name: str
type: str type: str
primary_class: str
attributes: list[str]
location: str location: str
confidence: float confidence: float
status: str status: str
@@ -508,6 +510,8 @@ class CandidateCapabilityResponse(BaseModel):
description: str description: str
inputs: list[str] inputs: list[str]
outputs: list[str] outputs: list[str]
primary_class: str
attributes: list[str]
confidence: float confidence: float
status: str status: str
source_refs: list[SourceReferenceResponse] source_refs: list[SourceReferenceResponse]
@@ -520,6 +524,8 @@ class CandidateAbilityResponse(BaseModel):
id: int id: int
name: str name: str
description: str description: str
primary_class: str
attributes: list[str]
confidence: float confidence: float
status: str status: str
source_refs: list[SourceReferenceResponse] source_refs: list[SourceReferenceResponse]
@@ -689,6 +695,8 @@ class FeatureResponse(BaseModel):
id: int id: int
name: str name: str
type: str type: str
primary_class: str
attributes: list[str]
location: str location: str
confidence: float confidence: float
confidence_label: str confidence_label: str
@@ -701,6 +709,8 @@ class CapabilityResponse(BaseModel):
description: str description: str
inputs: list[str] inputs: list[str]
outputs: list[str] outputs: list[str]
primary_class: str
attributes: list[str]
confidence: float confidence: float
confidence_label: str confidence_label: str
features: list[FeatureResponse] features: list[FeatureResponse]
@@ -711,6 +721,8 @@ class AbilityResponse(BaseModel):
id: int id: int
name: str name: str
description: str description: str
primary_class: str
attributes: list[str]
confidence: float confidence: float
confidence_label: str confidence_label: str
capabilities: list[CapabilityResponse] capabilities: list[CapabilityResponse]

View File

@@ -719,6 +719,8 @@ def repository_detail(
<h3>Add Ability</h3> <h3>Add Ability</h3>
<label>Name <input name="name" required></label> <label>Name <input name="name" required></label>
<label>Description <textarea name="description" rows="2"></textarea></label> <label>Description <textarea name="description" rows="2"></textarea></label>
<label>Primary class <input name="primary_class" value="ability" required></label>
<label>Attributes <input name="attributes" placeholder="Comma-separated"></label>
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label> <label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label>
<button type="submit">Add Ability</button> <button type="submit">Add Ability</button>
</form> </form>
@@ -729,6 +731,8 @@ def repository_detail(
<label>Description <textarea name="description" rows="2"></textarea></label> <label>Description <textarea name="description" rows="2"></textarea></label>
<label>Inputs <input name="inputs" placeholder="Comma-separated"></label> <label>Inputs <input name="inputs" placeholder="Comma-separated"></label>
<label>Outputs <input name="outputs" placeholder="Comma-separated"></label> <label>Outputs <input name="outputs" placeholder="Comma-separated"></label>
<label>Primary class <input name="primary_class" value="capability" required></label>
<label>Attributes <input name="attributes" placeholder="Comma-separated"></label>
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label> <label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label>
<button type="submit">Add Capability</button> <button type="submit">Add Capability</button>
</form> </form>
@@ -737,6 +741,8 @@ def repository_detail(
<label>Capability ID <input name="capability_id" type="number" min="1" required></label> <label>Capability ID <input name="capability_id" type="number" min="1" required></label>
<label>Name <input name="name" required></label> <label>Name <input name="name" required></label>
<label>Type <input name="type" required></label> <label>Type <input name="type" required></label>
<label>Primary class <input name="primary_class" placeholder="Defaults to type"></label>
<label>Attributes <input name="attributes" placeholder="Comma-separated; defaults to type"></label>
<label>Location <input name="location"></label> <label>Location <input name="location"></label>
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label> <label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label>
<button type="submit">Add Feature</button> <button type="submit">Add Feature</button>
@@ -835,6 +841,8 @@ def create_ability_from_form(
name: str = Form(...), name: str = Form(...),
description: str = Form(""), description: str = Form(""),
confidence: float = Form(1.0), confidence: float = Form(1.0),
primary_class: str = Form("ability"),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.add_ability( service.add_ability(
@@ -842,6 +850,8 @@ def create_ability_from_form(
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class or "ability",
attributes=split_csv(attributes),
) )
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@@ -855,6 +865,8 @@ def create_capability_from_form(
inputs: str = Form(""), inputs: str = Form(""),
outputs: str = Form(""), outputs: str = Form(""),
confidence: float = Form(1.0), confidence: float = Form(1.0),
primary_class: str = Form("capability"),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.add_capability( service.add_capability(
@@ -865,6 +877,8 @@ def create_capability_from_form(
inputs=split_csv(inputs), inputs=split_csv(inputs),
outputs=split_csv(outputs), outputs=split_csv(outputs),
confidence=confidence, confidence=confidence,
primary_class=primary_class or "capability",
attributes=split_csv(attributes),
) )
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@@ -877,6 +891,8 @@ def create_feature_from_form(
type: str = Form(...), type: str = Form(...),
location: str = Form(""), location: str = Form(""),
confidence: float = Form(1.0), confidence: float = Form(1.0),
primary_class: str = Form(""),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.add_feature( service.add_feature(
@@ -886,6 +902,8 @@ def create_feature_from_form(
type=type, type=type,
location=location, location=location,
confidence=confidence, confidence=confidence,
primary_class=primary_class or type,
attributes=split_csv(attributes) or [type],
) )
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@@ -924,6 +942,8 @@ def edit_ability_from_form(
name: str = Form(...), name: str = Form(...),
description: str = Form(""), description: str = Form(""),
confidence: float = Form(1.0), confidence: float = Form(1.0),
primary_class: str = Form("ability"),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.update_ability( service.update_ability(
@@ -932,6 +952,8 @@ def edit_ability_from_form(
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class or "ability",
attributes=split_csv(attributes),
) )
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@@ -955,6 +977,8 @@ def edit_capability_from_form(
inputs: str = Form(""), inputs: str = Form(""),
outputs: str = Form(""), outputs: str = Form(""),
confidence: float = Form(1.0), confidence: float = Form(1.0),
primary_class: str = Form("capability"),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.update_capability( service.update_capability(
@@ -965,6 +989,8 @@ def edit_capability_from_form(
inputs=split_csv(inputs), inputs=split_csv(inputs),
outputs=split_csv(outputs), outputs=split_csv(outputs),
confidence=confidence, confidence=confidence,
primary_class=primary_class or "capability",
attributes=split_csv(attributes),
) )
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@@ -987,6 +1013,8 @@ def edit_feature_from_form(
type: str = Form(...), type: str = Form(...),
location: str = Form(""), location: str = Form(""),
confidence: float = Form(1.0), confidence: float = Form(1.0),
primary_class: str = Form(""),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.update_feature( service.update_feature(
@@ -996,6 +1024,8 @@ def edit_feature_from_form(
type=type, type=type,
location=location, location=location,
confidence=confidence, confidence=confidence,
primary_class=primary_class or type,
attributes=split_csv(attributes) or [type],
) )
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
@@ -1208,6 +1238,7 @@ def repository_element_listing(
type: str = Query("abilities"), type: str = Query("abilities"),
q: str = Query(""), q: str = Query(""),
class_filter: str = Query(""), class_filter: str = Query(""),
attribute_filter: str = Query(""),
entry_filter: str = Query(""), entry_filter: str = Query(""),
candidate_status_filter: str = Query("active"), candidate_status_filter: str = Query("active"),
support_orientation_filter: str = Query(""), support_orientation_filter: str = Query(""),
@@ -1252,13 +1283,15 @@ def repository_element_listing(
elements, elements,
"", "",
"", "",
entry_filter, "",
candidate_status_filter, entry_filter=entry_filter,
candidate_status_filter=candidate_status_filter,
) )
filtered = filter_element_rows( filtered = filter_element_rows(
entry_scoped_elements, entry_scoped_elements,
q, q,
class_filter, class_filter,
attribute_filter,
candidate_status_filter="all", candidate_status_filter="all",
support_orientation_filter=support_orientation_filter, support_orientation_filter=support_orientation_filter,
) )
@@ -1283,11 +1316,13 @@ def repository_element_listing(
<div class="grid"> <div class="grid">
<label>Search <input name="q" value="{escape(q)}" placeholder="Name, parent, source, or class"></label> <label>Search <input name="q" value="{escape(q)}" placeholder="Name, parent, source, or class"></label>
<label>Class <input name="class_filter" value="{escape(class_filter)}" list="element-classes" placeholder="Any class"></label> <label>Class <input name="class_filter" value="{escape(class_filter)}" list="element-classes" placeholder="Any class"></label>
<label>Attribute <input name="attribute_filter" value="{escape(attribute_filter)}" list="element-attributes" placeholder="Any attribute"></label>
{render_entry_filter(entry_filter) if scope != "facts" else ""} {render_entry_filter(entry_filter) if scope != "facts" else ""}
{render_candidate_status_filter(candidate_status_filter) if scope != "facts" else ""} {render_candidate_status_filter(candidate_status_filter) if scope != "facts" else ""}
{render_support_orientation_filter(support_orientation_filter) if type in {"supports", "evidence"} else ""} {render_support_orientation_filter(support_orientation_filter) if type in {"supports", "evidence"} else ""}
</div> </div>
{render_class_datalist(entry_scoped_elements)} {render_class_datalist(entry_scoped_elements)}
{render_attribute_datalist(entry_scoped_elements)}
<div class="actions"> <div class="actions">
<button type="submit">Filter</button> <button type="submit">Filter</button>
<a class="button secondary" href="{filter_action}?scope={escape(listing_scope)}&type={escape(type)}{render_analysis_run_query_suffix(analysis_run_id)}">Clear</a> <a class="button secondary" href="{filter_action}?scope={escape(listing_scope)}&type={escape(type)}{render_analysis_run_query_suffix(analysis_run_id)}">Clear</a>
@@ -1562,6 +1597,8 @@ def edit_candidate_ability_from_form(
name: str = Form(...), name: str = Form(...),
description: str = Form(""), description: str = Form(""),
confidence: float = Form(...), confidence: float = Form(...),
primary_class: str = Form("ability"),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.edit_candidate_ability( service.edit_candidate_ability(
@@ -1571,6 +1608,8 @@ def edit_candidate_ability_from_form(
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class or "ability",
attributes=split_csv(attributes),
notes="Edited from web UI", notes="Edited from web UI",
) )
return RedirectResponse( return RedirectResponse(
@@ -1590,6 +1629,8 @@ def edit_candidate_capability_from_form(
name: str = Form(...), name: str = Form(...),
description: str = Form(""), description: str = Form(""),
confidence: float = Form(...), confidence: float = Form(...),
primary_class: str = Form("capability"),
attributes: str = Form(""),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> RedirectResponse: ) -> RedirectResponse:
service.edit_candidate_capability( service.edit_candidate_capability(
@@ -1599,6 +1640,42 @@ def edit_candidate_capability_from_form(
name=name, name=name,
description=description, description=description,
confidence=confidence, confidence=confidence,
primary_class=primary_class or "capability",
attributes=split_csv(attributes),
notes="Edited from web UI",
)
return RedirectResponse(
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
status_code=303,
)
@router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-features/{candidate_feature_id}/edit"
)
def edit_candidate_feature_from_form(
repository_id: int,
analysis_run_id: int,
candidate_feature_id: int,
name: str = Form(...),
type: str = Form(...),
location: str = Form(""),
confidence: float = Form(...),
primary_class: str = Form(""),
attributes: str = Form(""),
service: RegistryService = Depends(get_service),
) -> RedirectResponse:
service.edit_candidate_feature(
repository_id,
analysis_run_id,
candidate_feature_id,
name=name,
type=type,
location=location,
confidence=confidence,
primary_class=primary_class or type,
attributes=split_csv(attributes) or [type],
notes="Edited from web UI", notes="Edited from web UI",
) )
return RedirectResponse( return RedirectResponse(
@@ -2245,6 +2322,7 @@ def graph_element_rows(
item_kind="abilities", item_kind="abilities",
description=ability.get("description", ""), description=ability.get("description", ""),
confidence=ability.get("confidence", 1.0), confidence=ability.get("confidence", 1.0),
attributes=ability.get("attributes", []),
status=ability.get("status", ""), status=ability.get("status", ""),
entry_state=entry_state, entry_state=entry_state,
) )
@@ -2261,6 +2339,7 @@ def graph_element_rows(
item_kind="capabilities", item_kind="capabilities",
description=capability.get("description", ""), description=capability.get("description", ""),
confidence=capability.get("confidence", 1.0), confidence=capability.get("confidence", 1.0),
attributes=capability.get("attributes", []),
inputs=capability.get("inputs", []), inputs=capability.get("inputs", []),
outputs=capability.get("outputs", []), outputs=capability.get("outputs", []),
status=capability.get("status", ""), status=capability.get("status", ""),
@@ -2271,12 +2350,14 @@ def graph_element_rows(
if item_type == "features": if item_type == "features":
rows.append( rows.append(
element_row( element_row(
feature.get("type", "feature"), feature.get("primary_class", feature.get("type", "feature")),
feature["name"], feature["name"],
capability["name"], capability["name"],
feature.get("source_refs", []), feature.get("source_refs", []),
item_id=feature.get("id"), item_id=feature.get("id"),
item_kind="features", item_kind="features",
type=feature.get("type", ""),
attributes=feature.get("attributes", []),
confidence=feature.get("confidence", 1.0), confidence=feature.get("confidence", 1.0),
location=feature.get("location", ""), location=feature.get("location", ""),
status=feature.get("status", ""), status=feature.get("status", ""),
@@ -2367,12 +2448,14 @@ def filter_element_rows(
rows: list[dict], rows: list[dict],
query: str, query: str,
class_filter: str, class_filter: str,
attribute_filter: str = "",
entry_filter: str = "", entry_filter: str = "",
candidate_status_filter: str = "", candidate_status_filter: str = "",
support_orientation_filter: str = "", support_orientation_filter: str = "",
) -> list[dict]: ) -> list[dict]:
query = query.strip().lower() query = query.strip().lower()
class_filter = class_filter.strip().lower() class_filter = class_filter.strip().lower()
attribute_filter = attribute_filter.strip().lower()
entry_filter = entry_filter.strip().lower() entry_filter = entry_filter.strip().lower()
candidate_status_filter = candidate_status_filter.strip().lower() candidate_status_filter = candidate_status_filter.strip().lower()
support_orientation_filter = support_orientation_filter.strip().lower() support_orientation_filter = support_orientation_filter.strip().lower()
@@ -2387,9 +2470,15 @@ def filter_element_rows(
row_class = str(row["primary_class"]).lower() row_class = str(row["primary_class"]).lower()
if class_filter and class_filter not in row_class: if class_filter and class_filter not in row_class:
continue continue
row_attributes = [str(item).lower() for item in row.get("attributes", [])]
if attribute_filter and not any(
attribute_filter in item for item in row_attributes
):
continue
haystack = " ".join( haystack = " ".join(
[ [
str(row["primary_class"]), str(row["primary_class"]),
" ".join(str(item) for item in row.get("attributes", [])),
str(row["name"]), str(row["name"]),
str(row["parent"]), str(row["parent"]),
str(row.get("entry_state", "")), str(row.get("entry_state", "")),
@@ -2443,7 +2532,7 @@ def render_element_row(
return f""" return f"""
<tr> <tr>
<td>{render_entry_badge(row)}</td> <td>{render_entry_badge(row)}</td>
<td><span class="pill">{escape(str(row["primary_class"]))}</span></td> <td><span class="pill">{escape(str(row["primary_class"]))}</span>{render_attribute_pills(row)}</td>
<td>{escape(str(row["name"]))}</td> <td>{escape(str(row["name"]))}</td>
<td>{escape(str(row["parent"]))}</td> <td>{escape(str(row["parent"]))}</td>
<td>{render_element_source_detail(row)}</td> <td>{render_element_source_detail(row)}</td>
@@ -2452,6 +2541,20 @@ def render_element_row(
""" """
def render_attribute_pills(row: dict) -> str:
attributes = [
str(attribute)
for attribute in row.get("attributes", [])
if str(attribute) and str(attribute) != str(row.get("primary_class", ""))
]
if not attributes:
return ""
return "".join(
f' <span class="pill">{escape(attribute)}</span>'
for attribute in attributes
)
def render_element_source_detail(row: dict) -> str: def render_element_source_detail(row: dict) -> str:
if row.get("item_kind") == "evidence": if row.get("item_kind") == "evidence":
target = escape(str(row.get("target_kind") or "capability")) target = escape(str(row.get("target_kind") or "capability"))
@@ -2647,7 +2750,7 @@ def render_candidate_element_actions(
) )
status = row.get("status", "candidate") status = row.get("status", "candidate")
edit = "" edit = ""
if item_kind in {"abilities", "capabilities"}: if item_kind in {"abilities", "capabilities", "features"}:
edit_action = ( edit_action = (
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/{collection}/{item_id}/edit" f"/{collection}/{item_id}/edit"
@@ -2722,6 +2825,7 @@ def render_support_orientation_filter(support_orientation_filter: str) -> str:
def render_element_edit_fields(row: dict) -> str: def render_element_edit_fields(row: dict) -> str:
item_kind = row["item_kind"] item_kind = row["item_kind"]
name = escape(str(row["name"])) name = escape(str(row["name"]))
classification = render_classification_edit_fields(row)
if item_kind == "scope": if item_kind == "scope":
return f""" return f"""
<label>Name <input name="name" value="{name}" required></label> <label>Name <input name="name" value="{name}" required></label>
@@ -2734,6 +2838,7 @@ def render_element_edit_fields(row: dict) -> str:
return f""" return f"""
<label>Name <input name="name" value="{name}" required></label> <label>Name <input name="name" value="{name}" required></label>
<label>Type <input name="type" value="{feature_type}" required></label> <label>Type <input name="type" value="{feature_type}" required></label>
{classification}
<label>Location <input name="location" value="{location}"></label> <label>Location <input name="location" value="{location}"></label>
""" """
if item_kind == "evidence": if item_kind == "evidence":
@@ -2746,7 +2851,10 @@ def render_element_edit_fields(row: dict) -> str:
<label>Reference ID <input name="reference_id" type="number" min="1" value="{row.get('reference_id') or ''}"></label> <label>Reference ID <input name="reference_id" type="number" min="1" value="{row.get('reference_id') or ''}"></label>
<label>Strength <input name="strength" value="{escape(str(row.get("strength", "medium")))}" required></label> <label>Strength <input name="strength" value="{escape(str(row.get("strength", "medium")))}" required></label>
""" """
return f'<label>Name <input name="name" value="{name}" required></label>' return f"""
<label>Name <input name="name" value="{name}" required></label>
{classification}
"""
def render_element_hidden_fields(row: dict) -> str: def render_element_hidden_fields(row: dict) -> str:
@@ -2774,7 +2882,17 @@ def render_element_hidden_fields(row: dict) -> str:
def render_candidate_edit_fields(row: dict) -> str: def render_candidate_edit_fields(row: dict) -> str:
return f'<label>Name <input name="name" value="{escape(str(row["name"]))}" required></label>' if row.get("item_kind") == "features":
return f"""
<label>Name <input name="name" value="{escape(str(row["name"]))}" required></label>
<label>Type <input name="type" value="{escape(str(row.get("type", row.get("primary_class", ""))))}" required></label>
{render_classification_edit_fields(row)}
<label>Location <input name="location" value="{escape(str(row.get("location", "")))}"></label>
"""
return f"""
<label>Name <input name="name" value="{escape(str(row["name"]))}" required></label>
{render_classification_edit_fields(row)}
"""
def render_candidate_hidden_fields(row: dict) -> str: def render_candidate_hidden_fields(row: dict) -> str:
@@ -2784,6 +2902,17 @@ def render_candidate_hidden_fields(row: dict) -> str:
) )
def render_classification_edit_fields(row: dict) -> str:
return f"""
<label>Primary class <input name="primary_class" value="{escape(str(row.get("primary_class", "")))}" required></label>
<label>Attributes <input name="attributes" value="{escape(attributes_text(row))}" placeholder="Comma-separated"></label>
"""
def attributes_text(row: dict) -> str:
return ", ".join(str(item) for item in row.get("attributes", []) if str(item))
def render_class_datalist(rows: list[dict]) -> str: def render_class_datalist(rows: list[dict]) -> str:
classes = sorted({str(row["primary_class"]) for row in rows if row["primary_class"]}) classes = sorted({str(row["primary_class"]) for row in rows if row["primary_class"]})
options = "".join( options = "".join(
@@ -2793,6 +2922,22 @@ def render_class_datalist(rows: list[dict]) -> str:
return f'<datalist id="element-classes">{options}</datalist>' return f'<datalist id="element-classes">{options}</datalist>'
def render_attribute_datalist(rows: list[dict]) -> str:
attributes = sorted(
{
str(attribute)
for row in rows
for attribute in row.get("attributes", [])
if str(attribute)
}
)
options = "".join(
f'<option value="{escape(item)}"></option>'
for item in attributes
)
return f'<datalist id="element-attributes">{options}</datalist>'
def render_optional_hidden(name: str, value: int | None) -> str: def render_optional_hidden(name: str, value: int | None) -> str:
if value is None: if value is None:
return "" return ""
@@ -2958,10 +3103,17 @@ def render_candidate_edit_form(
f"/{collection}/{candidate['id']}/edit" f"/{collection}/{candidate['id']}/edit"
) )
confidence = f"{candidate['confidence']:.2f}" confidence = f"{candidate['confidence']:.2f}"
extra_fields = ""
if collection == "candidate-features":
extra_fields = f"""
<label>Type <input name="type" value="{escape(candidate['type'])}" required></label>
<label>Location <input name="location" value="{escape(candidate.get('location', ''))}"></label>
"""
return f""" return f"""
<form class="stack" method="post" action="{action}"> <form class="stack" method="post" action="{action}">
<label>Name <input name="name" value="{escape(candidate['name'])}" required></label> <label>Name <input name="name" value="{escape(candidate['name'])}" required></label>
<label>Description <textarea name="description" rows="2">{escape(candidate['description'])}</textarea></label> {extra_fields or f'<label>Description <textarea name="description" rows="2">{escape(candidate.get("description", ""))}</textarea></label>'}
{render_classification_edit_fields(candidate)}
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{confidence}" required></label> <label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{confidence}" required></label>
<button class="secondary" type="submit">Save Edit</button> <button class="secondary" type="submit">Save Edit</button>
</form> </form>
@@ -3012,8 +3164,10 @@ def render_candidate_feature(
<span class="pill">ID {feature["id"]}</span> <span class="pill">ID {feature["id"]}</span>
<span class="pill">{escape(feature["status"])}</span> <span class="pill">{escape(feature["status"])}</span>
<span class="pill">{escape(feature["type"])}</span> <span class="pill">{escape(feature["type"])}</span>
<span class="pill">{escape(feature.get("primary_class", feature["type"]))}</span>
<span class="source">{escape(feature["location"])}</span> <span class="source">{escape(feature["location"])}</span>
{render_candidate_reject_form('candidate-features', feature, repository_id, analysis_run_id)} {render_candidate_reject_form('candidate-features', feature, repository_id, analysis_run_id)}
{render_candidate_edit_form('candidate-features', feature, repository_id, analysis_run_id)}
{render_candidate_relink_form('candidate-features', feature, repository_id, analysis_run_id, 'target_capability_id', 'Target capability ID')} {render_candidate_relink_form('candidate-features', feature, repository_id, analysis_run_id, 'target_capability_id', 'Target capability ID')}
{render_candidate_merge_form('candidate-features', feature, repository_id, analysis_run_id, 'target_feature_id', 'Merge into feature ID')} {render_candidate_merge_form('candidate-features', feature, repository_id, analysis_run_id, 'target_feature_id', 'Merge into feature ID')}
</li> </li>

View File

@@ -52,13 +52,19 @@ def test_candidate_generator_builds_purpose_seed_from_observed_facts():
ability = graph[0] ability = graph[0]
assert ability.name == "Route Incoming Customer Email To The Right Team" assert ability.name == "Route Incoming Customer Email To The Right Team"
assert "Usefulness" not in ability.name assert "Usefulness" not in ability.name
assert ability.primary_class == "developer-tooling"
assert "interface" in ability.attributes
assert ability.source_refs[0].path == "README.md" assert ability.source_refs[0].path == "README.md"
interface_capability = ability.capabilities[0] interface_capability = ability.capabilities[0]
assert interface_capability.name == "Expose Repository Interface" assert interface_capability.name == "Expose Repository Interface"
assert interface_capability.primary_class == "interface"
assert {"surface", "api"} <= set(interface_capability.attributes)
assert interface_capability.confidence == 0.75 assert interface_capability.confidence == 0.75
assert interface_capability.inputs == ["HTTP request"] assert interface_capability.inputs == ["HTTP request"]
assert interface_capability.outputs == ["HTTP response"] assert interface_capability.outputs == ["HTTP response"]
assert interface_capability.features[0].type == "API" assert interface_capability.features[0].type == "API"
assert interface_capability.features[0].primary_class == "API"
assert {"API", "surface", "http"} <= set(interface_capability.features[0].attributes)
assert interface_capability.features[0].name == "POST /classify" assert interface_capability.features[0].name == "POST /classify"
assert interface_capability.features[0].location == "app.py" assert interface_capability.features[0].location == "app.py"
assert interface_capability.evidence[0].strength == "strong" assert interface_capability.evidence[0].strength == "strong"
@@ -273,8 +279,18 @@ def test_candidate_generator_maps_llm_provider_facts_to_capability():
for capability in graph[0].capabilities for capability in graph[0].capabilities
if capability.name == "Route LLM Requests Across Providers" if capability.name == "Route LLM Requests Across Providers"
) )
assert graph[0].primary_class == "ai-integration"
assert capability.primary_class == "llm-integration"
assert {"llm-provider", "openrouter", "claude", "fallback-policy"} <= set(
capability.attributes
)
feature_names = {feature.name for feature in capability.features} feature_names = {feature.name for feature in capability.features}
assert {"Use OpenRouter Models", "Use Claude Models"} <= feature_names assert {"Use OpenRouter Models", "Use Claude Models"} <= feature_names
assert "Configure LLM Provider Credentials" in feature_names assert "Configure LLM Provider Credentials" in feature_names
assert "Maintain LLM Provider Registry" in feature_names assert "Maintain LLM Provider Registry" in feature_names
assert "Apply LLM Provider Fallback Policy" in feature_names assert "Apply LLM Provider Fallback Policy" in feature_names
openrouter_feature = next(
feature for feature in capability.features if feature.name == "Use OpenRouter Models"
)
assert openrouter_feature.primary_class == "integration"
assert {"llm-provider", "openrouter"} <= set(openrouter_feature.attributes)

View File

@@ -813,13 +813,33 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path):
assert len(ability_map.abilities) == 1 assert len(ability_map.abilities) == 1
assert len(second_approval.abilities) == 1 assert len(second_approval.abilities) == 1
assert ability_map.abilities[0].name == "Support Example" assert ability_map.abilities[0].name == "Support Example"
assert ability_map.abilities[0].primary_class == "developer-tooling"
assert ability_map.abilities[0].attributes == ["interface"]
assert ability_map.abilities[0].capabilities[0].primary_class == "interface"
assert ability_map.abilities[0].capabilities[0].features[0].location == "app.py" assert ability_map.abilities[0].capabilities[0].features[0].location == "app.py"
assert ability_map.abilities[0].capabilities[0].features[0].primary_class == "API"
assert ability_map.abilities[0].capabilities[0].features[0].attributes == [
"API",
"surface",
"http",
]
assert ability_map.abilities[0].capabilities[0].features[0].source_refs assert ability_map.abilities[0].capabilities[0].features[0].source_refs
assert ability_map.abilities[0].capabilities[0].features[0].source_refs[0].line == 3 assert ability_map.abilities[0].capabilities[0].features[0].source_refs[0].line == 3
assert ability_map.abilities[0].capabilities[0].evidence[0].source_refs assert ability_map.abilities[0].capabilities[0].evidence[0].source_refs
candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id) candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id)
assert candidate_graph.abilities[0].status == "approved" assert candidate_graph.abilities[0].status == "approved"
assert candidate_graph.abilities[0].primary_class == "developer-tooling"
assert candidate_graph.abilities[0].capabilities[0].primary_class == "interface"
assert (
candidate_graph.abilities[0].capabilities[0].features[0].primary_class
== "API"
)
assert candidate_graph.abilities[0].capabilities[0].features[0].attributes == [
"API",
"surface",
"http",
]
decisions = service.list_review_decisions(repository.id, summary.analysis_run.id) decisions = service.list_review_decisions(repository.id, summary.analysis_run.id)
assert decisions[0].action == "approve_candidate_graph" assert decisions[0].action == "approve_candidate_graph"
assert decisions[0].notes == "Looks good for the first pass." assert decisions[0].notes == "Looks good for the first pass."

View File

@@ -22,6 +22,16 @@ def test_initialize_is_idempotent_and_applies_expected_columns(tmp_path):
feature_columns = { feature_columns = {
row[1] for row in connection.execute("PRAGMA table_info(approved_features)") row[1] for row in connection.execute("PRAGMA table_info(approved_features)")
} }
ability_columns = {
row[1] for row in connection.execute("PRAGMA table_info(approved_abilities)")
}
capability_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(approved_capabilities)")
}
candidate_feature_columns = {
row[1] for row in connection.execute("PRAGMA table_info(candidate_features)")
}
evidence_columns = { evidence_columns = {
row[1] for row in connection.execute("PRAGMA table_info(approved_evidence)") row[1] for row in connection.execute("PRAGMA table_info(approved_evidence)")
} }
@@ -33,6 +43,10 @@ def test_initialize_is_idempotent_and_applies_expected_columns(tmp_path):
} }
assert "source_refs" in feature_columns assert "source_refs" in feature_columns
assert {"primary_class", "attributes"} <= ability_columns
assert {"primary_class", "attributes"} <= capability_columns
assert {"primary_class", "attributes"} <= feature_columns
assert {"primary_class", "attributes"} <= candidate_feature_columns
assert "source_refs" in evidence_columns assert "source_refs" in evidence_columns
assert { assert {
"target_kind", "target_kind",

View File

@@ -1723,6 +1723,8 @@ def test_ui_manual_registry_entry_loop(tmp_path):
data={ data={
"name": "Manual Ability", "name": "Manual Ability",
"description": "Curated by hand.", "description": "Curated by hand.",
"primary_class": "repository-intelligence",
"attributes": "review, curation",
"confidence": "0.95", "confidence": "0.95",
}, },
follow_redirects=False, follow_redirects=False,
@@ -1740,6 +1742,8 @@ def test_ui_manual_registry_entry_loop(tmp_path):
"description": "Curated capability.", "description": "Curated capability.",
"inputs": "request, context", "inputs": "request, context",
"outputs": "response", "outputs": "response",
"primary_class": "review",
"attributes": "ui, workflow",
"confidence": "0.9", "confidence": "0.9",
}, },
follow_redirects=False, follow_redirects=False,
@@ -1755,6 +1759,8 @@ def test_ui_manual_registry_entry_loop(tmp_path):
"capability_id": str(capability_id), "capability_id": str(capability_id),
"name": "Manual API", "name": "Manual API",
"type": "REST endpoint", "type": "REST endpoint",
"primary_class": "api",
"attributes": "integration, review",
"location": "src/manual.py", "location": "src/manual.py",
"confidence": "0.88", "confidence": "0.88",
}, },
@@ -1807,6 +1813,8 @@ def test_ui_manual_registry_entry_loop(tmp_path):
data={ data={
"name": "Edited Manual Ability", "name": "Edited Manual Ability",
"description": "Edited by hand.", "description": "Edited by hand.",
"primary_class": "workflow-automation",
"attributes": "manual, curation",
"confidence": "0.8", "confidence": "0.8",
}, },
follow_redirects=False, follow_redirects=False,
@@ -1820,6 +1828,8 @@ def test_ui_manual_registry_entry_loop(tmp_path):
"description": "Edited capability.", "description": "Edited capability.",
"inputs": "ticket", "inputs": "ticket",
"outputs": "decision", "outputs": "decision",
"primary_class": "decisioning",
"attributes": "workflow, review",
"confidence": "0.75", "confidence": "0.75",
}, },
follow_redirects=False, follow_redirects=False,
@@ -1835,6 +1845,8 @@ def test_ui_manual_registry_entry_loop(tmp_path):
data={ data={
"name": "Edited Manual API", "name": "Edited Manual API",
"type": "HTTP endpoint", "type": "HTTP endpoint",
"primary_class": "ui",
"attributes": "api, review",
"location": "src/edited.py", "location": "src/edited.py",
"confidence": "0.7", "confidence": "0.7",
}, },
@@ -1865,6 +1877,32 @@ def test_ui_manual_registry_entry_loop(tmp_path):
assert f"references feature #{feature_id}" in detail_response.text assert f"references feature #{feature_id}" in detail_response.text
assert "downward support" in detail_response.text assert "downward support" in detail_response.text
ability_map = client.get(f"/repos/{repository_id}/ability-map").json()
edited_ability = ability_map["abilities"][0]
edited_capability = edited_ability["capabilities"][0]
edited_feature = edited_capability["features"][0]
assert edited_ability["primary_class"] == "workflow-automation"
assert edited_ability["attributes"] == ["manual", "curation"]
assert edited_capability["primary_class"] == "decisioning"
assert edited_capability["attributes"] == ["workflow", "review"]
assert edited_feature["primary_class"] == "ui"
assert edited_feature["attributes"] == ["api", "review"]
filtered_feature_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={
"scope": "all",
"entry_filter": "approved",
"type": "features",
"class_filter": "ui",
"attribute_filter": "api",
},
)
assert filtered_feature_listing.status_code == 200
assert "Attribute" in filtered_feature_listing.text
assert "Edited Manual API" in filtered_feature_listing.text
assert "1 of 1 shown" in filtered_feature_listing.text
upward_support_listing = client.get( upward_support_listing = client.get(
f"/ui/repos/{repository_id}/elements", f"/ui/repos/{repository_id}/elements",
params={ params={

View File

@@ -0,0 +1,124 @@
---
id: RREG-WP-0004
type: workplan
title: "Repository Ability Registry - Characteristic Classification And Navigation"
domain: capabilities
repo: repo-registry
status: active
owner: codex
topic_slug: foerster-capabilities
created: "2026-04-29"
updated: "2026-04-29"
state_hub_workstream_id: "ad67787f-a89d-4cde-957c-18ef39b43912"
---
## Goal
Make repository profiles easier to understand and refine by adding first-class
classification and navigation support for characteristics. The product should
help curators move from repository scope to abilities, capabilities, features,
support, and observed facts while preserving flexibility for overlap and messy
real-world abstraction.
## P0: Characteristic Classification Fields
```task
id: RREG-WP-0004-T01
status: done
priority: high
state_hub_task_id: "fd2664c2-eb33-42f3-9624-c74bb0d30456"
```
Add `primary_class` and `attributes` to approved and candidate abilities,
capabilities, and features. Keep the migration additive, preserve existing
feature `type` compatibility, and default existing data to useful classes.
Acceptance: API/UI serialization includes classification fields for approved and
candidate graph elements; existing repositories migrate without data loss; tests
cover default backfill and candidate-to-approved promotion.
Implementation note 2026-04-29: approved and candidate abilities, capabilities,
and features now carry `primary_class` and `attributes`. The migration is
additive, existing features default their primary class and first attribute from
legacy `type`, API response models expose the new fields, and approval preserves
candidate classification metadata.
## P1: Classification Review UI
```task
id: RREG-WP-0004-T02
status: done
priority: high
state_hub_task_id: "ec9a2676-9e41-4f2d-8436-80670e5e051f"
```
Expose primary class and attributes in manual add/edit forms and candidate review
actions. Let curators filter characteristic lists by primary class and secondary
attributes without losing approved/candidate status filters.
Acceptance: a curator can edit the class and attributes of an approved or
candidate ability, capability, or feature and immediately use those values for
filtering.
Implementation note 2026-04-29: manual add/edit forms and element-list edit
forms expose primary class and attributes for approved characteristics. Candidate
abilities, capabilities, and features can also be edited with classification
metadata, and element listings can filter by primary class and secondary
attribute.
## P1: Deterministic Classification Proposals
```task
id: RREG-WP-0004-T03
status: done
priority: medium
state_hub_task_id: "d1f9646e-ac74-4fad-9183-7d5c4d5c93e0"
```
Teach deterministic candidate generation to propose conservative primary classes
and attributes from scanner facts, file surfaces, routes, manifests, tests,
documentation, and provider/config signals.
Acceptance: repo-registry and llm-connect analyses produce useful
classification labels while LLM assistance remains optional and disabled paths
remain smooth.
Implementation note 2026-04-29: deterministic candidate generation now proposes
conservative classification metadata. Abilities can classify as
`repository-intelligence`, `ai-integration`, or `developer-tooling`; capabilities
include `interface`, `llm-integration`, and `repository-structure`; features add
surface/provider attributes such as `api`, `cli`, `http`, `llm-provider`,
`openrouter`, `claude`, `credential`, and `fallback-policy`.
## P1: Characteristic Drilldown Navigation
```task
id: RREG-WP-0004-T04
status: todo
priority: medium
state_hub_task_id: "14ede41f-a0cb-4a9a-b4ba-f23b34d7ae33"
```
Improve navigation from high-level scope and ability views down to capabilities,
features, support, and observed facts. Facts should feel like drilldown evidence,
not the primary orientation surface.
Acceptance: the repository page and element listings make it natural to move
from scope to abilities to lower-level support/facts, with counts, filters, and
clear breadcrumbs.
## P2: Classification Quality Feedback
```task
id: RREG-WP-0004-T05
status: todo
priority: low
state_hub_task_id: "691f3cb7-c8a2-4f80-a6c2-29cb5a0c7a96"
```
Capture classification expectation gaps and review smells, including missing
primary classes, wrong feature surface classes, overbroad attributes, and
same-level/upward support patterns that indicate suboptimal organization.
Acceptance: reviewers can record classification-specific improvement inputs that
feed the scanner coevolution workflow.