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:用户抽查第二批复核 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:查标第一轮改进:候选句带同一行的单元格、提示词 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。 - 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 { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue"; 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 { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue"; 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 { useChecklistOps, useDeletedItems, type ItemFields } from "@/lib/checklistOps";
import { groupCitations, type CitationGroup } from "@/lib/citations"; import { groupCitations, type CitationGroup } from "@/lib/citations";
import { useCrumbs } from "@/lib/crumbs"; import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels"; import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries"; import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import { toast } from "@/lib/toast"; 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 }; 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); 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 } = {}) { function select(i: ChecklistItem, opt: { scroll?: boolean } = {}) {
if (picking.value && picking.value.item.id !== i.id) cancelPick(); if (picking.value && picking.value.item.id !== i.id) cancelPick();
selId.value = i.id; selId.value = i.id;
@@ -266,6 +290,26 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</div> </div>
</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"> <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> <span class="ico w" aria-hidden="true">!</span>
<p>{{ w.message }}</p> <p>{{ w.message }}</p>
@@ -423,6 +467,31 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template> </template>
<style scoped> <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) */ /* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar { .cl-bar {
display: flex; display: flex;

View File

@@ -128,9 +128,23 @@
**一处要说明的**:调响应方式识别时,打印结果里顺带出现了留出集 S03、S06、S09(招标文件本身,不是投标文件)的识别结果,其中 S03、S09 和规范 §2 的记录不一致。没有据此改任何规则,留到留出集正式评测时再看。 **一处要说明的**:调响应方式识别时,打印结果里顺带出现了留出集 S03、S06、S09(招标文件本身,不是投标文件)的识别结果,其中 S03、S09 和规范 §2 的记录不一致。没有据此改任何规则,留到留出集正式评测时再看。
## 5. 下一步 ## 5. 第三轮:扫描件兜底、文件要在、响应方式存进清单(2026-11-04)
1. 证明材料和格式文件:扫描件兜底(C10)、文件要在(presence)按文件在不在判断。 **改了什么**:
2. 响应方式存进清单、清单页显示并可改(C12 的人工确认点)。 - **文件要在**(`check/presence.py`):「提供《授权委托书》」「须提交投标函」这类要求,从原文取出文件名,在投标文件里找写着它的标题;找到就算已响应(出处是那个标题),不调模型。
3. 第 2 步(投标侧结构识别、规则、C12 响应方式抽取)和第 3 步(整行上下文、偏离表直取、presence 判断)按 §2.2 的顺序改,每改一轮在开发集 14 份上重跑对比。 - **扫描件兜底**(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 步冻结代码后只跑一次。 3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。

View File

@@ -1171,6 +1171,34 @@ paths:
description: '`checklist_empty` 或 `project_read_only`' description: '`checklist_empty` 或 `project_read_only`'
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } 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: /projects/{project_id}/checklist/versions:
parameters: parameters:
- $ref: '#/components/parameters/ProjectId' - $ref: '#/components/parameters/ProjectId'
@@ -2469,6 +2497,10 @@ components:
type: object type: object
required: [status, version_no, counts, marking_rules] required: [status, version_no, counts, marking_rules]
properties: properties:
response_modes:
type: array
description: 招标文件对技术、商务要求的响应方式(C12)。识别出来的(origin = rule)可以改(origin = human,人工确认点);查标按它判没找到响应算不算问题
items: { $ref: '#/components/schemas/ResponseMode' }
status: { type: string, enum: [draft, confirmed] } status: { type: string, enum: [draft, confirmed] }
version_no: { type: integer, description: 最近一次确认的版本号;没确认过为 0 } version_no: { type: integer, description: 最近一次确认的版本号;没确认过为 0 }
confirmed_by: confirmed_by:
@@ -2863,6 +2895,25 @@ components:
oneOf: oneOf:
- $ref: '#/components/schemas/Timestamp' - $ref: '#/components/schemas/Timestamp'
- type: 'null' - 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: CheckItemResult:
type: object type: object
required: [checklist_item_id, verdict, layer, evidence_status, bid_citations, detail, reused] required: [checklist_item_id, verdict, layer, evidence_status, bid_citations, detail, reused]
@@ -2871,11 +2922,12 @@ components:
title: { type: [string, 'null'] } title: { type: [string, 'null'] }
verdict: verdict:
type: string 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: | description: |
已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对。 已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对 /
按招标文件默认接受(未列入偏离表的视为接受,不出问题)/ 默认接受的实质性要求、请人工核对(C12)。
没有校验通过的投标侧证据,不会是 responded(架构 §3.6) 没有校验通过的投标侧证据,不会是 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] } evidence_status: { type: string, enum: [verified, unverified, none] }
reason_code: { type: [string, 'null'], description: 证据没通过、漏答、模型错误等的原因码 } reason_code: { type: [string, 'null'], description: 证据没通过、漏答、模型错误等的原因码 }
bid_citations: 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"| {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})" f"- 规矩的投标文件:清单 {items_clean} 条,报出问题 {findings_clean} 个;其中规矩响应的要求上 {fp}/{ok}(每 100 条 {100 * fp / ok:.1f})"
if ok if ok

View File

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

View File

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

View File

@@ -826,6 +826,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/projects/{project_id}/checklist/versions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1783,6 +1803,8 @@ export interface components {
citation: components["schemas"]["Citation"]; citation: components["schemas"]["Citation"];
}; };
Checklist: { Checklist: {
/** @description 招标文件对技术、商务要求的响应方式(C12)。识别出来的(origin = rule)可以改(origin = human,人工确认点);查标按它判没找到响应算不算问题 */
response_modes?: components["schemas"]["ResponseMode"][];
/** @enum {string} */ /** @enum {string} */
status: "draft" | "confirmed"; status: "draft" | "confirmed";
/** @description 最近一次确认的版本号;没确认过为 0 */ /** @description 最近一次确认的版本号;没确认过为 0 */
@@ -2059,21 +2081,41 @@ export interface components {
resolved_by?: components["schemas"]["UserRef"] | null; resolved_by?: components["schemas"]["UserRef"] | null;
resolved_at?: components["schemas"]["Timestamp"] | 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: { CheckItemResult: {
checklist_item_id: components["schemas"]["Id"]; checklist_item_id: components["schemas"]["Id"];
title?: string | null; title?: string | null;
/** /**
* @description 已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对。 * @description 已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对 /
* 按招标文件默认接受(未列入偏离表的视为接受,不出问题)/ 默认接受的实质性要求、请人工核对(C12)。
* 没有校验通过的投标侧证据,不会是 responded(架构 §3.6) * 没有校验通过的投标侧证据,不会是 responded(架构 §3.6)
* *
* @enum {string} * @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} * @enum {string}
*/ */
layer: "deviation_table" | "rule_compare" | "model" | "retrieval"; layer: "deviation_table" | "rule_compare" | "model" | "retrieval" | "presence";
/** @enum {string} */ /** @enum {string} */
evidence_status: "verified" | "unverified" | "none"; evidence_status: "verified" | "unverified" | "none";
/** @description 证据没通过、漏答、模型错误等的原因码 */ /** @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: { listChecklistVersions: {
parameters: { parameters: {
query?: never; 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 = "投标文件只写了满足或响应,没有给出要求里要的具体值或材料。" text = "投标文件只写了满足或响应,没有给出要求里要的具体值或材料。"
return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"}) return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"})
if result.verdict == "scanned": if result.verdict == "scanned":
text = "候选内容所在的页面是扫描件,系统读不出文字,请人工核对。" total = result.detail.get("scanned_total") or 0
return Spec("manual_review", sev, f"请人工核对:{name}", text, base) text = (
f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"
"材料可能在扫描件页上,请人工核对。"
)
return Spec("manual_review", "hint", f"请人工核对(可能在扫描件里):{name}", text, base)
why = REASONS.get(result.reason_code or "", result.reason_code or "") why = REASONS.get(result.reason_code or "", result.reason_code or "")
return Spec("manual_review", sev, f"未判定:{name}", f"这条要求本次没有完成判断({why}),请人工核对。", base) 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 import service as ai
from bid_doctor.ai.providers.base import TransientError from bid_doctor.ai.providers.base import TransientError
from bid_doctor.check import presence
from bid_doctor.check.retrieval import Index from bid_doctor.check.retrieval import Index
from bid_doctor.core.db import TenantContext from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline.text_layer import Sentence, norm from bid_doctor.document.pipeline.text_layer import Sentence, norm
@@ -51,6 +52,7 @@ class BidDoc:
no: int # 在本次检查里的序号,组成短编号 d<序号>p<页>s<句> no: int # 在本次检查里的序号,组成短编号 d<序号>p<页>s<句>
label: str label: str
sentences: list[Sentence] sentences: list[Sentence]
scanned_pages: list[int] = field(default_factory=list[int]) # 没有文字层的页(扫描件),C10
@dataclass(slots=True) @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"} 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: def short_sid(doc: BidDoc, s: Sentence) -> str:
return f"d{doc.no}p{s.page_no}s{s.seq}" 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]] = {} # 每条清单项自己的候选句(同一行的单元格跟在后面显示,不单列) hits_of: dict[UUID, list[str]] = {} # 每条清单项自己的候选句(同一行的单元格跟在后面显示,不单列)
for item_id, item in items.items(): 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) hits = index.search(query_text(item), CANDIDATES)
if not hits: # 检索不到候选句:不调模型(m3-plan §2.1) if not hits: # 检索不到候选句:不调模型(m3-plan §2.1)
results[item_id] = Result("not_found", "retrieval", detail={"search_scope": scope}) 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): for no, d in enumerate(docs, start=1):
pages = await normalize.pages(ctx, d.id, on_stage, reuse=True, require_text=False) 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) 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( pages_info.append(
{ {
"id": str(d.id), "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) snapshot = await checklist_edit.version_items(s, r.checklist_version_id)
lots = await projects.lot_labels(s, r.project_id) lots = await projects.lot_labels(s, r.project_id)
pending = (await checklist.pending_counts(s, [r.project_id])).get(r.project_id, 0) 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 = ( tenders = (
await s.execute( await s.execute(
select(SourceDocument).where( 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)] [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) bidding = set(r.lot_ids)
items, other_lot = {}, 0 items, other_lot = {}, 0
for item_id, payload in snapshot.items(): 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) results, stats = await match.run(ctx, run_id, items, bid_docs, progress)
match.apply_modes(results, items, decisions) 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, "rules") # 第 2 步:报价形式、形式要件、需人工核对
await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点 await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Header, Response from fastapi import APIRouter, Header, Response
from pydantic import BaseModel, ConfigDict, Field 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.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects 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: async with tenant_tx(ctx) as s:
await projects.get(s, ctx, project_id) await projects.get(s, ctx, project_id)
return {"items": await edit.versions(s, 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()) status: Mapped[str] = mapped_column(server_default=FetchedValue())
last_version_no: Mapped[int] = mapped_column(server_default=FetchedValue()) last_version_no: Mapped[int] = mapped_column(server_default=FetchedValue())
confirmed_version_id: Mapped[UuidCol | None] confirmed_version_id: Mapped[UuidCol | None]
response_modes: Mapped[Json] = mapped_column(server_default=FetchedValue())
updated_at: Mapped[DbTs] updated_at: Mapped[DbTs]

View File

@@ -5,14 +5,24 @@
「其余无偏离内容无需赘述」; 「其余无偏离内容无需赘述」;
- 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。 - 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。
规则优先、纯函数;结果带原文句子,查标写进检查覆盖范围。 规则优先、纯函数;结果带原文句子,查标写进检查覆盖范围。
TODO(C12):存进清单、在清单页显示并可改(人工确认点);模型兜底规则没认出的写法。 解析时识别并存进清单(checklist.response_modes),清单页显示、可改(人工确认点);查标用存下来的。
TODO(C12):模型兜底规则没认出的写法。
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass, field 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 from bid_doctor.document.pipeline.text_layer import Sentence
EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)") EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)")
@@ -61,3 +71,64 @@ def detect(sentences: list[Sentence]) -> dict[str, Mode]:
elif m.explicit: elif m.explicit:
m.mode, m.status = "explicit", "found" m.mode, m.status = "explicit", "found"
return hits 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 for r, *_ in rules
if r.declared_count is not None and r.declared_count != found_by_rule.get(r.id, 0) 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 { 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", "status": cl.status if cl else "draft",
"version_no": cl.last_version_no if cl else 0, "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, "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.ai.providers.base import TransientError
from bid_doctor.billing import service as billing 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.checklist import service as checklist
from bid_doctor.core import events from bid_doctor.core import events
from bid_doctor.core.db import TenantContext, tenant_tx 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() 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) 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) 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 { return {
**stats, **stats,
"lots_found": len([k for k in lots if k != "默认"]), "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']}/resolve", json={"status": "modified"})).status_code == 404
assert (await b.post(f"/findings/{f['id']}/reopen")).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 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 assert results[a].verdict == "implicit_accepted" and findings.spec(results[a], items[a]) is None
spec = findings.spec(results[b], items[b]) spec = findings.spec(results[b], items[b])
assert results[b].verdict == "implicit_review" and spec is not None and spec.severity == "hint" 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" # 技术参数不是证明材料,照常报未找到