#!/usr/bin/env python3 """ Neuron Council Service — LLM anti-confabulation layer. Fires 3 parallel Ollama calls and aggregates votes to produce a confidence score + tags for any claim before it enters memory. Usage: python3 council_service.py [--port 7771] """ import argparse import asyncio import time import uuid from typing import Optional import httpx import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- OLLAMA_BASE = "http://localhost:11434/api/generate" COUNCIL_MODELS = ["neuron:latest", "dolphin3:8b", "neuron-ft:latest"] MODEL_TIMEOUT = 45.0 # seconds per model (models may need to load from cold) SYSTEM_PROMPT_TEMPLATE = """\ You are a fact-checker. You will be given a claim. Your job: assess if it is accurate, internally consistent, and grounded in reality. Respond with EXACTLY ONE WORD: - "plausible" if the claim seems accurate and well-grounded - "uncertain" if you cannot determine accuracy or the claim is ambiguous - "confabulation" if the claim appears to contain invented facts or clear errors Claim: {claim} Context: {context} Your verdict (one word only):""" VALID_VERDICTS = {"plausible", "uncertain", "confabulation"} # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- app = FastAPI( title="Neuron Council Service", description="LLM-council anti-confabulation layer for Neuron soul", version="1.0.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- class VerifyRequest(BaseModel): claim: str context: Optional[str] = "" class VerifyResponse(BaseModel): id: str claim: str confidence: float council_votes: list[str] summary: str tags: list[str] latency_ms: int # --------------------------------------------------------------------------- # Core logic # --------------------------------------------------------------------------- async def query_model(client: httpx.AsyncClient, model: str, prompt: str) -> str: """ Query a single Ollama model. Returns "plausible", "uncertain", or "confabulation". Returns "uncertain" on timeout. Raises httpx.ConnectError on connection failure. """ payload = { "model": model, "prompt": prompt, "stream": False, } try: resp = await client.post(OLLAMA_BASE, json=payload, timeout=MODEL_TIMEOUT) resp.raise_for_status() data = resp.json() raw = data.get("response", "").strip().lower().split()[0] if data.get("response", "").strip() else "uncertain" # Normalise to one of the three valid verdicts if raw not in VALID_VERDICTS: return "uncertain" return raw except httpx.TimeoutException: return "uncertain" async def run_council(claim: str, context: str) -> VerifyResponse: start = time.monotonic() prompt = SYSTEM_PROMPT_TEMPLATE.format(claim=claim, context=context) # Quick connectivity check — one tiny HEAD request to Ollama try: async with httpx.AsyncClient() as probe: await probe.get("http://localhost:11434/", timeout=2.0) except (httpx.ConnectError, httpx.TimeoutException): latency_ms = int((time.monotonic() - start) * 1000) return VerifyResponse( id=str(uuid.uuid4()), claim=claim, confidence=0.5, council_votes=[], summary="Ollama is unavailable; council could not convene.", tags=["council-unavailable"], latency_ms=latency_ms, ) # Fire all 3 model calls in parallel async with httpx.AsyncClient() as client: tasks = [query_model(client, m, prompt) for m in COUNCIL_MODELS] votes: list[str] = await asyncio.gather(*tasks) plausible_count = votes.count("plausible") latency_ms = int((time.monotonic() - start) * 1000) # Voting rules if plausible_count == 3: confidence = 0.85 tags = ["verified"] summary = "3/3 council members agree this is plausible." elif plausible_count == 2: confidence = 0.65 tags = ["council-split"] summary = "2/3 council members agree this is plausible." elif plausible_count == 1: confidence = 0.30 tags = ["unverified", "council-flagged"] summary = "1/3 council members found this plausible." else: confidence = 0.30 tags = ["unverified", "council-flagged"] summary = "0/3 council members found this plausible." return VerifyResponse( id=str(uuid.uuid4()), claim=claim, confidence=confidence, council_votes=votes, summary=summary, tags=tags, latency_ms=latency_ms, ) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.post("/api/neuron/council/verify", response_model=VerifyResponse) async def verify(req: VerifyRequest): return await run_council(req.claim, req.context or "") @app.get("/healthz") async def health(): return {"status": "ok", "service": "council"} # --------------------------------------------------------------------------- # Startup warm-up: pre-load all council models so first real call is fast # --------------------------------------------------------------------------- @app.on_event("startup") async def warmup_models(): """ Send a trivial prompt to each council model at startup. This forces Ollama to load the models into GPU memory so the first real council call does not pay the cold-load latency penalty. """ print("[council] Warming up council models...") warmup_prompt = "Reply with one word: ready" async with httpx.AsyncClient() as client: tasks = [ client.post( OLLAMA_BASE, json={"model": m, "prompt": warmup_prompt, "stream": False}, timeout=60.0, ) for m in COUNCIL_MODELS ] results = await asyncio.gather(*tasks, return_exceptions=True) for model, result in zip(COUNCIL_MODELS, results): if isinstance(result, Exception): print(f"[council] warm-up failed for {model}: {result}") else: print(f"[council] {model} warm and ready") print("[council] All models warmed up.") # --------------------------------------------------------------------------- # Entrypoint # --------------------------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser(description="Neuron Council Service") parser.add_argument("--port", type=int, default=7771, help="Port to listen on") parser.add_argument("--host", default="127.0.0.1", help="Host to bind to") args = parser.parse_args() print(f"[council] Starting on {args.host}:{args.port}") uvicorn.run(app, host=args.host, port=args.port, log_level="info")