diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts index 6177d009..ec83e616 100644 --- a/frontend-web/src/roundtable-planning/api/flow-extract.ts +++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts @@ -12,7 +12,7 @@ import { getBackendBaseURL } from "@/core/config"; import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; /** 第三步输入框意图分类结果(与后端 /intent 对应)。 */ -export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "qa" | "unclear"; +export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "ingest" | "qa" | "unclear"; /** * 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。 @@ -22,6 +22,8 @@ export async function classifyFlowIntent(req: { message: string; hasReport: boolean; hasFlow: boolean; + /** 当前是否可入库(入库按钮已出现、未入库);为真才允许识别出 ingest 意图。 */ + canIngest?: boolean; model?: string; signal?: AbortSignal; }): Promise<{ intent: FlowIntent; reason: string }> { @@ -33,6 +35,7 @@ export async function classifyFlowIntent(req: { message: req.message, has_report: req.hasReport, has_flow: req.hasFlow, + can_ingest: req.canIngest ?? false, model: req.model, }), signal: req.signal, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index bff0b6de..48a0b6c7 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -180,10 +180,19 @@ function buildMaterials(opts: { seatDeliveries: ReportSeatDelivery[]; consensusText: string; coordinatorPrompt?: string | null; + /** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */ + actionName?: string | null; }): string { - const { intentReady, seatDeliveries, consensusText, coordinatorPrompt } = opts; + const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts; const parts: string[] = []; + const an = actionName?.trim(); + if (an) { + parts.push("# 本次行动"); + parts.push(an); + parts.push(""); + } + const cp = coordinatorPrompt?.trim(); if (cp) { parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)"); @@ -295,6 +304,7 @@ function buildSummaryPrompt(opts: { seatDeliveries: ReportSeatDelivery[]; consensusText: string; coordinatorPrompt?: string | null; + actionName?: string | null; flowMode?: boolean; }): string { const parts: string[] = []; @@ -1262,6 +1272,9 @@ export function useStep3Summary( seatDeliveries, consensusText, coordinatorPrompt, + // xdfx(7BF):把本次行动名称喂进总结材料,让报告围绕该行动展开。 + actionName: + ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null, flowMode, }); await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode }); @@ -1518,10 +1531,17 @@ export function useStep3Summary( if (!intent) { // **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。 dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" }); + // 入库可用(按钮已出现、未入库 / 未在跑)时把 canIngest 传给意图识别——让「帮我入库」被识别为 + // ingest 而不是落到 qa/unclear(之前主分类器不懂入库,只有「稍后入库待命」时才识别入库,是个缺口)。 + const canIngestNow = + ingestAvailableRef.current && + ingestStatusRef.current !== "running" && + ingestStatusRef.current !== "done"; const r = await classifyFlowIntent({ message: text, hasReport: !!currentMdRef.current.trim(), hasFlow: !!flowGraphRef.current, + canIngest: canIngestNow, model: selectedModel || undefined, }); intent = r.intent; @@ -1533,6 +1553,12 @@ export function useStep3Summary( setPendingFlowClarification(text); // 组件渲染选项让用户自己定 return; } + if (intent === "ingest") { + // 识别为入库(后端已保证仅 can_ingest 时才会返回 ingest)→ 直接入库;复用调度气泡。 + if (dispatchBubbleId != null) finalizeDialogue(dispatchBubbleId, "正在入库…", "入库中"); + await runIngest(); + return; + } if (intent === "regenerate_flow" || intent === "modify_flow") { const flowAborter = new AbortController(); aborterRef.current = flowAborter; diff --git a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py index 760f5d43..c6fb9aea 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py +++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py @@ -78,11 +78,14 @@ class FlowIntentRequest(BaseModel): message: str = "" has_report: bool = False has_flow: bool = False + # 当前是否可入库(结构化抽取智能体已判定有适配本场景的入库技能、入库按钮已出现且未入库)。 + # 为真时才允许识别出 ingest 意图——否则「帮我入库」回退为 qa/unclear。 + can_ingest: bool = False model: str | None = Field(default=None) -# 意图枚举(与前端路由一一对应)。 -_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "qa", "unclear"} +# 意图枚举(与前端路由一一对应)。ingest 仅在 can_ingest=true 时可用。 +_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "ingest", "qa", "unclear"} # ── flow-json 容错解析(Python 版,对齐前端 lib/flow-graph.ts)────────────── @@ -434,13 +437,16 @@ _STRUCTURE = ( def _material(req: FlowExtractRequest) -> str: + # xdfx:把本次「行动名称」作为材料抬头喂进去(根节点就是它),让抽取贴着这条行动展开。 + action = (req.root_label or "").strip() + prefix = f"【本次行动】{action}\n\n" if action else "" report = (req.report_md or "").strip() if report: - return "===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 =====" + return prefix + "===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 =====" seats = [s for s in req.seat_deliveries if (s.content or "").strip()] if seats: body = "\n\n".join(f"## 席位{i + 1}:{s.sender or '(未具名席位)'}\n{s.content.strip()}" for i, s in enumerate(seats)) - return "===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + body + "\n===== 交付结束 =====" + return prefix + "===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + body + "\n===== 交付结束 =====" return "" @@ -703,23 +709,33 @@ def _parse_intent_json(text: str) -> dict[str, Any] | None: _INTENT_SYS = ( - "你是圆桌「方案总结」第三步输入框的**意图识别器**。第三步有两类产物:方案总结报告(Markdown) 和 结构化流程图。" - "用户在输入框发了一条消息,请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n" + "你是圆桌「方案总结」第三步对话的**意图识别器**,也是个能帮用户的助手。第三步已产出:方案总结报告(Markdown)、" + "结构化流程图,可能还可把成果入库到知识库。用户在对话框发了一条消息,请**按语义理解他真正想干什么**" + "(不要只做关键词匹配),只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n" '{"intent":"<枚举>","reason":"<一句话依据>"}\n' - "intent 只能取其一:\n" - "- regenerate_report:想重新分析 / 重写【方案总结报告】(如「重新生成报告」「报告再写一版」「重新分析一下」);\n" - "- modify_flow:想修改**当前流程图**的某些节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「流程图这里调整下」);\n" + "intent 取值:\n" + "- regenerate_report:想**重做/重写整份**方案总结报告(如「重新生成报告」「报告再写一版」「重新分析一下」);\n" + "- modify_flow:想**修改当前流程图**的节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「这一层调整下」);\n" "- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n" - "- qa:只是提问 / 咨询,不需要改任何产物(如「为什么选 OTA 平台」「这个目的什么意思」);\n" - "- unclear:意图不明确、无法判断要改报告还是改流程图还是提问(如只说「改一下」「不对」「重来」而没指明对象)。\n" - "判断要点:结合下面给出的上下文(是否已有报告 / 是否已有流程图);当前已有流程图时,含「节点/连线/这一层/这个目的/行为体…改/删/加」之类多半是 modify_flow;" - "明确提到「报告」才是 regenerate_report;只有完全无法判断对象时才用 unclear。\n" + "- ingest:想把本次成果**入库/沉淀/存进知识库**(如「帮我入库」「入库吧」「把方案存进知识库」「沉淀一下」);\n" + "- qa:**提问 / 要求解释、介绍、概述、答疑**,只需用文字回答、不改任何产物(如「介绍一下这报告的内容」" + "「这方案主要讲了啥」「为什么选 OTA 平台」「风险最高的是哪条」「帮我看看有没有遗漏」「总结成几句话」);\n" + "- unclear:**实在**判断不出他想做哪件事(如只说「改一下」「不对」「重来」「再说」且无任何指向)。\n" + "判断要点:\n" + "1. **优先识别出明确意图,不要动不动就 unclear**——只要能合理判断就给具体意图;尤其『介绍/讲讲/解释/说明/" + "为什么/是什么/有没有/帮我看/概述/总结一下』这类是**对内容的提问** → 一律 qa(用文字答),不要误判成重做报告。\n" + "2. 含『节点/连线/这一层/这个目的/行为体/流程图…的 改/删/加/换』 → modify_flow(前提是已有流程图)。\n" + "3. 明确说『报告』要重写/重做才是 regenerate_report;只是问报告内容是 qa。\n" + "4. **入库类意图只有在下文上下文标注「可入库=是」时才允许返回 ingest**;若标注不可入库,用户提入库就归 qa" + "(用文字说明当前不可入库/无适配技能)。\n" "示例:\n" - '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重新生成报告"}\n' - '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"要求修改流程图某节点"}\n' - '「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"要求重画整张流程图"}\n' - '「OTA 平台为什么是直接行为体」→ {"intent":"qa","reason":"纯提问不改产物"}\n' - '「改一下」→ {"intent":"unclear","reason":"没说改报告还是流程图"}' + '「帮我介绍一下这报告中的内容」→ {"intent":"qa","reason":"要求介绍报告内容,属提问答疑"}\n' + '「这方案为什么这么定」→ {"intent":"qa","reason":"询问方案理由"}\n' + '「帮我入库」(可入库=是) → {"intent":"ingest","reason":"明确要求入库且当前可入库"}\n' + '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重做报告"}\n' + '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"修改流程图某节点"}\n' + '「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"重画整张流程图"}\n' + '「改一下」→ {"intent":"unclear","reason":"没说改什么"}' ) @@ -729,7 +745,11 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo msg = (payload.message or "").strip() if not msg: return {"intent": "unclear", "reason": "空消息"} - ctx = f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};{'已生成流程图' if payload.has_flow else '尚无流程图'}。" + ctx = ( + f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};" + f"{'已生成流程图' if payload.has_flow else '尚无流程图'};" + f"可入库={'是' if payload.can_ingest else '否'}。" + ) try: model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False) res = await model.ainvoke( @@ -740,6 +760,9 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo intent = _as_str(obj.get("intent")).lower() if intent not in _INTENTS: intent = "unclear" + # 不可入库时模型若误判 ingest → 降级为 qa(用文字说明当前不可入库),不要触发入库。 + if intent == "ingest" and not payload.can_ingest: + intent = "qa" return {"intent": intent, "reason": _as_str(obj.get("reason"))} except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定 logger.warning("flow intent classify failed", exc_info=True)