541 lines
28 KiB
Python
541 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""纯技术方案流程能力(bid_tech_proposal,2026-09-14 批2)。
|
||
|
||
用户 2026-09-11 定义的产线第二流程:输入=技术需求书(非招标文件),产出=技术方案书。
|
||
分析集中在技术层面六类评估项:功能清单/功能点/硬件配置/技术架构/功能架构/部署架构;
|
||
方案书编写根据预定模版(无则 RAG/网络检索),**模版骨架由用户确认后**按章节分段编写,
|
||
章节 QC 评审**以用户提供的需求书为唯一基准**(对标覆盖度,不碰评分项),最终合成技术方案书。
|
||
|
||
机制分工(沿用产线既有约定:流转归机制,操作原语归本模块):
|
||
- bid_flow 对账器按已确认计划的 base_flow_key 走 tech 分支派发任务;
|
||
- 本模块固化操作原语:技术评估项抽取/补录/查询、模版候选生成、模版确认落骨架;
|
||
- QC 复用 bid_qc_reviews 机制(qc_type=tech_items,start_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):
|
||
"""分析维度 tech:LLM 抽取六类技术评估项落库 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 开写)。
|
||
|
||
chapters:JSON 数组 [{chapter_no,title,outline,cover_categories?}];
|
||
为空时自动按六类评估项 + 模版候选 LLM 生成。
|
||
template_source:kb/rag/web/manual(如实标注骨架依据来源)。
|
||
确认动作是用户专属(专用端点 tech_template_confirm.dspy),agent 无确认工具。
|
||
"""
|
||
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 "")
|