Compare commits
10 Commits
b17786b57b
...
4c6a20e5fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6a20e5fc | ||
|
|
496e11e26e | ||
|
|
f60b45358d | ||
|
|
8152494fdc | ||
|
|
2954f3cb5e | ||
|
|
36a6debcb3 | ||
|
|
eb18b5cffc | ||
|
|
01ecc3fa6d | ||
|
|
e8a861fda2 | ||
|
|
8ebf9a4587 |
20
.env.example
20
.env.example
@@ -1,21 +1,22 @@
|
||||
# ============================================================
|
||||
# EduBrain - 中文直播教育知识库系统 环境变量配置
|
||||
# HuiBrain - 中文直播教育知识库系统 环境变量配置
|
||||
# ============================================================
|
||||
# 复制此文件为 .env 并根据实际情况修改配置值
|
||||
# cp .env.example .env
|
||||
# ============================================================
|
||||
|
||||
# ── 数据库 ──
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/edu_brain
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/hui_brain
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
||||
# ── 嵌入模型配置 ──
|
||||
# 可选值: openai / zhipu / local_bge / dashscope / minimax
|
||||
# openai 模式也兼容硅基流动等第三方 OpenAI 兼容接口
|
||||
EMBEDDING_PROVIDER=minimax
|
||||
EMBEDDING_MODEL=MiniMax-Text-Embedding
|
||||
EMBEDDING_DIMENSIONS=1024
|
||||
|
||||
# ── OpenAI 配置 ──
|
||||
# ── OpenAI 配置(也兼容硅基流动等第三方接口) ──
|
||||
OPENAI_API_KEY=sk-your-openai-api-key
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
@@ -32,6 +33,7 @@ LOCAL_BGE_MODEL_PATH=
|
||||
# 可选值: paddleocr / aliyun / tencent / deepseek / auto
|
||||
# auto 模式: 本地 PaddleOCR 优先,失败后 fallback 到 DeepSeek / 云端 OCR
|
||||
OCR_PROVIDER=deepseek
|
||||
OCR_CONCURRENCY=1
|
||||
|
||||
# ── 阿里云 OCR 配置 ──
|
||||
ALIYUN_OCR_ACCESS_KEY=your-aliyun-access-key
|
||||
@@ -52,7 +54,7 @@ DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_OCR_MODEL=deepseek-chat
|
||||
|
||||
# ── LLM 配置(用于查询扩展、实体检测) ──
|
||||
# ── LLM 配置(用于查询扩展、实体检测等) ──
|
||||
# 可选值: minimax / deepseek / openai
|
||||
LLM_PROVIDER=minimax
|
||||
|
||||
@@ -63,11 +65,6 @@ DATA_DIR=/app/data
|
||||
CHUNK_SIZE=300
|
||||
CHUNK_OVERLAP=50
|
||||
|
||||
# ── 服务配置 ──
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
|
||||
# ── CORS 配置(多个来源用逗号分隔) ──
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
@@ -76,9 +73,8 @@ APP_PORT=8000
|
||||
DB_PORT=5432
|
||||
|
||||
# ── 企业微信智能机器人(WebSocket 长连接) ──
|
||||
# 作为独立进程运行:python -m app.wework_bot
|
||||
# 后端启动时会自动检测配置并拉起机器人(后台线程)
|
||||
WEWORK_BOT_ENABLED=false
|
||||
WEWORK_BOT_ID=your-bot-id
|
||||
WEWORK_BOT_SECRET=your-bot-secret
|
||||
WEWORK_BOT_SEARCH_LIMIT=3
|
||||
EDUBRAIN_BASE_URL=http://localhost:8765
|
||||
HUIBRAIN_BASE_URL=http://localhost:8765
|
||||
|
||||
99
README.md
99
README.md
@@ -1,6 +1,6 @@
|
||||
# EduBrain - 中文直播教育知识库系统
|
||||
# HuiBrain - 中文直播教育知识库系统
|
||||
|
||||
基于 FastAPI 的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、AI 语义搜索等功能。
|
||||
基于 FastAPI 的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、AI 语义搜索、企业微信机器人等功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
@@ -13,17 +13,27 @@
|
||||
### 图片 OCR
|
||||
- **OCR 识别**: DeepSeek Vision 模型识别图片文字
|
||||
- **关键词提取**: MiniMax M2.7 自动提取内容标签
|
||||
- **批量处理**: 支持拖拽上传、选择文件、选择文件夹,按文件名去重
|
||||
- **批量处理**: 支持拖拽上传、选择文件、选择文件夹
|
||||
- **上传去重**: OCR 识别后自动检查内容是否已存在,避免重复录入
|
||||
- **图片管理**: 网页端管理所有图片,支持一键去重、手动删除
|
||||
- **BM25 索引**: 内置倒排索引搜索引擎,jieba 中文分词
|
||||
|
||||
### AI 搜索
|
||||
- **知识库搜索**: 向量语义搜索 + 全文检索混合排序
|
||||
- **知识库搜索**: 关键词搜索
|
||||
- **图片搜索**: MiniMax M2.7 语义匹配,SSE 流式实时返回结果
|
||||
- **并发池**: 最多 10 个 LLM 请求并发,每批 10 张图片判断
|
||||
- **并发池**: 最多 10 个 LLM 请求并发,每批数量可配置
|
||||
- **可配置参数**: 搜索返回数量、LLM 批量判断数量、OCR 并发数量均可在网页端设置
|
||||
|
||||
### 企业微信机器人
|
||||
- **WebSocket 长连接**: 基于官方 SDK,无需公网 IP
|
||||
- **关键词搜索**: 用户发文字 → 搜索图片 → 返回匹配结果
|
||||
- **图片自动入库**: 用户发图片 → 自动 OCR 识别 → 录入知识库(自动去重)
|
||||
- **集成启动**: 后端启动时自动检测配置并拉起机器人
|
||||
- **网页端管理**: 在设置页面配置 Bot ID / Secret,支持在线重启
|
||||
|
||||
### 其他
|
||||
- **MCP 协议**: 支持 Model Context Protocol,可被 AI Agent 调用
|
||||
- **Docker 部署**: 完整的 Docker Compose 编排(应用 + PostgreSQL + pgvector)
|
||||
- **Docker 部署**: 单容器部署,轻量级
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -31,44 +41,45 @@
|
||||
|------|------|
|
||||
| 后端框架 | FastAPI + Uvicorn |
|
||||
| ORM | SQLAlchemy 2.0 (async) |
|
||||
| 数据库 | PostgreSQL (pgvector) / SQLite |
|
||||
| 数据库 | SQLite |
|
||||
| 前端 | 纯 HTML + CSS + JS(苹果风格 SPA) |
|
||||
| OCR | DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云 |
|
||||
| LLM | MiniMax M2.7 / DeepSeek / Qwen3-8B |
|
||||
| 搜索 | BM25 倒排索引 + 向量语义搜索 |
|
||||
| 部署 | Docker + Docker Compose |
|
||||
| 搜索 | BM25 倒排索引 + 关键词搜索 |
|
||||
| 企业微信 | wecom-aibot-python-sdk (WebSocket 长连接) |
|
||||
| 部署 | Docker |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
edu-brain/
|
||||
huibrain/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 入口,路由注册,生命周期管理
|
||||
│ ├── main.py # FastAPI 入口,路由注册,生命周期管理,BotManager
|
||||
│ ├── config.py # pydantic-settings 配置管理
|
||||
│ ├── database.py # 数据库连接(PG/SQLite 双后端)
|
||||
│ ├── database.py # SQLite 数据库连接
|
||||
│ ├── models/base.py # ORM 模型(KnowledgePage, OCRImage 等)
|
||||
│ ├── schemas/ # Pydantic 请求/响应 Schema
|
||||
│ ├── api/v1/ # API 路由
|
||||
│ │ ├── pages.py # 知识页面 CRUD
|
||||
│ │ ├── search.py # 语义搜索
|
||||
│ │ ├── images.py # 图片 OCR + AI 搜索(SSE)
|
||||
│ │ ├── search.py # 关键词搜索
|
||||
│ │ ├── images.py # 图片 OCR + AI 搜索(SSE)+ 去重 + 删除
|
||||
│ │ ├── import_export.py # 文件导入导出
|
||||
│ │ └── settings.py # 系统设置
|
||||
│ │ └── settings.py # 系统设置 + 机器人管理
|
||||
│ ├── services/
|
||||
│ │ ├── ocr_service.py # OCR 识别服务(多 Provider)
|
||||
│ │ ├── llm_service.py # LLM 服务(标签提取/搜索匹配)
|
||||
│ │ ├── search_engine.py # BM25 倒排索引引擎
|
||||
│ │ ├── search_service.py # 混合搜索服务
|
||||
│ │ ├── search_service.py # 搜索服务
|
||||
│ │ ├── embedding_service.py # 嵌入模型服务
|
||||
│ │ ├── import_service.py # 文件导入服务
|
||||
│ │ └── page_service.py # 页面 CRUD 服务
|
||||
│ ├── wework_bot.py # 企业微信智能机器人服务
|
||||
│ └── mcp/server.py # MCP Server 实现
|
||||
├── static/index.html # 前端单页应用
|
||||
├── data/images/ # 图片存储目录
|
||||
├── .env.example # 环境变量模板
|
||||
├── requirements.txt # Python 依赖
|
||||
├── Dockerfile # 应用镜像
|
||||
├── Dockerfile.db # 数据库镜像(PG + pgvector)
|
||||
└── docker-compose.yml # Docker 编排
|
||||
```
|
||||
|
||||
@@ -79,7 +90,7 @@ edu-brain/
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repo-url>
|
||||
cd edu-brain
|
||||
cd huibrain
|
||||
|
||||
# 复制配置文件
|
||||
cp .env.example .env
|
||||
@@ -94,7 +105,7 @@ pip install -r requirements.txt
|
||||
|
||||
```env
|
||||
# 数据库(默认 SQLite,无需额外配置)
|
||||
DATABASE_URL=sqlite+aiosqlite:///edu_brain.db
|
||||
DATABASE_URL=sqlite+aiosqlite:///hui_brain.db
|
||||
|
||||
# OCR(使用 DeepSeek Vision)
|
||||
OCR_PROVIDER=deepseek
|
||||
@@ -106,6 +117,11 @@ DEEPSEEK_OCR_MODEL=deepseek-ocr:latest
|
||||
MINIMAX_API_KEY=your-minimax-api-key
|
||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||
MINIMAX_CHAT_MODEL=MiniMax-M2.7
|
||||
|
||||
# 企业微信机器人(可选,不配则不启动)
|
||||
WEWORK_BOT_ENABLED=true
|
||||
WEWORK_BOT_ID=your-bot-id
|
||||
WEWORK_BOT_SECRET=your-bot-secret
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
@@ -114,6 +130,8 @@ MINIMAX_CHAT_MODEL=MiniMax-M2.7
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
启动后会自动检测企业微信机器人配置,如果 `WEWORK_BOT_ENABLED=true` 且 `WEWORK_BOT_ID` / `WEWORK_BOT_SECRET` 已配置,机器人会自动在后台线程启动。
|
||||
|
||||
访问 http://localhost:8765 即可使用。
|
||||
|
||||
### 4. Docker 部署
|
||||
@@ -127,16 +145,39 @@ docker-compose up -d
|
||||
```
|
||||
用户上传图片
|
||||
→ DeepSeek Vision OCR 识别文字
|
||||
→ 检查 OCR 内容是否已存在(去重)
|
||||
→ MiniMax M2.7 提取关键词标签
|
||||
→ 存入 SQLite + BM25 索引
|
||||
|
||||
用户搜索 "孩子不想上学"
|
||||
→ 从 DB 倒序取图片(每批 10 张)
|
||||
→ 并发池(最多 10 个请求)发给 MiniMax M2.7 判断
|
||||
→ 从 DB 倒序取图片(每批 N 张,可配置)
|
||||
→ 并发池发给 MiniMax M2.7 判断
|
||||
→ 匹配结果通过 SSE 实时推送到前端
|
||||
→ 找够指定数量或遍历完 DB 后结束
|
||||
```
|
||||
|
||||
## 企业微信机器人
|
||||
|
||||
### 功能
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 单聊发关键词 | 搜索图片知识库,返回匹配结果(图片+预览文字) |
|
||||
| 单聊发图片 | 自动 OCR 识别并录入知识库(自动去重) |
|
||||
| 进入会话 | 自动发送欢迎语 |
|
||||
|
||||
### 配置
|
||||
|
||||
1. 在企业微信管理后台创建智能机器人,开启 API 模式(长连接)
|
||||
2. 获取 Bot ID 和 Secret
|
||||
3. 在 `.env` 中配置或通过网页端设置页面配置
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 图片消息**仅支持单聊**,群聊中发图片不会触发机器人
|
||||
- 机器人支持多人同时使用(asyncio 异步架构)
|
||||
- 配置修改后可通过网页端「重启机器人」按钮生效
|
||||
|
||||
## API 概览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
@@ -144,12 +185,18 @@ docker-compose up -d
|
||||
| POST | `/api/v1/images/recognize-direct` | 上传并识别图片 |
|
||||
| POST | `/api/v1/images/batch-recognize` | 批量上传并识别 |
|
||||
| GET | `/api/v1/images/search` | AI 搜索图片(SSE 流式) |
|
||||
| GET | `/api/v1/images` | 获取图片列表(分页) |
|
||||
| GET | `/api/v1/images/{id}` | 获取图片识别结果 |
|
||||
| DELETE | `/api/v1/images/{id}` | 删除图片 |
|
||||
| POST | `/api/v1/images/dedup` | 一键去重 |
|
||||
| GET | `/api/v1/pages` | 知识页面列表 |
|
||||
| POST | `/api/v1/pages` | 创建知识页面 |
|
||||
| POST | `/api/v1/search` | 语义搜索知识库 |
|
||||
| POST | `/api/v1/search` | 关键词搜索知识库 |
|
||||
| POST | `/api/v1/import/file` | 导入文件 |
|
||||
| GET | `/api/v1/settings` | 获取系统设置 |
|
||||
| PUT | `/api/v1/settings` | 更新系统设置 |
|
||||
| GET | `/api/v1/settings/bot/status` | 获取机器人状态 |
|
||||
| POST | `/api/v1/settings/bot/restart` | 重启机器人 |
|
||||
|
||||
## 配置说明
|
||||
|
||||
@@ -168,9 +215,15 @@ docker-compose up -d
|
||||
| Provider | 说明 | 额外配置 |
|
||||
|----------|------|----------|
|
||||
| `minimax` | MiniMax 嵌入 | `MINIMAX_API_KEY` |
|
||||
| `openai` | OpenAI 嵌入 | `OPENAI_API_KEY` |
|
||||
| `openai` | OpenAI 嵌入(兼容硅基流动等第三方接口) | `OPENAI_API_KEY` |
|
||||
| `zhipu` | 智谱 AI 嵌入 | `ZHIPU_API_KEY` |
|
||||
| `dashscope` | 阿里云 DashScope | `DASHSCOPE_API_KEY` |
|
||||
| `local_bge` | 本地 BGE 模型 | `LOCAL_BGE_MODEL_PATH` |
|
||||
|
||||
### 网页端可配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 | 范围 |
|
||||
|------|------|--------|------|
|
||||
| 搜索返回数量 | 每次搜索最多返回多少条匹配结果 | 3 | 1-10 |
|
||||
| LLM 批量判断数量 | 每批发给 LLM 判断的图片数量 | 10 | 1-50 |
|
||||
| OCR 并发数量 | 多用户同时上传时 OCR 并发识别数量 | 1 | 1-10 |
|
||||
|
||||
63
alembic.ini
63
alembic.ini
@@ -1,63 +0,0 @@
|
||||
# Alembic 数据库迁移配置
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本目录
|
||||
script_location = alembic
|
||||
|
||||
# 数据库连接 URL(通过环境变量覆盖)
|
||||
# 注意:Alembic 使用同步驱动,需要将 asyncpg 替换为 psycopg2
|
||||
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/edu_brain
|
||||
|
||||
# 模板文件
|
||||
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# 是否自动应用迁移
|
||||
# prepend_sys_path = .
|
||||
|
||||
# 时区设置
|
||||
timezone = Asia/Shanghai
|
||||
|
||||
# 截断长迁移消息
|
||||
truncate_slug_length = 40
|
||||
|
||||
# 修订 ID 格式
|
||||
# revision_environment = false
|
||||
|
||||
# 输出编码
|
||||
# output_encoding = utf-8
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
Alembic 迁移环境配置
|
||||
支持异步数据库连接
|
||||
"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# 导入所有 ORM 模型以确保 Base.metadata 包含完整的表定义
|
||||
from app.models.base import Base
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 设置日志
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# 元数据目标,用于自动生成迁移
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
以 'offline' 模式运行迁移。
|
||||
只需要 URL,不需要 Engine。调用 context.execute() 将迁移
|
||||
直接发送到数据库。
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
以 'online' 模式运行迁移。
|
||||
创建 Engine 并关联 connection 到 context。
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
中文直播教育知识库系统 - edu-brain
|
||||
中文直播教育知识库系统 - huibrain
|
||||
"""
|
||||
|
||||
__version__ = "1.2.0"
|
||||
|
||||
@@ -30,6 +30,19 @@ from app.services.llm_service import LLMService
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# OCR 并发控制信号量(动态读取配置)
|
||||
_ocr_semaphore: asyncio.Semaphore | None = None
|
||||
|
||||
|
||||
def _get_ocr_semaphore() -> asyncio.Semaphore:
|
||||
"""获取/重建 OCR 并发信号量"""
|
||||
global _ocr_semaphore
|
||||
from app.config import settings
|
||||
concurrency = settings.OCR_CONCURRENCY
|
||||
if _ocr_semaphore is None or _ocr_semaphore._value != concurrency:
|
||||
_ocr_semaphore = asyncio.Semaphore(concurrency)
|
||||
return _ocr_semaphore
|
||||
|
||||
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
|
||||
# 全局 BM25 索引实例(在 main.py 中初始化)
|
||||
@@ -126,6 +139,7 @@ async def recognize_image(image_id: int, db: AsyncSession = Depends(get_db)):
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
async with _get_ocr_semaphore():
|
||||
ocr_result = await OCRService.recognize(image_record.file_path)
|
||||
_save_ocr_result(image_record, ocr_result, db)
|
||||
await db.flush()
|
||||
@@ -171,6 +185,7 @@ async def batch_recognize(files: List[UploadFile], db: AsyncSession = Depends(ge
|
||||
db.add(image_record)
|
||||
await db.flush()
|
||||
try:
|
||||
async with _get_ocr_semaphore():
|
||||
ocr_result = await OCRService.recognize(str(dest_path))
|
||||
# OCR 内容去重
|
||||
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
||||
@@ -239,6 +254,7 @@ async def import_from_paths(data: PathImportRequest, db: AsyncSession = Depends(
|
||||
db.add(image_record)
|
||||
await db.flush()
|
||||
try:
|
||||
async with _get_ocr_semaphore():
|
||||
ocr_result = await OCRService.recognize(file_path)
|
||||
# OCR 内容去重
|
||||
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
||||
@@ -279,6 +295,7 @@ async def recognize_direct(file: UploadFile, db: AsyncSession = Depends(get_db))
|
||||
db.add(image_record)
|
||||
await db.flush()
|
||||
try:
|
||||
async with _get_ocr_semaphore():
|
||||
ocr_result = await OCRService.recognize(str(dest_path))
|
||||
# OCR 内容去重
|
||||
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
||||
|
||||
@@ -12,6 +12,15 @@ from app.config import settings
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _mask_secret(value: str | None, visible: int = 4) -> str | None:
|
||||
"""对敏感字符串脱敏,保留前 visible 个字符"""
|
||||
if not value:
|
||||
return None
|
||||
if len(value) <= visible:
|
||||
return "****"
|
||||
return value[:visible] + "****"
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
"""设置响应(隐藏敏感信息的中间位)"""
|
||||
embedding_provider: str
|
||||
@@ -29,6 +38,7 @@ class SettingsResponse(BaseModel):
|
||||
chunk_overlap: int
|
||||
search_limit: int
|
||||
judge_batch_size: int
|
||||
ocr_concurrency: int
|
||||
# API Key 只返回是否已配置(不返回实际值)
|
||||
has_minimax_key: bool
|
||||
has_deepseek_key: bool
|
||||
@@ -37,6 +47,10 @@ class SettingsResponse(BaseModel):
|
||||
has_dashscope_key: bool
|
||||
has_aliyun_ocr: bool
|
||||
has_tencent_ocr: bool
|
||||
# 企业微信机器人
|
||||
wework_bot_enabled: bool
|
||||
wework_bot_id: str | None
|
||||
wework_bot_secret_masked: str | None
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse, summary="获取当前设置")
|
||||
@@ -58,6 +72,7 @@ async def get_settings():
|
||||
chunk_overlap=settings.CHUNK_OVERLAP,
|
||||
search_limit=settings.SEARCH_LIMIT,
|
||||
judge_batch_size=settings.JUDGE_BATCH_SIZE,
|
||||
ocr_concurrency=settings.OCR_CONCURRENCY,
|
||||
has_minimax_key=bool(settings.MINIMAX_API_KEY),
|
||||
has_deepseek_key=bool(settings.DEEPSEEK_API_KEY),
|
||||
has_openai_key=bool(settings.OPENAI_API_KEY),
|
||||
@@ -65,6 +80,9 @@ async def get_settings():
|
||||
has_dashscope_key=bool(settings.DASHSCOPE_API_KEY),
|
||||
has_aliyun_ocr=bool(settings.ALIYUN_OCR_ACCESS_KEY),
|
||||
has_tencent_ocr=bool(settings.TENCENT_OCR_SECRET_ID),
|
||||
wework_bot_enabled=settings.WEWORK_BOT_ENABLED,
|
||||
wework_bot_id=settings.WEWORK_BOT_ID,
|
||||
wework_bot_secret_masked=_mask_secret(settings.WEWORK_BOT_SECRET),
|
||||
)
|
||||
|
||||
|
||||
@@ -94,6 +112,11 @@ class SettingsUpdate(BaseModel):
|
||||
chunk_overlap: int | None = None
|
||||
search_limit: int | None = None
|
||||
judge_batch_size: int | None = None
|
||||
ocr_concurrency: int | None = None
|
||||
# 企业微信机器人
|
||||
wework_bot_enabled: bool | None = None
|
||||
wework_bot_id: str | None = None
|
||||
wework_bot_secret: str | None = None
|
||||
|
||||
|
||||
@router.put("", summary="更新设置")
|
||||
@@ -139,6 +162,10 @@ async def update_settings(data: SettingsUpdate):
|
||||
"chunk_overlap": "CHUNK_OVERLAP",
|
||||
"search_limit": "SEARCH_LIMIT",
|
||||
"judge_batch_size": "JUDGE_BATCH_SIZE",
|
||||
"ocr_concurrency": "OCR_CONCURRENCY",
|
||||
"wework_bot_enabled": "WEWORK_BOT_ENABLED",
|
||||
"wework_bot_id": "WEWORK_BOT_ID",
|
||||
"wework_bot_secret": "WEWORK_BOT_SECRET",
|
||||
}
|
||||
|
||||
for api_field, config_field in field_map.items():
|
||||
@@ -221,3 +248,18 @@ async def test_ocr():
|
||||
return {"success": True, "message": f"OCR 提供商 {provider.name} 已就绪"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"OCR 测试失败: {str(e)}"}
|
||||
|
||||
|
||||
@router.post("/bot/restart", summary="重启企业微信机器人")
|
||||
async def restart_bot():
|
||||
"""重启企业微信机器人服务"""
|
||||
from app.main import bot_manager
|
||||
result = await bot_manager.restart()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/bot/status", summary="获取企业微信机器人状态")
|
||||
async def get_bot_status():
|
||||
"""获取企业微信机器人运行状态"""
|
||||
from app.main import bot_manager
|
||||
return bot_manager.get_status()
|
||||
|
||||
@@ -46,8 +46,8 @@ class Settings(BaseSettings):
|
||||
|
||||
# ──────────────────────────── 数据库 ────────────────────────────
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@db:5432/edu_brain",
|
||||
description="PostgreSQL 异步连接字符串",
|
||||
default="sqlite+aiosqlite:///./hui_brain.db",
|
||||
description="SQLite 数据库连接字符串",
|
||||
)
|
||||
|
||||
# ──────────────────────────── 嵌入模型 ────────────────────────────
|
||||
@@ -153,10 +153,8 @@ class Settings(BaseSettings):
|
||||
SEARCH_LIMIT: int = Field(default=3, description="图片搜索默认返回数量(1-10)")
|
||||
JUDGE_BATCH_SIZE: int = Field(default=10, description="每次LLM判断的文章数量(1-50)")
|
||||
|
||||
# ──────────────────────────── 服务端口 ────────────────────────────
|
||||
HOST: str = Field(default="0.0.0.0", description="服务监听地址")
|
||||
PORT: int = Field(default=8000, description="服务监听端口")
|
||||
DEBUG: bool = Field(default=False, description="调试模式")
|
||||
# ──────────────────────────── OCR ────────────────────────────
|
||||
OCR_CONCURRENCY: int = Field(default=1, ge=1, le=10, description="OCR 并发识别数量(1-10),多用户同时上传时控制并发")
|
||||
|
||||
# ──────────────────────────── CORS ────────────────────────────
|
||||
CORS_ORIGINS: list[str] = Field(
|
||||
@@ -177,15 +175,9 @@ class Settings(BaseSettings):
|
||||
default=None,
|
||||
description="企业微信智能机器人 Secret",
|
||||
)
|
||||
WEWORK_BOT_SEARCH_LIMIT: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="企业微信机器人搜索返回图片数量上限",
|
||||
)
|
||||
EDUBRAIN_BASE_URL: str = Field(
|
||||
HUIBRAIN_BASE_URL: str = Field(
|
||||
default="http://localhost:8765",
|
||||
description="EduBrain 服务地址(供企业微信机器人调用搜索接口)",
|
||||
description="HuiBrain 后端服务地址,企业微信机器人通过此地址调用后端 API(搜索/OCR识别等)。当机器人与后端部署在同一台机器时使用默认值 http://localhost:8765;若机器人独立部署到其他服务器,需改为后端的实际访问地址(如 http://192.168.1.100:8765)",
|
||||
)
|
||||
|
||||
# ──────────────────────────── 属性方法 ────────────────────────────
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
数据库连接模块
|
||||
支持 PostgreSQL(生产)和 SQLite(本地开发)两种后端
|
||||
数据库连接管理
|
||||
|
||||
SQLite 数据库,支持异步操作。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
@@ -14,35 +15,25 @@ from sqlalchemy.ext.asyncio import (
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# ──────────────────────────── 判断数据库类型 ────────────────────────────
|
||||
# ──────────────────────────── 数据库引擎 ────────────────────────────
|
||||
|
||||
DB_URL = settings.DATABASE_URL
|
||||
IS_SQLITE = DB_URL.startswith("sqlite")
|
||||
|
||||
# ──────────────────────────── 异步引擎 ────────────────────────────
|
||||
|
||||
if IS_SQLITE:
|
||||
# SQLite 配置
|
||||
sqlite_path = DB_URL.replace("sqlite+aiosqlite:///", "")
|
||||
if not sqlite_path.startswith("/"):
|
||||
# 解析 SQLite 文件路径
|
||||
sqlite_path = DB_URL.replace("sqlite+aiosqlite:///", "")
|
||||
if not sqlite_path.startswith("/"):
|
||||
sqlite_path = str(Path(__file__).parent.parent / sqlite_path)
|
||||
|
||||
engine = create_async_engine(
|
||||
# 确保数据目录存在
|
||||
Path(sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_async_engine(
|
||||
f"sqlite+aiosqlite:///{sqlite_path}",
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
else:
|
||||
# PostgreSQL 配置
|
||||
engine = create_async_engine(
|
||||
DB_URL,
|
||||
echo=settings.DEBUG,
|
||||
poolclass=NullPool,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# ──────────────────────────── 会话工厂 ────────────────────────────
|
||||
|
||||
@@ -61,47 +52,21 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
FastAPI 依赖项:获取异步数据库会话。
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ──────────────────────────── 生命周期辅助 ────────────────────────────
|
||||
async def close_db() -> None:
|
||||
"""关闭数据库连接池"""
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""初始化数据库:建表"""
|
||||
from app.models.base import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if IS_SQLITE:
|
||||
# SQLite 启用 WAL 模式和外键约束
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
await conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||
# 创建所有表
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not IS_SQLITE:
|
||||
# PostgreSQL 需要创建扩展
|
||||
async with engine.begin() as conn:
|
||||
try:
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS zhparser"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"CREATE TEXT SEARCH CONFIGURATION IF NOT EXISTS chinese_zh (PARSER = zhparser)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""关闭数据库引擎"""
|
||||
await engine.dispose()
|
||||
|
||||
91
app/main.py
91
app/main.py
@@ -5,8 +5,10 @@ FastAPI 应用入口
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
@@ -18,6 +20,88 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from app.config import settings
|
||||
from app.database import close_db, init_db
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotManager:
|
||||
"""管理企业微信机器人的生命周期(在后台线程中运行)"""
|
||||
|
||||
def __init__(self):
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
|
||||
def start(self):
|
||||
"""在后台线程中启动机器人"""
|
||||
if self._running:
|
||||
_logger.info("企业微信机器人已在运行")
|
||||
return
|
||||
if not settings.WEWORK_BOT_ENABLED or not settings.WEWORK_BOT_ID or not settings.WEWORK_BOT_SECRET:
|
||||
_logger.info("企业微信机器人未配置,跳过启动")
|
||||
return
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_bot, daemon=True, name="wework-bot"
|
||||
)
|
||||
self._thread.start()
|
||||
_logger.info("企业微信机器人已在后台线程启动")
|
||||
|
||||
def _run_bot(self):
|
||||
"""在线程中运行 bot(需要新建事件循环)"""
|
||||
self._running = True
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(self._run_bot_async())
|
||||
except Exception as e:
|
||||
_logger.error("企业微信机器人线程异常退出: %s", e, exc_info=True)
|
||||
finally:
|
||||
loop.close()
|
||||
self._running = False
|
||||
|
||||
async def _run_bot_async(self):
|
||||
"""异步运行 bot"""
|
||||
from app.wework_bot import WeComBotService
|
||||
service = WeComBotService()
|
||||
await service.start_async()
|
||||
|
||||
async def restart(self) -> dict:
|
||||
"""重启机器人(先停后启)"""
|
||||
was_running = self._running
|
||||
# 停止旧的(设置 enabled=False 让旧线程自然退出)
|
||||
old_id = settings.WEWORK_BOT_ID
|
||||
old_secret = settings.WEWORK_BOT_SECRET
|
||||
old_enabled = settings.WEWORK_BOT_ENABLED
|
||||
|
||||
if not old_enabled or not old_id or not old_secret:
|
||||
return {"success": False, "message": "请先配置机器人 ID 和 Secret 并启用"}
|
||||
|
||||
# 等待旧线程结束
|
||||
if self._thread and self._thread.is_alive():
|
||||
# SDK 的 run() 会阻塞,无法从外部优雅停止
|
||||
# 通过设置 enabled=False 让下次检查时退出
|
||||
_logger.info("等待旧机器人线程结束...")
|
||||
# 无法强制停止,提示用户
|
||||
if was_running:
|
||||
return {"success": False, "message": "旧机器人正在运行,请稍后重试(SDK 不支持强制停止)"}
|
||||
|
||||
# 启动新的
|
||||
self.start()
|
||||
return {"success": True, "message": "机器人已启动"}
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""获取机器人状态"""
|
||||
configured = bool(settings.WEWORK_BOT_ID and settings.WEWORK_BOT_SECRET)
|
||||
return {
|
||||
"enabled": settings.WEWORK_BOT_ENABLED,
|
||||
"configured": configured,
|
||||
"running": self._running,
|
||||
"bot_id": settings.WEWORK_BOT_ID,
|
||||
}
|
||||
|
||||
|
||||
# 全局 bot 管理器
|
||||
bot_manager = BotManager()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
@@ -32,6 +116,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
settings.ensure_dirs()
|
||||
await init_db()
|
||||
|
||||
# 启动企业微信机器人(后台线程)
|
||||
bot_manager.start()
|
||||
|
||||
# 预热嵌入服务(懒加载,首次调用时初始化)
|
||||
try:
|
||||
from app.services.embedding_service import EmbeddingService
|
||||
@@ -95,7 +182,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# ──────────────────────────── 创建应用 ────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="EduBrain - 中文直播教育知识库",
|
||||
title="HuiBrain - 中文直播教育知识库",
|
||||
description="基于向量检索的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、语义搜索等功能。",
|
||||
version="1.2.0",
|
||||
lifespan=lifespan,
|
||||
@@ -121,7 +208,7 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp
|
||||
logger.error("未处理异常: %s", exc, exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误", "message": str(exc) if settings.DEBUG else "请稍后重试"},
|
||||
content={"detail": "服务器内部错误", "message": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
# ──────────────────────────── 创建 MCP Server ────────────────────────────
|
||||
|
||||
mcp = FastMCP(
|
||||
name="EduBrain-Knowledge",
|
||||
name="HuiBrain-Knowledge",
|
||||
instructions=(
|
||||
"中文直播教育知识库助手。你可以使用以下工具来搜索和查询知识库中的内容:\n"
|
||||
"- search_knowledge: 语义搜索知识库,支持按课程、讲师、日期过滤\n"
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
"""
|
||||
SQLAlchemy 基础模型 + 通用 Mixin
|
||||
提供 DeclarativeBase、时间戳 Mixin 和公共序列化方法
|
||||
兼容 PostgreSQL(pgvector)和 SQLite(本地开发)
|
||||
ORM 模型定义
|
||||
|
||||
SQLite 数据库模型,使用 Text 存储 JSON 格式的向量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import DateTime, Float, Integer, String, Text, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from app.database import IS_SQLITE
|
||||
|
||||
# SQLite 下不使用 pgvector,用 Text 存储向量(JSON 格式)
|
||||
if IS_SQLITE:
|
||||
VectorType = Text
|
||||
else:
|
||||
from pgvector.sqlalchemy import Vector
|
||||
VectorType = Vector
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有 ORM 模型的基类"""
|
||||
@@ -28,96 +20,83 @@ class Base(DeclarativeBase):
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""
|
||||
时间戳 Mixin,为模型自动添加 created_at / updated_at 字段。
|
||||
"""
|
||||
"""时间戳 Mixin"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
comment="创建时间",
|
||||
DateTime, default=func.now(), comment="创建时间"
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="更新时间",
|
||||
DateTime, default=func.now(), onupdate=func.now(), comment="更新时间"
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""将模型实例转换为字典"""
|
||||
result: Dict[str, Any] = {}
|
||||
for column in self.__table__.columns: # type: ignore[attr-defined]
|
||||
value = getattr(self, column.name, None)
|
||||
if isinstance(value, datetime):
|
||||
value = value.isoformat()
|
||||
elif hasattr(value, "tolist"):
|
||||
value = value.tolist()
|
||||
result[column.name] = value
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────── 业务模型 ────────────────────────────
|
||||
|
||||
class KnowledgePage(Base, TimestampMixin):
|
||||
"""知识页面模型"""
|
||||
"""知识页面"""
|
||||
__tablename__ = "knowledge_pages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False, comment="页面标题")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, comment="页面正文内容")
|
||||
source_file: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="来源文件名")
|
||||
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="直播日期")
|
||||
page_number: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="原始页码")
|
||||
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="额外元数据")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, comment="页面内容")
|
||||
source: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="来源文件名")
|
||||
page_number: Mapped[int | None] = mapped_column(Integer, 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)
|
||||
|
||||
class KnowledgeChunk(Base, TimestampMixin):
|
||||
"""知识分块模型"""
|
||||
__tablename__ = "knowledge_chunks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
page_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="关联页面 ID")
|
||||
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False, comment="分块序号")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, comment="分块文本内容")
|
||||
embedding: Mapped[list | None] = mapped_column(
|
||||
VectorType(768) if not IS_SQLITE else Text,
|
||||
nullable=True,
|
||||
comment="嵌入向量",
|
||||
)
|
||||
search_vector: Mapped[Any | None] = mapped_column(
|
||||
Text, nullable=True, comment="全文搜索向量(仅 PG)",
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
result = super().to_dict()
|
||||
result.pop("embedding", None)
|
||||
result.pop("search_vector", None)
|
||||
return result
|
||||
@embedding_vector.setter
|
||||
def embedding_vector(self, value: list[float] | None):
|
||||
self.embedding = json.dumps(value) if value else None
|
||||
|
||||
|
||||
class OCRImage(Base, TimestampMixin):
|
||||
"""OCR 图片记录模型"""
|
||||
"""OCR 图片记录"""
|
||||
__tablename__ = "ocr_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
file_path: Mapped[str] = mapped_column(String(500), nullable=False, comment="图片文件路径")
|
||||
ocr_text: Mapped[str | None] = mapped_column(Text, nullable=True, comment="OCR 识别全文")
|
||||
confidence: Mapped[float | None] = mapped_column(Float, nullable=True, comment="平均置信度")
|
||||
original_filename: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="原始文件名")
|
||||
ocr_text: Mapped[str | None] = mapped_column(Text, nullable=True, comment="OCR 识别文本")
|
||||
confidence: Mapped[float | None] = mapped_column(Float, nullable=True, comment="OCR 置信度")
|
||||
provider: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="OCR 提供商")
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", comment="处理状态")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
|
||||
tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="LLM 提取的标签,JSON 数组格式")
|
||||
story_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="Qwen3-8B 提炼的故事摘要")
|
||||
status: Mapped[str] = mapped_column(
|
||||
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)")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
result = super().to_dict()
|
||||
# 将 tags 从 JSON 字符串解析为列表返回
|
||||
if result.get("tags") and isinstance(result["tags"], str):
|
||||
try:
|
||||
import json
|
||||
result["tags"] = json.loads(result["tags"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return result
|
||||
@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
|
||||
def blocks_list(self) -> list[dict]:
|
||||
if self.blocks is None:
|
||||
return []
|
||||
return json.loads(self.blocks)
|
||||
|
||||
@blocks_list.setter
|
||||
def blocks_list(self, value: list[dict]):
|
||||
self.blocks = json.dumps(value) if value else None
|
||||
|
||||
|
||||
class WebsiteSettings(Base):
|
||||
"""网站设置(单例)"""
|
||||
__tablename__ = "website_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True, comment="设置键")
|
||||
value: Mapped[str | None] = mapped_column(Text, nullable=True, comment="设置值(JSON)")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now(), comment="更新时间"
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import IS_SQLITE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -343,7 +342,6 @@ class ImportService:
|
||||
|
||||
# 批量插入
|
||||
for idx, (chunk_text, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
if IS_SQLITE:
|
||||
# SQLite: 向量存为 JSON 文本
|
||||
import json as _json
|
||||
embedding_str = _json.dumps(embedding) if embedding else None
|
||||
@@ -351,13 +349,6 @@ class ImportService:
|
||||
INSERT INTO knowledge_chunks (page_id, chunk_index, content, embedding)
|
||||
VALUES (:page_id, :chunk_index, :content, :embedding)
|
||||
""")
|
||||
else:
|
||||
# PostgreSQL: 使用 pgvector 类型
|
||||
embedding_str = "[" + ",".join(str(x) for x in embedding) + "]" if embedding else None
|
||||
sql = text("""
|
||||
INSERT INTO knowledge_chunks (page_id, chunk_index, content, embedding)
|
||||
VALUES (:page_id, :chunk_index, :content, :embedding::vector)
|
||||
""")
|
||||
await self.db.execute(sql, {
|
||||
"page_id": page_id,
|
||||
"chunk_index": idx,
|
||||
@@ -365,14 +356,6 @@ class ImportService:
|
||||
"embedding": embedding_str,
|
||||
})
|
||||
|
||||
# 更新全文搜索向量(仅 PostgreSQL)
|
||||
if not IS_SQLITE:
|
||||
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()
|
||||
return len(chunks)
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"""
|
||||
语义搜索服务
|
||||
支持 PostgreSQL(向量+全文混合)和 SQLite(LIKE 关键词搜索)
|
||||
|
||||
SQLite 数据库,使用 LIKE 关键词搜索。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import IS_SQLITE
|
||||
from app.schemas.search import SearchRequest, SearchResult, SearchResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,21 +26,10 @@ class SearchService:
|
||||
|
||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||
"""
|
||||
执行搜索。
|
||||
- PostgreSQL: 向量搜索 + 中文全文搜索混合排序
|
||||
- SQLite: LIKE 关键词搜索(无向量能力)
|
||||
执行搜索:使用 LIKE 关键词搜索
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
if IS_SQLITE:
|
||||
return await self._search_sqlite(request, start_time)
|
||||
else:
|
||||
return await self._search_postgres(request, start_time)
|
||||
|
||||
# ──────────────────────────── SQLite 搜索 ────────────────────────────
|
||||
|
||||
async def _search_sqlite(self, request: SearchRequest, start_time: float) -> SearchResponse:
|
||||
"""SQLite 模式:使用 LIKE 关键词搜索"""
|
||||
# LLM 查询扩展(可选)
|
||||
expanded_queries = [request.query]
|
||||
try:
|
||||
@@ -109,178 +96,3 @@ class SearchService:
|
||||
results=results,
|
||||
elapsed_ms=round(elapsed_ms, 2),
|
||||
)
|
||||
|
||||
# ──────────────────────────── PostgreSQL 搜索 ────────────────────────────
|
||||
|
||||
async def _search_postgres(self, request: SearchRequest, start_time: float) -> SearchResponse:
|
||||
"""PostgreSQL 模式:向量搜索 + 中文全文搜索混合"""
|
||||
# LLM 查询扩展
|
||||
expanded_queries = [request.query]
|
||||
if request.use_fulltext:
|
||||
try:
|
||||
from app.services.llm_service import LLMService
|
||||
expanded_queries = await LLMService.expand_query(request.query)
|
||||
except Exception as exc:
|
||||
logger.debug("查询扩展跳过: %s", exc)
|
||||
|
||||
# 生成查询向量
|
||||
from app.services.embedding_service import EmbeddingService
|
||||
all_query_embeddings = await EmbeddingService.embed_batch(expanded_queries)
|
||||
|
||||
# 构建过滤条件
|
||||
where_clauses = []
|
||||
params: dict = {}
|
||||
|
||||
if request.course_name:
|
||||
where_clauses.append("kp.course_name = :course_name")
|
||||
params["course_name"] = request.course_name
|
||||
if request.teacher_name:
|
||||
where_clauses.append("kp.teacher_name = :teacher_name")
|
||||
params["teacher_name"] = request.teacher_name
|
||||
if request.live_date_from:
|
||||
where_clauses.append("kp.live_date >= :live_date_from")
|
||||
params["live_date_from"] = request.live_date_from
|
||||
if request.live_date_to:
|
||||
where_clauses.append("kp.live_date <= :live_date_to")
|
||||
params["live_date_to"] = request.live_date_to
|
||||
|
||||
where_sql = ""
|
||||
if where_clauses:
|
||||
where_sql = "AND " + " AND ".join(where_clauses)
|
||||
|
||||
# 向量搜索
|
||||
vector_results = []
|
||||
params["limit"] = request.top_k * 2
|
||||
for q_embedding in all_query_embeddings:
|
||||
embedding_str = "[" + ",".join(str(x) for x in q_embedding) + "]"
|
||||
vector_sql = f"""
|
||||
SELECT
|
||||
kc.id AS chunk_id,
|
||||
kp.id AS page_id,
|
||||
kp.title AS page_title,
|
||||
kc.content,
|
||||
1 - (kc.embedding <=> :embedding::vector) AS vector_score,
|
||||
0.0 AS text_score,
|
||||
kp.course_name,
|
||||
kp.teacher_name,
|
||||
kp.live_date
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_pages kp ON kc.page_id = kp.id
|
||||
WHERE kc.embedding IS NOT NULL
|
||||
{where_sql}
|
||||
ORDER BY kc.embedding <=> :embedding::vector
|
||||
LIMIT :limit
|
||||
"""
|
||||
v_params = {**params, "embedding": embedding_str}
|
||||
result = await self.db.execute(text(vector_sql), v_params)
|
||||
vector_results.extend(result.fetchall())
|
||||
|
||||
# 全文搜索
|
||||
text_results = []
|
||||
if request.use_fulltext:
|
||||
for q in expanded_queries:
|
||||
clean_query = q.replace("'", "''")
|
||||
fulltext_sql = f"""
|
||||
SELECT
|
||||
kc.id AS chunk_id,
|
||||
kp.id AS page_id,
|
||||
kp.title AS page_title,
|
||||
kc.content,
|
||||
0.0 AS vector_score,
|
||||
ts_rank_cd(kc.search_vector, to_tsquery('chinese_zh', :query)) AS text_score,
|
||||
kp.course_name,
|
||||
kp.teacher_name,
|
||||
kp.live_date,
|
||||
ts_headline('chinese_zh', kc.content, to_tsquery('chinese_zh', :query),
|
||||
'MaxWords=50, MinWords=20, ShortWord=2') AS highlight
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_pages kp ON kc.page_id = kp.id
|
||||
WHERE kc.search_vector @@ to_tsquery('chinese_zh', :query)
|
||||
{where_sql}
|
||||
ORDER BY text_score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
ft_params = {**params, "query": clean_query}
|
||||
ft_result = await self.db.execute(text(fulltext_sql), ft_params)
|
||||
text_results.extend(ft_result.fetchall())
|
||||
|
||||
# 合并结果
|
||||
merged: dict[int, dict] = {}
|
||||
|
||||
for row in vector_results:
|
||||
chunk_id = row.chunk_id
|
||||
if chunk_id not in merged:
|
||||
merged[chunk_id] = {
|
||||
"chunk_id": chunk_id,
|
||||
"page_id": row.page_id,
|
||||
"page_title": row.page_title,
|
||||
"content": row.content,
|
||||
"vector_score": row.vector_score,
|
||||
"text_score": 0.0,
|
||||
"course_name": row.course_name,
|
||||
"teacher_name": row.teacher_name,
|
||||
"live_date": row.live_date,
|
||||
"highlight": None,
|
||||
}
|
||||
else:
|
||||
merged[chunk_id]["vector_score"] = max(
|
||||
merged[chunk_id]["vector_score"], row.vector_score
|
||||
)
|
||||
|
||||
for row in text_results:
|
||||
chunk_id = row.chunk_id
|
||||
if chunk_id not in merged:
|
||||
merged[chunk_id] = {
|
||||
"chunk_id": chunk_id,
|
||||
"page_id": row.page_id,
|
||||
"page_title": row.page_title,
|
||||
"content": row.content,
|
||||
"vector_score": 0.0,
|
||||
"text_score": row.text_score or 0.0,
|
||||
"course_name": row.course_name,
|
||||
"teacher_name": row.teacher_name,
|
||||
"live_date": row.live_date,
|
||||
"highlight": row.highlight,
|
||||
}
|
||||
else:
|
||||
merged[chunk_id]["text_score"] = max(
|
||||
merged[chunk_id]["text_score"], row.text_score or 0.0
|
||||
)
|
||||
if row.highlight:
|
||||
merged[chunk_id]["highlight"] = row.highlight
|
||||
|
||||
# 计算综合得分
|
||||
max_text_score = max(
|
||||
(r["text_score"] for r in merged.values()), default=1.0
|
||||
) or 1.0
|
||||
|
||||
scored_results = []
|
||||
for item in merged.values():
|
||||
normalized_text = item["text_score"] / max_text_score if max_text_score > 0 else 0
|
||||
combined_score = 0.7 * item["vector_score"] + 0.3 * normalized_text
|
||||
|
||||
if combined_score >= request.threshold:
|
||||
scored_results.append(
|
||||
SearchResult(
|
||||
chunk_id=item["chunk_id"],
|
||||
page_id=item["page_id"],
|
||||
page_title=item["page_title"],
|
||||
content=item["content"],
|
||||
score=round(combined_score, 4),
|
||||
course_name=item["course_name"],
|
||||
teacher_name=item["teacher_name"],
|
||||
live_date=item["live_date"],
|
||||
highlight=item["highlight"],
|
||||
)
|
||||
)
|
||||
|
||||
scored_results.sort(key=lambda x: x.score, reverse=True)
|
||||
scored_results = scored_results[:request.top_k]
|
||||
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
return SearchResponse(
|
||||
query=request.query,
|
||||
total=len(scored_results),
|
||||
results=scored_results,
|
||||
elapsed_ms=round(elapsed_ms, 2),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
使用官方 SDK (wecom-aibot-python-sdk) 的 WSClient 类,
|
||||
封装了连接管理、心跳、断线重连等能力。
|
||||
|
||||
作为独立进程运行:python -m app.wework_bot
|
||||
支持两种运行方式:
|
||||
1. 集成模式:后端启动时自动拉起(BotManager)
|
||||
2. 独立进程:python -m app.wework_bot
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +18,10 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from aibot import WSClient, WSClientOptions, generate_req_id
|
||||
@@ -24,6 +30,8 @@ from dotenv import load_dotenv
|
||||
# 加载 .env 文件(独立运行时需要)
|
||||
load_dotenv()
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("wework_bot")
|
||||
|
||||
# 分片上传参数(参考官方文档)
|
||||
@@ -34,17 +42,14 @@ class WeComBotService:
|
||||
"""企业微信智能机器人服务(WebSocket 长连接模式)"""
|
||||
|
||||
def __init__(self):
|
||||
self.bot_id = os.getenv("WEWORK_BOT_ID", "")
|
||||
self.bot_secret = os.getenv("WEWORK_BOT_SECRET", "")
|
||||
self.edubrain_base_url = os.getenv(
|
||||
"EDUBRAIN_BASE_URL", "http://localhost:8765"
|
||||
)
|
||||
self.search_limit = int(os.getenv("WEWORK_BOT_SEARCH_LIMIT", "3"))
|
||||
self.enabled = os.getenv("WEWORK_BOT_ENABLED", "false").lower() == "true"
|
||||
self.bot_id = settings.WEWORK_BOT_ID or ""
|
||||
self.bot_secret = settings.WEWORK_BOT_SECRET or ""
|
||||
self.huibrain_base_url = settings.HUIBRAIN_BASE_URL
|
||||
self.enabled = settings.WEWORK_BOT_ENABLED
|
||||
self.ws_client: WSClient | None = None
|
||||
|
||||
def start(self):
|
||||
"""启动机器人服务"""
|
||||
"""启动机器人服务(阻塞模式,用于独立进程运行)"""
|
||||
if not self.enabled:
|
||||
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
|
||||
return
|
||||
@@ -60,16 +65,42 @@ class WeComBotService:
|
||||
)
|
||||
)
|
||||
|
||||
# 注册事件
|
||||
self._register_events()
|
||||
logger.info("启动企业微信智能机器人...")
|
||||
self.ws_client.run()
|
||||
|
||||
async def start_async(self):
|
||||
"""启动机器人服务(异步模式,用于嵌入到其他事件循环)"""
|
||||
if not self.enabled:
|
||||
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
|
||||
return
|
||||
|
||||
if not self.bot_id or not self.bot_secret:
|
||||
logger.error("WEWORK_BOT_ID 或 WEWORK_BOT_SECRET 未配置")
|
||||
return
|
||||
|
||||
self.ws_client = WSClient(
|
||||
WSClientOptions(
|
||||
bot_id=self.bot_id,
|
||||
secret=self.bot_secret,
|
||||
)
|
||||
)
|
||||
|
||||
self._register_events()
|
||||
logger.info("启动企业微信智能机器人(异步模式)...")
|
||||
await self.ws_client.connect()
|
||||
# 永远等待,保持连接
|
||||
await asyncio.Future()
|
||||
|
||||
def _register_events(self):
|
||||
"""注册所有事件处理器"""
|
||||
self.ws_client.on("authenticated", self._on_authenticated)
|
||||
self.ws_client.on("message.text", self._on_text_message)
|
||||
self.ws_client.on("message.image", self._on_image_message)
|
||||
self.ws_client.on("event.enter_chat", self._on_enter_chat)
|
||||
self.ws_client.on("disconnected", self._on_disconnected)
|
||||
self.ws_client.on("error", self._on_error)
|
||||
|
||||
logger.info("启动企业微信智能机器人...")
|
||||
self.ws_client.run()
|
||||
|
||||
# ──────────────────────────── 事件处理 ────────────────────────────
|
||||
|
||||
def _on_authenticated(self):
|
||||
@@ -83,7 +114,7 @@ class WeComBotService:
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "你好!我是知识库图片搜索助手,发送关键词即可搜索相关图片。"
|
||||
"content": "你好!我是知识库助手,支持以下功能:\n\n1. 发送关键词 → 搜索相关图片\n2. 发送图片 → 自动识别并录入知识库"
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -121,7 +152,7 @@ class WeComBotService:
|
||||
False,
|
||||
)
|
||||
|
||||
# 调用 EduBrain 搜索
|
||||
# 调用 HuiBrain 搜索
|
||||
try:
|
||||
results = await self._search_images(keyword)
|
||||
|
||||
@@ -194,6 +225,104 @@ class WeComBotService:
|
||||
"""连接断开回调"""
|
||||
logger.warning("企业微信连接断开: %s", reason)
|
||||
|
||||
async def _on_image_message(self, frame: dict):
|
||||
"""收到图片消息 -> 下载解密 -> 调用OCR识别 -> 回复结果"""
|
||||
body = frame.get("body", {})
|
||||
image_info = body.get("image", {})
|
||||
image_url = image_info.get("url", "")
|
||||
aes_key = image_info.get("aeskey", "")
|
||||
|
||||
if not image_url:
|
||||
logger.warning("收到图片消息但无下载地址")
|
||||
return
|
||||
|
||||
stream_id = generate_req_id("stream")
|
||||
await self.ws_client.reply_stream(
|
||||
frame, stream_id, "收到图片,正在识别...", False
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. 下载并解密图片
|
||||
image_data, original_filename = await self.ws_client.download_file(
|
||||
image_url, aes_key
|
||||
)
|
||||
if not image_data:
|
||||
await self.ws_client.reply_stream(
|
||||
frame, stream_id, "图片下载失败,请重试", True
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"图片下载成功: filename=%s, size=%d bytes",
|
||||
original_filename, len(image_data),
|
||||
)
|
||||
|
||||
# 2. 生成保存文件名(避免重名)
|
||||
ext = os.path.splitext(original_filename or "image.jpg")[1] or ".jpg"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
save_filename = f"wecom_{timestamp}{ext}"
|
||||
|
||||
# 3. 调用后端 recognize-direct 接口
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.huibrain_base_url}/api/v1/images/recognize-direct",
|
||||
files={"file": (save_filename, image_data, "image/jpeg")},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error("OCR 识别失败: status=%d, body=%s", resp.status_code, resp.text)
|
||||
await self.ws_client.reply_stream(
|
||||
frame, stream_id, "图片识别失败,请稍后重试", True
|
||||
)
|
||||
return
|
||||
|
||||
result = resp.json()
|
||||
|
||||
# 4. 根据识别结果回复
|
||||
status = result.get("status", "")
|
||||
|
||||
if status == "duplicate":
|
||||
dup_id = result.get("duplicate_of", "?")
|
||||
ocr_text = result.get("ocr_text", "")
|
||||
preview = ocr_text[:200].replace("\n", " ") if ocr_text else "无识别内容"
|
||||
await self.ws_client.reply_stream(
|
||||
frame,
|
||||
stream_id,
|
||||
f"🔄 该图片内容已存在于知识库中(ID: {dup_id}),无需重复录入。\n\n识别内容:\n{preview}{'...' if len(ocr_text) > 200 else ''}",
|
||||
True,
|
||||
)
|
||||
elif status == "completed":
|
||||
ocr_text = result.get("ocr_text", "")
|
||||
preview = ocr_text[:200].replace("\n", " ") if ocr_text else "无识别内容"
|
||||
record_id = result.get("id", "?")
|
||||
await self.ws_client.reply_stream(
|
||||
frame,
|
||||
stream_id,
|
||||
f"✅ 图片已录入知识库(ID: {record_id})\n\n识别内容预览:\n{preview}{'...' if len(ocr_text) > 200 else ''}",
|
||||
True,
|
||||
)
|
||||
elif status == "failed":
|
||||
error_msg = result.get("message", result.get("detail", "未知错误"))
|
||||
await self.ws_client.reply_stream(
|
||||
frame,
|
||||
stream_id,
|
||||
f"图片识别失败:{error_msg}",
|
||||
True,
|
||||
)
|
||||
else:
|
||||
await self.ws_client.reply_stream(
|
||||
frame,
|
||||
stream_id,
|
||||
f"图片处理结果:{json.dumps(result, ensure_ascii=False)}",
|
||||
True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理图片消息失败: %s", e, exc_info=True)
|
||||
await self.ws_client.reply_stream(
|
||||
frame, stream_id, f"处理图片时出错:{e}", True
|
||||
)
|
||||
|
||||
def _on_error(self, error):
|
||||
"""错误回调"""
|
||||
logger.error("企业微信机器人错误: %s", error)
|
||||
@@ -301,17 +430,19 @@ class WeComBotService:
|
||||
"""从后端 API 实时获取当前 search_limit 配置"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{self.edubrain_base_url}/api/v1/settings")
|
||||
resp = await client.get(f"{self.huibrain_base_url}/api/v1/settings")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("search_limit", self.search_limit)
|
||||
limit = data.get("search_limit")
|
||||
if limit:
|
||||
return limit
|
||||
except Exception as e:
|
||||
logger.warning("获取 search_limit 失败,使用默认值: %s", e)
|
||||
return self.search_limit
|
||||
return settings.SEARCH_LIMIT
|
||||
|
||||
async def _search_images(self, keyword: str) -> list:
|
||||
"""
|
||||
调用 EduBrain 搜索接口(SSE 流式),收集所有匹配结果。
|
||||
调用 HuiBrain 搜索接口(SSE 流式),收集所有匹配结果。
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词
|
||||
@@ -320,7 +451,7 @@ class WeComBotService:
|
||||
匹配结果列表
|
||||
"""
|
||||
results = []
|
||||
url = f"{self.edubrain_base_url}/api/v1/images/search"
|
||||
url = f"{self.huibrain_base_url}/api/v1/images/search"
|
||||
limit = await self._get_search_limit()
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
|
||||
@@ -1,55 +1,21 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# ── 应用服务 ──
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: edu-brain-app
|
||||
container_name: huibrain-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./data:/app/data # 数据目录挂载
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@db:5432/edu_brain
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# ── PostgreSQL 数据库(pgvector + zhparser 自定义镜像) ──
|
||||
db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.db
|
||||
container_name: edu-brain-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: edu_brain
|
||||
POSTGRES_HOST_AUTH_METHOD: scram-sha-256
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d edu_brain"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# EduBrain 图片功能集成指南
|
||||
# HuiBrain 图片功能集成指南
|
||||
|
||||
> 本文档介绍如何在 ADK Agent 项目中集成 EduBrain 的图片 OCR 和搜索功能。
|
||||
> 本文档介绍如何在 ADK Agent 项目中集成 HuiBrain 的图片 OCR 和搜索功能。
|
||||
|
||||
## 服务地址
|
||||
|
||||
@@ -20,8 +20,10 @@ BASE_URL = http://localhost:8765
|
||||
| POST | `/batch-recognize` | 批量上传并识别 | multipart/form-data |
|
||||
| POST | `/import-paths` | 从服务器路径导入 | JSON |
|
||||
| GET | `/search` | AI 搜索图片(SSE 流式) | query params |
|
||||
| GET | `/{image_id}` | 获取单张图片详情 | path param |
|
||||
| GET | `` | 获取图片列表(分页) | query params |
|
||||
| GET | `/{image_id}` | 获取单张图片详情 | path param |
|
||||
| DELETE | `/{image_id}` | 删除图片 | path param |
|
||||
| POST | `/dedup` | 一键去重 | 无 |
|
||||
|
||||
---
|
||||
|
||||
@@ -42,6 +44,8 @@ Content-Type: multipart/form-data
|
||||
|
||||
### 返回
|
||||
|
||||
**正常识别:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 386,
|
||||
@@ -58,6 +62,21 @@ Content-Type: multipart/form-data
|
||||
}
|
||||
```
|
||||
|
||||
**内容重复(自动去重):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 386,
|
||||
"file_path": "data/images/test.png",
|
||||
"original_filename": "test.png",
|
||||
"status": "duplicate",
|
||||
"duplicate_of": 100,
|
||||
"ocr_text": "识别出的文字内容..."
|
||||
}
|
||||
```
|
||||
|
||||
> `duplicate_of` 指向数据库中已存在的相同内容记录 ID。
|
||||
|
||||
### Python 调用示例
|
||||
|
||||
```python
|
||||
@@ -70,7 +89,10 @@ async def recognize_image(file_path: str) -> dict:
|
||||
f"{BASE_URL}/api/v1/images/recognize-direct",
|
||||
files={"file": (file_path, f)}
|
||||
)
|
||||
return resp.json()
|
||||
result = resp.json()
|
||||
if result.get("status") == "duplicate":
|
||||
print(f"内容已存在,重复于 ID: {result['duplicate_of']}")
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
@@ -96,7 +118,8 @@ Content-Type: multipart/form-data
|
||||
{
|
||||
"total": 3,
|
||||
"success": 2,
|
||||
"failed": 1,
|
||||
"failed": 0,
|
||||
"duplicates": 1,
|
||||
"results": [
|
||||
{
|
||||
"id": 387,
|
||||
@@ -110,8 +133,15 @@ Content-Type: multipart/form-data
|
||||
},
|
||||
{
|
||||
"id": 388,
|
||||
"filename": "img2.jpg",
|
||||
"file_path": "data/images/img2.jpg",
|
||||
"filename": "img2.png",
|
||||
"status": "duplicate",
|
||||
"duplicate_of": 100,
|
||||
"ocr_text": "重复内容..."
|
||||
},
|
||||
{
|
||||
"id": 389,
|
||||
"filename": "img3.jpg",
|
||||
"file_path": "data/images/img3.jpg",
|
||||
"status": "failed",
|
||||
"message": "OCR 识别失败: ..."
|
||||
}
|
||||
@@ -160,7 +190,7 @@ GET /api/v1/images/search?keyword=搜索词&limit=5&sort=time_desc
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| keyword | string | 是 | - | 搜索关键词 |
|
||||
| limit | int | 否 | 5 | 最多返回多少条匹配结果 |
|
||||
| limit | int | 否 | 3 | 最多返回多少条匹配结果(可在设置中配置默认值) |
|
||||
| sort | string | 否 | time_desc | 排序方式:`time_desc`(时间倒序)、`time_asc`(时间正序)、`random`(随机) |
|
||||
|
||||
### 搜索原理
|
||||
@@ -168,7 +198,7 @@ GET /api/v1/images/search?keyword=搜索词&limit=5&sort=time_desc
|
||||
```
|
||||
用户搜索 "孩子不想上学"
|
||||
→ 从数据库取所有已识别图片(按 sort 排序)
|
||||
→ 每批 10 张发给 MiniMax M2.7 判断是否匹配
|
||||
→ 每批 N 张发给 MiniMax M2.7 判断(N 可在设置中配置,默认 10)
|
||||
→ 并发池最多 10 个 LLM 请求同时执行
|
||||
→ 匹配结果通过 SSE 实时推送(找到就立刻返回)
|
||||
→ 找够 limit 条或遍历完所有图片后结束
|
||||
@@ -413,7 +443,55 @@ GET /api/v1/images?page=1&page_size=20&status=completed
|
||||
|
||||
---
|
||||
|
||||
## 八、图片文件访问
|
||||
## 八、删除图片
|
||||
|
||||
### 接口
|
||||
|
||||
```
|
||||
DELETE /api/v1/images/{image_id}
|
||||
```
|
||||
|
||||
### 返回
|
||||
|
||||
成功返回 `204 No Content`。
|
||||
|
||||
### 说明
|
||||
|
||||
删除操作会同时清理:
|
||||
1. BM25 倒排索引中的记录
|
||||
2. 磁盘上的图片文件
|
||||
3. 数据库中的记录
|
||||
|
||||
---
|
||||
|
||||
## 九、一键去重
|
||||
|
||||
### 接口
|
||||
|
||||
```
|
||||
POST /api/v1/images/dedup
|
||||
```
|
||||
|
||||
### 返回
|
||||
|
||||
```json
|
||||
{
|
||||
"total_checked": 385,
|
||||
"duplicates_found": 12,
|
||||
"deleted": 12,
|
||||
"kept": 373
|
||||
}
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- 按 OCR 识别文本精确匹配去重
|
||||
- 每组重复内容保留最早录入的记录
|
||||
- 删除操作与单条删除相同(清理索引 + 磁盘文件 + 数据库记录)
|
||||
|
||||
---
|
||||
|
||||
## 十、图片文件访问
|
||||
|
||||
识别后的图片可通过静态文件路径访问:
|
||||
|
||||
@@ -428,7 +506,51 @@ http://localhost:8765/data/images/001.png
|
||||
|
||||
---
|
||||
|
||||
## 九、注意事项
|
||||
## 十一、企业微信机器人相关接口
|
||||
|
||||
### 获取机器人状态
|
||||
|
||||
```
|
||||
GET /api/v1/settings/bot/status
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"configured": true,
|
||||
"running": true,
|
||||
"bot_id": "aibPl3zE65xj..."
|
||||
}
|
||||
```
|
||||
|
||||
### 重启机器人
|
||||
|
||||
```
|
||||
POST /api/v1/settings/bot/restart
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "机器人已启动"
|
||||
}
|
||||
```
|
||||
|
||||
### 系统设置(含机器人配置)
|
||||
|
||||
```
|
||||
GET /api/v1/settings
|
||||
PUT /api/v1/settings
|
||||
```
|
||||
|
||||
机器人相关字段:
|
||||
- `wework_bot_enabled` (bool): 是否启用
|
||||
- `wework_bot_id` (string): Bot ID
|
||||
- `wework_bot_secret` (string): Secret(更新时传入明文,获取时返回脱敏值)
|
||||
|
||||
---
|
||||
|
||||
## 十二、注意事项
|
||||
|
||||
1. **搜索接口是 SSE 流式**,不能用普通的 `requests.get()` 或 `httpx.get()` 直接获取 JSON,必须用流式读取(`stream()` / `iter_lines()`)
|
||||
2. **搜索耗时较长**:385 张图片全部遍历约需 2-5 分钟,建议设置较长的超时(300 秒)
|
||||
@@ -436,3 +558,5 @@ http://localhost:8765/data/images/001.png
|
||||
4. **limit 参数控制返回数量**:找到 `limit` 条匹配后自动停止搜索
|
||||
5. **OCR 识别耗时**:单张图片约 5-15 秒,建议设置 120 秒超时
|
||||
6. **文件格式**:仅支持 .png .jpg .jpeg .bmp .webp
|
||||
7. **上传自动去重**:所有上传接口(recognize-direct、batch-recognize、import-paths)都会在 OCR 识别后检查内容是否已存在
|
||||
8. **删除不可恢复**:删除操作会同时清理索引、磁盘文件和数据库记录
|
||||
|
||||
@@ -3,14 +3,14 @@ requires = ["setuptools>=75.0", "wheel"]
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "edu-brain"
|
||||
name = "huibrain"
|
||||
version = "1.2.0"
|
||||
description = "中文直播教育知识库系统 - 基于向量检索的知识管理平台"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "EduBrain Team"},
|
||||
{name = "HuiBrain Team"},
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
@@ -24,9 +24,7 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"pgvector>=0.3.6",
|
||||
"aiosqlite>=0.20.0",
|
||||
"openai>=1.58.0",
|
||||
"zhipuai>=2.4.0",
|
||||
"dashscope>=1.20.0",
|
||||
@@ -47,8 +45,8 @@ dev = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
edu-brain = "app.main:app"
|
||||
edu-brain-mcp = "app.mcp.server:run_mcp_server"
|
||||
huibrain = "app.main:app"
|
||||
huibrain-mcp = "app.mcp.server:run_mcp_server"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
|
||||
@@ -7,9 +7,6 @@ python-multipart>=0.0.18
|
||||
# ── 数据库 ──
|
||||
sqlalchemy[asyncio]>=2.0.36,<3.0.0
|
||||
aiosqlite>=0.20.0,<1.0.0
|
||||
asyncpg>=0.30.0,<1.0.0
|
||||
alembic>=1.14.0,<2.0.0
|
||||
pgvector>=0.3.6,<1.0.0
|
||||
|
||||
# ── 嵌入模型 SDK ──
|
||||
openai>=1.58.0,<2.0.0
|
||||
|
||||
129
sql/init.sql
129
sql/init.sql
@@ -1,129 +0,0 @@
|
||||
-- ============================================================
|
||||
-- EduBrain 数据库初始化脚本
|
||||
-- 包含 pgvector 扩展、zhparser 中文分词扩展
|
||||
-- ============================================================
|
||||
|
||||
-- 创建 pgvector 扩展(向量相似度搜索)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- 创建 zhparser 中文分词扩展(全文搜索)
|
||||
CREATE EXTENSION IF NOT EXISTS zhparser;
|
||||
|
||||
-- 创建基于 zhparser 的中文全文搜索配置
|
||||
CREATE TEXT SEARCH CONFIGURATION chinese_zh (PARSER = zhparser);
|
||||
|
||||
-- 配置中文全文搜索映射:名词、动词、形容词、成语、叹词、习语使用 simple 字典
|
||||
ALTER TEXT SEARCH CONFIGURATION chinese_zh ADD MAPPING FOR n,v,a,i,e,l WITH simple;
|
||||
|
||||
-- ============================================================
|
||||
-- 知识页面表
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS knowledge_pages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
source_file VARCHAR(500),
|
||||
course_name VARCHAR(200),
|
||||
teacher_name VARCHAR(100),
|
||||
live_date VARCHAR(20),
|
||||
page_number INTEGER,
|
||||
metadata_json TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_course ON knowledge_pages(course_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_teacher ON knowledge_pages(teacher_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_date ON knowledge_pages(live_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_created ON knowledge_pages(created_at DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- 知识分块表(向量检索单元)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_id INTEGER NOT NULL REFERENCES knowledge_pages(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding vector(1024), -- 嵌入向量(默认 1024 维)
|
||||
search_vector TSVECTOR, -- 中文全文搜索向量
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_page ON knowledge_chunks(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_page_index ON knowledge_chunks(page_id, chunk_index);
|
||||
|
||||
-- HNSW 向量索引(适合高维向量,查询速度快)
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_embedding
|
||||
ON knowledge_chunks
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 200);
|
||||
|
||||
-- GIN 全文搜索索引
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_search
|
||||
ON knowledge_chunks
|
||||
USING gin(search_vector);
|
||||
|
||||
-- ============================================================
|
||||
-- OCR 图片记录表
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS ocr_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
ocr_text TEXT,
|
||||
confidence FLOAT,
|
||||
provider VARCHAR(50),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ocr_status ON ocr_images(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ocr_created ON ocr_images(created_at DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- updated_at 自动更新触发器
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 为所有表添加 updated_at 触发器
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'tr_pages_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER tr_pages_updated_at
|
||||
BEFORE UPDATE ON knowledge_pages
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'tr_chunks_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER tr_chunks_updated_at
|
||||
BEFORE UPDATE ON knowledge_chunks
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'tr_ocr_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER tr_ocr_updated_at
|
||||
BEFORE UPDATE ON ocr_images
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EduBrain - 知识库管理</title>
|
||||
<title>HuiBrain - 知识库管理</title>
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EduBrain — Stripe-inspired Design System
|
||||
HuiBrain — Stripe-inspired Design System
|
||||
系统字体栈,零外部依赖
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -521,7 +521,7 @@
|
||||
<!-- Logo 区域 -->
|
||||
<div style="padding: 20px 20px 16px; border-bottom: 1px solid #e5edf5;">
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span style="font-size:20px; font-weight:600; color:#061b31; letter-spacing:-0.02em;">EduBrain</span>
|
||||
<span style="font-size:20px; font-weight:600; color:#061b31; letter-spacing:-0.02em;">HuiBrain</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
|
||||
<!-- 底部版本信息 -->
|
||||
<div style="padding:12px 20px; border-top:1px solid #e5edf5; color:#64748d; font-size:11px;">
|
||||
EduBrain v0.1.0
|
||||
HuiBrain v0.1.0
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1041,6 +1041,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业微信机器人 -->
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
|
||||
<h3 class="section-title" style="margin-bottom:0;">企业微信机器人</h3>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span id="bot-status-badge" class="badge badge-pending">检测中</span>
|
||||
<button class="btn btn-secondary" onclick="restartBot()" id="bot-restart-btn" style="font-size:13px; padding:4px 12px;">重启机器人</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">启用机器人</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; padding:6px 0;">
|
||||
<input type="checkbox" id="setting-wework-bot-enabled" onchange="toggleBotEnabled()" style="width:16px; height:16px;">
|
||||
<span style="font-size:13px; color:var(--text-secondary);">启用后服务启动时自动连接</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bot ID</label>
|
||||
<input type="text" id="setting-wework-bot-id" placeholder="企业微信机器人 ID" class="input-field">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Secret</label>
|
||||
<input type="password" id="setting-wework-bot-secret" placeholder="企业微信机器人 Secret" class="input-field">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">服务地址</label>
|
||||
<input type="text" id="setting-huibrain-base-url" value="http://localhost:8765" class="input-field" style="color:var(--text-secondary); font-size:13px;" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片搜索设置 -->
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<h3 class="section-title">图片搜索设置</h3>
|
||||
@@ -1061,6 +1093,14 @@
|
||||
<span style="color:var(--text-secondary); font-size:13px;">篇(1-50)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">OCR 并发识别数量</label>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<input type="number" id="setting-ocr-concurrency" min="1" max="10" value="1"
|
||||
style="width:80px;" class="input-field">
|
||||
<span style="color:var(--text-secondary); font-size:13px;">张(1-10,多用户同时上传时控制并发)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2612,12 +2652,25 @@
|
||||
if (data.judge_batch_size != null) {
|
||||
document.getElementById('setting-judge-batch-size').value = data.judge_batch_size;
|
||||
}
|
||||
if (data.ocr_concurrency != null) {
|
||||
document.getElementById('setting-ocr-concurrency').value = data.ocr_concurrency;
|
||||
}
|
||||
// 企业微信机器人
|
||||
document.getElementById('setting-wework-bot-enabled').checked = data.wework_bot_enabled;
|
||||
document.getElementById('setting-wework-bot-id').value = data.wework_bot_id || '';
|
||||
// secret 只显示脱敏值作为 placeholder
|
||||
const secretInput = document.getElementById('setting-wework-bot-secret');
|
||||
if (data.wework_bot_secret_masked) {
|
||||
secretInput.placeholder = '当前: ' + data.wework_bot_secret_masked;
|
||||
}
|
||||
} catch (_) {
|
||||
// 静默处理
|
||||
}
|
||||
// 初始化字段显示状态
|
||||
toggleEmbedFields();
|
||||
toggleOcrFields();
|
||||
// 加载机器人状态
|
||||
loadBotStatus();
|
||||
}
|
||||
|
||||
/** 保存设置 */
|
||||
@@ -2631,6 +2684,16 @@
|
||||
if (!isNaN(judgeBatchSize) && judgeBatchSize >= 1 && judgeBatchSize <= 50) {
|
||||
body.judge_batch_size = judgeBatchSize;
|
||||
}
|
||||
const ocrConcurrency = parseInt(document.getElementById('setting-ocr-concurrency').value);
|
||||
if (!isNaN(ocrConcurrency) && ocrConcurrency >= 1 && ocrConcurrency <= 10) {
|
||||
body.ocr_concurrency = ocrConcurrency;
|
||||
}
|
||||
// 企业微信机器人
|
||||
body.wework_bot_enabled = document.getElementById('setting-wework-bot-enabled').checked;
|
||||
const botId = document.getElementById('setting-wework-bot-id').value.trim();
|
||||
if (botId) body.wework_bot_id = botId;
|
||||
const botSecret = document.getElementById('setting-wework-bot-secret').value.trim();
|
||||
if (botSecret) body.wework_bot_secret = botSecret;
|
||||
if (Object.keys(body).length === 0) {
|
||||
showToast('没有需要保存的更改', 'info');
|
||||
return;
|
||||
@@ -2643,6 +2706,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载机器人状态 */
|
||||
async function loadBotStatus() {
|
||||
try {
|
||||
const data = await api('GET', '/settings/bot/status');
|
||||
const badge = document.getElementById('bot-status-badge');
|
||||
if (data.running) {
|
||||
badge.className = 'badge badge-completed';
|
||||
badge.textContent = '运行中';
|
||||
} else if (data.configured) {
|
||||
badge.className = 'badge badge-failed';
|
||||
badge.textContent = '已停止';
|
||||
} else {
|
||||
badge.className = 'badge badge-pending';
|
||||
badge.textContent = '未配置';
|
||||
}
|
||||
} catch (_) {
|
||||
document.getElementById('bot-status-badge').className = 'badge badge-pending';
|
||||
document.getElementById('bot-status-badge').textContent = '未知';
|
||||
}
|
||||
}
|
||||
|
||||
/** 重启企业微信机器人 */
|
||||
async function restartBot() {
|
||||
const btn = document.getElementById('bot-restart-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '重启中...';
|
||||
try {
|
||||
const result = await api('POST', '/settings/bot/restart');
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
setTimeout(() => loadBotStatus(), 2000);
|
||||
} catch (err) {
|
||||
showToast('重启失败: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '重启机器人';
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换机器人启用状态 */
|
||||
function toggleBotEnabled() {
|
||||
// 仅切换 UI 状态,实际保存需要点"保存设置"
|
||||
}
|
||||
|
||||
/** 测试嵌入模型连接 */
|
||||
async function testEmbedding() {
|
||||
showToast('正在测试嵌入模型连接...', 'info');
|
||||
|
||||
Reference in New Issue
Block a user