feat: 招标文件分析拆四维度并行(评分项/资质/文件要求+骨架/成本收益)——对账器按维度各派一个任务并行抽取;QC五类逐类并行审核每类一个审核任务;新增bid_cost_benefit成本收益分析表+extract_cost_benefit/add_cost_benefit_item;QC阈值9.5→8.5(params固化8.5);add_doc_requirement加去重防重派重复录入(2026-09-02)
This commit is contained in:
parent
7a6a0350dc
commit
66e7f79ec7
@ -94,7 +94,7 @@ BID_PROMPT = """你是「投标产线」的驾驶舱 agent,负责从招标文
|
||||
## 流转由机制驱动(重要)
|
||||
角色任务由系统状态对账器自动派发(每 15 秒一轮),你**不需要**手动为每个章节创建任务:
|
||||
- 解析产出须经 QC 契合度审核:评分标准/得分规则/资质/投标文件要求/章节骨架逐类对照招标文件原文
|
||||
按 10 分制打分,得分高于通过分(默认 9.5)才放行;不通过给改进意见 → 清空产出 → 解析重做 → 重审
|
||||
按 10 分制打分,得分高于通过分(当前 8.5)才放行;不通过给改进意见 → 清空产出 → 解析重做 → 重审
|
||||
- 章节评审不达标 → 章节自动退回重写
|
||||
- 整书评分不达标 → 改进意见自动落到问题章节并退回重写 → 重新合成 → 重新评分
|
||||
你的职责是:收到招标文件立即导入启动、处理卡点与提问、回答评审/评分中的人类决策。
|
||||
|
||||
@ -60,6 +60,90 @@ __CONTENT__
|
||||
金额只填数字(如 1200000),不带单位和千分位;日期只填 YYYY-MM-DD。"""
|
||||
|
||||
|
||||
# ── 四维度拆分抽取提示词(2026-09-02 起:解析任务拆成四个并行维度)──
|
||||
|
||||
PROMPT_SCORING = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【评分项与得分规则】这一个维度。
|
||||
|
||||
【招标文件正文】
|
||||
__CONTENT__
|
||||
|
||||
严格只输出一个 JSON 对象:
|
||||
{
|
||||
"tender_facts": {"title": "项目名称", "budget_amount": "预算金额数字", "risk_note": "评标相关风险提示,无则空"},
|
||||
"scoring_items": [
|
||||
{"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称",
|
||||
"max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)",
|
||||
"is_veto": "0或1(是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"}
|
||||
]
|
||||
}
|
||||
要求:
|
||||
1. 必须逐条覆盖评标办法中的每个评分项,不得漏项、不得编造;满分数字与原文一致。
|
||||
2. 得分规则保留原文档位/扣分/加分细则,不要概括失真。
|
||||
3. 否决项/废标条款 is_veto=1,漏标视为致命缺陷。
|
||||
4. 只输出评分维度相关内容,不要输出资质/文件要求/章节等其他维度。
|
||||
5. 招标文件里没有评标办法就输出 {"tender_facts": {}, "scoring_items": []},禁止编造。"""
|
||||
|
||||
PROMPT_QUALS = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【资质清单与要求】这一个维度。
|
||||
|
||||
【招标文件正文】
|
||||
__CONTENT__
|
||||
|
||||
严格只输出一个 JSON 对象:
|
||||
{
|
||||
"tender_facts": {"title": "项目名称", "qualification_req": "投标人资格/资质要求原文摘要"},
|
||||
"qualifications": [
|
||||
{"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1(是否强制)"}
|
||||
]
|
||||
}
|
||||
要求:
|
||||
1. 资格要求中的每项资质/证书/业绩门槛/体系认证全部录入,无漏项、无编造。
|
||||
2. 要求描述与原文一致(等级/有效期/联合体口径),强制性条款 is_mandatory=1,加分项不得误标必需。
|
||||
3. 只输出资质维度相关内容,不要输出评分/文件要求/章节等其他维度。
|
||||
4. 招标文件里没有资质要求就输出 {"tender_facts": {}, "qualifications": []},禁止编造。"""
|
||||
|
||||
PROMPT_REQS = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【投标文件要求与章节结构】这一个维度。
|
||||
|
||||
【招标文件正文】
|
||||
__CONTENT__
|
||||
|
||||
严格只输出一个 JSON 对象:
|
||||
{
|
||||
"doc_requirements": [
|
||||
{"req_type": "structure|format|seal|copies|deadline|other",
|
||||
"chapter_no": "章节号(structure 类必填,如 1 / 1.1)",
|
||||
"chapter_title": "章节标题(structure 类必填)",
|
||||
"requirement": "要求内容", "page_limit": 数字或null, "source_ref": "出处"}
|
||||
]
|
||||
}
|
||||
要求:
|
||||
1. req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出,章节号/标题准确。
|
||||
2. 格式/密封/份数/截止时间等要求(format/seal/copies/deadline)无遗漏。
|
||||
3. 只输出投标文件要求维度相关内容,不要输出评分/资质等其他维度。
|
||||
4. 只抽取正文中真实存在的内容,禁止编造;没有就输出 {"doc_requirements": []}。"""
|
||||
|
||||
PROMPT_COST_BENEFIT = """你是投标商务分析专家。下面是一份招标文件正文(可能被截断),请做【投标成本与收益分析】。
|
||||
|
||||
【招标文件正文】
|
||||
__CONTENT__
|
||||
|
||||
严格只输出一个 JSON 对象:
|
||||
{
|
||||
"items": [
|
||||
{"category": "cost|benefit", "item_name": "项目名(如 投标保证金/履约保证金/人员投入成本/设备采购成本/预期收益)",
|
||||
"amount": 金额数字或null, "amount_note": "金额口径说明(估算/上限/区间),估算须写清依据",
|
||||
"basis": "推理依据:招标文件原文引用 + 推理过程", "confidence": "high|medium|low",
|
||||
"source_ref": "出处(如 第二章 投标须知)"}
|
||||
],
|
||||
"summary": {"total_cost_est": 成本合计估算数字或null, "total_revenue_est": 收益合计估算数字或null,
|
||||
"margin_est": "毛利空间文字结论(区间/口径说明)", "note": "关键风险与口径声明"}
|
||||
}
|
||||
要求:
|
||||
1. 成本项:投标保证金、履约保证金、实施/运维人力成本、设备/软件采购成本、差旅/投标直接费用等,按招标要求和行业常识估算,估算口径必须写在 amount_note 和 basis 里。
|
||||
2. 收益项:预算金额/招标控制价(招标文件明确给出才算,不得编造数字)。
|
||||
3. 每项必须给 basis(原文引用+推理),招标文件没提的成本用行业常识估算并标 confidence=low,严禁凭空编造确定性数字。
|
||||
4. summary.margin_est 是成本合计与收益合计的差额分析,口径与单项一致;算不出就写"无法估算"并说明原因。"""
|
||||
|
||||
|
||||
# ══════════════ 招标文件读取 ══════════════
|
||||
|
||||
async def list_tender_files(project_id, extract_status=""):
|
||||
@ -324,6 +408,14 @@ async def add_doc_requirement(project_id, requirement, req_type="structure",
|
||||
return False, "缺少 requirement"
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
exist = await sor.sqlExe(
|
||||
"SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ "
|
||||
"AND req_type=${rt}$ AND requirement=${rq}$ LIMIT 1",
|
||||
{"pid": project_id, "rt": (req_type or "structure")[:30],
|
||||
"rq": str(requirement or "")[:500]})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if exist:
|
||||
return True, "投标文件要求已存在: %s" % str(requirement)[:50]
|
||||
n = await sor.sqlExe(
|
||||
"SELECT COUNT(*) AS c FROM bid_doc_requirements WHERE project_id=${pid}$",
|
||||
{"pid": project_id})
|
||||
@ -404,11 +496,53 @@ async def _upsert_tender_facts(project_id, facts, who=None, agent_id=None):
|
||||
detail="从招标文件抽取要素: %s" % ", ".join(sorted(data)))
|
||||
|
||||
|
||||
async def _load_tender_text(sor, project_id, file_id=""):
|
||||
"""读招标文件正文(各维度抽取共用)。返回 (txt, file_name, err)。
|
||||
|
||||
file_id 指定 → 取该记录;否则选最优记录(有正文优先、其次磁盘有文件,同类取最新)。
|
||||
磁盘文件首次读到时回写 content_text(幂等)。
|
||||
"""
|
||||
if file_id:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
|
||||
"WHERE id=${fid}$", {"fid": file_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return "", "", "项目下没有该招标文件(bid_tender_files),无法抽取"
|
||||
d = rec_to_dict(recs[0])
|
||||
else:
|
||||
# 选最优记录:有正文优先、其次磁盘有文件,同类取最新
|
||||
# (2026-09-01 修复:旧实现固定取最老记录,完整文件后来上传时永远读不到)
|
||||
d = await _best_tender_record(sor, project_id)
|
||||
if not d:
|
||||
return "", "", "项目下没有招标文件(bid_tender_files),无法抽取"
|
||||
txt = d.get("content_text") or ""
|
||||
if not txt.strip() and d.get("file_path"):
|
||||
pdir = await _project_dir_of(sor, project_id)
|
||||
real = d.get("_resolved_path") or _resolve_file_path(pdir, d["file_path"])
|
||||
txt = _extract_file_text(real) if real else ""
|
||||
if txt:
|
||||
await sor.sqlExe(
|
||||
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$ "
|
||||
"WHERE id=${fid}$",
|
||||
{"c": txt[:2000000], "p": real, "fid": d["id"]})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not txt.strip():
|
||||
return "", d.get("file_name", ""), (
|
||||
"招标文件正文为空,无法抽取(file=%s,file_path=%s);"
|
||||
"请确认文件已上传到项目目录" % (d.get("file_name", ""), d.get("file_path", "")))
|
||||
return txt, d.get("file_name", ""), ""
|
||||
|
||||
|
||||
async def extract_from_file(project_id, file_id="", model_name="", offset=0,
|
||||
length=30000, who=None, agent_id=None):
|
||||
"""LLM 一次性抽取三类信息并批量落库(评分项/资质/投标文件要求)。
|
||||
|
||||
大文件分段调用(offset 递增),重复项按名称去重,可安全多次调用。
|
||||
|
||||
【2026-09-02 起已由四维度拆分取代】对账器按维度各派一个任务并行抽取
|
||||
(extract_scoring / extract_quals / extract_reqs_outline / extract_cost_benefit),
|
||||
本函数保留仅为向后兼容(旧角色技能/旧项目重做仍可调),新流程不再用它。
|
||||
"""
|
||||
try:
|
||||
project_id = await resolve_project_id(project_id, file_id=file_id)
|
||||
@ -416,35 +550,9 @@ async def extract_from_file(project_id, file_id="", model_name="", offset=0,
|
||||
return False, str(e)
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
if file_id:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
|
||||
"WHERE id=${fid}$", {"fid": file_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return False, "项目下没有招标文件(bid_tender_files),无法抽取"
|
||||
d = rec_to_dict(recs[0])
|
||||
else:
|
||||
# 选最优记录:有正文优先、其次磁盘有文件,同类取最新
|
||||
# (2026-09-01 修复:旧实现固定取最老记录,完整文件后来上传时永远读不到)
|
||||
d = await _best_tender_record(sor, project_id)
|
||||
if not d:
|
||||
return False, "项目下没有招标文件(bid_tender_files),无法抽取"
|
||||
txt = d.get("content_text") or ""
|
||||
if not txt.strip() and d.get("file_path"):
|
||||
pdir = await _project_dir_of(sor, project_id)
|
||||
real = d.get("_resolved_path") or _resolve_file_path(pdir, d["file_path"])
|
||||
txt = _extract_file_text(real) if real else ""
|
||||
if txt:
|
||||
await sor.sqlExe(
|
||||
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$ "
|
||||
"WHERE id=${fid}$",
|
||||
{"c": txt[:2000000], "p": real, "fid": d["id"]})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not txt.strip():
|
||||
return False, ("招标文件正文为空,无法抽取(file=%s,file_path=%s);"
|
||||
"请确认文件已上传到项目目录" %
|
||||
(d.get("file_name", ""), d.get("file_path", "")))
|
||||
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
|
||||
if err:
|
||||
return False, err
|
||||
o = to_int(offset, 0)
|
||||
seg = txt[o:o + to_int(length, 30000)]
|
||||
data, err = await llm_json(EXTRACT_PROMPT.replace("__CONTENT__", seg),
|
||||
@ -480,10 +588,218 @@ async def extract_from_file(project_id, file_id="", model_name="", offset=0,
|
||||
who=who, agent_id=agent_id)
|
||||
cnt["doc_requirements"] += 1 if ok else 0
|
||||
return True, ("抽取完成(文件 %s,字符 %d-%d):评分项 %d,资质 %d,投标文件要求 %d"
|
||||
% (d.get("file_name", ""), o, o + len(seg),
|
||||
% (fname, o, o + len(seg),
|
||||
cnt["scoring_items"], cnt["qualifications"], cnt["doc_requirements"]))
|
||||
|
||||
|
||||
# ── 四维度拆分抽取(2026-09-02:解析任务拆成四个并行维度,各派一个任务)──
|
||||
|
||||
async def _extract_seg(txt, prompt, model_name, offset, length):
|
||||
"""取分段正文 → LLM 抽取。返回 (data, err)。"""
|
||||
o = to_int(offset, 0)
|
||||
seg = txt[o:o + to_int(length, 30000)]
|
||||
data, err = await llm_json(prompt.replace("__CONTENT__", seg),
|
||||
model_name=model_name, retries=1)
|
||||
if data is None:
|
||||
return None, "LLM 抽取失败: %s" % err
|
||||
return data, ""
|
||||
|
||||
|
||||
async def extract_scoring(project_id, file_id="", model_name="", offset=0,
|
||||
length=30000, who=None, agent_id=None):
|
||||
"""维度1:抽取评分项 + 得分规则(落 bid_scoring_items;顺带更新商务要素)。"""
|
||||
try:
|
||||
project_id = await resolve_project_id(project_id, file_id=file_id)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
|
||||
if err:
|
||||
return False, err
|
||||
data, err = await _extract_seg(txt, PROMPT_SCORING, model_name, offset, length)
|
||||
if data is None:
|
||||
return False, err
|
||||
cnt = 0
|
||||
await _upsert_tender_facts(project_id, data.get("tender_facts"),
|
||||
who=who, agent_id=agent_id)
|
||||
for it in (data.get("scoring_items") or []):
|
||||
if not isinstance(it, dict) or not it.get("item_name"):
|
||||
continue
|
||||
ok, _ = await add_scoring_item(
|
||||
project_id, it.get("item_name"), it.get("max_score", 0),
|
||||
scoring_rule=it.get("scoring_rule", ""), section=it.get("section", "technical"),
|
||||
item_no=it.get("item_no", ""), is_veto=it.get("is_veto", "0"),
|
||||
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
|
||||
cnt += 1 if ok else 0
|
||||
return True, ("评分项维度抽取完成(文件 %s,字符 %d-%d):录入 %d 项。"
|
||||
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000), cnt))
|
||||
|
||||
|
||||
async def extract_quals(project_id, file_id="", model_name="", offset=0,
|
||||
length=30000, who=None, agent_id=None):
|
||||
"""维度2:抽取资质清单与要求(落 bid_qualifications)。"""
|
||||
try:
|
||||
project_id = await resolve_project_id(project_id, file_id=file_id)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
|
||||
if err:
|
||||
return False, err
|
||||
data, err = await _extract_seg(txt, PROMPT_QUALS, model_name, offset, length)
|
||||
if data is None:
|
||||
return False, err
|
||||
cnt = 0
|
||||
await _upsert_tender_facts(project_id, data.get("tender_facts"),
|
||||
who=who, agent_id=agent_id)
|
||||
for it in (data.get("qualifications") or []):
|
||||
if not isinstance(it, dict) or not it.get("qual_name"):
|
||||
continue
|
||||
ok, _ = await add_qualification(
|
||||
project_id, it.get("qual_name"), requirement=it.get("requirement", ""),
|
||||
is_mandatory=it.get("is_mandatory", "1"), who=who, agent_id=agent_id)
|
||||
cnt += 1 if ok else 0
|
||||
return True, ("资质维度抽取完成(文件 %s,字符 %d-%d):录入 %d 项。"
|
||||
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000), cnt))
|
||||
|
||||
|
||||
async def extract_reqs_outline(project_id, file_id="", model_name="", offset=0,
|
||||
length=30000, who=None, agent_id=None):
|
||||
"""维度3:抽取投标文件要求(落 bid_doc_requirements),并自动生成章节骨架。"""
|
||||
try:
|
||||
project_id = await resolve_project_id(project_id, file_id=file_id)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
|
||||
if err:
|
||||
return False, err
|
||||
data, err = await _extract_seg(txt, PROMPT_REQS, model_name, offset, length)
|
||||
if data is None:
|
||||
return False, err
|
||||
cnt = 0
|
||||
for it in (data.get("doc_requirements") or []):
|
||||
if not isinstance(it, dict) or not it.get("requirement"):
|
||||
continue
|
||||
ok, _ = await add_doc_requirement(
|
||||
project_id, it.get("requirement"), req_type=it.get("req_type", "structure"),
|
||||
chapter_no=it.get("chapter_no", ""), chapter_title=it.get("chapter_title", ""),
|
||||
page_limit=it.get("page_limit", ""), source_ref=it.get("source_ref", ""),
|
||||
who=who, agent_id=agent_id)
|
||||
cnt += 1 if ok else 0
|
||||
# 自动生成章节骨架(structure 类 → 章节,评分项挂载能挂多少挂多少)
|
||||
ok_o, msg_o = await create_chapter_outline(project_id, who=who, agent_id=agent_id)
|
||||
return True, ("投标文件要求维度抽取完成(文件 %s,字符 %d-%d):录入 %d 条要求。%s"
|
||||
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000),
|
||||
cnt, msg_o if not ok_o else "章节骨架已生成。"))
|
||||
|
||||
|
||||
async def add_cost_benefit_item(project_id, category, item_name, amount="", amount_note="",
|
||||
basis="", confidence="medium", source_ref="",
|
||||
who=None, agent_id=None):
|
||||
"""补录一条成本/收益项(分析漏项或人工修正时用)。"""
|
||||
try:
|
||||
project_id = await resolve_project_id(project_id)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
if not item_name:
|
||||
return False, "缺少 item_name"
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
exist = await sor.sqlExe(
|
||||
"SELECT id FROM bid_cost_benefit WHERE project_id=${pid}$ AND item_name=${nm}$ "
|
||||
"AND category=${c}$ LIMIT 1",
|
||||
{"pid": project_id, "nm": str(item_name)[:250], "c": (category or "cost")[:16]})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if exist:
|
||||
return True, "该成本/收益项已存在: %s" % item_name
|
||||
n = await sor.sqlExe(
|
||||
"SELECT COUNT(*) AS c FROM bid_cost_benefit WHERE project_id=${pid}$",
|
||||
{"pid": project_id})
|
||||
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
|
||||
iid = new_id()
|
||||
amt = to_float(amount, 0.0)
|
||||
await sor.C("bid_cost_benefit", {
|
||||
"id": iid, "project_id": project_id,
|
||||
"kind": "item", "category": (category or "cost")[:16],
|
||||
"item_name": str(item_name)[:250],
|
||||
"amount": amt or None,
|
||||
"amount_note": str(amount_note or "")[:200],
|
||||
"basis": basis or "",
|
||||
"confidence": (confidence or "medium")[:16],
|
||||
"source_ref": str(source_ref or "")[:480],
|
||||
"order_no": order_no,
|
||||
})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
await record(sor, project_id, "bid_cost_benefit", iid, "add",
|
||||
who=who, agent_id=agent_id, detail=str(item_name)[:200])
|
||||
return True, "成本/收益项已录入: %s (%s)" % (item_name, category)
|
||||
|
||||
|
||||
async def extract_cost_benefit(project_id, file_id="", model_name="", offset=0,
|
||||
length=30000, who=None, agent_id=None):
|
||||
"""维度4:投标成本与收益分析(落 bid_cost_benefit:单项 + summary 结论行)。"""
|
||||
try:
|
||||
project_id = await resolve_project_id(project_id, file_id=file_id)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
db, dbname = get_db()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
|
||||
if err:
|
||||
return False, err
|
||||
data, err = await _extract_seg(txt, PROMPT_COST_BENEFIT, model_name, offset, length)
|
||||
if data is None:
|
||||
return False, err
|
||||
cnt = 0
|
||||
for it in (data.get("items") or []):
|
||||
if not isinstance(it, dict) or not it.get("item_name"):
|
||||
continue
|
||||
ok, _ = await add_cost_benefit_item(
|
||||
project_id, it.get("category", "cost"), it.get("item_name"),
|
||||
amount=it.get("amount", ""), amount_note=it.get("amount_note", ""),
|
||||
basis=it.get("basis", ""), confidence=it.get("confidence", "medium"),
|
||||
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
|
||||
cnt += 1 if ok else 0
|
||||
# 关键结论行(summary):kind=summary,供总览展示
|
||||
sm = data.get("summary") or {}
|
||||
if isinstance(sm, dict) and sm:
|
||||
note = (sm.get("margin_est") or "")[:2000]
|
||||
extra = (sm.get("note") or "")[:2000]
|
||||
db2, dbname2 = get_db()
|
||||
async with db2.sqlorContext(dbname2) as sor:
|
||||
exist = await sor.sqlExe(
|
||||
"SELECT id FROM bid_cost_benefit WHERE project_id=${pid}$ AND kind='summary' LIMIT 1",
|
||||
{"pid": project_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
sdata = {
|
||||
"category": "summary",
|
||||
"item_name": "成本收益关键结论",
|
||||
"amount": to_float(sm.get("total_cost_est"), 0.0) or None,
|
||||
"amount_note": ("成本合计估算;收益合计估算=%s"
|
||||
% str(sm.get("total_revenue_est") or "未估算"))[:200],
|
||||
"basis": (note + ("\n\n风险与口径:" + extra if extra else ""))[:20000],
|
||||
"confidence": "medium",
|
||||
}
|
||||
if exist:
|
||||
sid = getattr(exist[0], "id", "")
|
||||
sets = ", ".join("%s=${%s}$" % (k, k) for k in sdata if k != "id")
|
||||
p = dict(sdata); p["sid"] = sid
|
||||
await sor.sqlExe(
|
||||
"UPDATE bid_cost_benefit SET " + sets + ", updated_at=NOW() WHERE id=${sid}$", p)
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
else:
|
||||
sdata.update({"id": new_id(), "project_id": project_id, "kind": "summary",
|
||||
"source_ref": "", "order_no": 0})
|
||||
await sor.C("bid_cost_benefit", sdata)
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return True, ("成本收益维度分析完成(文件 %s):录入 %d 项 + 关键结论。"
|
||||
% (fname, cnt))
|
||||
|
||||
|
||||
# ══════════════ 查询 ══════════════
|
||||
|
||||
async def list_scoring_items(project_id, section=""):
|
||||
|
||||
@ -45,7 +45,8 @@ QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则
|
||||
QC_TYPE_QUALS = "qualifications" # 所需资质
|
||||
QC_TYPE_REQS = "doc_requirements" # 投标文件要求
|
||||
QC_TYPE_OUTLINE = "chapter_outline" # 章节骨架
|
||||
QC_TYPES = (QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE)
|
||||
QC_TYPE_COST = "cost_benefit" # 成本收益分析(2026-09-02 四维度拆分新增)
|
||||
QC_TYPES = (QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPE_COST)
|
||||
|
||||
# QC 审核对象 → 产出物落库表(QC 不通过时清空重做的对象;对账器判存在性也用)
|
||||
QC_OUTPUT_TABLE = {
|
||||
@ -53,6 +54,7 @@ QC_OUTPUT_TABLE = {
|
||||
QC_TYPE_QUALS: "bid_qualifications",
|
||||
QC_TYPE_REQS: "bid_doc_requirements",
|
||||
QC_TYPE_OUTLINE: "bid_chapters",
|
||||
QC_TYPE_COST: "bid_cost_benefit",
|
||||
}
|
||||
|
||||
# 任务未终结状态(判断「是否已有在办任务」用)
|
||||
@ -65,7 +67,7 @@ DEFAULT_PARAMS = {
|
||||
"bid_pass_ratio": "0.85", # 整书评分通过门限
|
||||
"bid_max_revise": "3", # 单章节最大重做次数
|
||||
"bid_max_round": "3", # 整书最大评分轮次
|
||||
"bid_qc_pass_score": "9.5", # 解析产出 QC 契合度通过分(10 分制,高于此分才放行)
|
||||
"bid_qc_pass_score": "8.5", # 解析产出 QC 契合度通过分(10 分制,高于此分才放行;2026-09-02 用户定 8.5)
|
||||
"bid_qc_max_round": "3", # 单类产出 QC 最大审核轮次(超限抛人工)
|
||||
"bid_chapter_min_words": "800", # 技术标章节最低字数(低于即评审必退)
|
||||
"bid_chapter_min_words_biz": "500", # 商务标章节最低字数
|
||||
@ -211,7 +213,7 @@ async def get_thresholds(sor):
|
||||
"bid_pass_ratio": to_float(await get_param(sor, "bid_pass_ratio"), 0.85),
|
||||
"max_revise": to_int(await get_param(sor, "bid_max_revise"), 3),
|
||||
"max_round": to_int(await get_param(sor, "bid_max_round"), 3),
|
||||
"qc_pass_score": to_float(await get_param(sor, "bid_qc_pass_score"), 9.5),
|
||||
"qc_pass_score": to_float(await get_param(sor, "bid_qc_pass_score"), 8.5),
|
||||
"qc_max_round": to_int(await get_param(sor, "bid_qc_max_round"), 3),
|
||||
"chapter_min_words": to_int(await get_param(sor, "bid_chapter_min_words"), 800),
|
||||
"chapter_min_words_biz": to_int(await get_param(sor, "bid_chapter_min_words_biz"), 500),
|
||||
|
||||
@ -25,7 +25,7 @@ from .bid_common import (
|
||||
CH_PENDING, CH_WRITING, CH_WRITTEN, CH_REJECTED, CH_APPROVED,
|
||||
DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, DOC_DELIVERED,
|
||||
HT_TENDER_FILE, HT_HUMAN_DOCS, HT_QC_ESCALATION, HT_DELIVERY_CONFIRM,
|
||||
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPES,
|
||||
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPE_COST, QC_TYPES,
|
||||
QC_OUTPUT_TABLE,
|
||||
)
|
||||
|
||||
@ -76,6 +76,98 @@ async def _redo_done_count(sor, project_id, since=''):
|
||||
return await _count(sor, sql, p)
|
||||
|
||||
|
||||
# ── 四维度分析辅助(2026-09-02:解析拆成四个并行维度)──
|
||||
# 维度定义:dim key → (任务标签, 对应 QC 类型)
|
||||
ANALYSIS_DIMS = (
|
||||
("scoring", "评分项与得分规则", (QC_TYPE_SCORING,)),
|
||||
("quals", "资质清单与要求", (QC_TYPE_QUALS,)),
|
||||
("reqs_outline", "投标文件要求与章节骨架", (QC_TYPE_REQS, QC_TYPE_OUTLINE)),
|
||||
("cost_benefit", "成本收益分析", (QC_TYPE_COST,)),
|
||||
)
|
||||
|
||||
DIM_QC_TYPES = {d: ts for d, _, ts in ANALYSIS_DIMS}
|
||||
QC_TYPE_TO_DIM = {q: d for d, _, ts in ANALYSIS_DIMS for q in ts}
|
||||
|
||||
|
||||
async def _missing_dims(n_items, n_struct, n_quals, n_cost, chapters):
|
||||
"""返回缺失的分析维度 [(dim, label)]。各维度独立判定,缺几个并行派几个。"""
|
||||
missing = []
|
||||
if n_items == 0:
|
||||
missing.append(("scoring", "评分项与得分规则"))
|
||||
if n_quals == 0:
|
||||
missing.append(("quals", "资质清单与要求"))
|
||||
if n_struct == 0 or not chapters:
|
||||
missing.append(("reqs_outline", "投标文件要求与章节骨架"))
|
||||
if n_cost == 0:
|
||||
missing.append(("cost_benefit", "成本收益分析"))
|
||||
return missing
|
||||
|
||||
|
||||
async def _open_dim_tasks(sor, project_id, dim):
|
||||
"""某维度在办分析任务数(按 params.analysis_dim 匹配)。"""
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
|
||||
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
|
||||
"('submitted','running','review','qc_review') "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(params,'$.analysis_dim'))=${d}$",
|
||||
{"p": project_id, "r": R_ANALYST, "d": dim})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return to_int(getattr(recs[0], "c", 0) if recs else 0)
|
||||
|
||||
|
||||
async def _dim_done_count(sor, project_id, dim, since=''):
|
||||
"""某维度已完成(首跑+重做)任务数。"""
|
||||
sql = ("SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
|
||||
"AND pipeline_id='role_task' AND role=${r}$ AND state IN ('approved','completed') "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(params,'$.analysis_dim'))=${d}$")
|
||||
p = {"p": project_id, "r": R_ANALYST, "d": dim}
|
||||
if since:
|
||||
sql += " AND updated_at >= ${since}$"
|
||||
p["since"] = since
|
||||
return await _count(sor, sql, p)
|
||||
|
||||
|
||||
async def _dim_redo_done_count(sor, project_id, dim, since=''):
|
||||
"""某维度重做(qc_redo=1)已完成任务数(不计入首跑重试上限)。"""
|
||||
sql = ("SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
|
||||
"AND pipeline_id='role_task' AND role=${r}$ AND state IN ('approved','completed') "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(params,'$.analysis_dim'))=${d}$ AND params LIKE ${kw}$")
|
||||
p = {"p": project_id, "r": R_ANALYST, "d": dim, "kw": "%qc_redo%"}
|
||||
if since:
|
||||
sql += " AND updated_at >= ${since}$"
|
||||
p["since"] = since
|
||||
return await _count(sor, sql, p)
|
||||
|
||||
|
||||
async def _dim_qc_improvements(sor, project_id, dim):
|
||||
"""某维度各 QC 类型最新一轮未通过的改进意见(全通过/未审核返回空列表)。"""
|
||||
imps = []
|
||||
for t in DIM_QC_TYPES.get(dim, ()):
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT round, passed, improvement FROM bid_qc_reviews "
|
||||
"WHERE project_id=${p}$ AND qc_type=${t}$ ORDER BY round DESC LIMIT 1",
|
||||
{"p": project_id, "t": t})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
continue
|
||||
r = rec_to_dict(recs[0])
|
||||
if str(r.get("passed")) == "1" or not (r.get("improvement") or "").strip():
|
||||
continue
|
||||
imps.append("[%s 第%s轮] %s" % (t, r.get("round"), r["improvement"]))
|
||||
return imps
|
||||
|
||||
|
||||
async def _open_qc_type_tasks(sor, project_id, qc_type):
|
||||
"""某类型在办 QC 审核任务数(params.qc_types 含该类型)。"""
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
|
||||
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
|
||||
"('submitted','running','review','qc_review') AND params LIKE ${kw}$",
|
||||
{"p": project_id, "r": R_QC, "kw": '%"qc_types": ["%s"]%' % qc_type})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return to_int(getattr(recs[0], "c", 0) if recs else 0)
|
||||
|
||||
|
||||
async def _latest_tender_file_ts(sor, project_id):
|
||||
"""项目最新招标文件的 created_at(无文件返回 '')。"""
|
||||
recs = await sor.sqlExe(
|
||||
@ -103,8 +195,8 @@ async def _latest_done_analysis_ts(sor, project_id):
|
||||
async def _reset_analysis_for_new_file(sor, project_id, since_ts):
|
||||
"""新招标文件到来 → 清空旧文件时代的解析残留,让新文件干净重跑(幂等)。
|
||||
|
||||
清空范围:四类产出物(评分项/资质/投标文件要求/章节骨架,旧文件抽的对新文件
|
||||
无意义)+ 早于新文件的 QC 审核记录(旧改进意见不能带入新文件解析)。
|
||||
清空范围:五类产出物(评分项/资质/投标文件要求/章节骨架/成本收益,旧文件抽的
|
||||
对新文件无意义)+ 早于新文件的 QC 审核记录(旧改进意见不能带入新文件解析)。
|
||||
顺手关闭旧「解析未产出完整结果」人工介入任务(自动恢复即处理完毕)。
|
||||
"""
|
||||
# 仅当存在早于新文件的 QC 记录时执行(说明此前有过旧解析,属恢复场景)
|
||||
@ -115,7 +207,7 @@ async def _reset_analysis_for_new_file(sor, project_id, since_ts):
|
||||
if not recs:
|
||||
return False
|
||||
for tbl in ("bid_scoring_items", "bid_qualifications", "bid_doc_requirements",
|
||||
"bid_chapters"):
|
||||
"bid_chapters", "bid_cost_benefit"):
|
||||
await sor.sqlExe(
|
||||
"DELETE FROM %s WHERE project_id=${p}$" % tbl, {"p": project_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
@ -296,7 +388,8 @@ async def reconcile_project(sor, project_id, project_name=""):
|
||||
sor, project_id, HT_TENDER_FILE,
|
||||
"上传招标文件:%s" % (p.get("name", "") or project_id),
|
||||
("请上传招标文件(含答疑/补遗文件)。\n"
|
||||
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。"))
|
||||
"上传后系统自动进入招标文件分析(四个维度并行:评分项与得分规则、资质清单、"
|
||||
"投标文件要求与章节骨架、成本收益分析)。"))
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["created: 上传招标文件任务 %s" % hid]
|
||||
if await has_pending_blocking_human_task(sor, project_id):
|
||||
@ -316,6 +409,8 @@ async def reconcile_project(sor, project_id, project_name=""):
|
||||
n_quals_pending = await _count(sor, "SELECT COUNT(*) AS c FROM bid_qualifications "
|
||||
"WHERE project_id=${p}$ AND match_status='pending'",
|
||||
{"p": project_id})
|
||||
n_cost = await _count(sor, "SELECT COUNT(*) AS c FROM bid_cost_benefit "
|
||||
"WHERE project_id=${p}$ AND kind='item'", {"p": project_id})
|
||||
|
||||
chs = await sor.sqlExe(
|
||||
"SELECT id, chapter_no, title, section, status, revise_count FROM bid_chapters "
|
||||
@ -329,80 +424,75 @@ async def reconcile_project(sor, project_id, project_name=""):
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
doc = rec_to_dict(docs[0]) if docs else {}
|
||||
|
||||
# ── A. 招标文件解析(评分项 / 资质 / 投标文件要求 / 章节骨架)──
|
||||
need_analysis = n_files > 0 and (n_items == 0 or n_struct == 0 or not chapters)
|
||||
if need_analysis:
|
||||
if await count_open_tasks(sor, project_id, role=R_ANALYST) > 0:
|
||||
return acts + ["waiting: 招标文件解析任务在办"]
|
||||
# 携带 QC 改进意见的重做任务(qc_redo=1)不计入首跑重试上限
|
||||
n_redo_open = await _count(
|
||||
sor, "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
|
||||
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
|
||||
"('submitted','running','review','qc_review') AND params LIKE ${kw}$",
|
||||
{"p": project_id, "r": R_ANALYST, "kw": "%qc_redo%"})
|
||||
attempts = await _done_task_count(sor, project_id, R_ANALYST)
|
||||
n_redo_done = await _redo_done_count(sor, project_id)
|
||||
# 自动恢复(2026-09-01):解析升级后来了**新的**招标文件(上传时间晚于
|
||||
# 最后一次解析完成)→ 清掉旧文件残留产出与旧 QC 意见,重试计数从新文件
|
||||
# 时刻重起。否则项目永久卡死:旧计数 ≥ 上限,永远不再派解析
|
||||
# (中传项目「走不下去」的另一半根因)。
|
||||
# ── A. 招标文件分析(2026-09-02 起拆成四个并行维度,各派一个任务)──
|
||||
# 1) 评分项与得分规则 2) 资质清单与要求 3) 投标文件要求与章节骨架 4) 成本收益分析
|
||||
# 各维度独立判定缺失 → 独立派发,引擎每项目并发上限(默认 4)保证真并行。
|
||||
# 新招标文件自动恢复(清旧产出重跑)在派发前统一执行。
|
||||
if n_files > 0:
|
||||
_ft = await _latest_tender_file_ts(sor, project_id)
|
||||
_at = await _latest_done_analysis_ts(sor, project_id)
|
||||
if _ft and _at and _ft > _at:
|
||||
if await _reset_analysis_for_new_file(sor, project_id, _ft):
|
||||
acts.append("recovered: 检测到新招标文件,已清空旧解析残留,重新解析")
|
||||
attempts = await _done_task_count(sor, project_id, R_ANALYST, since=_ft)
|
||||
n_redo_done = await _redo_done_count(sor, project_id, since=_ft)
|
||||
first_attempts = max(0, attempts - n_redo_done)
|
||||
qc_imp = await _qc_collect_improvements(sor, project_id)
|
||||
if qc_imp or n_redo_open > 0:
|
||||
# QC 退回重做:携带改进意见,不受首跑重试上限约束;但重做轮次有上限(防无限循环)
|
||||
if n_redo_done >= th["qc_max_round"]:
|
||||
acts.append("recovered: 检测到新招标文件,已清空旧分析残留,重新四维度分析")
|
||||
missing = await _missing_dims(n_items, n_struct, n_quals, n_cost, chapters)
|
||||
created_dims = []
|
||||
for dim, label in missing:
|
||||
if await _open_dim_tasks(sor, project_id, dim) > 0:
|
||||
acts.append("waiting: 分析维度「%s」任务在办" % label)
|
||||
continue
|
||||
_ft2 = _ft or await _latest_tender_file_ts(sor, project_id)
|
||||
attempts = await _dim_done_count(sor, project_id, dim, since=_ft2)
|
||||
n_redo_done = await _dim_redo_done_count(sor, project_id, dim, since=_ft2)
|
||||
# QC 退回重做(携带改进意见)优先于首跑判定,不计入首跑重试上限
|
||||
qc_imp = await _dim_qc_improvements(sor, project_id, dim)
|
||||
if qc_imp:
|
||||
if n_redo_done >= th["qc_max_round"]:
|
||||
ht = await find_human_task(sor, project_id, "general", status="pending")
|
||||
if not ht:
|
||||
await create_human_task(
|
||||
sor, project_id, "general",
|
||||
"分析维度「%s」重做轮次已用尽,需人工介入" % label,
|
||||
("QC 退回后该维度已重做 %d 轮,产出仍未通过契合度审核:\n%s\n\n"
|
||||
"请人工核对招标文件并修正产出物(对应管理页),"
|
||||
"或在 QC 审核记录页把对应记录 passed 改为 1 强制放行。"
|
||||
% (n_redo_done, "\n".join(qc_imp)[:3000])),
|
||||
assignee_role="owner.superuser")
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
acts.append("escalated: 维度「%s」QC 重做轮次用尽 → 人工介入" % label)
|
||||
continue
|
||||
tid = await create_role_task(
|
||||
sor, project_id, R_ANALYST,
|
||||
"%s 分析重做:%s(QC 退回,按改进意见修正)" % (pname, label),
|
||||
{"stage": "analysis_redo", "task_kind": "bid_analysis",
|
||||
"analysis_dim": dim, "qc_redo": 1, "qc_improvements": qc_imp})
|
||||
acts.append("created: 分析重做任务 %s(%s,改进意见 %d 条)" % (tid, label, len(qc_imp)))
|
||||
created_dims.append(dim)
|
||||
continue
|
||||
first_attempts = max(0, attempts - n_redo_done)
|
||||
if first_attempts >= MAX_ANALYST_ATTEMPT:
|
||||
ht = await find_human_task(sor, project_id, "general", status="pending")
|
||||
if not ht:
|
||||
await create_human_task(
|
||||
sor, project_id, "general",
|
||||
"解析重做轮次已用尽,需人工介入",
|
||||
("QC 退回后解析已重做 %d 轮,产出仍未通过契合度审核:\n%s\n\n"
|
||||
"请人工核对招标文件并修正产出物(评分项/资质/投标文件要求管理页),"
|
||||
"或在 QC 审核记录页把对应记录 passed 改为 1 强制放行。"
|
||||
% (n_redo_done, "\n".join(qc_imp)[:3000])),
|
||||
"分析维度「%s」未产出结果,需人工介入" % label,
|
||||
("该维度分析任务已完成 %d 次,产出仍为空。\n"
|
||||
"请人工检查招标文件是否可读(bid_tender_files.content_text),"
|
||||
"或人工补录该维度产出后完成本任务。" % first_attempts),
|
||||
assignee_role="owner.superuser")
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["escalated: QC 重做轮次用尽 → 人工介入"]
|
||||
acts.append("escalated: 维度「%s」重试超限 → 人工介入" % label)
|
||||
continue
|
||||
tid = await create_role_task(
|
||||
sor, project_id, R_ANALYST,
|
||||
"%s 招标文件解析重做(QC 退回,按改进意见修正)" % pname,
|
||||
{"stage": "analysis_redo", "task_kind": "bid_analysis", "qc_redo": 1,
|
||||
"qc_improvements": qc_imp})
|
||||
"%s 招标文件分析:%s" % (pname, label),
|
||||
{"stage": "analysis", "task_kind": "bid_analysis", "analysis_dim": dim})
|
||||
acts.append("created: 分析任务 %s(%s)" % (tid, label))
|
||||
created_dims.append(dim)
|
||||
if created_dims:
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["created: 解析重做任务 %s(QC 改进意见 %d 条)" % (tid, len(qc_imp))]
|
||||
if first_attempts >= MAX_ANALYST_ATTEMPT:
|
||||
ht = await find_human_task(sor, project_id, "general", status="pending")
|
||||
if not ht:
|
||||
await create_human_task(
|
||||
sor, project_id, "general", "招标文件解析未产出完整结果,需人工介入",
|
||||
("解析任务已完成 %d 次,但仍缺少:%s。\n"
|
||||
"请人工检查招标文件是否可读(bid_tender_files.content_text),"
|
||||
"或人工补录评分项/投标文件要求后完成本任务。"
|
||||
% (first_attempts,
|
||||
"、".join(x for x in [
|
||||
"评分项" if n_items == 0 else "",
|
||||
"投标文件章节结构" if n_struct == 0 else "",
|
||||
"章节骨架" if not chapters else ""] if x))),
|
||||
assignee_role="owner.superuser")
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
acts.append("escalated: 解析重试超限 → 人工介入")
|
||||
return acts
|
||||
tid = await create_role_task(
|
||||
sor, project_id, R_ANALYST,
|
||||
"%s 招标文件解析(评分项/资质/投标文件要求/章节骨架)" % pname,
|
||||
{"stage": "analysis", "task_kind": "bid_analysis"})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["created: 解析任务 %s" % tid]
|
||||
|
||||
# ── A2. 解析产出 QC 门禁(评分标准/得分规则/资质/投标文件要求/章节骨架的契合度审核)──
|
||||
# 每类产出须经 agent.qc 对照招标文件原文按 10 分制打分,得分高于通过分(默认 9.5)才放行。
|
||||
# ── A2. 分析产出 QC 门禁(五个类型逐一审核,每类一个审核任务并行;阈值见 bid_qc_pass_score)──
|
||||
qc_pending = await _qc_pending_types(sor, project_id)
|
||||
if qc_pending:
|
||||
# 轮次用尽守卫:最新审核已达上限仍未通过 → 不再空转派审核任务,
|
||||
@ -425,44 +515,57 @@ async def reconcile_project(sor, project_id, project_name=""):
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["blocked: QC 审核轮次用尽(%s),等人工介入"
|
||||
% "/".join(t for t, _, _, _ in over)]
|
||||
# awaiting_redo:产出已被 QC 清空 → 先重派解析重做,不能对空产出审核
|
||||
# awaiting_redo:产出已被 QC 清空 → 重派对应维度的重做任务,不能对空产出审核
|
||||
needs_redo = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending if s == "awaiting_redo"]
|
||||
if needs_redo:
|
||||
if await count_open_tasks(sor, project_id, role=R_ANALYST) > 0:
|
||||
return acts + ["waiting: 解析重做任务在办(QC 退回产出已清空)"]
|
||||
n_redo_done = await _redo_done_count(sor, project_id)
|
||||
if n_redo_done >= th["qc_max_round"]:
|
||||
ht = await find_human_task(sor, project_id, "general", status="pending")
|
||||
if not ht:
|
||||
await create_human_task(
|
||||
sor, project_id, "general",
|
||||
"解析重做轮次已用尽,需人工介入",
|
||||
("QC 退回后解析已重做 %d 轮,产出仍未通过契合度审核。\n"
|
||||
"请人工核对招标文件并修正产出物,或在 QC 审核记录页强制放行。"
|
||||
% n_redo_done),
|
||||
assignee_role="owner.superuser")
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["escalated: QC 重做轮次用尽 → 人工介入"]
|
||||
qc_imp = await _qc_collect_improvements(sor, project_id)
|
||||
tid = await create_role_task(
|
||||
sor, project_id, R_ANALYST,
|
||||
"%s 招标文件解析重做(QC 退回产出已清空,按改进意见重抽)" % pname,
|
||||
{"stage": "analysis_redo", "task_kind": "bid_analysis", "qc_redo": 1,
|
||||
"qc_improvements": qc_imp})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["created: 解析重做任务 %s(产出被 QC 清空:%s)"
|
||||
% (tid, "/".join(t for t, _, _, _ in needs_redo))]
|
||||
redo_dims = []
|
||||
for t, _, _, _ in needs_redo:
|
||||
dim = QC_TYPE_TO_DIM.get(t, "scoring")
|
||||
if dim in redo_dims:
|
||||
continue
|
||||
if await _open_dim_tasks(sor, project_id, dim) > 0:
|
||||
continue
|
||||
n_redo_done = await _dim_redo_done_count(sor, project_id, dim)
|
||||
if n_redo_done >= th["qc_max_round"]:
|
||||
ht = await find_human_task(sor, project_id, "general", status="pending")
|
||||
if not ht:
|
||||
await create_human_task(
|
||||
sor, project_id, "general",
|
||||
"分析维度「%s」重做轮次已用尽,需人工介入" % dim,
|
||||
("QC 退回后该维度已重做 %d 轮,产出仍未通过契合度审核。\n"
|
||||
"请人工核对招标文件并修正产出物,或在 QC 审核记录页强制放行。"
|
||||
% n_redo_done),
|
||||
assignee_role="owner.superuser")
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
continue
|
||||
qc_imp = await _dim_qc_improvements(sor, project_id, dim)
|
||||
await create_role_task(
|
||||
sor, project_id, R_ANALYST,
|
||||
"%s 分析重做:%s(QC 退回产出已清空,按改进意见重做)" % (pname, dim),
|
||||
{"stage": "analysis_redo", "task_kind": "bid_analysis",
|
||||
"analysis_dim": dim, "qc_redo": 1, "qc_improvements": qc_imp})
|
||||
redo_dims.append(dim)
|
||||
acts.append("created: 分析重做任务(%s,产出被 QC 清空)" % dim)
|
||||
if redo_dims:
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts
|
||||
return acts + ["waiting: 分析重做任务在办(QC 退回产出已清空)"]
|
||||
# 逐类型并行审核:每类一个审核任务(有在办的类型跳过)
|
||||
needs_qc = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending]
|
||||
if await count_open_tasks(sor, project_id, role=R_QC) > 0:
|
||||
return acts + ["waiting: 解析产出 QC 审核任务在办"]
|
||||
types_str = "/".join(t for t, _, _, _ in needs_qc)
|
||||
tid = await create_role_task(
|
||||
sor, project_id, R_QC,
|
||||
"%s 解析产出契合度审核(%s)" % (pname, types_str),
|
||||
{"stage": "qc_analysis", "task_kind": "bid_qc",
|
||||
"qc_types": [t for t, _, _, _ in needs_qc]})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts + ["created: QC 审核任务 %s(%s)" % (tid, types_str)]
|
||||
created_qc = []
|
||||
for t, _, _, _ in needs_qc:
|
||||
if await _open_qc_type_tasks(sor, project_id, t) > 0:
|
||||
continue
|
||||
tid = await create_role_task(
|
||||
sor, project_id, R_QC,
|
||||
"%s 解析产出契合度审核(%s)" % (pname, t),
|
||||
{"stage": "qc_analysis", "task_kind": "bid_qc", "qc_types": [t]})
|
||||
acts.append("created: QC 审核任务 %s(%s)" % (tid, t))
|
||||
created_qc.append(t)
|
||||
if created_qc:
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
return acts
|
||||
return acts + ["waiting: 解析产出 QC 审核任务在办"]
|
||||
|
||||
# ── B. 资料准备(知识库资质匹配 + 人类文件清单)──
|
||||
prep_done = await has_done_task(sor, project_id, R_PREP)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""解析产出 QC 契合度审核能力(agent.qc)。
|
||||
|
||||
对应业务:招标文件解析的每类产出物(评分项+得分规则 / 资质 / 投标文件要求 / 章节骨架)
|
||||
必须由 QC 对照招标文件原文逐项核对契合度,按 10 分制评分;**得分高于通过分(默认 9.5)才算完成**,
|
||||
必须由 QC 对照招标文件原文逐项核对契合度,按 10 分制评分;**得分高于通过分(当前 8.5)才算完成**,
|
||||
否则给出改进意见 → 清空该产出物 → 对账器重派解析任务带意见重做 → 重审。超轮次上限抛人工。
|
||||
|
||||
机制分工(与产线既有约定一致):
|
||||
@ -15,7 +15,8 @@ import logging
|
||||
from .bid_common import (
|
||||
get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int,
|
||||
record, create_human_task, find_human_task, get_thresholds, resolve_project_id,
|
||||
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPES,
|
||||
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPE_COST, QC_TYPES,
|
||||
QC_OUTPUT_TABLE,
|
||||
HT_QC_ESCALATION,
|
||||
)
|
||||
|
||||
@ -52,15 +53,16 @@ QC_DIMENSIONS = {
|
||||
"3. 评分项挂载正确:每个评分项能落到对应章节(target_chapter_no/名称匹配),无错挂漏挂;\n"
|
||||
"4. 章节要点来自评分标准原文枚举的子维度,不是通用目录。"
|
||||
),
|
||||
QC_TYPE_COST: (
|
||||
"投标成本收益分析契合度审核(对照招标文件商务条款与预算/保证金原文):\n"
|
||||
"1. 完整性:招标文件明确的成本/收益项(保证金、预算金额、付款条件等)全部覆盖,无漏项;\n"
|
||||
"2. 金额依据:招标原文明确给出的金额(预算/保证金)与原文一致,估算项标注口径与置信度;\n"
|
||||
"3. 推理合理:每项 basis 含原文引用+推理过程,无凭空编造的确定性数字;\n"
|
||||
"4. 结论一致:summary 的成本合计/收益合计与单项一致,毛利口径正确;算不出须说明原因,不得硬编。"
|
||||
),
|
||||
}
|
||||
|
||||
# 每类产出对应的落库表(QC 不通过时清空重做的对象)
|
||||
QC_OUTPUT_TABLE = {
|
||||
QC_TYPE_SCORING: "bid_scoring_items",
|
||||
QC_TYPE_QUALS: "bid_qualifications",
|
||||
QC_TYPE_REQS: "bid_doc_requirements",
|
||||
QC_TYPE_OUTLINE: "bid_chapters",
|
||||
}
|
||||
# 每类产出对应的落库表:统一从 bid_common.QC_OUTPUT_TABLE 导入(含 cost_benefit)
|
||||
|
||||
|
||||
async def _qc_type_valid(sor, qc_type):
|
||||
@ -149,6 +151,12 @@ async def start_qc(qc_type, project_id="", who=None, agent_id=None, task_id=""):
|
||||
"SELECT qual_name, is_mandatory, LEFT(requirement, 400) AS requirement "
|
||||
"FROM bid_qualifications WHERE project_id=${p}$ ORDER BY is_mandatory DESC LIMIT 300",
|
||||
{"p": project_id})
|
||||
elif qc_type == QC_TYPE_COST:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT kind, category, item_name, amount, amount_note, "
|
||||
"LEFT(basis, 500) AS basis, confidence, source_ref "
|
||||
"FROM bid_cost_benefit WHERE project_id=${p}$ ORDER BY kind DESC, order_no LIMIT 300",
|
||||
{"p": project_id})
|
||||
else:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT req_type, chapter_no, chapter_title, LEFT(requirement, 400) AS requirement, "
|
||||
@ -191,7 +199,7 @@ async def finish_qc(review_id, fit_score, improvement="", comments="",
|
||||
sc = to_float(fit_score, -1.0)
|
||||
if sc < 0 or sc > 10:
|
||||
return False, "fit_score 必须为 0~10 的数字"
|
||||
passed = sc > th["qc_pass_score"] # 严格高于通过分才算完成(9.5 → 需 >9.5)
|
||||
passed = sc > th["qc_pass_score"] # 严格高于通过分才算完成(8.5 → 需 >8.5)
|
||||
if not passed and not (improvement or "").strip():
|
||||
return False, ("QC 不通过(%.2f 未高于 %.2f)必须给出 improvement:\n"
|
||||
"逐项写清缺了什么、错在哪、对照招标文件原文哪一处,解析角色据此重做。"
|
||||
|
||||
@ -36,6 +36,43 @@ BID_ROLE_TOOL_SCHEMAS = {
|
||||
"offset": "分段起始字符(可选)", "length": "分段长度(可选)"},
|
||||
"required": [],
|
||||
},
|
||||
"extract_scoring": {
|
||||
"module": f"{_M}.bid_analysis_capability",
|
||||
"description": "分析维度1:抽取评分项+得分规则落库(任务 analysis_dim=scoring 时用)",
|
||||
"params": {"project_id": "项目ID(可选,默认当前)", "file_id": "招标文件ID(可选)",
|
||||
"offset": "分段起始字符(可选)", "length": "分段长度(可选)"},
|
||||
"required": [],
|
||||
},
|
||||
"extract_quals": {
|
||||
"module": f"{_M}.bid_analysis_capability",
|
||||
"description": "分析维度2:抽取资质清单与要求落库(任务 analysis_dim=quals 时用)",
|
||||
"params": {"project_id": "项目ID(可选,默认当前)", "file_id": "招标文件ID(可选)",
|
||||
"offset": "分段起始字符(可选)", "length": "分段长度(可选)"},
|
||||
"required": [],
|
||||
},
|
||||
"extract_reqs_outline": {
|
||||
"module": f"{_M}.bid_analysis_capability",
|
||||
"description": "分析维度3:抽取投标文件要求落库并自动生成章节骨架(任务 analysis_dim=reqs_outline 时用)",
|
||||
"params": {"project_id": "项目ID(可选,默认当前)", "file_id": "招标文件ID(可选)",
|
||||
"offset": "分段起始字符(可选)", "length": "分段长度(可选)"},
|
||||
"required": [],
|
||||
},
|
||||
"extract_cost_benefit": {
|
||||
"module": f"{_M}.bid_analysis_capability",
|
||||
"description": "分析维度4:投标成本与收益分析落库(任务 analysis_dim=cost_benefit 时用)",
|
||||
"params": {"project_id": "项目ID(可选,默认当前)", "file_id": "招标文件ID(可选)",
|
||||
"offset": "分段起始字符(可选)", "length": "分段长度(可选)"},
|
||||
"required": [],
|
||||
},
|
||||
"add_cost_benefit_item": {
|
||||
"module": f"{_M}.bid_analysis_capability",
|
||||
"description": "补录一条成本/收益项(分析漏项或人工修正时用)",
|
||||
"params": {"project_id": "项目ID(可选,默认当前)", "category": "cost|benefit",
|
||||
"item_name": "项目名", "amount": "金额数字(可选)",
|
||||
"amount_note": "金额口径说明(可选)", "basis": "推理依据(可选)",
|
||||
"confidence": "high|medium|low(可选)", "source_ref": "出处(可选)"},
|
||||
"required": ["category", "item_name"],
|
||||
},
|
||||
"add_scoring_item": {
|
||||
"module": f"{_M}.bid_analysis_capability",
|
||||
"description": "录入评分项及得分规则(抽取漏项时人工补录)",
|
||||
@ -189,7 +226,7 @@ BID_ROLE_TOOL_SCHEMAS = {
|
||||
},
|
||||
"finish_qc": {
|
||||
"module": f"{_M}.bid_qc_capability",
|
||||
"description": "落契合度分并判定:得分高于通过分(默认9.5,10分制)放行;不通过必须给改进意见,系统清空该类产出并触发解析重做",
|
||||
"description": "落契合度分并判定:得分高于通过分(当前8.5,10分制)放行;不通过必须给改进意见,系统清空该类产出并触发解析重做",
|
||||
"params": {"review_id": "start_qc 返回的评审ID", "fit_score": "契合度得分 0~10(可带一位小数)",
|
||||
"improvement": "改进意见(不通过必填:缺了什么/错在哪/对照原文哪一处)",
|
||||
"comments": "审核说明(可选)"},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user