"""招标文件解析能力:抽取评分项+得分规则、所需资质、投标文件要求,生成章节骨架。 对应业务:招标文件获得后,从招标文件中抽取评分项以及得分规则、所需资质、投标文件要求。 章节骨架规则(写进 bid-doc-spec 技能):章节子项直接来自评分标准原文枚举的子维度,不是通用目录。 """ import json import logging import os from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, json_loads, llm_json, record, resolve_project_id, CH_PENDING, TD_SUMMARIZED, ) logger = logging.getLogger("pipeline.bidding.analysis") EXTRACT_PROMPT = """你是招标文件分析师。下面是招标文件正文(可能被截断),请抽取结构化信息。 【招标文件正文】 __CONTENT__ 严格只输出一个 JSON 对象: { "tender_facts": { "title": "项目名称", "purchaser": "招标人/采购人单位名称", "tender_no": "招标编号/项目编号", "industry": "所属行业(可选)", "summary": "项目概况摘要,200字以内,说明采购标的、范围、主要要求", "bid_start_at": "获取招标文件/投标开始时间 YYYY-MM-DD(可选)", "bid_deadline": "投标截止时间 YYYY-MM-DD", "project_period": "项目周期/工期,如 12个月 / 180日历天", "budget_amount": "招标控制价/预算金额(仅数字,人民币元)", "deposit_amount": "投标保证金(仅数字,人民币元)", "qualification_req": "投标人资格/资质要求原文摘要", "key_dates": [{"name": "节点名称", "date": "YYYY-MM-DD"}], "risk_note": "风险提示:时间紧、资质缺口、否决项等;无则空" }, "scoring_items": [ {"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称", "max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)", "is_veto": "0或1(是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"} ], "qualifications": [ {"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1"} ], "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. tender_facts 是招标文件的核心商务要素(项目名称/招标人/招标编号/投标截止时间/项目周期/预算金额/保证金/资质门槛),必须优先准确抽取。 2. scoring_items 必须逐条覆盖评标办法中的每个评分项,满分数字准确;得分规则要保留原文档位/扣分/加分细则;否决项/废标条款 is_veto=1。 3. doc_requirements 中 req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出。 4. 只抽取正文中真实存在的内容,缺失就不要输出该字段/该条,禁止编造。 金额只填数字(如 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=""): """列出项目的招标文件。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: # 2026-09-04:与 list_scoring_items 同款——静默返空会让分析角色误判 # 「无招标文件」而阻塞(实测中电新项目:LLM 传项目名当 id,查空 → # 全维度首跑跑空)。明确报错指引补真实 project_id。 return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e db, dbname = get_db() async with db.sqlorContext(dbname) as sor: sql = ("SELECT id, file_name, file_type, extract_status, file_size, file_path " "FROM bid_tender_files WHERE project_id=${pid}$") p = {"pid": project_id} if extract_status: sql += " AND extract_status=${st}$" p["st"] = extract_status sql += " ORDER BY created_at" recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) return rows_to_dicts(recs) async def read_tender_file(file_id, offset=0, length=20000): """读招标文件正文(分段读,供 LLM 分析)。正文为空时尝试从磁盘提取。 磁盘提取支持相对路径:裸文件名/相对路径按项目根目录解析 (上传时可能只存了文件名,2026-09-01 修复)。 """ db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT id, project_id, file_name, file_path, content_text " "FROM bid_tender_files WHERE id=${fid}$", {"fid": file_id}) await sor.sqlExe("COMMIT", {}) if not recs: return "招标文件不存在: %s" % file_id d = rec_to_dict(recs[0]) txt = d.get("content_text") or "" if not txt.strip() and d.get("file_path"): pdir = await _project_dir_of(sor, d.get("project_id", "")) real = _resolve_file_path(pdir, d["file_path"], ws_base=await _workspace_base_of(sor)) txt = _extract_file_text(real) if real else "" if txt: await sor.sqlExe( "UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$, " "updated_at=NOW() WHERE id=${fid}$", {"c": txt[:2000000], "p": real, "fid": file_id}) await sor.sqlExe("COMMIT", {}) if not txt.strip(): return ("招标文件正文为空(file_path=%s)。请确认文件已上传且格式为 docx/pdf/txt。" % d.get("file_path", "")) o = to_int(offset, 0) n = to_int(length, 20000) seg = txt[o:o + n] return ("【%s】字符 %d-%d / 共 %d\n%s" % (d.get("file_name", ""), o, o + len(seg), len(txt), seg)) def _extract_file_text(path): """从 docx/pdf/txt 提取纯文本(无依赖时返回空串,不抛异常)。""" import os import re import zipfile if not path or not os.path.isfile(path): return "" ext = os.path.splitext(path)[1].lower() try: if ext in (".txt", ".md", ".csv", ".json"): with open(path, "r", encoding="utf-8", errors="ignore") as f: return f.read() if ext == ".docx": with zipfile.ZipFile(path) as z: out = [] for name in z.namelist(): if not (name.startswith("word/") and name.endswith(".xml")): continue if "document" not in name and "header" not in name and "footer" not in name: continue xml = z.read(name).decode("utf-8", errors="ignore") xml = xml.replace("", "\n") out.append("".join(re.findall(r"]*>(.*?)", xml, flags=re.S))) return "\n".join(out) if ext == ".pdf": try: import fitz doc = fitz.open(path) return "\n".join(pg.get_text() for pg in doc) except ImportError: return "" except Exception as e: logger.warning("extract file text failed %s: %s", path, str(e)[:120]) return "" async def _project_dir_of(sor, project_id): """项目根目录(供相对路径解析)。统一走引擎 get_project_dir_by_id: 读库(创建时已写死),不再自行推导——口径漂移是上传错位的根因(2026-09-01 根治)。 """ if not project_id: return "" try: from pipeline_service.workspace import get_project_dir_by_id pdir, _ = await get_project_dir_by_id(sor, project_id) return pdir or "" except Exception as e: logger.warning("project dir resolve failed pid=%s: %s", project_id, str(e)[:120]) return "" def _resolve_file_path(project_dir, file_path, ws_base=""): """把存储的 file_path 解析成磁盘绝对路径。 - 绝对路径且存在:原样返回; - 相对/裸文件名:依次尝试 项目根目录、项目根目录下的 docs/、 工作空间根(ws_base,2026-09-15 补——上传落点相对工作空间根存路径, 如 "0/第五章采购需求.docx",旧候选全解析不到 → 正文永远读空)、进程 cwd, 存在即返回; - 都找不到返回 ''。 """ fp = str(file_path or "").strip() if not fp: return "" if os.path.isabs(fp): return fp if os.path.isfile(fp) else "" cands = [] if project_dir: cands.append(os.path.join(project_dir, fp)) cands.append(os.path.join(project_dir, "docs", fp)) if ws_base: cands.append(os.path.join(ws_base, fp)) cands.append(fp) for c in cands: if os.path.isfile(c): return os.path.abspath(c) return "" async def _workspace_base_of(sor): """工作空间根目录(params.workspace_base,引擎统一口径)。失败返回 ''。""" try: from pipeline_service.workspace import get_workspace_base return await get_workspace_base(sor) or "" except Exception as e: logger.warning("workspace base resolve failed: %s", str(e)[:120]) return "" async def _best_tender_record(sor, project_id): """选最优招标文件记录(修复 2026-09-01「解析读旧截断稿」根因)。 一个项目可能有多条招标文件记录(重复上传/导入)。选择规则: **可得正文最多者胜**——正文取 len(content_text),磁盘文件取文件大小, 两者取大;并列时取最新。返回 dict 附带 _resolved_path(磁盘绝对路径提示)。 旧实现 ORDER BY created_at LIMIT 1 固定取最老记录——最老的往往是早期 截断摘要,完整文件后来才传,导致解析永远读不到完整文件。 只按「有正文优先」也不够:截断稿有正文、完整稿只在磁盘上,会误选截断稿。 """ recs = await sor.sqlExe( "SELECT id, project_id, file_name, file_path, content_text, created_at " "FROM bid_tender_files WHERE project_id=${pid}$ AND file_type='tender_doc' " "ORDER BY created_at DESC", {"pid": project_id}) if not recs: return {} rows = rows_to_dicts(recs, limit=50) pdir = await _project_dir_of(sor, project_id) ws_base = await _workspace_base_of(sor) best, best_score, best_real = {}, -1, "" for r in rows: score = len((r.get("content_text") or "").strip()) real = "" if (r.get("file_path") or "").strip(): real = _resolve_file_path(pdir, r["file_path"], ws_base=ws_base) if real: try: score = max(score, os.path.getsize(real)) except OSError: pass if score > best_score: best, best_score, best_real = r, score, real if best and best_real: best["_resolved_path"] = best_real return best async def mark_file_extracted(file_id, note="", who=None, agent_id=None): """标记招标文件已抽取完成。""" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: await sor.sqlExe( "UPDATE bid_tender_files SET extract_status='extracted', extract_note=${n}$, " "updated_at=NOW() WHERE id=${fid}$", {"n": (note or "")[:480], "fid": file_id}) await sor.sqlExe("COMMIT", {}) return True, "已标记抽取完成: %s" % file_id # ══════════════ 抽取落库(逐条) ══════════════ async def add_scoring_item(project_id, item_name, max_score, scoring_rule="", section="technical", item_no="", is_veto="0", source_ref="", target_chapter_no="", weight="", 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_scoring_items WHERE project_id=${pid}$ AND item_name=${nm}$ LIMIT 1", {"pid": project_id, "nm": str(item_name)[:250]}) await sor.sqlExe("COMMIT", {}) if exist: return True, "评分项已存在: %s" % item_name n = await sor.sqlExe( "SELECT COUNT(*) AS c FROM bid_scoring_items WHERE project_id=${pid}$", {"pid": project_id}) order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1 sid = new_id() await sor.C("bid_scoring_items", { "id": sid, "project_id": project_id, "section": (section or "technical")[:30], "item_no": str(item_no or "")[:30], "item_name": str(item_name)[:250], "max_score": to_float(max_score, 0.0), "scoring_rule": scoring_rule or "", "is_veto": "1" if str(is_veto) in ("1", "true", "True", "是") else "0", "weight": to_float(weight, 0.0) or None, "source_ref": str(source_ref or "")[:480], "target_chapter_no": str(target_chapter_no or "")[:30], "order_no": order_no, }) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_scoring_items", sid, "add", who=who, agent_id=agent_id, detail=str(item_name)[:200]) return True, "评分项已录入: %s (%s分)" % (item_name, max_score) async def add_qualification(project_id, qual_name, requirement="", is_mandatory="1", owner="kb", who=None, agent_id=None): """新增一项招标所需资质。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) if not qual_name: return False, "缺少 qual_name" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: exist = await sor.sqlExe( "SELECT id FROM bid_qualifications WHERE project_id=${pid}$ AND qual_name=${nm}$ LIMIT 1", {"pid": project_id, "nm": str(qual_name)[:250]}) await sor.sqlExe("COMMIT", {}) if exist: return True, "资质项已存在: %s" % qual_name qid = new_id() await sor.C("bid_qualifications", { "id": qid, "project_id": project_id, "qual_name": str(qual_name)[:250], "requirement": requirement or "", "is_mandatory": "1" if str(is_mandatory) in ("1", "true", "True", "是") else "0", "owner": (owner or "kb")[:15], "match_status": "pending", }) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_qualifications", qid, "add", who=who, agent_id=agent_id, detail=str(qual_name)[:200]) return True, "资质要求已录入: %s" % qual_name async def add_doc_requirement(project_id, requirement, req_type="structure", chapter_no="", chapter_title="", page_limit="", source_ref="", who=None, agent_id=None): """新增一条投标文件要求(structure 类同时是章节骨架来源)。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) if not requirement: 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}) await sor.sqlExe("COMMIT", {}) order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1 rid = new_id() await sor.C("bid_doc_requirements", { "id": rid, "project_id": project_id, "req_type": (req_type or "structure")[:30], "chapter_no": str(chapter_no or "")[:30], "chapter_title": str(chapter_title or "")[:250], "requirement": requirement or "", "page_limit": to_int(page_limit, 0) or None, "source_ref": str(source_ref or "")[:480], "order_no": order_no, }) await sor.sqlExe("COMMIT", {}) return True, "投标文件要求已录入: %s %s" % (req_type, chapter_title or "") async def update_doc_requirement(project_id, record_id="", chapter_no="", req_type="", requirement="", source_ref="", page_limit="", chapter_title="", delete=False, who=None, agent_id=None): """更新/删除既有投标文件要求条目(2026-09-03,根治 add 只增不改)。 定位:record_id 优先;否则 chapter_no+req_type 唯一定位。 - delete=True → 删除该条(编造/无出处项的处置); - requirement/source_ref/page_limit/chapter_title 非空 → 回写原条目 (纠正必须回写,不得只追加新条目形成矛盾对)。 """ 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: if record_id: recs = await sor.sqlExe( "SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ AND id=${r}$", {"pid": project_id, "r": record_id}) elif chapter_no: recs = await sor.sqlExe( "SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ " "AND chapter_no=${c}$ AND req_type=${rt}$", {"pid": project_id, "c": str(chapter_no)[:30], "rt": (req_type or "structure")[:30]}) else: return False, "需要 record_id 或 chapter_no 定位条目" await sor.sqlExe("COMMIT", {}) if not recs: return False, "未找到条目(record_id=%s chapter_no=%s req_type=%s)" % ( record_id, chapter_no, req_type) if len(recs) > 1 and not record_id: return False, "chapter_no+req_type 命中 %d 条,请改用 record_id 精确定位" % len(recs) rid = getattr(recs[0], "id", "") if delete: await sor.sqlExe("DELETE FROM bid_doc_requirements WHERE id=${r}$", {"r": rid}) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_doc_requirements", rid, "delete", who=who, agent_id=agent_id, detail="删除条目") return True, "已删除条目 %s" % rid sets, p = [], {"r": rid} if requirement: sets.append("requirement=${rq}$"); p["rq"] = requirement if chapter_title: sets.append("chapter_title=${ct}$"); p["ct"] = str(chapter_title)[:250] if source_ref: sets.append("source_ref=${sr}$"); p["sr"] = str(source_ref)[:480] if page_limit: sets.append("page_limit=${pl}$"); p["pl"] = to_int(page_limit, 0) or None if not sets: return False, "无可更新字段(requirement/source_ref/page_limit 至少传一个)" sets.append("updated_at=NOW()") await sor.sqlExe("UPDATE bid_doc_requirements SET " + ", ".join(sets) + " WHERE id=${r}$", p) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_doc_requirements", rid, "update", who=who, agent_id=agent_id, detail="回写原条目") return True, "已更新条目 %s" % rid async def _upsert_tender_facts(project_id, facts, who=None, agent_id=None): """把投标要素写入 bid_tenders(source=tender_doc,复用原招标信息表承载商务要素)。 无项目关联记录则新建,有则更新要素字段(幂等,可随文件重复解析覆盖)。 """ if not facts or not isinstance(facts, dict): return str_fields = ("title", "purchaser", "tender_no", "industry", "summary", "bid_start_at", "bid_deadline", "project_period", "qualification_req", "risk_note") num_fields = ("budget_amount", "deposit_amount") data = {} for k in str_fields: v = (facts.get(k) or "").strip() if v: data[k] = v[:250] if k in ("title", "purchaser", "tender_no", "industry", "bid_start_at", "bid_deadline", "project_period") else v[:4000] for k in num_fields: v = facts.get(k) if v not in (None, "", "无"): amt = to_float(str(v).replace(",", "").replace("元", "").strip(), 0.0) if amt > 0: data[k] = amt kd = facts.get("key_dates") if kd: data["key_dates"] = json.dumps(kd, ensure_ascii=False)[:4000] if not data: return db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT id FROM bid_tenders WHERE project_id=${pid}$ " "ORDER BY created_at DESC LIMIT 1", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) if recs: tid = getattr(recs[0], "id", "") sets = ", ".join("%s=${%s}$" % (k, k) for k in data) p = dict(data) p["tid"] = tid await sor.sqlExe( "UPDATE bid_tenders SET " + sets + ", updated_at=NOW() WHERE id=${tid}$", p) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_tenders", tid, "extract_facts", who=who, agent_id=agent_id, detail="更新要素: %s" % ", ".join(sorted(data))) else: tid = new_id() data["id"] = tid data["project_id"] = project_id data["org_id"] = "0" data["source"] = "tender_doc" data["status"] = TD_SUMMARIZED data["created_by"] = who or "" await sor.C("bid_tenders", data) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_tenders", tid, "extract_facts", who=who, agent_id=agent_id, 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"], ws_base=await _workspace_base_of(sor)) 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) 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 o = to_int(offset, 0) seg = txt[o:o + to_int(length, 30000)] data, err = await llm_json(EXTRACT_PROMPT.replace("__CONTENT__", seg), model_name=model_name, retries=1, project_id=project_id) if data is None: return False, "LLM 抽取失败: %s" % err cnt = {"scoring_items": 0, "qualifications": 0, "doc_requirements": 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["scoring_items"] += 1 if ok else 0 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["qualifications"] += 1 if ok else 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["doc_requirements"] += 1 if ok else 0 return True, ("抽取完成(文件 %s,字符 %d-%d):评分项 %d,资质 %d,投标文件要求 %d" % (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, project_id=""): """取分段正文 → 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, project_id=project_id or '') 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, project_id=project_id) 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, project_id=project_id) 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, project_id=project_id) 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 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 结论行)。""" 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, project_id=project_id) 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=""): try: project_id = await resolve_project_id(project_id) except ValueError as e: # 2026-09-03:静默返回 [] 会让 LLM 误判「无数据」(实测 C-12 绑定阻塞根因), # 改为明确报错,提示补 project_id。 return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e db, dbname = get_db() async with db.sqlorContext(dbname) as sor: sql = ("SELECT id, section, item_no, item_name, max_score, is_veto, " "target_chapter_no, scoring_rule FROM bid_scoring_items WHERE project_id=${pid}$") p = {"pid": project_id} if section: sql += " AND section=${sec}$" p["sec"] = section sql += " ORDER BY section, order_no" recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) out = rows_to_dicts(recs) for r in out: r["scoring_rule"] = (r.get("scoring_rule") or "")[:600] return out async def list_doc_requirements(project_id, req_type=""): 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: sql = ("SELECT id, req_type, chapter_no, chapter_title, requirement, page_limit " "FROM bid_doc_requirements WHERE project_id=${pid}$") p = {"pid": project_id} if req_type: sql += " AND req_type=${rt}$" p["rt"] = req_type sql += " ORDER BY req_type, order_no" recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) out = rows_to_dicts(recs) for r in out: r["requirement"] = (r.get("requirement") or "")[:600] return out # ══════════════ 章节骨架 ══════════════ def _infer_section(title): """按章节标题关键词推断商务/技术分类(商务标:报价/商务/资质证明/承诺函等;其余技术标)。""" t = title or "" biz_kw = ("报价", "投标函", "商务", "资格证明", "资质证明", "承诺书", "声明", "保证金", "法定代表人", "授权委托", "财务状况", "业绩一览", "合同格式") for kw in biz_kw: if kw in t: return "business" return "technical" async def create_chapter_outline(project_id, chapters="", who=None, agent_id=None): """生成章节骨架 → bid_chapters(status=pending)。 chapters 为空时:自动用 bid_doc_requirements(req_type=structure) 生成, 并按 target_chapter_no / 章节标题关键词把评分项挂到章节上(scoring_item_ids + max_score)。 """ try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) items = json_loads(chapters, []) if chapters else [] db, dbname = get_db() async with db.sqlorContext(dbname) as sor: if not items: recs = await sor.sqlExe( "SELECT chapter_no, chapter_title, requirement, page_limit FROM bid_doc_requirements " "WHERE project_id=${pid}$ AND req_type='structure' ORDER BY order_no", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) for r in (recs or []): d = rec_to_dict(r) if not (d.get("chapter_title") or d.get("chapter_no")): continue items.append({ "chapter_no": d.get("chapter_no") or "", "title": d.get("chapter_title") or "", "outline": (d.get("requirement") or "")[:4000], "section": _infer_section(d.get("chapter_title") or ""), }) if not items: return False, ("无章节来源:请先抽取 req_type=structure 的投标文件要求," "或显式传 chapters=[{\"chapter_no\":\"1\",\"title\":\"...\"}]") sitems = await sor.sqlExe( "SELECT id, item_name, max_score, section, target_chapter_no " "FROM bid_scoring_items WHERE project_id=${pid}$", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) sc = rows_to_dicts(sitems) created, skipped = 0, 0 for idx, it in enumerate(items): if not isinstance(it, dict): continue cno = str(it.get("chapter_no") or (idx + 1))[:30] title = str(it.get("title") or it.get("chapter_title") or "").strip()[:250] if not title: continue exist = await sor.sqlExe( "SELECT id FROM bid_chapters WHERE project_id=${pid}$ AND chapter_no=${cno}$ LIMIT 1", {"pid": project_id, "cno": cno}) await sor.sqlExe("COMMIT", {}) if exist: skipped += 1 continue matched, msum = [], 0.0 for s in sc: tgt = (s.get("target_chapter_no") or "").strip() nm = s.get("item_name") or "" if (tgt and tgt == cno) or (nm and (nm in title or title in nm)): matched.append(s["id"]) msum += to_float(s.get("max_score"), 0.0) sec = str(it.get("section") or _infer_section(title) or "technical")[:30] await sor.C("bid_chapters", { "id": new_id(), "project_id": project_id, "chapter_no": cno, "parent_no": str(it.get("parent_no") or "")[:30], "title": title, "section": sec, "scoring_item_ids": json.dumps(matched, ensure_ascii=False), "outline": str(it.get("outline") or "")[:20000], "status": CH_PENDING, "version": 1, "revise_count": 0, "max_score": msum or None, "assignee_role": "agent.bid_biz_writer" if sec == "business" else "agent.bid_writer", "order_no": idx + 1, }) created += 1 await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_chapters", project_id, "create_outline", who=who, agent_id=agent_id, detail="新增 %d 章" % created) return True, ("章节骨架已生成:新增 %d 章,已存在跳过 %d 章。" "系统将自动为每个章节派发编写任务。" % (created, skipped)) async def patch_chapters(project_id, patches="", who=None, agent_id=None): """按 chapter_no 增量更新已有章节(2026-09-03,根治 create_chapter_outline 只增不改)。 patches: JSON 数组,每项 {chapter_no, title?, outline?, section?, scoring_item_ids?, scoring_items_by_name?, max_score?}。 - scoring_item_ids 直接覆盖(传真实 id 数组); - scoring_items_by_name: [{item_name, max_score?}] 按名称在 bid_scoring_items 反查 id 绑定(item_name 为「商务N|名称|出处」格式时按包含匹配); - 白名单外的章节不动;不存在的 chapter_no 报错列出。 """ try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) items = json_loads(patches, []) if isinstance(patches, str) else (patches or []) if not items: return False, "patches 为空:传 JSON 数组 [{chapter_no, ...}]" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: sitems = await sor.sqlExe( "SELECT id, item_name, max_score FROM bid_scoring_items WHERE project_id=${pid}$", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) sc = rows_to_dicts(sitems) def _bind_by_name(names): ids, miss = [], [] for n in (names or []): nm = (n.get("item_name") or "").strip() if isinstance(n, dict) else str(n or "").strip() hit = None for s in sc: sn = s.get("item_name") or "" if nm and (nm == sn or nm in sn or sn in nm): hit = s break if hit: ids.append(hit["id"]) else: miss.append(nm) return ids, miss updated, missing, bound, unbound = [], [], 0, [] for it in items: if not isinstance(it, dict): continue cno = str(it.get("chapter_no") or "").strip() if not cno: continue recs = await sor.sqlExe( "SELECT id, scoring_item_ids FROM bid_chapters " "WHERE project_id=${pid}$ AND chapter_no=${cno}$ LIMIT 1", {"pid": project_id, "cno": cno}) await sor.sqlExe("COMMIT", {}) if not recs: missing.append(cno) continue sets, p = [], {"pid": project_id, "cno": cno} if it.get("title"): sets.append("title=${t}$"); p["t"] = str(it["title"])[:250] if it.get("outline") is not None and it.get("outline") != "": sets.append("outline=${o}$"); p["o"] = str(it["outline"])[:20000] if it.get("section"): sets.append("section=${s}$"); p["s"] = str(it["section"])[:30] ids = list(it.get("scoring_item_ids") or []) if it.get("scoring_items_by_name"): b, m = _bind_by_name(it["scoring_items_by_name"]) ids = b bound += len(b) unbound += m if it.get("scoring_item_ids") is not None or it.get("scoring_items_by_name"): sets.append("scoring_item_ids=${si}$") p["si"] = json.dumps(ids, ensure_ascii=False) if it.get("max_score") is not None: sets.append("max_score=${ms}$"); p["ms"] = to_float(it["max_score"], 0) if not sets: continue sets.append("updated_at=NOW()") sets.append("version=version+1") await sor.sqlExe( "UPDATE bid_chapters SET " + ", ".join(sets) + " WHERE project_id=${pid}$ AND chapter_no=${cno}$", p) updated.append(cno) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_chapters", project_id, "patch_outline", who=who, agent_id=agent_id, detail="更新 %d 章:%s" % (len(updated), ",".join(updated)[:200])) msg = "章节增量更新:成功 %d 章(%s),评分项按名绑定 %d 条" % ( len(updated), ",".join(updated)[:200], bound) if missing: msg += ";不存在的章节:%s" % ",".join(missing) if unbound: msg += ";按名未绑定的评分项:%s" % ",".join(unbound)[:200] return True, msg