Files
agent0/plugins/festinger/festinger/main.py
T
2026-04-21 22:10:08 +02:00

3179 lines
131 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 /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
* /v1/{path} Passthrough to upstream Anthropic
* /{path} Passthrough to upstream Ollama
"""
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, create_novel_soas, reset_graph,
)
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_context_discover, 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 — skip novel-word detection for these.
# Matches: paths (foo/bar), emails (a@b), file extensions (foo.py), dotted names (1.2.3),
# pure numbers, hex literals/colours.
_STRUCTURAL_RE = re.compile(
r"[/@]" # URL-like separator or email @
r"|\.\w" # dotted extension or namespace (e.g. foo.py, omega.13)
r"|^\d+$" # pure digits
r"|^\d[\d.]+\d$" # version string like 1.2 or 3.4.5
r"|^#[0-9a-f]{3,6}$" # hex colour
r"|^0x[0-9a-f]+$", # hex literal
re.IGNORECASE,
)
def _is_structural_token(token: str) -> bool:
"""Return True if token looks like a path, version, number, or URL fragment."""
return bool(_STRUCTURAL_RE.search(token))
def _sentence_containing(text: str, token: str, max_chars: int = 80) -> str:
"""Return a short excerpt of the first sentence containing token (case-insensitive)."""
for sentence in re.split(r"(?<=[.!?])\s+|\n+", text):
if token.lower() in sentence.lower():
return sentence.strip()[:max_chars]
return ""
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 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,
) -> 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,
skip_discovery=is_local,
)
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,
skip_discovery: bool = False,
) -> dict:
"""
Run the saliency + recollection pipeline over the prompt.
Returns a (possibly modified) body dict with the recollection block injected.
skip_discovery: when True, novel-word candidates are still registered in
SOAS (low saliency) but the LLM context-discover call is suppressed.
Used when the agent is routed to a local single-GPU model that cannot
handle simultaneous requests.
"""
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 {}
# Derive a ModelConfig from the intercepted request so context discovery can
# mirror Agent0's current model without a separate write_model_id config.
request_model = _extract_request_model_config(path, body, hdrs, cfg)
agent_name, _ = _extract_agent_name(body, hdrs) # body already cleaned by route handler
# Extract only the last user message — agent responses and reasoning traces
# are noise for both cue scanning and concept discovery.
user_text = _last_user_message_text(body, path)
if not user_text.strip():
return body
# 1. Scan user message for explicit relationship cues (fast, inline, bypasses LLM).
for cue in scan_cues(user_text):
await enqueue_cue(cue)
# 2. Tokenise the recent context (last user + last assistant) for the read path.
# Novel words from the user turn are also collected as LLM candidates.
prompt_text = extract_prompt_text(body, path)
tokens = tokenize(prompt_text)
salient_for_read: list[int] = []
# Candidate novel tokens from the USER message only — structural tokens
# (paths, versions, numbers) are filtered out. Capped to avoid flooding
# on very long messages.
MAX_NOVEL_PER_TURN = 8
novel_candidates: list[str] = []
# Only collect candidates from user-side tokens
user_tokens = set(tokenize(user_text))
for token in tokens:
soas_row = cache.soas_by_token.get(token)
if soas_row is None:
# Token absent from cache entirely — candidate domain word.
# Restrict to user-side tokens so we don't mine agent responses.
if (
token in user_tokens
and not _is_structural_token(token)
and len(novel_candidates) < MAX_NOVEL_PER_TURN
):
novel_candidates.append(token)
continue
if soas_row.saliency == 0.0 and soas_row.novelty == 0.0:
# Common English word — skip.
continue
cache.record_encounter(soas_row.id)
# Only surface in recollection if saliency is above threshold.
# Unconfirmed novel words (saliency=NOVEL_INITIAL_SALIENCY=0.1) are
# deliberately kept below the threshold until the LLM confirms them.
if soas_row.saliency >= read_threshold:
salient_for_read.append(soas_row.id)
# Register novel candidates in SOAS at low saliency (below read threshold).
# They become recollection attractors only after the LLM confirms them.
for token in novel_candidates:
ctx = _sentence_containing(user_text, token)
await create_novel_soas(pool, token, context=ctx)
# Do NOT add to salient_for_read — no zero-hit recollection until confirmed.
# 3. Enqueue for LLM-driven discovery if there are candidates to evaluate.
if novel_candidates and len(user_text) >= 20 and not skip_discovery:
await enqueue_context_discover(
user_text, novel_candidates,
agent_name=agent_name,
fallback_model=request_model,
)
if not salient_for_read:
return body
# 5. Build recollection block
block = build_recollection_block(salient_for_read, conf_floor, recency_days)
if not block:
return body
# 6. Inject into messages
if path == "/api/chat" or path == "/v1/chat/completions":
body = dict(body)
body["messages"] = inject_recollection(body.get("messages", []), block)
elif path == "/v1/messages":
body = inject_recollection_anthropic(body, block)
# /api/generate uses a flat prompt string — prepend there
elif path == "/api/generate":
body = dict(body)
body["prompt"] = block + "\n\n" + body.get("prompt", "")
return body
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.post("/api/chat")
async def chat(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
_, body = _extract_agent_name(await request.json(), 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", model)
try:
body = await process_prompt(body, "/api/chat", pool, cfg, dict(request.headers))
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
@app.post("/api/generate")
async def generate(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
_, body = _extract_agent_name(await request.json(), 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", model)
try:
body = await process_prompt(body, "/api/generate", pool, cfg, dict(request.headers))
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
# ---------------------------------------------------------------------------
# Anthropic Messages API (POST /v1/messages)
# ---------------------------------------------------------------------------
@app.post("/v1/messages")
async def anthropic_messages(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
raw_body = await request.body()
_, body = _extract_agent_name(json.loads(raw_body), 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 stream=%s req_preview=%.200s",
model, upstream, 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)
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
# ---------------------------------------------------------------------------
# OpenAI-compatible chat completions (POST /v1/chat/completions)
# ---------------------------------------------------------------------------
@app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
raw_body = await request.json()
hdrs = dict(request.headers)
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
)
# 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)
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
# 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 openai_chat_completions(request)
# ---------------------------------------------------------------------------
# /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,
}
# ---------------------------------------------------------------------------
# /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)
# ---------------------------------------------------------------------------
# /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: 260px; flex-shrink: 0; background: #1a1a2e; color: #ccc;
display: flex; flex-direction: column; gap: 0; 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: #666; 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 */
.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; }
/* Inputs */
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; }
/* Buttons */
.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; }
/* Dimension list */
.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; }
/* Node info */
#node-info { display: none; }
.ni-name { font-size: 1.05em; color: #fff; word-break: break-all; margin-bottom: 5px; }
.ni-meta { font-size: 0.75em; color: #666; margin-bottom: 6px; }
.dim-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.dbadge { padding: 2px 8px; border-radius: 10px; font-size: 0.72em; color: #fff; }
.nbr-label { font-size: 0.72em; color: #555; text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 4px; }
#nbr-list { list-style: none; max-height: 160px; overflow-y: auto; }
#nbr-list li { padding: 2px 0; font-size: 0.8em; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#nbr-list li:hover { color: #8888ff; }
#no-sel { font-size: 0.8em; color: #444; }
/* Graph area */
#graph-area { flex: 1; position: relative; overflow: hidden; background: #f9f9f9; }
#graph-svg { width: 100%; height: 100%; }
/* Loading */
#loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(249,249,249,0.85); 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 */
#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 controls */
#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(20,20,40,0.92);
color: #eee; padding: 7px 11px; border-radius: 5px; font-size: 0.78em;
display: none; z-index: 20; max-width: 200px; line-height: 1.5;
}
#tip strong { color: #fff; }
/* Legend strip at top of graph */
#legend {
position: absolute; top: 12px; left: 12px; display: flex; gap: 10px;
flex-wrap: wrap; z-index: 5;
}
.leg-item { display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.85);
padding: 3px 8px; border-radius: 12px; font-size: 0.72em; border: 1px solid #e0e0e0; cursor: pointer; }
.leg-item:hover { background: #fff; }
.leg-dot { width: 8px; height: 8px; border-radius: 50%; }
</style>
</head>
<body>
<!-- ── Sidebar ── -->
<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>
</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">1.0</span></label>
<input type="range" id="sal-slider" min="0" max="6" step="0.5" value="1.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>
<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">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="dim-badges" id="ni-dims"></div>
<div class="nbr-label">Connections</div>
<ul id="nbr-list"></ul>
</div>
</div>
</div>
<!-- ── Graph area ── -->
<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 reloading after the agent has run.</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>
// ─────────────────────────────────────────────────────────────────────────────
// Colour palette
// ─────────────────────────────────────────────────────────────────────────────
const PALETTE = [
'#4e79a7','#f28e2b','#59a14f','#e15759',
'#b07aa1','#76b7b2','#edc948','#ff9da7',
'#9c755f','#d4a0c7',
];
const dimColors = {};
['type','membership','runs-on','tech','owned-by','geography']
.forEach((d, i) => { dimColors[d] = PALETTE[i]; });
function dimColor(dim) {
if (!dimColors[dim]) {
dimColors[dim] = PALETTE[Object.keys(dimColors).length % PALETTE.length];
}
return dimColors[dim];
}
// ─────────────────────────────────────────────────────────────────────────────
// SVG + zoom
// ─────────────────────────────────────────────────────────────────────────────
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');
const linkLayer = root.append('g').attr('class', 'links');
const nodeLayer = root.append('g').attr('class', 'nodes');
const zoom = d3.zoom().scaleExtent([0.05, 12]).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.2).restart();
});
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
let graphData = null;
let sim = null;
function nodeR(d) { return 5 + Math.min(Math.log(d.saliency + 1) * 4, 14); }
// ─────────────────────────────────────────────────────────────────────────────
// Arrow markers (one per dimension, re-built when dim list changes)
// ─────────────────────────────────────────────────────────────────────────────
function buildMarkers(dims) {
defs.selectAll('marker').remove();
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', 18).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', dimColor(dim));
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Load data from server
// ─────────────────────────────────────────────────────────────────────────────
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" onclick="toggleDim('${dim}')" id="leg_${safe}">
<span class="leg-dot" style="background:${dimColor(dim)}"></span>${dim}
</span>`;
}).join('');
}
function toggleDim(dim) {
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
const cb = document.querySelector(`.dchk[value="${dim}"]`);
if (cb) { cb.checked = !cb.checked; renderWithFilters(); }
}
// ─────────────────────────────────────────────────────────────────────────────
// Filter and 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();
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();
// Fresh copies so D3 mutation doesn't corrupt graphData
const nd = nodes.map(n => ({ ...n }));
const ed = edges.map(e => ({ ...e }));
// ── Links ──
const link = linkLayer.selectAll('line').data(ed, e => `${e.source}:${e.target}:${e.dim}`);
link.exit().remove();
const linkMerge = link.enter().append('line').merge(link)
.attr('stroke', e => dimColor(e.dim))
.attr('stroke-opacity', 0.55)
.attr('stroke-width', e => e.is_isa ? 2 : 1.4)
.attr('marker-end', e => `url(#arr_${e.dim.replace(/[^a-zA-Z0-9]/g, '_')})`);
// ── 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))
.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', '#333');
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));
// Click on background clears selection
svg.on('click', () => clearSelection());
// ── Simulation ──
const charge = Math.max(-600, -150 - nd.length * 0.8);
sim = d3.forceSimulation(nd)
.force('link', d3.forceLink(ed).id(d => d.id).distance(80).strength(0.6))
.force('charge', d3.forceManyBody().strength(charge))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collide', d3.forceCollide().radius(d => nodeR(d) + 5))
.on('tick', () => {
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);
});
nodeMerge.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Node 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);
document.getElementById('ni-dims').innerHTML =
d.dims.map(dim => `<span class="dbadge" style="background:${dimColor(dim)}">${dim}</span>`).join('');
// Build neighbour list
const nbrs = edges.map(e => {
const sid = typeof e.source === 'object' ? e.source.id : e.source;
const tid = typeof e.target === 'object' ? e.target.id : e.target;
if (sid !== d.id && tid !== d.id) return null;
const nid = sid === d.id ? tid : sid;
const arrow = sid === d.id ? '\u2192' : '\u2190';
const nn = nodes.find(n => n.id === nid);
return { id: nid, token: nn ? nn.token : nid, dim: e.dim, arrow };
}).filter(Boolean);
document.getElementById('nbr-list').innerHTML = nbrs.map(n =>
`<li onclick="focusNode(${n.id})" style="color:${dimColor(n.dim)}">${n.arrow} ${n.token} <span style="color:#444;font-size:0.85em">[${n.dim}]</span></li>`
).join('');
// Dim/fade other nodes and edges
nodeLayer.selectAll('g.nd circle')
.attr('opacity', n => (n.id === d.id || nbrs.some(nb => nb.id === n.id)) ? 1 : 0.15);
nodeLayer.selectAll('g.nd text')
.attr('opacity', n => (n.id === d.id || nbrs.some(nb => nb.id === n.id)) ? 1 : 0.1);
linkLayer.selectAll('line').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.05;
});
}
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').attr('opacity', 0.55);
}
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, H/2 - d.y).scale(1.5)
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Zoom helpers
// ─────────────────────────────────────────────────────────────────────────────
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) {
tip.style.display = 'block';
tip.innerHTML = `<strong>${d.token}</strong><br>saliency: ${d.saliency}<br>${d.dims.join(', ')}`;
}
function moveTip(ev) { tip.style.left = (ev.pageX+14)+'px'; tip.style.top = (ev.pageY-10)+'px'; }
function hideTip() { tip.style.display = 'none'; }
// ─────────────────────────────────────────────────────────────────────────────
// Loading state
// ─────────────────────────────────────────────────────────────────────────────
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(); });
// ─────────────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────────────
loadGraph();
</script>
</body>
</html>"""
@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; }}
</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="/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>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; }}
}}
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"),
)