pipeline-bidding/pipeline_bidding/bid_tech_capability.py

541 lines
28 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.

# -*- coding: utf-8 -*-
"""纯技术方案流程能力bid_tech_proposal2026-09-14 批2
用户 2026-09-11 定义的产线第二流程:输入=技术需求书(非招标文件),产出=技术方案书。
分析集中在技术层面六类评估项:功能清单/功能点/硬件配置/技术架构/功能架构/部署架构;
方案书编写根据预定模版(无则 RAG/网络检索),**模版骨架由用户确认后**按章节分段编写,
章节 QC 评审**以用户提供的需求书为唯一基准**(对标覆盖度,不碰评分项),最终合成技术方案书。
机制分工(沿用产线既有约定:流转归机制,操作原语归本模块):
- bid_flow 对账器按已确认计划的 base_flow_key 走 tech 分支派发任务;
- 本模块固化操作原语:技术评估项抽取/补录/查询、模版候选生成、模版确认落骨架;
- QC 复用 bid_qc_reviews 机制qc_type=tech_itemsstart_qc/finish_qc 原语不变);
- 用户确认走平台待办tech_template_confirm 类型 + 专用确认端点agent 无确认工具)。
"""
import json
import logging
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, to_int, to_float, json_loads,
record, create_human_task, find_human_task, get_thresholds, get_project,
resolve_project_id, llm_json,
CH_PENDING, TECH_CATEGORIES, HT_TEMPLATE_CONFIRM, QC_TYPE_TECH,
)
logger = logging.getLogger("pipeline.bidding.tech")
TABLE = "bid_tech_items"
# 六类评估项中文名(抽取提示词/展示用)
TECH_CATEGORY_LABELS = {
"function_list": "功能清单",
"function_point": "功能点",
"hardware": "硬件配置",
"tech_arch": "技术架构",
"func_arch": "功能架构",
"deploy_arch": "部署架构",
}
PROMPT_TECH_ITEMS = """你是技术方案分析师。下面是一份【技术需求书】正文(可能被截断),请抽取六类技术评估项。
本任务只做技术层面分析,不涉及商务/评分/资质。
【六类评估项】
- function_list 功能清单:需求书要求的功能模块/子系统清单(一个模块一条)
- function_point 功能点:功能模块下的具体功能点(细化到可评估颗粒度)
- hardware 硬件配置:服务器/GPU/存储/网络等硬件要求(型号/数量/参数,需求书没写具体型号的如实标"未指定"
- tech_arch 技术架构:需求书对技术栈/框架/协议/性能指标/安全合规的要求
- func_arch 功能架构:系统分层/模块划分/集成关系要求
- deploy_arch 部署架构:部署模式(私有化/云/混合)/环境/容灾/扩缩容要求
【技术需求书正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"items": [
{"category": "function_list|function_point|hardware|tech_arch|func_arch|deploy_arch",
"item_name": "条目名称(简短可索引)",
"requirement": "需求书原文要求(引用原文,可截断到要点)",
"qty_note": "数量/规模说明(功能点数/台数/容量等,没有就空)",
"mandatory": "1|0需求书明确必须的为1",
"source_ref": "出处(章节/条款号)"}
]
}
要求:
1. 只录需求书**明确提出或可直接推出**的条目,禁止编造、禁止把通用最佳实践当需求;
2. requirement 必须能回溯原文source_ref 给章节号),抽取不到原文出处的标 source_ref="未明示"
3. 同一功能模块的多个功能点分多条录function_point不要合并成大条
4. 硬件参数逐项拆条(如"GPU服务器""存储阵列"各一条),参数写进 requirement。"""
async def _load_req_text(sor, project_id, file_id=""):
"""读技术需求书正文(复用招标文件读取——需求书也登记在 bid_tender_files"""
from .bid_analysis_capability import _load_tender_text
return await _load_tender_text(sor, project_id, file_id=file_id)
# ══════════════ 技术评估项:抽取 / 补录 / 查询 ══════════════
async def extract_tech_items(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""分析维度 techLLM 抽取六类技术评估项落库 bid_tech_items大文件分段调"""
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:
txt, fname, err = await _load_req_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)]
if not seg.strip():
return False, "正文分段为空offset=%s 超出文件长度),无需再抽" % o
data, err = await llm_json(PROMPT_TECH_ITEMS.replace("__CONTENT__", seg),
model_name=model_name, retries=1, project_id=project_id)
if data is None:
return False, "LLM 抽取失败: %s" % err
items = data.get("items") or []
if not isinstance(items, list):
return False, "LLM 返回格式错误items 非数组)"
cnt, skipped = 0, 0
async with db.sqlorContext(dbname) as sor:
# 已有条目(同段重复调用去重:同名同类不重复插)
recs = await sor.sqlExe(
"SELECT category, item_name FROM " + TABLE + " WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
exist = {(getattr(r, "category", ""), getattr(r, "item_name", ""))
for r in (recs or [])}
order_base = await _max_order(sor, project_id)
for it in items:
if not isinstance(it, dict):
continue
cat = str(it.get("category") or "").strip()
name = str(it.get("item_name") or "").strip()[:240]
if cat not in TECH_CATEGORIES or not name:
skipped += 1
continue
if (cat, name) in exist:
skipped += 1
continue
order_base += 1
await sor.C(TABLE, {
"id": new_id(), "project_id": project_id, "kind": "item",
"category": cat, "item_name": name,
"requirement": str(it.get("requirement") or "")[:20000],
"qty_note": str(it.get("qty_note") or "")[:200],
"mandatory": "1" if str(it.get("mandatory") or "") in ("1", "true", "True") else "0",
"confidence": "medium",
"source_ref": str(it.get("source_ref") or "")[:480],
"order_no": order_base,
})
exist.add((cat, name))
cnt += 1
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, TABLE, project_id, "extract_tech_items",
to_state="extracted", who=who, agent_id=agent_id,
detail="新增%d条(去重跳过%d) file=%s offset=%s" % (cnt, skipped, fname, o))
await sor.sqlExe("COMMIT", {})
return True, ("技术评估项抽取完成:新增 %d 条(重复/非法跳过 %d 条)。"
"六类分布用 list_tech_items 核对,漏项用 add_tech_item 补录。" % (cnt, skipped))
async def _max_order(sor, project_id):
recs = await sor.sqlExe(
"SELECT COALESCE(MAX(order_no),0) AS m FROM " + TABLE + " WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
return to_int(getattr(recs[0], "m", 0) if recs else 0)
async def add_tech_item(project_id, category, item_name, requirement="",
qty_note="", mandatory="0", source_ref="",
who=None, agent_id=None):
"""补录一条技术评估项(抽取漏项或人工修正)。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
cat = str(category or "").strip()
name = str(item_name or "").strip()[:240]
if cat not in TECH_CATEGORIES:
return False, "category 非法(应为 %s" % "/".join(TECH_CATEGORIES)
if not name:
return False, "缺少 item_name"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
order_no = await _max_order(sor, project_id) + 1
tid = new_id()
await sor.C(TABLE, {
"id": tid, "project_id": project_id, "kind": "item",
"category": cat, "item_name": name,
"requirement": str(requirement or "")[:20000],
"qty_note": str(qty_note or "")[:200],
"mandatory": "1" if str(mandatory) in ("1", "true", "True") else "0",
"confidence": "high",
"source_ref": str(source_ref or "")[:480],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, TABLE, tid, "add_tech_item",
who=who, agent_id=agent_id, detail="%s/%s" % (cat, name))
await sor.sqlExe("COMMIT", {})
return True, "已补录技术评估项:%s%s" % (name, TECH_CATEGORY_LABELS.get(cat, cat))
async def list_tech_items(project_id, category="", kind="item", limit=300):
"""列出技术评估项(默认只列 item 行;含六类分布统计头部)。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return "ERROR: %s" % e
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, kind, category, item_name, LEFT(requirement, 300) AS requirement, "
"qty_note, mandatory, confidence, source_ref, order_no "
"FROM " + TABLE + " WHERE project_id=${p}$")
p = {"p": project_id}
if category:
sql += " AND category=${c}$"
p["c"] = category
if kind:
sql += " AND kind=${k}$"
p["k"] = kind
sql += " ORDER BY category, order_no LIMIT " + str(to_int(limit, 300))
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
rows = rows_to_dicts(recs, limit=to_int(limit, 300))
stat = await sor.sqlExe(
"SELECT category, COUNT(*) AS c FROM " + TABLE +
" WHERE project_id=${p}$ AND kind='item' GROUP BY category", {"p": project_id})
await sor.sqlExe("COMMIT", {})
dist = {getattr(r, "category", ""): to_int(getattr(r, "c", 0)) for r in (stat or [])}
head = "六类分布:" + "".join(
"%s=%d" % (TECH_CATEGORY_LABELS.get(k, k), v) for k, v in
sorted(dist.items(), key=lambda kv: TECH_CATEGORIES.index(kv[0])
if kv[0] in TECH_CATEGORIES else 99))
missing = [TECH_CATEGORY_LABELS[c] for c in TECH_CATEGORIES if not dist.get(c)]
if missing and kind == "item":
head += "(缺类:%s——需求书确实没有则如实说明,有漏项用 add_tech_item 补录)" % "".join(missing)
if not rows:
return head + "\n(无记录)"
return head + "\n" + "\n".join(
json.dumps(r, ensure_ascii=False, default=str) for r in rows)
# ══════════════ 模版确定 + 用户确认骨架template_confirm 阶段) ══════════════
async def find_template_candidates(project_id, query="", model_name="",
who=None, agent_id=None):
"""检索技术方案模版候选:知识库(doc_type=tech_template/sample_bid) → RAG → 网络。
返回候选清单文本PM/analyst 角色工具)。三级降级链每级如实标注来源,
全部落空时如实说明——由角色 agent 按行业惯例自拟骨架(也要经用户确认)。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return "ERROR: %s" % e
q = (query or "技术方案书 目录 模版").strip()
lines = ["## 模版候选检索(%s" % q, ""]
# 1. 公司知识库
try:
from .bid_kb_capability import search_bid_kb
kb = await search_bid_kb(query=q, doc_type="tech_template")
if kb and "无命中" not in str(kb):
lines += ["### ① 公司知识库doc_type=tech_template预定模版优先", str(kb)[:3000], ""]
else:
kb2 = await search_bid_kb(query=q, doc_type="sample_bid")
if kb2 and "无命中" not in str(kb2):
lines += ["### ① 公司知识库(样板标书参照)", str(kb2)[:3000], ""]
else:
lines += ["### ① 公司知识库:无预定模版", ""]
except Exception as e:
lines += ["### ① 公司知识库:查询失败(%s" % str(e)[:100], ""]
# 2. RAG 检索
try:
from pipeline_service.rag_client import rag_search
data, err = await rag_search(project_id, q, top_k=5)
if err:
lines += ["### ② RAG 检索:失败(%s" % err[:150], ""]
else:
chunks = (data or {}).get("results") or (data or {}).get("chunks") or []
if chunks:
brief = "\n".join("- [%s] %s" % (
(c.get("doc_name") or c.get("kb_name") or "?"),
str(c.get("content") or c.get("text") or "")[:200])
for c in chunks[:5] if isinstance(c, dict))
lines += ["### ② RAG 知识库检索", brief, ""]
else:
lines += ["### ② RAG 检索:无命中", ""]
except Exception as e:
lines += ["### ② RAG 检索:不可用(%s" % str(e)[:100], ""]
# 3. 网络检索
try:
from pipeline_service.web_tools import tool_web_search
web = await tool_web_search(q + " 目录结构", limit=5)
lines += ["### ③ 网络检索", str(web)[:2500], ""]
except Exception as e:
lines += ["### ③ 网络检索:不可用(%s" % str(e)[:100], ""]
lines += ["---", "下一步结合六类评估项list_tech_items与候选模版拟定章节骨架"
"调 propose_tech_template 发用户确认待办(骨架确认前不得开写章节)。"]
return "\n".join(lines)
PROMPT_TECH_OUTLINE = """你是技术方案架构师。根据【技术需求书评估项】和【模版参考】拟定技术方案书的章节骨架。
【六类技术评估项(需求书抽取结果)】
__ITEMS__
【模版参考(可能为空——为空则按行业惯例自拟)】
__TEMPLATE__
严格只输出一个 JSON 对象:
{"chapters": [
{"chapter_no": "1", "title": "章节标题", "outline": "本章要写什么要点(覆盖哪些评估项,写到操作层)",
"cover_categories": ["function_list","tech_arch"]}
]}
要求:
1. 章节结构完整覆盖六类评估项:每个 category 至少被一章的 cover_categories 覆盖,漏覆盖 = 骨架不合格;
2. 有模版参考时优先沿用它章节顺序与命名惯例,按本项目评估项裁剪/补充(模版是蓝本不是成品);
3. 每章 outline 写清要响应的需求书条目(引用 item_name不写空话
4. 章节数控制在 6~15 章,粒度以"一章一任务可写完"为准;
5. chapter_no 用数字字符串("1","2",...),子节写进 outline 不单独成章。"""
async def propose_tech_template(project_id, chapters="", template_source="",
template_ref="", note="", model_name="",
who=None, agent_id=None):
"""生成技术方案章节骨架并发「模版确认」人类待办(用户确认后才落 bid_chapters 开写)。
chaptersJSON 数组 [{chapter_no,title,outline,cover_categories?}]
为空时自动按六类评估项 + 模版候选 LLM 生成。
template_sourcekb/rag/web/manual如实标注骨架依据来源
确认动作是用户专属(专用端点 tech_template_confirm.dspyagent 无确认工具。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
items = json_loads(chapters, []) if isinstance(chapters, str) else (chapters or [])
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
proj = await get_project(sor, project_id)
if not proj:
return False, "项目不存在: %s" % project_id
if not items:
# 自动生成:六类评估项 + 模版候选 → LLM 拟骨架
irecs = await sor.sqlExe(
"SELECT category, item_name, LEFT(requirement,200) AS requirement, mandatory "
"FROM " + TABLE + " WHERE project_id=${p}$ AND kind='item' "
"ORDER BY category, order_no LIMIT 400", {"p": project_id})
await sor.sqlExe("COMMIT", {})
irows = rows_to_dicts(irecs, limit=400)
if not irows:
return False, ("无技术评估项——先完成 tech_analysis 维度抽取"
"extract_tech_items骨架必须覆盖评估项不能凭空拟")
items_txt = "\n".join("- [%s]%s %s%s" % (
TECH_CATEGORY_LABELS.get(r.get("category"), r.get("category")),
"" if str(r.get("mandatory")) == "1" else "",
r.get("item_name"), str(r.get("requirement") or "")[:120]) for r in irows)
tpl_txt = str(template_ref or "")[:6000] or "(无模版参考,按行业惯例自拟)"
data, err = await llm_json(
PROMPT_TECH_OUTLINE.replace("__ITEMS__", items_txt).replace("__TEMPLATE__", tpl_txt),
model_name=model_name, retries=1, project_id=project_id)
if data is None:
return False, "LLM 生成骨架失败: %s" % err
items = data.get("chapters") or []
if not isinstance(items, list) or not items:
return False, "章节骨架为空chapters 解析失败),请传 JSON 数组或修正后重试"
# 规范化 + 覆盖度校验(机制硬校验:六类评估项必须全覆盖,缺类拒绝——
# 骨架漏类 = 方案书必漏需求 = QC 对标必退,不如在确认门之前就挡住)
norm, covered = [], set()
for idx, ch in enumerate(items):
if not isinstance(ch, dict):
continue
no = str(ch.get("chapter_no") or (idx + 1)).strip()[:30]
title = str(ch.get("title") or "").strip()[:250]
if not title:
continue
cats = ch.get("cover_categories") or []
if isinstance(cats, str):
cats = [c.strip() for c in cats.replace("", ",").split(",") if c.strip()]
cats = [c for c in cats if c in TECH_CATEGORIES]
covered.update(cats)
norm.append({"chapter_no": no, "title": title,
"outline": str(ch.get("outline") or "")[:4000],
"cover_categories": cats})
if not norm:
return False, "章节骨架全部非法(缺 title"
# 项目实际存在的评估类别(需求书没提硬件就不强制覆盖硬件)
crecs = await sor.sqlExe(
"SELECT DISTINCT category FROM " + TABLE + " WHERE project_id=${p}$ AND kind='item'",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
need = {getattr(r, "category", "") for r in (crecs or [])} & set(TECH_CATEGORIES)
miss = sorted(need - covered)
if miss:
return False, ("骨架未覆盖以下评估类别:%s。每类至少一章 cover_categories 覆盖,"
"请补章节或修正 cover_categories 后重提。"
% "".join(TECH_CATEGORY_LABELS.get(m, m) for m in miss))
# 骨架预览 markdown进待办正文
md = ["## 技术方案书章节骨架(待确认)", ""]
if template_source:
md.append("**模版来源:** %s%s" % (
{"kb": "公司知识库预定模版", "rag": "RAG 知识库检索",
"web": "网络检索", "manual": "按行业惯例自拟"}.get(template_source, template_source),
"%s" % template_ref[:200] if template_ref else ""))
md += ["", "| # | 章节 | 要点 | 覆盖评估类 |", "|---|---|---|---|"]
for ch in norm:
md.append("| %s | %s | %s | %s |" % (
ch["chapter_no"], ch["title"],
(ch["outline"] or "").replace("\n", " ")[:120],
"".join(TECH_CATEGORY_LABELS.get(c, c) for c in ch["cover_categories"]) or "-"))
md += ["", "---", "",
"确认后骨架落库并开始分章编写每章对标需求书原文QC 按需求覆盖度评审);",
"驳回请附意见(换模版/调章节/补覆盖),助手按意见修订后重新提案。"]
if note:
md += ["", "**助手说明:**", note[:2000]]
hid = await create_human_task(
sor, project_id, HT_TEMPLATE_CONFIRM,
"技术方案书章节骨架确认:%s" % (proj.get("name") or project_id),
"\n".join(md),
assignee_role="owner.superuser",
form_schema={"chapters": norm, "template_source": template_source or "",
"template_ref": (template_ref or "")[:2000]})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "pipeline_human_tasks", hid, "propose_tech_template",
to_state="pending_confirm", who=who, agent_id=agent_id,
detail="%d章 source=%s" % (len(norm), template_source or "-"))
await sor.sqlExe("COMMIT", {})
return True, ("已生成技术方案章节骨架(%d 章)并发「模版确认」待办(%s)。"
"用户确认前流程阻塞,你不能代替确认;确认后系统自动落骨架开写。"
% (len(norm), hid))
async def confirm_tech_template(human_task_id, decision, comment="",
operator_id=None):
"""用户确认/驳回章节骨架专用端点调用agent 无此工具)。
confirm骨架落 bid_chapters(section=technical, status=pending) + 关待办 → 对账器开写;
reject关待办status=rejected意见存 result_data→ 对账器重派模版任务带意见。
"""
if not human_task_id:
return False, "缺少 human_task_id"
if not operator_id:
return False, "未登录"
decision = str(decision or "").strip().lower()
if decision not in ("confirm", "reject"):
return False, "decision 必须是 confirm 或 reject"
if decision == "reject" and not (comment or "").strip():
return False, "驳回必须填写意见(助手按意见修订骨架)"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_human_tasks WHERE id=${h}$", {"h": human_task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "待办不存在: %s" % human_task_id
ht = rec_to_dict(recs[0])
if ht.get("task_type") != HT_TEMPLATE_CONFIRM:
return False, "该待办不是模版确认类型(%s" % ht.get("task_type")
if ht.get("status") != "pending":
return False, "待办已处理(当前 %s" % ht.get("status")
project_id = ht.get("project_id") or ""
# 确认权限:复用引擎 _check_confirm_operator同机构 + owner.superuser/项目创建者)——
# 与 flow_plan_confirm 确认门语义完全一致不自己另写一套校验2026-09-15 E2E 实测
# 发现自写「仅同机构」把超管/创建者语义丢了)。
try:
from pipeline_service.flow_plan_capability import _check_confirm_operator
ok_op, err_op = await _check_confirm_operator(
sor, {"project_id": project_id}, operator_id)
if not ok_op:
return False, err_op or "无权确认该项目模版"
except ImportError:
# 引擎不可用(理论不发生):兜底同机构校验
prec = await sor.sqlExe("SELECT org_id FROM sd_projects WHERE id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
proj_org = getattr(prec[0], "org_id", "") if prec else ""
from pipeline_service.human_task_capability import _get_user_org
user_org = await _get_user_org(sor, operator_id)
if proj_org and user_org != proj_org:
return False, "仅同机构用户可确认该项目模版"
if decision == "confirm":
schema = json_loads(ht.get("form_schema"), {}) or {}
if not isinstance(schema, dict):
schema = {}
chapters = schema.get("chapters") or []
if not chapters:
return False, "待办里没有骨架数据form_schema.chapters 空),无法落库"
# 幂等重落守卫仅当既有章节全部未开写pending时清旧骨架——
# 已有 writing/written/approved 章节说明流程已推进,重新确认会毁掉
# 已写内容,拒绝并提示走驳回/人工处置2026-09-15 加固)。
busy = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_chapters WHERE project_id=${p}$ "
"AND status<>${s}$", {"p": project_id, "s": CH_PENDING})
await sor.sqlExe("COMMIT", {})
if to_int(getattr(busy[0], "c", 0) if busy else 0) > 0:
return False, ("项目已有开写/已完成的章节,不能重新确认骨架(会毁掉已写内容)。"
"如需换骨架请先驳回或在章节管理页人工处置既有章节。")
# 幂等:清掉此前确认落库但尚未开写的旧骨架(驳回重提后再确认的场景)
await sor.sqlExe(
"DELETE FROM bid_chapters WHERE project_id=${p}$", {"p": project_id})
n = 0
for idx, ch in enumerate(chapters):
if not isinstance(ch, dict):
continue
await sor.C("bid_chapters", {
"id": new_id(), "project_id": project_id,
"chapter_no": str(ch.get("chapter_no") or (idx + 1))[:32],
"title": str(ch.get("title") or "")[:255],
"section": "technical",
"outline": str(ch.get("outline") or "")[:60000],
"status": CH_PENDING, "version": 1, "revise_count": 0,
"order_no": idx + 1,
})
n += 1
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', qc_status='passed', "
"qc_comment='用户已确认技术方案骨架', result_data=${rd}$, "
"submitted_by=${u}$, submitted_at=NOW() WHERE id=${h}$",
{"rd": json.dumps({"decision": "confirm", "chapters": n}, ensure_ascii=False),
"u": operator_id, "h": human_task_id})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_chapters", project_id, "template_confirmed",
to_state="pending", who=operator_id,
detail="骨架落库 %d" % n)
await sor.sqlExe("COMMIT", {})
logger.info("tech_template confirmed: project=%s chapters=%d by=%s",
project_id, n, operator_id)
return True, "骨架已确认并落库(%d 章),系统将自动开始分章编写。" % n
# reject
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='rejected', result_data=${rd}$, "
"submitted_by=${u}$, submitted_at=NOW() WHERE id=${h}$",
{"rd": json.dumps({"decision": "reject", "comment": (comment or "")[:2000]},
ensure_ascii=False),
"u": operator_id, "h": human_task_id})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "pipeline_human_tasks", human_task_id,
"template_rejected", to_state="rejected", who=operator_id,
detail=(comment or "")[:500])
await sor.sqlExe("COMMIT", {})
return True, "已驳回,助手将按你的意见修订骨架后重新提案。"
async def get_template_reject_comment(sor, project_id):
"""最新一次模版驳回意见(对账器重派模版任务时注入 params无则 '')。"""
ht = await find_human_task(sor, project_id, HT_TEMPLATE_CONFIRM, status="rejected")
if not ht:
return ""
rd = json_loads(ht.get("result_data"), {}) or {}
if not isinstance(rd, dict):
return ""
return str(rd.get("comment") or "")