feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)

- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 15:53:45 +08:00
parent cb0c2fcafe
commit 47c6a1258e
21 changed files with 529 additions and 24 deletions

View File

@@ -212,4 +212,5 @@ samples/
- 2026-11-04:用户抽查第二批复核 28 条,全部同意(两批合计 45 条)。
- 2026-11-04:查标第一轮改进:候选句带同一行的单元格、提示词 check.match v2。开发集 17 份上召回 30% → 50%,带标志词的问题 2% → 54%,误报略降,输入 token +44%;漏响应和响应对象错位变差(`docs/architecture/check-eval.md` §3)。评测集修了待修项、加了 3 份低密度投标文件,第三批复核 55 条、用户抽查 11 条全部同意。
- 2026-11-04:查标第二轮:评测集纠错(漏响应、错位的「变差」多是生成器只改了一处);新增响应方式识别(C12,规则优先)和「这条要不要投标人回答」的判断。开发集 17 份上规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32;证明材料缺失几乎查不出(扫描件)。第四批复核 45 条,用户抽查 9 条全部同意(四批共 65 条)。见 `docs/architecture/check-eval.md` §4。
- 下一步:扫描件兜底和文件要在(C10);响应方式存进清单并可改;查标第 2 步的投标侧结构识别和规则。
- 2026-11-04:查标第三轮:文件要在(按标题找文件名,不调模型)、扫描件兜底(C10,没找到的证明材料改为提示级「可能在扫描件里,请人工核对」)、响应方式存进清单并在清单页显示、可改(C12 的人工确认点,迁移 0007、0008)。开发集上规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32);证明材料缺失仍查不出,要靠 OCR / 多模态(S5)。见 `docs/architecture/check-eval.md` §5。
- 下一步:查标第 2 步的投标侧结构识别和规则检查(报价形式、形式要件)。

View File

@@ -9,7 +9,7 @@
*/
import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery } from "@tanstack/vue-query";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
@@ -23,6 +23,7 @@ import { api, errorTitle } from "@/lib/api";
import { useChecklistOps, useDeletedItems, type ItemFields } from "@/lib/checklistOps";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import { toast } from "@/lib/toast";
@@ -108,6 +109,29 @@ function openCitation(c: Citation, tail: Citation[] = []) {
target.value = { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256, more };
}
const openGroup = (g: CitationGroup) => openCitation(g.head, g.tail);
/* ---------- 响应方式(C12):招标文件要求逐条响应,还是只列偏离、没列出的视为接受;查标按它判没找到响应算不算问题 ---------- */
const qc = useQueryClient();
const MODE_CATEGORY: Record<string, string> = { technical: "技术要求", commercial: "商务要求" };
const MODE_STATUS: Record<string, string> = { unknown: "招标文件没写明,按逐条响应处理", conflict: "招标文件两处写法不一致,按逐条响应处理" };
const modeBusy = ref("");
async function setMode(category: "technical" | "commercial", mode: "explicit" | "implicit") {
modeBusy.value = category;
try {
await unwrap(
api.PUT("/projects/{project_id}/checklist/response-modes/{category}", {
params: { path: { project_id: props.projectId, category } },
body: { mode },
}),
);
await qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
toast(`已改为「${mode === "implicit" ? "只列偏离,没列出的视为接受" : "逐条响应"}」,下次检查按这个判断`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
modeBusy.value = "";
}
}
function select(i: ChecklistItem, opt: { scroll?: boolean } = {}) {
if (picking.value && picking.value.item.id !== i.id) cancelPick();
selId.value = i.id;
@@ -266,6 +290,26 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</div>
</div>
<div v-if="cl?.response_modes?.length" class="modes" aria-label="招标文件的响应方式">
<span class="cap muted">响应方式</span>
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
<select
:id="`mode-${m.category}`"
class="input sm"
:value="m.mode"
:disabled="readOnly || modeBusy === m.category"
@change="setMode(m.category, ($event.target as HTMLSelectElement).value as 'explicit' | 'implicit')"
>
<option value="explicit">逐条响应</option>
<option value="implicit">只列偏离,没列出的视为接受</option>
</select>
<button v-if="m.citations.length" type="button" class="plain cap" @click="openCitation(m.citations[0]!)">依据 · 第 {{ m.citations[0]!.page_no }} 页</button>
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
</div>
</div>
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ w.message }}</p>
@@ -423,6 +467,31 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
<style scoped>
/* 响应方式(C12):清单栏下面一行,每类一个选择框 */
.modes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-6);
margin-bottom: var(--sp-3);
}
.modes .mode {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.modes label {
font-size: var(--fs-caption);
color: var(--text-secondary);
white-space: nowrap;
}
.modes .sign {
margin: 0;
white-space: nowrap;
}
.modes > .cap {
white-space: nowrap;
}
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar {
display: flex;

View File

@@ -128,9 +128,23 @@
**一处要说明的**:调响应方式识别时,打印结果里顺带出现了留出集 S03、S06、S09(招标文件本身,不是投标文件)的识别结果,其中 S03、S09 和规范 §2 的记录不一致。没有据此改任何规则,留到留出集正式评测时再看。
## 5. 下一步
## 5. 第三轮:扫描件兜底、文件要在、响应方式存进清单(2026-11-04)
1. 证明材料和格式文件:扫描件兜底(C10)、文件要在(presence)按文件在不在判断。
2. 响应方式存进清单、清单页显示并可改(C12 的人工确认点)。
3. 第 2 步(投标侧结构识别、规则、C12 响应方式抽取)和第 3 步(整行上下文、偏离表直取、presence 判断)按 §2.2 的顺序改,每改一轮在开发集 14 份上重跑对比。
**改了什么**:
- **文件要在**(`check/presence.py`):「提供《授权委托书》」「须提交投标函」这类要求,从原文取出文件名,在投标文件里找写着它的标题;找到就算已响应(出处是那个标题),不调模型。
- **扫描件兜底**(C10):要附证明材料的要求没找到、而投标文件里有扫描件页时,不报「未找到响应」,改成提示级「可能在扫描件里,请人工核对」。没有 OCR 时,扫描件里有没有这份材料分不出来。
- **响应方式存进清单**(迁移 0008):解析时识别、带依据句子存下来;清单页显示,可以改(人工确认点,写审计,重新解析不覆盖人工改过的);查标用存下来的。
| 指标(17 份) | 上一轮(mode) | 本轮(scan) |
|---|---|---|
| 查标召回 | 98 / 150(65%) | 92 / 150(61%) |
| 致命级召回 | 22 / 32 | 22 / 32 |
| 规矩的投标文件报出的问题 | 229(致命 120、重要 58、提示 51) | 235(**致命 51**、重要 14、提示 170) |
| 规矩响应的要求上的误报 | 12.4 / 100 | 13.5 / 100 |
**读法**:问题的个数没变,变的是级别:规矩的投标文件上的致命问题少了一半多(120 → 51),多出来的是「请到扫描件里核对」的提示,致命级召回不变。总召回掉了 4 个点,主要是批次变化带来的波动(文件要在的条目不再进模型,同一批里的组合变了),以及几条漏响应被「标题里出现了文件名」误判为已响应——要盯着。评测集里证明材料全是扫描件页,所以证明材料缺失(D02)照旧查不出、扫描件兜底也分不出缺没缺;真正查出来要靠 OCR / 多模态(S5)。
## 6. 下一步
1. 第 2 步(投标侧结构识别、规则、C12 响应方式抽取)和第 3 步(整行上下文、偏离表直取、presence 判断)按 §2.2 的顺序改,每改一轮在开发集 14 份上重跑对比。
3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。

View File

@@ -1171,6 +1171,34 @@ paths:
description: '`checklist_empty` 或 `project_read_only`'
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
/projects/{project_id}/checklist/response-modes/{category}:
parameters:
- $ref: '#/components/parameters/ProjectId'
- name: category
in: path
required: true
schema: { type: string, enum: [technical, commercial] }
put:
tags: [checklist]
operationId: setResponseMode
summary: 改招标文件某一类要求的响应方式(C12 的人工确认点)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [mode]
properties:
mode: { type: string, enum: [explicit, implicit] }
responses:
'200':
description: 返回清单概况
content:
application/json:
schema: { $ref: '#/components/schemas/Checklist' }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
/projects/{project_id}/checklist/versions:
parameters:
- $ref: '#/components/parameters/ProjectId'
@@ -2469,6 +2497,10 @@ components:
type: object
required: [status, version_no, counts, marking_rules]
properties:
response_modes:
type: array
description: 招标文件对技术、商务要求的响应方式(C12)。识别出来的(origin = rule)可以改(origin = human,人工确认点);查标按它判没找到响应算不算问题
items: { $ref: '#/components/schemas/ResponseMode' }
status: { type: string, enum: [draft, confirmed] }
version_no: { type: integer, description: 最近一次确认的版本号;没确认过为 0 }
confirmed_by:
@@ -2863,6 +2895,25 @@ components:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
ResponseMode:
type: object
required: [category, mode, status, origin, citations]
properties:
category: { type: string, enum: [technical, commercial] }
mode: { type: string, enum: [explicit, implicit], description: 逐条响应 / 默认接受(只列偏离) }
status: { type: string, enum: [found, conflict, unknown], description: 写明了 / 两处写法冲突(按逐条处理)/ 没写明(按逐条处理) }
origin: { type: string, enum: [rule, human] }
citations:
type: array
items: { $ref: '#/components/schemas/Citation' }
set_by:
oneOf:
- $ref: '#/components/schemas/UserRef'
- type: 'null'
set_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
CheckItemResult:
type: object
required: [checklist_item_id, verdict, layer, evidence_status, bid_citations, detail, reused]
@@ -2871,11 +2922,12 @@ components:
title: { type: [string, 'null'] }
verdict:
type: string
enum: [responded, not_found, negative_deviation, suspected_unmet, no_value, undetermined, scanned]
enum: [responded, not_found, negative_deviation, suspected_unmet, no_value, undetermined, scanned, implicit_accepted, implicit_review]
description: |
已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对。
已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对 /
按招标文件默认接受(未列入偏离表的视为接受,不出问题)/ 默认接受的实质性要求、请人工核对(C12)。
没有校验通过的投标侧证据,不会是 responded(架构 §3.6)
layer: { type: string, enum: [deviation_table, rule_compare, model, retrieval], description: 结论来自哪一层(m3-plan §2.1) }
layer: { type: string, enum: [deviation_table, rule_compare, model, retrieval, presence], description: 结论来自哪一层(m3-plan §2.1;presence 是按文件在不在判断) }
evidence_status: { type: string, enum: [verified, unverified, none] }
reason_code: { type: [string, 'null'], description: 证据没通过、漏答、模型错误等的原因码 }
bid_citations:

View File

@@ -60,6 +60,7 @@ def main() -> None:
"| 分类 | 召回 |",
"|---|---|",
*[f"| {k} | {pct(rec[k], tot[k])} |" for k in sorted(tot) if k not in ("查标", "端到端", "致命级")],
f"| 召回里只是「扫描件待人工核对」的 | {rec['其中只是扫描件待人工核对']} |",
"",
f"- 规矩的投标文件:清单 {items_clean} 条,报出问题 {findings_clean} 个;其中规矩响应的要求上 {fp}/{ok}(每 100 条 {100 * fp / ok:.1f})"
if ok

View File

@@ -129,7 +129,15 @@ async def main() -> None:
docs = []
for n, pdf in enumerate(sorted(d.glob("v*.pdf")), start=1):
pages = text_layer.extract(f"{args.bid}-{pdf.stem}", pdf.read_bytes())
docs.append(match.BidDoc(uuid4(), n, pdf.stem, text_layer.body_sentences(pages)))
docs.append(
match.BidDoc(
uuid4(),
n,
pdf.stem,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
)
)
ai_service.run_json = baseline_run_json if args.baseline else cached_run_json # type: ignore[assignment]
if args.v1:
match.PROMPT = ("check.match", 1)
@@ -148,6 +156,7 @@ async def main() -> None:
results, stats = await match.run(ctx, uuid4(), items, docs, progress)
if decisions:
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, docs)
rows = []
for item_id, item in all_items.items():
if item_id not in items:

View File

@@ -5,6 +5,7 @@
- 清单项 → 标注单元:清单项的招标侧句子和单元的句子有重叠就对上(和读标评分同一口径);
- 按单元去重:同一要求在清单里有几条,只要一条报出就算召回,几条都报不算误报;
- 问题类型按类型族比(D01 / D02 / D09 / D11 / D13 → unresponded,D03 / D07 → suspected_unmet,……);
证明材料类(D02 / D11 / D13)报成「可能在扫描件里,请人工核对」也算标出来了(C10),另外单独计数;
- 只算复核过的问题(决策 E1);--include-unreviewed 时也算没复核的,报告里标明;
- 读标没抽到的问题单列「读标漏抽」:不算查标漏报,但计入端到端召回;
- 对不上任何标注单元的问题单列「无法对照」。
@@ -27,10 +28,10 @@ import gold # noqa: E402
OUT = EVALS / "out" / "check"
FAMILY = {
"D01": {"unresponded"},
"D02": {"unresponded"},
"D02": {"unresponded", "manual_review"},
"D09": {"unresponded"},
"D11": {"unresponded"},
"D13": {"unresponded"},
"D11": {"unresponded", "manual_review"},
"D13": {"unresponded", "manual_review"},
"D16": {"unresponded"},
"D03": {"suspected_unmet"},
"D07": {"suspected_unmet"},
@@ -122,6 +123,8 @@ def main() -> None:
if good:
for k in [*keys, "端到端", "查标"]:
rec[k] += 1
if all(it["finding"]["type"] == "manual_review" for it in good):
rec["其中只是扫描件待人工核对"] += 1
if fatal:
sev_n += 1
sev_ok += any(it["finding"]["severity"] == "fatal" for it in good)

View File

@@ -826,6 +826,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/checklist/response-modes/{category}": {
parameters: {
query?: never;
header?: never;
path: {
project_id: components["parameters"]["ProjectId"];
category: "technical" | "commercial";
};
cookie?: never;
};
get?: never;
/** 改招标文件某一类要求的响应方式(C12 的人工确认点) */
put: operations["setResponseMode"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/checklist/versions": {
parameters: {
query?: never;
@@ -1783,6 +1803,8 @@ export interface components {
citation: components["schemas"]["Citation"];
};
Checklist: {
/** @description 招标文件对技术、商务要求的响应方式(C12)。识别出来的(origin = rule)可以改(origin = human,人工确认点);查标按它判没找到响应算不算问题 */
response_modes?: components["schemas"]["ResponseMode"][];
/** @enum {string} */
status: "draft" | "confirmed";
/** @description 最近一次确认的版本号;没确认过为 0 */
@@ -2059,21 +2081,41 @@ export interface components {
resolved_by?: components["schemas"]["UserRef"] | null;
resolved_at?: components["schemas"]["Timestamp"] | null;
};
ResponseMode: {
/** @enum {string} */
category: "technical" | "commercial";
/**
* @description 逐条响应 / 默认接受(只列偏离)
* @enum {string}
*/
mode: "explicit" | "implicit";
/**
* @description 写明了 / 两处写法冲突(按逐条处理)/ 没写明(按逐条处理)
* @enum {string}
*/
status: "found" | "conflict" | "unknown";
/** @enum {string} */
origin: "rule" | "human";
citations: components["schemas"]["Citation"][];
set_by?: components["schemas"]["UserRef"] | null;
set_at?: components["schemas"]["Timestamp"] | null;
};
CheckItemResult: {
checklist_item_id: components["schemas"]["Id"];
title?: string | null;
/**
* @description 已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对。
* @description 已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对 /
* 按招标文件默认接受(未列入偏离表的视为接受,不出问题)/ 默认接受的实质性要求、请人工核对(C12)。
* 没有校验通过的投标侧证据,不会是 responded(架构 §3.6)
*
* @enum {string}
*/
verdict: "responded" | "not_found" | "negative_deviation" | "suspected_unmet" | "no_value" | "undetermined" | "scanned";
verdict: "responded" | "not_found" | "negative_deviation" | "suspected_unmet" | "no_value" | "undetermined" | "scanned" | "implicit_accepted" | "implicit_review";
/**
* @description 结论来自哪一层(m3-plan §2.1)
* @description 结论来自哪一层(m3-plan §2.1;presence 是按文件在不在判断)
* @enum {string}
*/
layer: "deviation_table" | "rule_compare" | "model" | "retrieval";
layer: "deviation_table" | "rule_compare" | "model" | "retrieval" | "presence";
/** @enum {string} */
evidence_status: "verified" | "unverified" | "none";
/** @description 证据没通过、漏答、模型错误等的原因码 */
@@ -3820,6 +3862,37 @@ export interface operations {
};
};
};
setResponseMode: {
parameters: {
query?: never;
header?: never;
path: {
project_id: components["parameters"]["ProjectId"];
category: "technical" | "commercial";
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
mode: "explicit" | "implicit";
};
};
};
responses: {
/** @description 返回清单概况 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Checklist"];
};
};
409: components["responses"]["ProjectReadOnly"];
};
};
listChecklistVersions: {
parameters: {
query?: never;

View File

@@ -0,0 +1,27 @@
"""查标结论的来源加 presence:「文件要在」类要求按文件在不在判断,不调模型。
Revision ID: 0007
"""
from __future__ import annotations
from alembic import op
revision = "0007"
down_revision = "0006"
LAYERS = "'deviation_table', 'rule_compare', 'model', 'retrieval'"
def upgrade() -> None:
op.get_bind().exec_driver_sql(f"""
ALTER TABLE check_item_result DROP CONSTRAINT check_item_result_layer_check;
ALTER TABLE check_item_result ADD CONSTRAINT check_item_result_layer_check CHECK (layer IN ({LAYERS}, 'presence'));
""")
def downgrade() -> None:
op.get_bind().exec_driver_sql(f"""
ALTER TABLE check_item_result DROP CONSTRAINT check_item_result_layer_check;
ALTER TABLE check_item_result ADD CONSTRAINT check_item_result_layer_check CHECK (layer IN ({LAYERS}));
""")

View File

@@ -0,0 +1,22 @@
"""招标文件的响应方式存进清单(决策 C12):[{category, mode, status, origin, citation_ids, set_by, set_at}]。
识别结果(origin = rule)在清单页显示,用户可以改(origin = human,人工确认点);重新解析不覆盖人工改过的。
查标按这里的响应方式判「没找到响应算不算问题」。
Revision ID: 0008
"""
from __future__ import annotations
from alembic import op
revision = "0008"
down_revision = "0007"
def upgrade() -> None:
op.get_bind().exec_driver_sql("ALTER TABLE checklist ADD COLUMN response_modes jsonb NOT NULL DEFAULT '[]'")
def downgrade() -> None:
op.get_bind().exec_driver_sql("ALTER TABLE checklist DROP COLUMN response_modes")

View File

@@ -85,7 +85,11 @@ def spec(result: Result, item: dict[str, Any]) -> Spec | None:
text = "投标文件只写了满足或响应,没有给出要求里要的具体值或材料。"
return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"})
if result.verdict == "scanned":
text = "候选内容所在的页面是扫描件,系统读不出文字,请人工核对。"
return Spec("manual_review", sev, f"请人工核对:{name}", text, base)
total = result.detail.get("scanned_total") or 0
text = (
f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"
"材料可能在扫描件页上,请人工核对。"
)
return Spec("manual_review", "hint", f"请人工核对(可能在扫描件里):{name}", text, base)
why = REASONS.get(result.reason_code or "", result.reason_code or "")
return Spec("manual_review", sev, f"未判定:{name}", f"这条要求本次没有完成判断({why}),请人工核对。", base)

View File

@@ -21,6 +21,7 @@ from uuid import UUID
from bid_doctor.ai import service as ai
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.check import presence
from bid_doctor.check.retrieval import Index
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline.text_layer import Sentence, norm
@@ -51,6 +52,7 @@ class BidDoc:
no: int # 在本次检查里的序号,组成短编号 d<序号>p<页>s<句>
label: str
sentences: list[Sentence]
scanned_pages: list[int] = field(default_factory=list[int]) # 没有文字层的页(扫描件),C10
@dataclass(slots=True)
@@ -86,6 +88,23 @@ def apply_modes(results: dict[UUID, Result], items: dict[UUID, dict[str, Any]],
r.detail = {**r.detail, "mode": "implicit"}
def scanned_fallback(
results: dict[UUID, Result], items: dict[UUID, dict[str, Any]], modes: dict[UUID, Any], docs: list[BidDoc]
) -> None:
"""扫描件兜底(C10):要附证明材料的要求没找到,而投标文件里有扫描件页时,不报「未找到响应」,
改成「可能在扫描件页上,请人工核对」。没有 OCR 时,扫描件里有没有这份材料分不出来。"""
pages = [f"{d.label} 第 {p} 页" for d in docs for p in d.scanned_pages]
if not pages:
return
for item_id, d in modes.items():
r = results.get(item_id)
cats = set(items.get(item_id, {}).get("categories") or [])
if r is None or r.verdict != "not_found" or not (d.evidence or cats & {"qualification", "scoring"}):
continue
r.verdict, r.evidence_status, r.reason_code = "scanned", "none", None
r.detail = {**r.detail, "scanned_pages": pages[:30], "scanned_total": len(pages)}
def short_sid(doc: BidDoc, s: Sentence) -> str:
return f"d{doc.no}p{s.page_no}s{s.seq}"
@@ -217,6 +236,11 @@ async def run(
} # 片段按编号查(同一行的单元格可能是别的条目的候选句)
hits_of: dict[UUID, list[str]] = {} # 每条清单项自己的候选句(同一行的单元格跟在后面显示,不单列)
for item_id, item in items.items():
wanted = presence.names(f"{item.get('title') or ''}{item.get('requirement_text') or ''}")
found = [(d, x) for n in wanted for d in docs if (x := presence.find(n, d.sentences))]
if wanted and len({id(x) for _, x in found}) >= len(wanted): # 点名要交的文件都在:按文件在不在判断,不调模型
results[item_id] = Result("responded", "presence", "verified", evidence=found[: len(wanted)])
continue
hits = index.search(query_text(item), CANDIDATES)
if not hits: # 检索不到候选句:不调模型(m3-plan §2.1)
results[item_id] = Result("not_found", "retrieval", detail={"search_scope": scope})

View File

@@ -0,0 +1,31 @@
"""「文件要在」类要求(presence):「提供《授权委托书》」「须提交投标函」——按文件在不在判断,不调模型(C12、C10)。
从要求原文里取出文件名,在投标文件里找写着这个文件名的句子(通常是标题,短句优先)。找到就算已响应,出处是那一句。
找不到交给后面的检索 + 模型判断;扫描件里的文件(营业执照、证书复印件)这里找不到,由扫描件兜底处理。
"""
from __future__ import annotations
import re
from bid_doctor.document.pipeline.text_layer import Sentence, norm
QUOTED = re.compile(r"《([^》]{2,30})》")
NAMED = re.compile(
r"(?:提供|提交|附|出具)(?:加盖[^,。;]{0,10}的)?([一-鿿()()]{2,16}?(?:函|书|声明|承诺|一览表|明细表|偏离表|响应表))"
)
MAX_TITLE_LEN = 60 # 标题一般不长;正文里顺带提到文件名不算这份文件在
def names(text: str) -> list[str]:
"""要求里点名要交的文件。"""
found = [m.group(1) for m in QUOTED.finditer(text)] or [m.group(1) for m in NAMED.finditer(text)]
return list(dict.fromkeys(re.sub(r"[((].*?[))]", "", n).strip() for n in found if n))
def find(name: str, sentences: list[Sentence]) -> Sentence | None:
key = norm(name)
if len(key) < 3:
return None
hits = [s for s in sentences if key in norm(s.text) and len(norm(s.text)) <= MAX_TITLE_LEN]
return min(hits, key=lambda s: len(s.text)) if hits else None

View File

@@ -119,7 +119,15 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
for no, d in enumerate(docs, start=1):
pages = await normalize.pages(ctx, d.id, on_stage, reuse=True, require_text=False)
label = document_label(d.role, d.seq_no, d.volume_name, d.volume_kind)
bid_docs.append(match.BidDoc(d.id, no, label, text_layer.body_sentences(pages)))
bid_docs.append(
match.BidDoc(
d.id,
no,
label,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
)
)
pages_info.append(
{
"id": str(d.id),
@@ -138,6 +146,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
snapshot = await checklist_edit.version_items(s, r.checklist_version_id)
lots = await projects.lot_labels(s, r.project_id)
pending = (await checklist.pending_counts(s, [r.project_id])).get(r.project_id, 0)
stored_modes = list((await checklist.get_or_create(s, ctx, r.project_id)).response_modes or [])
tenders = (
await s.execute(
select(SourceDocument).where(
@@ -154,7 +163,8 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
[text_layer.Page.from_json(pg) for pg in await documents.text_layer(s, ctx, d)]
)
]
modes = response_mode.detect(tender_sents) # 招标文件按类别的响应方式(C12)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
items, other_lot = {}, 0
for item_id, payload in snapshot.items():
@@ -179,6 +189,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
results, stats = await match.run(ctx, run_id, items, bid_docs, progress)
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, bid_docs)
await _stage(ctx, run_id, "rules") # 第 2 步:报价形式、形式要件、需人工核对
await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Header, Response
from pydantic import BaseModel, ConfigDict, Field
from bid_doctor.checklist import edit, service
from bid_doctor.checklist import edit, response_mode, service
from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects
@@ -143,3 +143,20 @@ async def list_versions(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await projects.get(s, ctx, project_id)
return {"items": await edit.versions(s, project_id)}
class ResponseModeIn(BaseModel):
mode: Literal["explicit", "implicit"]
@router.put("/projects/{project_id}/checklist/response-modes/{category}")
async def set_response_mode(
project_id: UUID, category: Literal["technical", "commercial"], body: ResponseModeIn, ctx: Ctx
) -> dict[str, Any]:
"""改招标文件某一类要求的响应方式(C12 的人工确认点):查标按它判「没找到响应算不算问题」。"""
async with tenant_tx(ctx) as s:
projects.ensure_editable(await projects.get(s, ctx, project_id))
cl = await service.get_or_create(s, ctx, project_id)
await response_mode.set_mode(s, ctx, cl, category, body.mode)
await s.flush()
return await service.overview(s, project_id)

View File

@@ -17,6 +17,7 @@ class Checklist(Base):
status: Mapped[str] = mapped_column(server_default=FetchedValue())
last_version_no: Mapped[int] = mapped_column(server_default=FetchedValue())
confirmed_version_id: Mapped[UuidCol | None]
response_modes: Mapped[Json] = mapped_column(server_default=FetchedValue())
updated_at: Mapped[DbTs]

View File

@@ -5,14 +5,24 @@
「其余无偏离内容无需赘述」;
- 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。
规则优先、纯函数;结果带原文句子,查标写进检查覆盖范围。
TODO(C12):存进清单、在清单页显示并可改(人工确认点);模型兜底规则没认出的写法。
解析时识别并存进清单(checklist.response_modes),清单页显示、可改(人工确认点);查标用存下来的。
TODO(C12):模型兜底规则没认出的写法。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.core.db import TenantContext
from bid_doctor.document import citations
from bid_doctor.document.models import SourceDocument
from bid_doctor.document.pipeline.text_layer import Sentence
EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)")
@@ -61,3 +71,64 @@ def detect(sentences: list[Sentence]) -> dict[str, Mode]:
elif m.explicit:
m.mode, m.status = "explicit", "found"
return hits
MAX_BASIS = 2 # 每类每种写法存几句依据
async def save(s: AsyncSession, ctx: TenantContext, cl: Any, doc: SourceDocument, sentences: list[Sentence]) -> None:
"""解析完一份招标文件后识别并存进清单。人工改过的类别不覆盖;补遗、澄清只在写明了响应方式时更新。"""
current = {m["category"]: m for m in cl.response_modes or []}
for c, m in detect(sentences).items():
old = current.get(c)
if old and old.get("origin") == "human":
continue
if doc.role != "tender" and m.status == "unknown":
continue
cites = []
for group in (m.explicit[:MAX_BASIS], m.implicit[:MAX_BASIS]):
for x in group:
cites += await citations.from_sentences(s, ctx, doc, [x], "rule", [])
current[c] = {
"category": c,
"mode": m.mode,
"status": m.status,
"origin": "rule",
"citation_ids": [str(x.id) for x in cites],
}
cl.response_modes = [current[c] for c in CATEGORIES if c in current]
def from_stored(stored: list[dict[str, Any]]) -> dict[str, Mode]:
"""查标用:存下来的响应方式(人工改过的为准)。"""
return {
m["category"]: Mode(
m["category"], m["mode"], "human" if m.get("origin") == "human" else m.get("status", "found")
)
for m in stored
}
async def set_mode(s: AsyncSession, ctx: TenantContext, cl: Any, category: str, mode: str) -> None:
"""用户改某一类的响应方式(人工确认点):记谁在什么时候改的,写审计。"""
current = {m["category"]: m for m in cl.response_modes or []}
before = current.get(category)
current[category] = {
**(before or {"category": category, "citation_ids": []}),
"mode": mode,
"status": "found",
"origin": "human",
"set_by": str(ctx.user_id),
"set_at": datetime.now(UTC).isoformat(),
}
cl.response_modes = [current[c] for c in CATEGORIES if c in current]
await audit.record(
s,
ctx,
"checklist.response_mode",
"checklist",
UUID(str(cl.id)),
project_id=cl.project_id,
before={"category": category, "mode": before.get("mode") if before else None},
after={"category": category, "mode": mode},
)

View File

@@ -208,7 +208,24 @@ async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
for r, *_ in rules
if r.declared_count is not None and r.declared_count != found_by_rule.get(r.id, 0)
]
stored_modes = list(cl.response_modes or []) if cl else []
mode_cites = await citations.to_api_many(s, [UUID(c) for m in stored_modes for c in m.get("citation_ids") or []])
mode_names = await identity.display_names(s, [UUID(m["set_by"]) for m in stored_modes if m.get("set_by")])
return {
"response_modes": [
{
"category": m["category"],
"mode": m["mode"],
"status": m.get("status", "found"),
"origin": m.get("origin", "rule"),
"citations": [mode_cites[UUID(c)] for c in m.get("citation_ids") or [] if UUID(c) in mode_cites],
"set_by": {"id": m["set_by"], "name": mode_names.get(UUID(m["set_by"]), "")}
if m.get("set_by")
else None,
"set_at": m.get("set_at"),
}
for m in stored_modes
],
"status": cl.status if cl else "draft",
"version_no": cl.last_version_no if cl else 0,
"confirmed_by": {"id": version.created_by, "name": names.get(version.created_by, "")} if version else None,

View File

@@ -18,7 +18,7 @@ from sqlalchemy import select
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing
from bid_doctor.checklist import extract
from bid_doctor.checklist import extract, response_mode
from bid_doctor.checklist import service as checklist
from bid_doctor.core import events
from bid_doctor.core.db import TenantContext, tenant_tx
@@ -138,6 +138,8 @@ async def _stages(ctx: TenantContext, job_id: UUID) -> dict[str, Any]:
d = (await s.execute(select(SourceDocument).where(SourceDocument.id == doc.id))).scalar_one()
lots = await project_extracted.apply(s, ctx, d, info, extract.legal_regime_by_rule(body), items)
stats = await checklist.merge(s, ctx, d, job_id, items, notes, lots)
cl = await checklist.get_or_create(s, ctx, d.project_id)
await response_mode.save(s, ctx, cl, d, body) # 招标文件的响应方式(C12),清单页显示、可改
return {
**stats,
"lots_found": len([k for k in lots if k != "默认"]),

View File

@@ -251,3 +251,24 @@ async def test_other_tenant_cannot_see_check(app_client: httpx.AsyncClient) -> N
assert (await b.post(f"/findings/{f['id']}/resolve", json={"status": "modified"})).status_code == 404
assert (await b.post(f"/findings/{f['id']}/reopen")).status_code == 404
assert (await b.post(f"/check-runs/{run['id']}/cancel")).status_code == 404
async def test_response_modes_are_stored_editable_and_used(user: User) -> None:
"""响应方式(C12):解析时存进清单;用户改成「默认接受」后,查标对没复述的一般要求不再报问题。"""
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
modes = {m["category"]: m for m in (await user.get(f"/projects/{project_id}/checklist")).json()["response_modes"]}
assert set(modes) == {"technical", "commercial"}
assert modes["technical"]["mode"] == "explicit" and modes["technical"]["status"] == "unknown"
r = await user.put(f"/projects/{project_id}/checklist/response-modes/technical", json={"mode": "implicit"})
assert r.status_code == 200, r.text
tech = next(m for m in r.json()["response_modes"] if m["category"] == "technical")
assert tech["origin"] == "human" and tech["mode"] == "implicit" and tech["set_by"]["name"] == "张工"
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
run = await run_check(user, project_id, [bid["id"]])
# 漏掉的 BFD 是 ★ 条款:默认接受下不报致命,改为提示级「请人工核对」
(f,) = await findings(user, run["id"])
assert f["type"] == "manual_review" and f["severity"] == "hint"
assert run["counts"]["fatal"] == 0

View File

@@ -131,3 +131,38 @@ def test_implicit_not_found_is_accepted_but_substantive_gets_a_hint() -> None:
assert results[a].verdict == "implicit_accepted" and findings.spec(results[a], items[a]) is None
spec = findings.spec(results[b], items[b])
assert results[b].verdict == "implicit_review" and spec is not None and spec.severity == "hint"
def test_presence_names_and_find() -> None:
from bid_doctor.check import presence
assert presence.names("须提供加盖公章的《法定代表人授权委托书》,否则投标无效") == ["法定代表人授权委托书"]
assert presence.names("投标人须提交投标函及开标一览表") == ["投标函"]
heading = sent(3, "4.法定代表人授权委托书")
body = sent(4, "本授权委托书声明:我单位授权……(这是正文里顺带提到的,很长" + "," * 60 + ")")
assert presence.find("法定代表人授权委托书", [body, heading]) is heading
assert presence.find("营业执照", [heading]) is None
def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
from uuid import uuid4
from bid_doctor.check import applicability
a, b = uuid4(), uuid4()
results = {a: match.Result("not_found", "model"), b: match.Result("not_found", "model")}
items = {
a: {"categories": ["qualification"], "title": "营业执照"},
b: {"categories": ["tech_param"], "title": "内存"},
}
decisions = {a: applicability.Decision(True, evidence=True), b: applicability.Decision(True)}
docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13])]
match.scanned_fallback(results, items, decisions, docs)
spec = findings.spec(results[a], items[a])
assert (
results[a].verdict == "scanned"
and spec is not None
and spec.severity == "hint"
and "2 页扫描件" in spec.explanation
)
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到