diff --git a/pipeline_bidding/bid_compose_capability.py b/pipeline_bidding/bid_compose_capability.py index 4d9ca4e..7fdfe83 100644 --- a/pipeline_bidding/bid_compose_capability.py +++ b/pipeline_bidding/bid_compose_capability.py @@ -8,6 +8,7 @@ docx 生成用 python-docx;不可用时降级为 md(诚实降级,不假装 import json import logging import os +import re from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, record, @@ -105,7 +106,6 @@ def _parse_md_image(line): """解析一行 markdown 图片语法,返回 (alt, url) 或 None。""" global _MD_IMG_RE if _MD_IMG_RE is None: - import re _MD_IMG_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$') m = _MD_IMG_RE.match(line or '') if not m: @@ -113,6 +113,35 @@ def _parse_md_image(line): return m.group(1), m.group(2) +_MD_TABLE_SEP_RE = re.compile(r'^\s*\|?[\s:|-]+\|[\s:|-]*$') + + +def _is_table_sep(line): + """markdown 表头分隔行(|---|---| 或 |:--|--:|)。""" + s = (line or '').strip() + return bool(s) and '-' in s and set(s) <= set('|-: ') + + +def _split_md_row(line): + """拆分一行 markdown 表格为单元格列表(去首尾竖线、按 | 切、strip)。""" + s = (line or '').strip() + if s.startswith('|'): + s = s[1:] + if s.endswith('|'): + s = s[:-1] + return [c.strip() for c in s.split('|')] + + +def _clean_md_cell(text): + """清理单元格内联 markdown(**粗体**/`code`/图片占位)→ 纯文本。""" + t = text or '' + t = re.sub(r'!\[([^\]]*)\]\([^)]*\)', r'\1', t) # 图片→alt + t = re.sub(r'\*\*([^*]+)\*\*', r'\1', t) # 粗体 + t = re.sub(r'`([^`]+)`', r'\1', t) # 行内代码 + t = re.sub(r'', ' ', t, flags=re.I) # 换行标签 + return t.strip() + + def _write_docx(path, title, subtitle, chapters): """用 python-docx 写标书。返回 (ok, page_hint_or_err)。 @@ -155,10 +184,45 @@ def _write_docx(path, title, subtitle, chapters): doc.add_page_break() img_ok = img_fail = 0 + tbl_ok = 0 for c in chapters: doc.add_heading("%s %s" % (c.get("chapter_no", ""), c.get("title", "")), level=1) - for line in (c.get("content") or "").split("\n"): - t = line.rstrip() + _lines = (c.get("content") or "").split("\n") + _i = 0 + _n = len(_lines) + while _i < _n: + t = _lines[_i].rstrip() + # ── markdown 表格块 → Word 表格(2026-09-16 用户报障:表格散成文字)── + # 识别:当前行含 '|' 且下一行是分隔行(|---|)。缓冲连续表格行整体渲染。 + if '|' in t and _i + 1 < _n and _is_table_sep(_lines[_i + 1]): + _rows = [_split_md_row(t)] + _i += 2 # 跳过表头行 + 分隔行 + while _i < _n: + _rt = _lines[_i].rstrip() + if '|' not in _rt or not _rt.strip(): + break + _rows.append(_split_md_row(_rt)) + _i += 1 + _ncol = max(len(r) for r in _rows) + try: + _tbl = doc.add_table(rows=0, cols=_ncol) + _tbl.style = 'Table Grid' + for _ri, _r in enumerate(_rows): + _cells = _tbl.add_row().cells + for _ci in range(_ncol): + _txt = _clean_md_cell(_r[_ci] if _ci < len(_r) else '') + _para = _cells[_ci].paragraphs[0] + _run = _para.add_run(_txt) + _run.font.size = Pt(9) + if _ri == 0: + _run.bold = True + tbl_ok += 1 + except Exception as e: + doc.add_paragraph("[表格渲染失败:%s]" % str(e)[:80]) + for _r in _rows: + doc.add_paragraph(" | ".join(_clean_md_cell(x) for x in _r)) + continue + _i += 1 if not t: continue img = _parse_md_image(t) @@ -191,12 +255,25 @@ def _write_docx(path, title, subtitle, chapters): doc.add_heading(t[2:], level=2) elif t.lstrip().startswith(("- ", "* ")): doc.add_paragraph(t.lstrip()[2:], style="List Bullet") + elif _is_table_sep(t): + continue # 游离分隔行(无表头)跳过 + elif t.strip().startswith('|') and t.strip().endswith('|'): + # 单行竖线内容但无分隔行跟随 → 按表格行渲染为单行表,避免散文字 + _cells1 = [_clean_md_cell(x) for x in _split_md_row(t)] + try: + _t1 = doc.add_table(rows=1, cols=max(1, len(_cells1))) + _t1.style = 'Table Grid' + for _ci, _cv in enumerate(_cells1): + _t1.rows[0].cells[_ci].paragraphs[0].add_run(_cv).font.size = Pt(9) + tbl_ok += 1 + except Exception: + doc.add_paragraph(t) else: doc.add_paragraph(t) doc.add_page_break() doc.save(path) - if img_ok or img_fail: - logger.info("compose: docx 配图嵌入 成功%d 失败%d", img_ok, img_fail) + if img_ok or img_fail or tbl_ok: + logger.info("compose: docx 配图嵌入 成功%d 失败%d;表格%d个", img_ok, img_fail, tbl_ok) return True, "" except Exception as e: return False, "%s: %s" % (type(e).__name__, str(e)[:200]) diff --git a/pipeline_bidding/bid_review_capability.py b/pipeline_bidding/bid_review_capability.py index f45898d..c3e296b 100644 --- a/pipeline_bidding/bid_review_capability.py +++ b/pipeline_bidding/bid_review_capability.py @@ -79,6 +79,20 @@ async def review_chapter(chapter_id, score, max_score="", comments="", detail="" "生成真实图片,产物 URL 以 ![图N 标题](URL) 独占一行嵌入;" "删除 mermaid/ASCII 字符画后重写提交。" % ";".join(sorted(set(_dg)))) comments = (dg_note + "\n" + (comments or "")).strip() + + # ── 实质性响应门禁(2026-09-16 用户报障根治):指南体/表单大面积待填必退 ── + # submit_chapter 已拦一道;此处兜底存量正文/绕行写入,章节评审级即给反馈 + # (不必等整书评分),意见自动附带。确定性检测,不采信评分器满分。 + try: + from pipeline_service.substance_gate import find_substance_problems, gate_message + _sg = find_substance_problems(c.get("content") or "") + _sg_note = gate_message(_sg, scene="本章") if _sg else "" + except ImportError: + _sg_note = "" + if _sg_note: + passed = False + comments = (_sg_note + "\n" + (comments or "")).strip() + _substance_fail = bool(_sg_note) # 实质门禁不通过 → 禁止 material_missing 豁免绕过 if not passed and not (comments or "").strip(): return False, ("评审不通过必须给出改进意见(comments)," "写清缺哪些评分维度、应补什么内容、对照哪条得分规则") @@ -121,6 +135,14 @@ async def review_chapter(chapter_id, score, max_score="", comments="", detail="" # 未回传 → 章节通过(真实分数+材料缺失披露注记,整书评分如实反映), # 同时发 material_supply 信息流待办通知人类补料,不冻结任何流转。 _mat_missing = str(material_missing).strip().lower() in ("1", "true", "yes", "y") + # 守卫(2026-09-16):实质门禁不通过的章节禁止用 material_missing 豁免绕过—— + # 「指南体/表单大面积待填」不是实体材料缺失,是写作本身没写实质内容; + # 若放行会把我上一轮加的占位符豁免变成质量门禁的后门(实测满分放行的帮凶)。 + if _mat_missing and _substance_fail: + _mat_missing = False + comments = ("【material_missing 豁免被机制拒绝】本章未通过实质性响应门禁" + "(是指南体/表单待填,非实体材料缺失),不适用材料缺失豁免通道," + "必须按实质门禁意见重写正文。\n" + (comments or "")).strip() if _mat_missing: await sor.sqlExe( "UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, " diff --git a/pipeline_bidding/bid_score_capability.py b/pipeline_bidding/bid_score_capability.py index cc394e0..078817b 100644 --- a/pipeline_bidding/bid_score_capability.py +++ b/pipeline_bidding/bid_score_capability.py @@ -200,6 +200,30 @@ async def finalize_bid_score(review_id, comments="", who=None, agent_id=None): ratio = (total / maxsum) if maxsum > 0 else 0.0 passed = (ratio >= th["bid_pass_ratio"]) and not veto_fail + # ── 实质性响应确定性门禁(2026-09-16 用户报障根治,最关键的一道)── + # 实测:指南体标书(投标函全是【待填】、实施方案写「本章覆盖评分子项①②③」) + # 被 LLM 评分器判 100/100 满分放行——评分报告自己都列了「无效标」却 passed。 + # 根治:质量闸门不靠 LLM 自觉,机制层用 substance_gate 确定性扫全书章节正文, + # 命中指南体/表单大面积待填 → 强制 passed=False,命中章节退回重写。 + # 与 diagram_gate 同一设计哲学(花钱/交付闸门必须确定性校验兜底)。 + substance_chs = [] # [(chapter_id, chapter_no, title, problems)] + try: + from pipeline_service.substance_gate import find_substance_problems + _chrecs = await sor.sqlExe( + "SELECT id, chapter_no, title, content FROM bid_chapters " + "WHERE project_id=${p}$ ORDER BY order_no, chapter_no", {"p": pid}) + await sor.sqlExe("COMMIT", {}) + for _c in (_chrecs or []): + _cd = rec_to_dict(_c) + _probs = find_substance_problems(_cd.get("content") or "") + if _probs: + substance_chs.append((_cd.get("id"), _cd.get("chapter_no"), + _cd.get("title"), _probs)) + except ImportError: + pass # 宿主未装 pipeline-service 时跳过(逃逸阀) + if substance_chs: + passed = False + weak = [s for s in scores if to_float(s.get("max_score"), 0.0) > 0 and to_float(s.get("score"), 0.0) / to_float(s.get("max_score"), 1.0) < th["bid_pass_ratio"]] @@ -207,6 +231,13 @@ async def finalize_bid_score(review_id, comments="", who=None, agent_id=None): % (total, maxsum, ratio * 100, th["bid_pass_ratio"] * 100)] if veto_fail: summary_lines.append("⛔ 否决项未响应:" + "、".join(veto_fail[:5])) + if substance_chs: + summary_lines.append("⛔ 实质性响应门禁不通过:%d 个章节是「编写指南体/表单大面积待填」" + "(不是投标文件正文本身),机制确定性拦截(不采信评分满分):" + % len(substance_chs)) + for _cid, _cno, _ct, _probs in substance_chs[:12]: + summary_lines.append(" - 第%s章《%s》:%s" + % (_cno, (_ct or "")[:24], ";".join(_probs)[:200])) for s in weak[:20]: summary_lines.append("- %s %.1f/%.1f → %s" % (s.get("item_name"), to_float(s.get("score")), @@ -260,10 +291,43 @@ async def finalize_bid_score(review_id, comments="", who=None, agent_id=None): # 把改进意见落到问题章节:approved → rejected(reconciler 会派重写任务) touched = 0 + # ① 实质性门禁命中章节强制退回(评分器可能给它们满分,但机制判定是指南体/待填) + for _cid, _cno, _ct, _probs in substance_chs: + if not _cid: + continue + _crec = await sor.sqlExe( + "SELECT id, status, revise_count FROM bid_chapters WHERE id=${c}$", {"c": _cid}) + await sor.sqlExe("COMMIT", {}) + if not _crec: + continue + _c = rec_to_dict(_crec[0]) + if _c.get("status") == CH_REJECTED: + continue # 已退回,避免 revise_count 重复累加 + _imp = ("【实质性响应门禁 · 第%s轮 · 机制确定性拦截】本章是「编写指南/作业指导书」" + "或表单大面积未填实,不是投标文件正文本身。检出:%s\n" + "必须重写为正文:① 删除全部「编制说明/编制依据/填写口径/自查表/人工填写清单」" + "等讲『怎么写』的元内容;② 投标函/承诺/报价按招标格式**直接填真实内容**" + "(投标人/报价/日期等能从项目或知识库取到的填实,实体材料缺失用规范占位符" + "【材料待补:具体材料名】,禁止【待填-xxx】自造占位);③ 技术/实施方案写实质" + "措施(架构/流程/量化指标/责任人/工期),不写「覆盖评分子项①②③」式说明。" + % (review.get("round"), ";".join(_probs)[:600])) + await sor.sqlExe( + "UPDATE bid_chapters SET status=${st}$, review_comment=${cm}$, " + "revise_count=${rc}$, updated_at=NOW() WHERE id=${c}$", + {"st": CH_REJECTED, "cm": _imp[:60000], + "rc": to_int(_c.get("revise_count"), 0) + 1, "c": _cid}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, "bid_chapters", _cid, "substance_reject", + from_state=_c.get("status"), to_state=CH_REJECTED, + who=who, agent_id=agent_id, + detail="实质性门禁退回:%s" % (";".join(_probs))[:300]) + touched += 1 + # ② 评分不达标章节退回(原有逻辑) + _substance_cids = set(_cid for _cid, _, _, _ in substance_chs if _cid) for s in weak: cid = s.get("target_chapter_id") or "" - if not cid: - continue + if not cid or cid in _substance_cids: + continue # 已被 substance 门禁退回,避免 revise_count 重复累加 crec = await sor.sqlExe( "SELECT id, status, revise_count, title FROM bid_chapters WHERE id=${c}$", {"c": cid}) diff --git a/pipeline_bidding/bid_write_capability.py b/pipeline_bidding/bid_write_capability.py index 08d484f..725b17d 100644 --- a/pipeline_bidding/bid_write_capability.py +++ b/pipeline_bidding/bid_write_capability.py @@ -169,6 +169,18 @@ async def submit_chapter(chapter_id, note="", who=None, agent_id=None): d = rec_to_dict(recs[0]) if len((d.get("content") or "").strip()) < MIN_CONTENT_LEN: return False, "章节正文为空或过短,请先 write_chapter 写入正文" + # ── 实质性响应硬门禁(2026-09-16 用户报障根治)── + # 拦「编写指南体/表单大面积待填」——交付的必须是投标文件正文本身,不是 + # 「教人怎么写标书」的元内容(编制说明/填写口径/自查表/【待填-xxx】占位)。 + # 确定性检测(substance_gate),不靠评审 LLM 自觉(实测指南体曾拿 100/100 满分)。 + # 挂在 submit_chapter(定稿提交唯一关口):append 分批写中途不误判,提交时查全文。 + try: + from pipeline_service.substance_gate import find_substance_problems, gate_message + _sg = find_substance_problems(d.get("content") or "") + if _sg: + return False, gate_message(_sg, scene="章节「%s」" % (d.get("title") or chapter_id)) + except ImportError: + pass # 宿主未装 pipeline-service 时放行(逃逸阀,与产线零接触分层一致) if d.get("status") not in (CH_WRITING, CH_PENDING, CH_REJECTED): return False, "当前状态 %s 不可提交(需 writing/pending/rejected)" % d.get("status") await sor.sqlExe(