aps-agent/server/agent_core/assistant.py

323 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 = """你是「工业智核」智能助手,对话能力对标 Kimi / 豆包:用户什么都会问,你都尽量答。
身份:日常问答 + 工厂排产专家。
铁律:
1. **先看【对话上文】**,理解用户是在接上一句(比如「是的」「那个」),再回答。
2. **先正面回答这句话**。问几点报时间;问文件夹就列文件;问排产就讲排产。
3. 禁止说:「跟排产无关帮不上」「换个排产问题」「没配大模型像 Kimi 才行」。
4. 说人话、短句;少用黑话,必须用时立刻白话解释。
5. 不要每轮粘贴功能清单。用 Markdown 排版。
6. 排产数字只能来自【项目上下文】;文件夹内容来自【文件夹清单】。
7. 有【当前时间】时,问几点必须用它。
"""
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"
"- **谁来做**:哪些设备/人能干这些活、哪天有空\n\n"
"你要是想亲手试一版,跟我说一声「带我排一版」就行。"
)
def _explain_can_schedule() -> str:
return (
"**会,这就是我的本职工作。**\n\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"
"可以说「现在几点了」「解析项目文件夹」「帮我分析项目数据」「带我排一版」。",
False,
)
math = _simple_math(t)
if math:
return f"算下来是 {math}。", False
if re.search(r"讲个笑话|来个笑话|说个笑话", t):
return (
"好,来一个车间版冷笑话:\n\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"
"若要**直接开排**,说「给我排产」;\n"
"若要看齐不齐,说「帮我看看现在能不能排」;\n"
"若要看文件夹,说「解析项目文件夹」。",
False,
)
# 开放问题:结合上文语气,不再甩「没配 Kimi」
return (
f"关于「{t[:48]}」,我先按你的话理解一下。\n\n"
"你要是想看**项目文件夹里有啥文件**,直接说「解析项目文件夹」;\n"
"想看**排产数据齐不齐**,说「帮我分析一下当前项目里面的数据」;\n"
"想**开排**,说「带我排一版」。\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)