feat: OCR 并发识别控制,支持多用户同时上传
- config.py: 新增 OCR_CONCURRENCY 参数(默认1,范围1-10) - images.py: 用 asyncio.Semaphore 控制 OCR 并发,4个上传端点统一受控 - settings API: 新增 ocr_concurrency 字段读写 - 前端设置页: 新增 OCR 并发数量输入框 - .env / .env.example: 新增 OCR_CONCURRENCY 配置项 - Semaphore 动态重建:修改配置后立即生效,无需重启
This commit is contained in:
@@ -33,6 +33,7 @@ LOCAL_BGE_MODEL_PATH=
|
|||||||
# 可选值: paddleocr / aliyun / tencent / deepseek / auto
|
# 可选值: paddleocr / aliyun / tencent / deepseek / auto
|
||||||
# auto 模式: 本地 PaddleOCR 优先,失败后 fallback 到 DeepSeek / 云端 OCR
|
# auto 模式: 本地 PaddleOCR 优先,失败后 fallback 到 DeepSeek / 云端 OCR
|
||||||
OCR_PROVIDER=deepseek
|
OCR_PROVIDER=deepseek
|
||||||
|
OCR_CONCURRENCY=1
|
||||||
|
|
||||||
# ── 阿里云 OCR 配置 ──
|
# ── 阿里云 OCR 配置 ──
|
||||||
ALIYUN_OCR_ACCESS_KEY=your-aliyun-access-key
|
ALIYUN_OCR_ACCESS_KEY=your-aliyun-access-key
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ from app.services.llm_service import LLMService
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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"}
|
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||||
|
|
||||||
# 全局 BM25 索引实例(在 main.py 中初始化)
|
# 全局 BM25 索引实例(在 main.py 中初始化)
|
||||||
@@ -126,6 +139,7 @@ async def recognize_image(image_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
async with _get_ocr_semaphore():
|
||||||
ocr_result = await OCRService.recognize(image_record.file_path)
|
ocr_result = await OCRService.recognize(image_record.file_path)
|
||||||
_save_ocr_result(image_record, ocr_result, db)
|
_save_ocr_result(image_record, ocr_result, db)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -171,6 +185,7 @@ async def batch_recognize(files: List[UploadFile], db: AsyncSession = Depends(ge
|
|||||||
db.add(image_record)
|
db.add(image_record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
try:
|
try:
|
||||||
|
async with _get_ocr_semaphore():
|
||||||
ocr_result = await OCRService.recognize(str(dest_path))
|
ocr_result = await OCRService.recognize(str(dest_path))
|
||||||
# OCR 内容去重
|
# OCR 内容去重
|
||||||
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
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)
|
db.add(image_record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
try:
|
try:
|
||||||
|
async with _get_ocr_semaphore():
|
||||||
ocr_result = await OCRService.recognize(file_path)
|
ocr_result = await OCRService.recognize(file_path)
|
||||||
# OCR 内容去重
|
# OCR 内容去重
|
||||||
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
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)
|
db.add(image_record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
try:
|
try:
|
||||||
|
async with _get_ocr_semaphore():
|
||||||
ocr_result = await OCRService.recognize(str(dest_path))
|
ocr_result = await OCRService.recognize(str(dest_path))
|
||||||
# OCR 内容去重
|
# OCR 内容去重
|
||||||
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SettingsResponse(BaseModel):
|
|||||||
chunk_overlap: int
|
chunk_overlap: int
|
||||||
search_limit: int
|
search_limit: int
|
||||||
judge_batch_size: int
|
judge_batch_size: int
|
||||||
|
ocr_concurrency: int
|
||||||
# API Key 只返回是否已配置(不返回实际值)
|
# API Key 只返回是否已配置(不返回实际值)
|
||||||
has_minimax_key: bool
|
has_minimax_key: bool
|
||||||
has_deepseek_key: bool
|
has_deepseek_key: bool
|
||||||
@@ -71,6 +72,7 @@ async def get_settings():
|
|||||||
chunk_overlap=settings.CHUNK_OVERLAP,
|
chunk_overlap=settings.CHUNK_OVERLAP,
|
||||||
search_limit=settings.SEARCH_LIMIT,
|
search_limit=settings.SEARCH_LIMIT,
|
||||||
judge_batch_size=settings.JUDGE_BATCH_SIZE,
|
judge_batch_size=settings.JUDGE_BATCH_SIZE,
|
||||||
|
ocr_concurrency=settings.OCR_CONCURRENCY,
|
||||||
has_minimax_key=bool(settings.MINIMAX_API_KEY),
|
has_minimax_key=bool(settings.MINIMAX_API_KEY),
|
||||||
has_deepseek_key=bool(settings.DEEPSEEK_API_KEY),
|
has_deepseek_key=bool(settings.DEEPSEEK_API_KEY),
|
||||||
has_openai_key=bool(settings.OPENAI_API_KEY),
|
has_openai_key=bool(settings.OPENAI_API_KEY),
|
||||||
@@ -110,6 +112,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
chunk_overlap: int | None = None
|
chunk_overlap: int | None = None
|
||||||
search_limit: int | None = None
|
search_limit: int | None = None
|
||||||
judge_batch_size: int | None = None
|
judge_batch_size: int | None = None
|
||||||
|
ocr_concurrency: int | None = None
|
||||||
# 企业微信机器人
|
# 企业微信机器人
|
||||||
wework_bot_enabled: bool | None = None
|
wework_bot_enabled: bool | None = None
|
||||||
wework_bot_id: str | None = None
|
wework_bot_id: str | None = None
|
||||||
@@ -159,6 +162,7 @@ async def update_settings(data: SettingsUpdate):
|
|||||||
"chunk_overlap": "CHUNK_OVERLAP",
|
"chunk_overlap": "CHUNK_OVERLAP",
|
||||||
"search_limit": "SEARCH_LIMIT",
|
"search_limit": "SEARCH_LIMIT",
|
||||||
"judge_batch_size": "JUDGE_BATCH_SIZE",
|
"judge_batch_size": "JUDGE_BATCH_SIZE",
|
||||||
|
"ocr_concurrency": "OCR_CONCURRENCY",
|
||||||
"wework_bot_enabled": "WEWORK_BOT_ENABLED",
|
"wework_bot_enabled": "WEWORK_BOT_ENABLED",
|
||||||
"wework_bot_id": "WEWORK_BOT_ID",
|
"wework_bot_id": "WEWORK_BOT_ID",
|
||||||
"wework_bot_secret": "WEWORK_BOT_SECRET",
|
"wework_bot_secret": "WEWORK_BOT_SECRET",
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ class Settings(BaseSettings):
|
|||||||
SEARCH_LIMIT: int = Field(default=3, description="图片搜索默认返回数量(1-10)")
|
SEARCH_LIMIT: int = Field(default=3, description="图片搜索默认返回数量(1-10)")
|
||||||
JUDGE_BATCH_SIZE: int = Field(default=10, description="每次LLM判断的文章数量(1-50)")
|
JUDGE_BATCH_SIZE: int = Field(default=10, description="每次LLM判断的文章数量(1-50)")
|
||||||
|
|
||||||
|
# ──────────────────────────── OCR ────────────────────────────
|
||||||
|
OCR_CONCURRENCY: int = Field(default=1, ge=1, le=10, description="OCR 并发识别数量(1-10),多用户同时上传时控制并发")
|
||||||
|
|
||||||
# ──────────────────────────── CORS ────────────────────────────
|
# ──────────────────────────── CORS ────────────────────────────
|
||||||
CORS_ORIGINS: list[str] = Field(
|
CORS_ORIGINS: list[str] = Field(
|
||||||
default=["*"],
|
default=["*"],
|
||||||
|
|||||||
@@ -1093,6 +1093,14 @@
|
|||||||
<span style="color:var(--text-secondary); font-size:13px;">篇(1-50)</span>
|
<span style="color:var(--text-secondary); font-size:13px;">篇(1-50)</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2644,6 +2652,9 @@
|
|||||||
if (data.judge_batch_size != null) {
|
if (data.judge_batch_size != null) {
|
||||||
document.getElementById('setting-judge-batch-size').value = data.judge_batch_size;
|
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-enabled').checked = data.wework_bot_enabled;
|
||||||
document.getElementById('setting-wework-bot-id').value = data.wework_bot_id || '';
|
document.getElementById('setting-wework-bot-id').value = data.wework_bot_id || '';
|
||||||
@@ -2673,6 +2684,10 @@
|
|||||||
if (!isNaN(judgeBatchSize) && judgeBatchSize >= 1 && judgeBatchSize <= 50) {
|
if (!isNaN(judgeBatchSize) && judgeBatchSize >= 1 && judgeBatchSize <= 50) {
|
||||||
body.judge_batch_size = judgeBatchSize;
|
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;
|
body.wework_bot_enabled = document.getElementById('setting-wework-bot-enabled').checked;
|
||||||
const botId = document.getElementById('setting-wework-bot-id').value.trim();
|
const botId = document.getElementById('setting-wework-bot-id').value.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user