src/knowledge_neighborhood.py — one read across four stores this repo
already has (src/requirements/, src/project_board.py,
src/context_engine/code_index.py, src/requirements/evidence.py) that
answers, for one file or requirement: what it must satisfy, which decisions
condition it, what tests cover it, and what fell out of date — typed
requirement -> decision(issue) -> symbol -> test -> run, never a second
index. No LLM call anywhere in this module.
| Method | Path | Query | Returns |
|---|---|---|---|
GET |
/api/projects/{project_id}/knowledge/neighborhood |
path?, req_key?, issue_key?, depth? (1-3, default 1) |
{"neighborhood": {...}} |
Owner-scoped via require_user + the same _project_or_404 helper
routes/requirements_routes.py and routes/board_routes.py already use — a
project id that is not this owner's answers exactly like one that does not
exist. At least one of path/req_key/issue_key should be given; with
none the result is an empty, well-formed neighborhood, never "every
requirement in the project" standing in for scope.
{
"neighborhood": {
"project_id": "…", "path": "src/auth.py", "req_key": null, "issue_key": null,
"depth": 1,
"nodes": [
{"id": "requirement:REQ-3", "type": "requirement", "label": "REQ-3: Users can log in",
"ref": "REQ-3", "why": "…", "stale": false,
"status": "accepted", "verified": true, "implemented": true, "tested": true},
{"id": "decision:FAU-9", "type": "decision", "label": "FAU-9: Add login",
"ref": "FAU-9", "why": "declarado por REQ-3", "stale": false, "status": "open"},
{"id": "symbol:src/auth.py@login", "type": "symbol", "ref": "src/auth.py@login",
"why": "enlazado por REQ-3 (implements)", "stale": false},
{"id": "test:tests/test_auth.py@test_login", "type": "test", "stale": false},
{"id": "run:run_123", "type": "run", "why": "evidencia registrada por REQ-3", "stale": false}
],
"edges": [
{"src": "requirement:REQ-3", "dst": "decision:FAU-9", "relation": "declared", "kind": "issue", "stale": false, "why": ""},
{"src": "requirement:REQ-3", "dst": "symbol:src/auth.py@login", "relation": "declared", "kind": "implements", "stale": false, "why": ""},
{"src": "requirement:REQ-3", "dst": "test:tests/test_auth.py@test_login", "relation": "declared", "kind": "tests", "stale": false, "why": ""},
{"src": "requirement:REQ-3", "dst": "run:run_123", "relation": "verified", "kind": "evidences", "stale": false, "why": ""}
],
"unknown": [],
"stale_refs": []
}
}unknown carries any requested req_key/issue_key that does not exist —
never answered with a fabricated neighborhood for an id that was not found
(same rule requirements/context.py::for_task's own unknown list
follows). stale_refs is the deduplicated headline list of every stale
node's ref, so a caller does not have to re-walk nodes to answer "is
anything here out of date".
declared— a human or agent recorded this link on purpose: a requirement'simplements/tests/issuelink (src/requirements/store.py'sLINK_KINDS).located— found mechanically: a code-index structural neighbor (code_index.neighbors) or an issue found by a text search over the board (project_board.list_issues(q=…)) that mentions the path/key but carries no explicit link.verified— anevidenceslink resolves against the requirement's CURRENT revision. This is the ONLY relation this module ever callsverified— the same rulesrc/requirements/evidence.py's own docstring states (onlykind == "evidences"sets it, never animplementslink or an@implementscomment). A requirement node's ownverifiedfield mirrorsevidence.matrix()['verified']exactly; nothing here recomputes that judgement differently.
stale: bool sits on both nodes and edges, and is never left implicit. It
is computed by delegating to evidence.resolve_link_state() for every
requirement -> {decision,symbol,test,run} edge — never recomputed here —
so the four staleness reasons evidence.py already names
(content_changed, symbol_not_found, target_missing,
requirement_changed_since_verification) are translated once, into a
short Spanish why, and repeated nowhere else:
evidence.py reason |
why shown |
|---|---|
content_changed |
el fichero cambió desde que se enlazó |
symbol_not_found |
el símbolo ya no está en el fichero |
target_missing |
el objetivo del enlace ya no existe |
path_outside_workspace |
el objetivo queda fuera del workspace |
requirement_changed_since_verification |
evidencia caducada: el requisito cambió desde que se verificó |
A target that no longer exists at all resolves to unknown in
evidence.py's own terms (evidence.matrix.stale counts only "stale"
live_state rows) and is not marked stale here either — "borrar test
→ degrada" and "cambiar requisito tras verificar → stale" stay two
different outcomes, exactly as evidence.py distinguishes them.
Requirement changes AFTER its evidence was verified, while the file it
implements and the test that covers it are both untouched ("test antiguo en
verde, código sin actualizar"): the requirement's evidences link goes
stale (evidence.resolve_link_state already computes this — see
requirement_changed_since_verification above), so:
- the
requirement -> runedge (kind="evidences") carriesstale: trueand awhycontaining "evidencia caducada"; - the requirement node's
verifiedisfalse—evidence.matrix()'s own_any('evidences')only counts alive_state == "linked"row, and a stale one is not that, so this module never has to special-case it; implemented/testedon the requirement node staytrue: the code and the test are fine, only the evidence is out of date, and the neighborhood says exactly that, not less and not more.
Covered by tests/test_cmp04_knowledge.py::test_decisive_stale_evidence_is_never_reported_verified.
- Requirements:
req_store.all_requirement_keys+req_store.list_links(matching apathagainst a declaredimplements/testslink's target prefix — same matchrequirements/context.py::_linked_keys_for_filesuses, kept as its own pass here because that helper is private and this one also needs the matching links, not just the keys), andevidence.matrix()for every resolved requirement (never recomputes linked/implemented/tested/verified/stale — reads them straight off). - Decisions:
project_board.get(issue_id)for a requirement's declaredissuelink, andproject_board.list_issues(project_id, q=…)for a text-mention search when apathis given — the latter alwaysrelation="located",kind="mentions", never conflated with a real link. - Symbols/tests:
code_index.symbols_in(path, …)for the file's own definitions, thencode_index.neighbors(symbol_id, hops=depth)for their structural neighbors (imports/calls/defines/tests/registers—code_index.EDGE_KINDS), allrelation="located". A node whose path/qualname looks like a test (test_,/tests/,_test.py) is typedtestrather thansymbol, the same heuristicknowledge_neighborhood._looks_like_testnames once and reuses for both the requirement-link path and the code-index path. - Runs: an
evidenceslink'stargetbecomes arunnode — this module does not resolve it againstagent_runs.py/changeset_store.pyfurther; the id is shown as-is, exactly asevidence.pystores it.
Bounded: depth clamps to MAX_DEPTH=3; MAX_NODES=400/MAX_EDGES=800
cap one response the same way context_engine/wiring.py's own
MAX_REPORT_ROWS caps a shadow report — a neighborhood with thousands of
callers has already made its point in the first few hundred.
studio/src/adapters/knowledge.ts—getNeighborhood(projectId, {path, reqKey, issueKey, depth}, signal), typedNeighborNode/NeighborEdge/Neighborhood.studio/src/screens/Project.tsx— "File neighborhood" (FileNeighborhoodcomponent) in the project's Context tab: a path in, four grouped lists out (what it must satisfy / decisions that condition it / tests that cover it / out of date).
A related but separate CMP-04 piece, NOT part of this endpoint: a per-turn
summary of what the turn's delivered context packets actually put in front
of the model, built from context_engine.wiring.deliver_round's own report
(src/context_engine/wiring.py — read-only from this lot; nothing there was
changed). Rows are {source, kind, ref, why}, deduplicated by (kind, ref)
across the turn's rounds.
- Persisted onto the turn's metadata as
metrics["context_receipts"]— the same pathmetrics["harness"]already rides intoroutes/chat_helpers.py::save_assistant_response'smd(that file is untouched;agent_loop.py'smetricsdict already flows straight intolast_metricsthere, so adding a key tometricswas the whole change). - Streamed once per turn as
data: {"type": "context_receipts", "data": [...]}, capped at 40 rows. - Decoded in
studio/src/adapters/chat.ts(ContextReceipt, thecontext_receiptsChatEventvariant,case 'context_receipts':indecode()). - Rendered in
studio/src/screens/studio/Transcript.tsxas a collapsed "Contexto usado (n) — por qué" card (ContextReceiptCard) on a settled assistant turn, with a link to the source whenrefis a URL.
See "Puntos a cablear por el orquestador" in
docs/adaptations/decisions/CMP-04.md for the one piece this lot could not
finish inside its own file list: ContextReceiptCard reads
turn.contextReceipts defensively (an unknown cast), because the
live-stream reducer that would populate that field on Turn lives in
studio/src/screens/studio/model.ts, outside W2-D's file list.