# ============================================================ # 通用排产助理(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)