feat: AtoCore Wiki — navigable project knowledge browser
Full wiki interface at /wiki with:
- /wiki — Homepage with project cards, search box, system stats
- /wiki/projects/{name} — Project page with clickable entity links
- /wiki/entities/{id} — Entity detail with relationships as links
- /wiki/search?q=... — Search across entities and memories
Every entity name in a project page links to its detail page.
Entity detail pages show properties, relationships as clickable
links to related entities, and breadcrumb navigation back to the
project and wiki home.
Responsive, dark-mode, mobile-friendly. Card grid for projects.
Generated on-demand from the database — always current, no static
files, source of truth is the DB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,12 @@ from atocore.interactions.service import (
|
|||||||
record_interaction,
|
record_interaction,
|
||||||
)
|
)
|
||||||
from atocore.engineering.mirror import generate_project_overview
|
from atocore.engineering.mirror import generate_project_overview
|
||||||
|
from atocore.engineering.wiki import (
|
||||||
|
render_entity,
|
||||||
|
render_homepage,
|
||||||
|
render_project,
|
||||||
|
render_search,
|
||||||
|
)
|
||||||
from atocore.engineering.service import (
|
from atocore.engineering.service import (
|
||||||
ENTITY_TYPES,
|
ENTITY_TYPES,
|
||||||
RELATIONSHIP_TYPES,
|
RELATIONSHIP_TYPES,
|
||||||
@@ -85,6 +91,33 @@ router = APIRouter()
|
|||||||
log = get_logger("api")
|
log = get_logger("api")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wiki routes (HTML, served first for clean URLs) ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wiki", response_class=HTMLResponse)
|
||||||
|
def wiki_home() -> HTMLResponse:
|
||||||
|
return HTMLResponse(content=render_homepage())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wiki/projects/{project_name}", response_class=HTMLResponse)
|
||||||
|
def wiki_project(project_name: str) -> HTMLResponse:
|
||||||
|
from atocore.projects.registry import resolve_project_name as _resolve
|
||||||
|
return HTMLResponse(content=render_project(_resolve(project_name)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wiki/entities/{entity_id}", response_class=HTMLResponse)
|
||||||
|
def wiki_entity(entity_id: str) -> HTMLResponse:
|
||||||
|
html = render_entity(entity_id)
|
||||||
|
if html is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wiki/search", response_class=HTMLResponse)
|
||||||
|
def wiki_search(q: str = "") -> HTMLResponse:
|
||||||
|
return HTMLResponse(content=render_search(q))
|
||||||
|
|
||||||
|
|
||||||
# --- Request/Response models ---
|
# --- Request/Response models ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
254
src/atocore/engineering/wiki.py
Normal file
254
src/atocore/engineering/wiki.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""AtoCore Wiki — navigable HTML pages from structured data.
|
||||||
|
|
||||||
|
A lightweight wiki served directly from the AtoCore API. Every page is
|
||||||
|
generated on-demand from the database so it's always current. Source of
|
||||||
|
truth is the database — the wiki is a derived view.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
/wiki Homepage with project list + search
|
||||||
|
/wiki/projects/{name} Full project overview
|
||||||
|
/wiki/entities/{id} Entity detail with relationships
|
||||||
|
/wiki/search?q=... Search entities, memories, state
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import markdown as md
|
||||||
|
|
||||||
|
from atocore.context.project_state import get_state
|
||||||
|
from atocore.engineering.service import (
|
||||||
|
get_entities,
|
||||||
|
get_entity,
|
||||||
|
get_entity_with_context,
|
||||||
|
get_relationships,
|
||||||
|
)
|
||||||
|
from atocore.memory.service import get_memories
|
||||||
|
from atocore.projects.registry import load_project_registry
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] | None = None) -> str:
|
||||||
|
nav = ""
|
||||||
|
if breadcrumbs:
|
||||||
|
parts = []
|
||||||
|
for label, href in breadcrumbs:
|
||||||
|
if href:
|
||||||
|
parts.append(f'<a href="{href}">{label}</a>')
|
||||||
|
else:
|
||||||
|
parts.append(f"<span>{label}</span>")
|
||||||
|
nav = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
|
||||||
|
|
||||||
|
return _TEMPLATE.replace("{{title}}", title).replace("{{nav}}", nav).replace("{{body}}", body_html)
|
||||||
|
|
||||||
|
|
||||||
|
def render_homepage() -> str:
|
||||||
|
projects = []
|
||||||
|
try:
|
||||||
|
registered = load_project_registry()
|
||||||
|
for p in registered:
|
||||||
|
entity_count = len(get_entities(project=p.project_id, limit=200))
|
||||||
|
memory_count = len(get_memories(project=p.project_id, active_only=True, limit=200))
|
||||||
|
state_count = len(get_state(p.project_id))
|
||||||
|
projects.append({
|
||||||
|
"id": p.project_id,
|
||||||
|
"description": p.description,
|
||||||
|
"entities": entity_count,
|
||||||
|
"memories": memory_count,
|
||||||
|
"state": state_count,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = ['<h1>AtoCore Wiki</h1>']
|
||||||
|
lines.append('<form class="search-box" action="/wiki/search" method="get">')
|
||||||
|
lines.append('<input type="text" name="q" placeholder="Search entities, memories, projects..." autofocus>')
|
||||||
|
lines.append('<button type="submit">Search</button>')
|
||||||
|
lines.append('</form>')
|
||||||
|
|
||||||
|
lines.append('<h2>Projects</h2>')
|
||||||
|
lines.append('<div class="card-grid">')
|
||||||
|
for p in projects:
|
||||||
|
lines.append(f'<a href="/wiki/projects/{p["id"]}" class="card">')
|
||||||
|
lines.append(f'<h3>{p["id"]}</h3>')
|
||||||
|
lines.append(f'<p>{p["description"][:120]}</p>')
|
||||||
|
lines.append(f'<div class="stats">{p["entities"]} entities · {p["memories"]} memories · {p["state"]} state entries</div>')
|
||||||
|
lines.append('</a>')
|
||||||
|
lines.append('</div>')
|
||||||
|
|
||||||
|
# Quick stats
|
||||||
|
all_entities = get_entities(limit=500)
|
||||||
|
all_memories = get_memories(active_only=True, limit=500)
|
||||||
|
lines.append('<h2>System</h2>')
|
||||||
|
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
|
||||||
|
lines.append(f'<p><a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
|
||||||
|
|
||||||
|
return render_html("AtoCore Wiki", "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def render_project(project: str) -> str:
|
||||||
|
from atocore.engineering.mirror import generate_project_overview
|
||||||
|
|
||||||
|
markdown_content = generate_project_overview(project)
|
||||||
|
# Convert entity names to links
|
||||||
|
entities = get_entities(project=project, limit=200)
|
||||||
|
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
|
||||||
|
|
||||||
|
for ent in sorted(entities, key=lambda e: len(e.name), reverse=True):
|
||||||
|
linked = f'<a href="/wiki/entities/{ent.id}" title="{ent.entity_type}">{ent.name}</a>'
|
||||||
|
html_body = html_body.replace(f"<strong>{ent.name}</strong>", f"<strong>{linked}</strong>", 1)
|
||||||
|
|
||||||
|
return render_html(
|
||||||
|
f"{project}",
|
||||||
|
html_body,
|
||||||
|
breadcrumbs=[("Wiki", "/wiki"), (project, "")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_entity(entity_id: str) -> str | None:
|
||||||
|
ctx = get_entity_with_context(entity_id)
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ent = ctx["entity"]
|
||||||
|
lines = [f'<h1>[{ent.entity_type}] {ent.name}</h1>']
|
||||||
|
|
||||||
|
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>')
|
||||||
|
if ent.properties:
|
||||||
|
lines.append('<h2>Properties</h2><ul>')
|
||||||
|
for k, v in ent.properties.items():
|
||||||
|
lines.append(f'<li><strong>{k}</strong>: {v}</li>')
|
||||||
|
lines.append('</ul>')
|
||||||
|
|
||||||
|
lines.append(f'<p class="meta">confidence: {ent.confidence} · status: {ent.status} · created: {ent.created_at}</p>')
|
||||||
|
|
||||||
|
if ctx["relationships"]:
|
||||||
|
lines.append('<h2>Relationships</h2><ul>')
|
||||||
|
for rel in ctx["relationships"]:
|
||||||
|
other_id = rel.target_entity_id if rel.source_entity_id == entity_id else rel.source_entity_id
|
||||||
|
other = ctx["related_entities"].get(other_id)
|
||||||
|
if other:
|
||||||
|
direction = "\u2192" if rel.source_entity_id == entity_id else "\u2190"
|
||||||
|
lines.append(
|
||||||
|
f'<li>{direction} <em>{rel.relationship_type}</em> '
|
||||||
|
f'<a href="/wiki/entities/{other_id}">[{other.entity_type}] {other.name}</a></li>'
|
||||||
|
)
|
||||||
|
lines.append('</ul>')
|
||||||
|
|
||||||
|
breadcrumbs = [("Wiki", "/wiki")]
|
||||||
|
if ent.project:
|
||||||
|
breadcrumbs.append((ent.project, f"/wiki/projects/{ent.project}"))
|
||||||
|
breadcrumbs.append((ent.name, ""))
|
||||||
|
|
||||||
|
return render_html(ent.name, "\n".join(lines), breadcrumbs=breadcrumbs)
|
||||||
|
|
||||||
|
|
||||||
|
def render_search(query: str) -> str:
|
||||||
|
lines = [f'<h1>Search: "{query}"</h1>']
|
||||||
|
|
||||||
|
# Search entities by name
|
||||||
|
entities = get_entities(name_contains=query, limit=20)
|
||||||
|
if entities:
|
||||||
|
lines.append(f'<h2>Entities ({len(entities)})</h2><ul>')
|
||||||
|
for e in entities:
|
||||||
|
proj = f' <span class="tag">{e.project}</span>' if e.project else ''
|
||||||
|
lines.append(
|
||||||
|
f'<li><a href="/wiki/entities/{e.id}">[{e.entity_type}] {e.name}</a>{proj}'
|
||||||
|
f'{" — " + e.description[:100] if e.description else ""}</li>'
|
||||||
|
)
|
||||||
|
lines.append('</ul>')
|
||||||
|
|
||||||
|
# Search memories
|
||||||
|
all_memories = get_memories(active_only=True, limit=200)
|
||||||
|
query_lower = query.lower()
|
||||||
|
matching_mems = [m for m in all_memories if query_lower in m.content.lower()][:10]
|
||||||
|
if matching_mems:
|
||||||
|
lines.append(f'<h2>Memories ({len(matching_mems)})</h2><ul>')
|
||||||
|
for m in matching_mems:
|
||||||
|
proj = f' <span class="tag">{m.project}</span>' if m.project else ''
|
||||||
|
lines.append(f'<li>[{m.memory_type}]{proj} {m.content[:200]}</li>')
|
||||||
|
lines.append('</ul>')
|
||||||
|
|
||||||
|
if not entities and not matching_mems:
|
||||||
|
lines.append('<p>No results found.</p>')
|
||||||
|
|
||||||
|
lines.append('<form class="search-box" action="/wiki/search" method="get">')
|
||||||
|
lines.append(f'<input type="text" name="q" value="{query}" autofocus>')
|
||||||
|
lines.append('<button type="submit">Search</button>')
|
||||||
|
lines.append('</form>')
|
||||||
|
|
||||||
|
return render_html(
|
||||||
|
f"Search: {query}",
|
||||||
|
"\n".join(lines),
|
||||||
|
breadcrumbs=[("Wiki", "/wiki"), ("Search", "")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TEMPLATE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{title}} — AtoCore</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #fafafa; --text: #1a1a2e; --accent: #2563eb; --border: #e2e8f0; --card: #fff; --hover: #f1f5f9; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; --border: #334155; --card: #1e293b; --hover: #334155; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.7; color: var(--text); background: var(--bg);
|
||||||
|
max-width: 800px; margin: 0 auto; padding: 1.5rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
|
||||||
|
h2 { font-size: 1.3rem; margin-top: 2rem; margin-bottom: 0.6rem; padding-bottom: 0.2rem; border-bottom: 2px solid var(--border); }
|
||||||
|
h3 { font-size: 1.1rem; margin-top: 1.2rem; margin-bottom: 0.4rem; }
|
||||||
|
p { margin-bottom: 0.8rem; }
|
||||||
|
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
|
||||||
|
li { margin-bottom: 0.3rem; }
|
||||||
|
li ul { margin-top: 0.2rem; }
|
||||||
|
strong { color: var(--accent); font-weight: 600; }
|
||||||
|
em { opacity: 0.7; font-size: 0.9em; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
blockquote {
|
||||||
|
background: var(--card); border-left: 4px solid var(--accent);
|
||||||
|
padding: 0.6rem 1rem; margin: 1rem 0; border-radius: 0 6px 6px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||||
|
.breadcrumbs { margin-bottom: 1.5rem; font-size: 0.85em; opacity: 0.7; }
|
||||||
|
.breadcrumbs a { opacity: 0.8; }
|
||||||
|
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
||||||
|
.tag { background: var(--accent); color: var(--bg); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75em; margin-left: 0.3rem; }
|
||||||
|
.search-box { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
|
||||||
|
.search-box input {
|
||||||
|
flex: 1; padding: 0.6rem 1rem; border: 2px solid var(--border);
|
||||||
|
border-radius: 8px; background: var(--card); color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.search-box input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.search-box button {
|
||||||
|
padding: 0.6rem 1.2rem; background: var(--accent); color: var(--bg);
|
||||||
|
border: none; border-radius: 8px; cursor: pointer; font-size: 1rem;
|
||||||
|
}
|
||||||
|
.card-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; margin: 1rem 0; }
|
||||||
|
@media (min-width: 600px) { .card-grid { grid-template-columns: 1fr 1fr; } }
|
||||||
|
.card {
|
||||||
|
display: block; background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 1.2rem; text-decoration: none;
|
||||||
|
color: var(--text); transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: var(--accent); background: var(--hover); text-decoration: none; }
|
||||||
|
.card h3 { color: var(--accent); margin: 0 0 0.3rem 0; }
|
||||||
|
.card p { font-size: 0.9em; margin: 0; opacity: 0.8; }
|
||||||
|
.card .stats { font-size: 0.8em; margin-top: 0.5rem; opacity: 0.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{nav}}
|
||||||
|
{{body}}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
Reference in New Issue
Block a user