489 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""投标产线公共层: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])