Spaces:
Sleeping
Sleeping
| """Project registry API -> the persistent, growable unit (1B foundation). | |
| Replaces the throwaway session-UUID model for new work. Old /library/history | |
| stays for legacy on-disk data (no migration). | |
| """ | |
| import asyncio | |
| import os | |
| import time | |
| from typing import List | |
| from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile | |
| from pydantic import BaseModel | |
| from app.rag.ingestion import LIBRARY_COLLECTION, ingest_file | |
| from app.routers.session import set_ingest_status | |
| from app.schemas.project import Project, ProjectDetail, ProjectFile, ProjectUpdateRequest | |
| from app.services.project_files import delete_project_uploads, project_upload_dir, remove_file_from_project | |
| from app.services.project_service import ProjectService | |
| from app.services.project_activity import ProjectActivityService | |
| from app.websockets.handlers import get_db, get_graph_manager | |
| router = APIRouter(prefix="/projects", tags=["projects"]) | |
| _service = ProjectService() | |
| _activity = ProjectActivityService() | |
| _PDF_CACHE_DIR = os.path.expanduser("~/.studybuddy/pdfs") | |
| def _is_demo_mode() -> bool: | |
| return os.getenv("DEPLOYMENT_ENV", "desktop") == "demo" | |
| class CreateProjectRequest(BaseModel): | |
| name: str | |
| intention: str = "learn" | |
| def _valid_project_id(project_id: str) -> bool: | |
| """Reject path-traversal payloads before they ever reach ProjectService._path. | |
| project_id is interpolated directly into a filesystem path | |
| (ProjectService._path); a value containing "/", "\\", or ".." could | |
| otherwise escape the registry root. | |
| """ | |
| return bool(project_id) and not any(c in project_id for c in ("/", "\\", "..")) | |
| def _project_detail(project: Project) -> ProjectDetail: | |
| return ProjectDetail(**project.model_dump(), document_id=_service.derive_document_id(project)) | |
| def _sync_project_files(project: Project, *, lock_held: bool = False) -> Project: | |
| """Drop registry entries whose project-upload file is no longer on disk.""" | |
| if not lock_held: | |
| with _service.mutation_sync(project.project_id): | |
| current = _service.load(project.project_id) | |
| return _sync_project_files(current or project, lock_held=True) | |
| upload_dir = project_upload_dir(project.project_id) | |
| existing = [f for f in project.files if os.path.isfile(os.path.join(upload_dir, f.filename))] | |
| if len(existing) != len(project.files): | |
| project.files = existing | |
| project.updated_at = time.time() | |
| _service.save(project) | |
| return project | |
| def _unique_project_filename(upload_dir: str, filename: str, file_id: str) -> str: | |
| stem, ext = os.path.splitext(os.path.basename(filename or "upload")) | |
| candidate = f"{stem}{ext}" | |
| path = os.path.join(upload_dir, candidate) | |
| if not os.path.exists(path): | |
| return candidate | |
| try: | |
| with open(path, "rb") as f: | |
| if ProjectService.file_id_for(f.read()) == file_id: | |
| return candidate | |
| except OSError: | |
| pass | |
| return f"{stem}-{file_id[:8]}{ext}" | |
| async def create_project(req: CreateProjectRequest): | |
| project = _service.create(req.name, req.intention) | |
| # Project memory is created lazily in Chroma on the first grounded | |
| # observation. Cognee is reserved for the cross-project student profile. | |
| _activity.record(project.project_id, "project_created", "Project created", metadata={"name": project.name}) | |
| return _project_detail(project) | |
| def list_projects(): | |
| return {"items": [_project_detail(_sync_project_files(p)).model_dump() for p in _service.list()]} | |
| async def rebuild_project_evidence(project_id: str): | |
| """Recreate derived evidence from preserved PDFs, regions, and annotations.""" | |
| from app.services.evidence_rebuild import EvidenceRebuildService | |
| get_graph_manager().clear_session(project_id) | |
| return await asyncio.get_running_loop().run_in_executor( | |
| None, | |
| EvidenceRebuildService().rebuild_project, | |
| project_id, | |
| ) | |
| def rebuild_project_evidence_status(project_id: str): | |
| from app.services.evidence_rebuild import EvidenceRebuildService | |
| return EvidenceRebuildService().status(project_id) | |
| def get_project(project_id: str): | |
| if not _valid_project_id(project_id): | |
| raise HTTPException(404, "Project not found") | |
| p = _service.load(project_id) | |
| if p is None: | |
| raise HTTPException(404, "Project not found") | |
| p = _sync_project_files(p) | |
| return _project_detail(p) | |
| def update_project(project_id: str, req: ProjectUpdateRequest): | |
| if not _valid_project_id(project_id): | |
| raise HTTPException(404, "Project not found") | |
| project = _service.update( | |
| project_id, | |
| name=req.name, | |
| intention=req.intention.value if req.intention is not None else None, | |
| title=req.title, | |
| ) | |
| if project is None: | |
| raise HTTPException(404, "Project not found") | |
| _activity.record(project_id, "project_updated", "Project details updated", metadata={"name": project.name}) | |
| return _project_detail(_sync_project_files(project)) | |
| def delete_project(project_id: str): | |
| if not _valid_project_id(project_id): | |
| raise HTTPException(404, "Project not found") | |
| with _service.mutation_sync(project_id): | |
| if _service.load(project_id) is None: | |
| raise HTTPException(404, "Project not found") | |
| try: | |
| from app.rag.evidence_ingestion import EvidenceIngestionService | |
| EvidenceIngestionService().reset_project(project_id) | |
| except Exception as exc: | |
| raise HTTPException(500, "Project evidence cleanup failed; project was preserved") from exc | |
| _service.delete(project_id) | |
| delete_project_uploads(project_id) | |
| try: | |
| from app.services.visual_lesson_store import VisualLessonStore | |
| VisualLessonStore().delete_project(project_id) | |
| except Exception: | |
| import logging | |
| logging.getLogger(__name__).exception("Visual lesson cleanup failed for %s", project_id) | |
| return {"status": "deleted"} | |
| async def add_files(project_id: str, background_tasks: BackgroundTasks, files: List[UploadFile] = File(...)): | |
| if not _valid_project_id(project_id): | |
| raise HTTPException(404, "Project not found") | |
| if len(files) > 5: | |
| raise HTTPException(400, "Add at most 5 files at a time") | |
| incoming = [(await file.read(), file.filename or "upload") for file in files] | |
| to_chunk: list[tuple[bytes, str]] = [] | |
| async with _service.mutation(project_id): | |
| project = _service.load(project_id) | |
| if project is None: | |
| raise HTTPException(404, "Project not found") | |
| project = _sync_project_files(project, lock_held=True) | |
| upload_dir = project_upload_dir(project_id) | |
| os.makedirs(upload_dir, exist_ok=True) | |
| os.makedirs(_PDF_CACHE_DIR, exist_ok=True) | |
| existing_ids = {file.file_id for file in project.files} | |
| for content, supplied_filename in incoming: | |
| file_id = ProjectService.file_id_for(content) | |
| cache_path = os.path.join(_PDF_CACHE_DIR, f"{file_id}.pdf") | |
| if not os.path.exists(cache_path): | |
| with open(cache_path, "wb") as handle: | |
| handle.write(content) | |
| if file_id not in existing_ids: | |
| filename = _unique_project_filename(upload_dir, supplied_filename, file_id) | |
| with open(os.path.join(upload_dir, filename), "wb") as handle: | |
| handle.write(content) | |
| project.files.append(ProjectFile(filename=filename, file_id=file_id)) | |
| existing_ids.add(file_id) | |
| to_chunk.append((content, filename)) | |
| project.updated_at = time.time() | |
| _service.save(project) | |
| if to_chunk: | |
| _activity.record( | |
| project_id, | |
| "paper_added", | |
| f"Added {len(to_chunk)} paper{'s' if len(to_chunk) != 1 else ''}", | |
| metadata={"filenames": [filename for _, filename in to_chunk]}, | |
| ) | |
| db = get_db() | |
| set_ingest_status(project_id, "indexing") | |
| if to_chunk: | |
| _activity.record(project_id, "ingest", "Parsing and indexing uploaded material", status="running") | |
| def _chunk_all(): | |
| try: | |
| total = 0 | |
| for content, filename in to_chunk: | |
| total += ingest_file( | |
| content, filename, LIBRARY_COLLECTION, db=db, | |
| skip_if_indexed=False, project_id=project_id, | |
| ) | |
| set_ingest_status(project_id, "ready") | |
| if to_chunk: | |
| _activity.record(project_id, "ingest", "Structured evidence indexing complete", metadata={"evidence_units": total}) | |
| if _is_demo_mode(): | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map skipped in demo mode", status="warning") | |
| return | |
| try: | |
| import asyncio | |
| from app.services.citation_graph import CitationGraphService | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map refreshing", status="running") | |
| asyncio.run(CitationGraphService().refresh(project_id)) | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map refreshed") | |
| except Exception: | |
| import logging | |
| logging.getLogger(__name__).exception("citation graph refresh failed for %s", project_id) | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map refresh failed", status="warning") | |
| except Exception: | |
| import logging | |
| logging.getLogger(__name__).exception("structured evidence indexing failed for %s", project_id) | |
| _activity.record(project_id, "ingest", "Structured evidence indexing failed", status="error") | |
| set_ingest_status(project_id, "error") | |
| background_tasks.add_task(_chunk_all) | |
| return _project_detail(project) | |
| async def remove_file(project_id: str, file_id: str): | |
| if not _valid_project_id(project_id): | |
| raise HTTPException(404, "Project not found") | |
| async with _service.mutation(project_id): | |
| project = _service.load(project_id) | |
| if project is None: | |
| raise HTTPException(404, "Project not found") | |
| project = _sync_project_files(project, lock_held=True) | |
| entry = next((file for file in project.files if file.file_id == file_id), None) | |
| if entry is None: | |
| raise HTTPException(404, "File not in project") | |
| previous_document_set_id = _service.derive_document_id(project) | |
| # Derived cleanup and source removal must finish before the registry | |
| # claims the paper is gone. Cleanup failures therefore leave the | |
| # project/file visible and retryable. | |
| await remove_file_from_project(project_id, entry.filename) | |
| project.files = [file for file in project.files if file.file_id != file_id] | |
| project.updated_at = time.time() | |
| _service.save(project) | |
| graph_manager = get_graph_manager() | |
| graph_manager.clear_session(project_id) | |
| graph_manager.clear_document_graph(previous_document_set_id) | |
| graph_manager.clear_document_graph(_service.derive_document_id(project)) | |
| _activity.record(project_id, "paper_removed", "Removed paper", metadata={"filename": entry.filename, "file_id": file_id}) | |
| if _is_demo_mode(): | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map skipped in demo mode", status="warning") | |
| return _project_detail(project) | |
| try: | |
| from app.services.citation_graph import CitationGraphService | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map refreshing", status="running") | |
| await CitationGraphService().refresh(project_id) | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map refreshed") | |
| except Exception: | |
| import logging | |
| logging.getLogger(__name__).exception("citation graph refresh failed for %s", project_id) | |
| _activity.record(project_id, "citation_graph_refresh", "Citation map refresh failed", status="warning") | |
| return _project_detail(project) | |