study-buddy / app /main.py
GitHub Actions
deploy d092bea3608b7a29952f16357fda39b7a29e399b
2e818da
Raw
History Blame Contribute Delete
9.31 kB
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)