Spaces:
Sleeping
Sleeping
File size: 9,310 Bytes
2e818da | 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 | import os
from pathlib import Path
from contextlib import asynccontextmanager
from dotenv import load_dotenv
# Must run before any local import that reads env vars. Resolve explicitly so
# Electron, uv, and direct uvicorn launches all see backend/.env regardless of cwd.
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from uvicorn.protocols.utils import ClientDisconnected
from fastapi.middleware.cors import CORSMiddleware
from app.routers import agent_control, annotations, citation_graph, control_room, health, library, projects, regions, sandbox, session, review, chat, draft, wiki, voice, visual_lessons
from app.websockets.handlers import get_connection_manager, get_db, handle_event
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
import logging
import os
import time
logger = logging.getLogger(__name__)
# Observability boundary: a sibling startup step to the Cognee bootstrap
# below (same timed pattern). Best-effort -> a telemetry init failure must
# never block the app from serving, so it degrades to disabled internally.
from app.observability.bootstrap import initialize_observability, shutdown_observability
from app.observability.config import get_observability_config
obs_cfg = get_observability_config()
try:
obs_t0 = time.perf_counter()
initialize_observability(app=app, config=obs_cfg)
if obs_cfg.mode != "disabled":
logger.warning(
"Observability init (mode=%s) took %.2fs",
obs_cfg.mode,
time.perf_counter() - obs_t0,
)
except Exception:
logger.exception("Observability init failed")
async def _shutdown_observability() -> None:
# Hard wall-clock deadline: a hung/unreachable OTLP collector must not
# stall process teardown on flush.
try:
await asyncio.wait_for(asyncio.to_thread(shutdown_observability, 5.0), timeout=6.0)
except Exception:
logger.exception("Observability shutdown failed")
try:
if os.getenv("DEPLOYMENT_ENV", "desktop") == "demo":
print("Demo mode: skipping Cognee bootstrap")
yield
return
await _run_cognee_bootstrap(logger)
yield
finally:
await _shutdown_observability()
async def _run_cognee_bootstrap(logger) -> None:
import asyncio
import logging
import os
import time
from pathlib import Path
# Cerebras has no embeddings endpoint -> Cognee's default embedding model
# (openai/text-embedding-3-large) would otherwise be routed through the
# OPENAI_API_BASE override above and 404. Use a local, fully offline
# embedding model instead (matches CLAUDE.md: Cognee never writes to cloud).
os.environ.setdefault("EMBEDDING_PROVIDER", "fastembed")
os.environ.setdefault("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
os.environ.setdefault("EMBEDDING_DIMENSIONS", "384")
skill_root = str(Path(__file__).resolve().parent / "memory_skills")
existing_skill_roots = os.environ.get("COGNEE_SKILL_SOURCE_ROOTS", "")
if skill_root not in existing_skill_roots.split(os.pathsep):
os.environ["COGNEE_SKILL_SOURCE_ROOTS"] = (
skill_root if not existing_skill_roots else existing_skill_roots + os.pathsep + skill_root
)
# Disable multi-tenant access control for Cognee 1.2+ since StudyBuddy uses a single local user
os.environ["ENABLE_BACKEND_ACCESS_CONTROL"] = "false"
t0 = time.perf_counter()
import cognee
try:
import importlib
from app.services.cognee_bootstrap import configure_cognee_llm
cognee_llm_client = importlib.import_module(
"cognee.infrastructure.llm.structured_output_framework.litellm_instructor.llm.get_llm_client"
)
configure_cognee_llm(
cognee.config,
clear_llm_client_cache=cognee_llm_client._get_llm_client_cached.cache_clear,
)
# Suppress expected "No data found" and "DatabaseNotCreatedError" logs on fresh installs
logging.getLogger("cognee.shared.logging_utils").setLevel(logging.CRITICAL)
root = str(Path.home() / ".studybuddy" / "cognee")
cognee.config.data_root_directory(root)
cognee.config.system_root_directory(f"{root}/system")
from cognee.infrastructure.databases.relational.create_db_and_tables import create_db_and_tables
await create_db_and_tables()
logger.warning("Cognee mandatory bootstrap took %.2fs", time.perf_counter() - t0)
# Bootstrap the one cross-project student-profile dataset. Project
# research memory is intentionally Chroma-backed and never creates a
# Cognee dataset.
from app.services.student_memory import StudentMemoryService
async def _warm_profile_dataset() -> None:
warm_t0 = time.perf_counter()
try:
await StudentMemoryService().ensure_profile_dataset()
logger.warning("Cognee profile dataset warmup took %.2fs", time.perf_counter() - warm_t0)
except Exception:
logger.exception("Cognee profile dataset warmup failed")
asyncio.create_task(_warm_profile_dataset())
except Exception as e:
print("Cognee setup error:", e)
app = FastAPI(title="ResearchMate API", lifespan=lifespan)
_origins_env = os.getenv("ALLOWED_ORIGINS", "")
_origins = (
_origins_env.split(",")
if _origins_env
else [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
]
)
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(control_room.router)
app.include_router(agent_control.router)
app.include_router(library.router)
app.include_router(projects.router)
app.include_router(sandbox.router)
app.include_router(session.router)
app.include_router(annotations.router)
app.include_router(regions.router)
app.include_router(review.router)
app.include_router(citation_graph.router)
app.include_router(chat.router, prefix="/chat", tags=["Chat"])
app.include_router(draft.router)
app.include_router(wiki.router)
app.include_router(voice.router)
app.include_router(visual_lessons.router)
@app.websocket("/ws/{project_id}")
async def websocket_endpoint(ws: WebSocket, project_id: str):
import asyncio
import logging
logger = logging.getLogger(__name__)
cm = get_connection_manager()
background_events: set[asyncio.Task[None]] = set()
async def run_event(event_type: str, data: dict) -> None:
try:
await handle_event(project_id, event_type, data)
if event_type in {"CHAT_TURN", "CONTEXT_CARD_REQUEST", "VISUALIZATION_REQUEST", "CHAT_VISUAL_REQUEST", "CHAT_COMPACT"}:
logger.info("websocket event done project=%s type=%s", project_id, event_type)
except (WebSocketDisconnect, ClientDisconnected, RuntimeError) as exc:
logger.info("websocket event aborted project=%s type=%s detail=%s", project_id, event_type, exc)
except Exception:
logger.exception("handle_event failed for session %s event %s", project_id, event_type)
await cm.send(project_id, "ERROR", {
"event_type": event_type,
"message": "Something went wrong processing that -> please try again.",
})
await cm.connect(project_id, ws)
logger.info("websocket connected project=%s", project_id)
try:
while True:
msg = await ws.receive_json()
event_type = msg.get("type", "")
if event_type in {"CHAT_TURN", "CONTEXT_CARD_REQUEST", "VISUALIZATION_REQUEST", "CHAT_VISUAL_REQUEST"}:
data = msg.get("data", {}) or {}
logger.info(
"websocket event start project=%s type=%s selection_len=%s has_image=%s",
project_id,
event_type,
len(data.get("selection_text") or ""),
bool(data.get("selection_image_base64")),
)
data = msg.get("data", {}) or {}
if event_type in {"CHAT_TURN", "CONTEXT_CARD_REQUEST", "VISUALIZATION_REQUEST", "CHAT_VISUAL_REQUEST", "CHAT_COMPACT"}:
task = asyncio.create_task(run_event(event_type, data))
background_events.add(task)
task.add_done_callback(background_events.discard)
else:
await run_event(event_type, data)
except WebSocketDisconnect as exc:
logger.info("websocket disconnected project=%s code=%s", project_id, getattr(exc, "code", None))
cm.disconnect(project_id, ws)
except RuntimeError as exc:
logger.info("websocket closed project=%s detail=%s", project_id, exc)
cm.disconnect(project_id, ws)
finally:
for task in background_events:
task.cancel()
if background_events:
await asyncio.gather(*background_events, return_exceptions=True)
|