4970 lines
206 KiB
Python
4970 lines
206 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)
|
||
|
||
|
||
async def _feature_enabled(pool, key: str, default: bool = True) -> bool:
|
||
"""Read a feature flag from the DB config table. Truthy unless explicitly disabled."""
|
||
val = await get_config(pool, key, "true" if default else "false")
|
||
return val.strip().lower() not in ("false", "0", "off", "no", "disabled")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Model ctx_length cache — model_name → num_ctx (0 = no injection)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_model_ctx_cache: dict[str, int] = {}
|
||
|
||
|
||
async def _reload_model_ctx_cache(pool) -> None:
|
||
"""Reload the model name → ctx_length map from DB."""
|
||
async with pool.acquire() as conn:
|
||
rows = await conn.fetch("SELECT model_name, ctx_length FROM models WHERE ctx_length > 0")
|
||
_model_ctx_cache.clear()
|
||
for r in rows:
|
||
_model_ctx_cache[r["model_name"]] = r["ctx_length"]
|
||
log.info("model_ctx_cache reloaded entries=%d", len(_model_ctx_cache))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 _reload_model_ctx_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).
|
||
Injects num_ctx for Ollama if the model has a configured context length.
|
||
Returns (assistant_text, raw_response_dict).
|
||
"""
|
||
body = dict(body)
|
||
body["stream"] = False
|
||
model = body.get("model", "?")
|
||
|
||
# Inject num_ctx for Ollama if this model has a configured context length
|
||
ctx = _model_ctx_cache.get(model, 0)
|
||
if ctx > 0 and "num_ctx" not in body:
|
||
body["num_ctx"] = ctx
|
||
log.info("injecting num_ctx=%d for model=%s", ctx, 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 _human_prefix(text: str, max_chars: int = 1000) -> str:
|
||
"""
|
||
Extract the human-written prefix of a user message, stopping before any
|
||
agent-framework context injection (EXTRAS, tool results, structured sections).
|
||
|
||
Agent Zero (and similar frameworks) append structured context to the user
|
||
message after a blank line followed by a section marker. We only want the
|
||
user's actual words — the first paragraph before any such marker.
|
||
|
||
Markers we stop at:
|
||
\\n\\n[ — Agent Zero [EXTRAS], [context], [solutions], …
|
||
\\n\\n< — XML-wrapped context blocks
|
||
\\n--- — horizontal rule separators
|
||
\\n# or \\n## — markdown headings injected by the framework
|
||
"""
|
||
# Structural section delimiters injected by agent frameworks
|
||
for delim in ("\n\n[", "\n\n<", "\n---", "\n[EXTRAS]", "\n[context]"):
|
||
idx = text.find(delim)
|
||
if 0 < idx < max_chars:
|
||
return text[:idx].strip()
|
||
return text[:max_chars].strip()
|
||
|
||
|
||
def _is_internal_subcall(user_text: str, sys_text: str = "") -> bool:
|
||
"""
|
||
Heuristic: returns True if this looks like an agent-internal sub-call
|
||
(memory extraction, consolidation, keyword search) rather than a main
|
||
user-facing turn. We skip recollection injection for these.
|
||
|
||
Signals checked in order:
|
||
1. User message is a JSON/array literal → memory operation payload
|
||
2. System message is very short → utility prompt, not a full agent
|
||
personality (Agent Zero's main system prompt is typically >5 000 chars;
|
||
memory sub-call prompts are usually <1 500 chars)
|
||
3. System message contains memory-task keywords in the first 200 chars
|
||
"""
|
||
# Signal 1: JSON payload
|
||
if user_text.strip().startswith(("{", "[")):
|
||
return True
|
||
|
||
if sys_text:
|
||
# Signal 2: system prompt suspiciously short for a real agent call
|
||
if len(sys_text) < 1500:
|
||
return True
|
||
|
||
# Signal 3: task-oriented system message (memory ops, keyword extraction…)
|
||
sys_head = sys_text[:200].lower()
|
||
task_markers = (
|
||
"extract keyword", "consolidat", "search for similar",
|
||
"rate the similarity", "memorize", "memory content",
|
||
)
|
||
if any(m in sys_head for m in task_markers):
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def _last_user_message_text(body: dict, path: str) -> str:
|
||
"""
|
||
Extract only the human-written prefix of the last user message.
|
||
Agent Zero and similar frameworks append large context sections (EXTRAS,
|
||
solutions, tool results) after the user's actual words. We stop at the
|
||
first structural delimiter so that domain tokens in the injected context
|
||
don't spuriously trigger recollection.
|
||
"""
|
||
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:
|
||
raw = " ".join(_extract_text_strings(last_user.get("content", "")))
|
||
return _human_prefix(raw)
|
||
return ""
|
||
raw = body.get("prompt", "")
|
||
return _human_prefix(raw)
|
||
|
||
|
||
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)
|
||
if await _feature_enabled(pool, "feature_loop_detection"):
|
||
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.
|
||
"""
|
||
if not await _feature_enabled(pool, "feature_recollection"):
|
||
return body
|
||
|
||
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 — human-written prefix only (strips agent framework context).
|
||
user_text = _last_user_message_text(body, path)
|
||
if not user_text.strip():
|
||
return body
|
||
|
||
# Skip injection for internal agent sub-calls (memory extraction,
|
||
# consolidation, keyword search). These are detected by:
|
||
# - user message being a JSON payload, OR
|
||
# - system message being very short (utility prompt, not a full agent), OR
|
||
# - system message starting with memory-task keywords.
|
||
raw_sys_text = _system_message_text(body, path)
|
||
if _is_internal_subcall(user_text, raw_sys_text):
|
||
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()
|
||
if raw_sys_text:
|
||
for tok in tokenize(raw_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)
|
||
if await _feature_enabled(pool, "feature_loop_detection"):
|
||
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)
|
||
text, raw = await call_ollama("/api/generate", body, upstream)
|
||
if await _feature_enabled(pool, "feature_loop_detection"):
|
||
messages = [{"role": "user", "content": body.get("prompt", "")}]
|
||
sess = session_key(model, 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["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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /chat — gnommoweb integration endpoint
|
||
# Accepts a conversation turn from gnommoweb and returns an agent reply.
|
||
# Currently a stub: echoes back a placeholder until full agent routing is wired.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.post("/chat")
|
||
async def gnommoweb_chat(request: Request) -> dict:
|
||
"""
|
||
Entry point for gnommoweb agent chat.
|
||
|
||
Looks up the agent's framework config (endpoint_url + api_key) from the
|
||
agent_frameworks table, forwards the message to Agent Zero's /api/api_message
|
||
endpoint, and returns the response.
|
||
|
||
Expected body:
|
||
{
|
||
"agent_id": <int>,
|
||
"conversation_id": <int|null>,
|
||
"context_id": <str|null>, # Agent Zero context id — pass back on subsequent turns
|
||
"user_id": <int>,
|
||
"message": <str>,
|
||
"history": [{"role": "user"|"assistant", "content": <str>}]
|
||
}
|
||
|
||
Returns:
|
||
{
|
||
"message": <str>,
|
||
"pose": <str>,
|
||
"context_id": <str|null>
|
||
}
|
||
"""
|
||
data = await request.json()
|
||
agent_id = data.get("agent_id")
|
||
conversation_id = data.get("conversation_id")
|
||
context_id = data.get("context_id") or ""
|
||
user_id = data.get("user_id")
|
||
message = data.get("message", "")
|
||
|
||
log.info(
|
||
"gnommoweb_chat agent_id=%s conv=%s user=%s msg_len=%d ctx=%s",
|
||
agent_id, conversation_id, user_id, len(message), context_id or "(new)",
|
||
)
|
||
|
||
pool = request.app.state.pool
|
||
|
||
# Look up agent framework config
|
||
async with pool.acquire() as conn:
|
||
row = await conn.fetchrow(
|
||
"SELECT endpoint_url, api_key, label FROM agent_frameworks WHERE agent_id = $1",
|
||
agent_id,
|
||
)
|
||
|
||
if not row:
|
||
log.warning("gnommoweb_chat no framework configured for agent_id=%s", agent_id)
|
||
return {
|
||
"message": f"[festinger] No Agent Zero endpoint configured for agent_id={agent_id}. Add it in the Festinger admin under Agent Frameworks.",
|
||
"pose": "neutral",
|
||
"context_id": context_id or None,
|
||
}
|
||
|
||
url = row["endpoint_url"].rstrip("/") + "/api/api_message"
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"X-API-KEY": row["api_key"],
|
||
}
|
||
body = {
|
||
"message": message,
|
||
"context_id": context_id,
|
||
}
|
||
|
||
log.info("gnommoweb_chat forwarding to %s (agent=%s label=%s)", url, agent_id, row["label"])
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||
r = await client.post(url, json=body, headers=headers)
|
||
if not r.is_success:
|
||
log.error("gnommoweb_chat agent_zero error %d: %s", r.status_code, r.text[:200])
|
||
return {
|
||
"message": f"[festinger] Agent Zero returned HTTP {r.status_code}: {r.text[:200]}",
|
||
"pose": "neutral",
|
||
"context_id": context_id or None,
|
||
}
|
||
resp = r.json()
|
||
new_context_id = resp.get("context_id") or context_id or None
|
||
reply = resp.get("response", "")
|
||
log.info("gnommoweb_chat reply len=%d new_ctx=%s", len(reply), new_context_id)
|
||
return {
|
||
"message": reply,
|
||
"pose": "neutral",
|
||
"context_id": new_context_id,
|
||
}
|
||
except httpx.TimeoutException:
|
||
log.error("gnommoweb_chat timeout forwarding to %s", url)
|
||
return {"message": "[festinger] Agent Zero timed out.", "pose": "neutral", "context_id": context_id or None}
|
||
except Exception as exc:
|
||
log.error("gnommoweb_chat error forwarding to %s: %s", url, exc)
|
||
return {"message": f"[festinger] Error: {exc}", "pose": "neutral", "context_id": context_id or None}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /agent-frameworks — per-agent Agent Zero endpoint config
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/agent-frameworks")
|
||
async def list_agent_frameworks(request: Request) -> dict:
|
||
pool = request.app.state.pool
|
||
async with pool.acquire() as conn:
|
||
rows = await conn.fetch(
|
||
"SELECT agent_id, endpoint_url, api_key, label, updated_at FROM agent_frameworks ORDER BY agent_id"
|
||
)
|
||
return {"agent_frameworks": [
|
||
{
|
||
"agent_id": r["agent_id"],
|
||
"endpoint_url": r["endpoint_url"],
|
||
"api_key": r["api_key"],
|
||
"label": r["label"],
|
||
"updated_at": r["updated_at"].isoformat(),
|
||
}
|
||
for r in rows
|
||
]}
|
||
|
||
|
||
@app.put("/agent-frameworks/{agent_id}")
|
||
async def upsert_agent_framework(agent_id: int, request: Request) -> dict:
|
||
pool = request.app.state.pool
|
||
data = await request.json()
|
||
endpoint_url = (data.get("endpoint_url") or "").strip()
|
||
api_key = (data.get("api_key") or "").strip()
|
||
label = (data.get("label") or "").strip()
|
||
if not endpoint_url:
|
||
return {"error": "endpoint_url is required"}
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(
|
||
"""
|
||
INSERT INTO agent_frameworks (agent_id, endpoint_url, api_key, label, updated_at)
|
||
VALUES ($1, $2, $3, $4, now())
|
||
ON CONFLICT (agent_id) DO UPDATE
|
||
SET endpoint_url = $2, api_key = $3, label = $4, updated_at = now()
|
||
""",
|
||
agent_id, endpoint_url, api_key, label,
|
||
)
|
||
log.info("agent_framework upserted agent_id=%d url=%s label=%s", agent_id, endpoint_url, label)
|
||
return {"status": "ok", "agent_id": agent_id}
|
||
|
||
|
||
@app.delete("/agent-frameworks/{agent_id}")
|
||
async def delete_agent_framework(agent_id: int, request: Request) -> dict:
|
||
pool = request.app.state.pool
|
||
async with pool.acquire() as conn:
|
||
result = await conn.execute("DELETE FROM agent_frameworks WHERE agent_id=$1", agent_id)
|
||
deleted = int(result.split()[-1]) if result else 0
|
||
if not deleted:
|
||
return {"error": f"agent_id {agent_id} not found"}
|
||
log.info("agent_framework deleted agent_id=%d", agent_id)
|
||
return {"status": "ok", "deleted": agent_id}
|
||
|
||
|
||
@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)
|
||
text, raw = await call_anthropic(body, upstream, headers)
|
||
if await _feature_enabled(pool, "feature_loop_detection"):
|
||
messages = body.get("messages", [])
|
||
sess = session_key(model, 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["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)
|
||
text, raw = await call_openai(body, upstream, headers)
|
||
if await _feature_enabled(pool, "feature_loop_detection"):
|
||
messages = body.get("messages", [])
|
||
sess = session_key(model, 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:
|
||
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())
|
||
|
||
|
||
@app.get("/{agent_id}/v1/models")
|
||
async def models_with_agent_id(agent_id: str, request: Request) -> Response:
|
||
"""
|
||
Model discovery for agent-prefixed base URLs.
|
||
|
||
Clients (LiteLLM, OpenAI SDK) that use base_url=http://festinger/{agent_id}/v1
|
||
will call GET /{agent_id}/v1/models to discover available models.
|
||
Strip the agent prefix and proxy to the agent's configured upstream.
|
||
"""
|
||
pool = request.app.state.pool
|
||
cfg = request.app.state.yaml_config
|
||
name = agent_id.lower()
|
||
|
||
agent_model = await _get_agent_routing_model(pool, name)
|
||
if agent_model and agent_model.base_url:
|
||
raw_base = agent_model.base_url.rstrip("/")
|
||
if raw_base.endswith("/v1"):
|
||
raw_base = raw_base[:-3]
|
||
upstream_url = f"{raw_base}/v1/models"
|
||
else:
|
||
# Fall back to configured OpenAI upstream
|
||
raw_base = cfg.get("upstream_openai", "").rstrip("/")
|
||
if raw_base.endswith("/v1"):
|
||
raw_base = raw_base[:-3]
|
||
upstream_url = f"{raw_base}/v1/models"
|
||
|
||
log.info("models_discovery agent=%s → %s", name, upstream_url)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
r = await client.get(upstream_url)
|
||
return Response(content=r.content, status_code=r.status_code,
|
||
media_type=r.headers.get("content-type", "application/json"))
|
||
except httpx.RequestError as exc:
|
||
log.error("models_discovery_error agent=%s url=%s %s", name, upstream_url, exc)
|
||
return Response(
|
||
content='{"object":"list","data":[]}',
|
||
status_code=200,
|
||
media_type="application/json",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /scan — gutask integration: scan task / letter text and return recollection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Saliency encounter weight per context type.
|
||
# Facts stated in tasks are stronger signals than chat overheard in passing.
|
||
_CONTEXT_WEIGHT: dict[str, float] = {
|
||
"task": 2.0,
|
||
"letter": 1.5,
|
||
"chat": 1.0,
|
||
}
|
||
|
||
|
||
@app.post("/scan")
|
||
async def scan_text(request: Request) -> dict:
|
||
"""
|
||
Scan a block of plain text for domain concepts and return a recollection block.
|
||
|
||
Called by gutask when an agent reads a task or a letter, so that the agent
|
||
sees relevant knowledge-graph context alongside the task/letter content.
|
||
|
||
Body:
|
||
text (str) — the task description, letter body, or any free text
|
||
agent (str) — agent name (for logging; optional)
|
||
context (str) — "task" | "letter" | "chat" (default: "task")
|
||
|
||
Returns:
|
||
recollection_block (str | null) — the <recollection>…</recollection> block,
|
||
or null if nothing salient was found
|
||
salient_tokens (list[str]) — concepts that triggered the block
|
||
cues_found (int) — number of URD cues extracted from the text
|
||
"""
|
||
pool = request.app.state.pool
|
||
data = await request.json()
|
||
|
||
text: str = data.get("text", "").strip()
|
||
agent_name: str = data.get("agent", "").strip().lower()
|
||
context_type: str = data.get("context", "task").strip().lower()
|
||
weight: float = _CONTEXT_WEIGHT.get(context_type, 1.0)
|
||
|
||
if not text:
|
||
return {"recollection_block": None, "salient_tokens": [], "cues_found": 0}
|
||
|
||
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"))
|
||
|
||
# 1. Cue scanner — extract explicit relationship assertions and enqueue them.
|
||
cues = list(scan_cues(text))
|
||
cues_found = len(cues)
|
||
for cue in cues:
|
||
await enqueue_cue(cue)
|
||
|
||
# 2. Token loop — find salient concepts and record weighted encounters.
|
||
tokens = tokenize(text)
|
||
salient_ids: list[int] = []
|
||
|
||
for token in tokens:
|
||
row = cache.soas_by_token.get(token)
|
||
if row is None or row.saliency == 0.0:
|
||
continue
|
||
# Weight the encounter: task/letter mentions count more than chat.
|
||
# We stage fractional deltas; flush_encounter_deltas rounds to int,
|
||
# so accumulate weight as repeated single increments for simplicity.
|
||
increments = max(1, round(weight))
|
||
for _ in range(increments):
|
||
cache.record_encounter(row.id)
|
||
if row.saliency >= read_threshold:
|
||
salient_ids.append(row.id)
|
||
|
||
if not salient_ids:
|
||
log.debug("scan | agent=%s context=%s cues=%d → no salient concepts",
|
||
agent_name or "(none)", context_type, cues_found)
|
||
return {"recollection_block": None, "salient_tokens": [], "cues_found": cues_found}
|
||
|
||
# 3. Build recollection block (session boost not applicable here — no system message).
|
||
block = build_recollection_block(salient_ids, conf_floor, recency_days)
|
||
|
||
salient_tokens = [cache.soas_by_id.get(cid, str(cid)) for cid in salient_ids]
|
||
log.info(
|
||
"scan | agent=%s context=%s cues=%d salient=%s\n%s",
|
||
agent_name or "(none)", context_type, cues_found, salient_tokens,
|
||
block or "(no block)",
|
||
)
|
||
|
||
return {
|
||
"recollection_block": block,
|
||
"salient_tokens": salient_tokens,
|
||
"cues_found": cues_found,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /recall/{concept} — gutask recall backend
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/recall/{concept:path}")
|
||
async def recall_concept(
|
||
concept: str,
|
||
request: Request,
|
||
depth: str = "brief",
|
||
) -> dict:
|
||
"""
|
||
Return what Festinger knows about a concept, formatted for agent display.
|
||
|
||
depth:
|
||
brief — URD edges only (same as recollection block, more readable)
|
||
detailed — edges + saliency stats + related concepts in same dimensions
|
||
everything — detailed + full write log history
|
||
"""
|
||
pool = request.app.state.pool
|
||
concept = concept.lower().strip()
|
||
|
||
row = cache.soas_by_token.get(concept)
|
||
if row is None:
|
||
# Try DB in case cache is stale
|
||
async with pool.acquire() as conn:
|
||
db_row = await conn.fetchrow(
|
||
"SELECT id, token, saliency, novelty, encounter_count, "
|
||
"first_seen_context, last_seen FROM soas WHERE token = $1",
|
||
concept,
|
||
)
|
||
if not db_row:
|
||
return {"concept": concept, "found": False, "text": f"No knowledge about '{concept}' in Festinger."}
|
||
from .cache import SoasRow
|
||
row = SoasRow(
|
||
id=db_row["id"], token=db_row["token"],
|
||
saliency=db_row["saliency"], novelty=db_row["novelty"],
|
||
encounter_count=db_row["encounter_count"],
|
||
first_seen_context=db_row["first_seen_context"] or "",
|
||
last_seen=db_row["last_seen"],
|
||
)
|
||
|
||
edges = cache.urd_by_concept.get(row.id, [])
|
||
reverse_edges = cache.urd_by_parent.get(row.id, [])
|
||
|
||
lines = [f"── {concept} {'─' * max(0, 50 - len(concept))}"]
|
||
|
||
if depth in ("detailed", "everything"):
|
||
from .recollection import recency_decay, centrality_bonus, effective_score
|
||
decay = recency_decay(row)
|
||
score = effective_score(row.id)
|
||
last_seen_str = (
|
||
row.last_seen.strftime("%Y-%m-%d") if row.last_seen else "never"
|
||
)
|
||
lines.append(f" saliency: {row.saliency:.2f} encounters: {row.encounter_count}"
|
||
f" score: {score:.2f} last seen: {last_seen_str}")
|
||
if row.first_seen_context:
|
||
lines.append(f" first seen: \"{row.first_seen_context[:80]}\"")
|
||
if row.id in cache.pending_conflicts:
|
||
lines.append(" ⚠ has pending conflict in resolution queue")
|
||
lines.append("")
|
||
|
||
if edges:
|
||
lines.append(" Relationships (outgoing):")
|
||
for e in edges:
|
||
conf_str = f" conf={e.confidence:.2f}" if e.confidence < 1.0 else ""
|
||
lines.append(f" [{e.dim_token}] → {e.parent_token}{conf_str}")
|
||
else:
|
||
lines.append(" No outgoing relationships stored.")
|
||
|
||
if reverse_edges:
|
||
lines.append("")
|
||
lines.append(" Referenced by:")
|
||
for e in reverse_edges[:10]:
|
||
child_token = cache.soas_by_id.get(e.concept_id, str(e.concept_id))
|
||
lines.append(f" [{e.dim_token}] ← {child_token}")
|
||
if len(reverse_edges) > 10:
|
||
lines.append(f" … and {len(reverse_edges) - 10} more")
|
||
|
||
if depth in ("detailed", "everything") and edges:
|
||
# Siblings: other concepts sharing the same parent in the same dimension
|
||
siblings: dict[str, list[str]] = {}
|
||
for e in edges:
|
||
peer_edges = cache.urd_by_parent.get(e.parent_id, [])
|
||
peers = [
|
||
cache.soas_by_id.get(pe.concept_id, str(pe.concept_id))
|
||
for pe in peer_edges
|
||
if pe.concept_id != row.id and pe.dim_id == e.dim_id
|
||
][:5]
|
||
if peers:
|
||
siblings[f"{e.dim_token}/{e.parent_token}"] = peers
|
||
if siblings:
|
||
lines.append("")
|
||
lines.append(" Siblings (same parent/dimension):")
|
||
for label, peers in siblings.items():
|
||
lines.append(f" {label}: {', '.join(peers)}")
|
||
|
||
if depth == "everything":
|
||
async with pool.acquire() as conn:
|
||
log_rows = await conn.fetch(
|
||
"""
|
||
SELECT op, parent_token, dim_token, is_isa, source, created_at
|
||
FROM kg_write_log WHERE concept_id = $1
|
||
ORDER BY created_at DESC LIMIT 20
|
||
""",
|
||
row.id,
|
||
)
|
||
if log_rows:
|
||
lines.append("")
|
||
lines.append(" Write history:")
|
||
for lr in log_rows:
|
||
ts = lr["created_at"].strftime("%Y-%m-%d")
|
||
isa = "is-a" if lr["is_isa"] else "is-part-of"
|
||
lines.append(
|
||
f" {ts} {lr['op']:<10} [{lr['dim_token']}] {isa} {lr['parent_token']}"
|
||
f" ({lr['source']})"
|
||
)
|
||
|
||
text = "\n".join(lines)
|
||
return {
|
||
"concept": concept,
|
||
"found": True,
|
||
"depth": depth,
|
||
"saliency": row.saliency,
|
||
"encounter_count": row.encounter_count,
|
||
"edges": [
|
||
{"dim": e.dim_token, "parent": e.parent_token,
|
||
"is_isa": e.is_isa, "confidence": e.confidence}
|
||
for e in edges
|
||
],
|
||
"text": text,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /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, ctx_length, 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 "", "ctx_length": r["ctx_length"],
|
||
"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()
|
||
ctx_length = int(data.get("ctx_length") or 0)
|
||
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, ctx_length) VALUES ($1,$2,$3,$4,$5) RETURNING id",
|
||
provider, model_name, api_key, base_url, ctx_length,
|
||
)
|
||
await _reload_model_ctx_cache(pool)
|
||
log.info("model created id=%d provider=%s model=%s ctx_length=%d", row["id"], provider, model_name, ctx_length)
|
||
return {"status": "ok", "id": row["id"]}
|
||
|
||
|
||
@app.put("/models/{model_id}")
|
||
async def update_model(model_id: int, request: Request) -> dict:
|
||
"""Update an existing model's ctx_length (and optionally other fields)."""
|
||
pool = request.app.state.pool
|
||
data = await request.json()
|
||
ctx_length = int(data.get("ctx_length") or 0)
|
||
async with pool.acquire() as conn:
|
||
result = await conn.execute(
|
||
"UPDATE models SET ctx_length=$1 WHERE id=$2",
|
||
ctx_length, model_id,
|
||
)
|
||
updated = int(result.split()[-1]) if result else 0
|
||
if not updated:
|
||
return {"error": f"model {model_id} not found"}
|
||
await _reload_model_ctx_cache(pool)
|
||
log.info("model updated id=%d ctx_length=%d", model_id, ctx_length)
|
||
return {"status": "ok", "id": model_id, "ctx_length": ctx_length}
|
||
|
||
|
||
@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"}
|
||
await _reload_model_ctx_cache(pool)
|
||
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(
|
||
"""INSERT INTO config (key, value, updated_at)
|
||
VALUES ($1, $2, now())
|
||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()""",
|
||
key, value,
|
||
)
|
||
log.info("config updated key=%s value=%s", key, value)
|
||
return {"status": "ok", "key": key, "value": value}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /test-chat — diagnostic: send a single message to upstream_openai and return
|
||
# the raw response + timing so the admin can verify the LLM connection works.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/test-chat/models")
|
||
async def test_chat_models(request: Request) -> dict:
|
||
"""Return the list of lm-studio/openai models configured in the DB."""
|
||
pool = request.app.state.pool
|
||
cfg = request.app.state.yaml_config
|
||
upstream = cfg.get("upstream_openai", "")
|
||
async with pool.acquire() as conn:
|
||
rows = await conn.fetch(
|
||
"SELECT model_name, base_url FROM models WHERE provider IN ('lm-studio', 'openai') ORDER BY id"
|
||
)
|
||
if rows:
|
||
models = [r["model_name"] for r in rows]
|
||
return {"ok": True, "models": models, "upstream": upstream}
|
||
return {"ok": False, "error": "No lm-studio or openai models configured — add one via Model Manager.", "models": []}
|
||
|
||
|
||
@app.post("/test-chat")
|
||
async def test_chat(request: Request) -> dict:
|
||
cfg = request.app.state.yaml_config
|
||
pool = request.app.state.pool
|
||
data = await request.json()
|
||
message = (data.get("message") or "Say hello in one sentence.").strip()
|
||
model = (data.get("model") or "").strip()
|
||
upstream = cfg.get("upstream_openai", "")
|
||
|
||
# Load configured models from DB so the UI can refresh the dropdown
|
||
async with pool.acquire() as conn:
|
||
db_rows = await conn.fetch(
|
||
"SELECT model_name, base_url FROM models WHERE provider IN ('lm-studio', 'openai') ORDER BY id"
|
||
)
|
||
available = [r["model_name"] for r in db_rows]
|
||
db_model_map = {r["model_name"]: r["base_url"] or "" for r in db_rows}
|
||
|
||
if not model:
|
||
if not available:
|
||
return {"ok": False, "error": "No lm-studio/openai models configured. Add one via Model Manager first.", "available": []}
|
||
model = available[0]
|
||
|
||
# Resolve upstream: prefer the model's own base_url from DB, fall back to upstream_openai config
|
||
raw_base = db_model_map.get(model, "") or upstream
|
||
if not raw_base:
|
||
return {"ok": False, "error": "upstream_openai not configured and model has no base_url", "available": available}
|
||
# Strip /v1 suffix — we append /v1/chat/completions ourselves
|
||
effective_upstream = raw_base.rstrip("/")
|
||
if effective_upstream.endswith("/v1"):
|
||
effective_upstream = effective_upstream[:-3]
|
||
|
||
url = f"{effective_upstream}/v1/chat/completions"
|
||
body = {
|
||
"model": model,
|
||
"stream": False,
|
||
"messages": [{"role": "user", "content": message}],
|
||
}
|
||
|
||
t0 = time.perf_counter()
|
||
try:
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
r = await client.post(url, json=body)
|
||
ms = int((time.perf_counter() - t0) * 1000)
|
||
if not r.is_success:
|
||
return {"ok": False, "error": f"HTTP {r.status_code}: {r.text[:400]}", "url": url, "model": model, "ms": ms, "available": available}
|
||
resp = r.json()
|
||
choices = resp.get("choices") or []
|
||
reply = choices[0].get("message", {}).get("content", "(empty)") if choices else "(empty response)"
|
||
return {"ok": True, "reply": reply, "model": model, "url": url, "ms": ms, "available": available}
|
||
except httpx.ConnectError as exc:
|
||
ms = int((time.perf_counter() - t0) * 1000)
|
||
return {"ok": False, "error": f"Connection refused at {effective_upstream} ({exc})", "url": url, "ms": ms, "available": available}
|
||
except httpx.TimeoutException:
|
||
ms = int((time.perf_counter() - t0) * 1000)
|
||
return {"ok": False, "error": f"Request timed out after {ms}ms", "url": url, "ms": ms, "available": available}
|
||
except Exception as exc:
|
||
ms = int((time.perf_counter() - t0) * 1000)
|
||
return {"ok": False, "error": str(exc), "url": url, "ms": ms, "available": available}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _join_upstream_url(base_url: str, probe_path: str) -> str:
|
||
"""Join an upstream base URL and probe path without producing /v1/v1."""
|
||
base = (base_url or "").rstrip("/")
|
||
path = probe_path if probe_path.startswith("/") else f"/{probe_path}"
|
||
if path.startswith("/v1/") and base.endswith("/v1"):
|
||
path = path[3:]
|
||
return f"{base}{path}"
|
||
|
||
|
||
def _extract_upstream_models(payload: dict) -> list[str]:
|
||
"""Extract model names from OpenAI-compatible (/v1/models) or Ollama (/api/tags) payloads."""
|
||
models: list[str] = []
|
||
if isinstance(payload.get("data"), list):
|
||
for item in payload["data"]:
|
||
if isinstance(item, dict):
|
||
model_id = item.get("id") or item.get("name")
|
||
if model_id:
|
||
models.append(str(model_id))
|
||
if isinstance(payload.get("models"), list):
|
||
for item in payload["models"]:
|
||
if isinstance(item, dict):
|
||
model_id = item.get("name") or item.get("model") or item.get("id")
|
||
if model_id:
|
||
models.append(str(model_id))
|
||
return models
|
||
|
||
|
||
async def _probe_upstream_connection(name: str, base_url: str, probe_path: str) -> dict:
|
||
"""
|
||
Probe a local inference upstream and return a state-machine-friendly status block.
|
||
|
||
gnommoweb can poll /health and display status_messages without needing to know
|
||
whether Festinger is configured for LM Studio's OpenAI-compatible API or
|
||
Ollama-compatible endpoints.
|
||
"""
|
||
url = _join_upstream_url(base_url, probe_path)
|
||
started = time.perf_counter()
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
response = await client.get(url)
|
||
latency_ms = round((time.perf_counter() - started) * 1000)
|
||
if response.is_success:
|
||
try:
|
||
payload = response.json()
|
||
except ValueError:
|
||
payload = {}
|
||
models = _extract_upstream_models(payload) if isinstance(payload, dict) else []
|
||
detail = f": {len(models)} model(s) available" if models else ""
|
||
return {
|
||
"name": name,
|
||
"ok": True,
|
||
"status": "ok",
|
||
"url": url,
|
||
"status_code": response.status_code,
|
||
"latency_ms": latency_ms,
|
||
"models": models,
|
||
"status_message": f"{name} reachable{detail}",
|
||
}
|
||
body = (response.text or "").strip().replace("\n", " ")[:200]
|
||
suffix = f" — {body}" if body else ""
|
||
return {
|
||
"name": name,
|
||
"ok": False,
|
||
"status": "error",
|
||
"url": url,
|
||
"status_code": response.status_code,
|
||
"latency_ms": latency_ms,
|
||
"models": [],
|
||
"status_message": f"{name} returned HTTP {response.status_code}{suffix}",
|
||
}
|
||
except httpx.RequestError as exc:
|
||
latency_ms = round((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"name": name,
|
||
"ok": False,
|
||
"status": "unreachable",
|
||
"url": url,
|
||
"status_code": None,
|
||
"latency_ms": latency_ms,
|
||
"models": [],
|
||
"status_message": f"{name} unreachable: {type(exc).__name__}: {exc}",
|
||
}
|
||
|
||
|
||
@app.get("/health")
|
||
async def health(request: Request) -> dict:
|
||
cfg = request.app.state.yaml_config
|
||
lm_studio = await _probe_upstream_connection(
|
||
"lm_studio",
|
||
cfg.get("upstream_openai", ""),
|
||
"/v1/models",
|
||
)
|
||
ollama = await _probe_upstream_connection(
|
||
"ollama",
|
||
cfg.get("upstream_ollama", ""),
|
||
"/api/tags",
|
||
)
|
||
connections = {"lm_studio": lm_studio, "ollama": ollama}
|
||
status = "ok" if any(conn["ok"] for conn in connections.values()) else "error"
|
||
return {
|
||
"status": status,
|
||
"upstream": cfg["upstream_ollama"],
|
||
"upstream_openai": cfg.get("upstream_openai"),
|
||
"connections": connections,
|
||
"status_messages": [conn["status_message"] for conn in connections.values()],
|
||
"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>Agent Frameworks</h2>
|
||
<p style="font-size:0.83em;color:#666;margin-bottom:0.8em">
|
||
Map each gnommoweb <code>agent_id</code> to an Agent Zero endpoint. Festinger forwards
|
||
<code>POST /chat</code> requests here. The API key is Agent Zero's <code>mcp_server_token</code>
|
||
(derived from runtime_id + AUTH_LOGIN + AUTH_PASSWORD — stable across restarts).
|
||
</p>
|
||
<table id="af-table" style="width:100%;border-collapse:collapse;font-size:0.85em;margin-bottom:0.8em">
|
||
<thead>
|
||
<tr style="text-align:left;border-bottom:1px solid #ccc">
|
||
<th style="padding:4px 8px">Agent ID</th>
|
||
<th style="padding:4px 8px">Label</th>
|
||
<th style="padding:4px 8px">Endpoint URL</th>
|
||
<th style="padding:4px 8px">API Key</th>
|
||
<th style="padding:4px 8px"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="af-rows"><tr><td colspan="5" style="padding:6px 8px;color:#999">Loading…</td></tr></tbody>
|
||
</table>
|
||
<details style="margin-bottom:1.5em">
|
||
<summary style="cursor:pointer;font-size:0.85em;color:#444;user-select:none">Add / edit framework</summary>
|
||
<div style="display:grid;grid-template-columns:80px 1fr 1fr 1fr auto;gap:6px;margin-top:0.6em;align-items:end">
|
||
<div>
|
||
<label style="font-size:0.8em;color:#555">Agent ID</label>
|
||
<input id="af-agent-id" type="number" min="1" style="width:100%;padding:4px 6px;font-family:monospace;border:1px solid #ccc;border-radius:3px">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:0.8em;color:#555">Label (e.g. gunnar)</label>
|
||
<input id="af-label" type="text" style="width:100%;padding:4px 6px;font-family:monospace;border:1px solid #ccc;border-radius:3px" placeholder="gunnar">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:0.8em;color:#555">Endpoint URL</label>
|
||
<input id="af-url" type="text" style="width:100%;padding:4px 6px;font-family:monospace;border:1px solid #ccc;border-radius:3px" placeholder="http://gunnar:80">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:0.8em;color:#555">API Key (mcp_server_token)</label>
|
||
<input id="af-key" type="text" style="width:100%;padding:4px 6px;font-family:monospace;border:1px solid #ccc;border-radius:3px">
|
||
</div>
|
||
<button onclick="saveFramework(this)" class="primary" style="height:30px;white-space:nowrap;align-self:end">Save</button>
|
||
</div>
|
||
</details>
|
||
<div id="af-status" style="font-size:0.8em;color:#666;margin-bottom:1.5em"></div>
|
||
|
||
<h2>Pipeline features</h2>
|
||
<p style="font-size:0.83em;color:#666;margin-bottom:0.8em">
|
||
Toggle enrichment steps on/off without restarting. Changes take effect immediately.
|
||
</p>
|
||
<div style="display:flex;flex-direction:column;gap:0.7em;margin-bottom:1em" id="features-section">
|
||
<label style="display:flex;align-items:center;gap:0.6em;cursor:pointer;font-size:0.9em">
|
||
<input type="checkbox" id="feat-recollection" onchange="setFeature('feature_recollection', this.checked)" style="width:16px;height:16px;cursor:pointer">
|
||
<span><b>Recollection</b> — inject knowledge-graph context into prompts</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:0.6em;cursor:pointer;font-size:0.9em">
|
||
<input type="checkbox" id="feat-loop-detection" onchange="setFeature('feature_loop_detection', this.checked)" style="width:16px;height:16px;cursor:pointer">
|
||
<span><b>Loop detection</b> — retry with mitigations on repeated identical responses</span>
|
||
</label>
|
||
</div>
|
||
<div id="features-status" style="font-size:0.8em;color:#666;margin-bottom:1.5em"></div>
|
||
|
||
<h2>Test Chat</h2>
|
||
<p style="font-size:0.83em;color:#666;margin-bottom:0.8em">
|
||
Send a test message via a configured model (lm-studio / openai) to verify the connection end-to-end.
|
||
</p>
|
||
<div id="tc-models-row" style="font-size:0.82em;color:#555;margin-bottom:0.6em">
|
||
<span id="tc-models-label">Loading available models…</span>
|
||
</div>
|
||
<div style="display:flex;gap:0.6em;align-items:flex-end;flex-wrap:wrap;margin-bottom:0.6em">
|
||
<select id="tc-model" style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;min-width:220px">
|
||
<option value="">— loading —</option>
|
||
</select>
|
||
<input id="tc-msg" type="text" value="Say hello in one sentence." style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;flex:1;min-width:200px">
|
||
<button onclick="sendTestChat(this)" class="primary" style="height:32px;white-space:nowrap">Send</button>
|
||
</div>
|
||
<div id="tc-result" style="font-size:0.85em;margin-bottom:1.5em"></div>
|
||
|
||
<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';
|
||
}}
|
||
|
||
async function loadAgentFrameworks() {{
|
||
const r = await fetch('/agent-frameworks');
|
||
const d = await r.json();
|
||
const tbody = document.getElementById('af-rows');
|
||
if (!d.agent_frameworks || !d.agent_frameworks.length) {{
|
||
tbody.innerHTML = '<tr><td colspan="5" style="padding:6px 8px;color:#999">No frameworks configured yet.</td></tr>';
|
||
return;
|
||
}}
|
||
tbody.innerHTML = d.agent_frameworks.map(f => `
|
||
<tr style="border-bottom:1px solid #eee">
|
||
<td style="padding:4px 8px;font-family:monospace">${{f.agent_id}}</td>
|
||
<td style="padding:4px 8px">${{f.label || '—'}}</td>
|
||
<td style="padding:4px 8px;font-family:monospace">${{f.endpoint_url}}</td>
|
||
<td style="padding:4px 8px;font-family:monospace">${{f.api_key ? f.api_key.slice(0,8) + '…' : '—'}}</td>
|
||
<td style="padding:4px 8px">
|
||
<button onclick="editFramework(${{f.agent_id}}, '${{f.label}}', '${{f.endpoint_url}}', '${{f.api_key}}')" style="font-size:0.8em;padding:2px 6px">Edit</button>
|
||
<button onclick="deleteFramework(${{f.agent_id}}, this)" style="font-size:0.8em;padding:2px 6px;margin-left:4px;color:#c00">Delete</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}}
|
||
|
||
function editFramework(agentId, label, url, key) {{
|
||
document.getElementById('af-agent-id').value = agentId;
|
||
document.getElementById('af-label').value = label;
|
||
document.getElementById('af-url').value = url;
|
||
document.getElementById('af-key').value = key;
|
||
}}
|
||
|
||
async function saveFramework(btn) {{
|
||
const agentId = parseInt(document.getElementById('af-agent-id').value);
|
||
const label = document.getElementById('af-label').value.trim();
|
||
const url = document.getElementById('af-url').value.trim();
|
||
const key = document.getElementById('af-key').value.trim();
|
||
if (!agentId || !url) {{
|
||
document.getElementById('af-status').textContent = 'Agent ID and Endpoint URL are required.';
|
||
return;
|
||
}}
|
||
btn.disabled = true;
|
||
document.getElementById('af-status').textContent = 'Saving…';
|
||
try {{
|
||
const r = await fetch(`/agent-frameworks/${{agentId}}`, {{
|
||
method: 'PUT',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify({{endpoint_url: url, api_key: key, label}})
|
||
}});
|
||
const d = await r.json();
|
||
document.getElementById('af-status').textContent = d.error ? 'Error: ' + d.error : 'Saved.';
|
||
await loadAgentFrameworks();
|
||
}} catch(e) {{
|
||
document.getElementById('af-status').textContent = 'Error: ' + e.message;
|
||
}} finally {{
|
||
btn.disabled = false;
|
||
}}
|
||
}}
|
||
|
||
async function deleteFramework(agentId, btn) {{
|
||
if (!confirm(`Delete framework for agent_id=${{agentId}}?`)) return;
|
||
btn.disabled = true;
|
||
try {{
|
||
const r = await fetch(`/agent-frameworks/${{agentId}}`, {{method: 'DELETE'}});
|
||
const d = await r.json();
|
||
document.getElementById('af-status').textContent = d.error ? 'Error: ' + d.error : `Deleted agent_id=${{agentId}}.`;
|
||
await loadAgentFrameworks();
|
||
}} catch(e) {{
|
||
document.getElementById('af-status').textContent = 'Error: ' + e.message;
|
||
}} finally {{
|
||
btn.disabled = false;
|
||
}}
|
||
}}
|
||
|
||
async function loadFeatures() {{
|
||
const r = await fetch('/config');
|
||
const cfg = (await r.json()).config;
|
||
const isEnabled = key => (cfg[key] || 'true').trim().toLowerCase() !== 'false'
|
||
&& (cfg[key] || 'true').trim().toLowerCase() !== '0'
|
||
&& (cfg[key] || 'true').trim().toLowerCase() !== 'off'
|
||
&& (cfg[key] || 'true').trim().toLowerCase() !== 'no';
|
||
document.getElementById('feat-recollection').checked = isEnabled('feature_recollection');
|
||
document.getElementById('feat-loop-detection').checked = isEnabled('feature_loop_detection');
|
||
}}
|
||
|
||
async function loadTestChatModels() {{
|
||
const sel = document.getElementById('tc-model');
|
||
const label = document.getElementById('tc-models-label');
|
||
try {{
|
||
const r = await fetch('/test-chat/models');
|
||
const d = await r.json();
|
||
if (d.ok && d.models.length) {{
|
||
sel.innerHTML = d.models.map(m => `<option value="${{m}}">${{m}}</option>`).join('');
|
||
label.innerHTML = `<strong>${{d.models.length}}</strong> model${{d.models.length===1?'':'s'}} available at <code>${{d.upstream}}</code>: ${{d.models.join(', ')}}`;
|
||
label.style.color = '#2a7a2a';
|
||
}} else {{
|
||
sel.innerHTML = '<option value="">— none found —</option>';
|
||
label.textContent = d.error || 'No models found';
|
||
label.style.color = '#b00';
|
||
}}
|
||
}} catch(e) {{
|
||
sel.innerHTML = '<option value="">— error —</option>';
|
||
label.textContent = `Failed to reach /test-chat/models: ${{e}}`;
|
||
label.style.color = '#b00';
|
||
}}
|
||
}}
|
||
|
||
async function sendTestChat(btn) {{
|
||
const model = document.getElementById('tc-model').value.trim();
|
||
const message = document.getElementById('tc-msg').value.trim() || 'Say hello in one sentence.';
|
||
const out = document.getElementById('tc-result');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Sending…';
|
||
out.innerHTML = '<span style="color:#888">Waiting for response…</span>';
|
||
try {{
|
||
const r = await fetch('/test-chat', {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify({{message, model: model || undefined}}),
|
||
}});
|
||
const d = await r.json();
|
||
// Refresh model dropdown if the response includes an updated list
|
||
if (d.available && d.available.length) {{
|
||
const sel = document.getElementById('tc-model');
|
||
const current = sel.value;
|
||
sel.innerHTML = d.available.map(m => `<option value="${{m}}"${{m===d.model?' selected':''}}>${{m}}</option>`).join('');
|
||
document.getElementById('tc-models-label').innerHTML =
|
||
`<strong>${{d.available.length}}</strong> model${{d.available.length===1?'':'s'}} available: ${{d.available.join(', ')}}`;
|
||
document.getElementById('tc-models-label').style.color = '#2a7a2a';
|
||
}}
|
||
if (d.ok) {{
|
||
out.innerHTML =
|
||
`<div style="color:#2a7a2a;margin-bottom:0.3em">✓ <strong>${{d.model}}</strong> — ${{d.ms}}ms</div>` +
|
||
`<pre style="margin:0;white-space:pre-wrap;background:#f4f9f4;border-color:#c0e0c0">${{d.reply}}</pre>`;
|
||
}} else {{
|
||
out.innerHTML =
|
||
`<div style="color:#b00;margin-bottom:0.3em">✗ ${{d.ms != null ? d.ms+'ms' : ''}}</div>` +
|
||
`<pre style="margin:0;white-space:pre-wrap;background:#fdf4f4;border-color:#e0c0c0">${{d.error}}</pre>`;
|
||
}}
|
||
}} catch(e) {{
|
||
out.innerHTML = `<span style="color:#b00">Request failed: ${{e}}</span>`;
|
||
}} finally {{
|
||
btn.disabled = false;
|
||
btn.textContent = 'Send';
|
||
}}
|
||
}}
|
||
|
||
async function setFeature(key, enabled) {{
|
||
const val = enabled ? 'true' : 'false';
|
||
const r = await fetch('/config', {{method:'POST', headers:{{'Content-Type':'application/json'}}, body:JSON.stringify({{key, value:val}})}});
|
||
const d = await r.json();
|
||
const status = document.getElementById('features-status');
|
||
if (d.status === 'ok') {{
|
||
status.textContent = `${{key}} set to ${{val}}`;
|
||
status.style.color = '#2a7a2a';
|
||
}} else {{
|
||
status.textContent = `Error: ${{JSON.stringify(d)}}`;
|
||
status.style.color = '#b00';
|
||
}}
|
||
setTimeout(() => {{ status.textContent = ''; }}, 3000);
|
||
}}
|
||
|
||
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();
|
||
loadAgentFrameworks();
|
||
loadFeatures();
|
||
loadTestChatModels();
|
||
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 ctx = m.ctx_length > 0
|
||
? `<span style="font-family:monospace;font-size:0.85em">${m.ctx_length.toLocaleString()}</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>${ctx} <button class="btn btn-sm" onclick="editCtx(${m.id},${m.ctx_length},this)" title="Set context length">✎</button></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();
|
||
}
|
||
|
||
// ─── Set context length ───────────────────────────────────────────────────────
|
||
async function editCtx(id, current, btn) {
|
||
const val = prompt(`Context length for model #${id} (0 = don't inject num_ctx, e.g. 8192):`, current);
|
||
if (val === null) return;
|
||
const ctx_length = parseInt(val) || 0;
|
||
btn.disabled = true;
|
||
const r = await fetch('/models/' + id, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ctx_length}),
|
||
});
|
||
btn.disabled = false;
|
||
const d = await r.json();
|
||
if (d.error) { toast('Error: ' + d.error, true); return; }
|
||
toast(`Model #${id} context length set to ${ctx_length || 'auto'}`);
|
||
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)
|
||
|
||
# Agent-prefixed paths: /{agent_id}/v1/... where the agent_id segment was not
|
||
# matched by a dedicated route (e.g. GET /{agent_id}/v1/completions).
|
||
# Strip the leading segment so the upstream receives a clean /v1/... path.
|
||
parts = path.split("/", 1)
|
||
if len(parts) == 2 and parts[1].startswith("v1/"):
|
||
stripped = parts[1]
|
||
log.info("passthrough strip_agent_prefix %s → /%s", path, stripped)
|
||
path = stripped
|
||
|
||
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"),
|
||
)
|