489 lines
22 KiB
Python
489 lines
22 KiB
Python
"""投标产线公共层: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])
|