"""FastAPI route definitions.""" from pathlib import Path from fastapi import APIRouter, HTTPException from pydantic import BaseModel import atocore.config as _config from atocore.context.builder import ( build_context, get_last_context_pack, _pack_to_dict, ) from atocore.context.project_state import ( CATEGORIES, get_state, invalidate_state, set_state, ) from atocore.ingestion.pipeline import ( get_ingestion_stats, get_source_status, ingest_configured_sources, ingest_file, ingest_folder, ) from atocore.memory.service import ( MEMORY_TYPES, create_memory, get_memories, invalidate_memory, supersede_memory, update_memory, ) from atocore.observability.logger import get_logger from atocore.retrieval.retriever import retrieve from atocore.retrieval.vector_store import get_vector_store router = APIRouter() log = get_logger("api") # --- Request/Response models --- class IngestRequest(BaseModel): path: str class IngestResponse(BaseModel): results: list[dict] class IngestSourcesResponse(BaseModel): results: list[dict] class QueryRequest(BaseModel): prompt: str top_k: int = 10 filter_tags: list[str] | None = None class QueryResponse(BaseModel): results: list[dict] class ContextBuildRequest(BaseModel): prompt: str project: str | None = None budget: int | None = None class ContextBuildResponse(BaseModel): formatted_context: str full_prompt: str chunks_used: int total_chars: int budget: int budget_remaining: int duration_ms: int chunks: list[dict] class MemoryCreateRequest(BaseModel): memory_type: str content: str project: str = "" confidence: float = 1.0 class MemoryUpdateRequest(BaseModel): content: str | None = None confidence: float | None = None status: str | None = None class ProjectStateSetRequest(BaseModel): project: str category: str key: str value: str source: str = "" confidence: float = 1.0 class ProjectStateGetRequest(BaseModel): project: str category: str | None = None class ProjectStateInvalidateRequest(BaseModel): project: str category: str key: str # --- Endpoints --- @router.post("/ingest", response_model=IngestResponse) def api_ingest(req: IngestRequest) -> IngestResponse: """Ingest a markdown file or folder.""" target = Path(req.path) try: if target.is_file(): results = [ingest_file(target)] elif target.is_dir(): results = ingest_folder(target) else: raise HTTPException(status_code=404, detail=f"Path not found: {req.path}") except HTTPException: raise except Exception as e: log.error("ingest_failed", path=req.path, error=str(e)) raise HTTPException(status_code=500, detail=f"Ingestion failed: {e}") return IngestResponse(results=results) @router.post("/ingest/sources", response_model=IngestSourcesResponse) def api_ingest_sources() -> IngestSourcesResponse: """Ingest enabled configured source directories.""" try: results = ingest_configured_sources() except Exception as e: log.error("ingest_sources_failed", error=str(e)) raise HTTPException(status_code=500, detail=f"Configured source ingestion failed: {e}") return IngestSourcesResponse(results=results) @router.post("/query", response_model=QueryResponse) def api_query(req: QueryRequest) -> QueryResponse: """Retrieve relevant chunks for a prompt.""" try: chunks = retrieve(req.prompt, top_k=req.top_k, filter_tags=req.filter_tags) except Exception as e: log.error("query_failed", prompt=req.prompt[:100], error=str(e)) raise HTTPException(status_code=500, detail=f"Query failed: {e}") return QueryResponse( results=[ { "chunk_id": c.chunk_id, "content": c.content, "score": c.score, "heading_path": c.heading_path, "source_file": c.source_file, "title": c.title, } for c in chunks ] ) @router.post("/context/build", response_model=ContextBuildResponse) def api_build_context(req: ContextBuildRequest) -> ContextBuildResponse: """Build a full context pack for a prompt.""" try: pack = build_context( user_prompt=req.prompt, project_hint=req.project, budget=req.budget, ) except Exception as e: log.error("context_build_failed", prompt=req.prompt[:100], error=str(e)) raise HTTPException(status_code=500, detail=f"Context build failed: {e}") pack_dict = _pack_to_dict(pack) return ContextBuildResponse( formatted_context=pack.formatted_context, full_prompt=pack.full_prompt, chunks_used=len(pack.chunks_used), total_chars=pack.total_chars, budget=pack.budget, budget_remaining=pack.budget_remaining, duration_ms=pack.duration_ms, chunks=pack_dict["chunks"], ) @router.post("/memory") def api_create_memory(req: MemoryCreateRequest) -> dict: """Create a new memory entry.""" try: mem = create_memory( memory_type=req.memory_type, content=req.content, project=req.project, confidence=req.confidence, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return {"status": "ok", "id": mem.id, "memory_type": mem.memory_type} @router.get("/memory") def api_get_memories( memory_type: str | None = None, project: str | None = None, active_only: bool = True, min_confidence: float = 0.0, limit: int = 50, ) -> dict: """List memories, optionally filtered.""" memories = get_memories( memory_type=memory_type, project=project, active_only=active_only, min_confidence=min_confidence, limit=limit, ) return { "memories": [ { "id": m.id, "memory_type": m.memory_type, "content": m.content, "project": m.project, "confidence": m.confidence, "status": m.status, "updated_at": m.updated_at, } for m in memories ], "types": MEMORY_TYPES, } @router.put("/memory/{memory_id}") def api_update_memory(memory_id: str, req: MemoryUpdateRequest) -> dict: """Update an existing memory.""" try: success = update_memory( memory_id=memory_id, content=req.content, confidence=req.confidence, status=req.status, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not success: raise HTTPException(status_code=404, detail="Memory not found") return {"status": "updated", "id": memory_id} @router.delete("/memory/{memory_id}") def api_invalidate_memory(memory_id: str) -> dict: """Invalidate a memory (error correction).""" success = invalidate_memory(memory_id) if not success: raise HTTPException(status_code=404, detail="Memory not found") return {"status": "invalidated", "id": memory_id} @router.post("/project/state") def api_set_project_state(req: ProjectStateSetRequest) -> dict: """Set or update a trusted project state entry.""" try: entry = set_state( project_name=req.project, category=req.category, key=req.key, value=req.value, source=req.source, confidence=req.confidence, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: log.error("set_state_failed", error=str(e)) raise HTTPException(status_code=500, detail=f"Failed to set state: {e}") return {"status": "ok", "id": entry.id, "category": entry.category, "key": entry.key} @router.get("/project/state/{project_name}") def api_get_project_state(project_name: str, category: str | None = None) -> dict: """Get trusted project state entries.""" entries = get_state(project_name, category=category) return { "project": project_name, "entries": [ { "id": e.id, "category": e.category, "key": e.key, "value": e.value, "source": e.source, "confidence": e.confidence, "status": e.status, "updated_at": e.updated_at, } for e in entries ], "categories": CATEGORIES, } @router.delete("/project/state") def api_invalidate_project_state(req: ProjectStateInvalidateRequest) -> dict: """Invalidate (supersede) a project state entry.""" success = invalidate_state(req.project, req.category, req.key) if not success: raise HTTPException(status_code=404, detail="State entry not found or already invalidated") return {"status": "invalidated", "project": req.project, "category": req.category, "key": req.key} @router.get("/health") def api_health() -> dict: """Health check.""" store = get_vector_store() source_status = get_source_status() return { "status": "ok", "version": "0.1.0", "vectors_count": store.count, "env": _config.settings.env, "machine_paths": { "db_path": str(_config.settings.db_path), "chroma_path": str(_config.settings.chroma_path), "log_dir": str(_config.settings.resolved_log_dir), "backup_dir": str(_config.settings.resolved_backup_dir), "run_dir": str(_config.settings.resolved_run_dir), }, "sources_ready": all( (not source["enabled"]) or (source["exists"] and source["is_dir"]) for source in source_status ), "source_status": source_status, } @router.get("/sources") def api_sources() -> dict: """Return configured ingestion source directories and readiness.""" return { "sources": get_source_status(), "vault_enabled": _config.settings.source_vault_enabled, "drive_enabled": _config.settings.source_drive_enabled, } @router.get("/stats") def api_stats() -> dict: """Ingestion statistics.""" return get_ingestion_stats() @router.get("/debug/context") def api_debug_context() -> dict: """Inspect the last assembled context pack.""" pack = get_last_context_pack() if pack is None: return {"message": "No context pack built yet."} return _pack_to_dict(pack)