Spaces:
Sleeping
Sleeping
File size: 13,455 Bytes
1605cbb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | """
FALSIFY live web server — FastAPI backend for the animated belief-revision UI.
This is the additive "web mode" for FALSIFY. The CLI (`python main.py`) is untouched;
this server exposes the same three core operations — build / revise / scoreboard — over
HTTP, plus a Server-Sent Events stream so a browser can *watch* belief revision happen:
the refute flash, the cascade sweep, the forget-dissolve.
Why SSE and not WebSockets: our event stream is one-directional (server -> browser), and
SSE is plain streaming HTTP. That makes it work behind the Hugging Face Spaces single-port
proxy AND across a Vercel(frontend)+Render(backend) split, neither of which hosts
long-lived WebSockets on their free tiers. See IMPLEMENTATION_PLAN.md §6.1.
Run locally: uvicorn server:app --reload --port 8000 -> http://localhost:8000
On HF Spaces: uvicorn server:app --host 0.0.0.0 --port 7860
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# Importing `falsify` first sets Cognee's env defaults (access-control off, etc.)
# before any Cognee module is imported — same ordering contract as main.py.
import falsify # noqa: F401
from falsify import events, graph_ops
from falsify.falsify import (
Scoreboard,
build_diamond_graph,
build_graph,
revise,
scoreboard,
use_backend,
)
from falsify.models import TruthState
from falsify.seed import NEW_FACT, NEW_FACT_2, QUESTION_TEXT
from falsify.utils import _STATE_STYLE, _infer_type, get_belief_summary
logger = logging.getLogger("falsify.server")
app = FastAPI(title="FALSIFY Live", version="1.0")
# Wide-open CORS: the demo may be served same-origin (HF monolith) or cross-origin
# (Vercel frontend -> Render backend). FRONTEND_ORIGIN narrows it when set.
app.add_middleware(
CORSMiddleware,
allow_origins=[os.environ.get("FRONTEND_ORIGIN", "*")],
allow_methods=["*"],
allow_headers=["*"],
)
# ------------------------------------------------------------------ server state
_seeded = None # the active SeededGraph (set on startup / reset)
_scenario: str = "simple" # "simple" | "diamond"
_backend_mode: str = "opensource" # "opensource" | "cloud"
_revise_lock = asyncio.Lock() # serialize revise() — single-user demo safety
# ------------------------------------------------------------------ request models
class ChatRequest(BaseModel):
message: str
demo: bool = True
class ScenarioRequest(BaseModel):
kind: str = "simple" # "simple" | "diamond"
class ModeRequest(BaseModel):
mode: str = "opensource" # "opensource" | "cloud"
url: Optional[str] = None
api_key: Optional[str] = None
# ------------------------------------------------------------------ JSON shaping
async def _graph_json() -> Dict[str, Any]:
"""Current graph as UI-ready JSON: nodes (with truth-state color) + edges.
Colors come straight from utils._STATE_STYLE so the web UI, the CLI console,
and the static HTML export all share one palette.
"""
nodes, edges = await graph_ops.load_graph()
ids = [nid for nid, _p in nodes]
truth = await graph_ops.get_truth(ids)
out_nodes: List[Dict[str, Any]] = []
for nid, props in nodes:
align = truth.get(str(nid), [TruthState.ALIVE.value])
state = align[0] if align else TruthState.ALIVE.value
_tag, color = _STATE_STYLE.get(state, ("ALIVE", "#22c55e"))
out_nodes.append({
"id": str(nid),
"label": graph_ops.node_label(props),
"type": _infer_type(props),
"state": state,
"color": color,
})
out_edges = [
{"source": str(s), "target": str(d), "relation": r}
for (s, d, r, _p) in edges
]
return {"nodes": out_nodes, "edges": out_edges,
"scenario": _scenario, "backend": _backend_mode}
def _scoreboard_json(b: Scoreboard) -> Dict[str, Any]:
return {
"question": b.question,
"falsify_answer": b.falsify_answer,
"falsify_support": b.falsify_support,
"rag_answer": b.rag_answer,
"rag_citations": b.rag_citations,
"stale": b.stale,
}
def _report_json(r) -> Dict[str, Any]:
return {
"new_fact": r.new_fact,
"refuted": r.refuted,
"invalidated": r.invalidated,
"forgotten": r.forgotten,
"forgotten_labels": r.forgotten_labels,
"hypothesis_actions": r.hypothesis_actions,
"retained_provenance": r.retained_provenance,
"new_evidence_id": r.new_evidence_id,
"epoch": r.epoch,
"revised": r.revised,
}
# ------------------------------------------------------------------ SSE stream
@app.get("/api/events")
async def api_events():
"""Server-Sent Events: one frame per belief mutation, fanned from the event bus."""
q = events.subscribe()
async def gen():
# Prime the stream so the client's onopen fires immediately behind proxies.
yield ": connected\n\n"
try:
while True:
try:
ev = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {json.dumps(ev)}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
events.unsubscribe(q)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # stop HF/Render proxies buffering the stream
},
)
# ------------------------------------------------------------------ REST endpoints
@app.get("/api/graph")
async def api_graph():
return await _graph_json()
@app.get("/api/scoreboard")
async def api_scoreboard(q: str = QUESTION_TEXT):
board = await scoreboard(q, _seeded)
return _scoreboard_json(board)
@app.post("/api/chat")
async def api_chat(req: ChatRequest):
"""A question (ends with '?') -> scoreboard; anything else -> a new fact to revise.
Graph mutations emit SSE events *during* revise(), so the browser animates the
cascade in real time while this call is still in flight.
"""
msg = req.message.strip()
if not msg:
return {"type": "noop", "data": {}}
if msg.endswith("?"):
board = await scoreboard(msg, _seeded)
return {"type": "answer", "data": _scoreboard_json(board)}
async with _revise_lock:
pinned = _seeded.refuted_target_id if (req.demo and _seeded) else None
await events.emit_step("ingest", "New contradicting fact received")
report = await revise(msg, pinned_target_id=pinned)
return {"type": "revision", "data": _report_json(report)}
@app.post("/api/scenario")
async def api_scenario(req: ScenarioRequest):
"""(Re)build the graph as the simple or diamond investigation."""
global _seeded, _scenario
if req.kind == "diamond":
_seeded, _scenario = await build_diamond_graph(), "diamond"
else:
_seeded, _scenario = await build_graph(), "simple"
await events.emit_graph_reset()
return await _graph_json()
@app.post("/api/reset")
async def api_reset():
"""Rebuild the current scenario from scratch (fresh belief state)."""
return await api_scenario(ScenarioRequest(kind=_scenario))
@app.post("/api/demo")
async def api_demo():
"""One-click headline demo on the simple graph: drop the back-dated-QA fact.
Rebuilds the simple investigation, then refutes E_qa (pinned, so it runs
keyless). Graph mutations stream over SSE while this call is in flight, so the
browser animates the cascade live; the returned report + scoreboard let the UI
finish with the promotion 'rise' and the FALSIFY-vs-RAG panel.
"""
global _seeded, _scenario
async with _revise_lock:
_seeded, _scenario = await build_graph(), "simple"
await events.emit_graph_reset()
await asyncio.sleep(0.4)
await events.emit_step("ingest", "New fact: the March 2021 QA report was back-dated")
report = await revise(NEW_FACT, pinned_target_id=_seeded.refuted_target_id)
board = await scoreboard(
QUESTION_TEXT, _seeded,
rag_snapshot=report.rag_snapshot if report.revised else None,
)
return {"report": _report_json(report), "scoreboard": _scoreboard_json(board)}
@app.post("/api/diamond")
async def api_diamond():
"""Two-phase diamond story: K2 survives phase 1, then collapses in phase 2.
Phase 1 refutes E_qa — the single-dependency conclusion K dies but the diamond
K2 survives on E_email. A pause lets the UI show K2 standing, then phase 2
refutes E_email and K2 finally collapses. Both targets are pinned so it runs
keyless and deterministically.
"""
global _seeded, _scenario
async with _revise_lock:
_seeded, _scenario = await build_diamond_graph(), "diamond"
await events.emit_graph_reset()
await asyncio.sleep(0.5)
await events.emit_step("phase1", "Phase 1 — the March 2021 QA report was back-dated")
r1 = await revise(NEW_FACT, pinned_target_id=_seeded.ids["E_qa"])
await asyncio.sleep(1.6) # hold on K2 surviving before the second blow
await events.emit_step("phase2", "Phase 2 — the January 2021 supplier email was fabricated")
r2 = await revise(NEW_FACT_2, pinned_target_id=_seeded.ids["E_email"])
board = await scoreboard(
QUESTION_TEXT, _seeded,
rag_snapshot=r2.rag_snapshot if r2.revised else None,
)
return {"phase1": _report_json(r1), "phase2": _report_json(r2),
"scoreboard": _scoreboard_json(board)}
@app.post("/api/upload")
async def api_upload(file: UploadFile = File(...)):
"""Ingest an uploaded document into memory via cognee.add + cognify.
Turns the fixed demo into a real product: a judge can drop their own file and
watch it become graph. Requires an LLM key for cognify; degrades with a clear
error otherwise (the demo keeps working).
"""
import cognee
raw = await file.read()
text = raw.decode("utf-8", "ignore").strip()
if not text:
return {"ok": False, "error": "empty or unreadable file"}
try:
await events.emit_step("upload", f"Ingesting {file.filename}…")
await cognee.add(text)
await cognee.cognify()
await events.emit_graph_reset()
return {"ok": True, "graph": await _graph_json()}
except Exception as exc: # most likely: no LLM key for cognify
logger.warning("upload cognify failed: %s", exc)
return {"ok": False, "error": str(exc),
"hint": "Set LLM_API_KEY to enable document ingestion."}
@app.get("/api/verify")
async def api_verify():
"""Re-read the persisted belief state from the on-disk graph store.
Truth-state (refuted / invalidated / superseded) is written ON the graph nodes
in Cognee's file-backed Kuzu store — not held in process memory — so re-reading
it here returns the *revised* beliefs, which is exactly what survives a full
process or container restart.
We deliberately do NOT evict the engine mid-request: in Ladybug subprocess mode
a worker process holds the DB file lock, so an in-process cold-reopen would
collide with that lock. The durable-proof claim rests on the truth_alignment
living in storage, and on the graph coming back revised after a real restart.
"""
try:
summary = await get_belief_summary()
return {"persisted": True, "summary": summary}
except Exception as exc:
logger.error("verify read failed: %s", exc)
return {"persisted": False, "error": str(exc)}
@app.post("/api/mode")
async def api_mode(req: ModeRequest):
"""Toggle the Cognee backend between self-hosted (open source) and Cognee Cloud."""
global _backend_mode
# Cloud creds can come from the request or the environment.
url = req.url or os.environ.get("COGNEE_CLOUD_URL")
api_key = req.api_key or os.environ.get("COGNEE_CLOUD_API_KEY")
_backend_mode = await use_backend(req.mode, url=url, api_key=api_key)
return {"backend": _backend_mode}
@app.get("/api/health")
async def api_health():
return {"ok": True, "scenario": _scenario, "backend": _backend_mode,
"subscribers": events.has_subscribers()}
# ------------------------------------------------------------------ lifecycle
@app.on_event("startup")
async def _startup():
"""Seed the simple investigation so the first page load shows a live graph."""
global _seeded
try:
_seeded = await build_graph()
logger.info("startup: seeded simple investigation")
except Exception as exc: # don't let a seed hiccup take the server down
logger.error("startup seed failed: %s", exc)
# Serve the built frontend LAST so /api/* routes take precedence. The directory is
# created by the Docker build (frontend/dist -> ./static); guard so local dev without
# a build still boots (API-only).
if os.path.isdir("static"):
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|