-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.py
More file actions
349 lines (288 loc) · 13.9 KB
/
Copy pathserver.py
File metadata and controls
349 lines (288 loc) · 13.9 KB
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
"""
Statewave Multi-Agent Memory — FastAPI + SSE backend.
Run with:
python server.py
Then open http://localhost:8000
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import litellm
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from sse_starlette.sse import EventSourceResponse
from statewave import StatewaveConnectionError
from agents.analyst import run_analyst
from agents.candidates import build_competitor_candidates
from agents.base import AsyncStatewaveClient, StatewaveError
load_dotenv()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
SUBJECT_ID = os.environ.get("SUBJECT_ID", "market-intel")
SOURCES_DIR = Path(__file__).parent / "sources"
_DEFAULT_SYNTHESIS_PROMPT = (
"You are a market intelligence analyst. Answer using ONLY the provided "
"memory context — facts compiled and conflict-resolved by Statewave. "
"Do not invent facts. Cite which source (bloomberg, techcrunch, earnings) "
"each claim comes from when known. Be concise: 3-5 sentences."
)
_SYNTHESIS_PROMPT = os.environ.get("SYNTHESIS_SYSTEM_PROMPT", _DEFAULT_SYNTHESIS_PROMPT)
_DEMO_SEED = os.environ.get("DEMO_SEED_BLOOMBERG_STRIPE", "true").lower() == "true"
def _discover_agents(sources_dir: Path) -> list[tuple[str, str]]:
"""Return [(agent_id, filename)] for every JSON file in sources_dir, sorted."""
return [(p.stem, p.name) for p in sorted(sources_dir.glob("*.json"))]
@asynccontextmanager
async def lifespan(app: FastAPI):
llm_key = os.environ.get("LLM_API_KEY", "")
if not llm_key:
raise RuntimeError(
"LLM_API_KEY is not set. Copy .env.example to .env and add your API key."
)
sw_url = os.environ.get("STATEWAVE_URL", "http://localhost:8100")
sw_key = os.environ.get("STATEWAVE_API_KEY")
try:
# Lightweight reachability probe: get_timeline returns {} on a 404
# (server up), and raises StatewaveConnectionError only when the
# backend can't be reached.
async with AsyncStatewaveClient(sw_url, sw_key) as sw:
await sw.get_timeline("healthcheck")
except StatewaveConnectionError:
logger.warning("Statewave not reachable at %s — start it before running agents.", sw_url)
yield
_APP_SECRET = os.environ.get("APP_SECRET", "")
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def _require_auth(key: str | None = Security(_api_key_header)) -> None:
if not _APP_SECRET:
return # auth disabled when APP_SECRET is unset (local dev)
if key != _APP_SECRET:
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key")
# Per-connection SSE queues: session_id -> asyncio.Queue
_queues: dict[str, asyncio.Queue] = {}
app = FastAPI(title="Statewave Multi-Agent Demo", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ── SSE helpers ───────────────────────────────────────────────────────────────
async def broadcast(event: dict) -> None:
dead: list[str] = []
for sid, q in list(_queues.items()):
try:
await q.put(event)
except Exception:
dead.append(sid)
for sid in dead:
_queues.pop(sid, None)
def _sw() -> AsyncStatewaveClient:
return AsyncStatewaveClient(
os.environ.get("STATEWAVE_URL", "http://localhost:8100"),
os.environ.get("STATEWAVE_API_KEY"),
)
# ── Routes ────────────────────────────────────────────────────────────────────
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
@app.get("/events", dependencies=[Security(_require_auth)])
async def events():
session_id = str(uuid4())
queue: asyncio.Queue = asyncio.Queue()
_queues[session_id] = queue
async def generator():
try:
while True:
event = await queue.get()
yield {"data": json.dumps(event)}
except asyncio.CancelledError:
pass
finally:
_queues.pop(session_id, None)
return EventSourceResponse(generator())
@app.post("/run", dependencies=[Security(_require_auth)])
async def run_agents():
"""Reset Statewave subject and launch analyst agents for each source file."""
async with _sw() as sw:
try:
await sw.delete_subject(SUBJECT_ID)
await broadcast({"type": "status", "msg": "Reset: prior subject cleared"})
except StatewaveError:
await broadcast({"type": "status", "msg": "Starting fresh (no prior data)"})
agents = _discover_agents(SOURCES_DIR)
llm_key = os.environ.get("LLM_API_KEY", "")
llm_model = os.environ.get("LLM_MODEL", "groq/llama-3.3-70b-versatile")
sw_url = os.environ.get("STATEWAVE_URL", "http://localhost:8100")
sw_key = os.environ.get("STATEWAVE_API_KEY")
def on_log(agent_id: str, msg: str) -> None:
# Strip Rich markup tags for web display
clean = _strip_markup(msg)
asyncio.create_task(broadcast({"type": "agent_log", "agent": agent_id, "msg": clean}))
def on_memory_update(agent_id: str, diff: dict) -> None:
asyncio.create_task(broadcast({"type": "memory_update", "agent": agent_id, "diff": diff}))
async def _seed_bloomberg_stripe() -> None:
"""Seed Bloomberg's stale Stripe pricing (3.5% + 35¢) as structured atomic
candidates — no LLM. The pricing candidate carries the authoritative v2
claim from the source, and positioning/differentiators are independent
atomic facts. Guarantees the later 2.9% source always has something to
supersede, while the independent Bloomberg facts survive that
supersession (the whole point of atomic structured candidates)."""
bloomberg = json.loads((SOURCES_DIR / "bloomberg.json").read_text(encoding="utf-8"))
stripe = next(c for c in bloomberg["competitors"] if c.get("name") == "Stripe")
published = bloomberg.get("published", "2026-05-16")
raw_text, candidates = build_competitor_candidates(stripe, "bloomberg", published)
async with AsyncStatewaveClient(sw_url, sw_key) as sw:
before_ids: set[str] = {m["id"] for m in await sw.search_memories(SUBJECT_ID)}
await sw.post_episode(
subject_id=SUBJECT_ID,
source="bloomberg",
type="agent.analyst.findings",
payload={
"text": raw_text,
"statewave": {"memory_candidates": candidates},
"competitor": "Stripe",
"source_label": "bloomberg",
"published": published,
},
)
await sw.compile_memories(SUBJECT_ID)
diff = await sw.get_memory_diff(SUBJECT_ID, before_ids)
# Push seed memory to the Live Memory panel so it shows green before agents run
if diff["new"]:
await broadcast({"type": "memory_update", "agent": "bloomberg", "diff": diff})
async def _run():
await broadcast({"type": "run_started"})
# Demo mode: pre-seed Bloomberg's stale Stripe pricing so TechCrunch's
# 2.9% fact will supersede it — the core conflict resolution demo moment.
# Disable by setting DEMO_SEED_BLOOMBERG_STRIPE=false in .env when using
# your own source files.
if _DEMO_SEED and (SOURCES_DIR / "bloomberg.json").exists():
try:
await _seed_bloomberg_stripe()
except StatewaveConnectionError as exc:
await broadcast({
"type": "agent_log",
"agent": "bloomberg",
"msg": (
"ERROR: unable to reach the Statewave backend at "
f"{sw_url}. Start the Statewave service or set STATEWAVE_URL. "
f"Details: {exc}"
),
})
await broadcast({"type": "agents_done", "supersessions": 0})
return
await broadcast({
"type": "agent_log", "agent": "bloomberg",
"msg": "Seeded: Stripe pricing at 3.5% + 35¢ (stale Bloomberg fact, pre-reversal)",
})
await broadcast({
"type": "agent_log", "agent": "bloomberg",
"msg": "Waiting for TechCrunch and Earnings agents to commit contradicting facts...",
})
# One shared lock serializes each agent's post→compile→diff so concurrent
# agents never double-compile the same uncompiled episode.
compile_lock = asyncio.Lock()
# Bloomberg skips Stripe only in demo mode (fact already seeded above).
# All other agents run without skipping.
tasks = []
for agent_id, source_filename in agents:
skip = {"Stripe"} if (_DEMO_SEED and agent_id == "bloomberg") else None
tasks.append(asyncio.create_task(run_analyst(
agent_id=agent_id,
source_file=str(SOURCES_DIR / source_filename),
subject_id=SUBJECT_ID,
llm_api_key=llm_key,
llm_model=llm_model,
statewave_url=sw_url,
statewave_api_key=sw_key,
on_log=on_log,
on_memory_update=on_memory_update,
skip_competitors=skip,
compile_lock=compile_lock,
)))
results = await asyncio.gather(*tasks, return_exceptions=True)
total_supersessions = 0
for (agent_id, _), result in zip(agents, results):
if isinstance(result, Exception):
logger.error("Agent %s failed: %s", agent_id, result)
await broadcast({"type": "agent_log", "agent": agent_id,
"msg": f"ERROR: {result}"})
elif isinstance(result, dict):
total_supersessions += result.get("supersessions", 0)
await broadcast({"type": "agents_done", "supersessions": total_supersessions})
asyncio.create_task(_run())
return {"status": "started"}
@app.post("/ask", dependencies=[Security(_require_auth)])
async def ask(body: dict):
"""Synthesis: recall from Statewave + stream LLM answer via SSE."""
question = (body.get("question") or "").strip()
if not question:
return {"error": "no question"}
await broadcast({"type": "synthesis_start", "question": question})
async with _sw() as sw:
try:
ctx = await sw.get_context(SUBJECT_ID, task=question, max_tokens=4000)
except StatewaveError as e:
await broadcast({"type": "synthesis_error", "msg": str(e)})
return {"error": str(e)}
assembled = ctx.get("assembled_context", "")
facts = ctx.get("facts", [])
token_est = ctx.get("token_estimate", 0)
if not assembled.strip():
await broadcast({"type": "synthesis_error",
"msg": "No memories found. Run agents first."})
return {"error": "no memories"}
await broadcast({"type": "synthesis_context",
"fact_count": len(facts), "token_estimate": token_est})
llm_key = os.environ.get("LLM_API_KEY", "")
llm_model = os.environ.get("LLM_MODEL", "groq/llama-3.3-70b-versatile")
try:
stream = await litellm.acompletion(
model=llm_model,
api_key=llm_key,
messages=[
{"role": "system", "content": _SYNTHESIS_PROMPT},
{
"role": "user",
"content": (
f"Question: {question}\n\n"
f"Memory context (active facts, conflicts resolved):\n{assembled}"
),
},
],
temperature=0.2,
stream=True,
timeout=60.0,
)
async for chunk in stream:
content = chunk.choices[0].delta.content
if content:
await broadcast({"type": "synthesis_token", "token": content})
await broadcast({"type": "synthesis_done"})
except (litellm.APIError, litellm.AuthenticationError, litellm.RateLimitError) as e:
logger.error("LLM error during synthesis: %s", e)
await broadcast({"type": "synthesis_error", "msg": str(e)})
except Exception as e:
logger.error("Unexpected synthesis error: %s", e)
await broadcast({"type": "synthesis_error", "msg": str(e)})
return {"status": "ok"}
@app.get("/memories", dependencies=[Security(_require_auth)])
async def get_memories():
"""Return current active memories for the subject."""
async with _sw() as sw:
try:
memories = await sw.search_memories(SUBJECT_ID)
return {"memories": memories}
except StatewaveError:
return {"memories": []}
# ── Markup stripping ──────────────────────────────────────────────────────────
_MARKUP_RE = re.compile(r"\[/?[a-zA-Z #0-9_]+\]")
def _strip_markup(text: str) -> str:
return _MARKUP_RE.sub("", text)
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=False)