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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user