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:
@@ -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 步的投标侧结构识别和规则检查(报价形式、形式要件)。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 步冻结代码后只跑一次。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
81
packages/api-client/src/schema.d.ts
vendored
81
packages/api-client/src/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
27
server/migrations/versions/0007_check_layer_presence.py
Normal file
27
server/migrations/versions/0007_check_layer_presence.py
Normal 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}));
|
||||
""")
|
||||
22
server/migrations/versions/0008_checklist_response_modes.py
Normal file
22
server/migrations/versions/0008_checklist_response_modes.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
31
server/src/bid_doctor/check/presence.py
Normal file
31
server/src/bid_doctor/check/presence.py
Normal 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
|
||||
@@ -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") # 前两道校验在匹配时逐条做完;这里只是进度节点
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "默认"]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" # 技术参数不是证明材料,照常报未找到
|
||||
|
||||
Reference in New Issue
Block a user