aps-agent/server/agent_core/assistant.py

317 lines
13 KiB
Python
Raw Normal View History

# ============================================================
# 通用排产助理(moduleId: core-assistant, 可重生 ✅)
# 像 Kimi:什么都能聊;带多轮上下文;排产是强项。
# ============================================================
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
from server.agent_core.context import (
format_history_for_llm,
normalize_history,
resolve_followup,
wants_folder_listing,
)
from server.agent_core.providers import get_provider
from server.aps_domain.readiness import analyze_project_data, check_readiness
from server.contracts import AgentReply, UIBlock
World = dict[str, Any]
HistoryItem = dict[str, str]
_WEEKDAYS = "一二三四五六日"
_SYSTEM = """你是「工业智核」企业级排产决策助手,服务对象是计划员、生产经理和工厂管理者。
输出要求:
1. 先给结论,再给关键数据、阻断项/风险和下一步操作。
2. 结合【对话上文】理解省略表达,但不要重复用户原话或暴露内部意图名。
3. 排产数字只能来自【项目上下文】;文件夹内容只能来自【文件夹清单】。
4. 所有数量、时长和日期都带单位或明确字段名;无法确认时直接说明“数据不足”。
5. 使用专业、克制、可执行的中文。避免“啥、咱们、哟、随便、我就上手”等口语,不使用笑话、营销话术或 emoji。
6. 不要每轮粘贴功能清单;只给当前目标最相关的一个下一步。
7. 有【当前时间】时,问几点必须使用该时间。
8. 只回答当前问题,不编造项目事实;没有依据时明确说明需要补充什么数据。
"""
def _now_text() -> str:
n = datetime.now()
wd = _WEEKDAYS[n.weekday()]
return n.strftime(f"%Y年%m月%d日 星期{wd} %H:%M")
def _world_brief(world: World) -> str:
active = [o for o in (world.get("flexOrders") or [])
if (o.get("status") or "") not in ("DONE", "CANCELLED")]
mats = world.get("flexMaterials") or []
routes = world.get("flexRoutings") or []
equip = [e for e in (world.get("flexEquipment") or []) if e.get("status") == "RUNNING"]
cal = world.get("flexCalendar") or []
report = check_readiness(world)
s = report["summary"]
lines = [
f"待排订单 {len(active)} 张,物料 {len(mats)} 种,工艺步骤 {len(routes)} 条,"
f"能干活的设备 {len(equip)} 台,班次安排 {len(cal)} 条。",
f"其中能直接开排约 {s.get('ready')} 张,卡住 {s.get('blocked')} 张,"
f"工时未填 {s.get('timePending')} 步。",
]
for o in active[:8]:
lines.append(
f"- 订单 {o.get('orderNo')}:产品 {o.get('productCode')},"
f"数量 {o.get('quantity')},交期 {o.get('dueDate') or o.get('deliveryDate') or '未填'}"
)
return "\n".join(lines)
def _knowledge_snippets(query: str, top_k: int = 3) -> str:
try:
from server.knowledge import get_knowledge
from server.knowledge.retrieval import hybrid_search
units = get_knowledge().iter_search_units()
hits = hybrid_search(units, query, top_k=top_k, min_score=0.04)
except Exception:
return ""
if not hits:
return ""
parts = []
for h in hits:
title = h.get("title") or h.get("assetId") or "资料"
body = (h.get("content") or "")[:400]
parts.append(f"《{title}》\n{body}")
return "\n\n".join(parts)
def _is_greeting(t: str) -> bool:
return bool(re.search(r"^(你好|您好|嗨|hi|hello|在吗)[\s!!.。??]*$", t, re.I))
def _is_time_query(t: str) -> bool:
return bool(re.search(
r"(现在|当前)?(几点|什么时候了|几时了)|现在几点|几点了|今日日期|今天(几号|日期|星期几)|现在是几号",
t,
))
def _is_definition(t: str) -> bool:
return bool(re.search(
r"(什么叫|什么是|啥叫|啥是|解释(一下|下)?|怎么理解|啥意思|定义).{0,12}排产"
r"|排产.{0,6}(什么意思|是什么|啥意思|怎么理解)",
t,
))
def _wants_inventory(t: str) -> bool:
return bool(re.search(
r"分析|盘点|现状|手头|缺什么|缺啥|齐不齐|能不能开排|现在(能不能|可以)排|"
r"看看(现在|项目|数据|订单)|有多少(单|订单|料)|数据内容|项目里面的数据",
t,
)) or "【文件解析】" in t
def _simple_math(t: str) -> str | None:
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([+\-*/×÷])\s*(\d+(?:\.\d+)?)\s*[=??]?\s*", t)
if not m:
return None
a, op, b = float(m.group(1)), m.group(2), float(m.group(3))
try:
if op == "+":
r = a + b
elif op == "-":
r = a - b
elif op in ("*", "×"):
r = a * b
elif op in ("/", "÷"):
if b == 0:
return "除数不能为 0。"
r = a / b
else:
return None
except Exception:
return None
if abs(r - round(r)) < 1e-9:
return f"**{int(round(r))}**"
return f"**{r:.4g}**"
def _explain_scheduling() -> str:
return (
"## 什么是排产?\n\n"
"排产是将订单需求转换为可执行的生产计划,明确产品、数量、工艺路线、"
"设备资源、开工时间和完工时间,并在交期、产能和物料约束之间做出安排。\n\n"
"**一份可执行的排产计划至少需要:**\n"
"- **需求**:订单、产品、数量和交期\n"
"- **工艺**:工艺路线、标准工时和前后置关系\n"
"- **资源**:设备能力、班次日历、模具和物料齐套状态\n\n"
"如果你提供当前项目数据,我可以先检查齐备度,再生成试排方案。"
)
def _explain_can_schedule() -> str:
return (
"**可以。**\n\n"
"我可以检查订单、工艺、设备、班次和物料数据,识别排产阻断项,"
"并按交期、产能或瓶颈策略生成试排方案。\n\n"
"建议先说「分析当前项目数据」,确认数据齐备度后再生成方案。"
)
def _answer_time() -> str:
return f"现在是 **{_now_text()}**(按你这台电脑的本地时间)。"
def _readiness_block(world: World) -> UIBlock:
report = check_readiness(world)
return UIBlock(
blockId="assistant-readiness", type="readiness",
props={"summary": report["summary"], "orders": report["orders"][:12],
"globalIssues": report["globalIssues"]},
)
def _offline_reply(
world: World, query: str, *, session_id: str | None = None,
) -> tuple[str, bool]:
t = query.strip()
if _is_greeting(t):
return (
"您好,我是工业智核排产助手。\n\n"
"我可以协助分析订单与生产数据、检查排产条件、生成试排方案并解释冲突。\n\n"
"请直接描述目标,例如「分析当前项目数据」或「生成交期优先试排方案」。",
False,
)
if _is_time_query(t):
return _answer_time(), False
if wants_folder_listing(t):
from server.aps_domain.folder_pack import analyze_work_dir
report = analyze_work_dir(world, session_id)
return report.get("markdown") or report.get("error") or "目录解析完成。", False
if _is_definition(t):
return _explain_scheduling(), False
if re.search(r"你会排产|会不会排|能不能排产|你会排吗", t):
return _explain_can_schedule(), False
if re.search(r"你是谁|你能做什么|怎么用|如何使用|介绍一下|你会什么", t):
return (
"我是工业智核的排产决策助手。\n\n"
"主要能力包括:分析项目数据、检查排产齐备度、生成和比较试排方案、"
"解释交期风险与产能瓶颈。\n\n"
"建议从「分析当前项目数据」开始。",
False,
)
math = _simple_math(t)
if math:
return f"算下来是 {math}。", False
if re.search(r"讲个笑话|来个笑话|说个笑话", t):
return (
"我目前专注于生产计划、排产数据和交期分析。\n\n"
"如果你有具体订单或现场问题,请直接描述,我会给出可执行的分析结果。",
False,
)
if re.search(r"天气|下雨|降温|热不热", t):
return (
"当前未接入实时天气数据,无法提供可靠天气判断。\n\n"
"如果你担心天气影响交期,请提供订单号,我可以检查对应计划和缓冲时间。",
False,
)
if _wants_inventory(t):
return analyze_project_data(world, t), True
# 祈使开排不应落到助理菜单(意图层应已拦截;此处再兜底一句口令)
if re.search(
r"给我排产|我要你排产|你给我排|帮我排产|开始排产|直接排|马上排|赶紧排|排产吧",
t,
):
return (
"我理解你的目标是生成排产方案。\n\n"
"请确认使用当前项目数据生成一版柔性试排方案;系统会先校验订单、工艺、"
"设备和班次条件,缺失数据会在结果中明确列出。",
False,
)
if re.search(r"排产|订单|交期|设备|工艺|物料|工时", t) and re.search(
r"什么|怎么|如何|吗|呢|?|\?", t,
):
return (
f"我理解你关注的是「{t[:40]}」。\n\n"
"请明确需要执行的操作:生成试排方案、检查数据齐备度,或查看项目文件。",
False,
)
# 开放问题:结合上文语气,不再甩「没配 Kimi」
return (
f"我暂未识别到「{t[:48]}」对应的明确业务目标。\n\n"
"请补充以下任一信息:订单号或产品、期望交期、需要检查的数据,或希望采用的排产策略。",
False,
)
async def reply(
world: World,
query: str,
*,
history: list[Any] | None = None,
session_id: str | None = None,
) -> AgentReply:
"""带多轮上下文的通用作答。"""
hist = normalize_history(history)
# 历史不含本轮用户句时,resolve 用 hist;前端传来的 history 通常不含当前句
follow = resolve_followup(query, hist)
if follow.get("kind") == "deny" and follow.get("reply"):
return AgentReply(text=str(follow["reply"]))
q = str(follow.get("text") or query or "").strip() or "你好"
note = str(follow.get("note") or "")
# 短确认被改写成「直接排一版 / 带我排一版」等 → 交给工作流口令识别更稳
# 此处若已是明确排产口令,仍可由 assistant 引导;gateway 会在 resolve 后重新 recognize。
# 助理层只负责问答类。
if _is_time_query(q) or _is_greeting(q) or _is_definition(q) \
or re.search(r"你会排产|会不会排|能不能排产|你会排吗", q) \
or wants_folder_listing(q):
text, with_card = _offline_reply(world, q, session_id=session_id)
if note and follow.get("kind") == "affirm":
text = f"已根据上一轮确认,继续执行:{q}\n\n" + text
return AgentReply(text=text, blocks=[_readiness_block(world)] if with_card else [])
provider = get_provider()
if provider.enabled:
brief = _world_brief(world)
knowledge = _knowledge_snippets(q)
hist_blob = format_history_for_llm(hist)
folder_hint = ""
if wants_folder_listing(q):
from server.aps_domain.folder_pack import analyze_work_dir
folder_hint = analyze_work_dir(world, session_id).get("markdown") or ""
user = (
f"【当前时间】{_now_text()}\n\n"
+ (f"【对话上文】\n{hist_blob}\n\n" if hist_blob else "")
+ (f"【上下文备注】{note}\n\n" if note else "")
+ f"【项目上下文】\n{brief}\n\n"
+ (f"【文件夹清单】\n{folder_hint}\n\n" if folder_hint else "")
+ (f"【厂内资料摘录】\n{knowledge}\n\n" if knowledge else "")
+ f"【用户本轮】\n{q}\n\n"
"请结合上文正面回答。用户说「是的」时,按上一轮建议执行或确认,不要反问「你问的是是的」。"
)
text = await provider.chat_text(_SYSTEM, user, timeout=45.0)
if text and text.strip():
blocks = [_readiness_block(world)] if _wants_inventory(q) else []
return AgentReply(text=text.strip(), blocks=blocks)
text, with_card = _offline_reply(world, q, session_id=session_id)
if note and follow.get("kind") == "affirm":
text = f"已根据上一轮确认,继续执行:{q}\n\n" + text
blocks = [_readiness_block(world)] if with_card else []
return AgentReply(text=text, blocks=blocks)