"""投标产线公共层:DB 上下文、角色规范化、门限参数、LLM 调用、项目/招标信息取数。 所有 bid_* capability 模块共用本模块,禁止各自重复实现(dspy/capability 都只调这里)。 """ import json import logging from sqlor.dbpools import DBPools from appPublic.uniqueID import getID DBNAME = "pipeline" PIPELINE_ID = "bidding_general" logger = logging.getLogger("pipeline.bidding") # ── 章节 / 标书状态 ── CH_PENDING = "pending" CH_WRITING = "writing" CH_WRITTEN = "written" CH_REVIEWING = "reviewing" CH_APPROVED = "approved" CH_REJECTED = "rejected" # ── 招标信息状态(bid_tenders 现仅承载解析抽取的商务要素,无审批流)── TD_SUMMARIZED = "summarized" # ── 标书状态 ── DOC_DRAFT = "draft" DOC_REVIEWING = "reviewing" DOC_PASSED = "passed" DOC_REJECTED = "rejected" DOC_DELIVERED = "delivered" # ── 人类任务类型(阻塞门禁)── HT_TENDER_FILE = "tender_file_upload" HT_HUMAN_DOCS = "human_docs_request" HT_QC_ESCALATION = "qc_escalation" HT_DELIVERY_CONFIRM = "bid_delivery_confirm" HT_FLOW_CONFIRM = "flow_plan_confirm" # 流程裁剪确认(引擎通用类型,pipeline_flow_plans 机制) HT_TEMPLATE_CONFIRM = "tech_template_confirm" # 技术方案模版/骨架确认(纯技术方案流程,2026-09-14 批2) BLOCKING_HT_TYPES = (HT_TENDER_FILE, HT_FLOW_CONFIRM, HT_TEMPLATE_CONFIRM) # 2026-09-03 起只保留「等招标文件」为全局阻塞: # - HT_HUMAN_DOCS(等人类资质文件)降级为章节级依赖——只阻塞商务章(等资料准备),技术/报价章不受影响; # - HT_QC_ESCALATION(QC 轮次用尽)降级为章节级依赖——只阻塞依赖该产出类型的章节,其余章节照常流转。 # 2026-09-11 增 HT_FLOW_CONFIRM:裁剪计划待用户确认期间全局阻塞(确认后才按计划派发, # 防「用户在确认、对账器按旧流程抢跑」)。驳回后的暂停由 bid_flow 查 pipeline_flow_plans 状态兜住。 # 2026-09-14 增 HT_TEMPLATE_CONFIRM:技术方案模版骨架待用户确认期间全局阻塞(用户明确要 # 「模版由用户确认后」才开写章节——骨架没确认就编写 = 方向错了全白写)。 # ── QC 契合度审核类型(解析产出逐类审核)── QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则 QC_TYPE_QUALS = "qualifications" # 所需资质 QC_TYPE_REQS = "doc_requirements" # 投标文件要求 QC_TYPE_OUTLINE = "chapter_outline" # 章节骨架 QC_TYPE_COST = "cost_benefit" # 成本收益分析(2026-09-02 四维度拆分新增) QC_TYPE_TECH = "tech_items" # 技术评估项(2026-09-14 纯技术方案流程新增) QC_TYPES = (QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPE_COST) # 技术方案流程专属 QC 类型(不进 QC_TYPES——标准流程的 _qc_pending_types 只遍历 QC_TYPES, # 技术方案流程由 bid_flow 的 tech 分支单独遍历 TECH_QC_TYPES,两条流程互不串审) TECH_QC_TYPES = (QC_TYPE_TECH,) # QC 审核对象 → 产出物落库表(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", QC_TYPE_COST: "bid_cost_benefit", QC_TYPE_TECH: "bid_tech_items", } # ── 流程裁剪(pipeline_flow_plans 机制,2026-09-11)── # 裁剪计划阶段 key(bid_ability.BID_FLOW_STAGES 声明)→ 分析维度映射。 # 对账器/PM 编排据此判定某维度是否执行、被裁维度的 QC 依赖是否视为通过。 STAGE_ANALYSIS = "analysis_scoring" STAGE_QUALS = "analysis_quals" STAGE_REQS_OUTLINE = "analysis_reqs_outline" STAGE_COST_BENEFIT = "analysis_cost_benefit" STAGE_QC = "qc_analysis" STAGE_PREP = "prep" STAGE_CH_REVIEW = "chapter_review" STAGE_WHOLE_SCORE = "whole_score" STAGE_DELIVERY = "delivery_confirm" DIM_TO_STAGE = { "scoring": STAGE_ANALYSIS, "quals": STAGE_QUALS, "reqs_outline": STAGE_REQS_OUTLINE, "cost_benefit": STAGE_COST_BENEFIT, } # ── 纯技术方案流程(bid_tech_proposal,2026-09-14 批2)── # 阶段 key(bid_ability.TECH_PROPOSAL_STAGES 声明)→ 与标准流程同名字段但含义不同: # 该流程以用户提供的技术需求书为唯一基准,QC/评审全部对标需求书原文,不碰评分项。 FLOW_KEY_TECH_PROPOSAL = "bid_tech_proposal" FLOW_KEY_STANDARD = "bid_standard" STAGE_TECH_ANALYSIS = "tech_analysis" # 技术需求解析(六类评估项) STAGE_TECH_QC = "tech_qc" # 解析产出 QC(对标需求书原文) STAGE_TEMPLATE_CONFIRM = "template_confirm" # 方案模版确定 + 用户确认骨架 STAGE_TECH_CH_WRITE = "tech_chapter_write" # 按确认骨架分章编写 STAGE_TECH_CH_QC = "tech_chapter_qc" # 章节 QC 评审(对标需求书覆盖度) STAGE_TECH_COMPOSE = "tech_compose" # 合成技术方案书 STAGE_TECH_DELIVERY = "tech_delivery_confirm" # 交付确认 # 六类技术评估项(用户 2026-09-11 定义:功能清单/功能点/硬件配置/技术架构/功能架构/部署架构) TECH_CATEGORIES = ("function_list", "function_point", "hardware", "tech_arch", "func_arch", "deploy_arch") # 任务未终结状态(判断「是否已有在办任务」用) # waiting 计入在办(2026-09-11):PM 复杂度拆解的子任务带 depends_on 时为 waiting, # 不算在办会导致对账器重复派发整维度任务/对半成品提前派 QC(09-04 编排堆积同款事故模式)。 OPEN_TASK_STATES = ("submitted", "running", "review", "qc_review", "waiting") # failed 任务单独处理:角色 agent 执行失败(如机构未配 LLM)→ 对账器不重复造任务,抛人工介入 # ── 门限默认值(可用 appbase params 表覆盖)── DEFAULT_PARAMS = { "bid_chapter_pass_ratio": "0.8", # 章节评审通过门限 "bid_pass_ratio": "0.85", # 整书评分通过门限 "bid_max_revise": "3", # 单章节最大重做次数 "bid_max_round": "3", # 整书最大评分轮次 "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", # 商务标章节最低字数 "bid_write_concurrency": "4", # 章节编写并发上限(单轮最多同时派发的编写任务数) "bid_split_chars": "60000", # 分析任务机械拆解阈值:招标文件正文超此字符数, # 分析维度自动拆为分段子任务(2026-09-15 用户要求: # 大任务必须拆解,不能一个任务跑几十分钟) } def get_db(): """取 DBPools(databases 为空时从 config 兜底注入,与引擎其他模块一致)。""" db = DBPools() if not db.databases: from appPublic.jsonConfig import getConfig config = getConfig() if config and config.databases: db.databases = config.databases return db, DBNAME def new_id(): return getID() def normalize_role(role): """agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 原样返回。""" role = (role or "").strip() if not role: return "" if "." in role: return role return "agent." + role def rec_to_dict(rec): """sqlor 行对象 → dict(过滤方法名,避免 DictObject 序列化泄漏内置方法)。""" if rec is None: return {} if isinstance(rec, dict): return {k: v for k, v in rec.items() if not callable(v)} d = {} for k, v in vars(rec).items(): if k.startswith("_") or callable(v): continue d[k] = v return d def rows_to_dicts(recs, limit=200): return [rec_to_dict(r) for r in (recs or [])[:limit]] def to_float(v, default=0.0): try: if v is None or v == "": return default return float(v) except (TypeError, ValueError): return default def to_int(v, default=0): try: if v is None or v == "": return default return int(float(v)) except (TypeError, ValueError): return default def json_loads(s, default=None): if not s: return default if default is not None else [] if isinstance(s, (list, dict)): return s try: return json.loads(s) except (json.JSONDecodeError, TypeError, ValueError): return default if default is not None else [] async def get_param(sor, key, default=""): """读 appbase params 表配置(复用引擎 workspace.get_param,不重复实现)+ 默认兜底。""" try: from pipeline_service.workspace import get_param as _gp v = await _gp(sor, key, "") if v not in (None, ""): return str(v) except Exception: pass return str(DEFAULT_PARAMS.get(key, default)) async def resolve_project_id(project_id="", **entity_ids): """补齐/校验 project_id:必须是 sd_projects 真实存在的项目。 角色工具经引擎分发时默认注入会话当前项目;为空时(无会话上下文)按实体反查, 避免「项目不明」导致跨项目读写。全部反查失败抛 ValueError。 键漂移防护(2026-09-02):调用方可能把项目「名字」误当 project_id 传入 (曾致 bid_chapters 26 条记录以名字为键落库,与正确 id 键骨架并存形成双骨架)。 此处统一校验:传入值先按 id 查;查不到再按 name 反查并归一为真实 id; 名字命中多个项目则拒绝(歧义),彻底堵住「名字当 id」写库。 """ if project_id: db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT id FROM sd_projects WHERE id=${p}$ LIMIT 1", {"p": project_id}) await sor.sqlExe("COMMIT", {}) if recs: return project_id # 按名字反查(键漂移容错:误传项目名 → 归一为真实 id) recs = await sor.sqlExe( "SELECT id FROM sd_projects WHERE name=${p}$", {"p": project_id}) await sor.sqlExe("COMMIT", {}) if len(recs) == 1: real_id = getattr(recs[0], "id", "") logger.warning("resolve_project_id: 项目名「%s」→ 归一为 id %s(键漂移防护)", project_id, real_id) return real_id if len(recs) > 1: raise ValueError("项目名「%s」匹配到多个项目,请改用项目 ID 指定" % project_id) raise ValueError("项目不存在:%s" % project_id) db, dbname = get_db() async with db.sqlorContext(dbname) as sor: for tbl, col, val in (("bid_chapters", "chapter_id", entity_ids.get("chapter_id")), ("bid_reviews", "review_id", entity_ids.get("review_id")), ("bid_documents", "document_id", entity_ids.get("document_id")), ("bid_tender_files", "file_id", entity_ids.get("file_id"))): if not val: continue recs = await sor.sqlExe( "SELECT project_id FROM " + tbl + " WHERE id=${v}$ LIMIT 1", {"v": val}) await sor.sqlExe("COMMIT", {}) if recs and getattr(recs[0], "project_id", ""): return getattr(recs[0], "project_id", "") raise ValueError("缺少 project_id(请指定项目或切换到项目)") async def get_thresholds(sor): """取门限参数(章节/整书/重做上限/轮次上限 + QC 契合度门限)。""" return { "chapter_pass_ratio": to_float(await get_param(sor, "bid_chapter_pass_ratio"), 0.8), "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"), 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), "write_concurrency": to_int(await get_param(sor, "bid_write_concurrency"), 4), "split_chars": to_int(await get_param(sor, "bid_split_chars"), 60000), } async def get_project(sor, project_id): """取项目记录(dict),不存在返回 {}。""" if not project_id: return {} recs = await sor.sqlExe( "SELECT id, name, org_id, status, pipeline_id, created_by, default_model " "FROM sd_projects WHERE id=${pid}$", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) return rec_to_dict(recs[0]) if recs else {} async def get_tender_by_project(sor, project_id): """取项目关联的招标信息(dict)。""" if not project_id: return {} recs = await sor.sqlExe( "SELECT * FROM bid_tenders WHERE project_id=${pid}$ ORDER BY created_at DESC LIMIT 1", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) return rec_to_dict(recs[0]) if recs else {} async def create_role_task(sor, project_id, role, title, params=None, owner_id="bid_flow"): """创建角色任务(pipeline_tasks,tenant_id=project_id,pipeline_id='role_task')。 与开发产线共用同一套任务表和 poller —— 投标产线不另建任务机制。 """ tid = new_id() params = dict(params or {}) # 豁免声明(2026-09-03):投标产线所有角色任务自带质量门禁 # (解析产出契合度审核 / 章节评审打分),交付后不走引擎通用 QC/PM 门禁。 params.setdefault("skip_generic_qc", True) await sor.C("pipeline_tasks", { "id": tid, "tenant_id": project_id, "pipeline_id": "role_task", "owner_id": owner_id, "title": (title or "")[:250], "params": json.dumps(params, ensure_ascii=False), "role": normalize_role(role), "state": "submitted", "claimed_by": None, }) logger.info("bid create_role_task role=%s task=%s project=%s", role, tid, project_id) return tid async def count_open_tasks(sor, project_id, role=None, chapter_id=None): """统计项目下未终结任务数(可按角色/章节过滤)。用于「已有在办任务就不重复派」。""" states = "','".join(OPEN_TASK_STATES) sql = ("SELECT id, params FROM pipeline_tasks WHERE tenant_id=${pid}$ " "AND pipeline_id='role_task' AND state IN ('" + states + "')") p = {"pid": project_id} if role: sql += " AND role=${role}$" p["role"] = normalize_role(role) recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) if chapter_id is None: return len(recs or []) n = 0 for r in (recs or []): prm = json_loads(getattr(r, "params", "") or "{}", {}) if isinstance(prm, dict) and prm.get("chapter_id") == chapter_id: n += 1 return n async def has_done_task(sor, project_id, role): """项目下该角色是否已有验收通过(approved/completed)的任务。""" recs = await sor.sqlExe( "SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ AND pipeline_id='role_task' " "AND role=${role}$ AND state IN ('approved','completed') LIMIT 1", {"pid": project_id, "role": normalize_role(role)}) await sor.sqlExe("COMMIT", {}) return bool(recs) async def has_pending_blocking_human_task(sor, project_id): """项目是否有阻塞中的人类任务(投标产线无迭代,按 project_id 判定)。""" types = "','".join(BLOCKING_HT_TYPES) recs = await sor.sqlExe( "SELECT COUNT(*) AS c FROM pipeline_human_tasks " "WHERE project_id=${pid}$ AND status='pending' " "AND task_type IN ('" + types + "')", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) return to_int(getattr(recs[0], "c", 0) if recs else 0) > 0 async def create_human_task(sor, project_id, task_type, title, description="", assignee_role="owner.superuser", assignee_id="", 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 → 不发新待办返回 ''(暂停/删除的 项目不再产生待办;恢复推进后对账器按需补发)。 """ prec = await sor.sqlExe( "SELECT status FROM sd_projects WHERE id=${p}$", {"p": project_id or ""}) await sor.sqlExe("COMMIT", {}) if not prec: logger.info("bid create_human_task skipped: project %s deleted (type=%s)", project_id, task_type) return "" _st = getattr(prec[0], "status", "") or "" if _st in ("paused", "archived", "cancelled", "completed"): logger.info("bid create_human_task skipped: project %s status=%s (type=%s)", project_id, _st, task_type) return "" 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 or "", "step_name": task_type, "version": 1, "task_type": task_type, "assignee_role": assignee_role or "owner.superuser", "assignee_id": assignee_id or "", "form_schema": json.dumps(form_schema or {}, ensure_ascii=False), "status": "pending", "project_id": project_id or "", "title": (title or "")[:480], "description": description or "", }) logger.info("bid create_human_task type=%s id=%s project=%s", task_type, hid, project_id) return hid async def find_human_task(sor, project_id, task_type, status=None): """查项目下指定类型人类任务(最新一条)。""" sql = ("SELECT * FROM pipeline_human_tasks WHERE project_id=${pid}$ " "AND task_type=${tt}$") p = {"pid": project_id, "tt": task_type} if status: sql += " AND status=${st}$" p["st"] = status sql += " ORDER BY created_at DESC LIMIT 1" recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) return rec_to_dict(recs[0]) if recs else {} async def llm_json(prompt, model_name="", retries=1, project_id=""): """调 LLM 并解析 JSON(剥离 markdown code fence)。失败返回 (None, err)。 project_id:透传到 llm_usage.project_id(按项目统计费用,2026-09-10)。 """ from pipeline_service.llm_bridge import llm_call last_err = "" for _ in range(max(1, retries + 1)): try: raw = await llm_call(prompt, model=model_name or None, project_id=project_id or '') txt = (raw or "").strip() if txt.startswith("```"): parts = txt.split("\n", 1) txt = parts[1] if len(parts) > 1 else txt if "```" in txt: txt = txt.rsplit("```", 1)[0] txt = txt.strip() # 容错:截取第一个 { 到最后一个 } if not txt.startswith("{") and "{" in txt: txt = txt[txt.find("{"):txt.rfind("}") + 1] return json.loads(txt), "" except Exception as e: last_err = str(e)[:200] return None, last_err or "LLM 返回无法解析为 JSON" async def record(sor, project_id, entity, entity_id, action, from_state=None, to_state=None, who=None, agent_id=None, detail=None): """写审计(复用引擎 audit,失败不影响主流程)。""" try: from pipeline_service.audit import record_audit await record_audit(project_id, entity, entity_id, action, from_state=from_state, to_state=to_state, who=normalize_role(who), agent_id=agent_id, detail=detail, sor=sor) except Exception as e: logger.warning("bid audit failed: %s", str(e)[:120])