317 lines
13 KiB
Python
317 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 = """你是「工业智核」企业级排产决策助手,服务对象是计划员、生产经理和工厂管理者。
|
||
|
||
输出要求:
|
||
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)
|