Spaces:
Sleeping
Sleeping
| import asyncio | |
| import importlib.util | |
| import json | |
| import logging | |
| import sys | |
| import uuid | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| logger = logging.getLogger(__name__) | |
| # ββ SDK shadowing fix ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Load rag_agent.py directly via importlib so its own SDK-fix code runs first, | |
| # setting sys.modules['agents'] to the openai-agents SDK. After exec_module() | |
| # returns, `from agents import Runner` resolves to the SDK, not the local package. | |
| _rag_agent_path = Path(__file__).parent.parent / "agents" / "rag_agent.py" | |
| _spec = importlib.util.spec_from_file_location("agents.rag_agent", str(_rag_agent_path)) | |
| _rag_mod = importlib.util.module_from_spec(_spec) | |
| sys.modules["agents.rag_agent"] = _rag_mod | |
| _spec.loader.exec_module(_rag_mod) # runs rag_agent.py β SDK is now sys.modules['agents'] | |
| create_rag_agent = _rag_mod.create_rag_agent | |
| get_run_config = _rag_mod.get_run_config | |
| # Now safe to import SDK symbols β agents points to the SDK in sys.modules | |
| from agents import Runner # noqa: E402 | |
| from agents.stream_events import RawResponsesStreamEvent # noqa: E402 | |
| from fastapi import APIRouter, HTTPException # noqa: E402 | |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse # noqa: E402 | |
| from openai.types.chat import ChatCompletionChunk # noqa: E402 | |
| from openai.types.responses import ResponseTextDeltaEvent # noqa: E402 | |
| from pydantic import BaseModel # noqa: E402 | |
| from cache.query_cache import _cache as _disk_cache # noqa: E402 | |
| from cache.query_cache import find_similar_cached, get_cache_stats, save_to_cache # noqa: E402 | |
| from db.history import list_sessions, load_history, save_turn # noqa: E402 | |
| from ingestion import ingest_all_documents # noqa: E402 | |
| from scraper.content_store import is_content_stale, load_latest_content # noqa: E402 | |
| from tools.list_documents_tool import list_indexed_documents # noqa: E402 | |
| router = APIRouter() | |
| # ββ request / response models ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class HistoryMessage(BaseModel): | |
| role: str # "user" | "assistant" | |
| content: str | |
| class ChatRequest(BaseModel): | |
| message: str | |
| session_id: str = "" | |
| history: list[HistoryMessage] = [] | |
| class FeedbackRequest(BaseModel): | |
| session_id: str = "" | |
| message: str # the user question that was answered | |
| rating: int # 1 (bad) β 5 (excellent) | |
| comment: str = "" # optional free-text comment | |
| # ββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_input(db_history: list[dict], message: str) -> list[dict]: | |
| """Merge DB history with the current message into Agents SDK input format.""" | |
| messages = [{"role": t["role"], "content": t["content"]} for t in db_history] | |
| messages.append({"role": "user", "content": message}) | |
| return messages | |
| async def _stream_and_cache(request: ChatRequest, db_history: list[dict]): | |
| """Stream token deltas as SSE, then persist the turn and save to cache.""" | |
| # Signal immediately that the agent is working so the frontend can show a spinner | |
| yield f"data: {json.dumps({'status': 'thinking'})}\n\n" | |
| full_text: list[str] = [] | |
| try: | |
| agent = create_rag_agent() | |
| result = Runner.run_streamed( | |
| agent, | |
| _build_input(db_history, request.message), | |
| run_config=get_run_config(), | |
| ) | |
| async for event in result.stream_events(): | |
| if not isinstance(event, RawResponsesStreamEvent): | |
| continue | |
| data = event.data | |
| delta: str | None = None | |
| if isinstance(data, ResponseTextDeltaEvent): | |
| # Responses API (OpenAI native) | |
| delta = data.delta | |
| elif isinstance(data, ChatCompletionChunk): | |
| # Chat Completions API (Gemini compatible) | |
| delta = data.choices[0].delta.content if data.choices else None | |
| if delta: | |
| full_text.append(delta) | |
| yield f"data: {json.dumps({'delta': delta})}\n\n" | |
| except Exception as exc: | |
| error_msg = ( | |
| "I'm sorry, I ran into a technical issue while processing your request. " | |
| "Please try again, or contact AgentRax support if the problem persists." | |
| ) | |
| yield f"data: {json.dumps({'error': error_msg, 'detail': str(exc)})}\n\n" | |
| finally: | |
| yield "data: [DONE]\n\n" | |
| if full_text: | |
| response_text = "".join(full_text) | |
| save_to_cache(request.message, response_text, []) | |
| if request.session_id: | |
| await asyncio.to_thread(save_turn, request.session_id, request.message, response_text) | |
| # ββ routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def chat(request: ChatRequest): | |
| """Return AgentRax answer, served from semantic cache when possible. | |
| Flow: | |
| 1. Load conversation history from PostgreSQL by session_id. | |
| 2. Check semantic cache (threshold 0.92). On HIT: return immediately, save turn. | |
| 3. On MISS: stream agent response, save to cache + DB on completion. | |
| Cache HIT β JSON { answer, sources } with X-Cache: HIT (zero LLM cost) | |
| Cache MISS β SSE token stream with X-Cache: MISS | |
| """ | |
| # Load DB history first β used by both HIT and MISS paths for context | |
| db_history = await asyncio.to_thread(load_history, request.session_id) | |
| cached = find_similar_cached(request.message) | |
| if cached: | |
| # Still persist the cached answer so history is complete | |
| if request.session_id: | |
| await asyncio.to_thread( | |
| save_turn, request.session_id, request.message, cached["answer"] | |
| ) | |
| return JSONResponse( | |
| content={"answer": cached["answer"], "sources": cached.get("sources", [])}, | |
| headers={"X-Cache": "HIT"}, | |
| ) | |
| return StreamingResponse( | |
| _stream_and_cache(request, db_history), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| "X-Cache": "MISS", | |
| }, | |
| ) | |
| async def get_sessions(): | |
| """Return all sessions ordered by last activity, with title and message count.""" | |
| try: | |
| sessions = await asyncio.to_thread(list_sessions) | |
| except Exception as exc: | |
| raise HTTPException(status_code=500, detail=str(exc)) from exc | |
| # Serialize datetime objects to ISO strings | |
| return { | |
| "sessions": [ | |
| {**s, "last_active": s["last_active"].isoformat() if s.get("last_active") else None} | |
| for s in sessions | |
| ] | |
| } | |
| async def get_history(session_id: str): | |
| """Return full conversation history for a session from PostgreSQL.""" | |
| try: | |
| turns = await asyncio.to_thread(load_history, session_id, 100) | |
| except Exception as exc: | |
| raise HTTPException(status_code=500, detail=str(exc)) from exc | |
| return {"session_id": session_id, "history": turns} | |
| async def cache_stats(): | |
| """Return hit counts and top queries from the semantic cache.""" | |
| return get_cache_stats() | |
| async def cache_clear(): | |
| """Wipe all entries from the semantic cache (admin use only).""" | |
| _disk_cache.clear() | |
| return {"status": "ok", "message": "Cache cleared."} | |
| async def site_status(): | |
| """Return metadata about the last scraped AgentRax snapshot.""" | |
| content = load_latest_content() | |
| if not content: | |
| return {"status": "no_data", "is_stale": True} | |
| return { | |
| "title": content.get("title"), | |
| "url": content.get("url"), | |
| "scraped_at": content.get("scraped_at") or content.get("saved_at"), | |
| "is_stale": is_content_stale(), | |
| } | |
| async def ingest(): | |
| """Scan documents/ and index all .pdf, .docx, and .md files into ChromaDB.""" | |
| try: | |
| result = await asyncio.to_thread(ingest_all_documents) | |
| except Exception as exc: | |
| raise HTTPException(status_code=500, detail=str(exc)) from exc | |
| return { | |
| "status": "ok", | |
| "documents_indexed": result.get("ingested", 0), | |
| "total_chunks": result.get("total_chunks", 0), | |
| } | |
| async def ingest_frontend_docs(): | |
| """Index .md documentation files from the AgentraX frontend docs/ folder. | |
| Automatically locates D:/Agentrax-Updated-Backend/AgentraX_Frontend/docs/ | |
| and indexes all Markdown files (ERD, FRD, implementation notes, etc.) | |
| into ChromaDB so the help agent can answer detailed technical questions. | |
| """ | |
| # Path: api/routes.py β api/ β AgentraXhelpAgent/ β Agentrax-Updated-Backend/ β AgentraX_Frontend/docs/ | |
| frontend_docs = Path(__file__).parent.parent.parent / "AgentraX_Frontend" / "docs" | |
| if not frontend_docs.exists(): | |
| raise HTTPException( | |
| status_code=404, | |
| detail=f"Frontend docs directory not found at: {frontend_docs}", | |
| ) | |
| md_files = list(frontend_docs.glob("*.md")) | |
| if not md_files: | |
| return {"status": "ok", "message": "No .md files found in frontend docs.", "documents_indexed": 0} | |
| try: | |
| result = await asyncio.to_thread(ingest_all_documents, [frontend_docs]) | |
| except Exception as exc: | |
| raise HTTPException(status_code=500, detail=str(exc)) from exc | |
| return { | |
| "status": "ok", | |
| "source": str(frontend_docs), | |
| "documents_indexed": result.get("ingested", 0), | |
| "total_chunks": result.get("total_chunks", 0), | |
| "files": result.get("files", []), | |
| } | |
| async def documents(): | |
| """Return all source file names currently indexed in the vector store.""" | |
| try: | |
| docs = await asyncio.to_thread(list_indexed_documents) | |
| except Exception as exc: | |
| raise HTTPException(status_code=500, detail=str(exc)) from exc | |
| return {"documents": docs} | |
| async def feedback(request: FeedbackRequest): | |
| """Accept user feedback (1β5 star rating) on a chat response. | |
| Feedback is appended to feedback_log.jsonl in the project root. | |
| Useful for identifying gaps in the knowledge base or poor responses. | |
| """ | |
| if not (1 <= request.rating <= 5): | |
| raise HTTPException(status_code=422, detail="Rating must be between 1 and 5.") | |
| entry = { | |
| "id": str(uuid.uuid4()), | |
| "session_id": request.session_id, | |
| "message": request.message, | |
| "rating": request.rating, | |
| "comment": request.comment, | |
| "submitted_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| log_path = Path(__file__).parent.parent / "feedback_log.jsonl" | |
| try: | |
| with log_path.open("a", encoding="utf-8") as f: | |
| f.write(json.dumps(entry, ensure_ascii=False) + "\n") | |
| except Exception as exc: | |
| logger.exception("Failed to write feedback entry.") | |
| raise HTTPException(status_code=500, detail="Could not save feedback.") from exc | |
| return {"status": "ok", "message": "Thank you for your feedback!"} | |
| async def health(): | |
| """Liveness check.""" | |
| return {"status": "ok"} | |