0.1.0
This commit is contained in:
@@ -1 +1,5 @@
|
||||
"""FastAPI 应用初始化模块。"""
|
||||
|
||||
# 导入 Celery 应用以确保 FastAPI 进程也加载 task_always_eager 配置。
|
||||
# pylint: disable=unused-import
|
||||
from app.workers.celery_app import celery_app as _celery_app # noqa: F401
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _apply_local_supabase_env() -> None:
|
||||
"""
|
||||
优先加载本地 .env 中的 Supabase 配置,避免宿主环境遗留的线上变量导致鉴权失败。
|
||||
仅覆盖 SUPABASE_* 相关键。
|
||||
"""
|
||||
env_path = Path(__file__).resolve().parents[1] / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
|
||||
content = env_path.read_text(encoding="utf-8").splitlines()
|
||||
kv: dict[str, str] = {}
|
||||
for line in content:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
kv[key.strip()] = value.strip()
|
||||
|
||||
for key in ("SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_ANON_KEY"):
|
||||
file_value = kv.get(key)
|
||||
if not file_value:
|
||||
continue
|
||||
os.environ[key] = file_value
|
||||
|
||||
|
||||
_apply_local_supabase_env()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""集中管理项目配置,来源于 .env / 环境变量。"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
supabase_url: str
|
||||
supabase_anon_key: Optional[str] = None
|
||||
supabase_service_role_key: str
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
|
||||
@@ -23,22 +23,28 @@ class AuthContext:
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Annotated[Optional[str], Header(convert_underscores=False)] = None,
|
||||
x_supabase_access_token: Annotated[
|
||||
Optional[str], Header(convert_underscores=False, alias="x-supabase-access-token")
|
||||
] = None,
|
||||
) -> AuthContext:
|
||||
"""
|
||||
验证 Supabase JWT,stage0 直接依赖 service_role 解析 token。
|
||||
生产环境应通过 API Gateway 注入 user。
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
header_token = authorization or (f"Bearer {x_supabase_access_token}" if x_supabase_access_token else None)
|
||||
if not header_token or not header_token.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
|
||||
token = authorization.replace("Bearer ", "", 1).strip()
|
||||
token = header_token.replace("Bearer ", "", 1).strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Empty token")
|
||||
print(f"[auth] Validating Supabase token prefix={token[:8]}")
|
||||
|
||||
auth_url = f"{settings.supabase_url.rstrip('/')}/auth/v1/user"
|
||||
apikey = settings.supabase_anon_key or settings.supabase_service_role_key
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"apikey": settings.supabase_service_role_key,
|
||||
"apikey": apikey,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import chat, health, luckysheet_ws, tasks
|
||||
from . import chat, health, luckysheet_ws, tasks, lightrag
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(tasks.router, tags=["tasks"])
|
||||
api_router.include_router(chat.router, tags=["chat"])
|
||||
api_router.include_router(lightrag.router, tags=["lightrag"])
|
||||
|
||||
root_router = APIRouter()
|
||||
root_router.include_router(health.router, tags=["health"])
|
||||
|
||||
@@ -1,26 +1,67 @@
|
||||
import asyncio
|
||||
from fastapi import APIRouter
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.deps import AuthDep
|
||||
from app.services.lightrag_service import lightrag_service
|
||||
from app.services.supabase_rest import supabase_rest
|
||||
|
||||
router = APIRouter(prefix="/chat")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def chat(query: str, document_id: str, auth: AuthDep) -> StreamingResponse: # noqa: ARG001
|
||||
async def chat(
|
||||
query: str,
|
||||
auth: AuthDep,
|
||||
document_id: Optional[str] = None,
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
SSE 流式占位。后续会调用 LightRAG + OpenAI。
|
||||
当前直接返回 mock 文字,确保前端链路可用。
|
||||
基于 LightRAG 的 SSE 流式回答。
|
||||
query: 必填问题
|
||||
document_id: 可选,指定所属 workspace
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="query 不能为空")
|
||||
|
||||
async def event_stream() -> asyncio.AsyncGenerator[str, None]:
|
||||
reply = await lightrag_service.query(query_text=query, user_id="placeholder-user")
|
||||
chunks = [reply[: len(reply) // 2 or 1], reply[len(reply) // 2 or 1 :]]
|
||||
for chunk in chunks:
|
||||
yield f"data: {chunk}\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
workspace_id: Optional[str] = None
|
||||
if document_id:
|
||||
document = supabase_rest.select_one(
|
||||
"documents", {"id": document_id, "user_id": auth.user_id}
|
||||
)
|
||||
if not document:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
workspace_id = (
|
||||
str(document.get("workspace_id")) if document.get("workspace_id") else None
|
||||
)
|
||||
|
||||
health = await lightrag_service.run_healthcheck()
|
||||
if not health.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=health.get("error", "LightRAG 未就绪"),
|
||||
)
|
||||
|
||||
lightrag_result = await lightrag_service.stream_answer(
|
||||
query_text=query, user_id=auth.user_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
if lightrag_result.get("is_streaming") and lightrag_result.get("iterator"):
|
||||
iterator = lightrag_result["iterator"]
|
||||
async for chunk in iterator:
|
||||
payload = json.dumps({"type": "chunk", "content": chunk})
|
||||
yield f"data: {payload}\n\n"
|
||||
else:
|
||||
payload = json.dumps(
|
||||
{"type": "chunk", "content": lightrag_result.get("content", "")}
|
||||
)
|
||||
yield f"data: {payload}\n\n"
|
||||
references = lightrag_result.get("references", [])
|
||||
yield f"data: {json.dumps({'type': 'references', 'data': references})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.deps import AuthDep
|
||||
from app.schemas.tasks import TaskStatusResponse
|
||||
from app.services.lightrag_service import lightrag_service
|
||||
from app.services.supabase_rest import supabase_rest
|
||||
from app.services.task_tracker import task_tracker
|
||||
from app.workers.tasks import lightrag_index_pipeline
|
||||
|
||||
router = APIRouter(prefix="/lightrag")
|
||||
|
||||
|
||||
class IndexRequest(BaseModel):
|
||||
document_id: str
|
||||
|
||||
|
||||
@router.post("/index", response_model=TaskStatusResponse)
|
||||
async def enqueue_lightrag_index(payload: IndexRequest, auth: AuthDep) -> TaskStatusResponse:
|
||||
document = supabase_rest.select_one(
|
||||
"documents", {"id": payload.document_id, "user_id": auth.user_id}
|
||||
)
|
||||
if not document:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
raw_text = str(document.get("raw_text") or "").strip()
|
||||
if not raw_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文档尚未生成 raw_text,无法索引",
|
||||
)
|
||||
|
||||
supabase_rest.update(
|
||||
"documents", {"id": payload.document_id}, {"index_status": "pending"}
|
||||
)
|
||||
task = task_tracker.create_task(
|
||||
user_id=auth.user_id, document_id=payload.document_id, task_type="index"
|
||||
)
|
||||
lightrag_index_pipeline.delay(task.task_id, payload.document_id, auth.user_id)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def lightrag_health():
|
||||
result = await lightrag_service.run_healthcheck()
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=result.get("error", "LightRAG 未就绪"),
|
||||
)
|
||||
return result
|
||||
@@ -102,8 +102,9 @@ def _decode_ws_payload(raw_message: str) -> Optional[dict]:
|
||||
|
||||
async def _fetch_supabase_user(access_token: str) -> Optional[dict]:
|
||||
base_url = settings.supabase_url.rstrip("/")
|
||||
apikey = settings.supabase_anon_key or settings.supabase_service_role_key
|
||||
headers = {
|
||||
"apikey": settings.supabase_service_role_key,
|
||||
"apikey": apikey,
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.deps import AuthDep
|
||||
from app.schemas.tasks import OcrTaskRequest, TaskStatusResponse
|
||||
from app.schemas.tasks import MediaOcrTaskRequest, OcrTaskRequest, TaskStatusResponse
|
||||
from app.services.supabase_rest import supabase_rest
|
||||
from app.services.task_tracker import task_tracker
|
||||
from app.workers.tasks import ocr_pipeline
|
||||
from app.workers.tasks import media_ocr_pipeline, ocr_pipeline
|
||||
from app.workers.utils import dispatch_task
|
||||
|
||||
router = APIRouter(prefix="/tasks")
|
||||
|
||||
@@ -12,7 +14,8 @@ router = APIRouter(prefix="/tasks")
|
||||
async def enqueue_ocr_task(payload: OcrTaskRequest, auth: AuthDep) -> TaskStatusResponse:
|
||||
"""记录任务并投递 Celery,阶段 0 返回占位任务。"""
|
||||
task = task_tracker.create_task(user_id=auth.user_id, document_id=payload.document_id, task_type="ocr")
|
||||
ocr_pipeline.delay(
|
||||
dispatch_task(
|
||||
ocr_pipeline,
|
||||
task_id=task.task_id,
|
||||
document_id=payload.document_id,
|
||||
file_url=str(payload.file_url),
|
||||
@@ -21,6 +24,31 @@ async def enqueue_ocr_task(payload: OcrTaskRequest, auth: AuthDep) -> TaskStatus
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/media-ocr")
|
||||
async def enqueue_media_ocr(payload: MediaOcrTaskRequest, auth: AuthDep) -> dict:
|
||||
"""触发媒体资产 OCR,占位实现先写入模拟文本。"""
|
||||
asset = supabase_rest.select_one("media_assets", {"id": payload.asset_id})
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media asset not found")
|
||||
|
||||
document_id = asset.get("document_id")
|
||||
if not document_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Media asset missing document")
|
||||
|
||||
document = supabase_rest.select_one("documents", {"id": document_id})
|
||||
if not document or document.get("user_id") != auth.user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
supabase_rest.update("media_assets", {"id": payload.asset_id}, {"ocr_status": "processing"})
|
||||
dispatch_task(
|
||||
media_ocr_pipeline,
|
||||
asset_id=payload.asset_id,
|
||||
user_id=auth.user_id,
|
||||
document_id=document_id,
|
||||
)
|
||||
return {"asset_id": payload.asset_id, "status": "queued"}
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskStatusResponse)
|
||||
async def get_task_status(task_id: str, auth: AuthDep) -> TaskStatusResponse:
|
||||
task = task_tracker.get_task(task_id=task_id, user_id=auth.user_id)
|
||||
|
||||
@@ -10,6 +10,10 @@ class OcrTaskRequest(BaseModel):
|
||||
file_url: AnyHttpUrl
|
||||
|
||||
|
||||
class MediaOcrTaskRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
class TaskStatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: Literal["pending", "processing", "completed", "failed"]
|
||||
|
||||
@@ -1,19 +1,277 @@
|
||||
"""LightRAG 集成占位。阶段 1 会在这里封装真正的查询与索引。"""
|
||||
"""LightRAG 集成。负责管理实例、增量索引与问答。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _ensure_lightrag_available() -> None:
|
||||
"""确保本地 LightRAG 代码可被 Python 找到."""
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
local_pkg = repo_root / "LightRAG"
|
||||
if local_pkg.exists():
|
||||
path_str = str(local_pkg)
|
||||
if path_str not in sys.path:
|
||||
sys.path.insert(0, path_str)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from lightrag import LightRAG, QueryParam
|
||||
from lightrag.kg.shared_storage import initialize_pipeline_status
|
||||
from lightrag.llm.openai import gpt_4o_mini_complete, openai_embed
|
||||
from lightrag.utils import logger as lightrag_logger
|
||||
_LIGHTRAG_AVAILABLE = True
|
||||
_IMPORT_ERROR: Optional[Exception] = None
|
||||
except Exception as exc: # pragma: no cover - 本地缺失或版本不兼容时使用占位实现
|
||||
_LIGHTRAG_AVAILABLE = False
|
||||
_IMPORT_ERROR = exc
|
||||
lightrag_logger = logging.getLogger("lightrag_stub")
|
||||
|
||||
class QueryParam: # type: ignore[override]
|
||||
def __init__(self, mode: str = "mix") -> None:
|
||||
self.mode = mode
|
||||
self.stream = True
|
||||
|
||||
class LightRAG: # type: ignore[override]
|
||||
async def initialize_storages(self) -> None:
|
||||
return None
|
||||
|
||||
async def ainsert(self, *_: Any, **__: Any) -> str:
|
||||
return "lightrag-skipped"
|
||||
|
||||
async def aquery_llm(self, *_: Any, **__: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"llm_response": {
|
||||
"response_iterator": iter(()),
|
||||
"content": "LightRAG 已跳过",
|
||||
"is_streaming": False,
|
||||
},
|
||||
"data": {"references": []},
|
||||
"metadata": {"skipped": True},
|
||||
}
|
||||
|
||||
async def initialize_pipeline_status() -> None: # type: ignore[override]
|
||||
return None
|
||||
|
||||
def gpt_4o_mini_complete(*_: Any, **__: Any) -> str: # type: ignore[override]
|
||||
return ""
|
||||
|
||||
def openai_embed(*_: Any, **__: Any) -> List[float]: # type: ignore[override]
|
||||
return []
|
||||
|
||||
_ensure_lightrag_available()
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LightRAGService:
|
||||
"""
|
||||
管理 LightRAG 单例、工作空间隔离与增量索引。
|
||||
- 每个 workspace 映射为一个 LightRAG 实例(共享 Postgres)
|
||||
- 支持在同步/异步上下文中调用
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.collection = settings.lightrag_collection
|
||||
self._instances: Dict[str, LightRAG] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
self._pipeline_ready = False
|
||||
self._configure_pg_env()
|
||||
if not _LIGHTRAG_AVAILABLE and _IMPORT_ERROR:
|
||||
logger.warning("LightRAG 不可用,使用占位实现:%s", _IMPORT_ERROR)
|
||||
self._working_dir = (
|
||||
Path(__file__).resolve().parent.parent / "runtime" / "lightrag_cache"
|
||||
)
|
||||
self._working_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._availability_error: Optional[str] = None
|
||||
|
||||
async def queue_index(self, document_id: str, raw_text: str) -> None:
|
||||
"""预留方法:后续调用 LightRAG.update_index。"""
|
||||
return None
|
||||
def _configure_pg_env(self) -> None:
|
||||
"""根据配置将 pgvector 连接信息注入 LightRAG 需要的环境变量。"""
|
||||
parsed = urlparse(settings.lightrag_db_url)
|
||||
if parsed.scheme not in {"postgresql", "postgres"}:
|
||||
self._availability_error = "LIGHTRAG_DB_URL 必须是 Postgres 连接串"
|
||||
return
|
||||
|
||||
async def query(self, query_text: str, user_id: str) -> str:
|
||||
"""预留方法:后续调用 LightRAG.query,当前返回占位回答。"""
|
||||
return f"[mock] {query_text}"
|
||||
if parsed.username:
|
||||
os.environ.setdefault("POSTGRES_USER", parsed.username)
|
||||
if parsed.password:
|
||||
os.environ.setdefault("POSTGRES_PASSWORD", parsed.password)
|
||||
if parsed.hostname:
|
||||
os.environ.setdefault("POSTGRES_HOST", parsed.hostname)
|
||||
if parsed.port:
|
||||
os.environ.setdefault("POSTGRES_PORT", str(parsed.port))
|
||||
if parsed.path and len(parsed.path) > 1:
|
||||
os.environ.setdefault("POSTGRES_DATABASE", parsed.path.lstrip("/"))
|
||||
# OpenAI key 也交给环境变量,缺失时依旧由外部控制
|
||||
if settings.openai_api_key:
|
||||
os.environ.setdefault("OPENAI_API_KEY", settings.openai_api_key)
|
||||
elif self._availability_error is None:
|
||||
# 允许无 key 但标记提示,便于健康检查返回可诊断信息
|
||||
self._availability_error = "缺少 OPENAI_API_KEY,LightRAG 将使用占位实现"
|
||||
|
||||
def _namespace(self, workspace_id: Optional[str], user_id: str) -> str:
|
||||
"""生成 LightRAG workspace 名称,优先 workspace,其次 user。"""
|
||||
if workspace_id:
|
||||
return f"workspace_{workspace_id}"
|
||||
return f"user_{user_id}"
|
||||
|
||||
def _is_available(self) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
判断 LightRAG 是否具备运行条件。
|
||||
- import 失败或配置错误时返回 False 和原因
|
||||
"""
|
||||
if not _LIGHTRAG_AVAILABLE:
|
||||
return False, f"LightRAG 导入失败: {self._availability_error or _IMPORT_ERROR}"
|
||||
if self._availability_error:
|
||||
# 缺少关键配置时也视为不可用
|
||||
return False, self._availability_error
|
||||
return True, None
|
||||
|
||||
async def _wait_with_timeout(self, coro: Any, *, timeout: float = 6.0) -> Any:
|
||||
"""为外部调用包一层超时,避免卡住 worker / healthcheck。"""
|
||||
return await asyncio.wait_for(coro, timeout=timeout)
|
||||
|
||||
async def _get_instance(self, workspace: str) -> LightRAG:
|
||||
if workspace in self._instances:
|
||||
return self._instances[workspace]
|
||||
|
||||
lock = self._locks.setdefault(workspace, asyncio.Lock())
|
||||
async with lock:
|
||||
if workspace in self._instances:
|
||||
return self._instances[workspace]
|
||||
|
||||
rag = LightRAG(
|
||||
working_dir=str(self._working_dir),
|
||||
workspace=workspace,
|
||||
kv_storage="PGKVStorage",
|
||||
vector_storage="PGVectorStorage",
|
||||
graph_storage="PGGraphStorage",
|
||||
doc_status_storage="PGDocStatusStorage",
|
||||
llm_model_func=gpt_4o_mini_complete,
|
||||
embedding_func=openai_embed,
|
||||
)
|
||||
await self._wait_with_timeout(rag.initialize_storages())
|
||||
if not self._pipeline_ready:
|
||||
await self._wait_with_timeout(initialize_pipeline_status())
|
||||
self._pipeline_ready = True
|
||||
self._instances[workspace] = rag
|
||||
lightrag_logger.info("LightRAG workspace %s ready", workspace)
|
||||
return rag
|
||||
|
||||
async def index_document_async(
|
||||
self,
|
||||
*,
|
||||
document_id: str,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
) -> str:
|
||||
workspace = self._namespace(workspace_id, user_id)
|
||||
rag = await self._get_instance(workspace)
|
||||
clean_text = text.strip()
|
||||
if not clean_text:
|
||||
raise ValueError("空文本无法建立 LightRAG 索引")
|
||||
file_path = f"doc://{document_id}"
|
||||
if title:
|
||||
file_path = f"{file_path}?title={title}"
|
||||
track_id = await rag.ainsert(
|
||||
clean_text,
|
||||
ids=[document_id],
|
||||
file_paths=[file_path],
|
||||
)
|
||||
return track_id
|
||||
|
||||
def index_document(
|
||||
self,
|
||||
*,
|
||||
document_id: str,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
) -> str:
|
||||
"""同步环境(如 Celery)调用的封装。"""
|
||||
return asyncio.run(
|
||||
self.index_document_async(
|
||||
document_id=document_id,
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
text=text,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
async def query_async(
|
||||
self,
|
||||
*,
|
||||
query_text: str,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str],
|
||||
stream: bool = True,
|
||||
mode: str = "mix",
|
||||
) -> Dict[str, Any]:
|
||||
workspace = self._namespace(workspace_id, user_id)
|
||||
rag = await self._get_instance(workspace)
|
||||
param = QueryParam(mode=mode)
|
||||
param.stream = stream
|
||||
result = await self._wait_with_timeout(rag.aquery_llm(query_text, param))
|
||||
return result
|
||||
|
||||
async def stream_answer(
|
||||
self,
|
||||
*,
|
||||
query_text: str,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""统一返回结构,包含 SSE 需要的 iterator 与引用。"""
|
||||
ok, reason = self._is_available()
|
||||
if not ok:
|
||||
return {
|
||||
"references": [],
|
||||
"iterator": iter(()),
|
||||
"content": f"LightRAG 未就绪:{reason}",
|
||||
"is_streaming": False,
|
||||
"metadata": {"skipped": True, "reason": reason},
|
||||
}
|
||||
result = await self.query_async(
|
||||
query_text=query_text,
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
stream=True,
|
||||
)
|
||||
llm_resp = result.get("llm_response", {})
|
||||
references: List[Dict[str, Any]] = (
|
||||
result.get("data", {}).get("references", []) or []
|
||||
)
|
||||
return {
|
||||
"references": references,
|
||||
"iterator": llm_resp.get("response_iterator"),
|
||||
"content": llm_resp.get("content"),
|
||||
"is_streaming": llm_resp.get("is_streaming", False),
|
||||
"metadata": result.get("metadata", {}),
|
||||
}
|
||||
|
||||
async def run_healthcheck(self) -> Dict[str, Any]:
|
||||
workspace = "__healthcheck__"
|
||||
ok, reason = self._is_available()
|
||||
if not ok:
|
||||
return {"ok": False, "workspace": workspace, "error": reason}
|
||||
try:
|
||||
rag = await self._get_instance(workspace)
|
||||
await self._wait_with_timeout(rag.doc_status.initialize(), timeout=5.0)
|
||||
return {"ok": True, "workspace": workspace}
|
||||
except Exception as exc: # pragma: no cover - 调试辅助
|
||||
return {"ok": False, "workspace": workspace, "error": str(exc)}
|
||||
|
||||
|
||||
lightrag_service = LightRAGService()
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
"""MinerU OCR 占位服务。"""
|
||||
"""MinerU OCR 服务封装,支持调用本地 MinerU HTTP 接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class MinerUServiceError(RuntimeError):
|
||||
"""MinerU 调用失败时抛出的异常。"""
|
||||
|
||||
|
||||
class MinerUService:
|
||||
def __init__(self) -> None:
|
||||
self.endpoint = os.getenv("MINERU_ENDPOINT", "http://127.0.0.1:18888").rstrip("/")
|
||||
timeout = float(os.getenv("MINERU_TIMEOUT_SECONDS", "300"))
|
||||
# MinerU 首次加载模型可能耗时较长,这里提高超时时间避免大文件 OCR 直接失败
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def _request_markdown(self, file_path: str) -> Tuple[Optional[str], dict]:
|
||||
"""
|
||||
调用 MinerU /file_parse,返回 (markdown, raw_response)。
|
||||
若接口未返回内容则返回 (None, response_dict)。
|
||||
"""
|
||||
if not self.endpoint:
|
||||
raise MinerUServiceError("未配置 MINERU_ENDPOINT,无法调用 MinerU")
|
||||
|
||||
url = f"{self.endpoint}/file_parse"
|
||||
file_name = Path(file_path).name
|
||||
backend = os.getenv("MINERU_DEFAULT_BACKEND", "vlm-transformers")
|
||||
model_path = os.getenv("MINERU_MODEL_PATH")
|
||||
with open(file_path, "rb") as fp:
|
||||
response = self._client.post(
|
||||
url,
|
||||
files={"files": (file_name, fp, "application/octet-stream")},
|
||||
data={
|
||||
"return_md": "true",
|
||||
"return_content_list": "false",
|
||||
"return_middle_json": "false",
|
||||
"response_format_zip": "false",
|
||||
"backend": backend,
|
||||
# fast_api main 会从环境注入 model_path;这里双保险随请求传递
|
||||
**({"model_path": model_path} if model_path else {}),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
results = payload.get("results") or {}
|
||||
if not isinstance(results, dict) or not results:
|
||||
return None, payload
|
||||
first_key = next(iter(results.keys()))
|
||||
md_content = results.get(first_key, {}).get("md_content")
|
||||
return md_content, payload
|
||||
|
||||
async def extract_markdown(self, file_url: str) -> str:
|
||||
"""
|
||||
阶段 0:直接返回固定内容,保证前端流程贯通。
|
||||
@@ -9,5 +62,19 @@ class MinerUService:
|
||||
"""
|
||||
return f"# OCR Placeholder\n\n源文件:{file_url}"
|
||||
|
||||
def extract_markdown_sync(self, file_path: str) -> str:
|
||||
"""
|
||||
Celery 任务使用的同步封装。
|
||||
- 若 MinerU 服务可用:调用 HTTP 接口返回 markdown
|
||||
- 若失败:抛出 MinerUServiceError 让上层标记失败
|
||||
"""
|
||||
try:
|
||||
markdown, raw = self._request_markdown(file_path)
|
||||
if markdown:
|
||||
return markdown
|
||||
raise MinerUServiceError(f"MinerU 未返回 md_content,响应片段:{str(raw)[:300]}")
|
||||
except Exception as exc: # pragma: no cover - IO/网络异常
|
||||
raise MinerUServiceError(f"MinerU 调用失败:{exc}") from exc
|
||||
|
||||
|
||||
mineru_service = MinerUService()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""简单的文件下载器,负责将 Supabase Storage 签名 URL 暂存到临时目录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class StorageFetcher:
|
||||
def __init__(self) -> None:
|
||||
self._client = httpx.Client(timeout=30.0)
|
||||
|
||||
def download(self, file_url: str) -> str:
|
||||
response = self._client.get(file_url)
|
||||
response.raise_for_status()
|
||||
suffix = Path(urlparse(file_url).path).suffix or ".bin"
|
||||
fd, path = tempfile.mkstemp(suffix=suffix)
|
||||
try:
|
||||
os.write(fd, response.content)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
def cleanup(self, path: Optional[str]) -> None:
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
storage_fetcher = StorageFetcher()
|
||||
@@ -12,10 +12,11 @@ class SupabaseRestClient:
|
||||
|
||||
def __init__(self) -> None:
|
||||
base_url = settings.supabase_url.rstrip("/")
|
||||
apikey = settings.supabase_anon_key or settings.supabase_service_role_key
|
||||
self.client = httpx.Client(
|
||||
base_url=f"{base_url}/rest/v1",
|
||||
headers={
|
||||
"apikey": settings.supabase_service_role_key,
|
||||
"apikey": apikey,
|
||||
"Authorization": f"Bearer {settings.supabase_service_role_key}",
|
||||
},
|
||||
timeout=10.0,
|
||||
@@ -43,6 +44,32 @@ class SupabaseRestClient:
|
||||
return data[0]
|
||||
return None
|
||||
|
||||
def select(
|
||||
self,
|
||||
table: str,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
columns: str = "*",
|
||||
order: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[Dict[str, Any]]:
|
||||
params: Dict[str, Any] = {"select": columns}
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
params[key] = f"eq.{value}"
|
||||
if order:
|
||||
params["order"] = order
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
response = self.client.get(f"/{table}", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if data:
|
||||
return [data]
|
||||
return []
|
||||
|
||||
def update(self, table: str, filters: Dict[str, Any], payload: Dict[str, Any]) -> None:
|
||||
params = {key: f"eq.{value}" for key, value in filters.items()}
|
||||
response = self.client.patch(
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from kombu import Queue
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def create_celery_app() -> Celery:
|
||||
app = Celery("wolai-backend")
|
||||
app.conf.broker_url = settings.redis_url
|
||||
app.conf.result_backend = settings.redis_url
|
||||
app.conf.task_routes = {"app.workers.tasks.*": {"queue": "wolai-tasks"}}
|
||||
app.conf.task_default_queue = "wolai-index"
|
||||
app.conf.task_queues = (
|
||||
Queue("wolai-ocr", routing_key="tasks.ocr", max_priority=10),
|
||||
Queue("wolai-index", routing_key="tasks.index", max_priority=10),
|
||||
)
|
||||
app.conf.task_routes = {
|
||||
"app.workers.tasks.ocr_pipeline": {
|
||||
"queue": "wolai-ocr",
|
||||
"routing_key": "tasks.ocr",
|
||||
"priority": 0,
|
||||
},
|
||||
"app.workers.tasks.media_ocr_pipeline": {
|
||||
"queue": "wolai-ocr",
|
||||
"routing_key": "tasks.ocr",
|
||||
"priority": 1,
|
||||
},
|
||||
"app.workers.tasks.lightrag_index_pipeline": {
|
||||
"queue": "wolai-index",
|
||||
"routing_key": "tasks.index",
|
||||
"priority": 5,
|
||||
},
|
||||
}
|
||||
app.conf.broker_transport_options = {"priority_steps": list(range(10))}
|
||||
app.conf.worker_prefetch_multiplier = 1
|
||||
app.conf.task_acks_late = True
|
||||
app.conf.worker_concurrency = 3
|
||||
app.conf.task_always_eager = _env_flag("CELERY_TASK_ALWAYS_EAGER", False)
|
||||
app.conf.task_eager_propagates = _env_flag("CELERY_TASK_EAGER_PROPAGATES", True)
|
||||
app.autodiscover_tasks(["app.workers"])
|
||||
return app
|
||||
|
||||
|
||||
celery_app = create_celery_app()
|
||||
celery_app.set_default()
|
||||
|
||||
@@ -1,45 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from app.services.lightrag_service import lightrag_service
|
||||
from app.services.mineru_service import mineru_service
|
||||
from app.services.storage_fetcher import storage_fetcher
|
||||
from app.services.supabase_rest import supabase_rest
|
||||
from app.services.task_tracker import task_tracker
|
||||
from app.workers.utils import dispatch_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="app.workers.tasks.ocr_pipeline")
|
||||
def ocr_pipeline(task_id: str, document_id: str, file_url: str, user_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
阶段 0 Celery 任务:模拟 OCR,向 documents+background_tasks 回写占位结果。
|
||||
阶段 1 将在此处串联 MinerU OCR 与 LightRAG 索引。
|
||||
阶段 0 Celery 任务:调用 MinerU 占位实现并写回 documents/background_tasks。
|
||||
阶段 1 会在此处替换为真实的 MinerU CLI / LightRAG 流程。
|
||||
"""
|
||||
supabase_rest.update("background_tasks", {"id": task_id}, {"status": "processing", "progress": 30})
|
||||
status = "completed"
|
||||
message = "OCR 完成"
|
||||
|
||||
markdown = f"# OCR 结果占位\\n\\n文件地址:{file_url}\\n\\n> 阶段 1 将替换为 MinerU 输出。"
|
||||
supabase_rest.update(
|
||||
"documents",
|
||||
{"id": document_id, "user_id": user_id},
|
||||
{
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": markdown,
|
||||
}
|
||||
]
|
||||
file_path = None
|
||||
try:
|
||||
file_path = storage_fetcher.download(file_url)
|
||||
markdown = mineru_service.extract_markdown_sync(file_path)
|
||||
supabase_rest.update(
|
||||
"documents",
|
||||
{"id": document_id, "user_id": user_id},
|
||||
{
|
||||
"content": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": markdown,
|
||||
}
|
||||
]
|
||||
},
|
||||
"raw_text": markdown,
|
||||
"index_status": "pending",
|
||||
},
|
||||
"raw_text": markdown,
|
||||
"index_status": "completed",
|
||||
},
|
||||
)
|
||||
)
|
||||
supabase_rest.update("background_tasks", {"id": task_id}, {"progress": 80})
|
||||
index_task = task_tracker.create_task(
|
||||
user_id=user_id, document_id=document_id, task_type="index"
|
||||
)
|
||||
dispatch_task(lightrag_index_pipeline, index_task.task_id, document_id, user_id)
|
||||
except Exception as exc: # pragma: no cover - 网络/IO异常
|
||||
status = "failed"
|
||||
message = f"OCR 失败:{exc}"
|
||||
finally:
|
||||
storage_fetcher.cleanup(file_path)
|
||||
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{
|
||||
"status": "completed",
|
||||
"status": status,
|
||||
"progress": 100,
|
||||
"message": "OCR 模拟完成",
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -47,5 +70,132 @@ def ocr_pipeline(task_id: str, document_id: str, file_url: str, user_id: str) ->
|
||||
"task_id": task_id,
|
||||
"document_id": document_id,
|
||||
"file_url": file_url,
|
||||
"status": "completed",
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name="app.workers.tasks.media_ocr_pipeline")
|
||||
def media_ocr_pipeline(asset_id: str, user_id: str, document_id: Optional[str] = None) -> Dict[str, str]:
|
||||
"""媒体 OCR:下载资源 → 调用 MinerU → 写回 ocr_text/ocr_payload。"""
|
||||
logger.info("媒体 OCR 任务执行 asset_id=%s user_id=%s", asset_id, user_id)
|
||||
asset = supabase_rest.select_one("media_assets", {"id": asset_id})
|
||||
if not asset:
|
||||
logger.warning("媒体 OCR 任务失败:资源不存在 %s", asset_id)
|
||||
return {"asset_id": asset_id, "status": "failed"}
|
||||
file_url = asset.get("file_url")
|
||||
ocr_status = "completed"
|
||||
ocr_text: Optional[str] = None
|
||||
message = "媒体 OCR 完成"
|
||||
file_path = None
|
||||
try:
|
||||
file_path = storage_fetcher.download(str(file_url))
|
||||
ocr_text = mineru_service.extract_markdown_sync(file_path)
|
||||
except Exception as exc: # pragma: no cover - 网络/IO异常
|
||||
logger.warning("媒体 OCR 失败 asset=%s err=%s", asset_id, exc)
|
||||
ocr_status = "failed"
|
||||
message = f"OCR 失败:{exc}"
|
||||
finally:
|
||||
storage_fetcher.cleanup(file_path)
|
||||
supabase_rest.update(
|
||||
"media_assets",
|
||||
{"id": asset_id},
|
||||
{
|
||||
"ocr_status": ocr_status,
|
||||
"ocr_text": ocr_text,
|
||||
"ocr_payload": {"pages": [], "summary": ocr_text} if ocr_text else None,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"document_id": document_id,
|
||||
"user_id": user_id,
|
||||
"status": ocr_status,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name="app.workers.tasks.lightrag_index_pipeline")
|
||||
def lightrag_index_pipeline(task_id: str, document_id: str, user_id: str) -> Dict[str, str]:
|
||||
"""执行 LightRAG 索引构建,独立于 OCR。"""
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{"status": "processing", "progress": 10, "message": "LightRAG 索引排队中"},
|
||||
)
|
||||
document = supabase_rest.select_one("documents", {"id": document_id, "user_id": user_id})
|
||||
if not document:
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{"status": "failed", "progress": 100, "message": "文档不存在"},
|
||||
)
|
||||
return {"status": "failed", "document_id": document_id}
|
||||
|
||||
# 先做健康检查,避免长时间阻塞或无谓重试
|
||||
try:
|
||||
health = asyncio.run(lightrag_service.run_healthcheck())
|
||||
except Exception as exc: # pragma: no cover - 调试辅助
|
||||
health = {"ok": False, "error": str(exc)}
|
||||
if not health.get("ok"):
|
||||
reason = health.get("error", "LightRAG 未就绪")
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{"status": "failed", "progress": 100, "message": reason},
|
||||
)
|
||||
supabase_rest.update(
|
||||
"documents", {"id": document_id}, {"index_status": "failed"}
|
||||
)
|
||||
return {"status": "failed", "document_id": document_id, "error": reason}
|
||||
|
||||
raw_text = document.get("raw_text") or ""
|
||||
text_to_index = str(raw_text).strip()
|
||||
if not text_to_index:
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{"status": "failed", "progress": 100, "message": "raw_text 为空,无法索引"},
|
||||
)
|
||||
return {"status": "failed", "document_id": document_id}
|
||||
workspace_id = document.get("workspace_id")
|
||||
title = document.get("title")
|
||||
supabase_rest.update(
|
||||
"documents", {"id": document_id}, {"index_status": "processing"}
|
||||
)
|
||||
try:
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{"progress": 40, "message": "LightRAG 建立中"},
|
||||
)
|
||||
lightrag_service.index_document(
|
||||
document_id=document_id,
|
||||
user_id=user_id,
|
||||
workspace_id=str(workspace_id) if workspace_id else None,
|
||||
text=text_to_index,
|
||||
title=title if isinstance(title, str) else None,
|
||||
)
|
||||
supabase_rest.update(
|
||||
"documents", {"id": document_id}, {"index_status": "completed"}
|
||||
)
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{"progress": 100, "status": "completed", "message": "LightRAG 完成"},
|
||||
)
|
||||
return {"status": "completed", "document_id": document_id}
|
||||
except Exception as exc: # pragma: no cover - 调试错误
|
||||
supabase_rest.update(
|
||||
"documents", {"id": document_id}, {"index_status": "failed"}
|
||||
)
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{
|
||||
"progress": 100,
|
||||
"status": "failed",
|
||||
"message": f"LightRAG 失败:{exc}",
|
||||
},
|
||||
)
|
||||
return {"status": "failed", "document_id": document_id, "error": str(exc)}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _env_flag(name: str) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return False
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def dispatch_task(task: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
"""
|
||||
根据环境变量决定直接执行任务还是通过 Celery delay。
|
||||
本地开发默认走 eager,同步执行便于调试。
|
||||
"""
|
||||
eager = _env_flag("CELERY_TASK_ALWAYS_EAGER")
|
||||
logger.info("dispatch_task task=%s eager=%s", getattr(task, "name", task.__name__), eager)
|
||||
if _env_flag("CELERY_TASK_ALWAYS_EAGER"):
|
||||
return task.apply(args=args, kwargs=kwargs)
|
||||
return task.delay(*args, **kwargs)
|
||||
Reference in New Issue
Block a user