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