Spaces:
Sleeping
Sleeping
| """ | |
| FastAPI application — REST API chuẩn production cho Study Group Assistant. | |
| Khởi động: | |
| uvicorn src.api:app --host 0.0.0.0 --port 8000 --reload | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import uvicorn | |
| import tempfile | |
| import uuid | |
| from asyncio import get_running_loop | |
| from concurrent.futures import ThreadPoolExecutor | |
| from contextlib import asynccontextmanager | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile, status | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel, Field | |
| from src.core import final_answer | |
| from src.qdrant_store import get_custom_prompt, save_custom_prompt | |
| from src.redis_client import redis_client | |
| logger = logging.getLogger(__name__) | |
| _executor = ThreadPoolExecutor() | |
| # ── Lifespan ────────────────────────────────────────────────────────────────── | |
| async def lifespan(app: FastAPI): | |
| logger.info("Study Group Assistant API starting up.") | |
| yield | |
| _executor.shutdown(wait=False) | |
| logger.info("Study Group Assistant API shut down.") | |
| # ── App ─────────────────────────────────────────────────────────────────────── | |
| app = FastAPI( | |
| title="Study Group Assistant API", | |
| description=( | |
| "AI agent giúp nhóm học tập tóm tắt hội thoại, " | |
| "tra cứu lịch trình và quản lý ghi nhớ." | |
| ), | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| docs_url="/docs", | |
| redoc_url="/redoc", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ── Middlewares ─────────────────────────────────────────────────────────────── | |
| async def attach_request_id(request: Request, call_next): | |
| """Gắn X-Request-ID vào mỗi request để dễ trace log.""" | |
| request_id = str(uuid.uuid4()) | |
| request.state.request_id = request_id | |
| response = await call_next(request) | |
| response.headers["X-Request-ID"] = request_id | |
| return response | |
| async def log_requests(request: Request, call_next): | |
| """Log method, path và status code của mỗi request.""" | |
| response = await call_next(request) | |
| logger.info( | |
| "%s %s → %d [rid=%s]", | |
| request.method, | |
| request.url.path, | |
| response.status_code, | |
| getattr(request.state, "request_id", "-"), | |
| ) | |
| return response | |
| # ── Pydantic models ─────────────────────────────────────────────────────────── | |
| class ChatRequest(BaseModel): | |
| conversation_id: str = Field(..., description="ID cuộc hội thoại DM") | |
| sender_id: str = Field(..., description="ID hoặc tên người gửi") | |
| query: str = Field(..., description="Câu hỏi hoặc yêu cầu") | |
| model_config = { | |
| "json_schema_extra": { | |
| "example": { | |
| "conversation_id": "98996225-512c-4491-96a2-bc71552328ca", | |
| "sender_id": "@Hoang", | |
| "query": "Tóm tắt cuộc trò chuyện hôm nay", | |
| } | |
| } | |
| } | |
| class ChatResponse(BaseModel): | |
| answer: str = Field(..., description="Câu trả lời từ agent") | |
| processing_time: str = Field(..., description="Thời gian xử lý, ví dụ '1.23s'") | |
| conversation_id: Optional[str] = None | |
| sender_id: str | |
| class ChartResponse(BaseModel): | |
| answer: str = Field(..., description="Câu trả lời từ agent") | |
| processing_time: str = Field(..., description="Thời gian xử lý, ví dụ '1.23s'") | |
| conversation_id: str | |
| sender_id: str | |
| chart_type: str | None = Field(None, description='"column" hoặc "pie"') | |
| chart_data: str | None = Field(None, description="JSON string chứa dữ liệu biểu đồ") | |
| class HealthComponent(BaseModel): | |
| status: str = Field(..., description="'ok' | 'degraded' | 'down'") | |
| detail: str = "" | |
| class HealthResponse(BaseModel): | |
| status: str = Field(..., description="'ok' | 'degraded'") | |
| timestamp: str | |
| components: dict[str, HealthComponent] | |
| class ErrorDetail(BaseModel): | |
| error: str | |
| detail: str = "" | |
| request_id: str = "" | |
| class CustomPromptRequest(BaseModel): | |
| user_id: str = Field(..., description="ID người dùng") | |
| prompt: str = Field(..., description="Nội dung custom prompt") | |
| model_config = { | |
| "json_schema_extra": { | |
| "example": { | |
| "user_id": "@Hoang", | |
| "prompt": "Luôn trả lời ngắn gọn trong 3 câu. Dùng bullet point khi liệt kê.", | |
| } | |
| } | |
| } | |
| class CustomPromptResponse(BaseModel): | |
| success: bool | |
| user_id: str | |
| prompt: str | |
| class IndexPdfResponse(BaseModel): | |
| pdf_name: str = Field(..., description="Tên file PDF đã index") | |
| chunks_indexed: int = Field(..., description="Số chunk đã upsert vào Qdrant") | |
| conversation_id: str | |
| class AtRiskRequest(BaseModel): | |
| room_ids: list[str] = Field(..., description="Danh sách room ID cần quét") | |
| hours: int = Field(24, ge=1, le=168, description="Cửa sổ thời gian nhìn lại (giờ), mặc định 24") | |
| class QuizRequest(BaseModel): | |
| content: str = Field(..., description="Nội dung bài giảng cần tạo quiz") | |
| k_question: int = Field(10, ge=1, le=50, description="Số câu hỏi cần tạo, mặc định 10") | |
| # ── Helper ──────────────────────────────────────────────────────────────────── | |
| def _request_id(request: Request) -> str: | |
| return getattr(request.state, "request_id", "") | |
| def _utcnow() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| # ── Routes ──────────────────────────────────────────────────────────────────── | |
| async def root(): | |
| return { | |
| "service": "Study Group Assistant API", | |
| "version": "1.0.0", | |
| "docs": "/docs", | |
| "health": "/health", | |
| } | |
| async def health(): | |
| redis_ok = redis_client.ping() | |
| return HealthResponse( | |
| status="ok" if redis_ok else "degraded", | |
| timestamp=_utcnow(), | |
| components={ | |
| "redis": HealthComponent( | |
| status="ok" if redis_ok else "down", | |
| detail="Connected" if redis_ok else "Connection failed — using local fallback", | |
| ), | |
| "agent": HealthComponent(status="ok"), | |
| }, | |
| ) | |
| async def chat(request: Request, body: ChatRequest): | |
| """ | |
| Gửi query đến agent, nhận câu trả lời và thời gian xử lý. | |
| Agent sẽ tự động: | |
| - Phân loại yêu cầu (trả lời trực tiếp hoặc tra cứu hội thoại) | |
| - Gọi các tool phù hợp (tóm tắt, lịch trình, ghi nhớ, web...) | |
| - Tổng hợp kết quả thành câu trả lời tự nhiên | |
| """ | |
| loop = get_running_loop() | |
| try: | |
| answer, elapsed, *_ = await loop.run_in_executor( | |
| _executor, | |
| lambda: final_answer(body.conversation_id, body.sender_id, body.query), | |
| ) | |
| except ValueError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, | |
| detail=str(e), | |
| ) | |
| except Exception as e: | |
| logger.exception( | |
| "Unhandled error in POST /api/v1/chat [rid=%s]", _request_id(request) | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi xử lý nội bộ. Vui lòng thử lại.", | |
| ) | |
| return ChatResponse( | |
| answer=answer, | |
| processing_time=elapsed, | |
| conversation_id=body.conversation_id, | |
| sender_id=body.sender_id, | |
| ) | |
| async def chat_with_pdf( | |
| request: Request, | |
| conversation_id: Optional[str] = Form(None, description="ID cuộc hội thoại DM hoặc room (room-{id}). Tùy chọn."), | |
| sender_id: str = Form(..., description="ID hoặc tên người gửi"), | |
| query: str = Form("", description="Câu hỏi về nội dung PDF. Để trống để chỉ index vào knowledge base."), | |
| file: UploadFile = File(..., description="File PDF (bắt buộc)"), | |
| ): | |
| _skip_qdrant = not conversation_id or conversation_id.strip() == "string" | |
| if not file.filename.lower().endswith(".pdf"): | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Chỉ chấp nhận file PDF.", | |
| ) | |
| import time as _time | |
| tmp_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: | |
| tmp.write(await file.read()) | |
| tmp_path = tmp.name | |
| loop = get_running_loop() | |
| if not query.strip(): | |
| # Không có query → chỉ index vào knowledge base | |
| t0 = _time.perf_counter() | |
| if not _skip_qdrant: | |
| from src.pdf_rag import index_pdf as _index_pdf | |
| try: | |
| await loop.run_in_executor( | |
| _executor, | |
| lambda: _index_pdf(tmp_path, file.filename, conversation_id), | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail=str(e), | |
| ) | |
| elapsed = f"{_time.perf_counter() - t0:.2f}s" | |
| return ChatResponse( | |
| answer=f'Đã thêm "{file.filename}" vào knowledge base.', | |
| processing_time=elapsed, | |
| conversation_id=conversation_id, | |
| sender_id=sender_id, | |
| ) | |
| # Có query → chat bình thường (auto-index diễn ra bên trong final_answer) | |
| # Dùng temp UUID khi conversation_id không hợp lệ để final_answer hoạt động bình thường | |
| _cid = conversation_id if not _skip_qdrant else uuid.uuid4().hex | |
| answer, elapsed, *_ = await loop.run_in_executor( | |
| _executor, | |
| lambda: final_answer( | |
| _cid, sender_id, query, | |
| pdf_path=tmp_path, pdf_name=file.filename, | |
| skip_pdf_indexing=_skip_qdrant, | |
| ), | |
| ) | |
| except ValueError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, | |
| detail=str(e), | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail=str(e), | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Unhandled error in POST /api/v1/chat_with_pdf [rid=%s]", _request_id(request) | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi xử lý nội bộ. Vui lòng thử lại.", | |
| ) | |
| finally: | |
| if tmp_path and os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| return ChatResponse( | |
| answer=answer, | |
| processing_time=elapsed, | |
| conversation_id=conversation_id, | |
| sender_id=sender_id, | |
| ) | |
| async def index_pdf_endpoint( | |
| request: Request, | |
| conversation_id: str = Form(..., description="ID cuộc hội thoại hoặc room (dùng room-{id} cho phòng nhóm)"), | |
| file: UploadFile = File(..., description="File PDF cần index vào Qdrant"), | |
| ): | |
| """ | |
| Chunk PDF → embed (dense + BM25) → upsert vào Qdrant. | |
| Không gọi LLM, không trả lời — chỉ build knowledge base. | |
| Gửi lại cùng file sẽ upsert (không tạo duplicate). | |
| Sau khi index, câu hỏi liên quan qua /api/v1/chat sẽ được orchestrator | |
| tự động dùng tool rag_search để tìm kiếm. | |
| """ | |
| if not file.filename.lower().endswith(".pdf"): | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Chỉ chấp nhận file PDF.", | |
| ) | |
| tmp_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: | |
| tmp.write(await file.read()) | |
| tmp_path = tmp.name | |
| from src.pdf_rag import index_pdf as _index_pdf | |
| loop = get_running_loop() | |
| chunks_indexed = await loop.run_in_executor( | |
| _executor, | |
| lambda: _index_pdf(tmp_path, file.filename, conversation_id), | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail=str(e), | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Unhandled error in POST /api/v1/index_pdf [rid=%s]", _request_id(request) | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi xử lý nội bộ. Vui lòng thử lại.", | |
| ) | |
| finally: | |
| if tmp_path and os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| return IndexPdfResponse( | |
| pdf_name=file.filename, | |
| chunks_indexed=chunks_indexed, | |
| conversation_id=conversation_id, | |
| ) | |
| async def set_custom_prompt(request: Request, body: CustomPromptRequest): | |
| """ | |
| Lưu hoặc cập nhật custom prompt của người dùng lên Qdrant. | |
| Prompt này sẽ được tự động inject vào system prompt khi user đó gửi query. | |
| """ | |
| loop = get_running_loop() | |
| ok = await loop.run_in_executor( | |
| _executor, | |
| lambda: save_custom_prompt(body.user_id, body.prompt), | |
| ) | |
| if not ok: | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Không thể lưu custom prompt. Kiểm tra cấu hình QDRANT_URL.", | |
| ) | |
| return CustomPromptResponse(success=True, user_id=body.user_id, prompt=body.prompt) | |
| async def get_user_custom_prompt(user_id: str, request: Request): | |
| loop = get_running_loop() | |
| prompt = await loop.run_in_executor( | |
| _executor, | |
| lambda: get_custom_prompt(user_id), | |
| ) | |
| if prompt is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Không tìm thấy custom prompt cho user '{user_id}'.", | |
| ) | |
| return CustomPromptResponse(success=True, user_id=user_id, prompt=prompt) | |
| _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} | |
| async def chat_with_image( | |
| request: Request, | |
| conversation_id: str = Form(..., description="ID cuộc hội thoại DM"), | |
| sender_id: str = Form(..., description="ID hoặc tên người gửi"), | |
| query: str = Form(..., description="Câu hỏi hoặc yêu cầu về nội dung ảnh"), | |
| file: UploadFile = File(..., description="File ảnh cần xử lý"), | |
| ): | |
| ext = os.path.splitext(file.filename.lower())[1] | |
| if ext not in _IMAGE_EXTENSIONS: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=f"Chỉ chấp nhận ảnh: {', '.join(_IMAGE_EXTENSIONS)}.", | |
| ) | |
| tmp_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: | |
| tmp.write(await file.read()) | |
| tmp_path = tmp.name | |
| loop = get_running_loop() | |
| answer, elapsed, *_ = await loop.run_in_executor( | |
| _executor, | |
| lambda: final_answer(conversation_id, sender_id, query, image_path=tmp_path), | |
| ) | |
| except ValueError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, | |
| detail=str(e), | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Unhandled error in POST /api/v1/chat_with_image [rid=%s]", _request_id(request) | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi xử lý nội bộ. Vui lòng thử lại.", | |
| ) | |
| finally: | |
| if tmp_path and os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| return ChatResponse( | |
| answer=answer, | |
| processing_time=elapsed, | |
| conversation_id=conversation_id, | |
| sender_id=sender_id, | |
| ) | |
| async def summary_chart(request: Request, body: ChatRequest): | |
| """ | |
| Phân tích tin nhắn nhóm, thống kê ý kiến unique users theo chủ đề từ query, | |
| trả về dữ liệu JSON sẵn sàng để UI vẽ biểu đồ cột hoặc tròn. | |
| Query cần chứa ý định vẽ biểu đồ, ví dụ: | |
| - "Vẽ biểu đồ cột thống kê nghề nghiệp thành viên" | |
| - "Vẽ biểu đồ tròn thể hiện độ tuổi" | |
| """ | |
| loop = get_running_loop() | |
| try: | |
| answer, elapsed, chart_type, chart_data = await loop.run_in_executor( | |
| _executor, | |
| lambda: final_answer(body.conversation_id, body.sender_id, body.query), | |
| ) | |
| except ValueError as e: | |
| raise HTTPException( | |
| status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, | |
| detail=str(e), | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Unhandled error in POST /api/v1/summary_chart [rid=%s]", _request_id(request) | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi xử lý nội bộ. Vui lòng thử lại.", | |
| ) | |
| return ChartResponse( | |
| answer=answer, | |
| processing_time=elapsed, | |
| conversation_id=body.conversation_id, | |
| sender_id=body.sender_id, | |
| chart_type=chart_type, | |
| chart_data=chart_data, | |
| ) | |
| # ── Quiz generation endpoint ────────────────────────────────────────────────── | |
| async def gen_quiz(request: Request, body: QuizRequest): | |
| """ | |
| Nhận nội dung bài giảng và số câu hỏi, trả về JSON quiz trắc nghiệm. | |
| Bỏ qua Orchestrator, đi thẳng vào quiz generation node. | |
| """ | |
| loop = get_running_loop() | |
| try: | |
| logger.info(f"[gen_quiz] Received request: content={len(body.content)} chars, k_question={body.k_question}") | |
| answer, *_ = await loop.run_in_executor( | |
| _executor, | |
| lambda: final_answer( | |
| conversation_id="quiz", | |
| sender_id="system", | |
| query=body.content, | |
| gen_quiz=True, | |
| k_question=body.k_question, | |
| ), | |
| ) | |
| logger.info(f"[gen_quiz] final_answer returned: {type(answer)} len={len(str(answer)) if answer else 0}") | |
| except Exception: | |
| logger.exception("Unhandled error in POST /api/v1/gen_quiz [rid=%s]", _request_id(request)) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi xử lý nội bộ. Vui lòng thử lại.", | |
| ) | |
| if not answer: | |
| logger.error("[gen_quiz] Answer is empty, returning error") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Không tạo được quiz từ nội dung cung cấp.", | |
| ) | |
| try: | |
| result = json.loads(answer) | |
| logger.info(f"[gen_quiz] Successfully parsed quiz with {len(result.get('questions', []))} questions") | |
| return result | |
| except json.JSONDecodeError as e: | |
| logger.error(f"[gen_quiz] JSON decode error: {e}") | |
| logger.error(f"[gen_quiz] Raw answer: {answer[:500]}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="LLM trả về JSON không hợp lệ.", | |
| ) | |
| # ── At-risk endpoint ────────────────────────────────────────────────────────── | |
| async def at_risk_analyze(request: Request, body: AtRiskRequest): | |
| """ | |
| Nhận danh sách room_ids và cửa sổ thời gian (hours), fetch tin nhắn từ Redis, | |
| dùng LLM phát hiện tín hiệu nguy cơ per-student: | |
| - stuck_phrases: học viên bị kẹt / không hiểu nội dung | |
| - unanswered_questions: câu hỏi học thuật chưa ai trả lời (kèm wait_minutes và suggested_points) | |
| - frustration_phrases: biểu đạt chán nản / burn out | |
| suggested_points cho unanswered_questions được tính từ thời gian chờ: | |
| 15-30 phút → 1, 30-60 phút → 2, 1-2 giờ → 3, >2 giờ → 4. | |
| NestJS tự cộng absence_points (từ Supabase profiles) để ra total_score. | |
| """ | |
| if not body.room_ids: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="room_ids không được để trống.", | |
| ) | |
| from src.at_risk import analyze_rooms, AnalysisResult | |
| loop = get_running_loop() | |
| try: | |
| result: AnalysisResult = await loop.run_in_executor( | |
| _executor, | |
| lambda: analyze_rooms(body.room_ids, body.hours), | |
| ) | |
| except Exception: | |
| logger.exception( | |
| "Unhandled error in POST /api/v1/at-risk/analyze [rid=%s]", _request_id(request) | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Lỗi phân tích. Xem log để biết thêm.", | |
| ) | |
| return result | |
| # ── Exception handlers ──────────────────────────────────────────────────────── | |
| async def not_found(request: Request, exc): | |
| return JSONResponse( | |
| status_code=404, | |
| content=ErrorDetail( | |
| error="Not Found", | |
| detail=f"Endpoint '{request.url.path}' không tồn tại.", | |
| request_id=_request_id(request), | |
| ).model_dump(), | |
| ) | |
| async def method_not_allowed(request: Request, exc): | |
| return JSONResponse( | |
| status_code=405, | |
| content=ErrorDetail( | |
| error="Method Not Allowed", | |
| detail=f"Method '{request.method}' không được hỗ trợ tại '{request.url.path}'.", | |
| request_id=_request_id(request), | |
| ).model_dump(), | |
| ) | |
| async def internal_error(request: Request, exc): | |
| return JSONResponse( | |
| status_code=500, | |
| content=ErrorDetail( | |
| error="Internal Server Error", | |
| detail="Đã xảy ra lỗi không mong muốn.", | |
| request_id=_request_id(request), | |
| ).model_dump(), | |
| ) | |
| if __name__ == "__main__": | |
| uvicorn.run("src.api:app", host="127.0.0.1", port=8000, reload=True) | |