From ee4bb234885aa46fc7792d34827a6963233def64 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Tue, 3 Nov 2026 18:55:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=88=86=E5=9D=97=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E8=B0=83=E7=94=A8=E6=A8=A1=E5=9E=8B=EF=BC=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8F=AF=E4=BB=8E=E4=BB=93=E5=BA=93=E6=A0=B9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=9A=84=20.env=20=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract:分块并发调用(每个任务最多 4 路),结果按分块顺序合并 - config:在 server/ 下运行时也读取仓库根目录的 .env(和 Compose 的 env_file 一致) Co-Authored-By: Claude Opus 5.5 --- server/src/bid_doctor/checklist/extract.py | 32 ++++++++++++++-------- server/src/bid_doctor/core/config.py | 2 +- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/src/bid_doctor/checklist/extract.py b/server/src/bid_doctor/checklist/extract.py index 697cf7b..89c0e87 100644 --- a/server/src/bid_doctor/checklist/extract.py +++ b/server/src/bid_doctor/checklist/extract.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio import re from dataclasses import dataclass, field from typing import Any @@ -17,6 +18,7 @@ from bid_doctor.document.pipeline.text_layer import Sentence, norm PROMPT_ID, PROMPT_VERSION = "extract.substantive", 1 CHUNK_CHARS = 6000 +CONCURRENCY = 4 # 每个解析任务同时调用模型的分块数;TODO(路由表):按「供应商 × 租户」统一限流 INVALID = re.compile(r"投标无效|响应无效|无效投标|作无效处理|按无效|否决其投标|否决投标|不予受理") RULE_NOTE = re.compile(r"(★|*|\*|斜体|下划线).{0,30}(实质性|无效|否决)|(实质性).{0,20}(★|*|\*|斜体|下划线)") DECLARED = re.compile(r"★[^。;]{0,12}?共\s*(\d+)\s*[条项处]") @@ -125,20 +127,28 @@ async def run(ctx: TenantContext, job_id: UUID, sentences: list[Sentence]) -> tu note_keys = {n.sentence.key for n in notes} declared = {n.marker for n in notes} out: list[Extracted] = [] - for chunk in _chunks(sentences): - by_id = {_sid(s): s for s in chunk} + chunks = _chunks(sentences) + gate = asyncio.Semaphore(CONCURRENCY) + + async def call(chunk: list[Sentence]) -> dict[str, Any]: user = ( "## 标注规则说明\n" + note_text + "\n\n## 待审分块\n" + "\n".join(f"[{_sid(s)}] {s.marked}" for s in chunk) ) - data = await ai.run_json( - ctx, - job_type="parse_job", - job_id=job_id, - task="extract.substantive", - prompt_id=PROMPT_ID, - version=PROMPT_VERSION, - user=user, - ) + async with gate: + return await ai.run_json( + ctx, + job_type="parse_job", + job_id=job_id, + task="extract.substantive", + prompt_id=PROMPT_ID, + version=PROMPT_VERSION, + user=user, + ) + + # 分块并发调用模型(架构 §3.4);结果按分块顺序处理,清单顺序和原文一致 + results = await asyncio.gather(*(call(c) for c in chunks)) + for chunk, data in zip(chunks, results, strict=True): + by_id = {_sid(s): s for s in chunk} for it in data.get("items") or []: if not isinstance(it, dict): continue diff --git a/server/src/bid_doctor/core/config.py b/server/src/bid_doctor/core/config.py index 4f56878..98032c3 100644 --- a/server/src/bid_doctor/core/config.py +++ b/server/src/bid_doctor/core/config.py @@ -13,7 +13,7 @@ DEV_SECRET = "dev-insecure-secret-change-me-0000000000000000" # noqa: S105 - class Settings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="BD_", env_file=".env", extra="ignore") + model_config = SettingsConfigDict(env_prefix="BD_", env_file=(".env", "../.env"), extra="ignore") env: Literal["dev", "test", "prod"] = "dev"