Files
agent0/plugins/festinger/festinger/main.py
T
2026-04-25 11:31:58 +02:00

3967 lines
162 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Festinger — main FastAPI application.
Routes:
POST /api/chat Ollama-compatible chat (loop detection + recollection injection)
POST /api/generate Ollama-compatible generate
POST /v1/messages Anthropic Messages API proxy (loop detection + recollection)
POST /v1/chat/completions OpenAI-compatible proxy (loop detection + recollection)
POST /chat/completions Alias for /v1/chat/completions (LiteLLM without /v1 prefix)
POST /{agent_id}/v1/chat/completions Agent-prefixed OpenAI proxy — agent identity from URL
POST /{agent_id}/chat/completions Agent-prefixed OpenAI proxy (no /v1 prefix variant)
POST /{agent_id}/v1/messages Agent-prefixed Anthropic proxy — agent identity from URL
POST /{agent_id}/api/chat Agent-prefixed Ollama chat — agent identity from URL
POST /{agent_id}/api/generate Agent-prefixed Ollama generate — agent identity from URL
POST /iknowthat Manual write path (gutask iknowthat)
POST /resolve/run Manually trigger nightly resolution job
POST /reload Reload URD cache (called by resolution job)
GET /health Health + stats
GET /conflicts Pending and recently resolved conflicts
GET /admin Minimal admin UI
* /{path} Passthrough to upstream Ollama or Anthropic
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import httpx
import yaml
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
from . import cache
from .db import (
close_pool, get_config, get_or_create_soas,
get_pool, init_schema, bootstrap_dimensions,
bootstrap_english_dictionary, warm_cache, reload_urd_cache,
flush_encounter_deltas, reset_graph, log_recollection,
)
from .loop_detector import apply_mitigations, record_and_check, session_key
from .cue_scanner import scan_cues
from .recollection import build_recollection_block, inject_recollection
from .resolution_job import run_resolution_job, last_run_timestamp
from .tokenizer import tokenize
from .llm_client import ModelConfig
from .write_queue import enqueue_cue, start_worker, stop_worker
from .urd_writer import InsertRequest, insert_urd_edge
from .wordnet import import_wordnet, CITATION as WORDNET_CITATION
from .test_scenarios import SCENARIOS, seed_scenario, reset_scenario
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
)
log = logging.getLogger("festinger")
CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
def load_yaml_config() -> dict:
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
# ---------------------------------------------------------------------------
# Lifespan — startup / shutdown
# ---------------------------------------------------------------------------
_scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
cfg = load_yaml_config()
dsn = os.environ.get("POSTGRES_DSN", cfg.get("postgres_dsn", ""))
pool = await get_pool(dsn)
app.state.pool = pool
app.state.yaml_config = cfg
await init_schema(pool)
await bootstrap_dimensions(pool)
await bootstrap_english_dictionary(pool)
await warm_cache(pool)
await start_worker(pool)
# Schedule saliency flush every 30 s
_scheduler.add_job(flush_encounter_deltas, "interval", seconds=30, args=[pool], id="saliency_flush")
# Schedule nightly resolution job from config table
resolution_cron = await get_config(pool, "resolution_schedule", "0 2 * * *")
cron_parts = resolution_cron.split()
if len(cron_parts) == 5:
minute, hour, day, month, dow = cron_parts
_scheduler.add_job(
run_resolution_job, "cron",
minute=minute, hour=hour, day=day, month=month, day_of_week=dow,
args=[pool], id="nightly_resolution",
)
_scheduler.start()
log.info("festinger ready")
yield
_scheduler.shutdown(wait=False)
await stop_worker()
await close_pool()
app = FastAPI(title="Festinger", lifespan=lifespan)
# ---------------------------------------------------------------------------
# Upstream error — preserves the real HTTP status code from upstream
# ---------------------------------------------------------------------------
class UpstreamError(Exception):
"""
Raised when an upstream (Anthropic / Ollama / OpenAI) returns a non-2xx
response. Carries the original status code and body so route handlers can
forward them verbatim instead of turning everything into a 500.
"""
def __init__(self, status_code: int, content: bytes, content_type: str, provider: str):
self.status_code = status_code
self.content = content
self.content_type = content_type or "application/json"
self.provider = provider
super().__init__(f"{provider} upstream returned {status_code}")
# ---------------------------------------------------------------------------
# Request / response logging middleware
# ---------------------------------------------------------------------------
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log every inbound request and its outcome. Catches unhandled exceptions."""
t0 = time.perf_counter()
method = request.method
path = request.url.path
qs = ("?" + str(request.url.query)) if request.url.query else ""
size = request.headers.get("content-length", "?")
log.info("%s %s%s bytes=%s", method, path, qs, size)
try:
response = await call_next(request)
ms = (time.perf_counter() - t0) * 1000
log.info("%d %s %.0fms", response.status_code, path, ms)
return response
except Exception as exc:
ms = (time.perf_counter() - t0) * 1000
log.exception("%s %s %.0fms unhandled %s: %s", method, path, ms, type(exc).__name__, exc)
return Response(
content=json.dumps({"error": str(exc), "type": type(exc).__name__}),
status_code=500,
media_type="application/json",
)
# ---------------------------------------------------------------------------
# Ollama forwarding helpers
# ---------------------------------------------------------------------------
async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
body = dict(body)
body["stream"] = False
model = body.get("model", "?")
url = f"{upstream}{path}"
log.info("upstream_call provider=ollama model=%s url=%s", model, url)
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(url, json=body)
except httpx.TimeoutException as exc:
log.error("upstream_timeout provider=ollama model=%s url=%s after=%.0fs %s",
model, url, time.perf_counter() - t0, exc)
raise
except httpx.RequestError as exc:
log.error("upstream_connect_error provider=ollama model=%s url=%s %s: %s",
model, url, type(exc).__name__, exc)
raise
ms = (time.perf_counter() - t0) * 1000
if not r.is_success:
log.error("upstream_error provider=ollama model=%s url=%s status=%d %.0fms body=%.500s",
model, url, r.status_code, ms, r.text)
raise UpstreamError(r.status_code, r.content, r.headers.get("content-type", ""), "ollama")
log.info("upstream_ok provider=ollama model=%s status=%d %.0fms", model, r.status_code, ms)
data = r.json()
if path == "/api/chat":
text = data.get("message", {}).get("content", "")
else:
text = data.get("response", "")
return text, data
# ---------------------------------------------------------------------------
# Anthropic forwarding helpers
# ---------------------------------------------------------------------------
def _relay_headers(request: Request, keep: tuple[str, ...]) -> dict[str, str]:
"""Extract a safe subset of request headers to forward upstream."""
return {
k: v for k, v in request.headers.items()
if k.lower() in keep
}
ANTHROPIC_RELAY_HEADERS = (
"x-api-key",
"anthropic-version",
"anthropic-beta",
"content-type",
)
OPENAI_RELAY_HEADERS = (
"authorization",
"content-type",
"openai-organization",
"openai-project",
)
def _anthropic_to_sse(data: dict) -> bytes:
"""
Convert a non-streaming Anthropic Messages API response into SSE bytes
that litellm expects when it sent stream=True.
Emits the minimal event sequence litellm's streaming parser requires:
message_start → content_block_start → content_block_delta(s)
→ content_block_stop → message_delta → message_stop
"""
def sse(event: str, payload: dict) -> str:
return f"event: {event}\ndata: {json.dumps(payload)}\n\n"
lines: list[str] = []
# message_start
lines.append(sse("message_start", {
"type": "message_start",
"message": {
"id": data.get("id", "msg_festinger"),
"type": "message",
"role": "assistant",
"content": [],
"model": data.get("model", ""),
"stop_reason": None,
"stop_sequence": None,
"usage": data.get("usage", {"input_tokens": 0, "output_tokens": 0}),
},
}))
lines.append(sse("ping", {"type": "ping"}))
content_blocks = data.get("content", [])
for idx, block in enumerate(content_blocks):
btype = block.get("type", "text")
if btype == "text":
lines.append(sse("content_block_start", {
"type": "content_block_start",
"index": idx,
"content_block": {"type": "text", "text": ""},
}))
lines.append(sse("content_block_delta", {
"type": "content_block_delta",
"index": idx,
"delta": {"type": "text_delta", "text": block.get("text", "")},
}))
elif btype == "tool_use":
lines.append(sse("content_block_start", {
"type": "content_block_start",
"index": idx,
"content_block": {
"type": "tool_use",
"id": block.get("id", ""),
"name": block.get("name", ""),
"input": {},
},
}))
lines.append(sse("content_block_delta", {
"type": "content_block_delta",
"index": idx,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(block.get("input", {})),
},
}))
lines.append(sse("content_block_stop", {
"type": "content_block_stop",
"index": idx,
}))
# message_delta + message_stop
usage = data.get("usage", {})
lines.append(sse("message_delta", {
"type": "message_delta",
"delta": {
"stop_reason": data.get("stop_reason", "end_turn"),
"stop_sequence": data.get("stop_sequence"),
},
"usage": {"output_tokens": usage.get("output_tokens", 0)},
}))
lines.append(sse("message_stop", {"type": "message_stop"}))
return "".join(lines).encode()
async def call_anthropic(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
"""
Forward a request to the Anthropic Messages API (non-streaming).
Returns (assistant_text, raw_response_dict).
"""
body = dict(body)
body["stream"] = False
if "anthropic-version" not in {k.lower() for k in headers}:
headers = {**headers, "anthropic-version": "2023-06-01"}
model = body.get("model", "?")
url = f"{upstream}/v1/messages"
log.info("upstream_call provider=anthropic model=%s url=%s", model, url)
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(url, json=body, headers=headers)
except httpx.TimeoutException as exc:
log.error("upstream_timeout provider=anthropic model=%s url=%s after=%.0fs %s",
model, url, time.perf_counter() - t0, exc)
raise
except httpx.RequestError as exc:
log.error("upstream_connect_error provider=anthropic model=%s url=%s %s: %s",
model, url, type(exc).__name__, exc)
raise
ms = (time.perf_counter() - t0) * 1000
if not r.is_success:
log.error("upstream_error provider=anthropic model=%s url=%s status=%d %.0fms body=%.500s",
model, url, r.status_code, ms, r.text)
raise UpstreamError(r.status_code, r.content, r.headers.get("content-type", ""), "anthropic")
log.info("upstream_ok provider=anthropic model=%s status=%d %.0fms", model, r.status_code, ms)
data = r.json()
text = ""
for block in data.get("content", []):
if block.get("type") == "text":
text += block.get("text", "")
stop_reason = data.get("stop_reason", "unknown")
usage = data.get("usage", {})
in_tok = usage.get("input_tokens", "?")
out_tok = usage.get("output_tokens", "?")
preview = text[:120].replace("\n", " ") if text else "(empty)"
log.info(
"upstream_response provider=anthropic model=%s stop_reason=%s in_tokens=%s out_tokens=%s text=%.120s",
model, stop_reason, in_tok, out_tok, preview,
)
if stop_reason == "max_tokens":
# Output was cut off at the token limit. The truncated response is
# always identical, so Festinger's loop detector will fire immediately
# and Agent0 will retry forever. Convert to a 400 so litellm raises
# ContextWindowExceededError and the agent can handle it gracefully.
log.error(
"upstream_max_tokens provider=anthropic model=%s in_tokens=%s out_tokens=%s"
" — response truncated, converting to 400 to break loop",
model, in_tok, out_tok,
)
error_body = json.dumps({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": (
f"max_tokens reached: response was truncated after {out_tok} output tokens "
f"({in_tok} input tokens used). "
"Reduce the prompt length or raise the max_tokens limit."
),
},
}).encode()
raise UpstreamError(400, error_body, "application/json", "anthropic")
return text, data
async def call_openai(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
"""
Forward a request to an OpenAI-compatible chat completions endpoint (non-streaming).
Returns (assistant_text, raw_response_dict).
"""
body = dict(body)
body["stream"] = False
model = body.get("model", "?")
url = f"{upstream}/v1/chat/completions"
log.info("upstream_call provider=openai model=%s url=%s", model, url)
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(url, json=body, headers=headers)
except httpx.TimeoutException as exc:
log.error("upstream_timeout provider=openai model=%s url=%s after=%.0fs %s",
model, url, time.perf_counter() - t0, exc)
raise
except httpx.RequestError as exc:
log.error("upstream_connect_error provider=openai model=%s url=%s %s: %s",
model, url, type(exc).__name__, exc)
raise
ms = (time.perf_counter() - t0) * 1000
if not r.is_success:
log.error("upstream_error provider=openai model=%s url=%s status=%d %.0fms body=%.500s",
model, url, r.status_code, ms, r.text)
raise UpstreamError(r.status_code, r.content, r.headers.get("content-type", ""), "openai")
log.info("upstream_ok provider=openai model=%s status=%d %.0fms", model, r.status_code, ms)
data = r.json()
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
return text, data
# ---------------------------------------------------------------------------
# Text extraction helpers (unified across API formats)
# ---------------------------------------------------------------------------
def _extract_text_strings(content) -> list[str]:
"""
Normalise any Anthropic content shape into a list of plain strings.
Handles: bare string, list of content-block dicts, or anything unexpected.
"""
if isinstance(content, str):
return [content] if content else []
if isinstance(content, list):
out = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if isinstance(text, str) and text:
out.append(text)
return out
return []
# Tokens that look structural/technical — kept for potential future use.
_STRUCTURAL_RE = re.compile(
r"[/@]"
r"|\.\w"
r"|^\d+$"
r"|^\d[\d.]+\d$"
r"|^#[0-9a-f]{3,6}$" # hex colour
r"|^0x[0-9a-f]+$", # hex literal
re.IGNORECASE,
)
def extract_prompt_text(body: dict, path: str) -> str:
"""
Extract text from the most recent turns only (last user + last assistant message).
Scanning full conversation history on every request is noisy and redundant —
the system prompt in particular is large and stable, so its novel words were
already registered on the first request.
"""
if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
messages = body.get("messages", [])
last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
last_assistant = next(
(m for m in reversed(messages) if m.get("role") == "assistant"), None
)
parts: list[str] = []
for m in filter(None, [last_user, last_assistant]):
parts.extend(_extract_text_strings(m.get("content", "")))
return " ".join(parts)
return body.get("prompt", "")
def _last_user_message_text(body: dict, path: str) -> str:
"""
Extract only the last user message for the write path.
Agent responses, thinking traces, and system prompts are excluded —
they are noise for concept discovery.
"""
if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
messages = body.get("messages", [])
last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
if last_user:
return " ".join(_extract_text_strings(last_user.get("content", "")))
return ""
return body.get("prompt", "")
def _last_assistant_message_text(body: dict, path: str) -> str:
"""Extract the last assistant message for cue scanning."""
if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
messages = body.get("messages", [])
last_assistant = next(
(m for m in reversed(messages) if m.get("role") == "assistant"), None
)
if last_assistant:
return " ".join(_extract_text_strings(last_assistant.get("content", "")))
return ""
def _system_message_text(body: dict, path: str) -> str:
"""
Extract the system message text from the request body.
- OpenAI/Ollama: first message with role='system' in the messages list.
- Anthropic: top-level 'system' field (string or content-block list).
"""
if path == "/v1/messages":
sys_field = body.get("system") or ""
if isinstance(sys_field, list):
return " ".join(
b.get("text", "") for b in sys_field
if isinstance(b, dict) and b.get("type") == "text"
)
return sys_field
if path in ("/api/chat", "/v1/chat/completions", "/api/generate"):
messages = body.get("messages", [])
sys_msg = next((m for m in messages if m.get("role") == "system"), None)
if sys_msg:
return " ".join(_extract_text_strings(sys_msg.get("content", "")))
return ""
def inject_recollection_anthropic(body: dict, block: str) -> dict:
"""
Inject a recollection block into an Anthropic Messages API request.
Anthropic uses a top-level 'system' field — either a plain string or a list of
content-block dicts. Normalise to a plain string before prepending the block.
"""
body = dict(body)
existing = body.get("system") or ""
if isinstance(existing, list):
# Flatten content-block list to plain text
existing = " ".join(
b.get("text", "") for b in existing
if isinstance(b, dict) and b.get("type") == "text"
)
body["system"] = block + ("\n\n" + existing if existing else "")
return body
# ---------------------------------------------------------------------------
# Request model mirroring
# ---------------------------------------------------------------------------
def _extract_request_model_config(
path: str,
body: dict,
request_headers: dict,
cfg: dict,
) -> ModelConfig | None:
"""
Build a ModelConfig from the intercepted request so Festinger's utility
LLM calls (context discovery) can use the same provider/model as Agent0 —
no separate write_model_id configuration needed.
Provider inference:
/v1/messages → anthropic
/v1/chat/completions → lm-studio (OpenAI-compatible; base_url from upstream_openai)
/api/chat, /api/generate → lm-studio (Ollama's OpenAI-compat endpoint; base_url from upstream_ollama)
"""
model_name = body.get("model", "")
if not model_name:
return None
if path == "/v1/messages":
api_key = request_headers.get("x-api-key", "")
return ModelConfig(
provider="claude",
model_name=model_name,
api_key=api_key,
)
if path == "/v1/chat/completions":
auth = request_headers.get("authorization", "")
api_key = auth[len("Bearer "):].strip() if auth.lower().startswith("bearer ") else auth
base_url = cfg.get("upstream_openai", "")
return ModelConfig(
provider="lm-studio",
model_name=model_name,
api_key=api_key or "lm-studio",
base_url=base_url,
)
if path in ("/api/chat", "/api/generate"):
# Ollama exposes an OpenAI-compatible endpoint at the same base URL.
base_url = cfg.get("upstream_ollama", "")
return ModelConfig(
provider="lm-studio",
model_name=model_name,
api_key="ollama",
base_url=base_url,
)
return None
# ---------------------------------------------------------------------------
# Agent routing — cross-protocol dispatch
# ---------------------------------------------------------------------------
def _openai_to_anthropic_body(body: dict, model_name: str) -> dict:
"""
Translate an OpenAI chat completions request to Anthropic Messages API format.
- system messages are lifted to the top-level 'system' field
- max_tokens defaults to 4096 if not specified
- temperature/top_p forwarded if present
"""
system_parts: list[str] = []
claude_messages: list[dict] = []
for m in body.get("messages", []):
role = m.get("role", "")
content = m.get("content", "")
if role == "system":
if isinstance(content, str):
system_parts.append(content)
elif isinstance(content, list):
system_parts.extend(
b.get("text", "") for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
else:
claude_messages.append(m)
anthropic_body: dict = {
"model": model_name,
"messages": claude_messages,
"max_tokens": body.get("max_tokens") or 4096,
}
if system_parts:
anthropic_body["system"] = "\n\n".join(system_parts)
for key in ("temperature", "top_p", "stop_sequences"):
if key in body:
anthropic_body[key] = body[key]
return anthropic_body
def _anthropic_to_openai_response(data: dict) -> dict:
"""Convert an Anthropic Messages API response to OpenAI chat completions format."""
text = "".join(
b.get("text", "") for b in data.get("content", [])
if b.get("type") == "text"
)
usage = data.get("usage", {})
stop_map = {"end_turn": "stop", "max_tokens": "length", "stop_sequence": "stop"}
finish = stop_map.get(data.get("stop_reason", "end_turn"), "stop")
return {
"id": data.get("id", "chatcmpl-festinger"),
"object": "chat.completion",
"created": int(time.time()),
"model": data.get("model", ""),
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": text},
"finish_reason": finish,
}],
"usage": {
"prompt_tokens": usage.get("input_tokens", 0),
"completion_tokens": usage.get("output_tokens", 0),
"total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
},
}
def _openai_sse_from_response(raw: dict) -> bytes:
"""
Synthesise a minimal OpenAI-compatible SSE stream from a complete (non-streaming)
OpenAI-format response dict. Used when the client sent stream=true but the
upstream was called non-streaming.
"""
text = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
model = raw.get("model", "")
cid = raw.get("id", "chatcmpl-festinger")
ts = int(time.time())
def chunk(delta: dict, finish_reason=None) -> str:
return "data: " + json.dumps({
"id": cid, "object": "chat.completion.chunk",
"created": ts, "model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}) + "\n\n"
parts = [
chunk({"role": "assistant", "content": ""}),
chunk({"content": text}),
chunk({}, finish_reason="stop"),
"data: [DONE]\n\n",
]
return "".join(parts).encode()
# ---------------------------------------------------------------------------
# Per-upstream concurrency control for local models
# ---------------------------------------------------------------------------
# Local inference servers (LM Studio, Ollama) typically run a single model
# on one GPU and queue or crash under concurrent requests. We serialize all
# agent-routed calls that share the same base URL through a semaphore.
# Cloud providers (claude, openai) are excluded — they handle concurrency fine.
_local_upstream_semaphores: dict[str, asyncio.Semaphore] = {}
def _get_upstream_semaphore(base_url: str) -> asyncio.Semaphore:
"""Return (creating if needed) a semaphore that limits concurrency to 1 for this upstream."""
key = base_url.rstrip("/").lower()
if key not in _local_upstream_semaphores:
_local_upstream_semaphores[key] = asyncio.Semaphore(1)
return _local_upstream_semaphores[key]
async def _get_agent_routing_model(pool, agent_name: str) -> ModelConfig | None:
"""
Look up the agent's configured model from the agent_models table.
agent_name is the normalised key (lowercase name or numeric ID string).
"""
if not agent_name:
return None
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT m.provider, m.model_name, m.api_key, m.base_url
FROM agent_models am
JOIN models m ON m.id = am.model_id
WHERE am.agent_name = $1
""",
agent_name,
)
if not row:
return None
return ModelConfig(
provider=row["provider"],
model_name=row["model_name"],
api_key=row["api_key"],
base_url=row["base_url"] or "",
)
async def _route_agent_chat(
body: dict,
agent_model: ModelConfig,
original_stream: bool,
pool,
cfg: dict,
request_headers: dict,
min_len: int,
agent_name: str = "",
) -> Response:
"""
Route an OpenAI-compatible chat completions request to the agent's
configured provider, handling cross-protocol translation.
claude → Anthropic Messages API (translated in both directions)
openai → OpenAI-compatible endpoint (base_url + model swap)
lm-studio→ same as openai, serialized through a per-upstream semaphore
"""
is_local = agent_model.provider in ("lm-studio", "ollama")
# Local single-GPU models can't handle concurrent requests.
# Skip context discovery so we don't fire a second LM Studio call
# at the same time as the main inference.
# (Configure write_model_id or a per-agent model for a cloud/separate
# model if you want memory building alongside local inference.)
body = await process_prompt(
body, "/v1/chat/completions", pool, cfg, request_headers, agent_name=agent_name,
)
sess = session_key(agent_model.model_name, body.get("messages", []))
# Capture upstream config for use in the loop-detection re-run closure
if agent_model.provider == "claude":
anthropic_upstream = agent_model.base_url or "https://api.anthropic.com"
anthropic_headers = {
"x-api-key": agent_model.api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
else:
# call_openai() appends /v1/chat/completions to whatever upstream it gets.
# DB base_url often ends in /v1 (correct for the OpenAI SDK used by the
# resolution job), so strip that suffix to avoid /v1/v1/chat/completions.
raw_base = agent_model.base_url or cfg.get("upstream_openai", "https://api.openai.com")
oai_upstream = raw_base.rstrip("/")
if oai_upstream.endswith("/v1"):
oai_upstream = oai_upstream[:-3]
oai_headers = {
"authorization": f"Bearer {agent_model.api_key or 'lm-studio'}",
"content-type": "application/json",
}
# One request at a time to local inference servers.
_local_sem = _get_upstream_semaphore(oai_upstream) if is_local else None
async def _call(current_body: dict) -> tuple[str, dict]:
"""Call the agent's upstream; always return (text, openai_format_dict)."""
if agent_model.provider == "claude":
ab = _openai_to_anthropic_body(current_body, agent_model.model_name)
text, raw_a = await call_anthropic(ab, anthropic_upstream, anthropic_headers)
return text, _anthropic_to_openai_response(raw_a)
else:
b = dict(current_body)
b["model"] = agent_model.model_name
if _local_sem is not None:
async with _local_sem:
text, raw_o = await call_openai(b, oai_upstream, oai_headers)
else:
text, raw_o = await call_openai(b, oai_upstream, oai_headers)
return text, raw_o
text, raw = await _call(body)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning(
"loop_detected (agent routed) agent_model=%s session=%s count=%d",
agent_model.model_name, sess[1], count,
)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["choices"] = [{
"index": 0,
"message": {"role": "assistant", "content": override},
"finish_reason": "stop",
}]
raw["loop_detected"] = True
else:
text, raw = await _call(body)
record_and_check(sess, text, min_len)
if original_stream:
return Response(content=_openai_sse_from_response(raw), media_type="text/event-stream")
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# Saliency + recollection pipeline
# ---------------------------------------------------------------------------
def _extract_agent_name(body: dict, headers: dict) -> tuple[str, dict]:
"""
Extract agent identity from the request body (LiteLLM extra params) or headers.
Priority: body agent_name > body agent_id > X-Agent-Name header > X-Agent-Id header.
Also returns a cleaned copy of the body with agent_id/agent_name stripped,
so unknown parameters are never forwarded to the upstream LLM.
"""
# Pull from body first — LiteLLM passes extra params as top-level JSON fields
agent_name = str(body.get("agent_name", "")).strip().lower()
agent_id = str(body.get("agent_id", "")).strip()
# Fall back to headers (X-Agent-Name / X-Agent-Id)
if not agent_name:
agent_name = headers.get("x-agent-name", "").strip().lower()
if not agent_id and not agent_name:
agent_id = headers.get("x-agent-id", "").strip()
identity = agent_name or agent_id # name preferred; id as string fallback
# Strip festinger-specific params so the upstream never sees them
clean_body = {k: v for k, v in body.items() if k not in ("agent_id", "agent_name")}
return identity, clean_body
async def process_prompt(
body: dict,
path: str,
pool,
cfg: dict,
request_headers: dict | None = None,
agent_name: str = "",
) -> dict:
"""
Run the saliency + recollection pipeline over the prompt.
Returns a (possibly modified) body dict with the recollection block injected.
agent_name may be passed in directly (e.g. extracted from the URL path) to
avoid re-parsing the body/headers; falls back to _extract_agent_name if empty.
"""
read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
hdrs = request_headers or {}
if not agent_name:
agent_name, _ = _extract_agent_name(body, hdrs) # body already cleaned by route handler
# Last user message — primary source for recollection reads.
user_text = _last_user_message_text(body, path)
if not user_text.strip():
return body
# 1. Scan user message for explicit relationship cues (fast, regex-only).
for cue in scan_cues(user_text):
await enqueue_cue(cue)
# 2. Also scan the last assistant message for is-a / is-part-of assertions.
# Agents frequently state facts in their responses ("X is a Y", "X runs on Z").
# No token-loop needed here — just cue extraction.
assistant_text = _last_assistant_message_text(body, path)
if assistant_text:
for cue in scan_cues(assistant_text):
await enqueue_cue(cue)
# 3. Session boost — scan the system message for domain tokens.
# Concepts grounding the agent's persona or project context rank higher
# in the recollection block for the entire session.
session_boost_ids: set[int] = set()
sys_text = _system_message_text(body, path)
if sys_text:
for tok in tokenize(sys_text):
row = cache.soas_by_token.get(tok)
if row and row.saliency > 0.0:
session_boost_ids.add(row.id)
if session_boost_ids:
boost_tokens = [cache.soas_by_id.get(i, str(i)) for i in session_boost_ids]
log.debug("session_boost | agent=%s tokens=%s", agent_name or "(none)", boost_tokens)
# 4. Token loop over user message for saliency-triggered recollection.
tokens = tokenize(user_text)
salient_for_read: list[int] = []
for token in tokens:
soas_row = cache.soas_by_token.get(token)
if soas_row is None:
continue
if soas_row.saliency == 0.0:
continue # saliency=0 is the English-word sentinel — skip
cache.record_encounter(soas_row.id)
if soas_row.saliency >= read_threshold:
salient_for_read.append(soas_row.id)
if not salient_for_read:
return body
# 5. Conflict spike — warn loudly if any salient concept has a pending contradiction.
conflicted = [
cache.soas_by_id.get(cid, str(cid))
for cid in salient_for_read
if cid in cache.pending_conflicts
]
if conflicted:
log.warning(
"recollection_conflict_spike | agent=%s conflicted=%s "
"(recollection block will show '?' suffix on affected dimensions)",
agent_name or "(none)", conflicted,
)
# 6. Build recollection block
block = build_recollection_block(
salient_for_read, conf_floor, recency_days,
session_boost_ids=session_boost_ids,
)
if not block:
return body
salient_tokens = [cache.soas_by_id.get(cid, str(cid)) for cid in salient_for_read]
log.info(
"recollection fired | agent=%s salient=%s\n%s",
agent_name or "(none)", salient_tokens, block,
)
# 7. Inject into messages
if path == "/api/chat" or path == "/v1/chat/completions":
body = dict(body)
body["messages"] = inject_recollection(body.get("messages", []), block)
log_messages = body["messages"]
elif path == "/v1/messages":
body = inject_recollection_anthropic(body, block)
# Reconstruct a messages-style list for the log viewer
sys_content = body.get("system", "")
log_messages = ([{"role": "system", "content": sys_content}] if sys_content else []) + body.get("messages", [])
elif path == "/api/generate":
body = dict(body)
body["prompt"] = block + "\n\n" + body.get("prompt", "")
log_messages = [{"role": "user", "content": body["prompt"]}]
else:
log_messages = []
asyncio.create_task(log_recollection(pool, agent_name or "", salient_tokens, block, log_messages))
return body
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
async def _handle_ollama_chat(request: Request, agent_name: str = "") -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
raw_body = await request.json()
if agent_name:
_, body = _extract_agent_name(raw_body, dict(request.headers))
else:
agent_name, body = _extract_agent_name(raw_body, dict(request.headers))
model = body.get("model", "unknown")
upstream = cfg["upstream_ollama"]
min_len = cfg["detection"]["min_length"]
log.info("chat route=/api/chat model=%s agent=%s", model, agent_name or "")
try:
body = await process_prompt(body, "/api/chat", pool, cfg, dict(request.headers), agent_name=agent_name)
text, raw = await call_ollama("/api/chat", body, upstream)
sess = session_key(model, body.get("messages", []))
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop_detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["message"] = {"role": "assistant", "content": override}
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/chat", body, upstream)
record_and_check(sess, text, min_len)
raw["message"] = {"role": "assistant", "content": text}
return Response(content=json.dumps(raw), media_type="application/json")
except UpstreamError as exc:
log.error("chat_upstream_error route=/api/chat model=%s %s", model, exc)
return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
except Exception as exc:
log.exception("chat_error route=/api/chat model=%s %s: %s", model, type(exc).__name__, exc)
raise
async def _handle_ollama_generate(request: Request, agent_name: str = "") -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
raw_body = await request.json()
if agent_name:
_, body = _extract_agent_name(raw_body, dict(request.headers))
else:
agent_name, body = _extract_agent_name(raw_body, dict(request.headers))
model = body.get("model", "unknown")
upstream = cfg["upstream_ollama"]
min_len = cfg["detection"]["min_length"]
log.info("chat route=/api/generate model=%s agent=%s", model, agent_name or "")
try:
body = await process_prompt(body, "/api/generate", pool, cfg, dict(request.headers), agent_name=agent_name)
messages = [{"role": "user", "content": body.get("prompt", "")}]
sess = session_key(model, messages)
text, raw = await call_ollama("/api/generate", body, upstream)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop_detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["response"] = override
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/generate", body, upstream)
record_and_check(sess, text, min_len)
raw["response"] = text
return Response(content=json.dumps(raw), media_type="application/json")
except UpstreamError as exc:
log.error("chat_upstream_error route=/api/generate model=%s %s", model, exc)
return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
except Exception as exc:
log.exception("chat_error route=/api/generate model=%s %s: %s", model, type(exc).__name__, exc)
raise
@app.post("/api/chat")
async def chat(request: Request) -> Response:
return await _handle_ollama_chat(request)
@app.post("/api/generate")
async def generate(request: Request) -> Response:
return await _handle_ollama_generate(request)
# ---------------------------------------------------------------------------
# Anthropic Messages API (POST /v1/messages)
# ---------------------------------------------------------------------------
async def _handle_anthropic_messages(request: Request, agent_name: str = "") -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
raw_body = await request.body()
parsed = json.loads(raw_body)
if agent_name:
_, body = _extract_agent_name(parsed, dict(request.headers))
else:
agent_name, body = _extract_agent_name(parsed, dict(request.headers))
# Capture streaming intent BEFORE call_anthropic forces stream=False
original_stream: bool = bool(body.get("stream", False))
model = body.get("model", "unknown")
upstream = cfg["upstream_anthropic"]
min_len = cfg["detection"]["min_length"]
log.info(
"chat route=/v1/messages model=%s upstream=%s agent=%s stream=%s req_preview=%.200s",
model, upstream, agent_name or "", original_stream,
raw_body[:200].decode(errors="replace").replace("\n", " "),
)
try:
headers = _relay_headers(request, ANTHROPIC_RELAY_HEADERS)
if "anthropic-version" not in {k.lower() for k in headers}:
headers["anthropic-version"] = "2023-06-01"
body = await process_prompt(body, "/v1/messages", pool, cfg, headers, agent_name=agent_name)
messages = body.get("messages", [])
sess = session_key(model, messages)
text, raw = await call_anthropic(body, upstream, headers)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop_detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["content"] = [{"type": "text", "text": override}]
raw["loop_detected"] = True
if original_stream:
return Response(content=_anthropic_to_sse(raw), media_type="text/event-stream")
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_anthropic(body, upstream, headers)
record_and_check(sess, text, min_len)
if original_stream:
sse_bytes = _anthropic_to_sse(raw)
log.info(
"streaming_response provider=anthropic model=%s converting json→sse bytes=%d preview=%.300s",
model, len(sse_bytes), sse_bytes[:300].decode(errors="replace"),
)
return Response(content=sse_bytes, media_type="text/event-stream")
resp_body = json.dumps(raw)
log.info(
"json_response provider=anthropic model=%s bytes=%d preview=%.300s",
model, len(resp_body), resp_body[:300],
)
return Response(content=resp_body, media_type="application/json")
except UpstreamError as exc:
log.error("chat_upstream_error route=/v1/messages model=%s %s", model, exc)
return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
except Exception as exc:
log.exception("chat_error route=/v1/messages model=%s %s: %s", model, type(exc).__name__, exc)
raise
@app.post("/v1/messages")
async def anthropic_messages(request: Request) -> Response:
return await _handle_anthropic_messages(request)
# ---------------------------------------------------------------------------
# OpenAI-compatible chat completions (POST /v1/chat/completions)
# ---------------------------------------------------------------------------
async def _handle_openai_chat(request: Request, agent_name: str = "") -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
raw_body = await request.json()
hdrs = dict(request.headers)
if agent_name:
_, body = _extract_agent_name(raw_body, hdrs)
else:
agent_name, body = _extract_agent_name(raw_body, hdrs) # strips agent_id/agent_name
model = body.get("model", "unknown")
upstream = cfg["upstream_openai"]
min_len = cfg["detection"]["min_length"]
original_stream: bool = bool(body.get("stream", False))
log.info("chat route=/v1/chat/completions model=%s upstream=%s agent=%s stream=%s",
model, upstream, agent_name or "", original_stream)
try:
# Agent routing: if agent has a registered model, dispatch cross-protocol
if agent_name:
agent_model = await _get_agent_routing_model(pool, agent_name)
if agent_model:
log.info(
"agent_route agent=%s provider=%s model=%s base_url=%s",
agent_name, agent_model.provider, agent_model.model_name,
agent_model.base_url or "(default)",
)
return await _route_agent_chat(
body, agent_model, original_stream, pool, cfg, hdrs, min_len, agent_name=agent_name,
)
# Standard path — forward to configured upstream unchanged
headers = _relay_headers(request, OPENAI_RELAY_HEADERS)
body = await process_prompt(body, "/v1/chat/completions", pool, cfg, hdrs, agent_name=agent_name)
messages = body.get("messages", [])
sess = session_key(model, messages)
text, raw = await call_openai(body, upstream, headers)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop_detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
if raw.get("choices"):
raw["choices"][0]["message"]["content"] = override
raw["loop_detected"] = True
if original_stream:
return Response(content=_openai_sse_from_response(raw), media_type="text/event-stream")
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_openai(body, upstream, headers)
record_and_check(sess, text, min_len)
if original_stream:
return Response(content=_openai_sse_from_response(raw), media_type="text/event-stream")
return Response(content=json.dumps(raw), media_type="application/json")
except UpstreamError as exc:
log.error("chat_upstream_error route=/v1/chat/completions model=%s %s", model, exc)
return Response(content=exc.content, status_code=exc.status_code, media_type=exc.content_type)
except Exception as exc:
log.exception("chat_error route=/v1/chat/completions model=%s %s: %s", model, type(exc).__name__, exc)
raise
@app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request) -> Response:
return await _handle_openai_chat(request)
# Alias: some LiteLLM provider types (custom_openai, openai_like) omit the
# /v1 prefix and post directly to /chat/completions.
@app.post("/chat/completions")
async def openai_chat_completions_no_prefix(request: Request) -> Response:
"""Alias for /v1/chat/completions — handles LiteLLM providers that omit /v1."""
return await _handle_openai_chat(request)
# ---------------------------------------------------------------------------
# Agent-prefixed routes /{agent_id}/...
#
# Configure each agent instance's base_url to include its name:
# http://festinger:11434/gerhard/v1 ← hermes (OpenAI-compat)
# http://festinger:11434/dobby/v1/messages ← Agent0 (Anthropic)
# http://festinger:11434/gunnar ← Ollama clients
#
# The agent_id is extracted from the URL path and takes priority over any
# agent_name/agent_id passed in the request body or headers.
# ---------------------------------------------------------------------------
@app.post("/{agent_id}/v1/chat/completions")
async def openai_chat_with_agent_id(agent_id: str, request: Request) -> Response:
return await _handle_openai_chat(request, agent_name=agent_id.lower())
@app.post("/{agent_id}/chat/completions")
async def openai_chat_with_agent_id_no_v1(agent_id: str, request: Request) -> Response:
return await _handle_openai_chat(request, agent_name=agent_id.lower())
@app.post("/{agent_id}/v1/messages")
async def anthropic_messages_with_agent_id(agent_id: str, request: Request) -> Response:
return await _handle_anthropic_messages(request, agent_name=agent_id.lower())
@app.post("/{agent_id}/api/chat")
async def ollama_chat_with_agent_id(agent_id: str, request: Request) -> Response:
return await _handle_ollama_chat(request, agent_name=agent_id.lower())
@app.post("/{agent_id}/api/generate")
async def ollama_generate_with_agent_id(agent_id: str, request: Request) -> Response:
return await _handle_ollama_generate(request, agent_name=agent_id.lower())
# ---------------------------------------------------------------------------
# /iknowthat — manual write path
# ---------------------------------------------------------------------------
@app.post("/iknowthat")
async def iknowthat(request: Request) -> dict:
"""
Parse and insert a manual fact.
Body: {"fact": "gnommoweb -isa repo in context of glitch_university"}
"""
pool = request.app.state.pool
data = await request.json()
fact = data.get("fact", "").strip()
if not fact:
return {"error": "fact is required"}
# Parse: "{subject} -isa|-ispart {parent} [in context of {dimension}]"
import re
m = re.match(
r"^(?P<subj>\S+)\s+(?P<rel>-isa|-ispart)\s+(?P<parent>\S+)"
r"(?:\s+in\s+context\s+of\s+(?P<dim>\S+))?$",
fact, re.IGNORECASE,
)
if not m:
return {"error": f"could not parse fact: {fact!r}. "
"Expected: '<subject> -isa|-ispart <parent> [in context of <dimension>]'"}
subj = m.group("subj").lower()
is_isa = m.group("rel").lower() == "-isa"
parent = m.group("parent").lower()
dim = (m.group("dim") or ("type" if is_isa else "membership")).lower()
subj_row = await get_or_create_soas(pool, subj)
parent_row = await get_or_create_soas(pool, parent)
dim_row = await get_or_create_soas(pool, dim)
req = InsertRequest(
concept_id=subj_row.id,
parent_id=parent_row.id,
dim_id=dim_row.id,
is_isa=is_isa,
confidence=1.0,
source="gutask",
)
collision = await insert_urd_edge(pool, req, priority=True)
if collision:
return {
"status": "collision",
"collision_type": collision.collision_type,
"fact": fact,
"message": "Conflict queued for nightly resolution (priority). "
"Current world model unchanged.",
}
return {
"status": "inserted",
"fact": fact,
"subject": subj,
"parent": parent,
"dimension": dim,
"is_isa": is_isa,
}
# ---------------------------------------------------------------------------
# /concepts — browse and search domain concepts
# ---------------------------------------------------------------------------
@app.get("/concepts")
async def list_concepts(
request: Request,
q: str = "",
limit: int = 50,
offset: int = 0,
) -> dict:
"""
Return domain concepts (saliency > 0 or novelty > 0) with their URD edges.
Optionally filter by token prefix/substring via ?q=.
"""
pool = request.app.state.pool
async with pool.acquire() as conn:
if q:
rows = await conn.fetch(
"""
SELECT id, token, saliency, novelty, encounter_count, first_seen_context
FROM soas
WHERE (saliency > 0 OR novelty > 0) AND token ILIKE $1
ORDER BY saliency DESC, encounter_count DESC
LIMIT $2 OFFSET $3
""",
f"%{q}%", limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM soas WHERE (saliency > 0 OR novelty > 0) AND token ILIKE $1",
f"%{q}%",
)
else:
rows = await conn.fetch(
"""
SELECT id, token, saliency, novelty, encounter_count, first_seen_context
FROM soas
WHERE saliency > 0 OR novelty > 0
ORDER BY saliency DESC, encounter_count DESC
LIMIT $1 OFFSET $2
""",
limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM soas WHERE saliency > 0 OR novelty > 0"
)
result = []
for r in rows:
cid = r["id"]
edges = cache.urd_by_concept.get(cid, [])
result.append({
"id": cid,
"token": r["token"],
"saliency": round(r["saliency"], 3),
"novelty": round(r["novelty"], 3),
"encounter_count": r["encounter_count"],
"first_seen_context": r["first_seen_context"] or "",
"edges": [
{
"dim": e.dim_token,
"parent": e.parent_token,
"is_isa": e.is_isa,
"confidence": round(e.confidence, 3),
"source": e.source,
}
for e in edges
],
})
return {"total": total, "concepts": result}
@app.delete("/concepts/{token:path}")
async def delete_concept(token: str, request: Request) -> dict:
"""Remove a concept and all its URD edges from SOAS."""
pool = request.app.state.pool
token = token.lower().strip()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("DELETE FROM kg_write_log WHERE concept_token = $1", token)
soas_id = await conn.fetchval("SELECT id FROM soas WHERE token = $1", token)
if soas_id is None:
return {"error": f"concept {token!r} not found"}
await conn.execute("DELETE FROM resolution_queue WHERE concept_id = $1", soas_id)
await conn.execute("DELETE FROM urd WHERE id = $1", soas_id)
await conn.execute("DELETE FROM soas WHERE id = $1", soas_id)
# Evict from cache
row = cache.soas_by_token.pop(token, None)
if row:
cache.soas_by_id.pop(row.id, None)
edges = cache.urd_by_concept.pop(row.id, [])
for e in edges:
cache.urd_by_concept_dim.pop((row.id, e.dim_id), None)
cache.urd_by_parent.pop(row.id, None)
cache.pending_conflicts.discard(row.id)
return {"status": "deleted", "token": token}
# ---------------------------------------------------------------------------
# /models — LLM model management
# ---------------------------------------------------------------------------
@app.get("/models")
async def list_models(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, provider, model_name, base_url, created_at FROM models ORDER BY id"
)
return {"models": [
{"id": r["id"], "provider": r["provider"], "model_name": r["model_name"],
"base_url": r["base_url"] or "", "created_at": r["created_at"].isoformat()}
for r in rows
]}
@app.post("/models")
async def create_model(request: Request) -> dict:
pool = request.app.state.pool
data = await request.json()
provider = data.get("provider", "").strip()
model_name = data.get("model_name", "").strip()
api_key = data.get("api_key", "").strip()
base_url = data.get("base_url", "").strip()
if not provider or not model_name:
return {"error": "provider and model_name are required"}
if provider not in ("claude", "openai", "lm-studio"):
return {"error": "provider must be 'claude', 'openai', or 'lm-studio'"}
if provider == "claude" and not api_key:
return {"error": "api_key is required for claude provider"}
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO models (provider, model_name, api_key, base_url) VALUES ($1,$2,$3,$4) RETURNING id",
provider, model_name, api_key, base_url,
)
log.info("model created id=%d provider=%s model=%s base_url=%s", row["id"], provider, model_name, base_url)
return {"status": "ok", "id": row["id"]}
@app.delete("/models/{model_id}")
async def delete_model(model_id: int, request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM models WHERE id=$1", model_id)
deleted = int(result.split()[-1]) if result else 0
if not deleted:
return {"error": f"model {model_id} not found"}
log.info("model deleted id=%d", model_id)
return {"status": "ok", "deleted": model_id}
# ---------------------------------------------------------------------------
# /agent-models — per-agent model assignments
# ---------------------------------------------------------------------------
@app.get("/agent-models")
async def list_agent_models(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT am.agent_name, am.model_id, am.created_at,
m.provider, m.model_name, m.base_url
FROM agent_models am
JOIN models m ON m.id = am.model_id
ORDER BY am.agent_name
"""
)
return {"agent_models": [
{
"agent_name": r["agent_name"],
"model_id": r["model_id"],
"provider": r["provider"],
"model_name": r["model_name"],
"base_url": r["base_url"] or "",
"created_at": r["created_at"].isoformat(),
}
for r in rows
]}
@app.put("/agent-models/{agent_name}")
async def set_agent_model(agent_name: str, request: Request) -> dict:
"""
Assign a model to an agent. agent_name is normalised to lowercase.
Body: {"model_id": 3}
"""
pool = request.app.state.pool
data = await request.json()
model_id = data.get("model_id")
if not model_id:
return {"error": "model_id is required"}
name = agent_name.strip().lower()
if not name:
return {"error": "agent_name must not be empty"}
async with pool.acquire() as conn:
# Verify the model exists
row = await conn.fetchrow("SELECT id, provider, model_name FROM models WHERE id=$1", int(model_id))
if not row:
return {"error": f"model {model_id} not found"}
await conn.execute(
"""
INSERT INTO agent_models (agent_name, model_id)
VALUES ($1, $2)
ON CONFLICT (agent_name) DO UPDATE SET model_id = EXCLUDED.model_id, created_at = now()
""",
name, int(model_id),
)
log.info("agent_model set agent=%s model_id=%s provider=%s model=%s",
name, model_id, row["provider"], row["model_name"])
return {"status": "ok", "agent_name": name, "model_id": int(model_id)}
@app.delete("/agent-models/{agent_name}")
async def delete_agent_model(agent_name: str, request: Request) -> dict:
pool = request.app.state.pool
name = agent_name.strip().lower()
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM agent_models WHERE agent_name=$1", name)
deleted = int(result.split()[-1]) if result else 0
if not deleted:
return {"error": f"no assignment found for agent '{name}'"}
log.info("agent_model deleted agent=%s", name)
return {"status": "ok", "deleted": name}
@app.get("/models/discover")
async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict:
"""
Proxy a GET to {base_url}/v1/models to discover models available in LM Studio
(or any OpenAI-compatible server). Avoids browser CORS restrictions.
"""
url = base_url.rstrip("/") + "/v1/models"
log.info("discover_models url=%s", url)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url)
if not r.is_success:
return {"error": f"server returned {r.status_code}", "models": []}
data = r.json()
model_ids = [m.get("id", m) for m in data.get("data", [])]
return {"models": model_ids, "raw": data}
except httpx.ConnectError:
return {"error": f"could not connect to {url} — is LM Studio running?", "models": []}
except Exception as exc:
return {"error": str(exc), "models": []}
@app.get("/config")
async def get_all_config(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT key, value, updated_at FROM config ORDER BY key")
return {"config": {r["key"]: r["value"] for r in rows}}
@app.post("/config")
async def update_config(request: Request) -> dict:
pool = request.app.state.pool
data = await request.json()
key = data.get("key", "").strip()
value = str(data.get("value", "")).strip()
if not key:
return {"error": "key is required"}
async with pool.acquire() as conn:
await conn.execute(
"UPDATE config SET value=$1, updated_at=now() WHERE key=$2",
value, key,
)
log.info("config updated key=%s value=%s", key, value)
return {"status": "ok", "key": key, "value": value}
# ---------------------------------------------------------------------------
# /resolve/run — manually trigger resolution job
# ---------------------------------------------------------------------------
@app.post("/resolve/run")
async def resolve_run(request: Request) -> dict:
pool = request.app.state.pool
result = await run_resolution_job(pool)
return result
# ---------------------------------------------------------------------------
# /wordnet/import — bulk-load WordNet lemmas into SOAS
# ---------------------------------------------------------------------------
@app.post("/wordnet/import")
async def wordnet_import(request: Request) -> dict:
"""
Import Princeton WordNet 3.x lemmas into SOAS (saliency=0, novelty=0).
Idempotent — already-present tokens are skipped.
Can take 515 seconds for a full import (~130k lemmas).
"""
pool = request.app.state.pool
result = await import_wordnet(pool)
return result
# ---------------------------------------------------------------------------
# /reload — reload URD cache (used after resolution job)
# ---------------------------------------------------------------------------
@app.post("/reload")
async def reload(request: Request) -> dict:
pool = request.app.state.pool
await reload_urd_cache(pool)
return {"status": "ok", "urd_edges": len(cache.urd_by_concept_dim)}
@app.post("/reset")
async def reset(request: Request) -> dict:
"""
Wipe all learned knowledge (URD, domain SOAS, resolution queue, write log).
Keeps the standard-English dictionary seed intact.
Re-bootstraps dimension roots so the graph is ready for new learning.
"""
pool = request.app.state.pool
counts = await reset_graph(pool)
# Re-bootstrap dimension self-referential roots
await bootstrap_dimensions(pool)
# Re-warm the URD cache (should now be empty except roots)
await reload_urd_cache(pool)
log.info("graph reset complete")
return {"status": "ok", **counts,
"soas_remaining": len(cache.soas_by_token),
"urd_edges": len(cache.urd_by_concept_dim)}
# ---------------------------------------------------------------------------
# /health
# ---------------------------------------------------------------------------
@app.get("/health")
async def health(request: Request) -> dict:
cfg = request.app.state.yaml_config
return {
"status": "ok",
"upstream": cfg["upstream_ollama"],
"active_loop_sessions": 0, # loop detector is stateful in-process
"soas_tokens": len(cache.soas_by_token),
"urd_edges": len(cache.urd_by_concept_dim),
"pending_conflicts": len(cache.pending_conflicts),
"last_resolution_run": last_run_timestamp(),
"timestamp": int(time.time()),
}
# ---------------------------------------------------------------------------
# /conflicts — expose resolution queue
# ---------------------------------------------------------------------------
@app.get("/conflicts")
async def conflicts(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, concept_id, existing_parent_id, incoming_parent_id,
dim_id, collision_type, status, resolution, priority,
created_at, resolved_at
FROM resolution_queue
ORDER BY status ASC, created_at DESC
LIMIT 100
"""
)
def format_row(r):
return {
"id": r["id"],
"concept": cache.soas_by_id.get(r["concept_id"], str(r["concept_id"])),
"existing_parent": cache.soas_by_id.get(r["existing_parent_id"], "?"),
"incoming_parent": cache.soas_by_id.get(r["incoming_parent_id"], "?"),
"dimension": cache.soas_by_id.get(r["dim_id"], "?"),
"collision_type": r["collision_type"],
"status": r["status"],
"resolution": r["resolution"],
"priority": r["priority"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
}
return {"conflicts": [format_row(r) for r in rows]}
@app.post("/conflicts/clear")
async def clear_conflicts(request: Request) -> dict:
"""
Delete all pending conflicts from the resolution queue and clear the
in-memory pending set. Resolved/error rows are left untouched.
"""
pool = request.app.state.pool
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM resolution_queue WHERE status = 'pending'"
)
# result is a string like "DELETE 17"
deleted = int(result.split()[-1]) if result else 0
cache.pending_conflicts.clear()
log.info("conflicts cleared deleted=%d", deleted)
return {"status": "ok", "deleted": deleted}
@app.get("/kg-log")
async def kg_log(request: Request, limit: int = 100, offset: int = 0, op: str = "") -> dict:
"""Return recent knowledge graph write log entries, newest first."""
pool = request.app.state.pool
query = """
SELECT id, op, concept_token, parent_token, prev_parent_token,
dim_token, is_isa, confidence, source, resolution_queue_id, created_at
FROM kg_write_log
{where}
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"""
count_query = "SELECT COUNT(*) FROM kg_write_log {where}"
if op:
async with pool.acquire() as conn:
rows = await conn.fetch(
query.format(where="WHERE op = $3"),
limit, offset, op,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM kg_write_log WHERE op = $1",
op,
)
else:
async with pool.acquire() as conn:
rows = await conn.fetch(query.format(where=""), limit, offset)
total = await conn.fetchval("SELECT COUNT(*) FROM kg_write_log")
def fmt(r):
return {
"id": r["id"],
"op": r["op"],
"concept": r["concept_token"],
"parent": r["parent_token"],
"prev_parent": r["prev_parent_token"],
"dimension": r["dim_token"],
"is_isa": r["is_isa"],
"confidence": round(r["confidence"], 3),
"source": r["source"],
"resolution_queue_id": r["resolution_queue_id"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
return {"total": total, "offset": offset, "limit": limit, "entries": [fmt(r) for r in rows]}
# ---------------------------------------------------------------------------
# /test — scenario seeding and reset (for integration testing)
# ---------------------------------------------------------------------------
@app.get("/test/scenarios")
async def list_scenarios() -> dict:
"""List all available test scenarios."""
return {
"scenarios": {
sid: {
"name": sc.name,
"trigger_fact": sc.trigger_fact,
"expected_collision": sc.expected_collision,
"expected_resolution": sc.expected_resolution,
}
for sid, sc in SCENARIOS.items()
}
}
@app.post("/test/seed/{scenario_id}")
async def test_seed(scenario_id: str, request: Request) -> dict:
"""
Seed the database with pre-defined world-model state for a named scenario.
After seeding, POST the trigger fact to /iknowthat to cause the collision.
Available scenarios: A (misclassification), B (ISA+ISA decompose), C (ISPART+ISPART)
"""
pool = request.app.state.pool
return await seed_scenario(pool, scenario_id)
@app.post("/test/reset/{scenario_id}")
async def test_reset(scenario_id: str, request: Request) -> dict:
"""
Remove URD edges, SOAS saliency boosts, and resolution queue rows
introduced by a scenario seed. Use before re-running a scenario.
"""
pool = request.app.state.pool
return await reset_scenario(pool, scenario_id)
# ---------------------------------------------------------------------------
# /recollection-log — browse prompts where recollection was injected
# ---------------------------------------------------------------------------
RECOLLECTION_LOG_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Recollection Log</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #0d0d1a; color: #ccc; padding: 20px; }
h1 { color: #fff; font-size: 1.2em; margin-bottom: 6px; }
.subtitle { font-size: 0.75em; color: #555; margin-bottom: 20px; }
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 18px; }
.btn { padding: 6px 14px; border: none; border-radius: 3px; cursor: pointer;
font-family: monospace; font-size: 0.82em; }
.btn-danger { background: #6b1a1a; color: #ffaaaa; }
.btn-danger:hover { background: #8b2a2a; }
.btn-secondary { background: #1e1e40; color: #aaa; }
.btn-secondary:hover { background: #2a2a55; }
.filter-row { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.filter-row input { background: #1a1a2e; border: 1px solid #2a2a4e; color: #ddd;
padding: 5px 10px; border-radius: 3px; font-family: monospace; font-size: 0.82em; width: 220px; }
.count { font-size: 0.75em; color: #555; }
.entry { background: #12122a; border: 1px solid #1e1e40; border-radius: 5px;
margin-bottom: 14px; overflow: hidden; }
.entry-header { padding: 10px 14px; display: flex; gap: 16px; align-items: baseline;
border-bottom: 1px solid #1e1e40; cursor: pointer; }
.entry-header:hover { background: #1a1a3a; }
.ts { color: #555; font-size: 0.78em; white-space: nowrap; }
.agent { color: #7070ff; font-size: 0.82em; font-weight: bold; }
.tokens { font-size: 0.78em; color: #aaa; flex: 1; }
.tok-tag { display: inline-block; background: #1e1e50; color: #9090ff;
border-radius: 3px; padding: 1px 6px; margin: 1px 3px 1px 0; }
.toggle-hint { font-size: 0.7em; color: #444; margin-left: auto; white-space: nowrap; }
.entry-body { display: none; padding: 14px; }
.entry-body.open { display: block; }
.section-label { font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em;
color: #555; margin-bottom: 6px; margin-top: 14px; }
.section-label:first-child { margin-top: 0; }
.recollection-block { background: #0a1020; border-left: 3px solid #3040aa;
padding: 10px 12px; font-size: 0.83em; white-space: pre-wrap; color: #aabbff;
border-radius: 0 3px 3px 0; line-height: 1.5; }
.messages-accordion { margin-top: 8px; }
.msg-toggle { background: #1a1a3a; border: none; color: #777; font-family: monospace;
font-size: 0.78em; padding: 5px 10px; border-radius: 3px; cursor: pointer;
width: 100%; text-align: left; }
.msg-toggle:hover { background: #222250; color: #aaa; }
.messages-list { display: none; margin-top: 8px; }
.messages-list.open { display: block; }
.msg { border: 1px solid #1e1e40; border-radius: 3px; margin-bottom: 6px; overflow: hidden; }
.msg-role { padding: 4px 10px; font-size: 0.72em; font-weight: bold; text-transform: uppercase;
letter-spacing: 0.08em; }
.role-system { background: #1a2040; color: #6688ff; }
.role-user { background: #1a2a1a; color: #66bb66; }
.role-assistant { background: #2a1a1a; color: #cc8844; }
.role-other { background: #1e1e2e; color: #888; }
.msg-content { padding: 8px 10px; font-size: 0.8em; white-space: pre-wrap;
line-height: 1.5; max-height: 300px; overflow-y: auto; color: #ccc; }
.empty { color: #444; font-size: 0.85em; padding: 30px 0; text-align: center; }
.pagination { display: flex; gap: 8px; align-items: center; margin-top: 20px; }
.pg-btn { padding: 5px 12px; background: #1e1e40; border: none; color: #aaa;
border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.8em; }
.pg-btn:hover { background: #2a2a55; }
.pg-btn:disabled { opacity: 0.35; cursor: default; }
.pg-info { font-size: 0.78em; color: #555; }
a.back { color: #5555aa; text-decoration: none; font-size: 0.82em; display: inline-block; margin-bottom: 16px; }
a.back:hover { color: #8888ff; }
</style>
</head>
<body>
<a href="/admin" class="back">← admin</a>
<h1>Recollection Log</h1>
<p class="subtitle">Every prompt where Festinger injected a &lt;recollection&gt; block.</p>
<div class="toolbar">
<button class="btn btn-danger" onclick="clearLog()">Clear log</button>
<button class="btn btn-secondary" onclick="load()">Refresh</button>
<span class="count" id="count-label"></span>
</div>
<div class="filter-row">
<input id="agent-filter" type="text" placeholder="Filter by agent…" oninput="load()" />
</div>
<div id="entries"></div>
<div class="pagination">
<button class="pg-btn" id="btn-prev" onclick="prevPage()" disabled>← prev</button>
<span class="pg-info" id="pg-info"></span>
<button class="pg-btn" id="btn-next" onclick="nextPage()">next →</button>
</div>
<script>
let currentPage = 0;
const PAGE = 20;
async function load() {
const agent = document.getElementById('agent-filter').value.trim();
const qs = new URLSearchParams({ limit: PAGE, offset: currentPage * PAGE });
if (agent) qs.set('agent', agent);
const res = await fetch('/recollection-log/data?' + qs);
const data = await res.json();
renderEntries(data.entries);
const total = data.total;
document.getElementById('count-label').textContent = total + ' event' + (total === 1 ? '' : 's');
document.getElementById('pg-info').textContent =
'page ' + (currentPage + 1) + ' of ' + Math.max(1, Math.ceil(total / PAGE));
document.getElementById('btn-prev').disabled = currentPage === 0;
document.getElementById('btn-next').disabled = (currentPage + 1) * PAGE >= total;
}
function renderEntries(entries) {
const el = document.getElementById('entries');
if (!entries.length) {
el.innerHTML = '<div class="empty">No recollection events yet.</div>';
return;
}
el.innerHTML = entries.map((e, i) => {
const tokHtml = (e.salient_tokens || []).map(t =>
'<span class="tok-tag">' + esc(t) + '</span>'
).join('');
const msgsHtml = (e.messages || []).map(m => {
const role = (m.role || 'unknown').toLowerCase();
const cls = ['system','user','assistant'].includes(role) ? 'role-' + role : 'role-other';
const content = typeof m.content === 'string' ? m.content
: JSON.stringify(m.content, null, 2);
return '<div class="msg"><div class="msg-role ' + cls + '">' + esc(role) + '</div>'
+ '<div class="msg-content">' + esc(content) + '</div></div>';
}).join('');
return '<div class="entry">'
+ '<div class="entry-header" onclick="toggle(' + i + ')">'
+ ' <span class="ts">' + esc(e.created_at) + '</span>'
+ ' <span class="agent">' + esc(e.agent_name || '(none)') + '</span>'
+ ' <span class="tokens">' + tokHtml + '</span>'
+ ' <span class="toggle-hint">click to expand</span>'
+ '</div>'
+ '<div class="entry-body" id="entry-body-' + i + '">'
+ ' <div class="section-label">Recollection block</div>'
+ ' <pre class="recollection-block">' + esc(e.recollection_block) + '</pre>'
+ ' <div class="section-label" style="margin-top:14px">Full prompt (' + (e.messages || []).length + ' messages)</div>'
+ ' <div class="messages-accordion">'
+ ' <button class="msg-toggle" onclick="toggleMsgs(event,' + i + ')">Show messages ▾</button>'
+ ' <div class="messages-list" id="msgs-' + i + '">' + msgsHtml + '</div>'
+ ' </div>'
+ '</div>'
+ '</div>';
}).join('');
}
function toggle(i) {
const el = document.getElementById('entry-body-' + i);
el.classList.toggle('open');
}
function toggleMsgs(evt, i) {
evt.stopPropagation();
const el = document.getElementById('msgs-' + i);
el.classList.toggle('open');
evt.target.textContent = el.classList.contains('open') ? 'Hide messages ▴' : 'Show messages ▾';
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
function prevPage() { currentPage = Math.max(0, currentPage - 1); load(); }
function nextPage() { currentPage++; load(); }
async function clearLog() {
if (!confirm('Delete all recollection log entries?')) return;
await fetch('/recollection-log', { method: 'DELETE' });
currentPage = 0;
load();
}
load();
</script>
</body>
</html>
"""
@app.get("/recollection-log", response_class=HTMLResponse)
async def recollection_log_ui() -> HTMLResponse:
return HTMLResponse(RECOLLECTION_LOG_HTML)
@app.get("/recollection-log/data")
async def recollection_log_data(
request: Request,
limit: int = 20,
offset: int = 0,
agent: str = "",
) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
if agent:
rows = await conn.fetch(
"""
SELECT id, created_at, agent_name, salient_tokens, recollection_block, messages_json
FROM recollection_log
WHERE agent_name ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
f"%{agent}%", limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM recollection_log WHERE agent_name ILIKE $1",
f"%{agent}%",
)
else:
rows = await conn.fetch(
"""
SELECT id, created_at, agent_name, salient_tokens, recollection_block, messages_json
FROM recollection_log
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
""",
limit, offset,
)
total = await conn.fetchval("SELECT COUNT(*) FROM recollection_log")
entries = []
for r in rows:
entries.append({
"id": r["id"],
"created_at": r["created_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
"agent_name": r["agent_name"],
"salient_tokens": list(r["salient_tokens"]),
"recollection_block": r["recollection_block"],
"messages": r["messages_json"],
})
return {"total": total, "offset": offset, "limit": limit, "entries": entries}
@app.delete("/recollection-log")
async def clear_recollection_log(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM recollection_log")
deleted = int(result.split()[-1]) if result else 0
log.info("recollection_log cleared deleted=%d", deleted)
return {"status": "ok", "deleted": deleted}
# ---------------------------------------------------------------------------
# /graph — knowledge graph explorer
# ---------------------------------------------------------------------------
@app.get("/graph/data")
async def graph_data(
request: Request,
dim: str = "",
min_saliency: float = 1.0,
limit: int = 400,
center: str = "",
) -> dict:
"""
Return nodes and edges for the knowledge graph explorer.
- dim: filter to a single dimension (empty = all)
- min_saliency: only include concepts above this threshold
- limit: max edges to return
- center: if set, return the neighbourhood of this concept token
"""
pool = request.app.state.pool
base_query = """
SELECT u.id AS concept_id, u.parent_id, u.dim_id,
u.is_isa, u.confidence,
sc.token AS concept_token,
COALESCE(sc.saliency, 0.0) AS concept_saliency,
sp.token AS parent_token,
COALESCE(sp.saliency, 0.0) AS parent_saliency,
sd.token AS dim_token
FROM urd u
JOIN soas sc ON sc.id = u.id
JOIN soas sp ON sp.id = u.parent_id
JOIN soas sd ON sd.id = u.dim_id
WHERE u.id != u.parent_id
"""
async with pool.acquire() as conn:
if center:
row = await conn.fetchrow("SELECT id FROM soas WHERE token = $1", center)
if not row:
return {"nodes": [], "edges": [], "dim_list": [], "total_nodes": 0, "total_edges": 0}
center_id = row["id"]
rows = await conn.fetch(
base_query + " AND (u.id = $1 OR u.parent_id = $1) AND ($2 = '' OR sd.token = $2) LIMIT $3",
center_id, dim, limit,
)
else:
rows = await conn.fetch(
base_query + " AND ($1 = '' OR sd.token = $1) AND sc.saliency >= $2 ORDER BY sc.saliency DESC LIMIT $3",
dim, min_saliency, limit,
)
nodes_map: dict = {}
edges = []
for r in rows:
cid, pid, dtok = r["concept_id"], r["parent_id"], r["dim_token"]
if cid not in nodes_map:
nodes_map[cid] = {"id": cid, "token": r["concept_token"],
"saliency": round(float(r["concept_saliency"]), 3), "dc": {}}
if pid not in nodes_map:
nodes_map[pid] = {"id": pid, "token": r["parent_token"],
"saliency": round(float(r["parent_saliency"]), 3), "dc": {}}
nodes_map[cid]["dc"][dtok] = nodes_map[cid]["dc"].get(dtok, 0) + 1
edges.append({"source": cid, "target": pid, "dim": dtok,
"is_isa": r["is_isa"], "confidence": round(float(r["confidence"]), 3)})
nodes = []
for n in nodes_map.values():
primary = max(n["dc"], key=n["dc"].get) if n["dc"] else "other"
nodes.append({"id": n["id"], "token": n["token"], "saliency": n["saliency"],
"primary_dim": primary, "dims": list(n["dc"].keys())})
dim_list = sorted({e["dim"] for e in edges})
return {"nodes": nodes, "edges": edges, "dim_list": dim_list,
"total_nodes": len(nodes), "total_edges": len(edges)}
GRAPH_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Knowledge Graph</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: monospace; display: flex; height: 100vh; overflow: hidden; background: #f0f0f0; color: #222; }}
/* ── Sidebar ── */
#sidebar {{
width: 270px; flex-shrink: 0; background: #1a1a2e; color: #ccc;
display: flex; flex-direction: column; overflow-y: auto;
}}
.sb-section {{ padding: 14px 16px; border-bottom: 1px solid #2a2a4e; }}
.sb-section:last-child {{ border-bottom: none; flex: 1; }}
#sb-title {{ font-size: 1.05em; color: #fff; font-weight: bold; }}
#sb-subtitle {{ font-size: 0.72em; color: #555; margin-top: 2px; }}
.back-link {{ font-size: 0.72em; color: #5555aa; text-decoration: none; }}
.back-link:hover {{ color: #8888ff; }}
.sec-label {{
font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em;
color: #555; margin-bottom: 8px; font-weight: bold;
}}
.stats-row {{ display: flex; gap: 8px; }}
.stat-box {{ flex: 1; background: #0d0d20; border-radius: 4px; padding: 7px 10px; }}
.stat-val {{ font-size: 1.25em; font-weight: bold; color: #7070ff; }}
.stat-lbl {{ font-size: 0.68em; color: #555; text-transform: uppercase; margin-top: 1px; }}
input[type="text"] {{
width: 100%; padding: 6px 8px; background: #0d0d20; border: 1px solid #2a2a4e;
color: #ddd; border-radius: 3px; font-family: monospace; font-size: 0.83em;
}}
input[type="text"]:focus {{ outline: 1px solid #7070ff; border-color: #7070ff; }}
input[type="range"] {{ width: 100%; accent-color: #7070ff; cursor: pointer; margin-top: 4px; }}
.range-vals {{ display: flex; justify-content: space-between; font-size: 0.7em; color: #555; }}
label.field-label {{ font-size: 0.8em; color: #999; display: flex; justify-content: space-between; }}
label.field-label span {{ color: #aaa; }}
.btn-row {{ display: flex; gap: 6px; margin-top: 8px; }}
.btn {{ flex: 1; padding: 7px 10px; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.83em; }}
.btn-primary {{ background: #4040cc; color: #fff; }}
.btn-primary:hover {{ background: #5555dd; }}
.btn-secondary {{ background: #1e1e40; color: #aaa; }}
.btn-secondary:hover {{ background: #2a2a55; }}
.btn-load {{ width: 100%; padding: 9px; background: #4040cc; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.87em; }}
.btn-load:hover {{ background: #5555dd; }}
.dim-item {{ display: flex; align-items: center; gap: 7px; padding: 3px 0; cursor: pointer; font-size: 0.82em; }}
.dim-dot {{ width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }}
.dim-item input {{ cursor: pointer; accent-color: #7070ff; }}
/* Selected node */
#node-info {{ display: none; }}
.ni-name {{ font-size: 1.05em; color: #fff; word-break: break-all; margin-bottom: 4px; }}
.ni-meta {{ font-size: 0.75em; color: #666; margin-bottom: 6px; }}
.nbr-label {{ font-size: 0.68em; color: #555; text-transform: uppercase; letter-spacing: 0.07em; margin: 8px 0 4px; }}
#nbr-list {{ list-style: none; max-height: 200px; overflow-y: auto; }}
#nbr-list li {{
padding: 3px 0; font-size: 0.78em; cursor: pointer;
border-bottom: 1px solid #1a1a3e; line-height: 1.4;
}}
#nbr-list li:hover {{ color: #aaaaff; }}
#nbr-list li .edge-dim {{ font-size: 0.85em; opacity: 0.7; }}
#no-sel {{ font-size: 0.8em; color: #444; }}
/* Graph area */
#graph-area {{ flex: 1; position: relative; overflow: hidden; background: #fafafa; }}
#graph-svg {{ width: 100%; height: 100%; }}
/* Loading */
#loading {{
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(250,250,250,0.88); font-size: 1em; color: #888; z-index: 10;
}}
#loading.hidden {{ display: none; }}
.spinner {{ display: inline-block; width: 18px; height: 18px; border: 2px solid #ccc;
border-top-color: #7070ff; border-radius: 50%; animation: spin 0.7s linear infinite; margin-right: 8px; }}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
#empty-state {{
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
flex-direction: column; gap: 10px; color: #999;
}}
#empty-state.visible {{ display: flex; }}
#empty-state p {{ font-size: 0.85em; }}
/* Zoom */
#zoom-ctrls {{ position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; }}
#zoom-ctrls button {{
width: 34px; height: 34px; background: #fff; border: 1px solid #ddd; border-radius: 4px;
cursor: pointer; font-size: 1.1em; line-height: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}}
#zoom-ctrls button:hover {{ background: #f0f0f0; }}
/* Tooltip */
#tip {{
position: absolute; pointer-events: none; background: rgba(15,15,35,0.95);
color: #eee; padding: 8px 12px; border-radius: 5px; font-size: 0.78em;
display: none; z-index: 20; max-width: 240px; line-height: 1.6;
}}
#tip strong {{ color: #fff; font-size: 1.1em; }}
#tip .tip-edge {{ color: #aaa; font-size: 0.9em; }}
#tip .tip-dim {{ font-weight: bold; }}
/* Legend */
#legend {{
position: absolute; top: 12px; left: 12px; display: flex; gap: 8px;
flex-wrap: wrap; z-index: 5; pointer-events: none;
}}
.leg-item {{
display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.88);
padding: 3px 10px; border-radius: 12px; font-size: 0.72em; border: 1px solid #ddd;
cursor: pointer; pointer-events: all;
}}
.leg-item:hover {{ background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.12); }}
.leg-dot {{ width: 10px; height: 10px; border-radius: 50%; }}
.leg-item.dimmed {{ opacity: 0.35; }}
/* View mode toggle */
#view-toggle {{ display: flex; gap: 4px; margin-bottom: 8px; }}
#view-toggle button {{
flex: 1; padding: 5px 8px; border: 1px solid #2a2a4e; border-radius: 3px;
background: #0d0d20; color: #666; cursor: pointer; font-family: monospace; font-size: 0.78em;
}}
#view-toggle button.active {{ background: #4040cc; color: #fff; border-color: #4040cc; }}
</style>
</head>
<body>
<div id="sidebar">
<div class="sb-section">
<div id="sb-title">Knowledge Graph</div>
<div id="sb-subtitle">Festinger URD explorer</div>
<div style="margin-top:6px"><a href="/admin" class="back-link">← admin</a></div>
</div>
<div class="sb-section">
<div class="stats-row">
<div class="stat-box"><div class="stat-val" id="s-nodes">—</div><div class="stat-lbl">Nodes</div></div>
<div class="stat-box"><div class="stat-val" id="s-edges">—</div><div class="stat-lbl">Edges</div></div>
<div class="stat-box"><div class="stat-val" id="s-dims">—</div><div class="stat-lbl">Dims</div></div>
</div>
</div>
<div class="sb-section">
<div class="sec-label">Search / focus</div>
<input type="text" id="search-input" placeholder="concept name…" />
<div class="btn-row">
<button class="btn btn-primary" onclick="searchFocus()">Focus</button>
<button class="btn btn-secondary" onclick="clearSearch()">Clear</button>
</div>
</div>
<div class="sb-section">
<div class="sec-label">Filters</div>
<label class="field-label">Min saliency <span id="sal-val">0.0</span></label>
<input type="range" id="sal-slider" min="0" max="6" step="0.5" value="0"
oninput="document.getElementById('sal-val').textContent=parseFloat(this.value).toFixed(1)">
<div class="range-vals"><span>0</span><span>6</span></div>
<div style="margin-top:10px"></div>
<label class="field-label">Max nodes <span id="limit-val">300</span></label>
<input type="range" id="limit-slider" min="50" max="600" step="50" value="300"
oninput="document.getElementById('limit-val').textContent=this.value">
<div class="range-vals"><span>50</span><span>600</span></div>
<div style="margin-top:10px"></div>
<label class="field-label" style="align-items:center">
<span>Show bubbles</span>
<input type="checkbox" id="show-hulls" checked style="cursor:pointer;accent-color:#7070ff"
onchange="renderWithFilters()">
</label>
<label class="field-label" style="align-items:center;margin-top:6px">
<span>Edge labels</span>
<input type="checkbox" id="show-edgelabels" checked style="cursor:pointer;accent-color:#7070ff"
onchange="renderWithFilters()">
</label>
</div>
<div class="sb-section">
<div class="sec-label">Dimensions</div>
<div id="dim-list"></div>
</div>
<div class="sb-section">
<button class="btn-load" onclick="loadGraph()">Reload graph</button>
</div>
<div class="sb-section" style="flex:1">
<div class="sec-label">Selected node</div>
<div id="no-sel" style="font-size:0.8em;color:#444">Click any node to inspect it.</div>
<div id="node-info">
<div class="ni-name" id="ni-name"></div>
<div class="ni-meta">saliency: <span id="ni-sal"></span></div>
<div class="nbr-label">Facts (outgoing)</div>
<ul id="nbr-list"></ul>
</div>
</div>
</div>
<div id="graph-area">
<svg id="graph-svg"></svg>
<div id="legend"></div>
<div id="loading"><span class="spinner"></span>Loading graph…</div>
<div id="empty-state">
<p>No nodes match the current filters.</p>
<p>Try lowering the saliency threshold or adding concepts via <a href="/admin" style="color:#7070ff">admin</a>.</p>
</div>
<div id="tip"></div>
<div id="zoom-ctrls">
<button onclick="zoomBy(1.4)" title="Zoom in">+</button>
<button onclick="resetZoom()" title="Fit">&#8857;</button>
<button onclick="zoomBy(0.7)" title="Zoom out">&#8722;</button>
</div>
</div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
// ── Palette ──────────────────────────────────────────────────────────────────
const PALETTE = [
'#4e79a7','#f28e2b','#59a14f','#e15759',
'#b07aa1','#76b7b2','#edc948','#ff9da7',
'#9c755f','#d4a0c7','#8cd17d','#499894',
];
const dimColors = {{}};
['type','membership','runs-on','tech','owned-by','geography']
.forEach((d, i) => {{ dimColors[d] = PALETTE[i]; }});
function dimColor(dim) {{
if (!dimColors[dim]) {{
const idx = Object.keys(dimColors).length % PALETTE.length;
dimColors[dim] = PALETTE[idx];
}}
return dimColors[dim];
}}
function hexWithAlpha(hex, a) {{
// Convert #rrggbb to rgba(r,g,b,a)
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `rgba(${{r}},${{g}},${{b}},${{a}})`;
}}
// ── SVG setup ─────────────────────────────────────────────────────────────────
const svgEl = document.getElementById('graph-svg');
const graphArea = document.getElementById('graph-area');
const svg = d3.select(svgEl);
const root = svg.append('g');
const defs = svg.append('defs');
// Layer order: hulls → links → edge-labels → nodes → dim-labels (top)
const hullLayer = root.append('g').attr('class', 'hulls');
const linkLayer = root.append('g').attr('class', 'links');
const edgeLblLayer = root.append('g').attr('class', 'edge-labels');
const nodeLayer = root.append('g').attr('class', 'nodes');
const dimLblLayer = root.append('g').attr('class', 'dim-labels'); // always on top
const zoom = d3.zoom().scaleExtent([0.04, 14]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);
let W = graphArea.clientWidth, H = graphArea.clientHeight;
window.addEventListener('resize', () => {{
W = graphArea.clientWidth; H = graphArea.clientHeight;
if (sim) sim.force('center', d3.forceCenter(W/2, H/2)).alpha(0.15).restart();
}});
// ── State ─────────────────────────────────────────────────────────────────────
let graphData = null;
let sim = null;
function nodeR(d) {{ return 5 + Math.min(Math.log1p(d.saliency) * 5, 16); }}
// ── Arrow markers + metaball filter ──────────────────────────────────────────
// BLOB_R: radius of the circle drawn at each node for the metaball effect.
// Larger = blobs reach further and merge sooner.
// BLOB_BLUR: Gaussian blur sigma. Controls how far each blob "spreads".
// BLOB_MULT / BLOB_CUT: alpha-threshold matrix. new_a = MULT*old_a - CUT.
// Threshold fires where old_a > CUT/MULT, i.e. within ≈ blur distance.
const BLOB_R = 44;
const BLOB_BLUR = 16;
const BLOB_MULT = 28;
const BLOB_CUT = 11;
const BLOB_OPACITY = 0.20; // overall transparency of the blob layer
function buildMarkers(dims) {{
defs.selectAll('marker').remove();
// Single shared metaball filter — applied per-dim group so colors stay separate.
defs.selectAll('#metaball-filter').remove();
const f = defs.append('filter')
.attr('id', 'metaball-filter')
.attr('x', '-60%').attr('y', '-60%')
.attr('width', '220%').attr('height', '220%')
.attr('color-interpolation-filters', 'sRGB');
f.append('feGaussianBlur')
.attr('in', 'SourceGraphic').attr('stdDeviation', BLOB_BLUR).attr('result', 'blur');
f.append('feColorMatrix')
.attr('in', 'blur').attr('type', 'matrix')
.attr('values', `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${{BLOB_MULT}} -${{BLOB_CUT}}`);
dims.forEach(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
defs.append('marker')
.attr('id', 'arr_' + safe)
.attr('viewBox', '0 -5 10 10').attr('refX', 20).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', dimColor(dim));
}});
}}
// ── Dim grouping ──────────────────────────────────────────────────────────────
// Group nodes by dimension (concept + parent both included).
function computeDimGroups(nd, ed) {{
const byId = {{}};
nd.forEach(n => byId[n.id] = n);
const groups = {{}};
for (const e of ed) {{
const src = typeof e.source === 'object' ? e.source : byId[e.source];
const tgt = typeof e.target === 'object' ? e.target : byId[e.target];
if (!src || !tgt) continue;
if (!groups[e.dim]) groups[e.dim] = new Set();
groups[e.dim].add(src);
groups[e.dim].add(tgt);
}}
return Object.entries(groups).map(([dim, nodeSet]) => ({{ dim, nodes: [...nodeSet] }}));
}}
// Centroid of a node set
function centroid(nodes) {{
return [d3.mean(nodes, n => n.x), d3.mean(nodes, n => n.y)];
}}
// ── Data load ─────────────────────────────────────────────────────────────────
async function loadGraph() {{
setLoading(true);
clearSelection();
const minSal = document.getElementById('sal-slider').value;
const limit = document.getElementById('limit-slider').value;
const center = document.getElementById('search-input').value.trim();
const params = new URLSearchParams({{ min_saliency: minSal, limit }});
if (center) params.set('center', center);
try {{
const resp = await fetch('/graph/data?' + params);
graphData = await resp.json();
buildDimCheckboxes(graphData.dim_list);
buildLegend(graphData.dim_list);
renderWithFilters();
}} catch(err) {{
console.error(err);
}} finally {{
setLoading(false);
}}
}}
// ── Dimension checkboxes ──────────────────────────────────────────────────────
function buildDimCheckboxes(dims) {{
const el = document.getElementById('dim-list');
const existing = new Set([...el.querySelectorAll('.dchk')].map(c => c.value));
dims.forEach(dim => {{
if (existing.has(dim)) return;
const label = document.createElement('label');
label.className = 'dim-item';
label.innerHTML =
`<input type="checkbox" class="dchk" value="${{dim}}" checked>` +
`<span class="dim-dot" style="background:${{dimColor(dim)}}"></span>` +
`<span>${{dim}}</span>`;
label.querySelector('input').addEventListener('change', renderWithFilters);
el.appendChild(label);
}});
}}
function enabledDims() {{
return new Set([...document.querySelectorAll('.dchk:checked')].map(c => c.value));
}}
// ── Legend ────────────────────────────────────────────────────────────────────
function buildLegend(dims) {{
const el = document.getElementById('legend');
el.innerHTML = dims.map(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
return `<span class="leg-item" id="leg_${{safe}}" onclick="toggleDim('${{dim}}')">
<span class="leg-dot" style="background:${{dimColor(dim)}}"></span>${{dim}}
</span>`;
}}).join('');
}}
function toggleDim(dim) {{
const cb = document.querySelector(`.dchk[value="${{dim}}"]`);
if (cb) {{ cb.checked = !cb.checked; renderWithFilters(); }}
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
const leg = document.getElementById('leg_' + safe);
if (leg) leg.classList.toggle('dimmed', !cb.checked);
}}
// ── Filter + render ───────────────────────────────────────────────────────────
function renderWithFilters() {{
if (!graphData) return;
const allowed = enabledDims();
const edges = graphData.edges.filter(e => allowed.has(e.dim));
const usedIds = new Set(edges.flatMap(e => [e.source, e.target]));
const nodes = graphData.nodes.filter(n => usedIds.has(n.id));
document.getElementById('s-nodes').textContent = nodes.length.toLocaleString();
document.getElementById('s-edges').textContent = edges.length.toLocaleString();
document.getElementById('s-dims').textContent = allowed.size;
const empty = document.getElementById('empty-state');
if (nodes.length === 0) {{ empty.classList.add('visible'); renderGraph([], []); return; }}
empty.classList.remove('visible');
renderGraph(nodes, edges);
}}
// ── D3 render ─────────────────────────────────────────────────────────────────
function renderGraph(nodes, edges) {{
W = graphArea.clientWidth; H = graphArea.clientHeight;
buildMarkers(graphData ? graphData.dim_list : []);
if (sim) sim.stop();
const nd = nodes.map(n => ({{ ...n }}));
const ed = edges.map(e => ({{ ...e }}));
const showHulls = document.getElementById('show-hulls').checked;
const showEdgeLabels = document.getElementById('show-edgelabels').checked;
// ── Metaball blobs ────────────────────────────────────────────────────────
// One filtered <g> per dimension. Each contains a circle per node.
// The blur+threshold SVG filter merges nearby circles into smooth organic
// blobs. Nodes in multiple dimensions sit inside overlapping blobs — the
// color mixing shows their cross-dimension membership visually.
// Labels are rendered in a separate unfiltered layer so they stay crisp.
function updateHulls() {{
if (!showHulls) {{ hullLayer.selectAll('*').remove(); return; }}
const groups = computeDimGroups(nd, ed);
// One <g class="dim-blob"> per dimension, with the metaball filter applied.
const blobGroups = hullLayer.selectAll('g.dim-blob')
.data(groups, g => g.dim)
.join('g').attr('class', 'dim-blob')
.attr('filter', 'url(#metaball-filter)')
.attr('opacity', BLOB_OPACITY);
// Inside each dim group: one circle per node.
blobGroups.each(function(g) {{
d3.select(this).selectAll('circle.bn')
.data(g.nodes, n => n.id)
.join('circle').attr('class', 'bn')
.attr('cx', n => n.x).attr('cy', n => n.y)
.attr('r', BLOB_R)
.attr('fill', dimColor(g.dim));
}});
// Dimension labels — in dimLblLayer (above nodes) so they're always crisp.
dimLblLayer.selectAll('text.blob-label')
.data(groups, g => g.dim)
.join('text').attr('class', 'blob-label')
.attr('x', g => centroid(g.nodes)[0])
.attr('y', g => Math.min(...g.nodes.map(n => n.y)) - BLOB_R * 0.6)
.attr('text-anchor', 'middle')
.attr('fill', g => dimColor(g.dim))
.attr('font-size', '12px')
.attr('font-family', 'monospace')
.attr('font-weight', 'bold')
.attr('opacity', 0.9)
.style('pointer-events', 'none')
// White halo so label is readable over any blob color
.style('paint-order', 'stroke fill')
.style('stroke', 'rgba(255,255,255,0.85)')
.style('stroke-width', '3px')
.style('stroke-linejoin', 'round');
}}
// ── Links ──
const linkKey = e => `${{typeof e.source==='object'?e.source.id:e.source}}:${{typeof e.target==='object'?e.target.id:e.target}}:${{e.dim}}`;
const link = linkLayer.selectAll('line.lnk').data(ed, linkKey);
link.exit().remove();
const linkMerge = link.enter().append('line').attr('class','lnk').merge(link)
.attr('stroke', e => dimColor(e.dim))
.attr('stroke-opacity', 0.55)
.attr('stroke-width', e => e.is_isa ? 2.2 : 1.5)
.attr('marker-end', e => `url(#arr_${{e.dim.replace(/[^a-zA-Z0-9]/g,'_')}})`);
// ── Edge labels ──
function updateEdgeLabels() {{
if (!showEdgeLabels) {{ edgeLblLayer.selectAll('*').remove(); return; }}
edgeLblLayer.selectAll('text.elbl')
.data(ed, linkKey)
.join('text').attr('class', 'elbl')
.attr('text-anchor', 'middle')
.attr('font-size', '9px')
.attr('font-family', 'monospace')
.attr('fill', e => dimColor(e.dim))
.attr('opacity', 0.7)
.style('pointer-events', 'none')
.text(e => e.dim);
}}
updateEdgeLabels();
// ── Nodes ──
const node = nodeLayer.selectAll('g.nd').data(nd, d => d.id);
node.exit().remove();
const nodeEnter = node.enter().append('g').attr('class', 'nd')
.call(d3.drag()
.on('start', (ev, d) => {{ if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }})
.on('drag', (ev, d) => {{ d.fx = ev.x; d.fy = ev.y; }})
.on('end', (ev, d) => {{ if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }})
)
.on('click', (ev, d) => {{ ev.stopPropagation(); selectNode(d, nd, ed); }})
.on('mouseover', (ev, d) => showTip(ev, d, nd, ed))
.on('mousemove', moveTip)
.on('mouseout', hideTip);
nodeEnter.append('circle');
nodeEnter.append('text')
.style('pointer-events', 'none').style('user-select', 'none')
.attr('text-anchor', 'middle').style('font-size', '10px').style('fill', '#111')
// White halo punches through any blob color sitting beneath the text
.style('paint-order', 'stroke fill')
.style('stroke', 'rgba(255,255,255,0.9)')
.style('stroke-width', '3.5px')
.style('stroke-linejoin', 'round');
const nodeMerge = nodeEnter.merge(node);
nodeMerge.select('circle')
.attr('r', nodeR)
.attr('fill', d => dimColor(d.primary_dim))
.attr('stroke', '#fff').attr('stroke-width', 1.5)
.style('cursor', 'pointer');
nodeMerge.select('text')
.text(d => d.token)
.attr('y', d => -(nodeR(d) + 4));
svg.on('click', () => clearSelection());
// ── Simulation ──
const charge = Math.max(-700, -200 - nd.length * 1.2);
sim = d3.forceSimulation(nd)
.force('link', d3.forceLink(ed).id(d => d.id).distance(90).strength(0.55))
.force('charge', d3.forceManyBody().strength(charge))
.force('center', d3.forceCenter(W/2, H/2))
.force('collide', d3.forceCollide().radius(d => nodeR(d) + 8))
.on('tick', () => {{
// Move link endpoints to node surface
linkMerge
.attr('x1', e => e.source.x).attr('y1', e => e.source.y)
.attr('x2', e => {{
const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
const l = Math.sqrt(dx*dx+dy*dy) || 1;
return e.target.x - dx/l*(nodeR(e.target)+2);
}})
.attr('y2', e => {{
const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
const l = Math.sqrt(dx*dx+dy*dy) || 1;
return e.target.y - dy/l*(nodeR(e.target)+2);
}});
// Edge label at midpoint
if (showEdgeLabels) {{
edgeLblLayer.selectAll('text.elbl')
.attr('x', e => (e.source.x + e.target.x) / 2)
.attr('y', e => (e.source.y + e.target.y) / 2 - 4);
}}
nodeMerge.attr('transform', d => `translate(${{d.x}},${{d.y}})`);
updateHulls();
}});
}}
// ── Selection ─────────────────────────────────────────────────────────────────
function selectNode(d, nodes, edges) {{
document.getElementById('no-sel').style.display = 'none';
document.getElementById('node-info').style.display = 'block';
document.getElementById('ni-name').textContent = d.token;
document.getElementById('ni-sal').textContent = d.saliency.toFixed(3);
// Build neighbour list showing full triadic context
const byId = {{}};
nodes.forEach(n => byId[n.id] = n);
const outEdges = edges.filter(e => {{
const sid = typeof e.source === 'object' ? e.source.id : e.source;
return sid === d.id;
}});
const inEdges = edges.filter(e => {{
const tid = typeof e.target === 'object' ? e.target.id : e.target;
return tid === d.id;
}});
let html = '';
if (outEdges.length) {{
html += outEdges.map(e => {{
const tid = typeof e.target === 'object' ? e.target.id : e.target;
const tn = byId[tid]; const tt = tn ? tn.token : '?';
const rel = e.is_isa ? 'is-a' : 'is-part-of';
return `<li onclick="focusNode(${{tid}})" style="color:${{dimColor(e.dim)}}">
→ ${{tt}} <span class="edge-dim">[${{e.dim}}]</span>
<br><span style="color:#555;font-size:0.82em;padding-left:8px">(${{rel}})</span>
</li>`;
}}).join('');
}}
if (inEdges.length) {{
html += `<li style="color:#444;cursor:default;padding-top:4px"><em>referenced by:</em></li>`;
html += inEdges.map(e => {{
const sid = typeof e.source === 'object' ? e.source.id : e.source;
const sn = byId[sid]; const st = sn ? sn.token : '?';
return `<li onclick="focusNode(${{sid}})" style="color:${{dimColor(e.dim)}}">
← ${{st}} <span class="edge-dim">[${{e.dim}}]</span>
</li>`;
}}).join('');
}}
if (!html) html = '<li style="color:#555;cursor:default">no connections visible</li>';
document.getElementById('nbr-list').innerHTML = html;
const allNeighborIds = new Set([
...outEdges.map(e => typeof e.target==='object' ? e.target.id : e.target),
...inEdges.map(e => typeof e.source==='object' ? e.source.id : e.source),
]);
nodeLayer.selectAll('g.nd circle')
.attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.12);
nodeLayer.selectAll('g.nd text')
.attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.08);
linkLayer.selectAll('line.lnk').attr('opacity', e => {{
const sid = typeof e.source==='object' ? e.source.id : e.source;
const tid = typeof e.target==='object' ? e.target.id : e.target;
return (sid===d.id || tid===d.id) ? 0.9 : 0.04;
}});
edgeLblLayer.selectAll('text.elbl').attr('opacity', e => {{
const sid = typeof e.source==='object' ? e.source.id : e.source;
const tid = typeof e.target==='object' ? e.target.id : e.target;
return (sid===d.id || tid===d.id) ? 0.9 : 0.04;
}});
}}
function clearSelection() {{
document.getElementById('no-sel').style.display = 'block';
document.getElementById('node-info').style.display = 'none';
nodeLayer.selectAll('g.nd circle').attr('opacity', 1);
nodeLayer.selectAll('g.nd text').attr('opacity', 1);
linkLayer.selectAll('line.lnk').attr('opacity', 0.55);
edgeLblLayer.selectAll('text.elbl').attr('opacity', 0.7);
}}
function focusNode(id) {{
const n = nodeLayer.selectAll('g.nd').filter(d => d.id === id);
if (n.empty()) return;
const d = n.datum();
svg.transition().duration(500).call(
zoom.transform, d3.zoomIdentity.translate(W/2 - d.x*1.8, H/2 - d.y*1.8).scale(1.8)
);
}}
// ── Zoom ──────────────────────────────────────────────────────────────────────
function zoomBy(f) {{ svg.transition().duration(300).call(zoom.scaleBy, f); }}
function resetZoom() {{
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(1));
}}
// ── Tooltip ───────────────────────────────────────────────────────────────────
const tip = document.getElementById('tip');
function showTip(ev, d, nodes, edges) {{
tip.style.display = 'block';
const byId = {{}};
nodes.forEach(n => byId[n.id] = n);
// Outgoing edges for this node
const out = edges.filter(e => {{
const sid = typeof e.source==='object' ? e.source.id : e.source;
return sid === d.id;
}});
let edgeHtml = out.map(e => {{
const tid = typeof e.target==='object' ? e.target.id : e.target;
const tn = byId[tid]; const tt = tn ? tn.token : '?';
return `<div class="tip-edge">→ ${{tt}} <span class="tip-dim" style="color:${{dimColor(e.dim)}}">${{e.dim}}</span></div>`;
}}).join('');
if (!edgeHtml) edgeHtml = `<div class="tip-edge" style="color:#555">no outgoing edges</div>`;
tip.innerHTML = `<strong>${{d.token}}</strong><br>saliency: ${{d.saliency}}<br>${{edgeHtml}}`;
}}
function moveTip(ev) {{ tip.style.left=(ev.pageX+14)+'px'; tip.style.top=(ev.pageY-10)+'px'; }}
function hideTip() {{ tip.style.display='none'; }}
// ── Loading ───────────────────────────────────────────────────────────────────
function setLoading(on) {{
document.getElementById('loading').classList.toggle('hidden', !on);
}}
// ── Search ────────────────────────────────────────────────────────────────────
function searchFocus() {{ loadGraph(); }}
function clearSearch() {{ document.getElementById('search-input').value=''; loadGraph(); }}
document.getElementById('search-input').addEventListener('keydown', e => {{
if (e.key === 'Enter') loadGraph();
}});
loadGraph();
</script>
</body>
</html>"""
GRAPH_HTML = GRAPH_HTML.replace('{{', '{').replace('}}', '}')
@app.get("/graph", response_class=HTMLResponse)
async def graph_explorer() -> str:
return GRAPH_HTML
# ---------------------------------------------------------------------------
# /admin — minimal HTML UI
# ---------------------------------------------------------------------------
ADMIN_HTML = """<!DOCTYPE html>
<html>
<head>
<title>Festinger Admin</title>
<style>
body {{ font-family: monospace; max-width: 960px; margin: 40px auto; padding: 0 20px; color: #222; }}
h1 {{ font-size: 1.4em; margin-bottom: 0.2em; }}
h2 {{ font-size: 1.1em; margin-top: 2em; border-bottom: 1px solid #ddd; padding-bottom: 4px; }}
.subtitle {{ color: #666; font-size: 0.85em; margin-bottom: 1.5em; }}
.stats {{ display: flex; gap: 2em; flex-wrap: wrap; margin: 1em 0; }}
.stat {{ background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 12px 20px; min-width: 130px; }}
.stat-label {{ font-size: 0.75em; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }}
.stat-value {{ font-size: 1.8em; font-weight: bold; margin-top: 2px; }}
.actions {{ display: flex; gap: 1em; flex-wrap: wrap; margin: 1em 0; }}
button {{ padding: 8px 18px; cursor: pointer; border: 1px solid #aaa; background: #fff; border-radius: 3px; font-family: monospace; }}
button:hover {{ background: #f0f0f0; }}
button.primary {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
button.primary:hover {{ background: #2a2a4e; }}
button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
pre {{ background: #f4f4f4; border: 1px solid #e0e0e0; border-radius: 3px; padding: 1em; overflow: auto; font-size: 0.85em; max-height: 400px; }}
.status-ok {{ color: #2a7a2a; }}
.status-err {{ color: #b00; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.82em; }}
th {{ text-align: left; border-bottom: 2px solid #ddd; padding: 4px 8px; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.04em; color: #666; }}
td {{ border-bottom: 1px solid #f0f0f0; padding: 4px 8px; vertical-align: top; }}
tr:hover td {{ background: #fafafa; }}
.op-insert {{ color: #2a7a2a; font-weight: bold; }}
.op-rewrite {{ color: #c07000; font-weight: bold; }}
.op-decompose {{ color: #5050cc; font-weight: bold; }}
.op-reclassify {{ color: #c02060; font-weight: bold; }}
.log-controls {{ display: flex; gap: 1em; align-items: center; margin: 0.5em 0 1em; }}
.log-controls select, .log-controls button {{ font-family: monospace; padding: 4px 10px; }}
.log-nav {{ margin-top: 0.5em; display: flex; gap: 1em; align-items: center; font-size: 0.85em; }}
footer {{ margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.78em; color: #888; }}
footer a {{ color: #888; }}
.fact-form {{ display: flex; gap: 0.6em; flex-wrap: wrap; align-items: flex-end; margin-top: 0.6em; }}
.fact-form input, .fact-form select {{ font-family: monospace; padding: 5px 8px; border: 1px solid #ccc; border-radius: 3px; }}
.concept-edges {{ font-size: 0.78em; color: #555; }}
.search-row {{ display: flex; gap: 0.6em; align-items: center; margin-bottom: 0.8em; }}
.search-row input {{ font-family: monospace; padding: 5px 10px; border: 1px solid #ccc; border-radius: 3px; width: 220px; }}
</style>
</head>
<body>
<h1>Festinger</h1>
<p class="subtitle">Ollama-compatible inference middleware — loop detection &amp; Recollections world model
&nbsp;&mdash;&nbsp;<a href="/graph" style="color:#1a1a2e">Knowledge Graph Explorer</a>
&nbsp;&mdash;&nbsp;<a href="/recollection-log" style="color:#1a1a2e">Recollection Log</a>
&nbsp;&mdash;&nbsp;<a href="/models-ui" style="color:#1a1a2e">Model Manager</a>
</p>
<h2>World model stats</h2>
<div class="stats" id="stats">
<div class="stat"><div class="stat-label">SOAS tokens</div><div class="stat-value" id="s-soas">…</div></div>
<div class="stat"><div class="stat-label">URD edges</div><div class="stat-value" id="s-urd">…</div></div>
<div class="stat"><div class="stat-label">Pending conflicts</div><div class="stat-value" id="s-conflicts">…</div></div>
<div class="stat"><div class="stat-label">Last resolution</div><div class="stat-value" style="font-size:0.85em" id="s-lastrun">…</div></div>
</div>
<h2>Resolution model</h2>
<div id="models-section">
<table id="models-table" style="margin-bottom:0.8em">
<thead><tr><th>ID</th><th>Provider</th><th>Model name</th><th>resolve?</th><th>write?</th><th></th></tr></thead>
<tbody id="models-tbody"><tr><td colspan="6">Loading…</td></tr></tbody>
</table>
<details style="margin-bottom:1em">
<summary style="cursor:pointer;font-size:0.9em;color:#555">Add model…</summary>
<div style="margin-top:0.6em;display:flex;gap:0.7em;flex-wrap:wrap;align-items:flex-end">
<label style="font-size:0.85em">Provider
<select id="m-provider" style="font-family:monospace;padding:4px 8px;display:block;margin-top:2px">
<option value="claude">claude</option>
<option value="openai">openai</option>
</select>
</label>
<label style="font-size:0.85em">Model name
<input id="m-name" type="text" value="claude-opus-4-6"
style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:200px">
</label>
<label style="font-size:0.85em">API key
<input id="m-key" type="password" placeholder="sk-ant-…"
style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:260px">
</label>
<button onclick="addModel(this)" style="height:32px">Add</button>
</div>
</details>
</div>
<h2>Agent models</h2>
<p style="font-size:0.83em;color:#666;margin-bottom:0.8em">
Routes the main inference request to the agent's model — full cross-protocol
(Claude ↔ LM Studio ↔ OpenAI). Agent must send <code>X-Agent-Name: GUNNAR</code>
or <code>X-Agent-Id: 3</code> on every request. Also determines which model
Festinger uses for memory writing (context discovery).
</p>
<table id="agent-models-table" style="margin-bottom:0.8em">
<thead><tr><th>Agent name / ID</th><th>Model ID</th><th>Provider</th><th>Model name</th><th></th></tr></thead>
<tbody id="agent-models-tbody"><tr><td colspan="5">Loading…</td></tr></tbody>
</table>
<details style="margin-bottom:1em">
<summary style="cursor:pointer;font-size:0.9em;color:#555">Assign model to agent…</summary>
<div style="margin-top:0.6em;display:flex;gap:0.7em;flex-wrap:wrap;align-items:flex-end">
<label style="font-size:0.85em">Agent name or ID (e.g. gunnar or 3)
<input id="am-agent" type="text" placeholder="gunnar or 3"
style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:160px">
</label>
<label style="font-size:0.85em">Model
<select id="am-model" style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px">
<option value="">— select —</option>
</select>
</label>
<button onclick="assignAgentModel(this)" style="height:32px">Assign</button>
</div>
</details>
<h2>Concepts</h2>
<p style="font-size:0.83em;color:#666;margin-bottom:0.6em">
Domain concepts with saliency above zero. Add facts manually; the agent's
is-a / is-part-of statements are also captured automatically via cue scanning.
</p>
<details open style="margin-bottom:1em">
<summary style="cursor:pointer;font-size:0.9em;color:#555;font-weight:bold">Add a fact…</summary>
<div class="fact-form" style="margin-top:0.7em">
<label style="font-size:0.85em">Concept
<input id="f-concept" type="text" placeholder="gnommoweb"
style="display:block;margin-top:2px;width:150px">
</label>
<label style="font-size:0.85em">Relation
<select id="f-rel" style="display:block;margin-top:2px;padding:6px 8px">
<option value="-isa">is-a (type)</option>
<option value="-ispart">is-part-of (membership)</option>
</select>
</label>
<label style="font-size:0.85em">Parent
<input id="f-parent" type="text" placeholder="repository"
style="display:block;margin-top:2px;width:150px">
</label>
<label style="font-size:0.85em">Dimension <span style="color:#aaa">(optional)</span>
<input id="f-dim" type="text" placeholder="type"
style="display:block;margin-top:2px;width:120px">
</label>
<button onclick="addFact(this)" style="height:32px;margin-top:0">Add fact</button>
</div>
<div id="fact-result" style="margin-top:0.5em;font-size:0.85em"></div>
</details>
<div class="search-row">
<input id="c-search" type="text" placeholder="Search concepts…"
oninput="debounceSearch()" onkeydown="if(event.key==='Enter')loadConcepts(0)">
<button onclick="loadConcepts(0)">Search</button>
<button onclick="document.getElementById('c-search').value='';loadConcepts(0)">Clear</button>
</div>
<div id="concepts-table-wrap"><em style="color:#aaa">Enter a search term or click Search to browse.</em></div>
<div class="log-nav" id="concepts-nav" style="display:none">
<button id="c-prev" onclick="conceptsPage(-1)" disabled>← prev</button>
<span id="c-page-info"></span>
<button id="c-next" onclick="conceptsPage(1)">next →</button>
</div>
<h2>Actions</h2>
<div class="actions">
<button class="primary" onclick="runResolution(this)">Run conflict resolution now</button>
<button onclick="runWordnetImport(this)">Import WordNet lemmas</button>
<button onclick="resetGraph(this)" style="color:#b00;border-color:#e0b0b0">&#9888; Reset knowledge graph</button>
</div>
<pre id="result" style="display:none"></pre>
<h2>Pending conflicts</h2>
<div class="actions" style="margin-bottom:0.5em">
<button onclick="clearConflicts(this)">Clear pending conflicts</button>
</div>
<pre id="conflicts-pre">Loading…</pre>
<h2>Knowledge graph write log</h2>
<div class="log-controls">
<label>Op filter:
<select id="log-op-filter" onchange="loadLog(0)">
<option value="">all</option>
<option value="insert">insert</option>
<option value="rewrite">rewrite</option>
<option value="decompose">decompose</option>
<option value="reclassify">reclassify</option>
</select>
</label>
<button onclick="loadLog(0)">Refresh</button>
</div>
<div id="log-table-wrap">Loading…</div>
<div class="log-nav" id="log-nav" style="display:none">
<button id="log-prev" onclick="logPage(-1)" disabled>← prev</button>
<span id="log-page-info"></span>
<button id="log-next" onclick="logPage(1)">next →</button>
</div>
<footer>
<strong>Vocabulary source:</strong>
Princeton University &ldquo;About WordNet.&rdquo; <em>WordNet.</em> Princeton University. 2010.
<a href="https://wordnet.princeton.edu/" target="_blank">https://wordnet.princeton.edu/</a>
&mdash; used to pre-seed the SOAS concept vocabulary at saliency&nbsp;0 (common English baseline).
</footer>
<script>
async function loadStats() {{
const r = await fetch('/health');
const d = await r.json();
document.getElementById('s-soas').textContent = d.soas_tokens.toLocaleString();
document.getElementById('s-urd').textContent = d.urd_edges.toLocaleString();
document.getElementById('s-conflicts').textContent = d.pending_conflicts;
document.getElementById('s-lastrun').textContent = d.last_resolution_run
? d.last_resolution_run.replace('T', ' ').slice(0, 19)
: 'never';
}}
let _cfg = {{}};
async function loadModels() {{
const [mr, cr] = await Promise.all([fetch('/models'), fetch('/config')]);
const md = await mr.json();
_cfg = (await cr.json()).config;
const resolveId = _cfg['resolve_model_id'] || '';
const writeId = _cfg['write_model_id'] || '';
const tbody = document.getElementById('models-tbody');
if (!md.models.length) {{
tbody.innerHTML = '<tr><td colspan="6" style="color:#999">No models yet — add one below.</td></tr>';
return;
}}
tbody.innerHTML = md.models.map(m => `
<tr>
<td>${{m.id}}</td>
<td>${{m.provider}}</td>
<td>${{m.model_name}}</td>
<td><button onclick="setConfig('resolve_model_id','${{m.id}}')" style="padding:2px 8px;font-size:0.8em;${{resolveId==String(m.id)?'background:#2a7a2a;color:#fff;border-color:#2a7a2a':''}}">${{resolveId==String(m.id)?'✓ active':'set'}}</button></td>
<td><button onclick="setConfig('write_model_id','${{m.id}}')" style="padding:2px 8px;font-size:0.8em;${{writeId==String(m.id)?'background:#2a7a2a;color:#fff;border-color:#2a7a2a':''}}">${{writeId==String(m.id)?'✓ active':'set'}}</button></td>
<td><button onclick="deleteModel(${{m.id}},this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
</tr>`).join('');
// Populate model dropdown in agent-models assignment form
const sel = document.getElementById('am-model');
sel.innerHTML = '<option value="">— select —</option>' +
md.models.map(m => `<option value="${{m.id}}">${{m.id}} — ${{m.provider}} / ${{m.model_name}}</option>`).join('');
}}
async function addModel(btn) {{
const provider = document.getElementById('m-provider').value;
const model_name = document.getElementById('m-name').value.trim();
const api_key = document.getElementById('m-key').value.trim();
if (!model_name || !api_key) {{ alert('Model name and API key are required.'); return; }}
btn.disabled = true;
try {{
const r = await fetch('/models', {{method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{provider, model_name, api_key}})}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
showResult('Model added (id=' + d.id + '). You can now set it as the resolve model.', true);
document.getElementById('m-key').value = '';
await loadModels();
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
async function deleteModel(id, btn) {{
if (!confirm('Delete model ' + id + '?')) return;
btn.disabled = true;
try {{
const r = await fetch('/models/' + id, {{method:'DELETE'}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
await loadModels();
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
async function setConfig(key, value) {{
const r = await fetch('/config', {{method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{key, value}})}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
showResult('Config updated: ' + key + ' = ' + value, true);
await loadModels();
}}
async function loadAgentModels() {{
const r = await fetch('/agent-models');
const d = await r.json();
const tbody = document.getElementById('agent-models-tbody');
if (!d.agent_models.length) {{
tbody.innerHTML = '<tr><td colspan="5" style="color:#999">No assignments yet.</td></tr>';
return;
}}
tbody.innerHTML = d.agent_models.map(a => `
<tr>
<td><strong>${{a.agent_name}}</strong></td>
<td>${{a.model_id}}</td>
<td>${{a.provider}}</td>
<td>${{a.model_name}}${{a.base_url ? ' <span style="color:#999;font-size:0.85em">(' + a.base_url + ')</span>' : ''}}</td>
<td><button onclick="removeAgentModel('${{a.agent_name}}',this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
</tr>`).join('');
}}
async function assignAgentModel(btn) {{
const agent = document.getElementById('am-agent').value.trim().toLowerCase();
const model_id = document.getElementById('am-model').value;
if (!agent) {{ alert('Enter an agent name.'); return; }}
if (!model_id) {{ alert('Select a model.'); return; }}
btn.disabled = true;
try {{
const r = await fetch('/agent-models/' + encodeURIComponent(agent), {{
method: 'PUT',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{model_id: parseInt(model_id)}})
}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
showResult('Assigned model ' + model_id + ' to agent "' + agent + '".', true);
document.getElementById('am-agent').value = '';
await loadAgentModels();
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
async function removeAgentModel(agent, btn) {{
if (!confirm('Remove model assignment for "' + agent + '"?')) return;
btn.disabled = true;
try {{
const r = await fetch('/agent-models/' + encodeURIComponent(agent), {{method: 'DELETE'}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
await loadAgentModels();
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
// --------------- Concepts ---------------
const CONCEPT_PAGE_SIZE = 30;
let conceptOffset = 0;
let conceptTotal = 0;
let _searchTimer = null;
function debounceSearch() {{
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => loadConcepts(0), 350);
}}
async function loadConcepts(offset) {{
conceptOffset = offset;
const q = document.getElementById('c-search').value.trim();
const params = new URLSearchParams({{limit: CONCEPT_PAGE_SIZE, offset: conceptOffset}});
if (q) params.set('q', q);
const r = await fetch('/concepts?' + params);
const d = await r.json();
conceptTotal = d.total;
const wrap = document.getElementById('concepts-table-wrap');
if (!d.concepts.length) {{
wrap.innerHTML = '<em style="color:#aaa">No concepts found.</em>';
document.getElementById('concepts-nav').style.display = 'none';
return;
}}
let html = '<table><thead><tr>'
+ '<th>Concept</th><th>Saliency</th><th>Encounters</th>'
+ '<th>Edges (dim → parent)</th><th>First seen</th><th></th>'
+ '</tr></thead><tbody>';
for (const c of d.concepts) {{
const edgeStr = c.edges.map(e =>
`<span style="color:#555">[${{e.dim}}]</span> ${{e.parent}}`
).join('&ensp;');
const ctx = c.first_seen_context
? `<span title="${{c.first_seen_context.replace(/"/g,'&quot;')}}" style="color:#999;font-size:0.8em">…</span>`
: '';
html += `<tr>
<td><strong>${{c.token}}</strong></td>
<td>${{c.saliency}}</td>
<td>${{c.encounter_count}}</td>
<td class="concept-edges">${{edgeStr || '<span style="color:#bbb">none</span>'}}</td>
<td>${{ctx}}</td>
<td><button onclick="deleteConcept('${{c.token}}',this)"
style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
</tr>`;
}}
html += '</tbody></table>';
wrap.innerHTML = html;
const nav = document.getElementById('concepts-nav');
if (conceptTotal > CONCEPT_PAGE_SIZE) {{
nav.style.display = 'flex';
document.getElementById('c-prev').disabled = conceptOffset === 0;
document.getElementById('c-next').disabled = conceptOffset + CONCEPT_PAGE_SIZE >= conceptTotal;
document.getElementById('c-page-info').textContent =
`${{conceptOffset + 1}}${{Math.min(conceptOffset + CONCEPT_PAGE_SIZE, conceptTotal)}} of ${{conceptTotal}}`;
}} else {{
nav.style.display = 'none';
}}
}}
function conceptsPage(dir) {{
const next = conceptOffset + dir * CONCEPT_PAGE_SIZE;
if (next < 0 || next >= conceptTotal) return;
loadConcepts(next);
}}
async function addFact(btn) {{
const concept = document.getElementById('f-concept').value.trim().toLowerCase();
const rel = document.getElementById('f-rel').value;
const parent = document.getElementById('f-parent').value.trim().toLowerCase();
const dim = document.getElementById('f-dim').value.trim().toLowerCase();
if (!concept || !parent) {{ alert('Concept and parent are required.'); return; }}
let fact = `${{concept}} ${{rel}} ${{parent}}`;
if (dim) fact += ` in context of ${{dim}}`;
btn.disabled = true;
const el = document.getElementById('fact-result');
try {{
const r = await fetch('/iknowthat', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{fact}})
}});
const d = await r.json();
if (d.error) {{
el.style.color = '#b00'; el.textContent = 'Error: ' + d.error;
}} else if (d.status === 'collision') {{
el.style.color = '#c07000';
el.textContent = `Collision (${{d.collision_type}}) — queued for resolution. Current graph unchanged.`;
}} else {{
el.style.color = '#2a7a2a';
el.textContent = `Inserted: ${{d.subject}} [${{d.is_isa ? 'is-a' : 'is-part-of'}}] ${{d.parent}} (dim: ${{d.dimension}})`;
document.getElementById('f-concept').value = '';
document.getElementById('f-parent').value = '';
document.getElementById('f-dim').value = '';
await loadStats();
loadConcepts(conceptOffset);
}}
}} catch(e) {{ el.style.color = '#b00'; el.textContent = 'Error: ' + e.message; }}
finally {{ btn.disabled = false; }}
}}
async function deleteConcept(token, btn) {{
if (!confirm('Delete concept "' + token + '" and all its edges?')) return;
btn.disabled = true;
try {{
const r = await fetch('/concepts/' + encodeURIComponent(token), {{method: 'DELETE'}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
showResult('Deleted: ' + token, true);
await loadStats();
loadConcepts(conceptOffset);
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
async function loadConflicts() {{
const r = await fetch('/conflicts');
const d = await r.json();
const pending = d.conflicts.filter(c => c.status === 'pending');
const el = document.getElementById('conflicts-pre');
el.textContent = pending.length
? JSON.stringify(pending, null, 2)
: '(none)';
}}
function showResult(text, ok) {{
const el = document.getElementById('result');
el.style.display = 'block';
el.className = ok ? 'status-ok' : 'status-err';
el.textContent = text;
}}
async function clearConflicts(btn) {{
if (!confirm('Delete all pending conflicts from the resolution queue?')) return;
btn.disabled = true;
showResult('Clearing pending conflicts…', true);
try {{
const r = await fetch('/conflicts/clear', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok);
await loadStats();
await loadConflicts();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
async function runResolution(btn) {{
btn.disabled = true;
showResult('Running resolution job…', true);
try {{
const r = await fetch('/resolve/run', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok);
await loadStats();
await loadConflicts();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
async function runWordnetImport(btn) {{
btn.disabled = true;
showResult('Importing WordNet lemmas — this may take 1020 seconds…', true);
try {{
const r = await fetch('/wordnet/import', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok && !d.error);
await loadStats();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
async function resetGraph(btn) {{
if (!confirm(
'This will DELETE all learned URD edges, domain concepts, conflicts, and write logs.\\n\\n' +
'The standard English dictionary seed will be kept.\\n\\n' +
'Are you sure?'
)) return;
btn.disabled = true;
showResult('Resetting knowledge graph…', true);
try {{
const r = await fetch('/reset', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok);
await loadStats();
await loadConflicts();
await loadLog(0);
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
const LOG_PAGE_SIZE = 50;
let logOffset = 0;
let logTotal = 0;
async function loadLog(offset) {{
logOffset = offset;
const op = document.getElementById('log-op-filter').value;
const params = new URLSearchParams({{limit: LOG_PAGE_SIZE, offset: logOffset}});
if (op) params.set('op', op);
const r = await fetch('/kg-log?' + params);
const d = await r.json();
logTotal = d.total;
const opClass = {{insert:'op-insert', rewrite:'op-rewrite', decompose:'op-decompose', reclassify:'op-reclassify'}};
if (!d.entries.length) {{
document.getElementById('log-table-wrap').innerHTML = '<em>No entries yet.</em>';
document.getElementById('log-nav').style.display = 'none';
return;
}}
let html = '<table><thead><tr>'
+ '<th>#</th><th>Time</th><th>Op</th><th>Concept</th>'
+ '<th>Dimension</th><th>Parent</th><th>Prev parent</th>'
+ '<th>is-a</th><th>Conf</th><th>Source</th>'
+ '</tr></thead><tbody>';
for (const e of d.entries) {{
const ts = e.created_at ? e.created_at.replace('T',' ').slice(0,19) : '';
const cls = opClass[e.op] || '';
html += `<tr>
<td>${{e.id}}</td>
<td>${{ts}}</td>
<td><span class="${{cls}}">${{e.op}}</span></td>
<td>${{e.concept}}</td>
<td>${{e.dimension}}</td>
<td>${{e.parent}}</td>
<td>${{e.prev_parent || ''}}</td>
<td>${{e.is_isa ? 'yes' : ''}}</td>
<td>${{e.confidence}}</td>
<td>${{e.source}}</td>
</tr>`;
}}
html += '</tbody></table>';
document.getElementById('log-table-wrap').innerHTML = html;
const nav = document.getElementById('log-nav');
nav.style.display = 'flex';
document.getElementById('log-prev').disabled = logOffset === 0;
document.getElementById('log-next').disabled = logOffset + LOG_PAGE_SIZE >= logTotal;
document.getElementById('log-page-info').textContent =
`${{logOffset + 1}}${{Math.min(logOffset + LOG_PAGE_SIZE, logTotal)}} of ${{logTotal}}`;
}}
function logPage(dir) {{
loadLog(Math.max(0, logOffset + dir * LOG_PAGE_SIZE));
}}
loadStats();
loadConflicts();
loadLog(0);
loadModels();
loadAgentModels();
</script>
</body>
</html>
"""
@app.get("/admin")
async def admin() -> Response:
return Response(
content=ADMIN_HTML.format(),
media_type="text/html",
headers={"Cache-Control": "no-store"},
)
# ---------------------------------------------------------------------------
# /models-ui — model manager page
# ---------------------------------------------------------------------------
MODELS_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Festinger — Model Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #f5f5f5; color: #222; min-height: 100vh; }
/* ── Top bar ── */
.topbar {
background: #1a1a2e; color: #fff;
padding: 0 28px; height: 52px;
display: flex; align-items: center; gap: 20px;
}
.topbar-title { font-size: 1.1em; font-weight: bold; letter-spacing: 0.02em; }
.topbar-sub { font-size: 0.78em; color: #666; }
.topbar a { color: #7070cc; font-size: 0.8em; text-decoration: none; }
.topbar a:hover { color: #9999ff; }
.topbar-spacer { flex: 1; }
/* ── Layout ── */
.page { max-width: 900px; margin: 0 auto; padding: 28px 20px 60px; }
/* ── Cards ── */
.card {
background: #fff; border: 1px solid #e0e0e0; border-radius: 6px;
margin-bottom: 20px; overflow: hidden;
}
.card-header {
background: #f8f8f8; border-bottom: 1px solid #e8e8e8;
padding: 12px 18px; display: flex; align-items: center; gap: 10px;
}
.card-title { font-size: 0.95em; font-weight: bold; }
.card-sub { font-size: 0.75em; color: #888; margin-left: auto; }
.card-body { padding: 18px; }
/* ── Active badge ── */
.badge {
display: inline-block; padding: 2px 9px; border-radius: 10px;
font-size: 0.72em; font-weight: bold; letter-spacing: 0.03em;
}
.badge-resolve { background: #d4edda; color: #155724; }
.badge-write { background: #cce5ff; color: #004085; }
.badge-both { background: #fff3cd; color: #856404; }
/* ── Tables ── */
table { width: 100%; border-collapse: collapse; font-size: 0.84em; }
th {
text-align: left; border-bottom: 2px solid #eee; padding: 6px 10px;
font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.06em; color: #888;
}
td { border-bottom: 1px solid #f2f2f2; padding: 7px 10px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* ── Forms ── */
.field-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 0.78em; color: #666; }
input[type="text"], input[type="password"], input[type="url"], select {
font-family: monospace; font-size: 0.85em;
padding: 7px 10px; border: 1px solid #ccc; border-radius: 4px;
background: #fff; color: #222;
}
input[type="text"]:focus, input[type="password"]:focus,
input[type="url"]:focus, select:focus {
outline: none; border-color: #7070cc;
}
input[type="text"].wide { width: 280px; }
input[type="password"].wide { width: 280px; }
input[type="url"].wide { width: 300px; }
/* ── Buttons ── */
.btn {
padding: 7px 16px; border-radius: 4px; border: 1px solid #bbb;
cursor: pointer; font-family: monospace; font-size: 0.84em;
background: #fff; color: #333; white-space: nowrap;
}
.btn:hover { background: #f0f0f0; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
.btn-primary:hover { background: #2a2a4e; }
.btn-success { background: #1e6e3e; color: #fff; border-color: #1e6e3e; }
.btn-success:hover { background: #27844b; }
.btn-sm { padding: 3px 10px; font-size: 0.78em; }
.btn-danger { color: #b00; border-color: #e0b0b0; }
.btn-danger:hover { background: #fff0f0; }
.btn-active { background: #d4edda; color: #155724; border-color: #b0d8bc; cursor: default; }
/* ── Divider ── */
.divider { height: 1px; background: #eee; margin: 16px 0; }
/* ── Discovery list ── */
#disc-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 20px; }
.disc-item {
background: #eef0ff; border: 1px solid #c8ccee; border-radius: 4px;
padding: 5px 12px; font-size: 0.82em; display: flex; align-items: center; gap: 8px;
}
.disc-item button { padding: 2px 8px; font-size: 0.78em; }
/* ── Status / toast ── */
#toast {
position: fixed; bottom: 24px; right: 24px; z-index: 100;
background: #1a1a2e; color: #fff; padding: 10px 18px; border-radius: 6px;
font-size: 0.85em; opacity: 0; transition: opacity 0.25s;
pointer-events: none; max-width: 360px;
}
#toast.show { opacity: 1; }
#toast.err { background: #8b0000; }
/* ── Separator label ── */
.sep { font-size: 0.72em; color: #aaa; text-transform: uppercase; letter-spacing: 0.08em;
margin: 18px 0 10px; }
/* ── Provider pill ── */
.pill {
display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.75em;
}
.pill-claude { background: #fce8d5; color: #6b3000; }
.pill-openai { background: #d5f0e8; color: #00502a; }
.pill-lm-studio { background: #e8d5fc; color: #3a006b; }
</style>
</head>
<body>
<div class="topbar">
<span class="topbar-title">Festinger</span>
<span class="topbar-sub">Model Manager</span>
<div class="topbar-spacer"></div>
<a href="/admin">← admin</a>
</div>
<div class="page">
<!-- ── Currently configured models ── -->
<div class="card">
<div class="card-header">
<span class="card-title">Configured models</span>
<span class="card-sub" id="cfg-sub">Loading…</span>
</div>
<div class="card-body" style="padding:0">
<table>
<thead><tr>
<th>ID</th><th>Provider</th><th>Model</th><th>Endpoint</th>
<th>Role</th><th>Actions</th>
</tr></thead>
<tbody id="models-tbody">
<tr><td colspan="6" style="color:#aaa;padding:18px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── LM Studio ── -->
<div class="card">
<div class="card-header">
<span class="card-title">LM Studio</span>
<span class="card-sub">OpenAI-compatible local inference</span>
</div>
<div class="card-body">
<p style="font-size:0.83em;color:#666;margin-bottom:14px">
Start LM Studio, load a model, and enable the local server
(default port&nbsp;1234). Festinger will discover available models
and register them for conflict resolution.
</p>
<div class="field-row">
<div class="field">
<label>LM Studio base URL</label>
<input type="url" id="lms-url" class="wide" value="http://host.docker.internal:1234">
</div>
<button class="btn btn-primary" onclick="discoverModels(this)">Discover models</button>
</div>
<div id="disc-error" style="display:none;color:#b00;font-size:0.82em;margin-top:6px"></div>
<div id="disc-list"></div>
</div>
</div>
<!-- ── Add Claude / OpenAI ── -->
<div class="card">
<div class="card-header">
<span class="card-title">Add cloud model</span>
<span class="card-sub">Claude or OpenAI-compatible</span>
</div>
<div class="card-body">
<div class="field-row">
<div class="field">
<label>Provider</label>
<select id="add-provider" onchange="onProviderChange()">
<option value="claude">Claude (Anthropic)</option>
<option value="openai">OpenAI</option>
<option value="lm-studio">Custom OpenAI-compatible</option>
</select>
</div>
<div class="field">
<label>Model name</label>
<input type="text" id="add-name" class="wide" value="claude-haiku-4-5-20251001">
</div>
<div class="field" id="add-key-field">
<label>API key</label>
<input type="password" id="add-key" class="wide" placeholder="sk-ant-…">
</div>
</div>
<div class="field-row" id="add-url-row" style="display:none">
<div class="field">
<label>Base URL</label>
<input type="url" id="add-url" style="width:360px" placeholder="http://host.docker.internal:1234/v1">
</div>
</div>
<button class="btn btn-primary" onclick="addModel(this)">Add model</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
// ─── State ────────────────────────────────────────────────────────────────────
let _cfg = {};
let _models = [];
// ─── Toast ────────────────────────────────────────────────────────────────────
let _toastTimer = null;
function toast(msg, err = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show' + (err ? ' err' : '');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.className = '', 3000);
}
// ─── Load ─────────────────────────────────────────────────────────────────────
async function load() {
const [mr, cr] = await Promise.all([fetch('/models'), fetch('/config')]);
_models = (await mr.json()).models || [];
_cfg = (await cr.json()).config || {};
renderModels();
}
function roleBadge(id) {
const sid = String(id);
const isResolve = _cfg['resolve_model_id'] === sid;
const isWrite = _cfg['write_model_id'] === sid;
if (isResolve && isWrite) return '<span class="badge badge-both">resolve + write</span>';
if (isResolve) return '<span class="badge badge-resolve">resolve</span>';
if (isWrite) return '<span class="badge badge-write">write</span>';
return '<span style="color:#bbb;font-size:0.8em">—</span>';
}
function providerPill(p) {
const cls = 'pill-' + p;
return `<span class="pill ${cls}">${p}</span>`;
}
function renderModels() {
const tbody = document.getElementById('models-tbody');
const sub = document.getElementById('cfg-sub');
if (!_models.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:#aaa;padding:18px">No models configured yet.</td></tr>';
sub.textContent = '0 models';
return;
}
sub.textContent = _models.length + ' model' + (_models.length !== 1 ? 's' : '');
const resolveId = _cfg['resolve_model_id'] || '';
const writeId = _cfg['write_model_id'] || '';
tbody.innerHTML = _models.map(m => {
const sid = String(m.id);
const endpoint = m.base_url
? `<span style="color:#555;font-size:0.85em">${m.base_url}</span>`
: '<span style="color:#ccc">—</span>';
const rBtn = resolveId === sid
? `<button class="btn btn-sm btn-active" disabled>✓ resolve</button>`
: `<button class="btn btn-sm" onclick="setRole('resolve_model_id','${sid}')">set resolve</button>`;
const wBtn = writeId === sid
? `<button class="btn btn-sm btn-active" disabled>✓ write</button>`
: `<button class="btn btn-sm" onclick="setRole('write_model_id','${sid}')">set write</button>`;
return `<tr>
<td style="color:#aaa">#${m.id}</td>
<td>${providerPill(m.provider)}</td>
<td>${m.model_name}</td>
<td>${endpoint}</td>
<td>${roleBadge(m.id)}</td>
<td style="display:flex;gap:6px;padding:6px 10px">
${rBtn} ${wBtn}
<button class="btn btn-sm btn-danger" onclick="deleteModel(${m.id},this)">✕</button>
</td>
</tr>`;
}).join('');
}
// ─── Set role ─────────────────────────────────────────────────────────────────
async function setRole(key, value) {
const r = await fetch('/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key, value}),
});
const d = await r.json();
if (d.error) { toast('Error: ' + d.error, true); return; }
_cfg[key] = value;
renderModels();
toast(key.replace('_model_id', '') + ' model set to #' + value);
}
// ─── Delete ───────────────────────────────────────────────────────────────────
async function deleteModel(id, btn) {
if (!confirm(`Delete model #${id}?`)) return;
btn.disabled = true;
const r = await fetch('/models/' + id, {method: 'DELETE'});
const d = await r.json();
btn.disabled = false;
if (d.error) { toast('Error: ' + d.error, true); return; }
toast('Model #' + id + ' deleted');
await load();
}
// ─── LM Studio discover ───────────────────────────────────────────────────────
async function discoverModels(btn) {
const base = document.getElementById('lms-url').value.trim();
const errEl = document.getElementById('disc-error');
const listEl = document.getElementById('disc-list');
errEl.style.display = 'none';
listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">Connecting…</span>';
btn.disabled = true;
try {
const r = await fetch('/models/discover?base_url=' + encodeURIComponent(base));
const d = await r.json();
btn.disabled = false;
if (d.error) {
errEl.textContent = d.error;
errEl.style.display = 'block';
listEl.innerHTML = '';
return;
}
if (!d.models.length) {
listEl.innerHTML = '<span style="color:#aaa;font-size:0.82em">No models loaded in LM Studio.</span>';
return;
}
listEl.innerHTML = d.models.map(name => `
<div class="disc-item">
<span>${name}</span>
<button class="btn btn-sm btn-success"
onclick="addLmStudioModel('${name}','${base}/v1',this)">
+ add
</button>
</div>
`).join('');
} catch(e) {
btn.disabled = false;
errEl.textContent = 'Request failed: ' + e.message;
errEl.style.display = 'block';
listEl.innerHTML = '';
}
}
async function addLmStudioModel(modelName, baseUrl, btn) {
btn.disabled = true;
const r = await fetch('/models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider: 'lm-studio', model_name: modelName, api_key: '', base_url: baseUrl}),
});
const d = await r.json();
btn.disabled = false;
if (d.error) { toast('Error: ' + d.error, true); return; }
toast(`Added "${modelName}" (#${d.id}). Click "set resolve" to activate it.`);
btn.textContent = '✓ added';
btn.classList.add('btn-active');
await load();
}
// ─── Add cloud model ──────────────────────────────────────────────────────────
function onProviderChange() {
const p = document.getElementById('add-provider').value;
document.getElementById('add-url-row').style.display = p === 'lm-studio' ? 'flex' : 'none';
document.getElementById('add-key-field').style.display = p === 'lm-studio' ? 'none' : 'flex';
const nameDefaults = {
claude: 'claude-haiku-4-5-20251001',
openai: 'gpt-4o-mini',
'lm-studio': '',
};
document.getElementById('add-name').value = nameDefaults[p] || '';
}
async function addModel(btn) {
const provider = document.getElementById('add-provider').value;
const modelName = document.getElementById('add-name').value.trim();
const apiKey = document.getElementById('add-key').value.trim();
const baseUrl = document.getElementById('add-url').value.trim();
if (!modelName) { toast('Model name is required', true); return; }
if (provider === 'claude' && !apiKey) { toast('API key required for Claude', true); return; }
btn.disabled = true;
const r = await fetch('/models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider, model_name: modelName, api_key: apiKey, base_url: baseUrl}),
});
const d = await r.json();
btn.disabled = false;
if (d.error) { toast('Error: ' + d.error, true); return; }
toast(`Model added (#${d.id}). Click "set resolve" to activate it.`);
document.getElementById('add-key').value = '';
await load();
}
// ─── Boot ─────────────────────────────────────────────────────────────────────
load();
</script>
</body>
</html>"""
@app.get("/models-ui", response_class=HTMLResponse)
async def models_ui() -> str:
return MODELS_HTML
# ---------------------------------------------------------------------------
# Passthrough — everything else forwarded to upstream Ollama
# ---------------------------------------------------------------------------
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
async def passthrough(path: str, request: Request) -> Response:
cfg = request.app.state.yaml_config
# Some LiteLLM configurations build wrong paths like v1/messages/chat/completions
# (when api_base already includes /v1/messages) or v1/chat/completions without
# a leading slash. Redirect all chat-completion variants to the proper handler.
if path.endswith("chat/completions") and request.method == "POST":
log.info("passthrough redirect %s → /v1/chat/completions", path)
return await openai_chat_completions(request)
if path.startswith("v1/"):
upstream = cfg["upstream_anthropic"]
relay_headers = ANTHROPIC_RELAY_HEADERS
provider = "anthropic"
else:
upstream = cfg["upstream_ollama"]
relay_headers = None
provider = "ollama"
url = f"{upstream}/{path}"
log.info("passthrough %s %s%s", request.method, path, url)
body = await request.body()
if relay_headers:
headers = _relay_headers(request, relay_headers)
else:
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.request(request.method, url, content=body, headers=headers)
except httpx.TimeoutException as exc:
log.error("passthrough_timeout provider=%s url=%s after=%.0fs %s",
provider, url, time.perf_counter() - t0, exc)
raise
except httpx.RequestError as exc:
log.error("passthrough_connect_error provider=%s url=%s %s: %s",
provider, url, type(exc).__name__, exc)
raise
ms = (time.perf_counter() - t0) * 1000
if not r.is_success:
log.warning("passthrough_error provider=%s url=%s status=%d %.0fms body=%.300s",
provider, url, r.status_code, ms, r.text)
else:
log.info("passthrough_ok provider=%s status=%d %.0fms", provider, r.status_code, ms)
return Response(
content=r.content,
status_code=r.status_code,
media_type=r.headers.get("content-type"),
)