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")