Compare commits

...

13 Commits

Author SHA1 Message Date
EduBrain Dev
4d5665d369 fix issue 2026-04-14 21:25:41 +08:00
EduBrain Dev
23eabca58d fix issue 2026-04-14 21:17:22 +08:00
EduBrain Dev
397a8d8641 fix issue 2026-04-14 21:10:05 +08:00
EduBrain Dev
dbb3cf622f fix issue 2026-04-14 21:04:00 +08:00
EduBrain Dev
1080bc3882 fix issue 2026-04-14 20:28:55 +08:00
EduBrain Dev
f486a18339 fix issue 2026-04-14 20:13:01 +08:00
EduBrain Dev
8d69533e11 fix issue 2026-04-14 19:55:47 +08:00
EduBrain Dev
db981b88b8 fix issue 2026-04-14 19:53:01 +08:00
EduBrain Dev
015e251727 fix issue 2026-04-14 19:07:39 +08:00
EduBrain Dev
11c5d36dfa fix issue 2026-04-14 18:53:26 +08:00
EduBrain Dev
a4b2b01c71 fix issue 2026-04-14 18:22:01 +08:00
EduBrain Dev
2a6e506416 fix deploy bug 2026-04-14 18:00:18 +08:00
EduBrain Dev
c5f96056fa fix docker issue 2026-04-14 17:25:06 +08:00
13 changed files with 386 additions and 709 deletions

View File

@@ -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 \

229
README.md
View File

@@ -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` 自动 fallbackPaddleOCR → 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/
│ ├── __init__.py # 包初始化,定义版本号
│ ├── main.py # FastAPI 入口路由注册生命周期管理BotManager │ ├── main.py # FastAPI 入口路由注册生命周期管理BotManager
│ ├── config.py # pydantic-settings 配置管理 │ ├── config.py # pydantic-settings 配置管理(所有环境变量)
│ ├── database.py # SQLite 数据库连接 │ ├── database.py # SQLite 异步数据库连接管理
│ ├── models/base.py # ORM 模型KnowledgePage, OCRImage 等 │ ├── wework_bot.py # 企业微信智能机器人服务WebSocket 长连接
│ ├── schemas/ # Pydantic 请求/响应 Schema │ ├── api/
├── api/v1/ # API 路由 │ └── v1/
│ │ ├── pages.py # 知识页面 CRUD │ │ ├── pages.py # 知识页面 CRUD(列表/详情/创建/更新/删除/重建索引)
│ │ ├── search.py # 关键词搜索 │ │ ├── search.py # 语义搜索(向量+全文混合)
│ │ ├── images.py # 图片 OCR + AI 搜索SSE+ 去重 + 删除 │ │ ├── images.py # 图片 OCR + AI 搜索SSE 流式+ 去重 + 删除
│ │ ├── import_export.py # 文件导入导出 │ │ ├── import_export.py # 文件导入.md/.txt/.docx+ 目录导入 + 导出JSON/Markdown
│ │ └── settings.py # 系统设置 + 机器人管理 │ │ └── 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 服务
── wework_bot.py # 企业微信智能机器人服务 ── mcp/
│ └── mcp/server.py # MCP Server 实现 └── server.py # MCP Server 实现5 个工具供 AI Agent 调用)
├── static/index.html # 前端单页应用 ├── static/
├── data/images/ # 图片存储目录 │ └── index.html # 前端单页应用(苹果风格 SPA
├── data/
│ ├── images/ # 图片存储目录
│ └── transcripts/ # 转写文字稿(.docx/.md
├── docs/
│ ├── IMAGE_API_GUIDE.md # 图片 API 集成指南
│ └── 软著材料/ # 软件著作权申请材料
├── .env.example # 环境变量模板 ├── .env.example # 环境变量模板
├── requirements.txt # Python 依赖 ├── .gitignore # Git 忽略规则
├── Dockerfile # 应用镜像 ├── pyproject.toml # Python 项目元数据 + 构建配置
── docker-compose.yml # Docker 编排 ── 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
``` ```
@@ -105,7 +146,7 @@ pip install -r requirements.txt
```env ```env
# 数据库(默认 SQLite无需额外配置 # 数据库(默认 SQLite无需额外配置
DATABASE_URL=sqlite+aiosqlite:///hui_brain.db DATABASE_URL=sqlite+aiosqlite:///data/hui_brain.db
# OCR使用 DeepSeek Vision # OCR使用 DeepSeek Vision
OCR_PROVIDER=deepseek OCR_PROVIDER=deepseek
@@ -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` | 自动 fallbackPaddleOCR → 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

View File

@@ -1,6 +1,6 @@
""" """
图片 OCR API 路由 图片 OCR API 路由
OCR 识别 + BM25 倒排索引搜索 OCR 识别 + AI 搜索
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,10 +21,9 @@ from sqlalchemy import select, func, text, delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database import get_db from app.database import get_db, async_session_factory
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,
@@ -172,10 +133,12 @@ async def batch_recognize(files: List[UploadFile], db: AsyncSession = Depends(ge
if not file.filename: if not file.filename:
results.append({"filename": "unknown", "status": "error", "message": "文件名为空"}) results.append({"filename": "unknown", "status": "error", "message": "文件名为空"})
continue continue
suffix = _check_image_ext(file.filename) # 只取文件名,去掉文件夹上传时带上的相对路径
dest_path = settings.images_dir / file.filename filename = os.path.basename(file.filename)
suffix = _check_image_ext(filename)
dest_path = settings.images_dir / filename
counter = 1 counter = 1
stem = os.path.splitext(file.filename)[0] stem = os.path.splitext(filename)[0]
while dest_path.exists(): while dest_path.exists():
dest_path = settings.images_dir / f"{stem}_{counter}{suffix}" dest_path = settings.images_dir / f"{stem}_{counter}{suffix}"
counter += 1 counter += 1
@@ -208,12 +171,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 +227,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)
@@ -282,10 +242,12 @@ async def import_from_paths(data: PathImportRequest, db: AsyncSession = Depends(
async def recognize_direct(file: UploadFile, db: AsyncSession = Depends(get_db)): async def recognize_direct(file: UploadFile, db: AsyncSession = Depends(get_db)):
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空") raise HTTPException(status_code=400, detail="文件名不能为空")
suffix = _check_image_ext(file.filename) # 只取文件名,去掉文件夹上传时带上的相对路径(如 xx/01.jpg -> 01.jpg
dest_path = settings.images_dir / file.filename filename = os.path.basename(file.filename)
suffix = _check_image_ext(filename)
dest_path = settings.images_dir / filename
counter = 1 counter = 1
stem = os.path.splitext(file.filename)[0] stem = os.path.splitext(filename)[0]
while dest_path.exists(): while dest_path.exists():
dest_path = settings.images_dir / f"{stem}_{counter}{suffix}" dest_path = settings.images_dir / f"{stem}_{counter}{suffix}"
counter += 1 counter += 1
@@ -315,12 +277,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],
@@ -339,15 +299,11 @@ async def search_images(
keyword: str, keyword: str,
limit: int = Query(5, ge=1, le=100), limit: int = Query(5, ge=1, le=100),
sort: str = Query("time_desc"), sort: str = Query("time_desc"),
db: AsyncSession = Depends(get_db),
): ):
""" """
AI 搜索图片SSE 流式返回)。 AI 搜索图片SSE 流式返回)。
1. 从 DB 取所有图片,按指定排序 注意:不使用 Depends(get_db),因为 StreamingResponse 的生命周期
2. 每批 5 条发给 Qwen3-8B 判断 超出 request scope需要在生成器内部自行管理 session。
3. 并发池最多 10 个请求
4. 匹配结果实时 SSE 推送
5. 找够 limit 个或遍历完 DB 后结束
""" """
async def event_stream(): async def event_stream():
@@ -360,6 +316,8 @@ async def search_images(
# 发送开始事件 # 发送开始事件
yield f"data: {json.dumps({'type': 'start', 'keyword': keyword, 'limit': limit}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'type': 'start', 'keyword': keyword, 'limit': limit}, ensure_ascii=False)}\n\n"
# 在生成器内部自行管理 session避免 get_db 提前关闭
async with async_session_factory() as db:
# 先获取总记录数 # 先获取总记录数
count_result = await db.execute( count_result = await db.execute(
select(func.count(OCRImage.id)).where(OCRImage.status == "completed") select(func.count(OCRImage.id)).where(OCRImage.status == "completed")
@@ -420,7 +378,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 +446,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 +515,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)

View File

@@ -46,7 +46,7 @@ class Settings(BaseSettings):
# ──────────────────────────── 数据库 ──────────────────────────── # ──────────────────────────── 数据库 ────────────────────────────
DATABASE_URL: str = Field( DATABASE_URL: str = Field(
default="sqlite+aiosqlite:///./hui_brain.db", default="sqlite+aiosqlite:///./data/hui_brain.db",
description="SQLite 数据库连接字符串", description="SQLite 数据库连接字符串",
) )

View File

@@ -33,6 +33,8 @@ Path(sqlite_path).parent.mkdir(parents=True, exist_ok=True)
engine = create_async_engine( engine = create_async_engine(
f"sqlite+aiosqlite:///{sqlite_path}", f"sqlite+aiosqlite:///{sqlite_path}",
echo=False, echo=False,
pool_size=1,
max_overflow=0,
) )
# ──────────────────────────── 会话工厂 ──────────────────────────── # ──────────────────────────── 会话工厂 ────────────────────────────
@@ -50,9 +52,15 @@ async_session_factory = async_sessionmaker(
async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
""" """
FastAPI 依赖项:获取异步数据库会话。 FastAPI 依赖项:获取异步数据库会话。
请求正常结束后自动 commit异常时自动 rollback。
""" """
async with async_session_factory() as session: async with async_session_factory() as session:
try:
yield session yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def close_db() -> None: async def close_db() -> None:

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import threading import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -15,7 +16,7 @@ from typing import AsyncGenerator
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from app.config import settings from app.config import settings
from app.database import close_db, init_db from app.database import close_db, init_db
@@ -107,7 +108,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 +127,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
# ── 关闭 ── # ── 关闭 ──
@@ -240,7 +194,11 @@ async def health_check():
@app.get("/data/images/{image_name}", tags=["前端"]) @app.get("/data/images/{image_name}", tags=["前端"])
async def serve_image(image_name: str): async def serve_image(image_name: str):
"""访问 images 目录下的图片文件(用于前端预览)""" """访问 images 目录下的图片文件(用于前端预览)"""
img_path = settings.images_dir / image_name # 防止路径遍历攻击
safe_name = os.path.basename(image_name)
if safe_name != image_name or ".." in image_name:
return JSONResponse(status_code=400, content={"detail": "非法文件名"})
img_path = settings.images_dir / safe_name
if not img_path.exists(): if not img_path.exists():
return JSONResponse(status_code=404, content={"detail": "图片不存在"}) return JSONResponse(status_code=404, content={"detail": "图片不存在"})
return FileResponse(str(img_path)) return FileResponse(str(img_path))
@@ -248,11 +206,20 @@ async def serve_image(image_name: str):
# ──────────────────────────── 前端页面 ──────────────────────────── # ──────────────────────────── 前端页面 ────────────────────────────
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""返回空响应,避免浏览器 404 报错"""
return Response(status_code=204)
@app.get("/", tags=["前端"]) @app.get("/", tags=["前端"])
async def index(): async def index():
"""返回前端管理页面""" """返回前端管理页面"""
static_dir = Path(__file__).parent.parent / "static" static_dir = Path(__file__).parent.parent / "static"
index_path = static_dir / "index.html" index_path = static_dir / "index.html"
if index_path.exists(): if index_path.exists():
return FileResponse(str(index_path)) return FileResponse(
str(index_path),
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
)
return RedirectResponse(url="/docs") return RedirectResponse(url="/docs")

View File

@@ -29,6 +29,16 @@ class TimestampMixin:
DateTime, default=func.now(), onupdate=func.now(), comment="更新时间" DateTime, default=func.now(), onupdate=func.now(), comment="更新时间"
) )
def to_dict(self) -> Dict[str, Any]:
"""将模型转为字典"""
result = {}
for col in self.__table__.columns:
val = getattr(self, col.name)
if isinstance(val, datetime):
val = val.isoformat()
result[col.name] = val
return result
class KnowledgePage(Base, TimestampMixin): class KnowledgePage(Base, TimestampMixin):
"""知识页面""" """知识页面"""
@@ -37,20 +47,24 @@ class KnowledgePage(Base, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(500), nullable=False, comment="页面标题") title: Mapped[str] = mapped_column(String(500), nullable=False, comment="页面标题")
content: Mapped[str] = mapped_column(Text, nullable=False, comment="页面内容") content: Mapped[str] = mapped_column(Text, nullable=False, comment="页面内容")
source: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="来源文件") source_file: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="来源文件路径")
page_number: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="原文件页码") page_number: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="原文件页码")
# 向量嵌入JSON 格式存储) course_name: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="课程名称")
teacher_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="讲师名称")
live_date: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="直播日期")
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="额外元数据JSON")
embedding: Mapped[str | None] = mapped_column(Text, nullable=True, comment="向量嵌入JSON") embedding: Mapped[str | None] = mapped_column(Text, nullable=True, comment="向量嵌入JSON")
@property
def embedding_vector(self) -> list[float] | None:
if self.embedding is None:
return None
return json.loads(self.embedding)
@embedding_vector.setter class KnowledgeChunk(Base, TimestampMixin):
def embedding_vector(self, value: list[float] | None): """知识页面分块"""
self.embedding = json.dumps(value) if value else None __tablename__ = "knowledge_chunks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
page_id: Mapped[int] = mapped_column(Integer, nullable=False, comment="关联页面 ID")
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False, comment="分块索引")
content: Mapped[str] = mapped_column(Text, nullable=False, comment="分块内容")
embedding: Mapped[str | None] = mapped_column(Text, nullable=True, comment="向量嵌入JSON")
class OCRImage(Base, TimestampMixin): class OCRImage(Base, TimestampMixin):
@@ -66,19 +80,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")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
@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]:
@@ -100,3 +103,13 @@ class WebsiteSettings(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), onupdate=func.now(), comment="更新时间" DateTime, default=func.now(), onupdate=func.now(), comment="更新时间"
) )
def to_dict(self) -> Dict[str, Any]:
"""将模型转为字典"""
result = {}
for col in self.__table__.columns:
val = getattr(self, col.name)
if isinstance(val, datetime):
val = val.isoformat()
result[col.name] = val
return result

View File

@@ -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]:
""" """
@@ -438,10 +286,12 @@ class LLMService:
try: try:
from openai import AsyncOpenAI from openai import AsyncOpenAI
http_client = httpx.AsyncClient(timeout=60.0)
try:
client = AsyncOpenAI( client = AsyncOpenAI(
api_key=settings.MINIMAX_API_KEY, api_key=settings.MINIMAX_API_KEY,
base_url=settings.MINIMAX_BASE_URL, base_url=settings.MINIMAX_BASE_URL,
http_client=httpx.AsyncClient(timeout=60.0), http_client=http_client,
) )
response = await client.chat.completions.create( response = await client.chat.completions.create(
model=settings.MINIMAX_CHAT_MODEL, model=settings.MINIMAX_CHAT_MODEL,
@@ -469,6 +319,8 @@ class LLMService:
matched_ids.append(articles[num - 1]["id"]) matched_ids.append(articles[num - 1]["id"])
return matched_ids return matched_ids
finally:
await http_client.aclose()
except Exception as exc: except Exception as exc:
logger.warning("LLM 批量判断失败: %s", exc) logger.warning("LLM 批量判断失败: %s", exc)
return [] return []

View File

@@ -102,7 +102,7 @@ class PaddleOCRProvider(OCRProviderBase):
async def recognize(self, image_path: str) -> OCRResult: async def recognize(self, image_path: str) -> OCRResult:
"""使用 PaddleOCR 识别图片""" """使用 PaddleOCR 识别图片"""
self._init_ocr() self._init_ocr()
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
def _run_ocr(): def _run_ocr():
result = self._ocr.ocr(image_path, cls=True) result = self._ocr.ocr(image_path, cls=True)
@@ -164,7 +164,7 @@ class AliyunOCRProvider(OCRProviderBase):
import asyncio import asyncio
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
def _call_api(): def _call_api():
from alibabacloud_tea_openapi.models import Config from alibabacloud_tea_openapi.models import Config
@@ -249,7 +249,7 @@ class TencentOCRProvider(OCRProviderBase):
import base64 import base64
import json import json
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
def _call_api(): def _call_api():
from tencentcloud.common import credential from tencentcloud.common import credential

View File

@@ -181,22 +181,15 @@ class PageService:
if embedding: if embedding:
embedding_str = "[" + ",".join(str(x) for x in embedding) + "]" embedding_str = "[" + ",".join(str(x) for x in embedding) + "]"
await self.db.execute(text(""" await self.db.execute(text(
INSERT INTO knowledge_chunks (page_id, chunk_index, content, embedding) "INSERT INTO knowledge_chunks (page_id, chunk_index, content, embedding) "
VALUES (:page_id, :chunk_index, :content, :embedding::vector) "VALUES (:page_id, :chunk_index, :content, :embedding)"
"""), { ), {
"page_id": page_id, "page_id": page_id,
"chunk_index": idx, "chunk_index": idx,
"content": chunk_text, "content": chunk_text,
"embedding": embedding_str, "embedding": embedding_str,
}) })
# 更新全文搜索向量
await self.db.execute(text("""
UPDATE knowledge_chunks
SET search_vector = to_tsvector('chinese_zh', content)
WHERE page_id = :page_id
"""), {"page_id": page_id})
await self.db.flush() await self.db.flush()
return {"page_id": page_id, "chunks": len(chunks)} return {"page_id": page_id, "chunks": len(chunks)}

View File

@@ -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

View File

@@ -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

View File

@@ -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;">
@@ -2156,8 +2149,6 @@
<span style="font-size:12px; color:#86868b; white-space:nowrap;">${statusText(item.status)}${item.elapsed ? ' (' + item.elapsed.toFixed(1) + 's)' : ''}</span> <span style="font-size:12px; color:#86868b; white-space:nowrap;">${statusText(item.status)}${item.elapsed ? ' (' + item.elapsed.toFixed(1) + 's)' : ''}</span>
${item.status === 'pending' && !ocrInProgress ? `<button class="btn btn-sm btn-secondary" onclick="removeOcrFile(${i})" style="padding:2px 8px; font-size:12px;">移除</button>` : ''} ${item.status === 'pending' && !ocrInProgress ? `<button class="btn btn-sm btn-secondary" onclick="removeOcrFile(${i})" style="padding:2px 8px; font-size:12px;">移除</button>` : ''}
</div> </div>
${kwHtml}
${storyHtml}
</div>`; </div>`;
}).join('')} }).join('')}
</div> </div>
@@ -2490,7 +2481,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 +2531,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 +2538,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');
@@ -2796,7 +2778,7 @@
async function loadSystemInfo() { async function loadSystemInfo() {
// 数据库状态检测 // 数据库状态检测
try { try {
await api('GET', '/../health'); await fetch(window.location.origin + '/health');
document.getElementById('sys-db-status').innerHTML = document.getElementById('sys-db-status').innerHTML =
'<span class="badge badge-completed">正常</span>'; '<span class="badge badge-completed">正常</span>';
} catch (_) { } catch (_) {