diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index 56df20e..568c2a6 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -102,8 +102,12 @@ CREATE TABLE IF NOT EXISTS candidate_evidence ( repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, analysis_run_id INTEGER NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE, capability_id INTEGER NOT NULL REFERENCES candidate_capabilities(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL DEFAULT 'capability', + target_id INTEGER, type TEXT NOT NULL, reference TEXT NOT NULL, + reference_kind TEXT NOT NULL DEFAULT 'source', + reference_id INTEGER, strength TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'candidate', source_refs TEXT NOT NULL DEFAULT '[]', @@ -156,8 +160,12 @@ CREATE TABLE IF NOT EXISTS approved_evidence ( id INTEGER PRIMARY KEY AUTOINCREMENT, repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, capability_id INTEGER NOT NULL REFERENCES approved_capabilities(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL DEFAULT 'capability', + target_id INTEGER, type TEXT NOT NULL, reference TEXT NOT NULL, + reference_kind TEXT NOT NULL DEFAULT 'source', + reference_id INTEGER, strength TEXT NOT NULL DEFAULT 'medium', source_refs TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 09b332a..c335280 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -145,6 +145,10 @@ class CandidateEvidence: strength: str status: str source_refs: list[SourceReference] + target_kind: str = "capability" + target_id: int | None = None + reference_kind: str = "source" + reference_id: int | None = None @dataclass(frozen=True) @@ -200,6 +204,10 @@ class Evidence: reference: str strength: str source_refs: list[SourceReference] = field(default_factory=list) + target_kind: str = "capability" + target_id: int | None = None + reference_kind: str = "source" + reference_id: int | None = None @dataclass(frozen=True) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index e809e3a..b3dacfc 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -1162,6 +1162,10 @@ class RegistryService: type: str, reference: str, strength: str = "medium", + target_kind: str = "capability", + target_id: int | None = None, + reference_kind: str = "source", + reference_id: int | None = None, ) -> int: self.store.ensure_capability(repository_id, capability_id) return self.store.create_evidence( @@ -1170,6 +1174,10 @@ class RegistryService: type=type, reference=reference, strength=strength, + target_kind=target_kind, + target_id=target_id, + reference_kind=reference_kind, + reference_id=reference_id, ) def update_evidence( @@ -1180,6 +1188,10 @@ class RegistryService: type: str | None = None, reference: str | None = None, strength: str | None = None, + target_kind: str | None = None, + target_id: int | None = None, + reference_kind: str | None = None, + reference_id: int | None = None, ) -> RepositoryAbilityMap: self.store.update_evidence( repository_id, @@ -1187,6 +1199,10 @@ class RegistryService: type=type, reference=reference, strength=strength, + target_kind=target_kind, + target_id=target_id, + reference_kind=reference_kind, + reference_id=reference_id, ) return self.store.get_ability_map(repository_id) diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 7e78d33..a37452e 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -48,6 +48,7 @@ class RegistryStore: connection.executescript(migration_path.read_text(encoding="utf-8")) self._ensure_content_chunks_table(connection) self._ensure_approved_source_ref_columns(connection) + self._ensure_evidence_relationship_columns(connection) self._ensure_expectation_gaps_table(connection) def connect(self) -> sqlite3.Connection: @@ -70,6 +71,41 @@ class RegistryStore: f"ALTER TABLE {table} ADD COLUMN source_refs TEXT NOT NULL DEFAULT '[]'" ) + def _ensure_evidence_relationship_columns( + self, + connection: sqlite3.Connection, + ) -> None: + for table in ("candidate_evidence", "approved_evidence"): + columns = { + row["name"] + for row in connection.execute(f"PRAGMA table_info({table})").fetchall() + } + if "target_kind" not in columns: + connection.execute( + f"ALTER TABLE {table} ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'capability'" + ) + if "target_id" not in columns: + connection.execute(f"ALTER TABLE {table} ADD COLUMN target_id INTEGER") + if "reference_kind" not in columns: + connection.execute( + f"ALTER TABLE {table} ADD COLUMN reference_kind TEXT NOT NULL DEFAULT 'source'" + ) + if "reference_id" not in columns: + connection.execute( + f"ALTER TABLE {table} ADD COLUMN reference_id INTEGER" + ) + connection.execute( + f""" + UPDATE {table} + SET target_kind = COALESCE(NULLIF(target_kind, ''), 'capability'), + target_id = COALESCE(target_id, capability_id), + reference_kind = COALESCE(NULLIF(reference_kind, ''), 'source') + WHERE target_id IS NULL + OR target_kind = '' + OR reference_kind = '' + """ + ) + def _ensure_content_chunks_table(self, connection: sqlite3.Connection) -> None: connection.execute( """ @@ -355,16 +391,21 @@ class RegistryStore: connection.execute( """ INSERT INTO candidate_evidence - (repository_id, analysis_run_id, capability_id, type, - reference, strength, source_refs) - VALUES (?, ?, ?, ?, ?, ?, ?) + (repository_id, analysis_run_id, capability_id, + target_kind, target_id, type, reference, + reference_kind, reference_id, strength, source_refs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( repository_id, analysis_run_id, capability_id, + "capability", + capability_id, evidence.type, evidence.reference, + "source", + None, evidence.strength, self._source_refs_to_json(evidence.source_refs), ), @@ -409,7 +450,8 @@ class RegistryStore: ).fetchall() evidence_rows = connection.execute( """ - SELECT id, capability_id, type, reference, strength, status, source_refs + SELECT id, capability_id, target_kind, target_id, type, reference, + reference_kind, reference_id, strength, status, source_refs FROM candidate_evidence WHERE repository_id = ? AND analysis_run_id = ? ORDER BY id @@ -442,6 +484,10 @@ class RegistryStore: strength=row["strength"], status=row["status"], source_refs=self._source_refs_from_json(row["source_refs"]), + target_kind=row["target_kind"], + target_id=row["target_id"], + reference_kind=row["reference_kind"], + reference_id=row["reference_id"], ) ) @@ -1709,20 +1755,30 @@ class RegistryStore: type: str, reference: str, strength: str, + target_kind: str = "capability", + target_id: int | None = None, + reference_kind: str = "source", + reference_id: int | None = None, source_refs: list[SourceReference] | None = None, ) -> int: + target_id = capability_id if target_id is None else target_id with self.connect() as connection: cursor = connection.execute( """ INSERT INTO approved_evidence - (repository_id, capability_id, type, reference, strength, source_refs) - VALUES (?, ?, ?, ?, ?, ?) + (repository_id, capability_id, target_kind, target_id, type, + reference, reference_kind, reference_id, strength, source_refs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( repository_id, capability_id, + target_kind, + target_id, type, reference, + reference_kind, + reference_id, strength, self._source_refs_to_json(source_refs or []), ), @@ -1737,6 +1793,10 @@ class RegistryStore: type: str | None = None, reference: str | None = None, strength: str | None = None, + target_kind: str | None = None, + target_id: int | None = None, + reference_kind: str | None = None, + reference_id: int | None = None, ) -> None: self._update_approved_row( table="approved_evidence", @@ -1747,6 +1807,10 @@ class RegistryStore: "type": type, "reference": reference, "strength": strength, + "target_kind": target_kind, + "target_id": target_id, + "reference_kind": reference_kind, + "reference_id": reference_id, }, ) @@ -1837,15 +1901,20 @@ class RegistryStore: connection.execute( """ INSERT INTO approved_evidence - (repository_id, capability_id, type, reference, strength, + (repository_id, capability_id, target_kind, target_id, + type, reference, reference_kind, reference_id, strength, source_refs) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( repository_id, approved_capability_id, + evidence.target_kind, + evidence.target_id or approved_capability_id, evidence.type, evidence.reference, + evidence.reference_kind, + evidence.reference_id, evidence.strength, self._source_refs_to_json(evidence.source_refs), ), @@ -1883,7 +1952,8 @@ class RegistryStore: ).fetchall() evidence_rows = connection.execute( """ - SELECT id, capability_id, type, reference, strength, source_refs + SELECT id, capability_id, target_kind, target_id, type, reference, + reference_kind, reference_id, strength, source_refs FROM approved_evidence WHERE repository_id = ? ORDER BY id @@ -1914,6 +1984,10 @@ class RegistryStore: reference=row["reference"], strength=row["strength"], source_refs=self._source_refs_from_json(row["source_refs"]), + target_kind=row["target_kind"], + target_id=row["target_id"], + reference_kind=row["reference_kind"], + reference_id=row["reference_id"], ) ) diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index 78655a3..ec40ca0 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -179,6 +179,10 @@ class EvidenceCreate(BaseModel): type: str reference: str strength: str = "medium" + target_kind: str = "capability" + target_id: int | None = None + reference_kind: str = "source" + reference_id: int | None = None model_config = { "json_schema_extra": { @@ -188,6 +192,8 @@ class EvidenceCreate(BaseModel): "type": "unit_test", "reference": "tests/test_email_classification.py", "strength": "strong", + "target_kind": "capability", + "reference_kind": "source", } ] } @@ -198,6 +204,10 @@ class EvidenceUpdate(BaseModel): type: str | None = None reference: str | None = None strength: str | None = None + target_kind: str | None = None + target_id: int | None = None + reference_kind: str | None = None + reference_id: int | None = None class AnalysisRunCreate(BaseModel): @@ -475,6 +485,10 @@ class CandidateEvidenceResponse(BaseModel): strength: str status: str source_refs: list[SourceReferenceResponse] + target_kind: str = "capability" + target_id: int | None = None + reference_kind: str = "source" + reference_id: int | None = None class CandidateFeatureResponse(BaseModel): @@ -665,6 +679,10 @@ class EvidenceResponse(BaseModel): reference: str strength: str source_refs: list[SourceReferenceResponse] + target_kind: str = "capability" + target_id: int | None = None + reference_kind: str = "source" + reference_id: int | None = None class FeatureResponse(BaseModel): diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index e4c1898..f692e84 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -592,8 +592,12 @@ def repository_detail(

Add Capability Support

+ + + +
@@ -724,6 +728,10 @@ def create_evidence_from_form( type: str = Form(...), reference: str = Form(...), strength: str = Form("medium"), + target_kind: str = Form("capability"), + target_id: int | None = Form(default=None), + reference_kind: str = Form("source"), + reference_id: int | None = Form(default=None), service: RegistryService = Depends(get_service), ) -> RedirectResponse: service.add_evidence( @@ -732,6 +740,10 @@ def create_evidence_from_form( type=type, reference=reference, strength=strength, + target_kind=target_kind, + target_id=target_id, + reference_kind=reference_kind, + reference_id=reference_id, ) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) @@ -836,6 +848,10 @@ def edit_evidence_from_form( type: str = Form(...), reference: str = Form(...), strength: str = Form("medium"), + target_kind: str = Form("capability"), + target_id: int | None = Form(default=None), + reference_kind: str = Form("source"), + reference_id: int | None = Form(default=None), service: RegistryService = Depends(get_service), ) -> RedirectResponse: service.update_evidence( @@ -844,6 +860,10 @@ def edit_evidence_from_form( type=type, reference=reference, strength=strength, + target_kind=target_kind, + target_id=target_id, + reference_kind=reference_kind, + reference_id=reference_id, ) return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) @@ -2795,15 +2815,25 @@ def render_approved_feature(feature: dict, repository_id: int) -> str: def render_approved_evidence(evidence: dict, repository_id: int) -> str: + target_kind = escape(str(evidence.get("target_kind") or "capability")) + target_id = evidence.get("target_id") + reference_kind = escape(str(evidence.get("reference_kind") or "source")) + reference_id = evidence.get("reference_id") return f"""
  • {escape(evidence["type"])} {escape(evidence["strength"])} + supports {target_kind}{f' #{target_id}' if target_id else ''} + references {reference_kind}{f' #{reference_id}' if reference_id else ''} {escape(evidence["reference"])} {render_sources(evidence.get("source_refs", []))}
    + + + +
    diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index cb36584..4b73361 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -75,6 +75,8 @@ def test_manual_registry_builds_ability_map(tmp_path): type="unit_test", reference="tests/test_email_classification.py", strength="strong", + reference_kind="fact", + reference_id=42, ) ability_map = service.ability_map(repository.id) @@ -86,6 +88,10 @@ def test_manual_registry_builds_ability_map(tmp_path): assert capability.inputs == ["subject", "body"] assert capability.features[0].location == "src/routes/classify_email.py" assert capability.evidence[0].strength == "strong" + assert capability.evidence[0].target_kind == "capability" + assert capability.evidence[0].target_id == capability_id + assert capability.evidence[0].reference_kind == "fact" + assert capability.evidence[0].reference_id == 42 def test_manual_registry_updates_and_deletes_approved_entries(tmp_path): @@ -127,6 +133,8 @@ def test_manual_registry_updates_and_deletes_approved_entries(tmp_path): repository.id, evidence_id, strength="strong", + reference_kind="feature", + reference_id=feature_id, ) ability = ability_map.abilities[0] @@ -137,6 +145,8 @@ def test_manual_registry_updates_and_deletes_approved_entries(tmp_path): assert capability.outputs == ["response"] assert capability.features[0].location == "src/api.py" assert capability.evidence[0].strength == "strong" + assert capability.evidence[0].reference_kind == "feature" + assert capability.evidence[0].reference_id == feature_id service.delete_feature(repository.id, feature_id) service.delete_evidence(repository.id, evidence_id) diff --git a/tests/test_storage_migrations.py b/tests/test_storage_migrations.py index 80eeaa9..f420ed1 100644 --- a/tests/test_storage_migrations.py +++ b/tests/test_storage_migrations.py @@ -34,6 +34,12 @@ def test_initialize_is_idempotent_and_applies_expected_columns(tmp_path): assert "source_refs" in feature_columns assert "source_refs" in evidence_columns + assert { + "target_kind", + "target_id", + "reference_kind", + "reference_id", + } <= evidence_columns assert "content_chunks" in tables assert "expectation_gaps" in tables diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 0cf0e5a..ac62516 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1609,6 +1609,8 @@ def test_ui_manual_registry_entry_loop(tmp_path): assert detail_response.status_code == 200 assert "Manual Characteristic Tuning" in detail_response.text assert "Add Capability Support" in detail_response.text + assert "Supported characteristic kind" in detail_response.text + assert "Reference kind" in detail_response.text ability_response = client.post( f"{repository_path}/abilities", @@ -1660,6 +1662,7 @@ def test_ui_manual_registry_entry_loop(tmp_path): "capability_id": str(capability_id), "type": "documentation", "reference": "README.md", + "reference_kind": "source", "strength": "medium", }, follow_redirects=False, @@ -1671,6 +1674,8 @@ def test_ui_manual_registry_entry_loop(tmp_path): assert "Manual Capability" in detail_response.text assert "Manual API" in detail_response.text assert "README.md" in detail_response.text + assert "supports capability" in detail_response.text + assert "references source" in detail_response.text assert "ID " in detail_response.text assert "Save Ability" in detail_response.text @@ -1719,6 +1724,10 @@ def test_ui_manual_registry_entry_loop(tmp_path): data={ "type": "test", "reference": "tests/test_manual.py", + "target_kind": "capability", + "target_id": str(capability_id), + "reference_kind": "feature", + "reference_id": str(feature_id), "strength": "strong", }, follow_redirects=False, @@ -1730,6 +1739,7 @@ def test_ui_manual_registry_entry_loop(tmp_path): assert "Edited Manual Capability" in detail_response.text assert "Edited Manual API" in detail_response.text assert "tests/test_manual.py" in detail_response.text + assert f"references feature #{feature_id}" in detail_response.text delete_feature_response = client.post( f"{repository_path}/features/{feature_id}/delete", diff --git a/workplans/RREG-WP-0003-automatic-repository-exploration.md b/workplans/RREG-WP-0003-automatic-repository-exploration.md index 44eb4b5..d3be710 100644 --- a/workplans/RREG-WP-0003-automatic-repository-exploration.md +++ b/workplans/RREG-WP-0003-automatic-repository-exploration.md @@ -226,3 +226,8 @@ capability, and allows manual tuning of evidence in a way that makes the support relationship clear. The data model has an additive path toward characteristic references so existing approved/candidate workflows continue to work while future iterations can link evidence to facts or deeper characteristics. + +Implementation note 2026-04-29: approved and candidate evidence now carry +additive support metadata: `target_kind`, `target_id`, `reference_kind`, and +`reference_id`. Existing capability-bound evidence remains compatible, while the +UI exposes these fields as supported-characteristic and reference metadata.