323 lines
13 KiB
Python
323 lines
13 KiB
Python
# ============================================================
|
||
# 通用排产助理(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)
|