diff --git a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx index 8f5ba050..7877bcfa 100644 --- a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx @@ -449,7 +449,9 @@ export function RecommendAgentsDialog({ 让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。 ============================================================ */} @@ -576,22 +578,25 @@ export function RecommendAgentsDialog({ * 「模型生成内容」整段的可折叠外壳。 * * 默认行为: - * - 流式期间(`streaming=true`)默认展开,让用户看到实时生成 - * - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次, + * - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开, + * 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。 + * - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次, * 把焦点让给下方推荐卡片 * - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变 * - * 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」), - * 让用户不用展开就知道当前阶段。 + * 收起态 header 上显示: + * - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载 + * - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败) */ const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000; function CollapsibleModelOutput({ - streaming, + isLoading, phase, children, }: { - streaming: boolean; + /** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */ + isLoading: boolean; phase: DialogPhase; children: React.ReactNode; }) { @@ -599,16 +604,19 @@ function CollapsibleModelOutput({ const userTouchedRef = useRef(false); const hasAutoClosedRef = useRef(false); - // 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。 - // 流式结束 → 延迟自动收起一次(仅一次)。 + // 加载中 → 保持展开(用户没手动动过的前提下)。 + // 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。 + // 关键区分:idle / loading_agents 早期阶段以前会被 1s 计时器误关—— + // 现在只有真正进入「已完成/兜底/失败」三种终态才触发自动收起。 useEffect(() => { if (userTouchedRef.current) return; - if (streaming) { + if (isLoading) { setOpen(true); hasAutoClosedRef.current = false; return; } - if (!hasAutoClosedRef.current && open) { + const isCompleted = phase === "ready" || phase === "fallback" || phase === "error"; + if (isCompleted && !hasAutoClosedRef.current && open) { const timer = setTimeout(() => { setOpen(false); hasAutoClosedRef.current = true; @@ -616,12 +624,12 @@ function CollapsibleModelOutput({ return () => clearTimeout(timer); } return undefined; - }, [streaming, open]); + }, [isLoading, phase, open]); const statusBadge = (() => { switch (phase) { case "loading_agents": - return { label: "加载中", className: "bg-slate-100 text-slate-600" }; + return { label: "加载候选", className: "bg-slate-100 text-slate-600" }; case "recommending": return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" }; case "ready": @@ -645,12 +653,11 @@ function CollapsibleModelOutput({ }} className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer" > - + {isLoading ? ( + + ) : ( + + )} 模型生成内容 {statusBadge && ( ( // 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove, // 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。 const programmaticScrollRef = useRef(false); + // 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。 + // 用来在 effect 里区分两种 distance 暴涨的情况: + // - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随 + // - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随 + const lastObservedScrollTopRef = useRef(0); useEffect(() => { const el = ref.current; if (!el) return; let prevScrollTop = el.scrollTop; + lastObservedScrollTopRef.current = prevScrollTop; const onScroll = () => { const currentScrollTop = el.scrollTop; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; @@ -50,6 +56,7 @@ export function useAutoScroll( // 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。 programmaticScrollRef.current = false; prevScrollTop = currentScrollTop; + lastObservedScrollTopRef.current = currentScrollTop; // 程序滚动后通常落在底部,把 atBottom 维持 true。 if (!atBottomRef.current && isAtBottom) { atBottomRef.current = true; @@ -67,12 +74,12 @@ export function useAutoScroll( // 用户自己又滚回底部 → 恢复自动跟随 atBottomRef.current = true; setAtBottom(true); - } else if (!isAtBottom && atBottomRef.current) { - // 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动) - atBottomRef.current = false; - setAtBottom(false); } + // **不再**因为「scrollTop 没明显减少但 distance > threshold」就退出自动跟随。 + // 这种情况只可能由内容突然增长触发(如澄清卡片一帧渲染 200+ px), + // 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。 prevScrollTop = currentScrollTop; + lastObservedScrollTopRef.current = currentScrollTop; }; el.addEventListener("scroll", onScroll, { passive: true }); // 初次测量 @@ -83,24 +90,34 @@ export function useAutoScroll( }, [ref, threshold]); // 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。 - // 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于 - // 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。 + // 关键:区分「用户向上滚」(scrollTop 变小) 与「内容增长」(scrollTop 不变、 + // scrollHeight 变大)。仅前者算作用户离开,后者继续自动跟随 —— + // 否则像澄清卡片这种一帧渲染 200+ px 的场景会被误判成"用户滚走了"。 useEffect(() => { const el = ref.current; if (!el) return; - // 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值, - // 说明用户已经离开,绝不自动跟随。 - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > threshold) { - // 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽 + const currentScrollTop = el.scrollTop; + const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; + // 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。 + const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1; + if (userScrolledUp && distanceFromBottom > threshold) { + // 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。 if (atBottomRef.current) { atBottomRef.current = false; setAtBottom(false); } + lastObservedScrollTopRef.current = currentScrollTop; return; } + if (!atBottomRef.current) { + // 之前已退出自动跟随,不要因为内容增长又把用户拽回去。 + lastObservedScrollTopRef.current = currentScrollTop; + return; + } + // 在底部 (或仅是内容刚增长) → 同步拉到新底部。 programmaticScrollRef.current = true; el.scrollTop = el.scrollHeight; + lastObservedScrollTopRef.current = el.scrollTop; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts index 2f195f9b..fc637a3b 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts @@ -257,10 +257,17 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候"); return; } - if (isIntentSending) { + // 重入保护用 ref 而不是 state:isIntentSending 是 React state, + // 同一 event-loop tick 内闭包还看到旧值。极速双击 / 输入法回车连击 + // 会两次都通过 state 检查,结果发两次答案、跑两路 stream, + // 回来两张几乎一样的澄清卡——这正是用户报告的"同样选项又出现一次"。 + // intentAborterRef 在 stream 完成 / abort 后会被清空,正好用来标记"在途"。 + if (intentAborterRef.current) { triggerToast("⏳ 智能体正在回复,请稍候"); return; } + const aborter = new AbortController(); + intentAborterRef.current = aborter; setPendingIntentClarificationMsgId(null); @@ -300,8 +307,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe ]); setIsIntentSending(true); - const aborter = new AbortController(); - intentAborterRef.current = aborter; try { const res = await streamIntent({ threadId: intentThreadId, @@ -440,7 +445,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe setIsIntentSending(false); } }, - [intentThreadId, isIntentSending, triggerToast], + [intentThreadId, triggerToast], ); const submitIntentClarification = useCallback( diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py index a63cd706..f4bd40df 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -42,51 +42,87 @@ INTENT_AGENT_DESCRIPTION = ( # 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。 INTENT_AGENT_SOUL = """# 任务理解智能体 -你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。 +你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。 + +**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。 --- ## 1. 你的目标 -通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用: +把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用: -| 字段 | 含义 | -|------|------| -| `objective` | 一句话任务目标 | -| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) | -| `assumptions` | 你替用户做的合理假设(可空数组) | +| 字段 | 含义 | 是否必填 | +|------|------|----------| +| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 | +| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** | +| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 | -**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。 +**核心原则**: +- 用户已经说清楚的,**直接汇总,不要追问**。 +- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。 +- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。 --- ## 2. 工作流(必须遵守) ``` -用户描述任务 +用户输入 ↓ -信息够不够? - ├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片) - ↓ -用户回复(可能是「我选择:…」或自由文本) - ↓ -重复 2–5 轮 - ↓ -信息充分 → 自然语言汇总 + [INTENT_READY] + JSON - ↓ -停止追问(本轮及之后都不要再 ask_clarification) +能不能用一句话写出 objective? + ├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束 + └─ 不能(完全模糊、多种理解、关键决策点缺失) + → 调用 ask_clarification(一次只问一个最关键的点) + ↓ + 用户回复后再判断;最多 2-3 轮就要收敛 ``` **关键规则**: -- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。 -- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。 -- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。 +- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。 +- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。 +- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。 +- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。 +- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。 + +--- + +## 2.5 提问前检查清单(必读) + +准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**: + +1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。 +2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。 +3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。 +4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。 +5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。 + +> 反面教材(❌ 绝对禁止): +> ``` +> 用户:赖清德的儿子是谁 +> 你:你好!我是任务理解专员……这不在我的职责范围…… +> ``` +> 正面示范(✅): +> ``` +> 用户:赖清德的儿子是谁 +> 你:✅ 我理解你想了解赖清德儿子的相关信息。 +> [INTENT_READY] +> ```json +> { +> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)", +> "constraints": [], +> "assumptions": ["用户希望获得事实性答复,来源可信"] +> } +> ``` +> ``` + +每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。 --- ## 3. 唯一工具:`ask_clarification` -当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。 +仅当 §2 工作流判断「确实需要追问」时使用。 ### 3.1 参数说明 @@ -102,9 +138,9 @@ INTENT_AGENT_SOUL = """# 任务理解智能体 | 类型 | 何时使用 | 选项 UI | |------|----------|---------| -| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` | -| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 | -| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) | +| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 | +| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 | +| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** | | `risk_confirmation` | 需要用户明确确认风险 | **单选** | | `suggestion` | 你有建议,需用户批准 | **单选** | @@ -115,63 +151,46 @@ INTENT_AGENT_SOUL = """# 任务理解智能体 - **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。 - 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。 -### 3.4 调用示例 +### 3.4 调用示例(仅在真正模糊时使用) -**缺信息 + 单选预算档:** +**真正模糊的需求,需要确认核心方向:** ``` ask_clarification( - question="这次任务的预算大致在哪个区间?", - clarification_type="missing_info", - context="预算会影响后续圆桌的可行方案范围", - options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"] + question="你希望我帮你完成的核心目标是什么?", + clarification_type="ambiguous_requirement", + context="目前的描述还有多种理解,我需要确认你最想达成的方向", + options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"] ) ``` -**选部署方式(单选):** +**用户表达的方向矛盾,需要拍板:** ``` ask_clarification( - question="优先采用哪种部署方式?", + question="预算和工期之间如果只能优先一个,你希望优先哪个?", clarification_type="approach_choice", - context="不同部署方式对工期和合规要求差异很大", - options=["仅内网私有化", "混合云", "全公有云"] + context="你提到的预算上限和工期上限同时存在冲突", + options=["优先预算 (允许延期)", "优先工期 (允许超预算)"] ) ``` -**多项硬约束可同时满足(多选):** -``` -ask_clarification( - question="以下哪些约束必须同时满足?(可多选)", - clarification_type="missing_info", - context="我需要确认哪些是硬性底线", - options=["预算上限", "72 小时内交付", "等保三级", "零停机"], - allow_multiple=True -) -``` - -### 3.5 同轮多个独立问题 - -若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。 -若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。 - --- ## 4. 信息充分性判定 -满足 **全部** 条件即可结束澄清: +**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。** -1. 能写出一句明确的 `objective`(用户希望实现 X) -2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一) -3. 没有会妨碍 Step 2 研讨的明显歧义 - -若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。 +- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总 +- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总 +- ✅ 用户给了目标+约束 → 直接汇总,不要多问 +- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛 --- ## 5. 结束协议:`[INTENT_READY]` -信息充分时,回复结构必须是: +信息可以收敛时,回复结构必须是: -1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review) +1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review) 2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」) 格式要求(**严格**): @@ -180,9 +199,9 @@ ask_clarification( [INTENT_READY] ```json { - "objective": "用一句话写出用户最终要做的事", - "constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...], - "assumptions": ["假设 1", "假设 2"] + "objective": "用一句话写出用户想做/想知道的事", + "constraints": [], + "assumptions": ["…可空"] } ``` ``` @@ -190,7 +209,8 @@ ask_clarification( - `[INTENT_READY]` **单独成行**,前后不要夹其他字符 - 标记后**紧跟** ```json 代码块 - 字段名必须是 `objective` / `constraints` / `assumptions` -- `constraints` 至少 1 条;`assumptions` 可为 `[]` +- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造 +- `assumptions` 可以为空数组 **输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。** @@ -198,50 +218,72 @@ ask_clarification( ## 6. 输出风格 -- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短) -- 提问要**具体**(❓),汇总要**结构化**(✅ 📋) -- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总 +- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]` +- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时) --- ## 7. 禁止事项 +- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型 +- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化) - ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用 - ❌ 输出具体实施方案、架构图、代码 - ❌ 在已输出 `[INTENT_READY]` 之后继续提问 - ❌ 用「还有什么要补充的吗」代替 `ask_clarification` +- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致) +- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点 --- ## 8. 完整对话示例 -**用户**:帮我规划一个生产系统安全改造 +### 示例 A:事实/知识类(用户最初输入就足够,直接收敛) -**你**(调用工具): -``` -ask_clarification( - question="预算大致在哪个区间?工期有没有硬性上限?", - clarification_type="missing_info", - context="这两项是后续圆桌排期的基础", - options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"] -) +**用户**:赖清德的儿子是谁 + +**你**(不调用工具,直接收敛): +✅ 我理解你想了解赖清德儿子的相关公开信息。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "了解赖清德儿子的姓名、身份等公开信息", + "constraints": [], + "assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"] +} ``` -**用户**:我选择:预算 300–800万 / 6–12个月 +--- -**你**(若还需确认合规): -``` -ask_clarification( - question="是否需要满足特定合规标准?", - clarification_type="missing_info", - options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"] -) +### 示例 B:创作类(无明确约束,直接收敛) + +**用户**:帮我写一首关于秋天的诗 + +**你**(直接收敛): +✅ 我理解你想要一首以秋天为主题的诗作。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "创作一首以秋天为主题的诗", + "constraints": [], + "assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"] +} ``` -**用户**:我选择:等保 2.0 +--- -**你**(汇总,不再调用工具): -✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。 +### 示例 C:规划类(有明显约束,但首条已经充分) + +**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造 + +**你**(不追问,直接收敛): +✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。 📋 @@ -249,10 +291,53 @@ ask_clarification( ```json { "objective": "对生产系统进行安全改造,满足等保 2.0 合规要求", - "constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"], + "constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"], "assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"] } ``` + +--- + +### 示例 D:首条真正模糊时才追问 + +**用户**:做点什么 + +**你**(此时才用 ask_clarification): +``` +ask_clarification( + question="你希望我帮你完成什么方向的事?", + clarification_type="ambiguous_requirement", + context="目前的描述太抽象,需要确认基本方向", + options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"] +) +``` + +**用户**:我选择:查询/咨询类(获取某方面的信息) + +**你**(继续追问一个最关键的点): +``` +ask_clarification( + question="你想了解的具体主题是什么?可以一句话描述。", + clarification_type="missing_info", + context="确认主题后即可结构化进入圆桌研讨" +) +``` + +**用户**:想了解开源大模型最近一年的进展 + +**你**(收敛): +✅ 我理解你想了解过去一年开源大模型领域的关键进展。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "梳理过去一年开源大模型领域的关键进展", + "constraints": [], + "assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"] +} +``` """ diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 7990aed8..f53ed6bd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -93,6 +93,109 @@ _INTENT_READY_PATTERN = re.compile( ) +def _normalize_question(text: str) -> str: + """归一化澄清问题文本,用于"和过往是否重复"的判断。 + + LLM 复述时常见模式: + - 完全一样的字符串 + - 仅前后空白、标点、emoji 不同(如多/少一个问号) + - 仅大小写差异(中文场景几乎不见,但兼容英文/拼音) + 所以归一化时统一:小写 + 去除非字母/数字/中文字符 + 折叠空白。 + Levenshtein/语义相似度等模糊匹配先不做,等观察到误判再迭代。 + """ + if not isinstance(text, str): + return "" + lowered = text.strip().lower() + # 只保留中英文字符与数字,去掉 ?。!?,。"":/、() 等标点 + emoji + 空白。 + return re.sub(r"[^0-9a-z一-鿿]+", "", lowered) + + +def _normalize_options(raw: object) -> tuple[str, ...]: + """把 options 拍平成可比较的归一化元组,用于"选项是否和上一轮一致"的判断。""" + if raw is None: + return () + if isinstance(raw, str): + # 偶有模型把 list 序列化成 JSON 字符串发回来。 + try: + raw = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return (_normalize_question(raw),) + if not isinstance(raw, list): + return (_normalize_question(str(raw)),) + out: list[str] = [] + for item in raw: + if isinstance(item, dict): + label = item.get("label") or item.get("title") or item.get("name") or item.get("value") or item.get("id") or "" + out.append(_normalize_question(str(label))) + else: + out.append(_normalize_question(str(item))) + # 顺序无关:LLM 偶尔会调换选项顺序,但语义上还是同一组。 + return tuple(sorted(o for o in out if o)) + + +def _is_repeated_clarification( + messages: list[dict[str, Any]], + *, + new_question: str, + new_options: object, + new_tool_call_id: str | None = None, +) -> bool: + """检测本轮的 ask_clarification 是否在过往轮次问过。 + + 判定为重复必须同时满足: + 1. 历史里至少有一次 ``ask_clarification`` 的问题文本 / 选项 与本次归一化相同; + 2. 用户在那次之后**已经发过消息**(即"已回答过")。 + 没有这条限制会把"模型在同一 run 内多次输出同一 tool_call_chunks" + 误判成重复,导致首次澄清都吞掉。 + 3. 不是同一个 tool_call(LangGraph 偶有把同一 call 拆成多块返回的情况)。 + + 传回 True → 上层应把 status 从 ``clarification`` 降级为 ``asking``, + 避免前端弹同一张卡片(配合 SOUL §2.5 prompt 双层防御)。 + """ + norm_new_q = _normalize_question(new_question) + norm_new_opts = _normalize_options(new_options) + if not norm_new_q and not norm_new_opts: + return False + + seen_match_index: int | None = None + for idx, msg in enumerate(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"): + continue + for tc in msg.get("tool_calls") or []: + if (tc.get("name") or "").lower() != "ask_clarification": + continue + if new_tool_call_id and tc.get("id") == new_tool_call_id: + # 同一个 tool_call 的不同 chunk,不算重复。 + continue + args = tc.get("args") or {} + past_q = _normalize_question(str(args.get("question") or "")) + past_opts = _normalize_options(args.get("options")) + # 问题文本和选项任一明确重叠就视为重复: + # - question 相同(options 可能空) + # - options 相同且非空(question 可能被改写) + q_match = bool(past_q) and past_q == norm_new_q + opt_match = bool(past_opts) and past_opts == norm_new_opts + if q_match or opt_match: + seen_match_index = idx + break + if seen_match_index is not None: + break + + if seen_match_index is None: + return False + + # 校验用户在该次澄清之后是否已经发过 human 消息(= 回答过)。 + for msg in messages[seen_match_index + 1:]: + if not isinstance(msg, dict): + continue + msg_type = (msg.get("type") or "").lower() + if msg_type in ("human", "humanmessage", "user"): + return True + return False + + @router.post("/init") async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: """创建任务理解会话用的空 thread。 @@ -264,6 +367,34 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin # 比 tool_call 旁的短 preamble 更适合作为气泡正文。 follow_up = _find_trailing_ai_text(messages, exclude_text=content) display_content = follow_up or content or question + + # 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt + # 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全 + # 重复时,把 status 从 clarification 降级为 asking,前端只渲染 + # 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。 + if _is_repeated_clarification( + messages, + new_question=question, + new_options=cargs.get("options"), + new_tool_call_id=clarification_call.get("id"), + ): + logger.warning( + "intent stream: dropping duplicate ask_clarification " + "(question=%r) — already asked & answered earlier in thread", + question[:80], + ) + fallback_msg = ( + display_content + or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)" + ) + final_frame: dict[str, Any] = { + "status": "asking", + "content": fallback_msg, + "summary": None, + } + yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n" + return + final_frame: dict[str, Any] = { "status": "clarification", "content": display_content, diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 219b4b9e..0ba125df 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI model: deepseek-chat - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-1520028668cb4cc388ed8315ff1e856e base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 @@ -58,12 +58,12 @@ models: temperature: 1.0 supports_vision: true supports_thinking: true - - name: minimax-m2.5 - display_name: MiniMax M2.5 + - name: glm-5 + display_name: glm-5 use: langchain_openai:ChatOpenAI - model: MiniMax-M2.5 - api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk - base_url: https://api.minimaxi.com/v1 + model: glm-5 + api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR + base_url: https://open.bigmodel.cn/api/paas/v4 max_tokens: 4096 temperature: 1.0 supports_vision: true