diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 9c0c57b..7a54793 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,9 +2,10 @@ from fastapi import APIRouter -from app.api.v1.routes import film, health +from app.api.v1.routes import film, health, llm router = APIRouter() router.include_router(health.router, tags=["health"]) router.include_router(film.router, prefix="/film", tags=["film"]) +router.include_router(llm.router, prefix="/llm", tags=["llm"]) diff --git a/backend/app/api/v1/routes/film.py b/backend/app/api/v1/routes/film.py index 153aa4e..94ce2ab 100644 --- a/backend/app/api/v1/routes/film.py +++ b/backend/app/api/v1/routes/film.py @@ -15,6 +15,7 @@ from app.core.skills_runtime import ( FilmShotlistResult, ) from app.dependencies import get_llm +from app.schemas.common import ApiResponse, success_response router = APIRouter() @@ -50,14 +51,14 @@ class ShotlistExtractRequest(BaseModel): @router.post( "/extract/entities", - response_model=FilmEntityExtractionResult, + response_model=ApiResponse[FilmEntityExtractionResult], summary="关键信息抽取", description="从小说文本中抽取人物、地点、道具,忠实原文、可追溯证据。", ) def extract_entities( body: EntityExtractRequest, llm: Runnable = Depends(get_llm), -) -> FilmEntityExtractionResult: +) -> ApiResponse[FilmEntityExtractionResult]: """FilmEntityExtractor:人物/地点/道具抽取。""" extractor = FilmEntityExtractor(llm) extractor.load_skill("film_entity_extractor") @@ -65,25 +66,26 @@ def extract_entities( [{"chunk_id": c.chunk_id, "text": c.text} for c in body.chunks], ensure_ascii=False, ) - return extractor.extract( + result = extractor.extract( { "source_id": body.source_id, "language": body.language or "zh", "chunks_json": chunks_json, } ) + return success_response(result) @router.post( "/extract/shotlist", - response_model=FilmShotlistResult, + response_model=ApiResponse[FilmShotlistResult], summary="分镜抽取", description="将小说片段转为可拍镜头表(景别/机位/运镜/转场/VFX)。", ) def extract_shotlist( body: ShotlistExtractRequest, llm: Runnable = Depends(get_llm), -) -> FilmShotlistResult: +) -> ApiResponse[FilmShotlistResult]: """FilmShotlistStoryboarder:场景/镜头/转场抽取。""" storyboarder = FilmShotlistStoryboarder(llm) storyboarder.load_skill("film_shotlist") @@ -91,7 +93,7 @@ def extract_shotlist( [{"chunk_id": c.chunk_id, "text": c.text} for c in body.chunks], ensure_ascii=False, ) - return storyboarder.extract( + result = storyboarder.extract( { "source_id": body.source_id, "source_title": body.source_title or "", @@ -99,3 +101,4 @@ def extract_shotlist( "chunks_json": chunks_json, } ) + return success_response(result) diff --git a/backend/app/api/v1/routes/health.py b/backend/app/api/v1/routes/health.py index 4d6a39a..b8d480f 100644 --- a/backend/app/api/v1/routes/health.py +++ b/backend/app/api/v1/routes/health.py @@ -2,9 +2,11 @@ from fastapi import APIRouter +from app.schemas.common import ApiResponse, success_response + router = APIRouter() -@router.get("/health") -async def v1_health(): - return {"status": "ok", "version": "v1"} +@router.get("/health", response_model=ApiResponse[dict]) +async def v1_health() -> ApiResponse[dict]: + return success_response(data={"status": "ok", "version": "v1"}) diff --git a/backend/app/api/v1/routes/llm.py b/backend/app/api/v1/routes/llm.py new file mode 100644 index 0000000..4e84064 --- /dev/null +++ b/backend/app/api/v1/routes/llm.py @@ -0,0 +1,350 @@ +"""LLM 相关基础配置的 CRUD 接口:Provider / Model / ModelSettings。""" + +from __future__ import annotations + +from typing import Sequence + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db +from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider +from app.schemas.common import ApiResponse, PaginatedData, success_response, paginated_response +from app.schemas.llm import ( + ModelCreate, + ModelRead, + ModelSettingsRead, + ModelSettingsUpdate, + ModelUpdate, + ProviderCreate, + ProviderRead, + ProviderUpdate, +) + +router = APIRouter() + +# 列表排序允许的字段(避免注入) +PROVIDER_ORDER_FIELDS = {"name", "created_at", "updated_at"} +MODEL_ORDER_FIELDS = {"name", "category", "created_at", "updated_at"} +DEFAULT_PAGE_SIZE = 10 +MAX_PAGE_SIZE = 100 + + +# ---------- Provider ---------- + + +@router.get( + "/providers", + response_model=ApiResponse[PaginatedData[ProviderRead]], + summary="列出模型供应商(分页)", +) +async def list_providers( + db: AsyncSession = Depends(get_db), + q: str | None = Query(None, description="关键字,过滤 name/description"), + order: str | None = Query(None, description="排序字段:name, created_at, updated_at"), + is_desc: bool = Query(False, description="是否倒序"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页条数"), +) -> ApiResponse[PaginatedData[ProviderRead]]: + stmt = select(Provider) + count_stmt = select(func.count()).select_from(Provider) + if q and q.strip(): + q_pattern = f"%{q.strip()}%" + stmt = stmt.where( + Provider.name.ilike(q_pattern) | Provider.description.ilike(q_pattern) + ) + count_stmt = count_stmt.where( + Provider.name.ilike(q_pattern) | Provider.description.ilike(q_pattern) + ) + order_col = order if order and order in PROVIDER_ORDER_FIELDS else "created_at" + order_attr = getattr(Provider, order_col) + stmt = stmt.order_by(order_attr.desc() if is_desc else order_attr.asc()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(stmt) + providers: Sequence[Provider] = result.scalars().all() + items = [ProviderRead.model_validate(p) for p in providers] + return paginated_response(items, page=page, page_size=page_size, total=total) + + +@router.post( + "/providers", + response_model=ApiResponse[ProviderRead], + status_code=status.HTTP_201_CREATED, + summary="创建模型供应商", +) +async def create_provider( + body: ProviderCreate, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ProviderRead]: + exists = await db.get(Provider, body.id) + if exists is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Provider with id={body.id} already exists", + ) + provider = Provider( + id=body.id, + name=body.name, + base_url=body.base_url, + api_key=body.api_key, + api_secret=body.api_secret, + description=body.description, + status=body.status, + created_by=body.created_by, + ) + db.add(provider) + await db.flush() + await db.refresh(provider) + return success_response(ProviderRead.model_validate(provider), code=201) + + +@router.get( + "/providers/{provider_id}", + response_model=ApiResponse[ProviderRead], + summary="获取单个模型供应商", +) +async def get_provider( + provider_id: str, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ProviderRead]: + provider = await db.get(Provider, provider_id) + if provider is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found") + return success_response(ProviderRead.model_validate(provider)) + + +@router.patch( + "/providers/{provider_id}", + response_model=ApiResponse[ProviderRead], + summary="更新模型供应商", +) +async def update_provider( + provider_id: str, + body: ProviderUpdate, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ProviderRead]: + provider = await db.get(Provider, provider_id) + if provider is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(provider, field, value) + + await db.flush() + await db.refresh(provider) + return success_response(ProviderRead.model_validate(provider)) + + +@router.delete( + "/providers/{provider_id}", + response_model=ApiResponse[None], + status_code=status.HTTP_200_OK, + summary="删除模型供应商", +) +async def delete_provider( + provider_id: str, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[None]: + provider = await db.get(Provider, provider_id) + if provider is None: + return success_response(None) + await db.delete(provider) + await db.flush() + return success_response(None) + + +# ---------- Model ---------- + + +@router.get( + "/models", + response_model=ApiResponse[PaginatedData[ModelRead]], + summary="列出模型(分页)", +) +async def list_models( + db: AsyncSession = Depends(get_db), + provider_id: str | None = Query(None, description="按供应商过滤"), + category: ModelCategoryKey | None = Query(None, description="按模型类别过滤"), + q: str | None = Query(None, description="关键字,过滤 name/description"), + order: str | None = Query(None, description="排序字段:name, category, created_at, updated_at"), + is_desc: bool = Query(False, description="是否倒序"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页条数"), +) -> ApiResponse[PaginatedData[ModelRead]]: + stmt = select(Model) + count_stmt = select(func.count()).select_from(Model) + if provider_id is not None: + stmt = stmt.where(Model.provider_id == provider_id) + count_stmt = count_stmt.where(Model.provider_id == provider_id) + if category is not None: + stmt = stmt.where(Model.category == category) + count_stmt = count_stmt.where(Model.category == category) + if q and q.strip(): + q_pattern = f"%{q.strip()}%" + stmt = stmt.where(Model.name.ilike(q_pattern) | Model.description.ilike(q_pattern)) + count_stmt = count_stmt.where( + Model.name.ilike(q_pattern) | Model.description.ilike(q_pattern) + ) + order_col = order if order and order in MODEL_ORDER_FIELDS else "created_at" + order_attr = getattr(Model, order_col) + stmt = stmt.order_by(order_attr.desc() if is_desc else order_attr.asc()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(stmt) + models: Sequence[Model] = result.scalars().all() + items = [ModelRead.model_validate(m) for m in models] + return paginated_response(items, page=page, page_size=page_size, total=total) + + +@router.post( + "/models", + response_model=ApiResponse[ModelRead], + status_code=status.HTTP_201_CREATED, + summary="创建模型", +) +async def create_model( + body: ModelCreate, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ModelRead]: + exists = await db.get(Model, body.id) + if exists is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Model with id={body.id} already exists", + ) + + # 确保 provider 存在 + provider = await db.get(Provider, body.provider_id) + if provider is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider not found") + + model = Model( + id=body.id, + name=body.name, + category=body.category, + provider_id=body.provider_id, + params=body.params, + description=body.description, + is_default=body.is_default, + created_by=body.created_by, + ) + db.add(model) + await db.flush() + await db.refresh(model) + return success_response(ModelRead.model_validate(model), code=201) + + +@router.get( + "/models/{model_id}", + response_model=ApiResponse[ModelRead], + summary="获取单个模型", +) +async def get_model( + model_id: str, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ModelRead]: + model = await db.get(Model, model_id) + if model is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + return success_response(ModelRead.model_validate(model)) + + +@router.patch( + "/models/{model_id}", + response_model=ApiResponse[ModelRead], + summary="更新模型", +) +async def update_model( + model_id: str, + body: ModelUpdate, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ModelRead]: + model = await db.get(Model, model_id) + if model is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") + + update_data = body.model_dump(exclude_unset=True) + + # 如果更新 provider_id,需要校验新 provider 是否存在 + new_provider_id = update_data.get("provider_id") + if new_provider_id is not None: + provider = await db.get(Provider, new_provider_id) + if provider is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider not found") + + for field, value in update_data.items(): + setattr(model, field, value) + + await db.flush() + await db.refresh(model) + return success_response(ModelRead.model_validate(model)) + + +@router.delete( + "/models/{model_id}", + response_model=ApiResponse[None], + status_code=status.HTTP_200_OK, + summary="删除模型", +) +async def delete_model( + model_id: str, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[None]: + model = await db.get(Model, model_id) + if model is None: + return success_response(None) + await db.delete(model) + await db.flush() + return success_response(None) + + +# ---------- ModelSettings(单例) ---------- + + +async def _get_or_create_settings(db: AsyncSession) -> ModelSettings: + """内部工具:获取或创建单例设置行(id=1)。""" + settings = await db.get(ModelSettings, 1) + if settings is None: + settings = ModelSettings(id=1) + db.add(settings) + await db.flush() + await db.refresh(settings) + return settings + + +@router.get( + "/model-settings", + response_model=ApiResponse[ModelSettingsRead], + summary="获取模型全局设置(单例)", +) +async def get_model_settings( + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ModelSettingsRead]: + settings = await _get_or_create_settings(db) + return success_response(ModelSettingsRead.model_validate(settings)) + + +@router.put( + "/model-settings", + response_model=ApiResponse[ModelSettingsRead], + summary="更新模型全局设置(单例)", +) +async def update_model_settings( + body: ModelSettingsUpdate, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ModelSettingsRead]: + settings = await _get_or_create_settings(db) + + update_data = body.model_dump() + for field, value in update_data.items(): + setattr(settings, field, value) + + await db.flush() + await db.refresh(settings) + return success_response(ModelSettingsRead.model_validate(settings)) + diff --git a/backend/app/main.py b/backend/app/main.py index f7b346b..40116af 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,11 +2,53 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse -from app.config import settings from app.api.v1 import router as api_v1_router +from app.config import settings +from app.schemas.common import ApiResponse + + +def _error_message(detail: object) -> str: + """将异常 detail 转为前端可读的 message。""" + if isinstance(detail, str): + return detail + if isinstance(detail, list): + parts = [] + for item in detail: + if isinstance(item, dict) and "msg" in item: + loc = item.get("loc", ()) + loc_str = ".".join(str(x) for x in loc if x != "body") + parts.append(f"{loc_str}: {item['msg']}" if loc_str else item["msg"]) + else: + parts.append(str(item)) + return "; ".join(parts) if parts else "Validation error" + return str(detail) + + +async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """HTTP 异常统一为 { code, message, data: null }。""" + from fastapi import HTTPException + + if isinstance(exc, HTTPException): + code = exc.status_code + message = _error_message(exc.detail) + else: + code = 500 + message = "Internal server error" + body = ApiResponse[None](code=code, message=message, data=None).model_dump() + return JSONResponse(status_code=code, content=body) + + +async def validation_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """422 校验异常统一为 { code: 422, message, data: null }。""" + assert isinstance(exc, RequestValidationError) + message = _error_message(exc.errors()) + body = ApiResponse[None](code=422, message=message, data=None).model_dump() + return JSONResponse(status_code=422, content=body) @asynccontextmanager @@ -26,6 +68,11 @@ app = FastAPI( redoc_url="/redoc", ) +# 统一错误响应格式:{ code, message, data: null } +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(HTTPException, http_exception_handler) +app.add_exception_handler(Exception, http_exception_handler) + app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, @@ -41,4 +88,5 @@ app.include_router(api_v1_router, prefix=settings.api_v1_prefix) @app.get("/health") async def health(): """健康检查。""" - return {"status": "ok"} + from app.schemas.common import success_response + return success_response({"status": "ok"}) diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..fc456fb --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,73 @@ +"""API 全局响应结构与分页。""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class ApiResponse(BaseModel, Generic[T]): + """无分页时的统一响应结构。""" + + code: int = Field(200, description="与 HTTP 状态码一致") + message: str = Field("success", description="提示信息") + data: T | None = Field(None, description="实际数据") + + +class Pagination(BaseModel): + """分页信息。""" + + page: int = Field(..., description="当前页,从 1 开始") + page_size: int = Field(..., description="每页条数") + total: int = Field(..., description="总条数") + max_page: int = Field(..., description="最大页码") + + +class PaginatedData(BaseModel, Generic[T]): + """有分页时的 data 结构。""" + + items: list[T] = Field(..., description="当前页数据") + pagination: Pagination = Field(..., description="分页信息") + + +def success_response( + data: T, + *, + code: int = 200, + message: str = "success", +) -> ApiResponse[T]: + """构造无分页的成功响应。""" + return ApiResponse(code=code, message=message, data=data) + + +def paginated_response( + items: list[T], + *, + page: int, + page_size: int, + total: int, + code: int = 200, + message: str = "success", +) -> ApiResponse[PaginatedData[T]]: + """构造有分页的成功响应。""" + max_page = max(1, (total + page_size - 1) // page_size) if page_size > 0 else 1 + pagination = Pagination( + page=page, + page_size=page_size, + total=total, + max_page=max_page, + ) + payload = PaginatedData(items=items, pagination=pagination) + return ApiResponse(code=code, message=message, data=payload) + + +def error_response( + *, + code: int = 500, + message: str = "error", +) -> ApiResponse[None]: + """构造错误响应(data 为 null)。""" + return ApiResponse(code=code, message=message, data=None) diff --git a/backend/app/schemas/llm.py b/backend/app/schemas/llm.py new file mode 100644 index 0000000..b2f6159 --- /dev/null +++ b/backend/app/schemas/llm.py @@ -0,0 +1,117 @@ +"""与 LLM 相关的 Pydantic Schema(Provider/Model/ModelSettings)。""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + +from app.models.llm import AgentTypeKey, LogLevel, ModelCategoryKey, ProviderStatus + + +class ProviderBase(BaseModel): + """供应商通用字段(不含敏感字段)。""" + + name: str = Field(..., description="供应商名称") + base_url: str = Field(..., description="API Base URL") + description: str = Field("", description="说明") + status: ProviderStatus = Field( + ProviderStatus.testing, + description="状态:active/testing/disabled", + ) + created_by: str = Field("", description="创建人") + + +class ProviderCreate(ProviderBase): + """创建供应商时的请求体,允许填写敏感字段。""" + + id: str = Field(..., description="供应商 ID") + api_key: str = Field("", description="API Key(敏感,不在响应中回显)") + api_secret: str = Field("", description="API Secret(敏感,不在响应中回显)") + + +class ProviderUpdate(BaseModel): + """更新供应商时的可选字段。""" + + name: str | None = Field(None, description="供应商名称") + base_url: str | None = Field(None, description="API Base URL") + description: str | None = Field(None, description="说明") + status: ProviderStatus | None = Field( + None, + description="状态:active/testing/disabled", + ) + api_key: str | None = Field(None, description="API Key(敏感,不在响应中回显)") + api_secret: str | None = Field(None, description="API Secret(敏感,不在响应中回显)") + + +class ProviderRead(ProviderBase): + """对外返回的供应商信息(不包含 api_key/api_secret)。""" + + id: str = Field(..., description="供应商 ID") + + class Config: + from_attributes = True + + +class ModelBase(BaseModel): + """模型通用字段。""" + + name: str = Field(..., description="模型名称") + category: ModelCategoryKey = Field(..., description="模型类别:text/image/video") + provider_id: str = Field(..., description="所属供应商 ID") + params: dict[str, Any] = Field(default_factory=dict, description="模型参数(JSON)") + description: str = Field("", description="说明") + is_default: bool = Field(False, description="是否默认") + created_by: str = Field("", description="创建人") + + +class ModelCreate(ModelBase): + """创建模型请求体。""" + + id: str = Field(..., description="模型 ID") + + +class ModelUpdate(BaseModel): + """更新模型请求体(全部可选)。""" + + name: str | None = Field(None, description="模型名称") + category: ModelCategoryKey | None = Field(None, description="模型类别") + provider_id: str | None = Field(None, description="所属供应商 ID") + params: dict[str, Any] | None = Field(None, description="模型参数(JSON)") + description: str | None = Field(None, description="说明") + is_default: bool | None = Field(None, description="是否默认") + + +class ModelRead(ModelBase): + """对外返回的模型信息。""" + + id: str = Field(..., description="模型 ID") + + class Config: + from_attributes = True + + +class ModelSettingsBase(BaseModel): + """模型全局设置通用字段。""" + + default_text_model_id: str | None = Field(None, description="默认文本模型 ID") + default_image_model_id: str | None = Field(None, description="默认图片模型 ID") + default_video_model_id: str | None = Field(None, description="默认视频模型 ID") + api_timeout: int = Field(30, description="API 超时(秒)") + log_level: LogLevel = Field(LogLevel.info, description="日志级别") + + +class ModelSettingsUpdate(ModelSettingsBase): + """更新或保存模型全局设置请求体。""" + + pass + + +class ModelSettingsRead(ModelSettingsBase): + """对外返回的模型全局设置。""" + + id: int = Field(..., description="设置行 ID(通常为 1)") + + class Config: + from_attributes = True +