Projects now appear under three buckets based on their state entries: - Active Contracts - Leads & Prospects - Internal Tools & Infra Each card shows the stage as a tag on the project title, the client as an italic subtitle, and the project description. Empty buckets hide. Makes it obvious at a glance what's contracted vs lead vs internal. Paired with stage/type/client state entries added to all 6 projects so the grouping has data to work with. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""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_entries = get_state(p.project_id)
|
|
|
|
# Pull stage/type/client from state entries
|
|
stage = ""
|
|
proj_type = ""
|
|
client = ""
|
|
for e in state_entries:
|
|
if e.category == "status":
|
|
if e.key == "stage":
|
|
stage = e.value
|
|
elif e.key == "type":
|
|
proj_type = e.value
|
|
elif e.key == "client":
|
|
client = e.value
|
|
|
|
projects.append({
|
|
"id": p.project_id,
|
|
"description": p.description,
|
|
"entities": entity_count,
|
|
"memories": memory_count,
|
|
"state": len(state_entries),
|
|
"stage": stage,
|
|
"type": proj_type,
|
|
"client": client,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# Group by high-level bucket
|
|
buckets: dict[str, list] = {
|
|
"Active Contracts": [],
|
|
"Leads & Prospects": [],
|
|
"Internal Tools & Infra": [],
|
|
"Other": [],
|
|
}
|
|
for p in projects:
|
|
t = p["type"].lower()
|
|
s = p["stage"].lower()
|
|
if "lead" in t or "lead" in s or "prospect" in s:
|
|
buckets["Leads & Prospects"].append(p)
|
|
elif "contract" in t or ("active" in s and "contract" in s):
|
|
buckets["Active Contracts"].append(p)
|
|
elif "infra" in t or "tool" in t or "internal" in t:
|
|
buckets["Internal Tools & Infra"].append(p)
|
|
else:
|
|
buckets["Other"].append(p)
|
|
|
|
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>')
|
|
|
|
for bucket_name, items in buckets.items():
|
|
if not items:
|
|
continue
|
|
lines.append(f'<h2>{bucket_name}</h2>')
|
|
lines.append('<div class="card-grid">')
|
|
for p in items:
|
|
client_line = f'<div class="client">{p["client"]}</div>' if p["client"] else ''
|
|
stage_tag = f'<span class="tag">{p["stage"].split(" — ")[0]}</span>' if p["stage"] else ''
|
|
lines.append(f'<a href="/wiki/projects/{p["id"]}" class="card">')
|
|
lines.append(f'<h3>{p["id"]} {stage_tag}</h3>')
|
|
lines.append(client_line)
|
|
lines.append(f'<p>{p["description"][:140]}</p>')
|
|
lines.append(f'<div class="stats">{p["entities"]} entities · {p["memories"]} memories · {p["state"]} state</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; }
|
|
.card .client { font-size: 0.85em; opacity: 0.65; margin-bottom: 0.3rem; font-style: italic; }
|
|
.card h3 .tag { font-size: 0.65em; vertical-align: middle; margin-left: 0.4rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{nav}}
|
|
{{body}}
|
|
</body>
|
|
</html>"""
|