chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""FastAPI 应用初始化模块。"""
|
||||
@@ -0,0 +1,25 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""集中管理项目配置,来源于 .env / 环境变量。"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
supabase_url: str
|
||||
supabase_service_role_key: str
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
openai_api_key: str = ""
|
||||
lightrag_db_url: str
|
||||
lightrag_collection: str = "wolai-docs"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""惰性实例化设置,避免重复读取文件。"""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from typing import Optional
|
||||
|
||||
try: # Python 3.9+
|
||||
from typing import Annotated # type: ignore[attr-defined]
|
||||
except ImportError: # Python 3.8 fallback
|
||||
from typing_extensions import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
user_id: str
|
||||
access_token: str
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Annotated[Optional[str], Header(convert_underscores=False)] = None,
|
||||
) -> AuthContext:
|
||||
"""
|
||||
验证 Supabase JWT,stage0 直接依赖 service_role 解析 token。
|
||||
生产环境应通过 API Gateway 注入 user。
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
|
||||
token = authorization.replace("Bearer ", "", 1).strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Empty token")
|
||||
|
||||
auth_url = f"{settings.supabase_url.rstrip('/')}/auth/v1/user"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"apikey": settings.supabase_service_role_key,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
response = await client.get(auth_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc: # pragma: no cover - 网络异常
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Supabase token") from exc
|
||||
|
||||
data = response.json()
|
||||
user_id = data.get("id") or data.get("user", {}).get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Supabase token")
|
||||
|
||||
return AuthContext(user_id=str(user_id), access_token=token)
|
||||
|
||||
|
||||
AuthDep = Annotated[AuthContext, Depends(get_current_user)]
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import api_router, root_router
|
||||
|
||||
app = FastAPI(title="Wolai Backend", version="0.1.0-stage0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_url, "http://localhost:3000", "http://localhost:3001"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(root_router)
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Dict[str, str]:
|
||||
return {"message": "Wolai backend stage0 up"}
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import chat, health, tasks
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(tasks.router, tags=["tasks"])
|
||||
api_router.include_router(chat.router, tags=["chat"])
|
||||
|
||||
root_router = APIRouter()
|
||||
root_router.include_router(health.router, tags=["health"])
|
||||
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.deps import AuthDep
|
||||
from app.services.lightrag_service import lightrag_service
|
||||
|
||||
router = APIRouter(prefix="/chat")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def chat(query: str, document_id: str, auth: AuthDep) -> StreamingResponse: # noqa: ARG001
|
||||
"""
|
||||
SSE 流式占位。后续会调用 LightRAG + OpenAI。
|
||||
当前直接返回 mock 文字,确保前端链路可用。
|
||||
"""
|
||||
|
||||
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)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def healthcheck() -> Dict[str, str]:
|
||||
"""简单健康检查,供阶段 0 自检使用。"""
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,29 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.deps import AuthDep
|
||||
from app.schemas.tasks import OcrTaskRequest, TaskStatusResponse
|
||||
from app.services.task_tracker import task_tracker
|
||||
from app.workers.tasks import ocr_pipeline
|
||||
|
||||
router = APIRouter(prefix="/tasks")
|
||||
|
||||
|
||||
@router.post("/ocr", response_model=TaskStatusResponse)
|
||||
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(
|
||||
task_id=task.task_id,
|
||||
document_id=payload.document_id,
|
||||
file_url=str(payload.file_url),
|
||||
user_id=auth.user_id,
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
@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)
|
||||
if not task:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||
return task
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
|
||||
|
||||
class OcrTaskRequest(BaseModel):
|
||||
document_id: str
|
||||
file_url: AnyHttpUrl
|
||||
|
||||
|
||||
class TaskStatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: Literal["pending", "processing", "completed", "failed"]
|
||||
progress: int = 0
|
||||
message: Optional[str] = None
|
||||
@@ -0,0 +1,19 @@
|
||||
"""LightRAG 集成占位。阶段 1 会在这里封装真正的查询与索引。"""
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LightRAGService:
|
||||
def __init__(self) -> None:
|
||||
self.collection = settings.lightrag_collection
|
||||
|
||||
async def queue_index(self, document_id: str, raw_text: str) -> None:
|
||||
"""预留方法:后续调用 LightRAG.update_index。"""
|
||||
return None
|
||||
|
||||
async def query(self, query_text: str, user_id: str) -> str:
|
||||
"""预留方法:后续调用 LightRAG.query,当前返回占位回答。"""
|
||||
return f"[mock] {query_text}"
|
||||
|
||||
|
||||
lightrag_service = LightRAGService()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""MinerU OCR 占位服务。"""
|
||||
|
||||
|
||||
class MinerUService:
|
||||
async def extract_markdown(self, file_url: str) -> str:
|
||||
"""
|
||||
阶段 0:直接返回固定内容,保证前端流程贯通。
|
||||
阶段 1:调用 MinerU CLI / SDK,从 Supabase Storage 下载文件后解析。
|
||||
"""
|
||||
return f"# OCR Placeholder\n\n源文件:{file_url}"
|
||||
|
||||
|
||||
mineru_service = MinerUService()
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class SupabaseRestClient:
|
||||
"""轻量封装 Supabase RESTful API,兼容本地 sb_secret 密钥。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
base_url = settings.supabase_url.rstrip("/")
|
||||
self.client = httpx.Client(
|
||||
base_url=f"{base_url}/rest/v1",
|
||||
headers={
|
||||
"apikey": settings.supabase_service_role_key,
|
||||
"Authorization": f"Bearer {settings.supabase_service_role_key}",
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
def insert(self, table: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
response = self.client.post(
|
||||
f"/{table}",
|
||||
json=payload,
|
||||
headers={"Prefer": "return=representation"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data[0]
|
||||
return data
|
||||
|
||||
def select_one(self, table: str, filters: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
params = {key: f"eq.{value}" for key, value in filters.items()}
|
||||
params["select"] = "*"
|
||||
response = self.client.get(f"/{table}", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list) and data:
|
||||
return data[0]
|
||||
return None
|
||||
|
||||
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(
|
||||
f"/{table}",
|
||||
params=params,
|
||||
json=payload,
|
||||
headers={"Prefer": "return=minimal"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
supabase_rest = SupabaseRestClient()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.schemas.tasks import TaskStatusResponse
|
||||
from app.services.supabase_rest import supabase_rest
|
||||
|
||||
|
||||
class SupabaseTaskTracker:
|
||||
"""利用 Supabase background_tasks 表追踪进度。"""
|
||||
|
||||
def create_task(self, *, user_id: str, document_id: str, task_type: str = "ocr") -> TaskStatusResponse:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"document_id": document_id,
|
||||
"task_type": task_type,
|
||||
"status": "pending",
|
||||
"progress": 0,
|
||||
}
|
||||
record = supabase_rest.insert("background_tasks", payload)
|
||||
return self._to_response(record)
|
||||
|
||||
def get_task(self, *, task_id: str, user_id: str) -> Optional[TaskStatusResponse]:
|
||||
record = supabase_rest.select_one("background_tasks", {"id": task_id, "user_id": user_id})
|
||||
if not record:
|
||||
return None
|
||||
return self._to_response(record)
|
||||
|
||||
def update_task(self, *, task_id: str, **kwargs: Any) -> None:
|
||||
supabase_rest.update("background_tasks", {"id": task_id}, kwargs)
|
||||
|
||||
def _to_response(self, record: Optional[Dict[str, Any]]) -> TaskStatusResponse:
|
||||
if not record:
|
||||
raise ValueError("Task record missing")
|
||||
return TaskStatusResponse(
|
||||
task_id=str(record["id"]),
|
||||
status=record.get("status", "pending"),
|
||||
progress=record.get("progress", 0),
|
||||
message=record.get("message"),
|
||||
)
|
||||
|
||||
|
||||
task_tracker = SupabaseTaskTracker()
|
||||
@@ -0,0 +1,15 @@
|
||||
from celery import Celery
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
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.autodiscover_tasks(["app.workers"])
|
||||
return app
|
||||
|
||||
|
||||
celery_app = create_celery_app()
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from app.services.supabase_rest import supabase_rest
|
||||
|
||||
|
||||
@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 索引。
|
||||
"""
|
||||
supabase_rest.update("background_tasks", {"id": task_id}, {"status": "processing", "progress": 30})
|
||||
|
||||
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,
|
||||
}
|
||||
]
|
||||
},
|
||||
"raw_text": markdown,
|
||||
"index_status": "completed",
|
||||
},
|
||||
)
|
||||
|
||||
supabase_rest.update(
|
||||
"background_tasks",
|
||||
{"id": task_id},
|
||||
{
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"message": "OCR 模拟完成",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"document_id": document_id,
|
||||
"file_url": file_url,
|
||||
"status": "completed",
|
||||
}
|
||||
Reference in New Issue
Block a user