Adding Festinger with wordnet
This commit is contained in:
@@ -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 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)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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 & 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 “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 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 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;
|
||||
}}
|
||||
}}
|
||||
|
||||
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"),
|
||||
)
|
||||
Reference in New Issue
Block a user