Evidence with supportive metadata

This commit is contained in:
2026-04-29 15:52:37 +02:00
parent 6c0a7db5e4
commit eb1513e463
10 changed files with 194 additions and 9 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"],
)
)

View File

@@ -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):

View File

@@ -592,8 +592,12 @@ def repository_detail(
<form class="stack" method="post" action="/ui/repos/{repository_id}/evidence">
<h3>Add Capability Support</h3>
<label>Supported capability ID <input name="capability_id" type="number" min="1" required></label>
<label>Supported characteristic kind <input name="target_kind" value="capability" required></label>
<label>Supported characteristic ID <input name="target_id" type="number" min="1" placeholder="Defaults to supported capability ID"></label>
<label>Support type <input name="type" placeholder="fact, documentation, test, example, feature" required></label>
<label>Reference <input name="reference" placeholder="Observed fact, file, or lower-level characteristic" required></label>
<label>Reference kind <input name="reference_kind" value="source" placeholder="source, fact, feature, capability"></label>
<label>Reference ID <input name="reference_id" type="number" min="1" placeholder="Optional fact or characteristic ID"></label>
<label>Strength <input name="strength" value="medium" required></label>
<button type="submit">Add Support</button>
</form>
@@ -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"""
<li>
<strong>{escape(evidence["type"])}</strong>
<span class="pill">{escape(evidence["strength"])}</span>
<span class="pill">supports {target_kind}{f' #{target_id}' if target_id else ''}</span>
<span class="pill">references {reference_kind}{f' #{reference_id}' if reference_id else ''}</span>
<span class="source">{escape(evidence["reference"])}</span>
{render_sources(evidence.get("source_refs", []))}
<form class="stack" method="post" action="/ui/repos/{repository_id}/evidence/{evidence['id']}/edit">
<label>Supported characteristic kind <input name="target_kind" value="{target_kind}" required></label>
<label>Supported characteristic ID <input name="target_id" type="number" min="1" value="{target_id or ''}"></label>
<label>Support type <input name="type" value="{escape(evidence['type'])}" required></label>
<label>Reference <input name="reference" value="{escape(evidence['reference'])}" required></label>
<label>Reference kind <input name="reference_kind" value="{reference_kind}" required></label>
<label>Reference ID <input name="reference_id" type="number" min="1" value="{reference_id or ''}"></label>
<label>Strength <input name="strength" value="{escape(evidence['strength'])}" required></label>
<button class="secondary" type="submit">Save Support</button>
</form>

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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.