From 1167371844fbcccb4d66beb4a4dddae38b6d5d2c Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 16 Sep 2026 12:01:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(mechanism):=20=E4=BA=94=E8=BF=9E=E4=BF=AE(2?= =?UTF-8?q?026-09-16=E7=94=98=E8=82=83=E9=A1=B9=E7=9B=AE=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?)=E2=80=94=E2=80=94=E2=91=A0cost=5Fbenefit=E7=BB=B4=E5=BA=A6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90list/update/delete=E4=B8=89=E5=B7=A5=E5=85=B7?= =?UTF-8?q?(analyst=E6=97=A0=E6=9F=A5=E7=9C=8B=E5=B7=A5=E5=85=B7=E2=86=92Q?= =?UTF-8?q?C=E9=80=80=E5=9B=9E=E7=9B=B2=E9=87=8D=E6=8A=BD=E2=86=9224?= =?UTF-8?q?=E8=A1=8C=E4=BA=92=E6=96=A5=E5=A0=86=E7=A7=AF=E4=B8=89=E8=BD=AE?= =?UTF-8?q?=E5=85=A8=E7=81=AD,=E4=B8=8E2026-09-03=20create=5Fchapter=5Fout?= =?UTF-8?q?line=E5=8F=AA=E5=A2=9E=E4=B8=8D=E6=94=B9=E5=90=8C=E6=AC=BE?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=E6=BC=8F=E4=BF=AE)=20=E2=91=A1write=5Fchapte?= =?UTF-8?q?r=E6=94=AF=E6=8C=81mode=3Dappend=E5=88=86=E6=89=B9=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0(2.2=E7=AB=A0403=E8=A1=8C=E5=93=8D=E5=BA=94=E8=A1=A8?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=86=99=E4=B8=8D=E8=BF=9B,=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E6=98=8E=E7=A1=AE=E8=A6=81=E6=B1=82=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E4=BE=A7=E5=BC=80=E6=94=BE=E5=88=86=E6=AE=B5=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?)=20=E2=91=A2bid=5Fflow=20C0=E6=AE=B5writing=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E5=9B=9E=E6=94=B6(=E5=86=99=E8=80=85?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=BB=88=E6=80=81=E5=90=8E=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E5=8D=A1writing=E6=B0=B8=E4=B9=85=E6=AD=BB=E6=80=81,1.9/2.1.4?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=8D=A1=E6=AD=BB11=E5=B0=8F=E6=97=B6;?= =?UTF-8?q?=E6=9C=89=E6=AD=A3=E6=96=87=E2=86=92written=E6=B4=BE=E8=AF=84?= =?UTF-8?q?=E5=AE=A1,=E6=97=A0=E6=AD=A3=E6=96=87=E2=86=92rejected=E9=87=8D?= =?UTF-8?q?=E6=B4=BE)=20=E2=91=A3=E5=8D=87=E7=BA=A7=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E7=BA=A7=E5=8E=BB=E9=87=8Ddedup=5Fby=5Ftitle?= =?UTF-8?q?+C1=E6=AE=B5=E4=B8=89=E6=80=81=E9=97=AD=E7=8E=AF(=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E7=BA=A7dedup=E5=90=9E=E6=8E=89=E7=AC=AC2~N=E7=AB=A0?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=BF=A1=E5=8F=B7:1/2.1.5/2.2=E4=B8=89?= =?UTF-8?q?=E7=AB=A0=E8=BE=BE=E4=B8=8A=E9=99=90=E5=8F=AA=E6=9C=892.1.7?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=BE=85=E5=8A=9E;=E4=BA=BA=E5=B7=A5done?= =?UTF-8?q?=E5=90=8Erevise=5Fcount=E5=A4=8D=E4=BD=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E6=B4=BE;=E4=BF=A1=E5=8F=B7=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=E6=8C=89=E6=A0=87=E9=A2=98=E8=A1=A5=E5=8F=91)=20=E2=91=A4revie?= =?UTF-8?q?w=5Fchapter=E5=8A=A0material=5Fmissing=E9=80=9A=E9=81=93(?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E6=9D=90=E6=96=99=E7=BC=BA=E5=A4=B1=E5=A6=82?= =?UTF-8?q?=E5=AE=9E=E6=8A=AB=E9=9C=B2=E5=90=8E=E6=94=BE=E8=A1=8C+material?= =?UTF-8?q?=5Fsupply=E4=BF=A1=E6=81=AF=E6=B5=81=E5=BE=85=E5=8A=9E,?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=83=A7=E5=86=99=E4=BD=9C=E8=BD=AE=E6=AC=A1?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E7=AB=A0=E8=8A=82,=E5=AF=B9=E9=BD=902026-09-?= =?UTF-8?q?15=E7=94=A8=E6=88=B7=E8=A3=81=E5=AE=9A=E8=B5=84=E8=B4=A8=3D?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=B5=81=E9=9D=9E=E9=97=A8=E7=A6=81=E6=B5=81?= =?UTF-8?q?)+qc=5Fescalation=E5=BE=85=E5=8A=9E=E8=87=AA=E5=8A=A8=E5=85=B3?= =?UTF-8?q?=E9=97=AD(A2b=E6=AE=B5:=E8=BD=AE=E6=AC=A1=E7=94=A8=E5=B0=BD?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87/?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E5=90=8E=E5=AF=B9=E8=B4=A6=E5=99=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=94=B6=E5=8D=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- pipeline_bidding/bid_analysis_capability.py | 106 ++++++++++++++++++ pipeline_bidding/bid_common.py | 32 ++++-- pipeline_bidding/bid_flow.py | 116 +++++++++++++++++++- pipeline_bidding/bid_review_capability.py | 66 +++++++++-- pipeline_bidding/bid_write_capability.py | 35 +++++- pipeline_bidding/role_tool_schemas.py | 32 +++++- 7 files changed, 362 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 4f8ca30..9fed849 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,11 @@ load_pipeline_bidding() # import 后必须再显式调用,漏了无任何日 `load_pipeline_bidding()`(幂等)做三件事: -1. `role_tool_schemas.register_role_tools()` — 把约 55 个投标角色工具 schema - (read_tender_file / extract_* / add_* / patch_chapters / write_chapter / - review_chapter / start_qc / finish_qc / compose_bid / start_bid_score / +1. `role_tool_schemas.register_role_tools()` — 把约 60 个投标角色工具 schema + (read_tender_file / extract_* / add_* / list_cost_benefit / update_cost_benefit_item / + delete_cost_benefit_item / patch_chapters / write_chapter(支持 mode=append 分批追加超长章节)/ + review_chapter(支持 material_missing=1 实体材料缺失如实披露放行)/ start_qc / finish_qc / + compose_bid / start_bid_score / finalize_bid_score / dispatch_analysis_dim 等)注册进引擎 `pipeline_service.capability_tools.TOOL_SCHEMAS`(引擎 `exec_capability_tool` 的 `module` 支持全路径,裸名仍走 `pipeline_service.*`,SDLC 零变化)。 diff --git a/pipeline_bidding/bid_analysis_capability.py b/pipeline_bidding/bid_analysis_capability.py index 639fc57..8734e09 100644 --- a/pipeline_bidding/bid_analysis_capability.py +++ b/pipeline_bidding/bid_analysis_capability.py @@ -829,6 +829,112 @@ async def add_cost_benefit_item(project_id, category, item_name, amount="", amou return True, "成本/收益项已录入: %s (%s)" % (item_name, category) +async def list_cost_benefit(project_id): + """列出成本/收益分析全部条目(QC 重做前必读现状,禁止盲重抽)。 + + 2026-09-16 甘肃项目实测:analyst 没有查看工具 → QC 退回后看不到旧行 → + 只能整维度重抽 → 新行换名追加、旧行无人删 → 24 行互斥重复堆积, + QC 三轮全灭抛人工(与 2026-09-03 create_chapter_outline 只增不改同款缺陷)。 + """ + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, kind, category, item_name, amount, amount_note, confidence, " + "source_ref, order_no, created_at FROM bid_cost_benefit " + "WHERE project_id=${pid}$ ORDER BY kind DESC, order_no, id", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + out = rows_to_dicts(recs) + for r in out: + r["source_ref"] = (r.get("source_ref") or "")[:200] + r["amount_note"] = (r.get("amount_note") or "")[:200] + return json.dumps({"total": len(out), "items": out}, ensure_ascii=False, default=str) + + +async def update_cost_benefit_item(project_id, record_id, item_name="", amount="", + amount_note="", basis="", confidence="", + source_ref="", who=None, agent_id=None): + """修改一条成本/收益项(QC 改进意见要求合并口径/修正引用/补算式时用)。 + + 只改传入的字段;amount 传空串不改、传 0 改为 0。改前必须先 list_cost_benefit。 + """ + if not record_id: + return False, "缺少 record_id(先 list_cost_benefit 查到目标行 id)" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT * FROM bid_cost_benefit WHERE id=${i}$ AND project_id=${p}$", + {"i": record_id, "p": project_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "记录不存在或不属于本项目: %s" % record_id + old = rec_to_dict(recs[0]) + upd = {} + if item_name: + upd["item_name"] = str(item_name)[:250] + if str(amount) not in ("", None): + upd["amount"] = to_float(amount, 0.0) or None + if amount_note: + upd["amount_note"] = str(amount_note)[:200] + if basis: + upd["basis"] = basis + if confidence: + upd["confidence"] = str(confidence)[:16] + if source_ref: + upd["source_ref"] = str(source_ref)[:480] + if not upd: + return False, "没有传入任何要修改的字段" + sets = ", ".join("%s=${%s}$" % (k, k) for k in upd) + p = dict(upd); p["i"] = record_id + await sor.sqlExe( + "UPDATE bid_cost_benefit SET " + sets + ", updated_at=NOW() WHERE id=${i}$", p) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_cost_benefit", record_id, "update", + who=who, agent_id=agent_id, + detail="; ".join("%s: %s→%s" % (k, str(old.get(k))[:40], str(v)[:40]) + for k, v in upd.items())[:480]) + return True, "成本/收益项已更新: %s(字段:%s)" % (record_id, ",".join(upd.keys())) + + +async def delete_cost_benefit_item(project_id, record_id, reason="", + who=None, agent_id=None): + """删除一条成本/收益项(QC 判定互斥重复/占位无效行时用,reason 必填留审计)。""" + if not record_id: + return False, "缺少 record_id(先 list_cost_benefit 查到目标行 id)" + if not (reason or "").strip(): + return False, "删除必须给出 reason(对照 QC 改进意见说明为何删:重复/占位/被取代)" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT item_name, category FROM bid_cost_benefit " + "WHERE id=${i}$ AND project_id=${p}$", + {"i": record_id, "p": project_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "记录不存在或不属于本项目: %s" % record_id + nm = getattr(recs[0], "item_name", "") + await sor.sqlExe( + "DELETE FROM bid_cost_benefit WHERE id=${i}$ AND project_id=${p}$", + {"i": record_id, "p": project_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_cost_benefit", record_id, "delete", + who=who, agent_id=agent_id, + detail="%s | 理由: %s" % (str(nm)[:60], str(reason)[:200])) + return True, "已删除成本/收益项: %s(理由已留审计)" % nm + + 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 结论行)。""" diff --git a/pipeline_bidding/bid_common.py b/pipeline_bidding/bid_common.py index df5aab9..be035a8 100644 --- a/pipeline_bidding/bid_common.py +++ b/pipeline_bidding/bid_common.py @@ -369,13 +369,18 @@ async def has_pending_blocking_human_task(sor, project_id): async def create_human_task(sor, project_id, task_type, title, description="", assignee_role="owner.superuser", assignee_id="", - form_schema=None): + form_schema=None, dedup_by_title=False, task_id=""): """创建人类任务(pipeline_human_tasks)。iteration_id 留空 → 走项目级门禁。 同一事项只发一次待办(2026-09-01 机制层不变量):同项目+同类型已有 pending 任务 → 直接返回已有 id,不重复创建。防止对账器/agent 每轮 重复发同类待办把用户淹没。 + dedup_by_title(2026-09-16 甘肃项目实测 bug):「事项」粒度是标题而非类型—— + 多个章节各自达重做上限都发 general 类型待办时,类型级去重把第 2~N 章的 + 升级信号静默吞掉(只剩最先那条,其余章节人工介入信号丢失)。 + True 时按 (task_type+title) 去重:同标题 pending 才复用,不同标题各发一条。 + 项目状态门禁(2026-09-15,与引擎 create_human_task 同款):项目已删除或 处于 paused/archived/cancelled/completed → 不发新待办返回 ''(暂停/删除的 项目不再产生待办;恢复推进后对账器按需补发)。 @@ -392,15 +397,28 @@ async def create_human_task(sor, project_id, task_type, title, description="", logger.info("bid create_human_task skipped: project %s status=%s (type=%s)", project_id, _st, task_type) return "" - exist = await find_human_task(sor, project_id, task_type, status="pending") - if exist: - logger.info("bid create_human_task dedup: type=%s existing=%s project=%s", - task_type, exist.get("id"), project_id) - return exist.get("id") + if dedup_by_title: + recs = await sor.sqlExe( + "SELECT id FROM pipeline_human_tasks WHERE project_id=${pid}$ " + "AND task_type=${tt}$ AND title=${ti}$ AND status='pending' " + "ORDER BY created_at DESC LIMIT 1", + {"pid": project_id, "tt": task_type, "ti": (title or "")[:480]}) + await sor.sqlExe("COMMIT", {}) + if recs: + _eid = getattr(recs[0], "id", "") + logger.info("bid create_human_task dedup(title): type=%s title=%s existing=%s", + task_type, (title or "")[:40], _eid) + return _eid + else: + exist = await find_human_task(sor, project_id, task_type, status="pending") + if exist: + logger.info("bid create_human_task dedup: type=%s existing=%s project=%s", + task_type, exist.get("id"), project_id) + return exist.get("id") hid = new_id() await sor.C("pipeline_human_tasks", { "id": hid, - "task_id": "", + "task_id": task_id or "", "step_name": task_type, "version": 1, "task_type": task_type, diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index ef065be..2e991f6 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -18,7 +18,7 @@ import logging import os from .bid_common import ( - get_db, rows_to_dicts, rec_to_dict, to_int, to_float, json_loads, + get_db, rows_to_dicts, rec_to_dict, to_int, to_float, json_loads, record, create_role_task, count_open_tasks, has_done_task, has_pending_blocking_human_task, find_human_task, get_thresholds, create_human_task, get_project, PIPELINE_ID, @@ -905,6 +905,7 @@ async def reconcile_project(sor, project_id, project_name=""): _kept.append(_qp) qc_pending = _kept over_types = set() + _over_active = False # 2026-09-16:本轮是否仍存在「轮次用尽等人工」的类型 if qc_pending: # 轮次用尽守卫:最新审核已达上限仍未通过且产出未更新 → 不再空转派审核任务, # 确保有阻塞人工任务(逃逸阀:人工修产出物或在记录页强制放行后流程自恢复)。 @@ -914,6 +915,7 @@ async def reconcile_project(sor, project_id, project_name=""): if s == "rework" and rnd >= th["qc_max_round"] and not on] if over: over_types = set(t for t, _, _, _, _ in over) + _over_active = True ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") if not ht: await create_human_task( @@ -1036,6 +1038,21 @@ async def reconcile_project(sor, project_id, project_name=""): await sor.sqlExe("COMMIT", {}) # 不提前 return:继续往下走章节级派发(依赖已满足的章节立即启动) + # ── A2b. qc_escalation 待办自动关闭(2026-09-16 甘肃项目实测 bug)── + # 升级待办发出后,人工修产出/记录页强制放行/重做通过 → 轮次用尽状态消失 + # (_over_active=False),但 pending 的 qc_escalation 待办无人关闭就永远挂着, + # 角标常驻误导「还有事没办」。逃逸阀闭环的另一半:问题解决 → 待办自动收。 + if not _over_active: + _ht_esc = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") + if _ht_esc: + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='done', qc_status='passed', " + "qc_comment='对账器自动关闭:QC 轮次用尽的类型已全部通过/放行,流程已恢复', " + "submitted_at=NOW() WHERE id=${h}$ AND status='pending'", + {"h": _ht_esc.get("id")}) + await sor.sqlExe("COMMIT", {}) + acts.append("auto-closed: qc_escalation 待办(轮次用尽类型已全部通过/放行)") + # ── B. 资料准备(知识库资质匹配 + 人类文件清单)── # 2026-09-03 章节级依赖:资料准备是商务章(business)的上游依赖, # 不再阻塞全局流转——技术/报价章在上游 QC 通过后立即开写。 @@ -1056,6 +1073,103 @@ async def reconcile_project(sor, project_id, project_name=""): if not chapters: return acts + ["idle: 无章节,等待解析产出章节骨架"] + # ── C0. writing 孤儿章节回收(2026-09-16 甘肃项目实测 bug)── + # 写者任务 write_chapter 落 writing 后进程被打断/超时终态(任务 approved/failed/ + # cancelled),章节停在 writing:C 段只扫 pending/rejected、D 段只扫 written, + # writing+无任务 = 永久死态(1.9/2.1.4 两章卡死 11 小时无人回收)。 + # 回收规则:无任何在办任务时——有正文 → written(D 段自动派评审续走质量循环); + # 无正文 → rejected(C 段重派编写)。revise_count 不动(评审才是计轮主体)。 + for c in chapters: + if c.get("status") != CH_WRITING: + continue + if await count_open_tasks(sor, project_id, chapter_id=c["id"]) > 0: + continue # 写者/评审任务还在办,不是孤儿 + _has_txt = await sor.sqlExe( + "SELECT LENGTH(content) AS n FROM bid_chapters WHERE id=${i}$", {"i": c["id"]}) + await sor.sqlExe("COMMIT", {}) + _n = to_int(getattr(_has_txt[0], "n", 0) if _has_txt else 0, 0) + _new_st = CH_WRITTEN if _n > 0 else CH_REJECTED + await sor.sqlExe( + "UPDATE bid_chapters SET status=${st}$, updated_at=NOW() " + "WHERE id=${i}$ AND status=${old}$", + {"st": _new_st, "i": c["id"], "old": CH_WRITING}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_chapters", c["id"], "orphan_recover", + from_state=CH_WRITING, to_state=_new_st, who="bid_flow", + detail="writing 孤儿回收:无在办任务,正文 %d 字符 → %s" % (_n, _new_st)) + c["status"] = _new_st # 本轮内存态同步,后续段直接可用 + acts.append("recovered: 章[%s] writing 孤儿回收 → %s(无在办任务)" + % (c.get("chapter_no"), _new_st)) + if any("orphan_recover" in (a or "") or "recovered: 章" in (a or "") for a in acts): + await sor.sqlExe("COMMIT", {}) + + # ── C1. 超重做上限章节的人工闭环(2026-09-16 甘肃项目实测 bug)── + # 旧行为缺陷:升级待办只在 review_chapter 调用瞬间发,且 general 类型级去重 + # 会把第 2~N 章的信号吞掉;人工完成待办后章节永久冻结(revise_count 已超限, + # 无任何复位路径)。三态闭环: + # ① 有 pending 升级待办(标题精确匹配本章)→ 真在等人工,跳过; + # ② 无 pending 但有 done → 人工已处理 → revise_count 复位 0,C 段自动重派 + # 写作(人工确认换一轮写作机会,留审计); + # ③ 无 pending 也无 done(信号曾丢失/从未发过)→ 按标题补发升级待办。 + _esc_types = ("material_supply", "general") + for c in chapters: + if c.get("status") != CH_REJECTED: + continue + if to_int(c.get("revise_count"), 0) <= th["max_revise"]: + continue + _titles = ("章节反复不达标需人工介入:%s %s" % (c.get("chapter_no"), c.get("title")), + "章节实体材料缺口(信息流):%s %s" % (c.get("chapter_no"), c.get("title"))) + _in = ",".join("'${t%d}$'" % i for i in range(len(_titles))) + _p = {"pid": project_id} + for i, t in enumerate(_titles): + _p["t%d" % i] = t[:480] + _recs = await sor.sqlExe( + "SELECT id, status FROM pipeline_human_tasks WHERE project_id=${pid}$ " + "AND task_type IN ('" + "','".join(_esc_types) + "') AND title IN (" + _in + ")", + _p) + await sor.sqlExe("COMMIT", {}) + _rows = rows_to_dicts(_recs) + if any(r.get("status") == "pending" for r in _rows): + continue # ① 等人工中 + if any(r.get("status") == "done" for r in _rows): + # ② 人工已处理 → 复位重派 + await sor.sqlExe( + "UPDATE bid_chapters SET revise_count=0, updated_at=NOW() " + "WHERE id=${i}$ AND status=${old}$", + {"i": c["id"], "old": CH_REJECTED}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_chapters", c["id"], "human_reset", + to_state=CH_REJECTED, who="bid_flow", + detail="人工已完成升级待办 → revise_count 复位,自动重派写作") + c["revise_count"] = 0 + acts.append("recovered: 章[%s] 人工处理完成,revise_count 复位自动重派" + % c.get("chapter_no")) + else: + # ③ 信号丢失/从未发过 → 补发(材料缺失挂起的章按评审意见判类型) + _cmr = await sor.sqlExe( + "SELECT LEFT(review_comment, 1600) AS rc FROM bid_chapters WHERE id=${i}$", + {"i": c["id"]}) + await sor.sqlExe("COMMIT", {}) + _cm = (getattr(_cmr[0], "rc", "") if _cmr else "") or "" + if "材料缺失" in _cm and "非写作缺陷" in _cm: + _tt, _ti = "material_supply", _titles[1] + _desc = ("章节「%s %s」评审判定实体材料缺失(补发信息流待办)。\n评审意见:\n%s\n\n" + "如需补材料请上传后走增补轮重写该章;不补则完成本待办即可。" + % (c.get("chapter_no"), c.get("title"), _cm[:1500])) + else: + _tt, _ti = "general", _titles[0] + _desc = ("章节「%s %s」已重做 %d 次仍未达门限(补发升级待办,原信号丢失)。\n" + "评审意见:\n%s\n\n" + "请人工判断:补充素材/调整章节范围/降低目标分,处理完成后完成本任务。" + % (c.get("chapter_no"), c.get("title"), + to_int(c.get("revise_count"), 0), _cm[:1500])) + await create_human_task( + sor, project_id, _tt, _ti, _desc, + assignee_role="owner.superuser", dedup_by_title=True) + await sor.sqlExe("COMMIT", {}) + acts.append("escalated: 章[%s] 超重做上限且无待办记录,补发人工介入待办" + % c.get("chapter_no")) + # ── C. 章节编写(一章一任务、并发派发;pending/rejected 且无在办任务)── # 商务/技术分角色:section=business → 商务标写者;其余(technical/price)→ 技术标写者。 # 并发:单轮最多派 write_concurrency 个编写任务(引擎另有每项目并发上限兜底)。 diff --git a/pipeline_bidding/bid_review_capability.py b/pipeline_bidding/bid_review_capability.py index 8e56997..f45898d 100644 --- a/pipeline_bidding/bid_review_capability.py +++ b/pipeline_bidding/bid_review_capability.py @@ -26,11 +26,16 @@ async def _chapter(sor, chapter_id): async def review_chapter(chapter_id, score, max_score="", comments="", detail="", - who=None, agent_id=None, task_id=""): + material_missing="", who=None, agent_id=None, task_id=""): """章节评审落库 + 按门限判定通过/退回。 score / max_score:按该章节对应评分项的得分规则打分(max_score 省略时取章节 max_score)。 comments:不通过时必须给出可执行的改进意见(缺失则拒绝,避免"退回但不说怎么改")。 + material_missing(2026-09-16 甘肃项目 2.1.5/2.1.7 实测 bug):评审判定失分全部因 + 「实体材料缺失」(证书扫描件/截图/业绩证明未回传)、写作层已达可写上限时传 1—— + 章节如实披露缺口后通过(不烧写作轮次、不冻结流转),同时发 material_supply + 信息流待办通知人类补料,材料到位后可走增补轮重写该章。 + 与 2026-09-15 用户裁定一致:资质缺口=信息流,非门禁流。 """ if not chapter_id: return False, "缺少 chapter_id" @@ -109,6 +114,42 @@ async def review_chapter(chapter_id, score, max_score="", comments="", detail="" return True, ("章节「%s」评审通过 %.2f/%.2f(%.0f%%,门限 %.0f%%)" % (c.get("title"), sc, ms, ratio * 100, th["chapter_pass_ratio"] * 100)) + # ── 实体材料缺失通道(2026-09-16):如实标注后通过 + 信息流待办 ── + # 旧行为 bug:证书扫描件/截图未回传 → 评审每轮打低分退回 → 写者无法变出 + # 材料仍重写 → 烧光 revise_count → 章节冻结阻塞合成。与 2026-09-15 用户裁定 + # 「资质缺口=信息流待办,非门禁流」对齐:写作层已达上限、失分全因实体材料 + # 未回传 → 章节通过(真实分数+材料缺失披露注记,整书评分如实反映), + # 同时发 material_supply 信息流待办通知人类补料,不冻结任何流转。 + _mat_missing = str(material_missing).strip().lower() in ("1", "true", "yes", "y") + if _mat_missing: + await sor.sqlExe( + "UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, " + "review_comment=${cm}$, updated_at=NOW() WHERE id=${cid}$", + {"st": CH_APPROVED, "sc": sc, "ms": ms, + "cm": ("【材料缺失如实披露后放行(2026-09-15 用户裁定:资质缺口=信息流" + "非门禁流)】实体材料未回传致失分 %.2f/%.2f,写作层已达可写上限," + "不计写作重做轮次;已发人类供料待办,材料到位后可增补。\n" + % (sc, ms) + (comments or ""))[:60000], + "cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, TABLE, chapter_id, "review_material_missing_waive", + from_state=c.get("status"), to_state=CH_APPROVED, + who=who, agent_id=agent_id, + detail="%.2f/%.2f 实体材料缺失如实披露放行(不计轮次)" % (sc, ms)) + title = "章节实体材料缺口(信息流):%s %s" % (c.get("chapter_no"), c.get("title")) + await create_human_task( + sor, pid, "material_supply", title, + ("章节「%s %s」评审判定:写作层已达可写上限,失分 %.2f/%.2f 全部因实体材料" + "未回传。章节已如实披露缺口后放行(不阻塞标书推进)。\n\n评审意见:\n%s\n\n" + "如需补材料(证书扫描件/截图/业绩证明),上传到项目工作空间/知识库后" + "可在增补轮重写该章;不补则完成本待办即可。" + % (c.get("chapter_no"), c.get("title"), sc, ms, (comments or "")[:1500])), + assignee_role="owner.superuser", dedup_by_title=True) + await sor.sqlExe("COMMIT", {}) + return True, ("章节「%s」评审 %.2f/%.2f:失分全部因实体材料缺失(非写作缺陷)," + "已如实披露放行 + 发信息流供料待办「%s」,不阻塞标书推进。" + % (c.get("title"), sc, ms, title)) + revise = to_int(c.get("revise_count"), 0) + 1 await sor.sqlExe( "UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, " @@ -122,18 +163,19 @@ async def review_chapter(chapter_id, score, max_score="", comments="", detail="" detail="%.2f/%.2f revise=%d" % (sc, ms, revise)) if revise > th["max_revise"]: - exist = await find_human_task(sor, pid, "general", status="pending") + # 2026-09-16 标题级去重:多章各自达上限各发一条(旧代码类型级 dedup, + # 第 2~N 章升级信号被第一条 general pending 吞掉——甘肃项目 1/2.1.5/2.2 + # 三章达上限只有 2.1.7 一条待办,其余静默丢失)。 title = "章节反复不达标需人工介入:%s %s" % (c.get("chapter_no"), c.get("title")) - if not (exist and exist.get("title") == title): - await create_human_task( - sor, pid, "general", title, - ("章节「%s %s」已重做 %d 次仍未达门限(最近得分 %.2f/%.2f,门限 %.0f%%)。\n" - "最新改进意见:\n%s\n\n" - "请人工判断:补充素材/调整章节范围/降低目标分,处理完成后完成本任务。" - % (c.get("chapter_no"), c.get("title"), revise, sc, ms, - th["chapter_pass_ratio"] * 100, (comments or "")[:1500])), - assignee_role="owner.superuser") - await sor.sqlExe("COMMIT", {}) + await create_human_task( + sor, pid, "general", title, + ("章节「%s %s」已重做 %d 次仍未达门限(最近得分 %.2f/%.2f,门限 %.0f%%)。\n" + "最新改进意见:\n%s\n\n" + "请人工判断:补充素材/调整章节范围/降低目标分,处理完成后完成本任务。" + % (c.get("chapter_no"), c.get("title"), revise, sc, ms, + th["chapter_pass_ratio"] * 100, (comments or "")[:1500])), + assignee_role="owner.superuser", dedup_by_title=True) + await sor.sqlExe("COMMIT", {}) return True, ("章节「%s」评审不通过 %.2f/%.2f,已达重做上限 %d 次 → 已抛人工介入任务" % (c.get("title"), sc, ms, th["max_revise"])) return True, ("章节「%s」评审不通过 %.2f/%.2f(%.0f%% < 门限 %.0f%%),已退回重写" diff --git a/pipeline_bidding/bid_write_capability.py b/pipeline_bidding/bid_write_capability.py index 1eb34fd..48fe2c8 100644 --- a/pipeline_bidding/bid_write_capability.py +++ b/pipeline_bidding/bid_write_capability.py @@ -77,15 +77,27 @@ async def chapter_detail(chapter_id, with_content="1", length=12000): return json.dumps(out, ensure_ascii=False, default=str) -async def write_chapter(chapter_id, content, summary="", who=None, agent_id=None): - """写入/覆盖章节正文:pending/rejected/writing → writing,版本+1,字数统计。""" +async def write_chapter(chapter_id, content, summary="", mode="replace", + who=None, agent_id=None): + """写入章节正文:mode=replace 覆盖 / append 追加,版本+1,字数统计。 + + append(2026-09-16 新增,甘肃项目 2.2 章实测):超长章节(如 403 行技术 + 参数响应表)一次 write 超出单条工具调用可承载长度写不进去,评审明确要求 + 「机制侧开放 write_chapter 分段追加,按标的分批每批 50~80 行」。 + 追加时自动在两段之间补换行;篇幅门禁按累计总字数判定。 + """ if not chapter_id: return False, "缺少 chapter_id" txt = content or "" - if len(txt.strip()) < MIN_CONTENT_LEN: + mode = (mode or "replace").strip().lower() + if mode not in ("replace", "append"): + return False, "mode 只支持 replace(覆盖)或 append(追加)" + if len(txt.strip()) < MIN_CONTENT_LEN and mode == "replace": return False, ("章节正文过短(%d 字符 < %d),拒绝写入。请按招标文件要求与评分规则" "写到操作层面(量化指标/流程步骤/责任人/表单),不要写空话。" % (len(txt.strip()), MIN_CONTENT_LEN)) + if mode == "append" and not txt.strip(): + return False, "追加内容为空" # ── 配图形态硬门禁(2026-09-15 用户要求:标书章节的图必须 t2i/i2i 真图)── # 确定性判定(复用 pipeline_service.diagram_gate),mermaid/ASCII 伪图拒绝写入; # 平台无图像模型时正文如实标注「配图缺失」即豁免。 @@ -108,16 +120,27 @@ async def write_chapter(chapter_id, content, summary="", who=None, agent_id=None if d.get("status") == CH_APPROVED: return False, "章节已评审通过(approved),不可覆盖;如需修改请先由评审/评分角色退回" ver = to_int(d.get("version"), 1) + if mode == "append": + old_txt = d.get("content") or "" + new_txt = (old_txt.rstrip() + "\n\n" + txt.strip() + "\n") if old_txt.strip() \ + else (txt.strip() + "\n") + else: + new_txt = txt await sor.sqlExe( "UPDATE " + TABLE + " SET content=${c}$, word_count=${w}$, status=${st}$, " "version=${v}$, updated_at=NOW() WHERE id=${cid}$", - {"c": txt, "w": len(txt), "st": CH_WRITING, "v": ver + 1, "cid": chapter_id}) + {"c": new_txt, "w": len(new_txt), "st": CH_WRITING, "v": ver + 1, "cid": chapter_id}) await sor.sqlExe("COMMIT", {}) - await record(sor, d.get("project_id"), TABLE, chapter_id, "write", + await record(sor, d.get("project_id"), TABLE, chapter_id, + "append" if mode == "append" else "write", from_state=d.get("status"), to_state=CH_WRITING, who=who, agent_id=agent_id, detail=(summary or "")[:480]) + if mode == "append": + return True, ("章节「%s」已追加 %d 字符(累计 %d 字符,v%d)。" + "全部批次写完后回读核对总行数/总字数,再调 submit_chapter 提交评审。" + % (d.get("title"), len(txt), len(new_txt), ver + 1)) return True, ("章节「%s」正文已保存(%d 字符,v%d)。确认完成后调用 submit_chapter 提交评审。" - % (d.get("title"), len(txt), ver + 1)) + % (d.get("title"), len(new_txt), ver + 1)) async def submit_chapter(chapter_id, note="", who=None, agent_id=None): diff --git a/pipeline_bidding/role_tool_schemas.py b/pipeline_bidding/role_tool_schemas.py index f1e7052..bba1826 100644 --- a/pipeline_bidding/role_tool_schemas.py +++ b/pipeline_bidding/role_tool_schemas.py @@ -73,6 +73,28 @@ BID_ROLE_TOOL_SCHEMAS = { "confidence": "high|medium|low(可选)", "source_ref": "出处(可选)"}, "required": ["category", "item_name"], }, + "list_cost_benefit": { + "module": f"{_M}.bid_analysis_capability", + "description": "列出成本/收益分析全部条目。QC 重做/修正前必读现状——禁止看不到旧行就盲重抽(会堆积互斥重复条)", + "params": {"project_id": "项目ID(可选,默认当前)"}, + "required": [], + }, + "update_cost_benefit_item": { + "module": f"{_M}.bid_analysis_capability", + "description": "修改一条成本/收益项(QC 意见要求合并口径/修正引用/补算式时用;只改传入字段,改前先 list_cost_benefit 查 id)", + "params": {"project_id": "项目ID(可选,默认当前)", "record_id": "目标行id(必填)", + "item_name": "新名称(可选)", "amount": "新金额(可选)", + "amount_note": "新口径说明(可选)", "basis": "新推理依据(可选)", + "confidence": "high|medium|low(可选)", "source_ref": "新出处(可选)"}, + "required": ["record_id"], + }, + "delete_cost_benefit_item": { + "module": f"{_M}.bid_analysis_capability", + "description": "删除一条成本/收益项(QC 判定互斥重复/占位无效行时用;reason 必填留审计)", + "params": {"project_id": "项目ID(可选,默认当前)", "record_id": "目标行id(必填)", + "reason": "删除理由(必填:重复/占位/被哪条取代)"}, + "required": ["record_id", "reason"], + }, "add_scoring_item": { "module": f"{_M}.bid_analysis_capability", "description": "录入评分项及得分规则(抽取漏项时人工补录)", @@ -195,8 +217,9 @@ BID_ROLE_TOOL_SCHEMAS = { }, "write_chapter": { "module": f"{_M}.bid_write_capability", - "description": "写入章节正文(写到操作层面,禁止空话;有最小长度门槛)", - "params": {"chapter_id": "章节ID", "content": "章节正文", "summary": "本次写作要点(可选)"}, + "description": "写入章节正文(写到操作层面,禁止空话;有最小长度门槛)。超长章节(如几百行响应表)用 mode=append 分批追加,每批50~80行,全部写完回读核对再 submit", + "params": {"chapter_id": "章节ID", "content": "本批正文", + "mode": "replace覆盖(默认)|append追加", "summary": "本次写作要点(可选)"}, "required": ["chapter_id", "content"], }, "submit_chapter": { @@ -208,9 +231,10 @@ BID_ROLE_TOOL_SCHEMAS = { # ── bid_review_capability:标书评审员 ── "review_chapter": { "module": f"{_M}.bid_review_capability", - "description": "章节评审打分:对照评分项得分规则+招标文件/投标文件要求;不达标必须给改进意见", + "description": "章节评审打分:对照评分项得分规则+招标文件/投标文件要求;不达标必须给改进意见。若写作层已达可写上限、失分全部因实体材料未回传(证书扫描件/截图等写作者变不出的东西),传material_missing=1——章节如实披露后通过不冻结流转,自动发信息流供料待办,禁止再烧写作轮次", "params": {"chapter_id": "章节ID", "score": "得分(数字)", "max_score": "满分(可选,默认取章节挂载满分)", - "comments": "评审意见/改进意见(不通过必填)", "detail": "评审明细(可选)"}, + "comments": "评审意见/改进意见(不通过必填)", "detail": "评审明细(可选)", + "material_missing": "1=失分因实体材料缺失且写作者无法闭环(可选)"}, "required": ["chapter_id", "score"], }, "approve_chapter": {