"""Adaptive RAG API。""" from __future__ import annotations import asyncio import io import re from datetime import datetime from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import success_response from ..database import get_db from ..dependencies.auth import get_current_user, require_roles from ..dependencies.tenant import get_tenant_id from ..models import User from ..schemas.rag import ( EntityResolutionRequest, KnowledgeDocCreateRequest, KnowledgeURLCreateRequest, MultiStepSearchRequest, QAExportQuery, QAFeedbackCreateRequest, QAListQuery, QASessionListQuery, QASessionRenameRequest, RAGAskRequest, SymbolSearchRequest, SymbolSyncRequest, ) from ..services.adaptive_rag_service import AdaptiveRAGService from ..services.symbol_index_service import SymbolIndexService router = APIRouter(prefix="/rag", tags=["Adaptive RAG"]) def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int: try: return int(getattr(user, "tenant_id") or fallback_tenant_id or 1) except Exception: return int(fallback_tenant_id or 1) def _safe_decode_bytes(raw: bytes) -> str: for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"): try: return raw.decode(encoding) except Exception: continue return raw.decode("utf-8", errors="ignore") def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]: name = str(filename or "uploaded.txt") suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt" if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}: return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw)) if suffix == "pdf": try: from pypdf import PdfReader # type: ignore except Exception as exc: raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc reader = PdfReader(io.BytesIO(raw)) parts: list[str] = [] for idx, page in enumerate(reader.pages, start=1): page_text = (page.extract_text() or "").strip() if not page_text: continue # 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。 parts.append(f"[[PAGE:{idx}]]\n{page_text}") text = "\n\n".join(parts) return ("pdf", text) if suffix in {"docx", "doc"}: try: from docx import Document # type: ignore except Exception as exc: raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc doc = Document(io.BytesIO(raw)) text = "\n".join([p.text for p in doc.paragraphs if p.text]) return ("word", text) raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}") @router.post("/ask") async def rag_ask( request: RAGAskRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) result = await service.answer_question( question=request.question, asked_by=int(current_user.id), asked_via=request.asked_via, project_id=request.project_id, source_chat_id=request.source_chat_id, session_id=request.session_id, force_new_session=bool(request.new_session), thinking_mode=request.thinking_mode, action_mode=request.action_mode, sync_requirement=bool(request.sync_requirement), ) return success_response(result) @router.get("/manifest") async def rag_manifest( project_id: int | None = Query(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.get_project_manifest(project_id=project_id) return success_response(data) @router.get("/features/catalog") async def rag_feature_catalog( query: str | None = Query(default=None), limit: int = Query(default=20, ge=1, le=200), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.get_feature_catalog(query=query, limit=limit) return success_response(data) @router.post("/entity-resolution") async def rag_entity_resolution( request: EntityResolutionRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.entity_resolution( question=request.question, project_id=request.project_id, limit=request.limit, ) return success_response(data) @router.post("/multi-step-search") async def rag_multi_step_search( request: MultiStepSearchRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.multi_step_search( question=request.question, project_id=request.project_id, source_chat_id=request.source_chat_id, session_state=None, ) return success_response(data) @router.post("/symbols/sync") async def rag_symbols_sync( request: SymbolSyncRequest, current_user: User = Depends(require_roles("ADMIN", "BOSS")), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.sync_codebase_symbols( base_dir=request.base_dir, max_files=request.max_files, force_reindex=request.force_reindex, ) return success_response(data) @router.post("/symbols/search") async def rag_symbols_search( request: SymbolSearchRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.search_symbols( query=request.query, language=request.language, symbol_type=request.symbol_type, limit=request.limit, ) return success_response(data) @router.get("/symbols/overview") async def rag_symbols_overview( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False) return success_response(data) @router.get("/symbols/{symbol_id}") async def rag_symbol_detail( symbol_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) try: data = await service.get_symbol_detail(symbol_id=symbol_id) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(data) @router.get("/knowledge-base/docs") async def list_knowledge_docs( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) return success_response(await service.list_knowledge_docs()) @router.post("/knowledge-base/docs") async def create_knowledge_doc( request: KnowledgeDocCreateRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) result = await service.create_knowledge_doc( title=request.title, file_type=request.file_type, raw_content=request.content, uploaded_by=int(current_user.id), source_type=request.source_type, file_url=request.file_url, project_id=request.project_id, ) return success_response(result) @router.post("/knowledge-base/upload") async def upload_knowledge_doc( file: UploadFile = File(...), project_id: int | None = Form(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): raw = await file.read() if not raw: raise HTTPException(status_code=400, detail="上传文件为空") if len(raw) > 15 * 1024 * 1024: raise HTTPException(status_code=400, detail="文件过大,限制 15MB") file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw) if not str(text or "").strip(): raise HTTPException(status_code=400, detail="无法提取有效文本内容") effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) doc = await service.create_knowledge_doc_placeholder( title=(file.filename or "uploaded.txt")[:255], file_type=file_type, uploaded_by=int(current_user.id), source_type="file", file_url=None, ) asyncio.create_task( AdaptiveRAGService.process_knowledge_doc_content_async( tenant_id=effective_tenant_id, doc_id=int(doc.id), raw_content=text, project_id=project_id, ) ) return success_response(service._serialize_doc(doc)) @router.post("/knowledge-base/urls") async def create_knowledge_url( request: KnowledgeURLCreateRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): url = str(request.url or "").strip() if not re.match(r"^https?://", url, flags=re.IGNORECASE): raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头") effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) doc = await service.create_knowledge_doc_placeholder( title=(request.title or url)[:255], file_type="url", uploaded_by=int(current_user.id), source_type="url", file_url=url, ) asyncio.create_task( AdaptiveRAGService.refresh_url_doc_async( tenant_id=effective_tenant_id, doc_id=int(doc.id), project_id=request.project_id, ) ) return success_response(service._serialize_doc(doc)) @router.post("/knowledge-base/docs/{doc_id}/refresh") async def refresh_knowledge_doc( doc_id: int, project_id: int | None = Query(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) try: doc = await service.get_knowledge_doc(doc_id=doc_id) if str(doc.get("source_type") or "").lower() != "url": raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh") marked = await service.mark_doc_processing(doc_id=doc_id) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc asyncio.create_task( AdaptiveRAGService.refresh_url_doc_async( tenant_id=effective_tenant_id, doc_id=doc_id, project_id=project_id, ) ) return success_response(marked) @router.delete("/knowledge-base/docs/{doc_id}") async def delete_knowledge_doc( doc_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) ok = await service.delete_knowledge_doc(doc_id=doc_id) if not ok: raise HTTPException(status_code=404, detail="文档不存在") return success_response({"deleted": True, "doc_id": doc_id}) @router.post("/qa-records/{qa_id}/feedback") async def submit_qa_feedback( qa_id: int, request: QAFeedbackCreateRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) try: result = await service.submit_feedback( qa_id=qa_id, rating=request.rating, helpfulness=request.helpfulness, error_types=request.error_types, correct_answer=request.correct_answer, submitted_by=int(current_user.id), ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(result) @router.get("/qa-records") async def list_qa_records( project_id: int | None = Query(default=None), date_from: datetime | None = Query(default=None), date_to: datetime | None = Query(default=None), rating: str | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user query = QAListQuery( project_id=project_id, date_from=date_from, date_to=date_to, rating=rating, page=page, page_size=page_size, ) service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) result = await service.list_qa_records( project_id=query.project_id, date_from=query.date_from, date_to=query.date_to, rating=query.rating, page=query.page, page_size=query.page_size, ) return success_response(result) @router.get("/qa-sessions") async def list_qa_sessions( date_from: datetime | None = Query(default=None), date_to: datetime | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): query = QASessionListQuery( date_from=date_from, date_to=date_to, page=page, page_size=page_size, ) service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) result = await service.list_qa_sessions( user_id=int(current_user.id), date_from=query.date_from, date_to=query.date_to, page=query.page, page_size=query.page_size, ) return success_response(result) @router.get("/qa-sessions/{session_id}") async def qa_session_detail( session_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) try: result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id)) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(result) @router.get("/qa-sessions/{session_id}/state") async def qa_session_state_replay( session_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) try: result = await service.get_session_state_replay( session_id=session_id, user_id=int(current_user.id), ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(result) @router.patch("/qa-sessions/{session_id}/title") async def rename_qa_session( session_id: int, request: QASessionRenameRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) try: result = await service.rename_qa_session( session_id=session_id, user_id=int(current_user.id), title=request.title, ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(result) @router.delete("/qa-sessions/{session_id}") async def delete_qa_session( session_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id)) if not deleted: raise HTTPException(status_code=404, detail="qa session 不存在") return success_response({"deleted": True, "session_id": session_id}) @router.get("/qa-records/stats") async def qa_records_stats( project_id: int | None = Query(default=None), date_from: datetime | None = Query(default=None), date_to: datetime | None = Query(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) result = await service.get_qa_stats( project_id=project_id, date_from=date_from, date_to=date_to, ) return success_response(result) @router.get("/qa-records/export") async def export_qa_records_jsonl( include_unrated: bool = Query(default=False), project_id: int | None = Query(default=None), rating: str | None = Query(default=None), domain: str | None = Query(default=None), date_from: datetime | None = Query(default=None), date_to: datetime | None = Query(default=None), current_user: User = Depends(require_roles("ADMIN", "BOSS")), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user query = QAExportQuery( include_unrated=include_unrated, project_id=project_id, rating=rating, domain=domain, date_from=date_from, date_to=date_to, ) service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) payload = await service.export_qa_jsonl( include_unrated=query.include_unrated, project_id=query.project_id, rating=query.rating, domain=query.domain, date_from=query.date_from, date_to=query.date_to, ) return Response(content=payload, media_type="application/x-ndjson")