from __future__ import annotations import os from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from app.services.observability_summary import observability_summary from app.services.project_activity import ProjectActivityService from app.services.project_intelligence import ProjectIntelligenceService from app.services.project_service import ProjectService from app.services.student_memory import StudentMemoryService router = APIRouter(prefix="/api/projects", tags=["control-room"]) _project_service = ProjectService() _activity = ProjectActivityService() def _is_demo_mode() -> bool: return os.getenv("DEPLOYMENT_ENV", "desktop") == "demo" def _artifact_roots() -> dict[str, str]: home = Path.home() / ".studybuddy" return { "graphs": str(home / "graphs"), "sessions": str(home / "sessions"), "citation_graphs": str(home / "citation_graphs"), "cache": str(home / "cache"), "annotations": str(home / "annotations"), "summaries": str(home / "summaries"), "cognee": str(home / "cognee"), } def _exists(path: str) -> bool: return bool(path) and os.path.exists(path) def _count_files(path: str, suffix: str = "") -> int: if not os.path.isdir(path): return 0 return len([name for name in os.listdir(path) if not suffix or name.endswith(suffix)]) def _file_meta(path: str) -> tuple[int, float]: if not _exists(path): return 0, 0.0 try: return os.path.getsize(path), os.path.getmtime(path) except OSError: return 0, 0.0 def _artifact(id_: str, label: str, saved: bool, detail: str = "", path: str = "") -> dict[str, Any]: size, updated_at = _file_meta(path) if path else (0, 0.0) return { "id": id_, "key": id_, "label": label, "status": "saved" if saved else "missing", "exists": saved, "detail": detail, "path": path or detail, "size_bytes": size, "updated_at": updated_at, } def project_artifacts(project_id: str, document_id: str = "") -> list[dict[str, Any]]: roots = _artifact_roots() graph_path = os.path.join(roots["graphs"], f"{project_id}.json") doc_graph_path = os.path.join(roots["graphs"], f"doc_{document_id}.json") if document_id else "" session_path = os.path.join(roots["sessions"], f"{project_id}.json") citation_path = os.path.join(roots["citation_graphs"], f"{project_id}.json") annotation_count = _count_files(roots["annotations"], ".json") summary_count = _count_files(roots["summaries"], ".md") cache_count = _count_files(roots["cache"], ".json") return [ _artifact("project_graph", "Project graph", _exists(graph_path), graph_path, graph_path), _artifact("document_graph_cache", "Document graph cache", _exists(doc_graph_path), doc_graph_path, doc_graph_path), _artifact("project_snapshot", "Project snapshot", _exists(session_path), session_path, session_path), _artifact("citation_graph", "Citation Graph", _exists(citation_path), citation_path, citation_path), _artifact("output_cache", "Generated output cache", cache_count > 0, f"{cache_count} cached item(s)"), _artifact("annotations", "Annotations", annotation_count > 0, f"{annotation_count} annotation file(s)"), _artifact("summary", "Summary artifacts", summary_count > 0, f"{summary_count} markdown summary file(s)"), _artifact("cognee", "Cognee memory store", os.path.isdir(roots["cognee"]), roots["cognee"], roots["cognee"]), ] @router.get("/{project_id}/activity") def get_project_activity(project_id: str, limit: int = 50): if _project_service.load(project_id) is None: raise HTTPException(404, "Project not found") return {"project_id": project_id, "items": [row.model_dump() for row in _activity.list(project_id, limit=limit)]} @router.get("/{project_id}/artifacts") def get_project_artifacts(project_id: str): project = _project_service.load(project_id) if project is None: raise HTTPException(404, "Project not found") document_id = _project_service.derive_document_id(project) return {"project_id": project_id, "items": project_artifacts(project_id, document_id=document_id)} @router.get("/{project_id}/control-room") async def get_control_room(project_id: str, fast: bool = True): project = _project_service.load(project_id) if project is None: raise HTTPException(404, "Project not found") document_id = _project_service.derive_document_id(project) activities = [row.model_dump() for row in _activity.list(project_id, limit=25)] artifacts = project_artifacts(project_id, document_id=document_id) active_jobs = [row for row in activities if row.get("status") == "running"][:5] memory = ( { "project_id": project_id, "state": "disabled", "profile_dataset": False, "project_dataset": False, "inventory_ok": False, "provenance_ok": False, "export_ok": False, "last_error": "Cognee memory is disabled in demo mode.", } if _is_demo_mode() else { "project_id": project_id, "state": "refreshing" if active_jobs else "cached", "profile_dataset": None, "project_dataset": None, "inventory_ok": False, "provenance_ok": False, "export_ok": False, "last_error": "Memory inspection is loading in the background.", } if fast else await StudentMemoryService().memory_status(project_id, include_liveness=True) ) intelligence = ProjectIntelligenceService().status(project) return { "project": {**project.model_dump(), "document_id": document_id}, "brief": intelligence["brief"], "stats": intelligence["stats"], "settings_summary": intelligence["settings_summary"], "live_log": activities, "memory": memory, "memory_status": memory, "activity": activities, "artifacts": artifacts, "debug_artifacts": artifacts, "active_jobs": active_jobs, "observability": observability_summary(), }