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:用户抽查第二批复核 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 步的投标侧结构识别和规则检查(报价形式、形式要件)。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 步冻结代码后只跑一次。
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
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 = "投标文件只写了满足或响应,没有给出要求里要的具体值或材料。"
|
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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
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):
|
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") # 前两道校验在匹配时逐条做完;这里只是进度节点
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 != "默认"]),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" # 技术参数不是证明材料,照常报未找到
|
||||||
|
|||||||
Reference in New Issue
Block a user