Adding Festinger with wordnet

This commit is contained in:
2026-04-19 16:16:13 +02:00
parent a27aa713d3
commit 8ff73d32ae
48 changed files with 485400 additions and 0 deletions
+821
View File
@@ -0,0 +1,821 @@
"""
Festinger — main FastAPI application.
Routes:
POST /api/chat Ollama-compatible chat (loop detection + recollection injection)
POST /api/generate Ollama-compatible generate
POST /v1/messages Anthropic Messages API proxy (loop detection + recollection)
POST /v1/chat/completions OpenAI-compatible proxy (loop detection + recollection)
POST /iknowthat Manual write path (gutask iknowthat)
POST /resolve/run Manually trigger nightly resolution job
POST /reload Reload URD cache (called by resolution job)
GET /health Health + stats
GET /conflicts Pending and recently resolved conflicts
GET /admin Minimal admin UI
* /v1/{path} Passthrough to upstream Anthropic
* /{path} Passthrough to upstream Ollama
"""
from __future__ import annotations
import json
import logging
import os
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,
)
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 .write_queue import enqueue_concept, enqueue_cue, start_worker, stop_worker
from .urd_writer import InsertRequest, insert_urd_edge
from .wordnet import import_wordnet, CITATION as WORDNET_CITATION
from .test_scenarios import SCENARIOS, seed_scenario, reset_scenario
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
)
log = logging.getLogger("festinger")
CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
def load_yaml_config() -> dict:
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
# ---------------------------------------------------------------------------
# Lifespan — startup / shutdown
# ---------------------------------------------------------------------------
_scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
cfg = load_yaml_config()
dsn = os.environ.get("POSTGRES_DSN", cfg.get("postgres_dsn", ""))
pool = await get_pool(dsn)
app.state.pool = pool
app.state.yaml_config = cfg
await init_schema(pool)
await bootstrap_dimensions(pool)
await bootstrap_english_dictionary(pool)
await warm_cache(pool)
await start_worker(pool)
# Schedule saliency flush every 30 s
_scheduler.add_job(flush_encounter_deltas, "interval", seconds=30, args=[pool], id="saliency_flush")
# Schedule nightly resolution job from config table
resolution_cron = await get_config(pool, "resolution_schedule", "0 2 * * *")
cron_parts = resolution_cron.split()
if len(cron_parts) == 5:
minute, hour, day, month, dow = cron_parts
_scheduler.add_job(
run_resolution_job, "cron",
minute=minute, hour=hour, day=day, month=month, day_of_week=dow,
args=[pool], id="nightly_resolution",
)
_scheduler.start()
log.info("festinger ready")
yield
_scheduler.shutdown(wait=False)
await stop_worker()
await close_pool()
app = FastAPI(title="Festinger", lifespan=lifespan)
# ---------------------------------------------------------------------------
# Ollama forwarding helpers
# ---------------------------------------------------------------------------
async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
body = dict(body)
body["stream"] = False
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{upstream}{path}", json=body)
r.raise_for_status()
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",
)
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
# Anthropic requires anthropic-version header; add default if caller omitted it
if "anthropic-version" not in {k.lower() for k in headers}:
headers = {**headers, "anthropic-version": "2023-06-01"}
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{upstream}/v1/messages",
json=body,
headers=headers,
)
r.raise_for_status()
data = r.json()
# Extract text from Anthropic content blocks
text = ""
for block in data.get("content", []):
if block.get("type") == "text":
text += block.get("text", "")
return text, data
async def call_openai(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
"""
Forward a request to an OpenAI-compatible chat completions endpoint (non-streaming).
Returns (assistant_text, raw_response_dict).
"""
body = dict(body)
body["stream"] = False
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{upstream}/v1/chat/completions",
json=body,
headers=headers,
)
r.raise_for_status()
data = r.json()
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
return text, data
# ---------------------------------------------------------------------------
# Text extraction helpers (unified across API formats)
# ---------------------------------------------------------------------------
def extract_prompt_text(body: dict, path: str) -> str:
"""Extract a flat string from a request body for saliency processing."""
if path in ("/api/chat", "/v1/chat/completions"):
messages = body.get("messages", [])
parts = []
for m in messages:
content = m.get("content", "")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
# Anthropic-style content blocks
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
# Include top-level system field (Anthropic format)
if body.get("system"):
parts.insert(0, body["system"])
return " ".join(parts)
if path == "/v1/messages":
return extract_prompt_text(body, "/v1/chat/completions")
return body.get("prompt", "")
def inject_recollection_anthropic(body: dict, block: str) -> dict:
"""
Inject a recollection block into an Anthropic Messages API request.
Anthropic uses a top-level 'system' string field rather than a system message.
"""
body = dict(body)
existing = body.get("system") or ""
body["system"] = block + ("\n\n" + existing if existing else "")
return body
# ---------------------------------------------------------------------------
# Saliency + recollection pipeline
# ---------------------------------------------------------------------------
async def process_prompt(body: dict, path: str, pool, cfg: dict) -> dict:
"""
Run the saliency + recollection pipeline over the prompt.
Returns a (possibly modified) body dict with the recollection block injected.
"""
read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
write_threshold = float(await get_config(pool, "saliency_write_threshold", "1.2"))
conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
prompt_text = extract_prompt_text(body, path)
if not prompt_text.strip():
return body
# 1. Scan for explicit relationship cues (bypass threshold)
cues = scan_cues(prompt_text)
for cue in cues:
await enqueue_cue(cue)
# 2. Tokenise + update saliency
tokens = tokenize(prompt_text)
salient_for_read: list[int] = []
salient_for_write: list[str] = []
for token in tokens:
soas_row = cache.soas_by_token.get(token)
if soas_row is None:
# New token — get_or_create happens in background via queue when needed
continue # unknown token — skip saliency for now; write queue handles creation
cache.record_encounter(soas_row.id)
if soas_row.saliency >= read_threshold:
salient_for_read.append(soas_row.id)
if soas_row.saliency >= write_threshold and soas_row.novelty < 1.0:
salient_for_write.append(token)
for token in salient_for_write:
await enqueue_concept(token)
if not salient_for_read:
return body
# 3. Build recollection block
block = build_recollection_block(salient_for_read, conf_floor, recency_days)
if not block:
return body
# 4. Inject into messages
if path == "/api/chat" or path == "/v1/chat/completions":
body = dict(body)
body["messages"] = inject_recollection(body.get("messages", []), block)
elif path == "/v1/messages":
body = inject_recollection_anthropic(body, block)
# /api/generate uses a flat prompt string — prepend there
elif path == "/api/generate":
body = dict(body)
body["prompt"] = block + "\n\n" + body.get("prompt", "")
return body
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.post("/api/chat")
async def chat(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_ollama"]
min_len = cfg["detection"]["min_length"]
body = await process_prompt(body, "/api/chat", pool, cfg)
text, raw = await call_ollama("/api/chat", body, upstream)
sess = session_key(model, body.get("messages", []))
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["message"] = {"role": "assistant", "content": override}
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/chat", body, upstream)
record_and_check(sess, text, min_len)
raw["message"] = {"role": "assistant", "content": text}
return Response(content=json.dumps(raw), media_type="application/json")
@app.post("/api/generate")
async def generate(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_ollama"]
min_len = cfg["detection"]["min_length"]
body = await process_prompt(body, "/api/generate", pool, cfg)
messages = [{"role": "user", "content": body.get("prompt", "")}]
sess = session_key(model, messages)
text, raw = await call_ollama("/api/generate", body, upstream)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["response"] = override
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/generate", body, upstream)
record_and_check(sess, text, min_len)
raw["response"] = text
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# Anthropic Messages API (POST /v1/messages)
# ---------------------------------------------------------------------------
@app.post("/v1/messages")
async def anthropic_messages(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_anthropic"]
min_len = cfg["detection"]["min_length"]
headers = _relay_headers(request, ANTHROPIC_RELAY_HEADERS)
# Ensure anthropic-version is present
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)
# Use messages list as session key (same logic as /api/chat)
messages = body.get("messages", [])
sess = session_key(model, messages)
text, raw = await call_anthropic(body, upstream, headers)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
# Return a minimal Anthropic-format response with the override message
raw["content"] = [{"type": "text", "text": override}]
raw["loop_detected"] = True
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)
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# OpenAI-compatible chat completions (POST /v1/chat/completions)
# ---------------------------------------------------------------------------
@app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_openai"]
min_len = cfg["detection"]["min_length"]
headers = _relay_headers(request, OPENAI_RELAY_HEADERS)
body = await process_prompt(body, "/v1/chat/completions", pool, cfg)
messages = body.get("messages", [])
sess = session_key(model, messages)
text, raw = await call_openai(body, upstream, headers)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
if raw.get("choices"):
raw["choices"][0]["message"]["content"] = override
raw["loop_detected"] = True
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)
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# /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,
}
# ---------------------------------------------------------------------------
# /resolve/run — manually trigger resolution job
# ---------------------------------------------------------------------------
@app.post("/resolve/run")
async def resolve_run(request: Request) -> dict:
pool = request.app.state.pool
result = await run_resolution_job(pool)
return result
# ---------------------------------------------------------------------------
# /wordnet/import — bulk-load WordNet lemmas into SOAS
# ---------------------------------------------------------------------------
@app.post("/wordnet/import")
async def wordnet_import(request: Request) -> dict:
"""
Import Princeton WordNet 3.x lemmas into SOAS (saliency=0, novelty=0).
Idempotent — already-present tokens are skipped.
Can take 515 seconds for a full import (~130k lemmas).
"""
pool = request.app.state.pool
result = await import_wordnet(pool)
return result
# ---------------------------------------------------------------------------
# /reload — reload URD cache (used after resolution job)
# ---------------------------------------------------------------------------
@app.post("/reload")
async def reload(request: Request) -> dict:
pool = request.app.state.pool
await reload_urd_cache(pool)
return {"status": "ok", "urd_edges": len(cache.urd_by_concept_dim)}
# ---------------------------------------------------------------------------
# /health
# ---------------------------------------------------------------------------
@app.get("/health")
async def health(request: Request) -> dict:
cfg = request.app.state.yaml_config
return {
"status": "ok",
"upstream": cfg["upstream_ollama"],
"active_loop_sessions": 0, # loop detector is stateful in-process
"soas_tokens": len(cache.soas_by_token),
"urd_edges": len(cache.urd_by_concept_dim),
"pending_conflicts": len(cache.pending_conflicts),
"last_resolution_run": last_run_timestamp(),
"timestamp": int(time.time()),
}
# ---------------------------------------------------------------------------
# /conflicts — expose resolution queue
# ---------------------------------------------------------------------------
@app.get("/conflicts")
async def conflicts(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, concept_id, existing_parent_id, incoming_parent_id,
dim_id, collision_type, status, resolution, priority,
created_at, resolved_at
FROM resolution_queue
ORDER BY status ASC, created_at DESC
LIMIT 100
"""
)
def format_row(r):
return {
"id": r["id"],
"concept": cache.soas_by_id.get(r["concept_id"], str(r["concept_id"])),
"existing_parent": cache.soas_by_id.get(r["existing_parent_id"], "?"),
"incoming_parent": cache.soas_by_id.get(r["incoming_parent_id"], "?"),
"dimension": cache.soas_by_id.get(r["dim_id"], "?"),
"collision_type": r["collision_type"],
"status": r["status"],
"resolution": r["resolution"],
"priority": r["priority"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
}
return {"conflicts": [format_row(r) for r in rows]}
# ---------------------------------------------------------------------------
# /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)
# ---------------------------------------------------------------------------
# /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; }}
footer {{ margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.78em; color: #888; }}
footer a {{ color: #888; }}
</style>
</head>
<body>
<h1>Festinger</h1>
<p class="subtitle">Ollama-compatible inference middleware — loop detection &amp; Recollections world model</p>
<h2>World model stats</h2>
<div class="stats" id="stats">
<div class="stat"><div class="stat-label">SOAS tokens</div><div class="stat-value" id="s-soas">…</div></div>
<div class="stat"><div class="stat-label">URD edges</div><div class="stat-value" id="s-urd">…</div></div>
<div class="stat"><div class="stat-label">Pending conflicts</div><div class="stat-value" id="s-conflicts">…</div></div>
<div class="stat"><div class="stat-label">Last resolution</div><div class="stat-value" style="font-size:0.85em" id="s-lastrun">…</div></div>
</div>
<h2>Actions</h2>
<div class="actions">
<button class="primary" onclick="runResolution(this)">Run conflict resolution now</button>
<button onclick="runWordnetImport(this)">Import WordNet lemmas</button>
</div>
<pre id="result" style="display:none"></pre>
<h2>Pending conflicts</h2>
<pre id="conflicts-pre">Loading…</pre>
<footer>
<strong>Vocabulary source:</strong>
Princeton University &ldquo;About WordNet.&rdquo; <em>WordNet.</em> Princeton University. 2010.
<a href="https://wordnet.princeton.edu/" target="_blank">https://wordnet.princeton.edu/</a>
&mdash; used to pre-seed the SOAS concept vocabulary at saliency&nbsp;0 (common English baseline).
</footer>
<script>
async function loadStats() {{
const r = await fetch('/health');
const d = await r.json();
document.getElementById('s-soas').textContent = d.soas_tokens.toLocaleString();
document.getElementById('s-urd').textContent = d.urd_edges.toLocaleString();
document.getElementById('s-conflicts').textContent = d.pending_conflicts;
document.getElementById('s-lastrun').textContent = d.last_resolution_run
? d.last_resolution_run.replace('T', ' ').slice(0, 19)
: 'never';
}}
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 runResolution(btn) {{
btn.disabled = true;
showResult('Running resolution job…', true);
try {{
const r = await fetch('/resolve/run', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok);
await loadStats();
await loadConflicts();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
async function runWordnetImport(btn) {{
btn.disabled = true;
showResult('Importing WordNet lemmas — this may take 1020 seconds…', true);
try {{
const r = await fetch('/wordnet/import', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok && !d.error);
await loadStats();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
loadStats();
loadConflicts();
</script>
</body>
</html>
"""
@app.get("/admin", response_class=HTMLResponse)
async def admin() -> str:
return ADMIN_HTML.format()
# ---------------------------------------------------------------------------
# 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
# Route /v1/* to Anthropic; everything else (including /api/*) to Ollama
if path.startswith("v1/"):
upstream = cfg["upstream_anthropic"]
relay_headers = ANTHROPIC_RELAY_HEADERS
else:
upstream = cfg["upstream_ollama"]
relay_headers = None
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"}
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.request(
request.method,
f"{upstream}/{path}",
content=body,
headers=headers,
)
return Response(
content=r.content,
status_code=r.status_code,
media_type=r.headers.get("content-type"),
)