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

@@ -129,6 +129,13 @@ def wiki_capture() -> HTMLResponse:
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)
def wiki_memory(memory_id: str) -> HTMLResponse:
"""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
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
entities = get_entities(project=project, limit=200)
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:
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
from atocore.assets import get_asset
@@ -397,7 +540,8 @@ def render_entity(entity_id: str) -> str | None:
if ent.project:
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
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:
lines.append('<h2>Properties</h2><ul>')
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 span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
.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-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; }