# ============================================================ # 多轮对话状态机(moduleId: core-dialog, 可重生 ✅) # M-F:听不懂就追问,缺数据就引导。 # ① 澄清槽位机:意图置信 0.5–0.85 / 拒识 → 出「澄清卡」(候选意图按钮), # 下一句优先按澄清上下文解析(「第一个」「1」或候选关键词直接回填), # 两轮未解则放弃回兜底。 # ② 引导式排产向导(schedule.wizard):readiness → 缺路线推荐行业模板 / # 缺工时逐条追问 / 缺日历给默认 → 齐备后自动试排。 # 会话态仅存内存(进程内),不入 world;对世界的写全部走 P2 确认卡。 # ============================================================ from __future__ import annotations import re from typing import Any from server.contracts import AgentReply, IntentResult, UIBlock # session_id → {"clarify": {...}|None, "wizard": {...}|None} _SESSIONS: dict[str, dict[str, Any]] = {} def _state(session_id: str) -> dict[str, Any]: return _SESSIONS.setdefault(session_id, {"clarify": None, "wizard": None}) def reset_session(session_id: str) -> None: _SESSIONS.pop(session_id, None) # ============================================================ # 领域路由(意图分层的兜底层):先判「用户在聊哪个域」, # 域命中而子意图不明 → 澄清卡列出该域常用动作。 # ============================================================ _DOMAINS: list[dict[str, Any]] = [ {"domain": "排产", "pattern": r"排产|排程|试排|排一版|计划|交期|甘特|开始|怎么办", "candidates": [ ("schedule.wizard", "引导式排产向导(从头带我排)"), ("flex.schedule", "柔性排产(能力池直接排一版)"), ("assistant.reply", "先分析现状再决定"), ("flex.capacity", "看瓶颈产能"), ]}, {"domain": "订单", "pattern": r"订单|插单|急单|销售单|下单", "candidates": [ ("order.pool", "查订单池"), ("rush.evaluate", "评估紧急插单"), ("plan.trace", "追溯订单计划"), ]}, {"domain": "主数据", "pattern": r"主数据|物料|BOM|工艺路线|工时|设备|产线|资源|分析.*(文件|数据|项目)|这个文件|文件解析", "candidates": [ ("data.analyze", "分析项目/文件数据与排产缺口"), ("master.query", "查主数据"), ("readiness.query", "数据齐备度 / 工时维护情况"), ("flex.time.update", "维护工时"), ]}, {"domain": "知识", "pattern": r"知识|规定|SOP|文档|工艺模式|导入.*文档", "candidates": [ ("knowledge.query", "查知识库"), ("knowledge.import", "导入知识文档"), ]}, {"domain": "技能", "pattern": r"skill|算法|外部引擎", "candidates": [ ("skill.list", "查看已接入算法 Skill"), ("skill.health", "Skill 健康检查"), ]}, ] _INTENT_LABELS = {c[0]: c[1] for d in _DOMAINS for c in d["candidates"]} def guess_domain(text: str) -> dict[str, Any] | None: for d in _DOMAINS: if re.search(d["pattern"], text, re.I): return d return None # ============================================================ # ① 澄清槽位机 # ============================================================ def needs_clarification(intent: IntentResult) -> bool: """低置信(0.4–0.85)需要澄清;通用助理与帮助不澄清。""" if intent.intent in ("help", "assistant.reply", "data.analyze", "readiness.query"): return False if intent.intent == "unknown": return True return 0.4 <= intent.confidence < 0.85 def make_clarification(session_id: str, text: str, intent: IntentResult) -> AgentReply | None: """构造澄清卡:候选意图(猜测+确认),保存会话澄清态。""" candidates: list[tuple[str, str]] = [] if intent.intent != "unknown" and intent.intent in _INTENT_LABELS: candidates.append((intent.intent, _INTENT_LABELS[intent.intent])) elif intent.intent != "unknown": candidates.append((intent.intent, intent.intent)) domain = guess_domain(text) if domain: for c in domain["candidates"]: if all(c[0] != x[0] for x in candidates): candidates.append(c) candidates = candidates[:3] st = _state(session_id) prev = st.get("clarify") or {} if not candidates and prev.get("candidates"): # 本句无新线索但澄清上下文还在 → 复用上一轮候选继续追问 candidates = [(c["intent"], c["label"]) for c in prev["candidates"]] if not candidates: return None # 无候选 → 交回原兜底(帮助文案) prev_rounds = prev.get("rounds", 0) if prev_rounds >= 2: # 两轮未解 → 放弃澄清 st["clarify"] = None return None st["clarify"] = { "originalText": text, "candidates": [{"intent": c[0], "label": c[1], "params": intent.params if c[0] == intent.intent else {}} for c in candidates], "rounds": prev_rounds + 1, } lines = [f"请确认需要执行的操作(回复序号即可):"] for i, c in enumerate(candidates, start=1): lines.append(f"{i}. {c[1]}") lines.append("如果以上选项均不符合,请补充订单、产品或目标操作。") block = UIBlock( blockId=f"clarify-{session_id[:8]}", type="clarify", props={"question": "请确认需要执行的操作:", "options": [{"index": i + 1, "intent": c[0], "label": c[1]} for i, c in enumerate(candidates)], "originalText": text}) return AgentReply(text="\n".join(lines), blocks=[block], intent=intent) def _resolve_clarify(session_id: str, text: str) -> IntentResult | None: """澄清上下文中解析回填:序号 / 候选关键词 → 直接产出意图。""" st = _state(session_id) ctx = st.get("clarify") if not ctx: return None t = text.strip() cands = ctx["candidates"] picked = None m = re.match(r"^(?:第?\s*([1-9一二三])\s*个?)$", t) if m: num_map = {"一": 1, "二": 2, "三": 3} idx = num_map.get(m.group(1)) or int(m.group(1)) if 1 <= idx <= len(cands): picked = cands[idx - 1] if picked is None: for c in cands: if c["label"] and (t in c["label"] or c["label"] in t) and len(t) >= 2: picked = c break if t.lower() == c["intent"].lower(): picked = c break if picked: st["clarify"] = None return IntentResult(intent=picked["intent"], params=picked.get("params") or {}, confidence=1.0, source="RULE_FAST") return None # 未回填 → 交回正常识别(rounds 已计) # ============================================================ # ② 引导式排产向导 # ============================================================ _WIZ_CANCEL = r"取消|退出|不排了|算了" _WIZ_GO = r"^(排|开始|好|可以|确认|确认试排|继续|继续排产|go)$" def start_wizard(store, session_id: str, actor: str = "web") -> AgentReply: """入口:readiness 检查 → 全绿直接给「试排」确认,否则进入引导流程。""" st = _state(session_id) st["clarify"] = None st["wizard"] = {"step": "start"} return _wizard_advance(store, session_id, actor) def _wizard_advance(store, session_id: str, actor: str) -> AgentReply: """核心推进:重跑 readiness,按缺口类型给下一步。""" from server.aps_domain.readiness import check_readiness st = _state(session_id) wiz = st["wizard"] or {} world = store.data report = check_readiness(world) s = report["summary"] # 无订单 → 引导录入订单 if s["total"] == 0: st["wizard"] = None return AgentReply(text="当前没有待排订单,无法启动排产向导。\n" "请先导入订单表(Excel 批量)或新建订单;完成后再次执行“开始排产”。") # 缺日历 → 给默认日历选项 if any(gi["type"] == "NO_CALENDAR" for gi in report["globalIssues"]): st["wizard"] = {"step": "fix_calendar"} return AgentReply(text="缺少班次日历,无法计算可用生产时段。\n" "请选择“默认日历”(单班 08:00-17:00,午休 1 小时,周一至周五)," "或提供实际班制(例如“两班制,08:00-23:00,周一至周六”)。", blocks=[_wizard_block("fix_calendar", "缺班次日历", ["默认日历", "自定义班制"])]) # 缺路线 → 推荐行业模板 no_routing = [r for r in report["orders"] if any(i["type"] == "NO_ROUTING" for i in r["issues"])] if no_routing: target = no_routing[0] pc = target["productCode"] pname = next((m.get("name") for m in world.get("flexMaterials") or [] if m.get("code") == pc), pc) from server.knowledge.routing_templates import recommend_templates try: tpls = recommend_templates(f"{pname} {pc}", top_k=3) except Exception: tpls = [] if not tpls: try: from server.knowledge.routing_templates import list_templates tpls = list_templates()[:3] except Exception: tpls = [] st["wizard"] = {"step": "pick_template", "productCode": pc, "productName": pname, "templates": [t["code"] for t in tpls]} lines = [f"订单 {target['orderNo']} 的产品「{pname}」尚未配置工艺路线。" f"以下是根据产品特征匹配的行业模板,请回复序号选择:"] for i, t in enumerate(tpls, start=1): steps_brief = "→".join(x["operationName"] for x in t["steps"][:6]) more = "…" if len(t["steps"]) > 6 else "" total = sum(x["stdMinDefault"] for x in t["steps"]) lines.append(f"{i}. {t['name']}:{steps_brief}{more}(约 {total:.0f} 分/件)") lines.append("如果没有合适模板,请回复“跳过”,稍后在主数据中维护工艺路线。") return AgentReply(text="\n".join(lines), blocks=[_wizard_block("pick_template", f"为 {pname} 选工艺模板", [f"{i}. {t['name']}" for i, t in enumerate(tpls, 1)] + ["跳过"], extra={"templates": tpls, "productCode": pc})]) # 缺工时 → 逐工序追问 pending = [(r["productCode"], i["detail"]) for r in report["orders"] for i in r["issues"] if i["type"] == "TIME_UNMAINTAINED"] if pending: # 提取 (product, op) 队列 queue: list[tuple[str, str]] = [] seen = set() for r in report["orders"]: for i in r["issues"]: if i["type"] != "TIME_UNMAINTAINED": continue m = re.search(r"工序\s*(\S+?)\s*无工时", i["detail"]) key = (r["productCode"], m.group(1) if m else "") if key not in seen and key[1]: seen.add(key) queue.append(key) if queue: pc, op = queue[0] st["wizard"] = {"step": "fill_time", "queue": queue} return AgentReply(text=f"还有 {len(queue)} 道工序缺少标准工时。当前需要补充:\n" f"产品 {pc} 的工序「{op}」单件多少分钟?" f"请回复分钟数(如“45”),或回复“跳过”暂不维护。", blocks=[_wizard_block("fill_time", f"补工时:{pc} × {op}", ["跳过", "取消"])]) # 无能力设备(模板应用时已自动补能力,这里剩真缺口) no_eq = [r for r in report["orders"] if any(i["type"] == "NO_CAPABLE_EQUIPMENT" for i in r["issues"])] if no_eq: st["wizard"] = None details = ";".join(i["detail"] for r in no_eq[:3] for i in r["issues"] if i["type"] == "NO_CAPABLE_EQUIPMENT") return AgentReply(text=f"以下工序尚未匹配到可用设备:{details}。\n" f"请在主数据页补充设备工序能力,完成后再次执行“检查数据齐备度”。") # 全绿(或仅剩警告)→ 试排确认 warn_note = "" if s["withWarnings"]: warn_note = f"({s['withWarnings']} 张订单带推断工时/缺料告警,结果会显式标注)" st["wizard"] = {"step": "confirm_run"} return AgentReply(text=f"数据齐备:{s['total']} 张订单可以生成试排方案{warn_note}。\n" f"回复“确认试排”按瓶颈锚策略生成方案;回复“取消”退出向导。", blocks=[_wizard_block("confirm_run", "数据齐备,可以生成试排方案", ["确认试排", "取消"])]) def _wizard_block(step: str, title: str, options: list[str], extra: dict[str, Any] | None = None) -> UIBlock: return UIBlock(blockId=f"wizard-{step}", type="wizard", props={"step": step, "title": title, "options": options, **(extra or {})}) def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply | IntentResult | None: """向导激活时的每轮处理。返回 AgentReply(继续向导)/ IntentResult(放行执行)/ None(退出向导走正常识别)。""" from server.agent_core import harness from server.agent_core.audit import write_audit st = _state(session_id) wiz = st.get("wizard") or {} step = wiz.get("step") t = text.strip() if re.search(_WIZ_CANCEL, t): st["wizard"] = None return AgentReply(text="已退出排产向导。需要时可重新执行“开始排产”。") if step == "fix_calendar": if re.search(r"默认日历|默认|单班", t): store.data["flexCalendar"] = [{ "shiftCode": "D", "startTime": "08:00", "endTime": "17:00", "breaks": [{"start": "12:00", "end": "13:00"}], "workdays": [1, 2, 3, 4, 5]}] write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE", action="flex.resource.patch", target={"type": "CALENDAR", "id": "default"}, power="P1", rationale={"wizard": True, "calendar": "默认单班"}) store.save() reply = _wizard_advance(store, session_id, actor) reply.text = "已建立默认班次日历(单班 08:00-17:00,周一至周五)。\n\n" + reply.text return reply return AgentReply(text="未识别到有效班制。请选择“默认日历”,或提供班次数量、工作时间和工作日。") if step == "pick_template": if re.search(r"^跳过$", t): st["wizard"] = {"step": "resume"} return AgentReply(text="已跳过该产品的工艺路线。请在主数据中维护后,再执行“继续排产”。") codes = wiz.get("templates") or [] idx = None m = re.match(r"^(?:第?\s*([1-9一二三])\s*个?)", t) if m: num_map = {"一": 1, "二": 2, "三": 3} idx = num_map.get(m.group(1)) or int(m.group(1)) tpl_code = codes[idx - 1] if idx and 1 <= idx <= len(codes) else None if not tpl_code: for c in codes: from server.knowledge.routing_templates import get_template tpl = get_template(c) if tpl and tpl["name"] in t: tpl_code = c break if not tpl_code: return AgentReply(text="请选择模板序号(1/2/3),或回复“跳过”“取消”。") pc = wiz.get("productCode") or "" pname = wiz.get("productName") or pc from server.knowledge.routing_templates import get_template tpl = get_template(tpl_code) title = f"用模板「{tpl['name']}」生成 {pname} 工艺路线" block = harness.stage_confirmation( session_id, "routing.template.apply", {"templateCode": tpl_code, "productCode": pc, "productName": pname}, title=title, summary_lines=[f"模板:{tpl['code']}({len(tpl['steps'])} 步)", "工时取模板区间中值,来源标「模板」,可实测覆盖。", f"知识出处:{tpl.get('assetId') or '内置'}"]) write_audit(store.data, store.next_id, actor=actor, category="GATE", action="routing.template.apply.stage", target={"type": "TEMPLATE", "id": tpl_code}, power="P2", rationale={"confirmId": block.props["confirmId"], "productCode": pc, "wizard": True}) store.save() st["wizard"] = {"step": "resume"} return AgentReply(text=f"{title}需要确认(P2)。确认后执行“继续排产”推进下一步。", blocks=[block]) if step == "fill_time": queue: list = list(wiz.get("queue") or []) if not queue: st["wizard"] = {"step": "resume"} return _wizard_advance(store, session_id, actor) pc, op = queue[0] if re.search(r"^跳过$", t): queue.pop(0) st["wizard"] = {"step": "fill_time", "queue": queue} if queue else {"step": "resume"} if queue: return AgentReply(text=f"已跳过当前工序。下一项:产品 {queue[0][0]} 的工序「{queue[0][1]}」单件多少分钟?") return _wizard_advance(store, session_id, actor) m = re.search(r"(\d+(?:\.\d+)?)", t) if not m: return AgentReply(text=f"请回复标准工时(分钟/件),或回复“跳过”“取消”。当前:{pc} × {op}") std_min = float(m.group(1)) title = f"更新工时:{pc} × {op} → {std_min} 分钟/件" block = harness.stage_confirmation( session_id, "flex.time.update", {"productCode": pc, "operationCode": op, "stdMin": std_min, "source": "实测"}, title=title, summary_lines=[f"单件工时 → {std_min} 分钟(来源=实测)"]) write_audit(store.data, store.next_id, actor=actor, category="GATE", action="flex.time.update.stage", target={"type": "ROUTING_TIME", "id": f"{pc}/{op}"}, power="P2", rationale={"confirmId": block.props["confirmId"], "wizard": True}) store.save() queue.pop(0) st["wizard"] = {"step": "fill_time", "queue": queue} if queue else {"step": "resume"} nxt = (f"\n下一条:产品 {queue[0][0]} 的工序「{queue[0][1]}」单件多少分钟?" if queue else "\n工时都过了一遍。批准确认卡后说「继续排产」。") return AgentReply(text=f"{title}已生成确认卡(P2)。{nxt}", blocks=[block]) if step == "confirm_run": if re.match(_WIZ_GO, t) or re.search(r"^试排|排产$", t): st["wizard"] = None return IntentResult(intent="flex.schedule", params={"sortMode": "BOTTLENECK"}, confidence=1.0, source="RULE_FAST") st["wizard"] = None return None # 用户说了别的 → 退出向导走正常识别 if step == "resume": if re.search(r"继续|接着|下一步|排产", t): return _wizard_advance(store, session_id, actor) st["wizard"] = None return None # 未知向导态 → 重置 st["wizard"] = None return None # ============================================================ # 对话管线钩子(gateway /api/chat 调用) # ============================================================ def pre_route(store, session_id: str, text: str, actor: str = "web") -> AgentReply | IntentResult | None: """识别前钩子:向导 / 澄清上下文优先。""" st = _state(session_id) if st.get("wizard"): out = _wizard_turn(store, session_id, text, actor) if out is not None: return out if st.get("clarify"): resolved = _resolve_clarify(session_id, text) if resolved is not None: return resolved return None def post_route(store, session_id: str, text: str, intent: IntentResult) -> AgentReply | None: """识别后钩子:低置信/拒识 → 猜测+确认澄清卡(替代直接吐帮助全文)。""" if not needs_clarification(intent): _state(session_id)["clarify"] = None # 高置信 → 清澄清态 return None return make_clarification(session_id, text, intent)