diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c635081 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# label_ai_service Development Guidelines + +Auto-generated from all feature plans. Last updated: 2026-04-10 + +## Active Technologies + +- Python 3.12.13(conda `label` 环境) + FastAPI ≥0.111, uvicorn[standard] ≥0.29, pydantic ≥2.7, zhipuai ≥2.1, boto3 ≥1.34, pdfplumber ≥0.11, python-docx ≥1.1, opencv-python-headless ≥4.9, numpy ≥1.26, httpx ≥0.27, python-dotenv ≥1.0, pyyaml ≥6.0 (001-ai-service-requirements) + +## Project Structure + +```text +backend/ +frontend/ +tests/ +``` + +## Commands + +cd src; pytest; ruff check . + +## Code Style + +Python 3.12.13(conda `label` 环境): Follow standard conventions + +## Recent Changes + +- 001-ai-service-requirements: Added Python 3.12.13(conda `label` 环境) + FastAPI ≥0.111, uvicorn[standard] ≥0.29, pydantic ≥2.7, zhipuai ≥2.1, boto3 ≥1.34, pdfplumber ≥0.11, python-docx ≥1.1, opencv-python-headless ≥4.9, numpy ≥1.26, httpx ≥0.27, python-dotenv ≥1.0, pyyaml ≥6.0 + + + diff --git a/specs/001-ai-service-requirements/contracts/api.md b/specs/001-ai-service-requirements/contracts/api.md new file mode 100644 index 0000000..3a93151 --- /dev/null +++ b/specs/001-ai-service-requirements/contracts/api.md @@ -0,0 +1,333 @@ +# API Contract: AI 服务接口定义 + +**Branch**: `001-ai-service-requirements` | **Date**: 2026-04-10 +**Base URL**: `http://ai-service:8000` +**API Prefix**: `/api/v1` +**Swagger**: `/docs`(FastAPI 自动生成) + +--- + +## 通用约定 + +### 请求格式 +- 所有请求体:`Content-Type: application/json` +- 无认证机制(内网服务,仅 Java 后端调用) + +### 响应格式 +- 成功:HTTP 2xx,JSON 响应体 +- 错误:HTTP 4xx/5xx,统一错误格式: + ```json + {"code": "ERROR_CODE", "message": "具体描述"} + ``` + +### 错误码 + +| HTTP 状态码 | code | 触发条件 | +|------------|------|---------| +| 400 | UNSUPPORTED_FILE_TYPE | 文件格式不支持(如 .xlsx) | +| 400 | VIDEO_TOO_LARGE | 视频文件超过大小上限 | +| 502 | STORAGE_ERROR | RustFS 不可达或文件不存在 | +| 502 | LLM_PARSE_ERROR | GLM 返回非合法 JSON | +| 503 | LLM_CALL_ERROR | GLM API 限流 / 超时 | +| 500 | INTERNAL_ERROR | 未捕获异常 | + +--- + +## 端点一览 + +| 端点 | 方法 | 功能 | 响应码 | +|------|------|------|--------| +| `/health` | GET | 健康检查 | 200 | +| `/api/v1/text/extract` | POST | 文档三元组提取 | 200 | +| `/api/v1/image/extract` | POST | 图像四元组提取 | 200 | +| `/api/v1/video/extract-frames` | POST | 视频帧提取(异步) | 202 | +| `/api/v1/video/to-text` | POST | 视频转文本(异步) | 202 | +| `/api/v1/qa/gen-text` | POST | 文本问答对生成 | 200 | +| `/api/v1/qa/gen-image` | POST | 图像问答对生成 | 200 | +| `/api/v1/finetune/start` | POST | 提交微调任务 | 200 | +| `/api/v1/finetune/status/{jobId}` | GET | 查询微调状态 | 200 | + +--- + +## 端点详情 + +### GET /health + +健康检查端点,无需认证,无请求体。 + +**响应(200 OK)**: +```json +{"status": "ok"} +``` + +--- + +### POST /api/v1/text/extract + +从存储中指定路径的文档提取文本三元组。 + +**请求体**: +```json +{ + "file_path": "text/202404/123.txt", + "file_name": "设备规范.txt", + "model": "glm-4-flash", + "prompt_template": "..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file_path | string | 是 | RustFS 中的文件路径 | +| file_name | string | 是 | 带扩展名的文件名(用于判断格式) | +| model | string | 否 | 模型名,默认使用 config 中的 default_text | +| prompt_template | string | 否 | 自定义提示词,不传使用内置模板 | + +**支持格式**: `.txt`, `.pdf`, `.docx` + +**响应(200 OK)**: +```json +{ + "items": [ + { + "subject": "变压器", + "predicate": "额定电压", + "object": "110kV", + "source_snippet": "该变压器额定电压为110kV", + "source_offset": {"start": 120, "end": 150} + } + ] +} +``` + +--- + +### POST /api/v1/image/extract + +从存储中指定路径的图片提取知识四元组,并自动裁剪 bbox 区域。 + +**请求体**: +```json +{ + "file_path": "image/202404/456.jpg", + "task_id": 789, + "model": "glm-4v-flash", + "prompt_template": "..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file_path | string | 是 | RustFS 中的图片路径 | +| task_id | int | 是 | 标注任务 ID(用于构造裁剪图存储路径) | +| model | string | 否 | 默认使用 config 中的 default_vision | +| prompt_template | string | 否 | 自定义提示词 | + +**响应(200 OK)**: +```json +{ + "items": [ + { + "subject": "电缆接头", + "predicate": "位于", + "object": "配电箱左侧", + "qualifier": "2024年检修现场", + "bbox": {"x": 10, "y": 20, "w": 100, "h": 80}, + "cropped_image_path": "crops/789/0.jpg" + } + ] +} +``` + +--- + +### POST /api/v1/video/extract-frames + +触发视频帧提取后台任务,立即返回。 + +**请求体**: +```json +{ + "file_path": "video/202404/001.mp4", + "source_id": 10, + "job_id": 42, + "mode": "interval", + "frame_interval": 30 +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file_path | string | 是 | RustFS 中的视频路径 | +| source_id | int | 是 | 原始资料 ID(用于构造帧存储路径) | +| job_id | int | 是 | 由 Java 后端分配的任务 ID | +| mode | string | 否 | `interval`(默认)或 `keyframe` | +| frame_interval | int | 否 | interval 模式专用,按帧数步进,默认 30 | + +**响应(202 Accepted)**: +```json +{"message": "任务已接受,后台处理中", "job_id": 42} +``` + +**完成后回调 Java 后端**(POST `{BACKEND_CALLBACK_URL}`): +```json +{ + "job_id": 42, + "status": "SUCCESS", + "frames": [ + {"frame_index": 0, "time_sec": 0.0, "frame_path": "frames/10/0.jpg"} + ], + "error_message": null +} +``` + +--- + +### POST /api/v1/video/to-text + +触发视频片段转文字后台任务,立即返回。 + +**请求体**: +```json +{ + "file_path": "video/202404/001.mp4", + "source_id": 10, + "job_id": 43, + "start_sec": 0, + "end_sec": 120, + "model": "glm-4v-flash", + "prompt_template": "..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file_path | string | 是 | RustFS 中的视频路径 | +| source_id | int | 是 | 原始资料 ID | +| job_id | int | 是 | 由 Java 后端分配的任务 ID | +| start_sec | float | 是 | 分析起始时间(秒) | +| end_sec | float | 是 | 分析结束时间(秒) | +| model | string | 否 | 默认使用 config 中的 default_vision | +| prompt_template | string | 否 | 自定义提示词 | + +**响应(202 Accepted)**: +```json +{"message": "任务已接受,后台处理中", "job_id": 43} +``` + +**完成后回调 Java 后端**(POST `{BACKEND_CALLBACK_URL}`): +```json +{ + "job_id": 43, + "status": "SUCCESS", + "output_path": "video-text/10/1712800000.txt", + "error_message": null +} +``` + +--- + +### POST /api/v1/qa/gen-text + +基于文本三元组批量生成候选问答对。 + +**请求体**: +```json +{ + "items": [ + { + "subject": "变压器", + "predicate": "额定电压", + "object": "110kV", + "source_snippet": "该变压器额定电压为110kV" + } + ], + "model": "glm-4-flash", + "prompt_template": "..." +} +``` + +**响应(200 OK)**: +```json +{ + "pairs": [ + {"question": "变压器的额定电压是多少?", "answer": "该变压器额定电压为110kV。"} + ] +} +``` + +--- + +### POST /api/v1/qa/gen-image + +基于图像四元组生成候选图文问答对。图片由 AI 服务从存储自动获取,调用方只需提供路径。 + +**请求体**: +```json +{ + "items": [ + { + "subject": "电缆接头", + "predicate": "位于", + "object": "配电箱左侧", + "qualifier": "2024年检修现场", + "cropped_image_path": "crops/789/0.jpg" + } + ], + "model": "glm-4v-flash", + "prompt_template": "..." +} +``` + +**响应(200 OK)**: +```json +{ + "pairs": [ + { + "question": "图中电缆接头位于何处?", + "answer": "图中电缆接头位于配电箱左侧。", + "image_path": "crops/789/0.jpg" + } + ] +} +``` + +--- + +### POST /api/v1/finetune/start + +向 ZhipuAI 提交微调任务。 + +**请求体**: +```json +{ + "jsonl_url": "https://rustfs.example.com/finetune-export/export/xxx.jsonl", + "base_model": "glm-4-flash", + "hyperparams": {"learning_rate": 1e-4, "epochs": 3} +} +``` + +**响应(200 OK)**: +```json +{"job_id": "glm-ft-xxxxxx"} +``` + +--- + +### GET /api/v1/finetune/status/{jobId} + +查询微调任务状态。 + +**路径参数**: `jobId` — 微调任务 ID(由 `/finetune/start` 返回) + +**响应(200 OK)**: +```json +{ + "job_id": "glm-ft-xxxxxx", + "status": "RUNNING", + "progress": 45, + "error_message": null +} +``` + +`status` 取值: `RUNNING` | `SUCCESS` | `FAILED` diff --git a/specs/001-ai-service-requirements/data-model.md b/specs/001-ai-service-requirements/data-model.md new file mode 100644 index 0000000..5ed2438 --- /dev/null +++ b/specs/001-ai-service-requirements/data-model.md @@ -0,0 +1,167 @@ +# Data Model: AI 服务 + +**Branch**: `001-ai-service-requirements` | **Date**: 2026-04-10 + +--- + +## 实体定义 + +### TripleItem(文本三元组) + +从文档中提取的一条知识关系。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| subject | string | 非空 | 主语实体 | +| predicate | string | 非空 | 谓语/关系 | +| object | string | 非空 | 宾语实体 | +| source_snippet | string | 非空 | 原文中的证据片段(直接引用) | +| source_offset.start | int | ≥0 | 证据片段在全文中的起始字符偏移 | +| source_offset.end | int | >start | 证据片段在全文中的结束字符偏移 | + +**状态转换**: 无(只读输出) + +--- + +### QuadrupleItem(图像四元组) + +从图像中提取的一条知识关系,带图像位置信息。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| subject | string | 非空 | 主体实体 | +| predicate | string | 非空 | 关系/属性 | +| object | string | 非空 | 客体实体 | +| qualifier | string | 可为空 | 修饰信息(时间、条件、场景) | +| bbox.x | int | ≥0 | 边界框左上角 x 像素坐标 | +| bbox.y | int | ≥0 | 边界框左上角 y 像素坐标 | +| bbox.w | int | >0 | 边界框宽度(像素) | +| bbox.h | int | >0 | 边界框高度(像素) | +| cropped_image_path | string | 非空 | 裁剪图在 RustFS 中的存储路径 | + +**派生规则**: `cropped_image_path = "crops/{task_id}/{item_index}.jpg"`,由 image_service 自动生成并上传 + +--- + +### QAPair(文本问答对) + +由文本三元组生成的训练候选问答对。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| question | string | 非空 | 问题文本 | +| answer | string | 非空 | 答案文本 | + +--- + +### ImageQAPair(图像问答对) + +由图像四元组生成的训练候选图文问答对。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| question | string | 非空 | 问题文本 | +| answer | string | 非空 | 答案文本 | +| image_path | string | 非空 | 对应裁剪图的存储路径(来源于 QuadrupleItem.cropped_image_path) | + +--- + +### FrameInfo(视频帧信息) + +视频帧提取任务中单帧的元数据。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| frame_index | int | ≥0 | 帧在视频中的原始帧序号 | +| time_sec | float | ≥0.0 | 帧对应的时间点(秒) | +| frame_path | string | 非空 | 帧图在 RustFS 中的存储路径 | + +**派生规则**: `frame_path = "frames/{source_id}/{upload_index}.jpg"` + +--- + +### VideoJobCallback(视频任务回调) + +异步视频任务完成后发送给 Java 后端的通知载荷。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| job_id | int | 非空 | 由 Java 后端分配的任务 ID | +| status | string | SUCCESS \| FAILED | 任务最终状态 | +| frames | FrameInfo[] \| null | 仅帧提取时非 null | 提取的帧列表(可为空列表) | +| output_path | string \| null | 仅视频转文本时非 null | 输出文字描述的存储路径 | +| error_message | string \| null | 仅 FAILED 时非 null | 错误描述 | + +--- + +### FinetuneJob(微调任务) + +微调任务的状态快照。 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| job_id | string | 非空 | 由 ZhipuAI 平台分配的任务 ID(如 "glm-ft-xxxxxx") | +| status | string | RUNNING \| SUCCESS \| FAILED | 当前状态 | +| progress | int \| null | 0-100 \| null | 完成百分比(ZhipuAI 支持时) | +| error_message | string \| null | 仅 FAILED 时非 null | 错误描述 | + +**状态映射**: +``` +ZhipuAI "running" → RUNNING +ZhipuAI "succeeded" → SUCCESS +ZhipuAI "failed" → FAILED +其他 → RUNNING(保守处理) +``` + +--- + +## RustFS 存储路径规范 + +| 资源类型 | 存储桶 | 路径格式 | +|----------|--------|----------| +| 上传文本文件 | `source-data` | `text/{年月}/{source_id}.txt` | +| 上传图片 | `source-data` | `image/{年月}/{source_id}.jpg` | +| 上传视频 | `source-data` | `video/{年月}/{source_id}.mp4` | +| 视频帧图 | `source-data` | `frames/{source_id}/{upload_index}.jpg` | +| 视频转译文本 | `source-data` | `video-text/{source_id}/{timestamp}.txt` | +| 图像/帧 bbox 裁剪图 | `source-data` | `crops/{task_id}/{item_index}.jpg` | +| 导出 JSONL 文件 | `finetune-export` | `export/{batchUuid}.jsonl` | + +--- + +## 配置模型 + +### config.yaml(非敏感,提交 git) + +```yaml +server: + port: 8000 + log_level: INFO + +storage: + buckets: + source_data: "source-data" + finetune_export: "finetune-export" + +backend: {} # callback_url 由 .env 注入 + +video: + frame_sample_count: 8 # 视频转文本时均匀采样帧数 + max_file_size_mb: 200 # 视频大小上限(可通过 MAX_VIDEO_SIZE_MB 覆盖) + +models: + default_text: "glm-4-flash" + default_vision: "glm-4v-flash" +``` + +### 环境变量覆盖映射 + +| 环境变量 | YAML 路径 | 说明 | +|----------|-----------|------| +| ZHIPUAI_API_KEY | zhipuai.api_key | 必填 | +| STORAGE_ACCESS_KEY | storage.access_key | 必填 | +| STORAGE_SECRET_KEY | storage.secret_key | 必填 | +| STORAGE_ENDPOINT | storage.endpoint | RustFS 地址 | +| BACKEND_CALLBACK_URL | backend.callback_url | Java 后端回调接口 | +| LOG_LEVEL | server.log_level | 日志级别 | +| MAX_VIDEO_SIZE_MB | video.max_file_size_mb | 视频大小上限 | diff --git a/specs/001-ai-service-requirements/plan.md b/specs/001-ai-service-requirements/plan.md new file mode 100644 index 0000000..900cffb --- /dev/null +++ b/specs/001-ai-service-requirements/plan.md @@ -0,0 +1,120 @@ +# Implementation Plan: AI 服务需求文档 + +**Branch**: `001-ai-service-requirements` | **Date**: 2026-04-10 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/001-ai-service-requirements/spec.md` + +> **参考实现计划(主计划)**: `docs/superpowers/plans/2026-04-10-ai-service-impl.md` +> 本文件为 speckit 规划框架文档,详细 TDD 任务(17 个步骤含完整代码)见上述主计划。 + +## Summary + +实现一个独立部署的 Python FastAPI AI 服务,为知识图谱标注平台提供文本三元组提取、图像四元组提取、视频帧处理、问答对生成和 GLM 微调管理能力。服务通过 RustFS S3 API 读写文件,通过 ZhipuAI GLM API 调用大模型,通过回调接口通知 Java 后端异步任务结果。采用 ABC 适配层(LLMClient / StorageClient)保证可扩展性,FastAPI BackgroundTasks 处理视频长任务,全量 TDD 开发。 + +## Technical Context + +**Language/Version**: Python 3.12.13(conda `label` 环境) +**Primary Dependencies**: FastAPI ≥0.111, uvicorn[standard] ≥0.29, pydantic ≥2.7, zhipuai ≥2.1, boto3 ≥1.34, pdfplumber ≥0.11, python-docx ≥1.1, opencv-python-headless ≥4.9, numpy ≥1.26, httpx ≥0.27, python-dotenv ≥1.0, pyyaml ≥6.0 +**Storage**: RustFS(S3 兼容协议,boto3 访问) +**Testing**: pytest ≥8.0 + pytest-asyncio ≥0.23,所有 service 和 router 均有单元测试 +**Target Platform**: Linux 容器(Docker + Docker Compose) +**Project Type**: web-service +**Performance Goals**: 文本提取 <60s;图像提取 <30s;视频任务接受 <1s;健康检查 <1s;QA 生成(≤10条)<90s +**Constraints**: 视频文件大小上限默认 200MB(可通过 MAX_VIDEO_SIZE_MB 环境变量配置);不访问数据库;GLM 为云端 API,图片须以 base64 传输;ZhipuAI SDK 同步阻塞,须在线程池中执行 +**Scale/Scope**: 低并发(ADMIN 手动触发),同时不超过 5 个视频任务 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +> 项目 constitution 为未填充的模板,无项目特定约束规则。以下采用通用工程原则进行评估。 + +| 原则 | 状态 | 说明 | +|------|------|------| +| 测试优先(TDD) | ✅ 通过 | 实现计划采用红绿重构循环,所有模块先写测试 | +| 简单性(YAGNI) | ✅ 通过 | BackgroundTasks 而非 Celery;无数据库;适配层仅当前实现 | +| 可观测性 | ✅ 通过 | JSON 结构化日志,含请求/GLM/视频任务维度 | +| 错误分类 | ✅ 通过 | 4 种异常类(400/502/503/500),结构化响应 | +| 可扩展性 | ✅ 通过 | LLMClient / StorageClient ABC 适配层 | +| 配置分层 | ✅ 通过 | config.yaml + .env + 环境变量覆盖 | + +**GATE RESULT**: ✅ 无违规,可进入 Phase 0。 + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-ai-service-requirements/ +├── plan.md # 本文件 (/speckit.plan 输出) +├── research.md # Phase 0 输出 +├── data-model.md # Phase 1 输出 +├── quickstart.md # Phase 1 输出 +├── contracts/ # Phase 1 输出 +│ └── api.md +└── tasks.md # Phase 2 输出 (/speckit.tasks - 未由本命令创建) +``` + +### Source Code (repository root) + +```text +label_ai_service/ +├── app/ +│ ├── main.py # FastAPI 应用入口,lifespan,/health 端点 +│ ├── core/ +│ │ ├── config.py # YAML + .env 分层配置,lru_cache 单例 +│ │ ├── logging.py # JSON 结构化日志,请求日志中间件 +│ │ ├── exceptions.py # 自定义异常 + 全局处理器 +│ │ ├── json_utils.py # GLM 响应 JSON 解析(兼容 Markdown 代码块) +│ │ └── dependencies.py # FastAPI Depends 工厂函数 +│ ├── clients/ +│ │ ├── llm/ +│ │ │ ├── base.py # LLMClient ABC(chat / chat_vision) +│ │ │ └── zhipuai_client.py # ZhipuAI 实现(线程池包装同步 SDK) +│ │ └── storage/ +│ │ ├── base.py # StorageClient ABC(download/upload/presigned/size) +│ │ └── rustfs_client.py # RustFS S3 兼容实现 +│ ├── services/ +│ │ ├── text_service.py # TXT/PDF/DOCX 解析 + 三元组提取 +│ │ ├── image_service.py # 四元组提取 + bbox 裁剪 +│ │ ├── video_service.py # 帧提取 + 视频转文本(BackgroundTask) +│ │ ├── qa_service.py # 文本/图像问答对生成(图像用 base64) +│ │ └── finetune_service.py # 微调任务提交与查询 +│ ├── routers/ +│ │ ├── text.py # POST /api/v1/text/extract +│ │ ├── image.py # POST /api/v1/image/extract +│ │ ├── video.py # POST /api/v1/video/extract-frames, /to-text +│ │ ├── qa.py # POST /api/v1/qa/gen-text, /gen-image +│ │ └── finetune.py # POST /api/v1/finetune/start, GET /status/{id} +│ └── models/ +│ ├── text_models.py +│ ├── image_models.py +│ ├── video_models.py +│ ├── qa_models.py +│ └── finetune_models.py +├── tests/ +│ ├── conftest.py # mock_llm, mock_storage fixtures +│ ├── test_config.py +│ ├── test_llm_client.py +│ ├── test_storage_client.py +│ ├── test_text_service.py +│ ├── test_text_router.py +│ ├── test_image_service.py +│ ├── test_image_router.py +│ ├── test_video_service.py +│ ├── test_video_router.py +│ ├── test_qa_service.py +│ ├── test_qa_router.py +│ ├── test_finetune_service.py +│ └── test_finetune_router.py +├── config.yaml +├── .env +├── requirements.txt +├── Dockerfile +└── docker-compose.yml +``` + +**Structure Decision**: 单项目结构(Option 1),分层为 routers → services → clients,测试与源码并列。 + +## Complexity Tracking + +> Constitution 无违规,此节无需填写。 diff --git a/specs/001-ai-service-requirements/quickstart.md b/specs/001-ai-service-requirements/quickstart.md new file mode 100644 index 0000000..53b6133 --- /dev/null +++ b/specs/001-ai-service-requirements/quickstart.md @@ -0,0 +1,109 @@ +# Quickstart: AI 服务开发指南 + +**Branch**: `001-ai-service-requirements` | **Date**: 2026-04-10 + +--- + +## 环境准备 + +```bash +# 激活 conda 环境 +conda activate label + +# 安装依赖(在 label_ai_service 目录下) +pip install -r requirements.txt +``` + +--- + +## 本地开发启动 + +```bash +# 1. 复制并配置 .env(已提交模板) +# 编辑 .env 填写真实的 ZHIPUAI_API_KEY 和 STORAGE_ENDPOINT + +# 2. 启动开发服务器 +conda run -n label uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 3. 访问 Swagger 文档 +# http://localhost:8000/docs +``` + +--- + +## 运行测试 + +```bash +# 运行全部测试 +conda run -n label pytest tests/ -v + +# 运行指定模块测试 +conda run -n label pytest tests/test_text_service.py -v + +# 运行带覆盖率报告 +conda run -n label pytest tests/ --cov=app --cov-report=term-missing +``` + +--- + +## Docker 部署 + +```bash +# 构建镜像 +docker build -t label-ai-service:dev . + +# 使用 docker-compose 启动(含 RustFS) +docker-compose up -d + +# 查看日志 +docker-compose logs -f ai-service + +# 健康检查 +curl http://localhost:8000/health +``` + +--- + +## 关键配置说明 + +### 视频大小上限调整 + +无需重建镜像,在 `.env` 中添加: +```ini +MAX_VIDEO_SIZE_MB=500 +``` + +### 切换大模型 + +修改 `config.yaml`: +```yaml +models: + default_text: "glm-4-flash" # 文本模型 + default_vision: "glm-4v-flash" # 视觉模型 +``` + +--- + +## 开发流程(TDD) + +详细的 17 个任务步骤(含完整代码)见主实现计划: +`docs/superpowers/plans/2026-04-10-ai-service-impl.md` + +每个任务的开发步骤: +1. 编写失败测试(`pytest ... -v` 验证失败) +2. 实现最小代码使测试通过(`pytest ... -v` 验证通过) +3. Commit + +--- + +## 目录结构速查 + +``` +app/ +├── main.py # 入口,/health 端点,路由注册 +├── core/ # 配置、日志、异常、工具 +├── clients/ # LLM 和 Storage 适配层(ABC + 实现) +├── services/ # 业务逻辑(text/image/video/qa/finetune) +├── routers/ # HTTP 路由处理 +└── models/ # Pydantic 请求/响应 Schema +``` diff --git a/specs/001-ai-service-requirements/research.md b/specs/001-ai-service-requirements/research.md new file mode 100644 index 0000000..b703aa8 --- /dev/null +++ b/specs/001-ai-service-requirements/research.md @@ -0,0 +1,76 @@ +# Research: AI 服务实现方案 + +**Branch**: `001-ai-service-requirements` | **Date**: 2026-04-10 +**Status**: 完成(所有决策已在设计阶段确定,无待研究项) + +--- + +## 决策记录 + +### D-001: 异步框架选型 + +**Decision**: FastAPI + uvicorn +**Rationale**: 原生 async/await 支持、Pydantic 自动校验、自动生成 Swagger 文档、Python 生态系中性能和开发效率的最优权衡。 +**Alternatives considered**: Django(过重)、Flask(无原生异步)、aiohttp(无自动文档和类型校验) + +--- + +### D-002: ZhipuAI SDK 调用方式 + +**Decision**: 同步 SDK 通过 `asyncio.get_event_loop().run_in_executor(None, ...)` 在线程池中调用 +**Rationale**: ZhipuAI 官方 SDK 为同步阻塞设计,直接在 async 函数中调用会阻塞事件循环。`run_in_executor` 将阻塞调用卸载到线程池,保持 FastAPI 事件循环响应能力。 +**Alternatives considered**: 使用 `asyncio.to_thread()`(Python 3.9+ 语法糖,等效实现,选择 run_in_executor 保持向后兼容性);使用 httpx 直接调用 ZhipuAI HTTP API(绕过 SDK 但增加维护负担) + +--- + +### D-003: 图像 QA 生成的图片传输方式 + +**Decision**: base64 编码嵌入消息体(`data:image/jpeg;base64,...`) +**Rationale**: RustFS 部署在 Docker 内网(endpoint: `http://rustfs:9000`),presigned URL 指向内网地址,云端 GLM-4V 无法访问。base64 编码将图片内容直接内联到 API 请求,不依赖网络可达性。 +**Alternatives considered**: presigned URL(不可行,内网地址云端不可达);公网 RustFS 暴露(增加安全风险) + +--- + +### D-004: 视频长任务处理机制 + +**Decision**: FastAPI BackgroundTasks + HTTP 回调通知 Java 后端 +**Rationale**: 视频处理耗时不可控(几秒到几分钟),同步等待会超时。BackgroundTasks 无需额外中间件(Redis/Celery),部署简单,任务状态通过回调接口由 Java 后端管理,符合整体架构风格。并发量有限(≤5个同时任务),BackgroundTasks 完全够用。 +**Alternatives considered**: Celery(需 Redis broker,引入额外运维负担);asyncio.create_task(进程重启会丢失任务) + +--- + +### D-005: 分层配置方案 + +**Decision**: config.yaml(稳定非敏感配置)+ .env(密钥和环境差异项),环境变量优先级高于 YAML +**Rationale**: YAML 提供结构化可读性,适合 git 追踪非敏感配置变更;.env 格式为 Docker `env_file` 原生支持;环境变量覆盖机制使容器部署时无需重建镜像即可切换配置。 +**Alternatives considered**: 纯 .env 文件(缺乏结构化,复杂配置难维护);数据库存储配置(过重) + +--- + +### D-006: 视频大文件 OOM 防护 + +**Decision**: 在视频路由层(接受请求后、启动后台任务前)通过 `storage.get_object_size()` 查询文件大小,超限返回 HTTP 400 +**Rationale**: 在下载前拒绝,避免实际 OOM;大小限制通过 config.yaml + MAX_VIDEO_SIZE_MB 环境变量运行时可配置,无需重建镜像;实现简单,无需引入流式下载的新抽象。 +**Alternatives considered**: 流式下载(Completeness: 9/10,但 YAGNI,当前规模不需要);不限制(Completeness: 4/10,有 OOM 风险) + +--- + +### D-007: 视频关键帧检测算法 + +**Decision**: 帧差分(frame difference)近似检测:计算当前帧与前帧灰度图的像素差均值,差值超过阈值(默认 30.0)判定为场景切换 +**Rationale**: OpenCV 无原生 I 帧检测 API(`CAP_PROP_POS_FRAMES` 是帧定位,非 I 帧标识)。帧差分简单有效,对场景切换检测准确,且无需视频解码器底层支持。 +**Alternatives considered**: 基于编码信息的 I 帧检测(需 FFmpeg 支持,引入额外依赖);固定间隔(不够智能,不适合关键帧模式) + +--- + +### D-008: 测试策略 + +**Decision**: pytest + pytest-asyncio,Service 层和 Router 层分别测试,使用 AsyncMock 模拟外部依赖 +**Rationale**: Service 层测试业务逻辑,不依赖 HTTP;Router 层使用 TestClient 测试完整请求流程。视频 service 测试使用真实小视频文件(OpenCV VideoWriter 生成),验证帧提取逻辑正确性。 +**Alternatives considered**: 仅集成测试(需要真实 RustFS 和 ZhipuAI,CI 成本高);全部单元测试(无法覆盖路由和异常处理器集成) + +--- + +## 无待解决项 + +所有 NEEDS CLARIFICATION 均已在设计阶段通过用户确认或合理默认值解决。本 research.md 仅作决策存档。