aps-agent/server/agent_core/dialog.py

432 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 多轮对话状态机(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="当前没有待排订单。先录入订单吧——可以说"
"「导入订单」(Excel 批量)或「新建订单 产品X 数量100 交期…」,"
"录完再说「我要排产」。")
# 缺日历 → 给默认日历选项
if any(gi["type"] == "NO_CALENDAR" for gi in report["globalIssues"]):
st["wizard"] = {"step": "fix_calendar"}
return AgentReply(text="缺班次日历(不知道每天几点开工、一周开几天)。\n"
"回复「默认日历」建单班 08:00-17:00(午休1h)周一~周五;"
"或告诉我你们的班制(如「两班 8点到23点 周一到周六」)。",
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"请在主数据页为设备登记工序能力(capabilities),"
f"或说「设备 XX 增加能力 工序YY」;完成后再说「我要排产」。")
# 全绿(或仅剩警告)→ 试排确认
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"请回复分钟数(如「45」)、「跳过」或「取消」。当前:{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)