fix docker issue
This commit is contained in:
@@ -2,7 +2,7 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
# ── 系统依赖(PaddleOCR 需要的图形库) ──
|
# ── 系统依赖(PaddleOCR 需要的图形库) ──
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libgl1-mesa-glx \
|
libgl1 \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libsm6 \
|
libsm6 \
|
||||||
libxext6 \
|
libxext6 \
|
||||||
|
|||||||
233
README.md
233
README.md
@@ -1,88 +1,129 @@
|
|||||||
# HuiBrain - 中文直播教育知识库系统
|
# HuiBrain - 中文直播教育知识库系统
|
||||||
|
|
||||||
基于 FastAPI 的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、AI 语义搜索、企业微信机器人等功能。
|
基于 FastAPI 的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、AI 语义搜索、企业微信机器人、MCP 协议等功能。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 知识库管理
|
### 知识库管理
|
||||||
- **多格式导入**: Markdown / 纯文本 / Word 文档(.docx),自动按标题分页
|
- **多格式导入**: Markdown(解析 frontmatter + 按 `##` 标题分页)/ 纯文本 / Word 文档(.docx,按 Heading 分页)
|
||||||
- **批量导入**: 支持多文件批量导入,实时进度显示
|
- **批量导入**: 支持多文件批量导入、整个目录导入,实时进度显示
|
||||||
- **智能分块**: 按段落边界切分文本,保留重叠区域
|
- **智能分块**: 按段落边界切分文本,保留重叠区域(默认 chunk_size=300, overlap=50)
|
||||||
- **向量嵌入**: 支持 MiniMax / OpenAI / 智谱 / DashScope / 本地 BGE 五种嵌入模型
|
- **向量嵌入**: 支持 MiniMax / OpenAI / 智谱 / DashScope / 本地 BGE 五种嵌入模型
|
||||||
|
- **知识导出**: 支持 JSON 和 Markdown 两种格式导出
|
||||||
|
- **索引重建**: 支持单页面重新生成嵌入向量
|
||||||
|
|
||||||
### 图片 OCR
|
### 图片 OCR
|
||||||
- **OCR 识别**: DeepSeek Vision 模型识别图片文字
|
- **多 Provider**: DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云,支持 `auto` 自动 fallback(PaddleOCR → DeepSeek → 阿里云 → 腾讯云)
|
||||||
- **关键词提取**: MiniMax M2.7 自动提取内容标签
|
- **批量处理**: 支持拖拽上传、选择文件、选择文件夹、服务器路径批量导入
|
||||||
- **批量处理**: 支持拖拽上传、选择文件、选择文件夹
|
|
||||||
- **上传去重**: OCR 识别后自动检查内容是否已存在,避免重复录入
|
- **上传去重**: OCR 识别后自动检查内容是否已存在,避免重复录入
|
||||||
- **图片管理**: 网页端管理所有图片,支持一键去重、手动删除
|
- **图片管理**: 网页端管理所有图片,支持一键去重、手动删除
|
||||||
- **BM25 索引**: 内置倒排索引搜索引擎,jieba 中文分词
|
- **并发控制**: 通过 asyncio.Semaphore 控制 OCR 并发数量(可配置 1-10)
|
||||||
|
|
||||||
### AI 搜索
|
### AI 搜索
|
||||||
- **知识库搜索**: 关键词搜索
|
- **知识库搜索**: LIKE 关键词搜索 + LLM 查询扩展(生成语义相近的替代表述)
|
||||||
- **图片搜索**: MiniMax M2.7 语义匹配,SSE 流式实时返回结果
|
- **图片搜索**: MiniMax M2.7 语义匹配,SSE 流式实时返回结果
|
||||||
- **并发池**: 最多 10 个 LLM 请求并发,每批数量可配置
|
- **并发池**: 最多 10 个 LLM 请求并发,每批数量可配置
|
||||||
- **可配置参数**: 搜索返回数量、LLM 批量判断数量、OCR 并发数量均可在网页端设置
|
- **实体检测**: 自动识别知识点、技巧、人物、概念等实体类型
|
||||||
|
- **可配置参数**: 搜索返回数量、LLM 批量判断数量、OCR 并发数量均可在网页端动态修改
|
||||||
|
|
||||||
### 企业微信机器人
|
### 企业微信机器人
|
||||||
- **WebSocket 长连接**: 基于官方 SDK,无需公网 IP
|
- **WebSocket 长连接**: 基于 wecom-aibot-python-sdk,无需公网 IP
|
||||||
- **关键词搜索**: 用户发文字 → 搜索图片 → 返回匹配结果
|
- **关键词搜索**: 用户发文字 → 清理口语化前缀 → 搜索图片 → 流式回复文本 + 逐张发送图片
|
||||||
- **图片自动入库**: 用户发图片 → 自动 OCR 识别 → 录入知识库(自动去重)
|
- **图片自动入库**: 用户发图片 → 下载解密 → OCR 识别 → 自动去重入库 → 回复结果
|
||||||
- **集成启动**: 后端启动时自动检测配置并拉起机器人
|
- **分片上传**: 支持大图片的三步分片上传(init → chunk → finish)
|
||||||
|
- **欢迎语**: 进入会话时自动发送欢迎语
|
||||||
|
- **集成启动**: 后端启动时自动检测配置并拉起机器人(BotManager 后台线程)
|
||||||
- **网页端管理**: 在设置页面配置 Bot ID / Secret,支持在线重启
|
- **网页端管理**: 在设置页面配置 Bot ID / Secret,支持在线重启
|
||||||
|
|
||||||
|
### MCP 协议
|
||||||
|
- **MCP Server**: 支持 stdio 和 sse 两种传输协议,可被外部 AI Agent 直接调用
|
||||||
|
- **5 个工具**:
|
||||||
|
- `search_knowledge`: 语义搜索知识库
|
||||||
|
- `get_page`: 获取知识页面详情
|
||||||
|
- `list_courses`: 列出所有课程
|
||||||
|
- `get_page_chunks`: 获取页面的分块内容
|
||||||
|
- `search_images`: 根据关键字搜索已 OCR 识别的聊天截图
|
||||||
|
- **命令行入口**: `huibrain-mcp`(通过 pyproject.toml 定义)
|
||||||
|
|
||||||
### 其他
|
### 其他
|
||||||
- **MCP 协议**: 支持 Model Context Protocol,可被 AI Agent 调用
|
- **运行时配置**: 所有参数均可通过网页端设置 API 动态修改,无需重启服务
|
||||||
|
- **服务测试**: 网页端支持在线测试嵌入模型、LLM、OCR 服务连通性
|
||||||
- **Docker 部署**: 单容器部署,轻量级
|
- **Docker 部署**: 单容器部署,轻量级
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 | 说明 |
|
||||||
|------|------|
|
|------|------|------|
|
||||||
| 后端框架 | FastAPI + Uvicorn |
|
| 后端框架 | FastAPI + Uvicorn | 异步 Python Web 框架 |
|
||||||
| ORM | SQLAlchemy 2.0 (async) |
|
| ORM | SQLAlchemy 2.0 (async) | 异步 ORM |
|
||||||
| 数据库 | SQLite |
|
| 数据库 | SQLite (aiosqlite) | 轻量级,WAL 模式 |
|
||||||
| 前端 | 纯 HTML + CSS + JS(苹果风格 SPA) |
|
| 配置管理 | pydantic-settings | 环境变量 / .env 文件加载 |
|
||||||
| OCR | DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云 |
|
| 前端 | 纯 HTML + CSS + JS | 苹果风格 SPA 单页应用 |
|
||||||
| LLM | MiniMax M2.7 / DeepSeek / Qwen3-8B |
|
| OCR | DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云 | 4 种 Provider + auto fallback |
|
||||||
| 搜索 | BM25 倒排索引 + 关键词搜索 |
|
| LLM | MiniMax M2.7 / DeepSeek / OpenAI | 搜索匹配、查询扩展、批量判断 |
|
||||||
| 企业微信 | wecom-aibot-python-sdk (WebSocket 长连接) |
|
| 嵌入模型 | MiniMax / OpenAI / 智谱 / DashScope / 本地 BGE | 5 种嵌入 Provider |
|
||||||
| 部署 | Docker |
|
| 搜索 | LIKE 关键词 + LLM 查询扩展 | 知识库搜索;图片搜索走 LLM 语义匹配 |
|
||||||
|
| 企业微信 | wecom-aibot-python-sdk | WebSocket 长连接模式 |
|
||||||
|
| MCP | mcp[cli] >= 1.2.0 | Model Context Protocol |
|
||||||
|
| 部署 | Docker + docker-compose | 单容器部署 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
huibrain/
|
huibrain/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py # FastAPI 入口,路由注册,生命周期管理,BotManager
|
│ ├── __init__.py # 包初始化,定义版本号
|
||||||
│ ├── config.py # pydantic-settings 配置管理
|
│ ├── main.py # FastAPI 入口,路由注册,生命周期管理,BotManager
|
||||||
│ ├── database.py # SQLite 数据库连接
|
│ ├── config.py # pydantic-settings 配置管理(所有环境变量)
|
||||||
│ ├── models/base.py # ORM 模型(KnowledgePage, OCRImage 等)
|
│ ├── database.py # SQLite 异步数据库连接管理
|
||||||
│ ├── schemas/ # Pydantic 请求/响应 Schema
|
│ ├── wework_bot.py # 企业微信智能机器人服务(WebSocket 长连接)
|
||||||
│ ├── api/v1/ # API 路由
|
│ ├── api/
|
||||||
│ │ ├── pages.py # 知识页面 CRUD
|
│ │ └── v1/
|
||||||
│ │ ├── search.py # 关键词搜索
|
│ │ ├── pages.py # 知识页面 CRUD(列表/详情/创建/更新/删除/重建索引)
|
||||||
│ │ ├── images.py # 图片 OCR + AI 搜索(SSE)+ 去重 + 删除
|
│ │ ├── search.py # 语义搜索(向量+全文混合)
|
||||||
│ │ ├── import_export.py # 文件导入导出
|
│ │ ├── images.py # 图片 OCR + AI 搜索(SSE 流式)+ 去重 + 删除
|
||||||
│ │ └── settings.py # 系统设置 + 机器人管理
|
│ │ ├── import_export.py # 文件导入(.md/.txt/.docx)+ 目录导入 + 导出(JSON/Markdown)
|
||||||
|
│ │ └── settings.py # 系统设置 + 机器人管理 + 服务测试 + 统计信息
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── base.py # ORM 模型(KnowledgePage, KnowledgeChunk, OCRImage, WebsiteSettings)
|
||||||
|
│ ├── schemas/
|
||||||
|
│ │ ├── ocr.py # OCR 相关 Schema
|
||||||
|
│ │ ├── page.py # 页面相关 Schema
|
||||||
|
│ │ └── search.py # 搜索相关 Schema
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── ocr_service.py # OCR 识别服务(多 Provider)
|
│ │ ├── ocr_service.py # OCR 识别服务(4 种 Provider + auto fallback)
|
||||||
│ │ ├── llm_service.py # LLM 服务(标签提取/搜索匹配)
|
│ │ ├── llm_service.py # LLM 服务(聊天/查询扩展/批量判断)
|
||||||
│ │ ├── search_engine.py # BM25 倒排索引引擎
|
│ │ ├── search_service.py # 知识库搜索服务(LIKE 关键词搜索)
|
||||||
│ │ ├── search_service.py # 搜索服务
|
│ │ ├── embedding_service.py # 嵌入模型服务(5 种 Provider)
|
||||||
│ │ ├── embedding_service.py # 嵌入模型服务
|
│ │ ├── import_service.py # 文件导入服务(分块+向量化)
|
||||||
│ │ ├── import_service.py # 文件导入服务
|
│ │ └── page_service.py # 页面 CRUD 服务
|
||||||
│ │ └── page_service.py # 页面 CRUD 服务
|
│ └── mcp/
|
||||||
│ ├── wework_bot.py # 企业微信智能机器人服务
|
│ └── server.py # MCP Server 实现(5 个工具供 AI Agent 调用)
|
||||||
│ └── mcp/server.py # MCP Server 实现
|
├── static/
|
||||||
├── static/index.html # 前端单页应用
|
│ └── index.html # 前端单页应用(苹果风格 SPA)
|
||||||
├── data/images/ # 图片存储目录
|
├── data/
|
||||||
├── .env.example # 环境变量模板
|
│ ├── images/ # 图片存储目录
|
||||||
├── requirements.txt # Python 依赖
|
│ └── transcripts/ # 转写文字稿(.docx/.md)
|
||||||
├── Dockerfile # 应用镜像
|
├── docs/
|
||||||
└── docker-compose.yml # Docker 编排
|
│ ├── IMAGE_API_GUIDE.md # 图片 API 集成指南
|
||||||
|
│ └── 软著材料/ # 软件著作权申请材料
|
||||||
|
├── .env.example # 环境变量模板
|
||||||
|
├── .gitignore # Git 忽略规则
|
||||||
|
├── pyproject.toml # Python 项目元数据 + 构建配置
|
||||||
|
├── requirements.txt # Python 依赖清单
|
||||||
|
├── Dockerfile # Docker 镜像构建
|
||||||
|
└── docker-compose.yml # Docker Compose 编排
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
| 模型 | 表名 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `KnowledgePage` | knowledge_pages | 知识页面(标题/内容/来源/课程/讲师/日期/向量嵌入) |
|
||||||
|
| `KnowledgeChunk` | knowledge_chunks | 文本分块(关联页面/分块索引/内容/嵌入向量) |
|
||||||
|
| `OCRImage` | ocr_images | OCR 图片记录(文件路径/OCR文本/置信度/Provider/状态/文本块) |
|
||||||
|
| `WebsiteSettings` | website_settings | 网站设置(键值对,单例模式) |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 环境准备
|
### 1. 环境准备
|
||||||
@@ -95,7 +136,7 @@ cd huibrain
|
|||||||
# 复制配置文件
|
# 复制配置文件
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖(Python >= 3.11)
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -113,7 +154,7 @@ DEEPSEEK_API_KEY=your-deepseek-api-key
|
|||||||
DEEPSEEK_BASE_URL=http://your-ollama-host:11434/v1
|
DEEPSEEK_BASE_URL=http://your-ollama-host:11434/v1
|
||||||
DEEPSEEK_OCR_MODEL=deepseek-ocr:latest
|
DEEPSEEK_OCR_MODEL=deepseek-ocr:latest
|
||||||
|
|
||||||
# LLM(用于标签提取和搜索匹配)
|
# LLM(用于搜索匹配)
|
||||||
MINIMAX_API_KEY=your-minimax-api-key
|
MINIMAX_API_KEY=your-minimax-api-key
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
MINIMAX_CHAT_MODEL=MiniMax-M2.7
|
MINIMAX_CHAT_MODEL=MiniMax-M2.7
|
||||||
@@ -140,16 +181,20 @@ uvicorn app.main:app --host 0.0.0.0 --port 8765
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 单容器部署,数据持久化通过 `./data:/app/data` 卷挂载
|
||||||
|
- 默认端口 8000,可通过 `APP_PORT` 环境变量调整
|
||||||
|
- 内置健康检查,每 30 秒检查 `/health` 端点
|
||||||
|
|
||||||
## 图片搜索工作流
|
## 图片搜索工作流
|
||||||
|
|
||||||
```
|
```
|
||||||
用户上传图片
|
用户上传图片
|
||||||
→ DeepSeek Vision OCR 识别文字
|
→ OCR 识别文字(DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云,支持 auto fallback)
|
||||||
→ 检查 OCR 内容是否已存在(去重)
|
→ 检查 OCR 内容是否已存在(去重)
|
||||||
→ MiniMax M2.7 提取关键词标签
|
→ 存入 SQLite
|
||||||
→ 存入 SQLite + BM25 索引
|
|
||||||
|
|
||||||
用户搜索 "孩子不想上学"
|
用户搜索 "孩子不想上学"
|
||||||
|
→ LLM 查询扩展(生成语义相近的替代表述)
|
||||||
→ 从 DB 倒序取图片(每批 N 张,可配置)
|
→ 从 DB 倒序取图片(每批 N 张,可配置)
|
||||||
→ 并发池发给 MiniMax M2.7 判断
|
→ 并发池发给 MiniMax M2.7 判断
|
||||||
→ 匹配结果通过 SSE 实时推送到前端
|
→ 匹配结果通过 SSE 实时推送到前端
|
||||||
@@ -162,8 +207,8 @@ docker-compose up -d
|
|||||||
|
|
||||||
| 场景 | 说明 |
|
| 场景 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 单聊发关键词 | 搜索图片知识库,返回匹配结果(图片+预览文字) |
|
| 单聊发关键词 | 清理口语化前缀 → 搜索图片 → 流式回复文本 + 逐张发送图片 |
|
||||||
| 单聊发图片 | 自动 OCR 识别并录入知识库(自动去重) |
|
| 单聊发图片 | 下载解密 → OCR 识别 → 自动去重入库 → 回复结果 |
|
||||||
| 进入会话 | 自动发送欢迎语 |
|
| 进入会话 | 自动发送欢迎语 |
|
||||||
|
|
||||||
### 配置
|
### 配置
|
||||||
@@ -176,39 +221,80 @@ docker-compose up -d
|
|||||||
|
|
||||||
- 图片消息**仅支持单聊**,群聊中发图片不会触发机器人
|
- 图片消息**仅支持单聊**,群聊中发图片不会触发机器人
|
||||||
- 机器人支持多人同时使用(asyncio 异步架构)
|
- 机器人支持多人同时使用(asyncio 异步架构)
|
||||||
|
- 大图片支持分片上传(init → chunk → finish)
|
||||||
- 配置修改后可通过网页端「重启机器人」按钮生效
|
- 配置修改后可通过网页端「重启机器人」按钮生效
|
||||||
|
|
||||||
## API 概览
|
## API 概览
|
||||||
|
|
||||||
|
### 知识页面
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST | `/api/v1/images/recognize-direct` | 上传并识别图片 |
|
| GET | `/api/v1/pages` | 知识页面列表(分页) |
|
||||||
|
| GET | `/api/v1/pages/{id}` | 获取知识页面详情 |
|
||||||
|
| POST | `/api/v1/pages` | 创建知识页面 |
|
||||||
|
| PUT | `/api/v1/pages/{id}` | 更新知识页面 |
|
||||||
|
| DELETE | `/api/v1/pages/{id}` | 删除知识页面 |
|
||||||
|
| POST | `/api/v1/pages/{id}/rebuild-index` | 重建页面嵌入索引 |
|
||||||
|
|
||||||
|
### 搜索
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/v1/search` | 关键词搜索知识库 |
|
||||||
|
|
||||||
|
### 图片 OCR
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/v1/images/recognize` | 上传并识别图片(两步:先上传再识别) |
|
||||||
|
| POST | `/api/v1/images/recognize-direct` | 一步上传并识别图片 |
|
||||||
| POST | `/api/v1/images/batch-recognize` | 批量上传并识别 |
|
| POST | `/api/v1/images/batch-recognize` | 批量上传并识别 |
|
||||||
|
| POST | `/api/v1/images/import-path` | 服务器路径批量导入 |
|
||||||
| GET | `/api/v1/images/search` | AI 搜索图片(SSE 流式) |
|
| GET | `/api/v1/images/search` | AI 搜索图片(SSE 流式) |
|
||||||
| GET | `/api/v1/images` | 获取图片列表(分页) |
|
| GET | `/api/v1/images` | 获取图片列表(分页) |
|
||||||
| GET | `/api/v1/images/{id}` | 获取图片识别结果 |
|
| GET | `/api/v1/images/{id}` | 获取图片识别结果 |
|
||||||
| DELETE | `/api/v1/images/{id}` | 删除图片 |
|
| DELETE | `/api/v1/images/{id}` | 删除图片 |
|
||||||
| POST | `/api/v1/images/dedup` | 一键去重 |
|
| POST | `/api/v1/images/dedup` | 一键去重 |
|
||||||
| GET | `/api/v1/pages` | 知识页面列表 |
|
|
||||||
| POST | `/api/v1/pages` | 创建知识页面 |
|
### 导入导出
|
||||||
| POST | `/api/v1/search` | 关键词搜索知识库 |
|
|
||||||
| POST | `/api/v1/import/file` | 导入文件 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/v1/import/file` | 导入文件(.md/.txt/.docx) |
|
||||||
|
| POST | `/api/v1/import/directory` | 导入整个目录 |
|
||||||
|
| GET | `/api/v1/export` | 导出知识库(JSON/Markdown) |
|
||||||
|
|
||||||
|
### 系统设置
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
| GET | `/api/v1/settings` | 获取系统设置 |
|
| GET | `/api/v1/settings` | 获取系统设置 |
|
||||||
| PUT | `/api/v1/settings` | 更新系统设置 |
|
| PUT | `/api/v1/settings` | 更新系统设置 |
|
||||||
|
| GET | `/api/v1/settings/stats` | 获取统计信息 |
|
||||||
|
| POST | `/api/v1/settings/test/embedding` | 测试嵌入模型连通性 |
|
||||||
|
| POST | `/api/v1/settings/test/llm` | 测试 LLM 服务连通性 |
|
||||||
|
| POST | `/api/v1/settings/test/ocr` | 测试 OCR 服务连通性 |
|
||||||
| GET | `/api/v1/settings/bot/status` | 获取机器人状态 |
|
| GET | `/api/v1/settings/bot/status` | 获取机器人状态 |
|
||||||
| POST | `/api/v1/settings/bot/restart` | 重启机器人 |
|
| POST | `/api/v1/settings/bot/restart` | 重启机器人 |
|
||||||
|
|
||||||
|
### 系统
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/health` | 健康检查 |
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
### OCR Provider
|
### OCR Provider
|
||||||
|
|
||||||
| Provider | 说明 | 额外配置 |
|
| Provider | 说明 | 额外配置 |
|
||||||
|----------|------|----------|
|
|----------|------|----------|
|
||||||
| `deepseek` | DeepSeek Vision(推荐) | `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL` |
|
| `deepseek` | DeepSeek Vision(推荐,支持 Ollama 自部署) | `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL` |
|
||||||
| `paddleocr` | 本地 PaddleOCR | 无 |
|
| `paddleocr` | 本地 PaddleOCR(免费、离线) | 无 |
|
||||||
| `aliyun` | 阿里云 OCR | `ALIYUN_OCR_ACCESS_KEY`, `ALIYUN_OCR_SECRET` |
|
| `aliyun` | 阿里云 OCR | `ALIYUN_OCR_ACCESS_KEY`, `ALIYUN_OCR_SECRET` |
|
||||||
| `tencent` | 腾讯云 OCR | `TENCENT_OCR_SECRET_ID`, `TENCENT_OCR_SECRET_KEY` |
|
| `tencent` | 腾讯云 OCR | `TENCENT_OCR_SECRET_ID`, `TENCENT_OCR_SECRET_KEY` |
|
||||||
| `auto` | 自动 fallback | 配置多个 Provider |
|
| `auto` | 自动 fallback(PaddleOCR → DeepSeek → 阿里云 → 腾讯云) | 配置多个 Provider |
|
||||||
|
|
||||||
### 嵌入模型
|
### 嵌入模型
|
||||||
|
|
||||||
@@ -227,3 +313,16 @@ docker-compose up -d
|
|||||||
| 搜索返回数量 | 每次搜索最多返回多少条匹配结果 | 3 | 1-10 |
|
| 搜索返回数量 | 每次搜索最多返回多少条匹配结果 | 3 | 1-10 |
|
||||||
| LLM 批量判断数量 | 每批发给 LLM 判断的图片数量 | 10 | 1-50 |
|
| LLM 批量判断数量 | 每批发给 LLM 判断的图片数量 | 10 | 1-50 |
|
||||||
| OCR 并发数量 | 多用户同时上传时 OCR 并发识别数量 | 1 | 1-10 |
|
| OCR 并发数量 | 多用户同时上传时 OCR 并发识别数量 | 1 | 1-10 |
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
- **策略模式**: OCR 和嵌入服务均使用抽象基类 + 工厂模式,支持多 Provider 热切换
|
||||||
|
- **单例模式**: EmbeddingService、OCRService、LLMService 均为类方法单例,支持 `reset()` 重置
|
||||||
|
- **异步架构**: 全异步(FastAPI + SQLAlchemy async + asyncio),企业微信机器人在后台线程独立事件循环运行
|
||||||
|
- **SSE 流式**: 图片搜索使用 Server-Sent Events 实时推送结果,前端可即时展示
|
||||||
|
- **并发控制**: OCR 并发通过 Semaphore 控制,LLM 搜索通过并发池(最多 10 个)控制
|
||||||
|
- **运行时配置**: 所有参数均可通过网页端设置 API 动态修改,无需重启服务
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
图片 OCR API 路由
|
图片 OCR API 路由
|
||||||
OCR 识别 + BM25 倒排索引搜索
|
OCR 识别 + AI 搜索
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,7 +24,6 @@ from app.config import settings
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.base import OCRImage
|
from app.models.base import OCRImage
|
||||||
from app.services.ocr_service import OCRService
|
from app.services.ocr_service import OCRService
|
||||||
from app.services.search_engine import BM25Index, tokenize
|
|
||||||
from app.services.llm_service import LLMService
|
from app.services.llm_service import LLMService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,9 +44,6 @@ def _get_ocr_semaphore() -> asyncio.Semaphore:
|
|||||||
|
|
||||||
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||||
|
|
||||||
# 全局 BM25 索引实例(在 main.py 中初始化)
|
|
||||||
bm25_index: Optional[BM25Index] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_image_ext(filename: str) -> str:
|
def _check_image_ext(filename: str) -> str:
|
||||||
suffix = os.path.splitext(filename)[1].lower()
|
suffix = os.path.splitext(filename)[1].lower()
|
||||||
@@ -62,41 +58,8 @@ def _save_ocr_result(record: OCRImage, ocr_result, db: AsyncSession):
|
|||||||
record.confidence = ocr_result.confidence
|
record.confidence = ocr_result.confidence
|
||||||
record.provider = ocr_result.provider
|
record.provider = ocr_result.provider
|
||||||
record.status = "completed"
|
record.status = "completed"
|
||||||
# 保留 keywords(如果有)
|
|
||||||
if hasattr(ocr_result, 'keywords') and ocr_result.keywords:
|
|
||||||
record.tags = json.dumps(ocr_result.keywords, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _add_to_index(record: OCRImage):
|
|
||||||
"""将 OCR 结果添加到 BM25 索引"""
|
|
||||||
if bm25_index is None or not record.ocr_text:
|
|
||||||
return
|
|
||||||
# 索引内容 = OCR 文本(权重最高)
|
|
||||||
index_text = record.ocr_text
|
|
||||||
# 如果有 tags/story_summary 也加入索引
|
|
||||||
if record.tags:
|
|
||||||
try:
|
|
||||||
tags = json.loads(record.tags)
|
|
||||||
if tags:
|
|
||||||
index_text += "\n" + " ".join(tags)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if record.story_summary:
|
|
||||||
index_text += "\n" + record.story_summary
|
|
||||||
|
|
||||||
bm25_index.add_document(
|
|
||||||
doc_id=record.id,
|
|
||||||
text=index_text,
|
|
||||||
metadata={
|
|
||||||
"file_path": record.file_path,
|
|
||||||
"tags": json.loads(record.tags) if record.tags else [],
|
|
||||||
"story_summary": record.story_summary or "",
|
|
||||||
"confidence": record.confidence,
|
|
||||||
"provider": record.provider,
|
|
||||||
"created_at": str(record.created_at),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_ocr_duplicate(ocr_text: str, db: AsyncSession) -> Optional[int]:
|
async def _check_ocr_duplicate(ocr_text: str, db: AsyncSession) -> Optional[int]:
|
||||||
"""检查 OCR 文本是否已存在,返回已存在记录的 ID 或 None"""
|
"""检查 OCR 文本是否已存在,返回已存在记录的 ID 或 None"""
|
||||||
@@ -144,13 +107,11 @@ async def recognize_image(image_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
_save_ocr_result(image_record, ocr_result, db)
|
_save_ocr_result(image_record, ocr_result, db)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(image_record)
|
await db.refresh(image_record)
|
||||||
_add_to_index(image_record)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": image_record.id,
|
"id": image_record.id,
|
||||||
"file_path": image_record.file_path,
|
"file_path": image_record.file_path,
|
||||||
"ocr_text": image_record.ocr_text,
|
"ocr_text": image_record.ocr_text,
|
||||||
"tags": json.loads(image_record.tags) if image_record.tags else [],
|
|
||||||
"confidence": image_record.confidence,
|
"confidence": image_record.confidence,
|
||||||
"provider": image_record.provider,
|
"provider": image_record.provider,
|
||||||
"status": image_record.status,
|
"status": image_record.status,
|
||||||
@@ -208,12 +169,10 @@ async def batch_recognize(files: List[UploadFile], db: AsyncSession = Depends(ge
|
|||||||
_save_ocr_result(image_record, ocr_result, db)
|
_save_ocr_result(image_record, ocr_result, db)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(image_record)
|
await db.refresh(image_record)
|
||||||
_add_to_index(image_record)
|
|
||||||
results.append({
|
results.append({
|
||||||
"id": image_record.id, "filename": file.filename,
|
"id": image_record.id, "filename": file.filename,
|
||||||
"file_path": str(dest_path), "status": "completed",
|
"file_path": str(dest_path), "status": "completed",
|
||||||
"ocr_text": ocr_result.text,
|
"ocr_text": ocr_result.text,
|
||||||
"tags": json.loads(image_record.tags) if image_record.tags else [],
|
|
||||||
"confidence": ocr_result.confidence, "provider": ocr_result.provider,
|
"confidence": ocr_result.confidence, "provider": ocr_result.provider,
|
||||||
"block_count": len(ocr_result.blocks),
|
"block_count": len(ocr_result.blocks),
|
||||||
})
|
})
|
||||||
@@ -266,8 +225,7 @@ async def import_from_paths(data: PathImportRequest, db: AsyncSession = Depends(
|
|||||||
_save_ocr_result(image_record, ocr_result, db)
|
_save_ocr_result(image_record, ocr_result, db)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(image_record)
|
await db.refresh(image_record)
|
||||||
_add_to_index(image_record)
|
results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "completed", "ocr_text": ocr_result.text, "confidence": ocr_result.confidence, "provider": ocr_result.provider})
|
||||||
results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "completed", "ocr_text": ocr_result.text, "tags": json.loads(image_record.tags) if image_record.tags else [], "confidence": ocr_result.confidence, "provider": ocr_result.provider})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
image_record.status = "failed"
|
image_record.status = "failed"
|
||||||
image_record.error_message = str(e)
|
image_record.error_message = str(e)
|
||||||
@@ -315,12 +273,10 @@ async def recognize_direct(file: UploadFile, db: AsyncSession = Depends(get_db))
|
|||||||
_save_ocr_result(image_record, ocr_result, db)
|
_save_ocr_result(image_record, ocr_result, db)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(image_record)
|
await db.refresh(image_record)
|
||||||
_add_to_index(image_record)
|
|
||||||
return {
|
return {
|
||||||
"id": image_record.id, "file_path": image_record.file_path,
|
"id": image_record.id, "file_path": image_record.file_path,
|
||||||
"original_filename": file.filename,
|
"original_filename": file.filename,
|
||||||
"ocr_text": image_record.ocr_text,
|
"ocr_text": image_record.ocr_text,
|
||||||
"tags": json.loads(image_record.tags) if image_record.tags else [],
|
|
||||||
"confidence": image_record.confidence, "provider": image_record.provider,
|
"confidence": image_record.confidence, "provider": image_record.provider,
|
||||||
"status": image_record.status,
|
"status": image_record.status,
|
||||||
"blocks": [{"text": b.text, "bbox": b.bbox, "confidence": b.confidence} for b in ocr_result.blocks],
|
"blocks": [{"text": b.text, "bbox": b.bbox, "confidence": b.confidence} for b in ocr_result.blocks],
|
||||||
@@ -420,7 +376,6 @@ async def search_images(
|
|||||||
"image_base64": image_base64,
|
"image_base64": image_base64,
|
||||||
"ocr_text": item.ocr_text,
|
"ocr_text": item.ocr_text,
|
||||||
"ocr_text_preview": (item.ocr_text[:150] + "...") if item.ocr_text and len(item.ocr_text) > 150 else item.ocr_text,
|
"ocr_text_preview": (item.ocr_text[:150] + "...") if item.ocr_text and len(item.ocr_text) > 150 else item.ocr_text,
|
||||||
"tags": json.loads(item.tags) if item.tags else [],
|
|
||||||
"confidence": item.confidence,
|
"confidence": item.confidence,
|
||||||
"provider": item.provider,
|
"provider": item.provider,
|
||||||
"created_at": str(item.created_at),
|
"created_at": str(item.created_at),
|
||||||
@@ -489,9 +444,6 @@ async def delete_image(image_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
image_record = result.scalar_one_or_none()
|
image_record = result.scalar_one_or_none()
|
||||||
if image_record is None:
|
if image_record is None:
|
||||||
raise HTTPException(status_code=404, detail="图片记录不存在")
|
raise HTTPException(status_code=404, detail="图片记录不存在")
|
||||||
# 从 BM25 索引移除
|
|
||||||
if bm25_index is not None:
|
|
||||||
bm25_index.remove_document(image_record.id)
|
|
||||||
# 删除磁盘上的图片文件
|
# 删除磁盘上的图片文件
|
||||||
try:
|
try:
|
||||||
os.unlink(image_record.file_path)
|
os.unlink(image_record.file_path)
|
||||||
@@ -561,9 +513,6 @@ async def dedup_images(db: AsyncSession = Depends(get_db)):
|
|||||||
duplicates_found += len(group) - 1
|
duplicates_found += len(group) - 1
|
||||||
kept += 1
|
kept += 1
|
||||||
for record in group[1:]:
|
for record in group[1:]:
|
||||||
# 从 BM25 索引移除
|
|
||||||
if bm25_index is not None:
|
|
||||||
bm25_index.remove_document(record.id)
|
|
||||||
# 删除磁盘上的图片文件
|
# 删除磁盘上的图片文件
|
||||||
try:
|
try:
|
||||||
os.unlink(record.file_path)
|
os.unlink(record.file_path)
|
||||||
|
|||||||
49
app/main.py
49
app/main.py
@@ -107,7 +107,7 @@ bot_manager = BotManager()
|
|||||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""
|
"""
|
||||||
应用生命周期管理:
|
应用生命周期管理:
|
||||||
- 启动时:初始化数据目录、验证数据库连接、预热嵌入服务、初始化 BM25 索引
|
- 启动时:初始化数据目录、验证数据库连接、预热嵌入服务
|
||||||
- 关闭时:释放数据库连接
|
- 关闭时:释放数据库连接
|
||||||
"""
|
"""
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -126,53 +126,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.warning("嵌入服务预热失败(将在首次使用时重试): %s", exc)
|
_logger.warning("嵌入服务预热失败(将在首次使用时重试): %s", exc)
|
||||||
|
|
||||||
# ── 初始化 BM25 索引 ──
|
|
||||||
try:
|
|
||||||
from app.services.search_engine import BM25Index
|
|
||||||
import app.api.v1.images as images_module
|
|
||||||
|
|
||||||
_images_bm25 = BM25Index()
|
|
||||||
|
|
||||||
# 加载数据库中已有的 OCR 记录到索引
|
|
||||||
from app.database import async_session_factory
|
|
||||||
from app.models.base import OCRImage
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(OCRImage).where(OCRImage.status == "completed")
|
|
||||||
)
|
|
||||||
records = result.scalars().all()
|
|
||||||
for record in records:
|
|
||||||
if record.ocr_text:
|
|
||||||
index_text = record.ocr_text
|
|
||||||
if record.tags:
|
|
||||||
try:
|
|
||||||
tags = json.loads(record.tags)
|
|
||||||
if tags:
|
|
||||||
index_text += "\n" + " ".join(tags)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if record.story_summary:
|
|
||||||
index_text += "\n" + record.story_summary
|
|
||||||
_images_bm25.add_document(
|
|
||||||
doc_id=record.id,
|
|
||||||
text=index_text,
|
|
||||||
metadata={
|
|
||||||
"file_path": record.file_path,
|
|
||||||
"tags": json.loads(record.tags) if record.tags else [],
|
|
||||||
"story_summary": record.story_summary or "",
|
|
||||||
"confidence": record.confidence,
|
|
||||||
"provider": record.provider,
|
|
||||||
"created_at": str(record.created_at),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
images_module.bm25_index = _images_bm25
|
|
||||||
_logger.info("BM25 索引加载完成: %d 条记录", _images_bm25.size)
|
|
||||||
except Exception as exc:
|
|
||||||
_logger.warning("BM25 索引初始化失败: %s", exc)
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# ── 关闭 ──
|
# ── 关闭 ──
|
||||||
|
|||||||
@@ -66,20 +66,8 @@ class OCRImage(Base, TimestampMixin):
|
|||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20), default="pending", comment="状态: pending/processing/completed/failed"
|
String(20), default="pending", comment="状态: pending/processing/completed/failed"
|
||||||
)
|
)
|
||||||
tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="关键词标签(JSON 数组)")
|
|
||||||
story_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="故事摘要")
|
|
||||||
blocks: Mapped[str | None] = mapped_column(Text, nullable=True, comment="OCR 文本块(JSON)")
|
blocks: Mapped[str | None] = mapped_column(Text, nullable=True, comment="OCR 文本块(JSON)")
|
||||||
|
|
||||||
@property
|
|
||||||
def tags_list(self) -> list[str]:
|
|
||||||
if self.tags is None:
|
|
||||||
return []
|
|
||||||
return json.loads(self.tags)
|
|
||||||
|
|
||||||
@tags_list.setter
|
|
||||||
def tags_list(self, value: list[str]):
|
|
||||||
self.tags = json.dumps(value) if value else None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def blocks_list(self) -> list[dict]:
|
def blocks_list(self) -> list[dict]:
|
||||||
if self.blocks is None:
|
if self.blocks is None:
|
||||||
|
|||||||
@@ -236,158 +236,6 @@ class LLMService:
|
|||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def extract_tags(cls, text: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
从文本中提取语义标签。
|
|
||||||
用于图片 OCR 内容的标签化和搜索查询的标签化。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: 待提取标签的文本
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
标签列表,如 ["教育", "高考数学", "二次方程", "解题技巧"]
|
|
||||||
"""
|
|
||||||
messages = [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"你是一个内容标签提取助手。用户会给你一段文字内容,"
|
|
||||||
"你需要从中提取 3-10 个能概括内容主题的标签。\n"
|
|
||||||
"标签要求:\n"
|
|
||||||
"1. 用简短的词语(2-6个字)\n"
|
|
||||||
"2. 涵盖主题、场景、情感、关键实体等维度\n"
|
|
||||||
"3. 考虑同义词和近义词(如'不想工作'也打上'躺平'标签)\n"
|
|
||||||
"4. 只输出 JSON 数组,不要其他文字\n"
|
|
||||||
'例如:["高考数学", "二次方程", "韦达定理", "解题技巧"]'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": f"请从以下内容中提取标签:\n\n{text[:2000]}",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await cls.chat(messages, temperature=0.3, max_tokens=256)
|
|
||||||
result = result.strip()
|
|
||||||
if result.startswith("```"):
|
|
||||||
result = result.split("\n", 1)[-1]
|
|
||||||
if result.endswith("```"):
|
|
||||||
result = result[:-3]
|
|
||||||
result = result.strip()
|
|
||||||
tags = json.loads(result)
|
|
||||||
if isinstance(tags, list):
|
|
||||||
# 去重、清洗
|
|
||||||
seen = set()
|
|
||||||
clean = []
|
|
||||||
for t in tags:
|
|
||||||
t = str(t).strip()
|
|
||||||
if 1 <= len(t) <= 20 and t not in seen:
|
|
||||||
seen.add(t)
|
|
||||||
clean.append(t)
|
|
||||||
return clean[:15]
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("标签提取失败: %s", exc)
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def extract_search_tags(cls, query: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
从用户搜索查询中提取标签,用于匹配数据库中存储的标签。
|
|
||||||
和 extract_tags 类似,但更侧重于提取搜索意图相关的标签。
|
|
||||||
"""
|
|
||||||
messages = [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"你是一个搜索意图分析助手。用户会给你一个搜索查询,"
|
|
||||||
"你需要提取 3-8 个可能匹配的标签词。\n"
|
|
||||||
"标签要求:\n"
|
|
||||||
"1. 用简短的词语(2-6个字)\n"
|
|
||||||
"2. 包含同义词和近义词\n"
|
|
||||||
"3. 包含上义词和下义词\n"
|
|
||||||
"4. 只输出 JSON 数组\n"
|
|
||||||
'例如:用户搜"孩子躺平",输出 ["躺平", "不上班", "年轻人", "就业", "摆烂", "啃老"]'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": f"请从以下搜索查询中提取匹配标签:{query}",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await cls.chat(messages, temperature=0.3, max_tokens=256)
|
|
||||||
result = result.strip()
|
|
||||||
if result.startswith("```"):
|
|
||||||
result = result.split("\n", 1)[-1]
|
|
||||||
if result.endswith("```"):
|
|
||||||
result = result[:-3]
|
|
||||||
result = result.strip()
|
|
||||||
tags = json.loads(result)
|
|
||||||
if isinstance(tags, list):
|
|
||||||
seen = set()
|
|
||||||
clean = []
|
|
||||||
for t in tags:
|
|
||||||
t = str(t).strip()
|
|
||||||
if 1 <= len(t) <= 20 and t not in seen:
|
|
||||||
seen.add(t)
|
|
||||||
clean.append(t)
|
|
||||||
return clean[:10]
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("搜索标签提取失败: %s", exc)
|
|
||||||
|
|
||||||
# 降级:直接用原始查询词作为标签
|
|
||||||
return [query.strip()] if query.strip() else []
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def summarize_story(cls, text: str) -> str:
|
|
||||||
"""
|
|
||||||
用 Qwen3-8B 提炼图片 OCR 文本的故事内容。
|
|
||||||
非思考模式,直接输出。
|
|
||||||
"""
|
|
||||||
messages = [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"你是一个内容提炼助手。用户会给你一段从图片中识别出的文字内容,"
|
|
||||||
"你需要提炼出其中讲述的故事或事件。\n"
|
|
||||||
"要求:\n"
|
|
||||||
"1. 用简洁的自然语言描述图片内容讲述的故事或事件\n"
|
|
||||||
"2. 保留关键人物、事件、情感、场景等核心信息\n"
|
|
||||||
"3. 50-200字\n"
|
|
||||||
"4. 直接输出提炼内容,不要加前缀"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": f"/no_think\n请提炼以下内容的故事:\n\n{text[:3000]}",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
client = AsyncOpenAI(
|
|
||||||
api_key=settings.OPENAI_API_KEY,
|
|
||||||
base_url=settings.OPENAI_BASE_URL,
|
|
||||||
)
|
|
||||||
response = await client.chat.completions.create(
|
|
||||||
model="Qwen/Qwen3-8B",
|
|
||||||
messages=messages,
|
|
||||||
temperature=0.3,
|
|
||||||
max_tokens=512,
|
|
||||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
|
||||||
)
|
|
||||||
content = response.choices[0].message.content or ""
|
|
||||||
# 清理可能的 /no_think 回显
|
|
||||||
content = content.replace("/no_think", "").strip()
|
|
||||||
return content
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("故事提炼失败: %s", exc)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def judge_batch(cls, query: str, articles: List[dict]) -> List[int]:
|
async def judge_batch(cls, query: str, articles: List[dict]) -> List[int]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
"""
|
|
||||||
BM25 倒排索引搜索引擎
|
|
||||||
用于图片 OCR 内容的全文搜索
|
|
||||||
参考 Meilisearch / Whoosh 设计
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import math
|
|
||||||
import logging
|
|
||||||
from collections import Counter, defaultdict
|
|
||||||
from typing import List, Dict, Optional, Tuple
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 尝试导入 jieba,失败则用简单的字符分割
|
|
||||||
try:
|
|
||||||
import jieba
|
|
||||||
jieba.setLogLevel(logging.WARNING)
|
|
||||||
def tokenize(text: str) -> List[str]:
|
|
||||||
"""中文分词"""
|
|
||||||
return [w for w in jieba.cut_for_search(text) if len(w.strip()) > 1]
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("jieba 未安装,使用简单分词(pip install jieba 获得更好效果)")
|
|
||||||
def tokenize(text: str) -> List[str]:
|
|
||||||
"""简单分词:按空格和标点分割"""
|
|
||||||
return [w for w in re.split(r'[\s,.\-;:!?"\'/\\|()\[\]{}<>,。;:!?""''、()【】]+', text) if len(w.strip()) > 1]
|
|
||||||
|
|
||||||
|
|
||||||
class BM25Index:
|
|
||||||
"""
|
|
||||||
BM25 倒排索引
|
|
||||||
- 支持增量添加文档
|
|
||||||
- 支持删除文档
|
|
||||||
- 支持中文分词(jieba)
|
|
||||||
- BM25 评分排序
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, k1: float = 1.5, b: float = 0.75):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
k1: 词频饱和参数(默认 1.5)
|
|
||||||
b: 文档长度归一化参数(默认 0.75)
|
|
||||||
"""
|
|
||||||
self.k1 = k1
|
|
||||||
self.b = b
|
|
||||||
|
|
||||||
# 倒排索引:term -> {doc_id: tf}
|
|
||||||
self.inverted_index: Dict[str, Dict[int, int]] = defaultdict(lambda: defaultdict(int))
|
|
||||||
|
|
||||||
# 文档信息
|
|
||||||
self.doc_lengths: Dict[int, int] = {} # doc_id -> 文档长度(词数)
|
|
||||||
self.doc_count: int = 0
|
|
||||||
self.avg_doc_length: float = 0.0
|
|
||||||
|
|
||||||
# 文档存储(存完整数据)
|
|
||||||
self.documents: Dict[int, dict] = {}
|
|
||||||
|
|
||||||
# IDF 缓存
|
|
||||||
self._idf_cache: Dict[str, float] = {}
|
|
||||||
|
|
||||||
def add_document(self, doc_id: int, text: str, metadata: Optional[dict] = None):
|
|
||||||
"""
|
|
||||||
添加文档到索引
|
|
||||||
|
|
||||||
Args:
|
|
||||||
doc_id: 文档 ID
|
|
||||||
text: 待索引的文本内容
|
|
||||||
metadata: 附加元数据(如 file_path, tags 等)
|
|
||||||
"""
|
|
||||||
# 如果已存在,先删除
|
|
||||||
if doc_id in self.documents:
|
|
||||||
self.remove_document(doc_id)
|
|
||||||
|
|
||||||
tokens = tokenize(text)
|
|
||||||
self.doc_lengths[doc_id] = len(tokens)
|
|
||||||
self.documents[doc_id] = {
|
|
||||||
"text": text,
|
|
||||||
"tokens": tokens,
|
|
||||||
"metadata": metadata or {},
|
|
||||||
}
|
|
||||||
|
|
||||||
# 构建倒排索引
|
|
||||||
tf = Counter(tokens)
|
|
||||||
for term, count in tf.items():
|
|
||||||
self.inverted_index[term][doc_id] = count
|
|
||||||
|
|
||||||
self.doc_count += 1
|
|
||||||
self._update_avg_doc_length()
|
|
||||||
self._idf_cache.clear() # 文档数变了,IDF 需要重算
|
|
||||||
|
|
||||||
def remove_document(self, doc_id: int):
|
|
||||||
"""从索引中删除文档"""
|
|
||||||
if doc_id not in self.documents:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 从倒排索引中移除
|
|
||||||
tokens = self.documents[doc_id].get("tokens", [])
|
|
||||||
for term in set(tokens):
|
|
||||||
if term in self.inverted_index and doc_id in self.inverted_index[term]:
|
|
||||||
del self.inverted_index[term][doc_id]
|
|
||||||
if not self.inverted_index[term]:
|
|
||||||
del self.inverted_index[term]
|
|
||||||
|
|
||||||
del self.documents[doc_id]
|
|
||||||
del self.doc_lengths[doc_id]
|
|
||||||
self.doc_count -= 1
|
|
||||||
self._update_avg_doc_length()
|
|
||||||
self._idf_cache.clear()
|
|
||||||
|
|
||||||
def _update_avg_doc_length(self):
|
|
||||||
"""更新平均文档长度"""
|
|
||||||
if self.doc_count > 0:
|
|
||||||
self.avg_doc_length = sum(self.doc_lengths.values()) / self.doc_count
|
|
||||||
else:
|
|
||||||
self.avg_doc_length = 0.0
|
|
||||||
|
|
||||||
def _idf(self, term: str) -> float:
|
|
||||||
"""
|
|
||||||
计算逆文档频率 IDF
|
|
||||||
IDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1)
|
|
||||||
其中 N 是文档总数,df(t) 是包含 term 的文档数
|
|
||||||
"""
|
|
||||||
if term in self._idf_cache:
|
|
||||||
return self._idf_cache[term]
|
|
||||||
|
|
||||||
df = len(self.inverted_index.get(term, {}))
|
|
||||||
idf = math.log((self.doc_count - df + 0.5) / (df + 0.5) + 1)
|
|
||||||
|
|
||||||
self._idf_cache[term] = idf
|
|
||||||
return idf
|
|
||||||
|
|
||||||
def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
|
|
||||||
"""
|
|
||||||
BM25 搜索
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: 搜索查询(会自动分词)
|
|
||||||
top_k: 返回前 K 个结果
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
[(doc_id, score), ...] 按 BM25 分数降序排列
|
|
||||||
"""
|
|
||||||
if self.doc_count == 0 or self.avg_doc_length == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
query_tokens = tokenize(query)
|
|
||||||
if not query_tokens:
|
|
||||||
return []
|
|
||||||
|
|
||||||
scores: Dict[int, float] = defaultdict(float)
|
|
||||||
|
|
||||||
for term in query_tokens:
|
|
||||||
idf = self._idf(term)
|
|
||||||
postings = self.inverted_index.get(term, {})
|
|
||||||
|
|
||||||
for doc_id, tf in postings.items():
|
|
||||||
dl = self.doc_lengths[doc_id]
|
|
||||||
# BM25 公式
|
|
||||||
numerator = tf * (self.k1 + 1)
|
|
||||||
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self.avg_doc_length)
|
|
||||||
scores[doc_id] += idf * numerator / denominator
|
|
||||||
|
|
||||||
# 按分数降序排序
|
|
||||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
|
||||||
return ranked[:top_k]
|
|
||||||
|
|
||||||
def get_document(self, doc_id: int) -> Optional[dict]:
|
|
||||||
"""获取文档完整数据"""
|
|
||||||
return self.documents.get(doc_id)
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""清空索引"""
|
|
||||||
self.inverted_index.clear()
|
|
||||||
self.doc_lengths.clear()
|
|
||||||
self.documents.clear()
|
|
||||||
self._idf_cache.clear()
|
|
||||||
self.doc_count = 0
|
|
||||||
self.avg_doc_length = 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self) -> int:
|
|
||||||
"""索引中的文档数量"""
|
|
||||||
return self.doc_count
|
|
||||||
@@ -28,9 +28,6 @@ python-frontmatter>=1.0.0,<2.0.0
|
|||||||
# ── Word 文档解析 ──
|
# ── Word 文档解析 ──
|
||||||
python-docx>=1.1.0,<2.0.0
|
python-docx>=1.1.0,<2.0.0
|
||||||
|
|
||||||
# ── 中文分词(BM25 搜索引擎) ──
|
|
||||||
jieba>=0.42.1
|
|
||||||
|
|
||||||
# ── 工具库 ──
|
# ── 工具库 ──
|
||||||
numpy>=1.26.0,<3.0.0
|
numpy>=1.26.0,<3.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -2141,13 +2141,6 @@
|
|||||||
queue.innerHTML = `
|
queue.innerHTML = `
|
||||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||||
${ocrFiles.map((item, i) => {
|
${ocrFiles.map((item, i) => {
|
||||||
const keywords = (item.result && item.result.tags) ? item.result.tags : [];
|
|
||||||
const kwHtml = keywords.length > 0
|
|
||||||
? `<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">${keywords.map(k => `<span style="display:inline-block; padding:1px 8px; background:#e8f4fd; color:#0071e3; border-radius:980px; font-size:11px;">${escapeHtml(k)}</span>`).join('')}</div>`
|
|
||||||
: '';
|
|
||||||
const storyHtml = (item.result && item.result.story_summary)
|
|
||||||
? `<p style="font-size:12px; color:#6e6e73; margin-top:2px; font-style:italic;">${escapeHtml(item.result.story_summary)}</p>`
|
|
||||||
: '';
|
|
||||||
return `
|
return `
|
||||||
<div style="padding:8px 12px; background:#f5f5f7; border-radius:10px;">
|
<div style="padding:8px 12px; background:#f5f5f7; border-radius:10px;">
|
||||||
<div style="display:flex; align-items:center; gap:10px;">
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
@@ -2490,7 +2483,6 @@
|
|||||||
路径: ${escapeHtml(result.file_path || '')}
|
路径: ${escapeHtml(result.file_path || '')}
|
||||||
</p>
|
</p>
|
||||||
${imgHtml}
|
${imgHtml}
|
||||||
${result.story_summary ? `<div style="background:#f0f5ff; border-radius:10px; padding:12px; margin-bottom:12px; border-left:3px solid #0071e3;"><p style="font-size:13px; color:#1d1d1f; margin:0; font-weight:500;">故事摘要</p><p style="font-size:13px; color:#424245; margin:4px 0 0 0; line-height:1.5;">${escapeHtml(result.story_summary)}</p></div>` : ''}
|
|
||||||
<div style="background:#f5f5f7; border-radius:12px; padding:16px; max-height:300px; overflow-y:auto;">
|
<div style="background:#f5f5f7; border-radius:12px; padding:16px; max-height:300px; overflow-y:auto;">
|
||||||
<pre style="white-space:pre-wrap; font-size:14px; color:#424245; line-height:1.6; font-family:inherit; margin:0;">${escapeHtml(result.ocr_text || '无识别结果')}</pre>
|
<pre style="white-space:pre-wrap; font-size:14px; color:#424245; line-height:1.6; font-family:inherit; margin:0;">${escapeHtml(result.ocr_text || '无识别结果')}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -2541,13 +2533,6 @@
|
|||||||
}
|
}
|
||||||
} else if (data.type === 'result') {
|
} else if (data.type === 'result') {
|
||||||
foundCount++;
|
foundCount++;
|
||||||
const tagsHtml = (tags) => {
|
|
||||||
if (!tags || tags.length === 0) return '';
|
|
||||||
return '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' +
|
|
||||||
tags.map(t => `<span style="display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:rgba(0,122,255,0.1); color:#007aff; white-space:nowrap;">${escapeHtml(t)}</span>`).join('') +
|
|
||||||
'</div>';
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemHtml = `
|
const itemHtml = `
|
||||||
<div style="background:#f5f5f7; border-radius:10px; padding:12px; cursor:pointer; animation:fadeIn 0.3s;" onclick="viewOcrResult(${data.id})">
|
<div style="background:#f5f5f7; border-radius:10px; padding:12px; cursor:pointer; animation:fadeIn 0.3s;" onclick="viewOcrResult(${data.id})">
|
||||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
|
||||||
@@ -2555,7 +2540,6 @@
|
|||||||
<span style="font-size:12px; color:#86868b;">#${foundCount}</span>
|
<span style="font-size:12px; color:#86868b;">#${foundCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:13px; color:#86868b;">${escapeHtml(data.ocr_text_preview || '')}</p>
|
<p style="font-size:13px; color:#86868b;">${escapeHtml(data.ocr_text_preview || '')}</p>
|
||||||
${tagsHtml(data.tags)}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const itemsContainer = document.getElementById('ocr-search-items');
|
const itemsContainer = document.getElementById('ocr-search-items');
|
||||||
|
|||||||
Reference in New Issue
Block a user