aps-agent/server/agent_core/dialog.py

430 lines
21 KiB
Python
Raw Normal View History

# ============================================================
# 多轮对话状态机(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)