feat(wiki): [[wikilinks]] with redlinks + cross-project resolver (Issue B)

Last P2 from Antoine's "daily-usable" sprint. Entities referenced via
[[Name]] in descriptions or mirror markdown now render as:

- live wikilink if the name matches an entity in the same project
- live cross-project link with "(in project X)" scope indicator if the
  only match is in another project
- red italic redlink pointing at /wiki/new?name=... otherwise

Clicking a redlink opens a pre-filled "create this entity" form that
POSTs to /v1/entities and redirects to the new entity's page.

- engineering/wiki.py: _wikilink_transform + _resolve_wikilink,
  applied in render_project (pre-markdown) and render_entity
  (description body). render_new_entity_form for the create page.
  CSS for .wikilink / .wikilink-cross / .redlink / .new-entity-form
- api/routes.py: GET /wiki/new?name&project
- tests/test_wikilinks.py: 12 tests including the spec regression
  (A references [[B]] -> redlink; create B -> link becomes live)
- DEV-LEDGER.md: session log + test_count 521 -> 533

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:15:14 -04:00
parent b94f9dff56
commit e147ab2abd
4 changed files with 298 additions and 2 deletions

View File

@@ -9,7 +9,7 @@
- **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z) - **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z)
- **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed) - **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed)
- **main_tip**: `999788b` - **main_tip**: `999788b`
- **test_count**: 521 (prior 509 + 12 new PATCH-entity tests) - **test_count**: 533 (prior 521 + 12 new wikilink/redlink tests)
- **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression) - **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression)
- **vectors**: 33,253 - **vectors**: 33,253
- **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity) - **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
@@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
## Session Log ## Session Log
- **2026-04-22 Claude (pm)** Issue B (wiki redlinks) landed — last remaining P2 from Antoine's sprint plan. `_wikilink_transform(text, current_project)` in `src/atocore/engineering/wiki.py` replaces `[[Name]]` / `[[Name|Display]]` tokens (pre-markdown) with HTML anchors. Resolution order: same-project exact-name match → live `wikilink`; other-project match → live link with `(in project X)` scope indicator (`wikilink-cross`); no match → `redlink` pointing at `/wiki/new?name=<quoted>&project=<current>`. New route `GET /wiki/new` renders a pre-filled "create this entity" form that POSTs to `/v1/entities` via a minimal inline fetch() and redirects to the new entity's wiki page on success. Transform applied in `render_project` (over the mirror markdown) and `render_entity` (over the description body). CSS: dashed-underline accent for live wikilinks, red italic + dashed for redlinks. 12 new tests including the regression from the spec (entity A references `[[EntityB]]` → initial render has `class="redlink"`; after EntityB is created, re-render no longer has redlink and includes `/wiki/entities/{b.id}`). Tests 521 → 533. All 6 acceptance criteria from the sprint plan ("daily-usable") now green: retract/supersede, edit without cloning, cross-project has a home, visual evidence, wiki readable, AKC can capture reliably.
- **2026-04-22 Claude** PATCH `/entities/{id}` + Issue D (/v1/engineering/* aliases) landed. New `update_entity()` in `src/atocore/engineering/service.py` supports partial updates to description (replace), properties (shallow merge — `null` value deletes a key), confidence (0..1, 400 on bounds violation), source_refs (append + dedup). Writes an `updated` audit row with full before/after snapshots. Forbidden via this path: entity_type / project / name / status — those require supersede+create or the dedicated status endpoints, by design. New route `PATCH /entities/{id}` aliased under `/v1`. Issue D: all 10 `/engineering/*` query paths (decisions, systems, components/{id}/requirements, changes, gaps + sub-paths, impact, evidence) added to the `/v1` allowlist. 12 new PATCH tests (merge, null-delete, confidence bounds, source_refs dedup, 404, audit row, v1 alias). Tests 509 → 521. Next: commit + deploy, then Issue B (wiki redlinks) as the last remaining P2 per Antoine's sprint order. - **2026-04-22 Claude** PATCH `/entities/{id}` + Issue D (/v1/engineering/* aliases) landed. New `update_entity()` in `src/atocore/engineering/service.py` supports partial updates to description (replace), properties (shallow merge — `null` value deletes a key), confidence (0..1, 400 on bounds violation), source_refs (append + dedup). Writes an `updated` audit row with full before/after snapshots. Forbidden via this path: entity_type / project / name / status — those require supersede+create or the dedicated status endpoints, by design. New route `PATCH /entities/{id}` aliased under `/v1`. Issue D: all 10 `/engineering/*` query paths (decisions, systems, components/{id}/requirements, changes, gaps + sub-paths, impact, evidence) added to the `/v1` allowlist. 12 new PATCH tests (merge, null-delete, confidence bounds, source_refs dedup, 404, audit row, v1 alias). Tests 509 → 521. Next: commit + deploy, then Issue B (wiki redlinks) as the last remaining P2 per Antoine's sprint order.
- **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes. - **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes.

View File

@@ -129,6 +129,13 @@ def wiki_capture() -> HTMLResponse:
return HTMLResponse(content=render_capture()) return HTMLResponse(content=render_capture())
@router.get("/wiki/new", response_class=HTMLResponse)
def wiki_new_entity(name: str = "", project: str = "") -> HTMLResponse:
"""Issue B: "create this entity" form pre-filled from a redlink."""
from atocore.engineering.wiki import render_new_entity_form
return HTMLResponse(content=render_new_entity_form(name=name, project=project))
@router.get("/wiki/memories/{memory_id}", response_class=HTMLResponse) @router.get("/wiki/memories/{memory_id}", response_class=HTMLResponse)
def wiki_memory(memory_id: str) -> HTMLResponse: def wiki_memory(memory_id: str) -> HTMLResponse:
"""Phase 7E: memory detail with audit trail + neighbors.""" """Phase 7E: memory detail with audit trail + neighbors."""

View File

@@ -262,6 +262,10 @@ def render_project(project: str) -> str:
from atocore.engineering.mirror import generate_project_overview from atocore.engineering.mirror import generate_project_overview
markdown_content = generate_project_overview(project) markdown_content = generate_project_overview(project)
# Resolve [[Wikilinks]] before markdown so redlinks / cross-project
# indicators appear in the rendered HTML. (Issue B)
markdown_content = _wikilink_transform(markdown_content, current_project=project)
# Convert entity names to links # Convert entity names to links
entities = get_entities(project=project, limit=200) entities = get_entities(project=project, limit=200)
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"]) html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
@@ -277,6 +281,145 @@ def render_project(project: str) -> str:
) )
import re as _re
_WIKILINK_PATTERN = _re.compile(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]+?))?\]\]")
def _resolve_wikilink(target: str, current_project: str | None) -> tuple[str, str, str]:
"""Resolve a ``[[Name]]`` target to ``(href, css_class, extra_suffix)``.
Resolution order (Issue B):
1. Same-project exact name match → live link (class ``wikilink``).
2. Other-project exact name match → live link with ``(in project X)``
suffix (class ``wikilink wikilink-cross``).
3. No match → redlink pointing at ``/wiki/new?name=...`` so clicking
opens a pre-filled "create this entity" form (class ``redlink``).
"""
needle = target.strip()
if not needle:
return ("/wiki/new", "redlink", "")
same_project = None
cross_project = None
try:
candidates = get_entities(name_contains=needle, limit=200)
except Exception:
candidates = []
lowered = needle.lower()
for ent in candidates:
if ent.name.lower() != lowered:
continue
if current_project and ent.project == current_project:
same_project = ent
break
if cross_project is None:
cross_project = ent
if same_project is not None:
return (f"/wiki/entities/{same_project.id}", "wikilink", "")
if cross_project is not None:
suffix = (
f' <span class="wikilink-scope">(in {cross_project.project})</span>'
if cross_project.project
else ' <span class="wikilink-scope">(global)</span>'
)
return (f"/wiki/entities/{cross_project.id}", "wikilink wikilink-cross", suffix)
from urllib.parse import quote
href = f"/wiki/new?name={quote(needle)}"
if current_project:
href += f"&project={quote(current_project)}"
return (href, "redlink", "")
def _wikilink_transform(text: str, current_project: str | None) -> str:
"""Replace ``[[Name]]`` / ``[[Name|Display]]`` tokens with HTML anchors.
Runs before markdown rendering. Emits raw HTML which python-markdown
preserves unchanged.
"""
if not text or "[[" not in text:
return text
def _sub(match: _re.Match) -> str:
target = match.group(1)
display = (match.group(2) or target).strip()
href, cls, suffix = _resolve_wikilink(target, current_project)
title = "create this entity" if cls == "redlink" else target.strip()
return (
f'<a href="{href}" class="{cls}" title="{_escape_attr(title)}">'
f'{_escape_html(display)}</a>{suffix}'
)
return _WIKILINK_PATTERN.sub(_sub, text)
def render_new_entity_form(name: str = "", project: str = "") -> str:
"""Issue B — "create this entity" form targeted by redlinks."""
from atocore.engineering.service import ENTITY_TYPES
safe_name = _escape_attr(name or "")
safe_project = _escape_attr(project or "")
opts = "".join(
f'<option value="{t}">{t}</option>' for t in ENTITY_TYPES
)
lines = [
'<h1>Create entity</h1>',
('<p>This entity was referenced via a wikilink but does not exist yet. '
'Fill in the details to create it — the wiki link will resolve on reload.</p>'),
'<form id="new-entity-form" class="new-entity-form">',
f'<label>Name<br><input type="text" name="name" value="{safe_name}" required></label>',
f'<label>Entity type<br><select name="entity_type" required>{opts}</select></label>',
f'<label>Project<br><input type="text" name="project" value="{safe_project}" '
f'placeholder="leave blank for cross-project / global"></label>',
'<label>Description<br><textarea name="description" rows="4"></textarea></label>',
'<button type="submit">Create</button>',
'<div id="new-entity-result" class="new-entity-result"></div>',
'</form>',
"""<script>
(function() {
const form = document.getElementById('new-entity-form');
const out = document.getElementById('new-entity-result');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = new FormData(form);
const body = {
name: fd.get('name'),
entity_type: fd.get('entity_type'),
project: fd.get('project') || '',
description: fd.get('description') || '',
};
try {
const r = await fetch('/v1/entities', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const j = await r.json();
if (r.ok) {
out.innerHTML = 'Created: <a href="/wiki/entities/' + j.id + '">' +
(j.name || 'new entity') + '</a>';
setTimeout(() => { window.location.href = '/wiki/entities/' + j.id; }, 800);
} else {
out.textContent = 'Error: ' + (j.detail || JSON.stringify(j));
}
} catch (e) {
out.textContent = 'Network error: ' + e;
}
});
})();
</script>""",
]
return render_html(
f"Create {name}" if name else "Create entity",
"\n".join(lines),
breadcrumbs=[("Wiki", "/wiki"), ("Create entity", "")],
)
def _render_visual_evidence(entity_id: str, ctx: dict) -> str: def _render_visual_evidence(entity_id: str, ctx: dict) -> str:
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip.""" """Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
from atocore.assets import get_asset from atocore.assets import get_asset
@@ -397,7 +540,8 @@ def render_entity(entity_id: str) -> str | None:
if ent.project: if ent.project:
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>') lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
if ent.description: if ent.description:
lines.append(f'<p>{ent.description}</p>') desc_html = _wikilink_transform(ent.description, current_project=ent.project)
lines.append(f'<p>{desc_html}</p>')
if ent.properties: if ent.properties:
lines.append('<h2>Properties</h2><ul>') lines.append('<h2>Properties</h2><ul>')
for k, v in ent.properties.items(): for k, v in ent.properties.items():
@@ -917,6 +1061,17 @@ _TEMPLATE = """<!DOCTYPE html>
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; } .stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; } .stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; } .meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
.wikilink { color: var(--accent); text-decoration: none; border-bottom: 1px dashed transparent; }
.wikilink:hover { border-bottom-color: var(--accent); }
.wikilink-cross { border-bottom-style: dotted; }
.wikilink-scope { font-size: 0.75em; opacity: 0.6; font-style: italic; }
.redlink { color: #d0473d; text-decoration: none; font-style: italic; border-bottom: 1px dashed #d0473d; }
.redlink:hover { background: rgba(208, 71, 61, 0.08); }
.new-entity-form { display: flex; flex-direction: column; gap: 0.9rem; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.2rem; }
.new-entity-form label { display: flex; flex-direction: column; font-size: 0.88rem; opacity: 0.8; }
.new-entity-form input, .new-entity-form select, .new-entity-form textarea { margin-top: 0.3rem; padding: 0.45rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.95rem; }
.new-entity-form button { align-self: flex-start; padding: 0.5rem 1.1rem; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; }
.new-entity-result { font-size: 0.9rem; opacity: 0.85; min-height: 1em; }
.evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; } .evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; }
.evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; } .evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; }
.evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; } .evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; }

132
tests/test_wikilinks.py Normal file
View File

@@ -0,0 +1,132 @@
"""Issue B — wikilinks with redlinks + cross-project resolution."""
import pytest
from fastapi.testclient import TestClient
from atocore.engineering.service import (
create_entity,
init_engineering_schema,
)
from atocore.engineering.wiki import (
_resolve_wikilink,
_wikilink_transform,
render_entity,
render_new_entity_form,
render_project,
)
from atocore.main import app
from atocore.models.database import init_db
@pytest.fixture
def env(tmp_data_dir, tmp_path, monkeypatch):
registry_path = tmp_path / "test-registry.json"
registry_path.write_text('{"projects": []}', encoding="utf-8")
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
from atocore import config
config.settings = config.Settings()
init_db()
init_engineering_schema()
yield tmp_data_dir
def test_resolve_wikilink_same_project_is_live(env):
tower = create_entity(entity_type="component", name="Tower", project="p05")
href, cls, _ = _resolve_wikilink("Tower", current_project="p05")
assert href == f"/wiki/entities/{tower.id}"
assert cls == "wikilink"
def test_resolve_wikilink_missing_is_redlink(env):
href, cls, suffix = _resolve_wikilink("DoesNotExist", current_project="p05")
assert "/wiki/new" in href
assert "name=DoesNotExist" in href
assert cls == "redlink"
def test_resolve_wikilink_cross_project_indicator(env):
other = create_entity(entity_type="material", name="Invar", project="p06")
href, cls, suffix = _resolve_wikilink("Invar", current_project="p05")
assert href == f"/wiki/entities/{other.id}"
assert "wikilink-cross" in cls
assert "in p06" in suffix
def test_resolve_wikilink_case_insensitive(env):
tower = create_entity(entity_type="component", name="Tower", project="p05")
href, cls, _ = _resolve_wikilink("tower", current_project="p05")
assert href == f"/wiki/entities/{tower.id}"
assert cls == "wikilink"
def test_transform_replaces_brackets_with_anchor(env):
create_entity(entity_type="component", name="Base Plate", project="p05")
out = _wikilink_transform("See [[Base Plate]] for details.", current_project="p05")
assert '<a href="/wiki/entities/' in out
assert 'class="wikilink"' in out
assert "[[Base Plate]]" not in out
def test_transform_redlink_for_missing(env):
out = _wikilink_transform("Mentions [[Ghost]] nowhere.", current_project="p05")
assert 'class="redlink"' in out
assert "/wiki/new?name=Ghost" in out
def test_transform_alias_syntax(env):
tower = create_entity(entity_type="component", name="Tower", project="p05")
out = _wikilink_transform("The [[Tower|big tower]] is tall.", current_project="p05")
assert f'href="/wiki/entities/{tower.id}"' in out
assert ">big tower<" in out
def test_render_entity_description_has_redlink(env):
a = create_entity(
entity_type="component",
name="EntityA",
project="p05",
description="This depends on [[MissingPart]] which does not exist.",
)
html = render_entity(a.id)
assert 'class="redlink"' in html
assert "/wiki/new?name=MissingPart" in html
def test_regression_redlink_becomes_live_once_target_created(env):
a = create_entity(
entity_type="component",
name="EntityA",
project="p05",
description="Connected to [[EntityB]].",
)
# Pre-create: redlink.
html_before = render_entity(a.id)
assert 'class="redlink"' in html_before
b = create_entity(entity_type="component", name="EntityB", project="p05")
html_after = render_entity(a.id)
assert 'class="redlink"' not in html_after
assert f"/wiki/entities/{b.id}" in html_after
def test_new_entity_form_prefills_name():
html = render_new_entity_form(name="FreshEntity", project="p05")
assert 'value="FreshEntity"' in html
assert 'value="p05"' in html
assert "entity_type" in html
assert 'method="post"' not in html # JS-driven
def test_wiki_new_route_renders(env):
client = TestClient(app)
r = client.get("/wiki/new?name=NewThing&project=p05")
assert r.status_code == 200
assert "NewThing" in r.text
assert "Create entity" in r.text
def test_wiki_new_url_escapes_special_chars(env):
# "steel (likely)" is the kind of awkward name AKC produces
href, cls, _ = _resolve_wikilink("steel (likely)", current_project="p05")
assert cls == "redlink"
assert "name=steel%20%28likely%29" in href