aps-agent/server/agent_core/assistant.py

317 lines
13 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-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)