feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用

后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 11:48:10 +08:00
parent 6ab42959df
commit e499de7486
56 changed files with 3735 additions and 183 deletions

View File

@@ -156,6 +156,46 @@ def _project_info(lines: list[tuple[str, str, str, str]]) -> dict[str, Any]:
return {"fields": {k: fields.get(k, none) for k in INFO_KEYS}, "lots": []}
ITEM = re.compile(r"^\[(I\d+)\] (.*)$")
FRAGMENT = re.compile(r"^\[(d\d+p\d+s\d+)\]([^)]*)(.*)$")
CJK2 = re.compile(r"[\u4e00-\u9fff]")
MATCH_RATIO = 0.35
def _bigrams(text: str) -> set[str]:
chars = "".join(CJK2.findall(text)) + "".join(re.findall(r"[A-Za-z0-9]+", text)).lower()
return {chars[i : i + 2] for i in range(len(chars) - 1)}
def _check_match(user: str) -> dict[str, Any]:
"""候选句和要求原文的字 2-gram 重合比例够高就判「已响应」,引用那一句;否则「未找到」。"""
head, _, frags = user.partition("## 投标文件片段")
fragments = {m.group(1): m.group(2) for raw in frags.splitlines() if (m := FRAGMENT.match(raw.strip()))}
results: list[dict[str, Any]] = []
key, req, sids = "", "", []
for raw in [*head.splitlines(), "[I0] 结束"]:
line = raw.strip()
if m := ITEM.match(line):
if key:
want = _bigrams(req)
best = max(sids, key=lambda x: len(want & _bigrams(fragments.get(x, ""))), default=None)
hit = best is not None and want and len(want & _bigrams(fragments[best])) / len(want) >= MATCH_RATIO
results.append(
{
"item": key,
"verdict": "responded" if hit else "not_found",
"evidence": [{"sid": best, "quote": fragments[best]}] if hit and best else [],
"response_value": None,
}
)
key, req, sids = m.group(1), m.group(2), []
elif line.startswith("要求原文:"):
req = line.removeprefix("要求原文:")
elif line.startswith("候选句:"):
sids = [x for x in line.removeprefix("候选句:").split("、") if x]
return {"results": results}
TASKS: dict[str, Callable[[list[tuple[str, str, str, str]]], dict[str, Any]]] = {
"extract.substantive": _substantive,
"extract.qualification": _qualification,
@@ -171,5 +211,8 @@ class FakeProvider:
async def complete_json(self, system: str, user: str, capability: str, task: str) -> Completion:
del system, capability
data = TASKS.get(task, lambda _lines: {"items": []})(_lines(user))
if task == "check.match":
data = _check_match(user)
else:
data = TASKS.get(task, lambda _lines: {"items": []})(_lines(user))
return Completion(data=data, raw=json.dumps(data, ensure_ascii=False), model="fake-extractor")