import asyncio import json import uvicorn from fastapi import FastAPI, File, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel from typing import Any, List, Optional import blood_rag import reports import tools as notepad from agent import run_agent, stream_agent from config import PORT app = FastAPI(title="Agentic Notepad Server") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # ── OpenAI-compatible request types ─────────────────────────────────────────── class Message(BaseModel): role: str content: Any class ChatRequest(BaseModel): model: Optional[str] = None # None → fall back to LLM_MODEL in agent config messages: List[Message] stream: bool = False temperature: Optional[float] = None # ── /v1/chat/completions ─────────────────────────────────────────────────────── @app.post("/v1/chat/completions") async def chat_completions(req: ChatRequest): msgs = [m.model_dump() for m in req.messages] model = req.model or None # pass None → agent uses LLM_MODEL from config print(f"[agent] ▶ using model: {model or 'default → ' + __import__('config').LLM_MODEL}", flush=True) if req.stream: async def event_stream(): async for token in stream_agent(msgs, model=model): chunk = { "id": "chatcmpl-agent", "object": "chat.completion.chunk", "choices": [{"delta": {"content": token}, "index": 0, "finish_reason": None}], } yield f"data: {json.dumps(chunk)}\n\n" done = { "id": "chatcmpl-agent", "object": "chat.completion.chunk", "choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}], } yield f"data: {json.dumps(done)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") text = await run_agent(msgs, model=model) return { "id": "chatcmpl-agent", "object": "chat.completion", "model": model or "gpt-4o-mini", "choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop", "index": 0}], } # ── Blood RAG routes ────────────────────────────────────────────────────────── @app.post("/api/blood-rag/upload") async def blood_rag_upload(file: UploadFile = File(...)): raw = await file.read() name = file.filename or "document" try: text = raw.decode("utf-8", errors="replace") except Exception: return {"error": "Could not decode file"} meta = await blood_rag.add_document(name, text) return meta @app.get("/api/blood-rag/documents") async def blood_rag_list(): return blood_rag.list_documents() @app.delete("/api/blood-rag/documents/{doc_id}") async def blood_rag_delete(doc_id: str): deleted = blood_rag.delete_document(doc_id) return {"deleted": deleted, "doc_id": doc_id} @app.get("/api/blood-rag/search") async def blood_rag_search(q: str, top_k: int = 4): result = await blood_rag.search(q, top_k=top_k) return {"query": q, "result": result} # ── Blood report routes ──────────────────────────────────────────────────────── @app.post("/api/reports/upload") async def upload_report(file: UploadFile = File(...)): raw = await file.read() return await reports.process_report_upload(raw, file.filename or "report.pdf") @app.get("/api/reports/current") async def current_report(): return reports.get_current_report() @app.delete("/api/reports/current") async def delete_report(): reports.clear_report() return {"status": "cleared"} # ── Notepad routes ───────────────────────────────────────────────────────────── @app.get("/notepad", response_class=HTMLResponse) async def notepad_page(): return _NOTEPAD_HTML @app.get("/notepad/events") async def notepad_events(): async def stream(): q = asyncio.Queue(maxsize=30) notepad.add_listener(q) try: yield f"data: {json.dumps(notepad.get_notes_snapshot())}\n\n" while True: try: payload = await asyncio.wait_for(q.get(), timeout=25) yield f"data: {payload}\n\n" except asyncio.TimeoutError: yield ": keepalive\n\n" finally: notepad.remove_listener(q) return StreamingResponse(stream(), media_type="text/event-stream") @app.post("/notepad/clear") async def clear_notepad_route(): notepad.clear_notepad() return {"status": "cleared"} @app.get("/notepad/content") async def notepad_content(): return notepad.get_notes_snapshot() # ── Notepad HTML ─────────────────────────────────────────────────────────────── _NOTEPAD_HTML = """ Live Notepad

Live Notepad

Notes appear here in real time as you speak to the avatar

Connecting…
No notes yet.
Tell the avatar: "Note that…" or "Write down that…"
""" if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=PORT, reload=True)