feat: professionalize assistant responses

This commit is contained in:
Daiwen2023 2026-08-21 07:32:13 +08:00
parent ef7256f12c
commit 887a2317e1
24 changed files with 272 additions and 189 deletions

View File

@ -438,7 +438,7 @@ export default function App() {
// 命令面板条目:口令(发对话)/ 面板(布局开关)/ 视图(视口切换)
const paletteItems: PaletteItem[] = [
...[
'多策略方案对比', '试排一版交期优先', '产能均衡重排', '建一个检查点',
'多策略方案对比', '生成一版交期优先试排方案', '产能均衡重排', '建一个检查点',
'回滚到上一个检查点', '查看冲突', '标出超期订单', '查看当前KPI', '发布当前排产版本',
].map(q => ({
id: `cmd-${q}`, kind: '口令' as const, label: q, hint: '发送到对话',

View File

@ -168,11 +168,11 @@ function GuestWorkspace() {
<div className="chat-log">
<div className="chat-start guest-chat-start">
<span className="chat-start-mark">IB</span>
<h1>今天想先忙哪件事?</h1>
<h1>请选择要处理的任务</h1>
<div className="starter-grid guest-starter-grid">
<button type="button" className="starter-card" onClick={openLogin}>
<span className="starter-icon spark"><ThunderboltOutlined /></span>
<strong>试排一版交期优先</strong><small>试排一版交期优先</small>
<strong>生成交期优先试排方案</strong><small>生成一版交期优先试排方案</small>
</button>
<button type="button" className="starter-card" onClick={openLogin}>
<span className="starter-icon compare"><SwapOutlined /></span>

View File

@ -8,6 +8,10 @@ import { chatStream, fetchGuidance, postConfirm, postImportPreview, postScenario
import { loadMessages, loadMessagesPreferServer, saveMessages } from '../projects/store';
import { ChatMarkdown } from './ChatMarkdown';
const SHOW_INTENT_DEBUG = (import.meta as ImportMeta & {
env: Record<string, string | undefined>;
}).env.VITE_SHOW_INTENT_DEBUG === '1';
interface Msg {
role: 'user' | 'agent';
text: string;
@ -26,12 +30,19 @@ function ThinkingPanel(props: {
}) {
const last = props.steps[props.steps.length - 1];
const pct = last?.pct;
const stepLabel = (step: string) => ({
'连接助手': '连接服务',
'收到指令': '收到请求',
'理解意图': '确认目标',
'开始执行': '执行操作',
'生成回复': '整理结果',
}[step] ?? step);
return (
<div className={`thinking-panel${props.open ? ' open' : ''}${props.streaming ? ' live' : ''}`}>
<button type="button" className="thinking-head" onClick={props.onToggle}>
<span className={`thinking-spinner${props.streaming ? '' : ' done'}`} aria-hidden />
<span className="thinking-title">
{props.streaming ? 'Thinking' : '已完成思考'}
{props.streaming ? '处理中' : '处理完成'}
{typeof pct === 'number' && props.streaming ? ` · ${Math.round(pct)}%` : ''}
</span>
<span className="thinking-caret">{props.open ? '▾' : '▸'}</span>
@ -43,14 +54,14 @@ function ThinkingPanel(props: {
<span className="thinking-mark">
{s.status === 'done' ? '✓' : s.status === 'warn' ? '!' : '·'}
</span>
<span className="thinking-step">{s.step}</span>
<span className="thinking-step">{stepLabel(s.step)}</span>
{!!s.detail && <span className="thinking-detail">{s.detail}</span>}
</li>
))}
</ul>
)}
{!props.open && props.streaming && last && (
<div className="thinking-current">{last.step}{last.detail ? ` · ${last.detail}` : ''}</div>
<div className="thinking-current">{stepLabel(last.step)}{last.detail ? ` · ${last.detail}` : ''}</div>
)}
</div>
);
@ -94,13 +105,13 @@ export interface ChatHandle {
}
const QUICK = [
'跑一版柔性排产', '瓶颈产能', 'PDU 500套什么时候能交',
'生成一版柔性排产方案', '瓶颈产能', 'PDU 500 套交期评估',
'新建订单 客户 示例客户 产品 请替换为真实料号 数量 120 交期 8月20日',
'新建物料 编码 SCR-NEW 名称 不锈钢螺丝 类型 原料 库存 800',
'导入订单\n客户A,成品料号,200,2026-08-01,VIP\n客户 客户B 产品 成品料号 数量 100 交期 8月15日',
'导入物料\nSCR-NEW,不锈钢螺丝,原料,包,800\nPCB-X,试验板,原料,块,200',
'追溯订单依据', '分解全部订单', '主数据录入顺序有什么规定',
'多策略方案对比', '试排一版交期优先', '产能均衡重排',
'多策略方案对比', '生成一版交期优先试排方案', '产能均衡重排',
'换线有什么规定', '生成排产方案报告', '生成日报', '生成版本对比报告',
'建一个检查点', '回滚到上一个检查点',
'只看VIP订单', '标出超期订单', '查看冲突',
@ -238,7 +249,7 @@ const ChatPanel = forwardRef<ChatHandle, {
(sessionRef as { current: string; _hist?: typeof history })._hist = history;
return [...base, { role: 'user', text: t }, {
role: 'agent', text: '', streaming: true, thinkingOpen: true,
thinking: [{ step: '连接助手', detail: '已发送,等待处理…', status: 'running', pct: 0 }],
thinking: [{ step: '连接助手', detail: '请求已发送,等待服务响应…', status: 'running', pct: 0 }],
}];
});
const sid = sessionRef.current;
@ -272,7 +283,7 @@ const ChatPanel = forwardRef<ChatHandle, {
}));
else if (ev.type === 'command') props.onCommand(ev.command);
else if (ev.type === 'block') patchLast(m => ({ ...m, blocks: [...(m.blocks ?? []), ev.block] }));
else if (ev.type === 'error') patchLast(m => ({ ...m, text: m.text + '\n⚠ ' + ev.message }));
else if (ev.type === 'error') patchLast(m => ({ ...m, text: m.text + '\n\n**操作未完成**:' + ev.message }));
else if (ev.type === 'done') {
patchLast(m => ({
...m,
@ -284,7 +295,7 @@ const ChatPanel = forwardRef<ChatHandle, {
}));
props.onWorldRefresh();
}
}, hist).catch(() => patchLast(m => ({ ...m, text: m.text + '\n⚠ 连接失败,请确认后端已启动。', streaming: false })));
}, hist).catch(() => patchLast(m => ({ ...m, text: m.text + '\n\n**连接未完成**:服务暂时不可用,请稍后重试。', streaming: false })));
setBusy(false);
}, [busy, patchLast, props]);
@ -385,7 +396,7 @@ const ChatPanel = forwardRef<ChatHandle, {
};
recognition.onend = () => setListening(false);
recognitionRef.current = recognition;
setVoiceNotice('正在听,请说出排产要求或主数据说明。');
setVoiceNotice('正在监听,请说明排产目标或主数据变更。');
setListening(true);
recognition.start();
}, [autoGrow, listening]);
@ -394,7 +405,7 @@ const ChatPanel = forwardRef<ChatHandle, {
const handleConfirm = useCallback(async (confirmId: string, approve: boolean) => {
const result = await postConfirm(sessionRef.current, confirmId, approve)
.catch(() => ({ message: '确认请求失败', refresh: false }));
.catch(() => ({ message: '确认操作未完成,数据未修改。', refresh: false }));
setMsgs(ms => [...ms, { role: 'agent', text: result.message }]);
if (result.refresh) props.onWorldRefresh();
return result;
@ -402,7 +413,7 @@ const ChatPanel = forwardRef<ChatHandle, {
const handleApply = useCallback(async (card: ScenarioCard) => {
const result = await postScenarioApply(sessionRef.current, card.strategy, card.engine)
.catch(() => ({ message: '采用失败,请确认后端已启动', refresh: false }));
.catch(() => ({ message: '方案未采用,数据未修改。请稍后重试。', refresh: false }));
setMsgs(ms => [...ms, { role: 'agent', text: result.message || `已采用【${card.label}】` }]);
if (result.refresh) props.onWorldRefresh();
}, [props]);
@ -498,7 +509,7 @@ const ChatPanel = forwardRef<ChatHandle, {
? <ChatMarkdown text={m.text} streaming={!!m.streaming} />
: <span className="bubble-loading">正在处理…</span>)
: <>{m.text}{m.streaming ? '▍' : ''}</>}
{m.intent && !m.streaming && (
{SHOW_INTENT_DEBUG && m.intent && !m.streaming && (
<details className="intent-debug">
<summary>调试信息</summary>
<pre>{JSON.stringify(m.intent, null, 2)}</pre>
@ -640,7 +651,7 @@ const ChatPanel = forwardRef<ChatHandle, {
void sendDraft();
}
}}
placeholder="输入排产指令…(Enter 发送 · Shift+Enter 换行)"
placeholder="描述订单、交期或排产目标…(Enter 发送 · Shift+Enter 换行)"
/>
<button
type="button"
@ -669,10 +680,10 @@ type StarterIconName = 'spark' | 'compare' | 'risk' | 'report';
function ChatStart(props: { onSend: (text: string) => void }) {
const defaults: Array<{ icon: StarterIconName; title: string; text: string }> = [
{ icon: 'spark', title: '帮我排一版', text: '带我排一版' },
{ icon: 'compare', title: '对比几种排法', text: '多策略方案对比' },
{ icon: 'risk', title: '看看哪卡住了', text: '查看冲突并标出超期订单' },
{ icon: 'report', title: '出一份工作表', text: '生成排产方案报告' },
{ icon: 'spark', title: '生成试排方案', text: '生成一版交期优先试排方案' },
{ icon: 'compare', title: '对比排产策略', text: '多策略方案对比' },
{ icon: 'risk', title: '查看冲突与超期', text: '查看冲突并标出超期订单' },
{ icon: 'report', title: '生成排产报告', text: '生成排产方案报告' },
];
const [starters, setStarters] = useState(defaults);
const [hint, setHint] = useState<string | null>(null);
@ -698,10 +709,10 @@ function ChatStart(props: { onSend: (text: string) => void }) {
return (
<div className="chat-start">
<div className="chat-start-mark">IB</div>
<h1>今天想先忙哪件事?</h1>
<h1>请选择要处理的任务</h1>
{hint
? <p className="chat-start-hint">{hint}</p>
: <p className="chat-start-hint">随便说就行,打错字、说方言都可以。不会专业词也没关系,我慢慢带着你。</p>}
: <p className="chat-start-hint">可以描述订单、交期、设备约束或需要生成的排产报告。</p>}
<div className="starter-grid">
{starters.map(item => (
<button key={item.title + item.text} type="button" className="starter-card" onClick={() => props.onSend(item.text)}>

View File

@ -1405,7 +1405,7 @@ function TracePanel(props: {
pagination={false}
title={() => `③ 排产产物 · 工单 ${s.workOrders.length}${s.productionOrders.length > 0
? ` · PO:${s.productionOrders.map(p => `${p.orderNo}(${p.lineName || '未分线'})`).join('、')}` : ''}`}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="尚无工单(请先「试排一版」)" /> }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="尚无工单,请先生成试排方案。" /> }}
style={{ marginBottom: 12 }}
/>

View File

@ -401,7 +401,7 @@ export default function TimelineRail(props: {
{!flexWo.length && !versions.length && !cpGroups.length ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有排产结构。对话说「试排一版」或打开柔性工作台。"
description="当前没有排产结构。请生成试排方案,或打开柔性排产工作台。"
style={{ marginTop: 48 }}
/>
) : (
@ -475,4 +475,4 @@ function fmtRange(start: string, end: string): string {
const s = start?.slice(5, 16) ?? '';
const e = end?.slice(11, 16) ?? '';
return e ? `${s} → ${e}` : s;
}
}

View File

@ -79,7 +79,7 @@ export default function FlexGanttView(props: {
useEffect(() => { dragRef.current = drag; }, [drag]);
if (!data || data.workOrders.length === 0)
return <div className="empty" ref={wrapRef}>当前暂无柔性排产工单<br />请先跑一版柔性排产,例如:跑一版柔性排产</div>;
return <div className="empty" ref={wrapRef}>当前暂无柔性排产工单<br />请先执行“生成一版柔性排产方案”</div>;
if (viewMode === 'replay') {
return (

View File

@ -73,7 +73,7 @@ export default function GanttView(props: {
return (
<div className="empty vp-empty-guide" ref={wrapRef}>
<strong>尚无排产版本</strong>
<span>在对话中说「试排一版交期优先」,或点欢迎页建议卡片。</span>
<span>在对话中执行“生成一版交期优先试排方案”,或选择欢迎页建议卡片。</span>
<span className="faint">也可说「下一步建议」获取当前状态推荐动作。</span>
</div>
);

View File

@ -20,7 +20,7 @@ function fcTone(fc?: FlexCapacityForecast): string {
export default function PoolCapacityView(props: { data: FlexCapacityData | null }) {
const d = props.data;
if (!d?.pools?.length) {
return <div className="proj-empty">暂无产能池数据。先导入柔性设备/工序或跑一版柔性排产。</div>;
return <div className="proj-empty">暂无产能池数据。请先导入柔性设备与工序,或生成柔性排产方案。</div>;
}
const bn = d.bottleneckPool;
const alerts = d.alerts ?? [];

View File

@ -1748,3 +1748,14 @@
- **类型**:chore
- **做了什么**:`poc/workbench.html` 静态左说右动验证
## 2026-08-21 — 企业级排产回复文案与状态提示
- **类型**:fix + ux + tests + docs
- **做了什么**:统一助手系统提示词与离线回复为“结论 → 依据 → 阻断项/风险 → 下一步”;重写数据齐备度、排产向导、目录分析、目录导入和报告空态文案;欢迎页、快捷指令、空态和错误提示改为业务化表达。
- **界面行为**:`Thinking`、内部 intent 名称和开发环境错误细节默认不再展示;可通过 `VITE_SHOW_INTENT_DEBUG=1` 显式打开调试块。
- **涉及路径**:`server/agent_core/assistant.py`、`server/agent_core/dialog.py`、`server/agent_core/context.py`、`server/gateway/app.py`、`server/aps_domain/{readiness,guidance,folder_pack,reports,workflow}.py`、`apps/web/src/{chat/ChatPanel.tsx,App.tsx,auth/AuthGate.tsx,viewport,timeline,orders}`。
- **验证**:核心回复/向导/齐备度/引导测试 `37 passed`;全量黄金测试 `1156 passed, 20 skipped`,另有 7 项依赖外部 MES 网络或本机 MOM 文件的环境失败;`npm run build:web` 通过;`server` compileall 通过;本地 `8000/5173` 健康检查通过。
- **文档同步**:新增 `docs/development/response-style.md`。
- **遗留**:workflow 及集成模块中仍有少量历史成功回执使用勾选符号,后续可按规范继续收敛;未 commit/push/merge/publish。
---

View File

@ -73,7 +73,8 @@
2. [Harness 门禁](./architecture/harness.md)
3. [可重生模块](./architecture/modules.md)
4. [Plan 可重生运行时](./architecture/plan-runtime.md)
5. 蓝图全文:[plan.md](../plan.md)
5. [对话回复规范](./development/response-style.md)
6. 蓝图全文:[plan.md](../plan.md)
### 算法

View File

@ -0,0 +1,30 @@
# 对话回复规范
## 定位
工业智核是面向计划员、生产经理和工厂管理者的排产决策助手。回复应帮助用户确认事实、理解风险并完成下一步操作,不以闲聊或娱乐为目标。
## 回复结构
业务回复优先采用以下顺序:
1. **结论**:直接回答当前问题,说明是否可以继续。
2. **依据**:列出关键数量、时间、资源和数据来源;数量必须带单位。
3. **阻断项/风险**:明确缺失数据、冲突和不确定性。
4. **下一步**:只给当前目标最相关的操作建议。
## 用词
- 使用“待排订单、可用设备、标准工时、数据齐备度、阻断项、试排方案”等业务术语。
- 避免“啥、咱们、哟、随便、我就上手、大白话、手头有啥”等口语表达。
- 不向用户暴露内部 intent 名称、模块名、异常堆栈或模型供应商名称。
- 不使用笑话、营销话术或 emoji 作为业务回复内容。
- 用户可以使用口语输入,但系统输出应保持专业、克制和可执行。
## 不确定性
没有可靠数据时必须明确说明“数据不足”或“当前无法确认”,同时指出需要补充的字段。不得用“应该、差不多、看起来可以”等模糊结论代替数据校验。
## 状态提示
处理中状态使用“正在确认目标”“正在分析项目数据”“正在生成排产方案”“正在整理结果”等业务描述。生产环境默认不显示内部调试信息;仅设置 `VITE_SHOW_INTENT_DEBUG=1` 时允许显示 intent 调试块。

View File

@ -23,18 +23,17 @@ HistoryItem = dict[str, str]
_WEEKDAYS = "一二三四五六日"
_SYSTEM = """你是「工业智核」智能助手,对话能力对标 Kimi / 豆包:用户什么都会问,你都尽量答。
_SYSTEM = """你是「工业智核」企业级排产决策助手,服务对象是计划员、生产经理和工厂管理者。
身份:日常问答 + 工厂排产专家。
铁律:
1. **先看【对话上文】**,理解用户是在接上一句(比如「是的」「那个」),再回答。
2. **先正面回答这句话**。问几点报时间;问文件夹就列文件;问排产就讲排产。
3. 禁止说:「跟排产无关帮不上」「换个排产问题」「没配大模型像 Kimi 才行」。
4. 说人话、短句;少用黑话,必须用时立刻白话解释。
5. 不要每轮粘贴功能清单。用 Markdown 排版。
6. 排产数字只能来自【项目上下文】;文件夹内容来自【文件夹清单】。
7. 有【当前时间】时,问几点必须用它。
输出要求:
1. 先给结论,再给关键数据、阻断项/风险和下一步操作。
2. 结合【对话上文】理解省略表达,但不要重复用户原话或暴露内部意图名。
3. 排产数字只能来自【项目上下文】;文件夹内容只能来自【文件夹清单】。
4. 所有数量、时长和日期都带单位或明确字段名;无法确认时直接说明“数据不足”。
5. 使用专业、克制、可执行的中文。避免“啥、咱们、哟、随便、我就上手”等口语,不使用笑话、营销话术或 emoji。
6. 不要每轮粘贴功能清单;只给当前目标最相关的一个下一步。
7. 有【当前时间】时,问几点必须使用该时间。
8. 只回答当前问题,不编造项目事实;没有依据时明确说明需要补充什么数据。
"""
@ -126,7 +125,7 @@ def _simple_math(t: str) -> str | None:
r = a * b
elif op in ("/", "÷"):
if b == 0:
return "除数不能是 0 哟。"
return "除数不能为 0。"
r = a / b
else:
return None
@ -139,24 +138,23 @@ def _simple_math(t: str) -> str | None:
def _explain_scheduling() -> str:
return (
"**排产是啥?**\n\n"
"用大白话讲:工厂里有很多订单要做,每张单要经过几道工序、用哪台设备、"
"什么时候开工完工——**把这些事提前安排清楚,就叫排产**。\n\n"
"好比家里做饭:先想清楚先炒哪个菜、用哪口锅、几点能上桌。\n\n"
"**排产一般要弄清三件事:**\n"
"- **做什么**:哪些订单、做什么产品、做多少、啥时候要\n"
"- **怎么做**:几道工序、每道大概多久\n"
"- **谁来做**:哪些设备/人能干这些活、哪天有空\n\n"
"你要是想亲手试一版,跟我说一声「带我排一版」就行。"
"## 什么是排产?\n\n"
"排产是将订单需求转换为可执行的生产计划,明确产品、数量、工艺路线、"
"设备资源、开工时间和完工时间,并在交期、产能和物料约束之间做出安排。\n\n"
"**一份可执行的排产计划至少需要:**\n"
"- **需求**:订单、产品、数量和交期\n"
"- **工艺**:工艺路线、标准工时和前后置关系\n"
"- **资源**:设备能力、班次日历、模具和物料齐套状态\n\n"
"如果你提供当前项目数据,我可以先检查齐备度,再生成试排方案。"
)
def _explain_can_schedule() -> str:
return (
"**会,这就是我的本职工作。**\n\n"
"你把订单和现场情况告诉我,我可以帮你看缺不缺、排出一版计划。\n"
"日常问题(几点了、算个数、闲聊)也都能聊。\n\n"
"想现在就试?直接说「带我排一版」或「直接排一版」。"
"**可以。**\n\n"
"我可以检查订单、工艺、设备、班次和物料数据,识别排产阻断项,"
"并按交期、产能或瓶颈策略生成试排方案。\n\n"
"建议先说「分析当前项目数据」,确认数据齐备度后再生成方案。"
)
@ -180,9 +178,9 @@ def _offline_reply(
if _is_greeting(t):
return (
"你好,我在。\n\n"
"啥都能问:几点了、算个数、看项目文件夹、排产排程……怎么说都行。\n\n"
"想排产的话,说「带我排一版」我就上手。",
"您好,我是工业智核排产助手。\n\n"
"我可以协助分析订单与生产数据、检查排产条件、生成试排方案并解释冲突。\n\n"
"请直接描述目标,例如「分析当前项目数据」或「生成交期优先试排方案」。",
False,
)
@ -202,8 +200,10 @@ def _offline_reply(
if re.search(r"你是谁|你能做什么|怎么用|如何使用|介绍一下|你会什么", t):
return (
"我是工业智核里的助手:**日常问题也能聊**,排产是我最拿手的。\n\n"
"可以说「现在几点了」「解析项目文件夹」「帮我分析项目数据」「带我排一版」。",
"我是工业智核的排产决策助手。\n\n"
"主要能力包括:分析项目数据、检查排产齐备度、生成和比较试排方案、"
"解释交期风险与产能瓶颈。\n\n"
"建议从「分析当前项目数据」开始。",
False,
)
@ -213,16 +213,15 @@ def _offline_reply(
if re.search(r"讲个笑话|来个笑话|说个笑话", t):
return (
"好,来一个车间版冷笑话:\n\n"
"计划员问设备:「你今天忙不忙?」\n"
"设备说:「不忙,我排期排到下下下个月了,早就躺平了。」",
"我目前专注于生产计划、排产数据和交期分析。\n\n"
"如果你有具体订单或现场问题,请直接描述,我会给出可执行的分析结果。",
False,
)
if re.search(r"天气|下雨|降温|热不热", t):
return (
"我这边看不到窗外实时天气,手机天气最准。\n\n"
"要是担心交期被耽误,把单号跟我说,我帮你看计划。",
"当前未接入实时天气数据,无法提供可靠天气判断。\n\n"
"如果你担心天气影响交期,请提供订单号,我可以检查对应计划和缓冲时间。",
False,
)
@ -235,29 +234,24 @@ def _offline_reply(
t,
):
return (
"明白,你是要**直接开排**。\n\n"
"请再说一次「给我排产」或点快捷指令「跑一版柔性排产」——"
"我会按工程目录/当前数据开排,不再让你选菜单。",
"我理解你的目标是生成排产方案。\n\n"
"请确认使用当前项目数据生成一版柔性试排方案;系统会先校验订单、工艺、"
"设备和班次条件,缺失数据会在结果中明确列出。",
False,
)
if re.search(r"排产|订单|交期|设备|工艺|物料|工时", t) and re.search(
r"什么|怎么|如何|吗|呢|?|\?", t,
):
return (
f"你问的是「{t[:40]}」。\n\n"
"若要**直接开排**,说「给我排产」;\n"
"若要看齐不齐,说「帮我看看现在能不能排」;\n"
"若要看文件夹,说「解析项目文件夹」。",
f"我理解你关注的是「{t[:40]}」。\n\n"
"请明确需要执行的操作:生成试排方案、检查数据齐备度,或查看项目文件。",
False,
)
# 开放问题:结合上文语气,不再甩「没配 Kimi」
return (
f"关于「{t[:48]}」,我先按你的话理解一下。\n\n"
"你要是想看**项目文件夹里有啥文件**,直接说「解析项目文件夹」;\n"
"想看**排产数据齐不齐**,说「帮我分析一下当前项目里面的数据」;\n"
"想**开排**,说「带我排一版」。\n\n"
"也可以把具体想法再说详细一点,我按你的目标接着做。",
f"我暂未识别到「{t[:48]}」对应的明确业务目标。\n\n"
"请补充以下任一信息:订单号或产品、期望交期、需要检查的数据,或希望采用的排产策略。",
False,
)
@ -288,7 +282,7 @@ async def reply(
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
text = f"已根据上一轮确认,继续执行:{q}\n\n" + text
return AgentReply(text=text, blocks=[_readiness_block(world)] if with_card else [])
provider = get_provider()
@ -317,6 +311,6 @@ async def reply(
text, with_card = _offline_reply(world, q, session_id=session_id)
if note and follow.get("kind") == "affirm":
text = f"(明白,按你说的「{q}」来)\n\n" + text
text = f"已根据上一轮确认,继续执行:{q}\n\n" + text
blocks = [_readiness_block(world)] if with_card else []
return AgentReply(text=text, blocks=blocks)

View File

@ -87,7 +87,7 @@ def resolve_followup(text: str, history: list[HistoryItem]) -> dict[str, Any]:
"text": t,
"note": "用户拒绝了上一轮建议",
"kind": "deny",
"reply": "好,那先不动。你想到啥随时说,我接着帮你。",
"reply": "已取消本次操作,当前数据未修改。需要继续时,请重新描述目标。",
}
if _AFFIRM.match(t) and prev_agent:

View File

@ -118,13 +118,13 @@ def make_clarification(session_id: str, text: str, intent: IntentResult) -> Agen
for c in candidates],
"rounds": prev_rounds + 1,
}
lines = [f"我不太确定你的意思,你是想:(回复序号即可)"]
lines = [f"请确认需要执行的操作(回复序号即可):"]
for i, c in enumerate(candidates, start=1):
lines.append(f"{i}. {c[1]}")
lines.append("都不是的话,请换个说法补充要点。")
lines.append("如果以上选项均不符合,请补充订单、产品或目标操作。")
block = UIBlock(
blockId=f"clarify-{session_id[:8]}", type="clarify",
props={"question": "我不太确定你的意思,你是想:",
props={"question": "请确认需要执行的操作:",
"options": [{"index": i + 1, "intent": c[0], "label": c[1]}
for i, c in enumerate(candidates)],
"originalText": text})
@ -165,7 +165,7 @@ def _resolve_clarify(session_id: str, text: str) -> IntentResult | None:
# ② 引导式排产向导
# ============================================================
_WIZ_CANCEL = r"取消|退出|不排了|算了"
_WIZ_GO = r"^(排|开始|好|可以|确认|继续|继续排产|go)$"
_WIZ_GO = r"^(排|开始|好|可以|确认|确认试排|继续|继续排产|go)$"
def start_wizard(store, session_id: str, actor: str = "web") -> AgentReply:
@ -188,16 +188,15 @@ def _wizard_advance(store, session_id: str, actor: str) -> AgentReply:
# 无订单 → 引导录入订单
if s["total"] == 0:
st["wizard"] = None
return AgentReply(text="当前没有待排订单。先录入订单吧——可以说"
"「导入订单」(Excel 批量)或「新建订单 产品X 数量100 交期…」,"
"录完再说「我要排产」。")
return AgentReply(text="当前没有待排订单,无法启动排产向导。\n"
"请先导入订单表(Excel 批量)或新建订单;完成后再次执行“开始排产”。")
# 缺日历 → 给默认日历选项
if any(gi["type"] == "NO_CALENDAR" for gi in report["globalIssues"]):
st["wizard"] = {"step": "fix_calendar"}
return AgentReply(text="缺班次日历(不知道每天几点开工、一周开几天)。\n"
"回复「默认日历」建单班 08:00-17:00(午休1h)周一~周五;"
"或告诉我你们的班制(如「两班 8点到23点 周一到周六」)。",
return AgentReply(text="缺少班次日历,无法计算可用生产时段。\n"
"请选择“默认日历”(单班 08:00-17:00,午休 1 小时,周一至周五),"
"或提供实际班制(例如“两班制,08:00-23:00,周一至周六”)。",
blocks=[_wizard_block("fix_calendar", "缺班次日历",
["默认日历", "自定义班制"])])
@ -222,14 +221,14 @@ def _wizard_advance(store, session_id: str, actor: str) -> AgentReply:
tpls = []
st["wizard"] = {"step": "pick_template", "productCode": pc, "productName": pname,
"templates": [t["code"] for t in tpls]}
lines = [f"订单 {target['orderNo']} 的产品「{pname}」还没有工艺路线。"
f"根据产品特征,推荐这些行业模板:(回复序号采用)"]
lines = [f"订单 {target['orderNo']} 的产品「{pname}」尚未配置工艺路线。"
f"以下是根据产品特征匹配的行业模板,请回复序号选择:"]
for i, t in enumerate(tpls, start=1):
steps_brief = "→".join(x["operationName"] for x in t["steps"][:6])
more = "…" if len(t["steps"]) > 6 else ""
total = sum(x["stdMinDefault"] for x in t["steps"])
lines.append(f"{i}. {t['name']}:{steps_brief}{more}(约 {total:.0f} 分/件)")
lines.append("都不合适可以回复「跳过」,稍后手工维护路线。")
lines.append("如果没有合适模板,请回复“跳过”,稍后在主数据中维护工艺路线。")
return AgentReply(text="\n".join(lines),
blocks=[_wizard_block("pick_template", f"为 {pname} 选工艺模板",
[f"{i}. {t['name']}" for i, t in enumerate(tpls, 1)] + ["跳过"],
@ -254,9 +253,9 @@ def _wizard_advance(store, session_id: str, actor: str) -> AgentReply:
if queue:
pc, op = queue[0]
st["wizard"] = {"step": "fill_time", "queue": queue}
return AgentReply(text=f"还有 {len(queue)} 道工序没有工时。先补第一条:\n"
return AgentReply(text=f"还有 {len(queue)} 道工序缺少标准工时。当前需要补充:\n"
f"产品 {pc} 的工序「{op}」单件多少分钟?"
f"回复数字(如「45」),或回复「跳过」逐条略过。",
f"请回复分钟数(如“45”),或回复“跳过”暂不维护。",
blocks=[_wizard_block("fill_time", f"补工时:{pc} × {op}",
["跳过", "取消"])])
@ -267,18 +266,17 @@ def _wizard_advance(store, session_id: str, actor: str) -> AgentReply:
st["wizard"] = None
details = ";".join(i["detail"] for r in no_eq[:3] for i in r["issues"]
if i["type"] == "NO_CAPABLE_EQUIPMENT")
return AgentReply(text=f"还有工序没有能力设备:{details}。\n"
f"请在主数据页为设备登记工序能力(capabilities),"
f"或说「设备 XX 增加能力 工序YY」;完成后再说「我要排产」。")
return AgentReply(text=f"以下工序尚未匹配到可用设备:{details}。\n"
f"请在主数据页补充设备工序能力,完成后再次执行“检查数据齐备度”。")
# 全绿(或仅剩警告)→ 试排确认
warn_note = ""
if s["withWarnings"]:
warn_note = f"({s['withWarnings']} 张订单带推断工时/缺料告警,结果会显式标注)"
st["wizard"] = {"step": "confirm_run"}
return AgentReply(text=f"数据齐备 ✅ {s['total']} 张订单可排{warn_note}。\n"
f"回复「排」立即按瓶颈锚模式试排;回复「取消」退出向导。",
blocks=[_wizard_block("confirm_run", "数据齐备,可以试排", ["排", "取消"])])
return AgentReply(text=f"数据齐备:{s['total']} 张订单可以生成试排方案{warn_note}。\n"
f"回复“确认试排”按瓶颈锚策略生成方案;回复“取消”退出向导。",
blocks=[_wizard_block("confirm_run", "数据齐备,可以生成试排方案", ["确认试排", "取消"])])
def _wizard_block(step: str, title: str, options: list[str],
@ -298,7 +296,7 @@ def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply |
if re.search(_WIZ_CANCEL, t):
st["wizard"] = None
return AgentReply(text="已退出排产向导。随时说「我要排产」重新开始。")
return AgentReply(text="已退出排产向导。需要时可重新执行“开始排产”。")
if step == "fix_calendar":
if re.search(r"默认日历|默认|单班", t):
@ -310,14 +308,14 @@ def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply |
power="P1", rationale={"wizard": True, "calendar": "默认单班"})
store.save()
reply = _wizard_advance(store, session_id, actor)
reply.text = "已建默认日历(单班 08:00-17:00,周一~五)✅\n\n" + reply.text
reply.text = "已建立默认班次日历(单班 08:00-17:00,周一至周五)。\n\n" + reply.text
return reply
return AgentReply(text="没听懂班制。回复「默认日历」,或稍后在主数据页维护后再说「继续排产」。")
return AgentReply(text="未识别到有效班制。请选择“默认日历”,或提供班次数量、工作时间和工作日。")
if step == "pick_template":
if re.search(r"^跳过$", t):
st["wizard"] = {"step": "resume"}
return AgentReply(text="已跳过该产品。稍后可在主数据页维护路线;说「继续排产」推进其它检查。")
return AgentReply(text="已跳过该产品的工艺路线。请在主数据中维护后,再执行“继续排产”。")
codes = wiz.get("templates") or []
idx = None
m = re.match(r"^(?:第?\s*([1-9一二三])\s*个?)", t)
@ -333,7 +331,7 @@ def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply |
tpl_code = c
break
if not tpl_code:
return AgentReply(text="请回复模板序号(1/2/3)、「跳过」或「取消」。")
return AgentReply(text="请选择模板序号(1/2/3),或回复“跳过”“取消”。")
pc = wiz.get("productCode") or ""
pname = wiz.get("productName") or pc
from server.knowledge.routing_templates import get_template
@ -352,7 +350,7 @@ def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply |
rationale={"confirmId": block.props["confirmId"], "productCode": pc, "wizard": True})
store.save()
st["wizard"] = {"step": "resume"}
return AgentReply(text=f"{title}——需要你确认(P2)。批准后说「继续排产」推进下一步。",
return AgentReply(text=f"{title}需要确认(P2)。确认后执行“继续排产”推进下一步。",
blocks=[block])
if step == "fill_time":
@ -365,11 +363,11 @@ def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply |
queue.pop(0)
st["wizard"] = {"step": "fill_time", "queue": queue} if queue else {"step": "resume"}
if queue:
return AgentReply(text=f"已跳过。下一条:产品 {queue[0][0]} 的工序「{queue[0][1]}」单件多少分钟?")
return AgentReply(text=f"已跳过当前工序。下一项:产品 {queue[0][0]} 的工序「{queue[0][1]}」单件多少分钟?")
return _wizard_advance(store, session_id, actor)
m = re.search(r"(\d+(?:\.\d+)?)", t)
if not m:
return AgentReply(text=f"请回复分钟数(如「45」)、「跳过」或「取消」。当前:{pc} × {op}")
return AgentReply(text=f"请回复标准工时(分钟/件),或回复“跳过”“取消”。当前:{pc} × {op}")
std_min = float(m.group(1))
title = f"更新工时:{pc} × {op} → {std_min} 分钟/件"
block = harness.stage_confirmation(
@ -385,7 +383,7 @@ def _wizard_turn(store, session_id: str, text: str, actor: str) -> AgentReply |
st["wizard"] = {"step": "fill_time", "queue": queue} if queue else {"step": "resume"}
nxt = (f"\n下一条:产品 {queue[0][0]} 的工序「{queue[0][1]}」单件多少分钟?"
if queue else "\n工时都过了一遍。批准确认卡后说「继续排产」。")
return AgentReply(text=f"{title}——已出确认卡(P2)。{nxt}", blocks=[block])
return AgentReply(text=f"{title}已生成确认卡(P2)。{nxt}", blocks=[block])
if step == "confirm_run":
if re.match(_WIZ_GO, t) or re.search(r"^试排|排产$", t):

View File

@ -635,20 +635,20 @@ def _to_markdown(
lines.append("")
if can_schedule:
lines.append("**结论:按文件内容看,基本具备开排条件。**")
lines.append("你可以说「根据这些数据排产」——我先收进系统,再校验后试排。")
lines.append("**结论:按文件内容看,已基本满足排产条件。**")
lines.append("下一步:确认导入这些数据,系统将继续校验并生成试排方案。")
else:
lines.append("**结论:现在还不能稳妥开排。**")
lines.append("还缺:" + ("、".join(missing) if missing else "有效数据行"))
lines.append("把缺的表补进工程目录(或改好报错行)后再叫我。")
lines.append("**结论:当前不满足直接排产条件。**")
lines.append("缺失项:" + ("、".join(missing) if missing else "有效数据行"))
lines.append("请补充缺失数据或修正错误记录后重新执行目录分析。")
ws = (world_ready or {}).get("summary") or {}
if ws:
lines += [
"",
f"(系统里当前已有待排单 {ws.get('total', 0)} 张,"
f"可排 {ws.get('ready', 0)},卡住 {ws.get('blocked', 0)}。"
f"导入目录后会按新数据重新看。)",
f"(系统当前已有待排订单 {ws.get('total', 0)} 张,"
f"可排 {ws.get('ready', 0)} 张,存在阻断 {ws.get('blocked', 0)} 张。"
f"导入目录后将基于新数据重新校验。)",
]
return "\n".join(lines)
@ -663,5 +663,5 @@ def confirmation_for_folder_schedule(report: dict[str, Any]) -> tuple[str, list[
n = (report.get("kindCounts") or {}).get(k, 0)
if n:
lines.append(f"· {KIND_CN.get(k, k)} {n} 行")
lines.append("确认后写入柔性主数据并试排一版(可回滚)")
lines.append("确认后写入柔性主数据并生成试排方案(支持回滚)")
return title, lines

View File

@ -35,8 +35,8 @@ def build_guidance(world: World, *, context: str | None = None) -> dict[str, Any
"reason": "甘特为空,需先试排生成草稿。",
})
suggestions.append({
"id": "run_delivery", "label": "试排一版交期优先",
"command": "试排一版交期优先", "priority": 10,
"id": "run_delivery", "label": "生成交期优先试排方案",
"command": "生成一版交期优先试排方案", "priority": 10,
"reason": "无版本时空态首选",
})
suggestions.append({
@ -87,8 +87,8 @@ def build_guidance(world: World, *, context: str | None = None) -> dict[str, Any
"reason": "能力池/虚拟产线需跑柔性排产。",
})
suggestions.append({
"id": "flex_run", "label": "跑一版柔性排产",
"command": "跑一版柔性排产", "priority": 12,
"id": "flex_run", "label": "生成柔性排产方案",
"command": "生成一版柔性排产方案", "priority": 12,
"reason": "柔性轨空态",
})
@ -124,7 +124,7 @@ def build_guidance(world: World, *, context: str | None = None) -> dict[str, Any
if not ranked:
ranked = [{
"id": "help", "label": "看看能做什么",
"id": "help", "label": "查看可用操作",
"command": "帮助", "priority": 1,
"reason": "默认兜底",
}]

View File

@ -155,41 +155,47 @@ def check_readiness(world: World, order_ids: list[int] | None = None) -> dict[st
def readiness_text(report: dict[str, Any]) -> str:
"""齐备度报告 → 人话(车间能听懂)。"""
"""齐备度报告:结论、关键指标、阻断项与下一步。"""
s = report["summary"]
if not s["total"]:
conclusion = "当前没有待排订单,暂时无法生成试排方案。"
elif s["blocked"]:
conclusion = f"当前有 {s['blocked']} 张订单存在阻断项,暂不建议直接排产。"
else:
conclusion = f"当前 {s['total']} 张订单已通过基础齐备度检查,可以生成试排方案。"
lines = [
f"我帮你看了看:一共 {s['total']} 张要排的单子,"
f"大概 {s['ready']} 张能直接开排,"
f"{s['blocked']} 张还卡着,"
f"{s['withWarnings']} 张能排但有点风险。",
f"工时这边:一共 {s['timeTotal']} 道步骤,"
f"还有 {s['timePending']} 道没填好,"
f"{s['timeInferred']} 道是估出来的(不太准)。",
"## 数据齐备度",
f"**结论:{conclusion}**",
"",
f"- 待排订单:{s['total']} 张;可直接排产:{s['ready']} 张;存在阻断:{s['blocked']} 张。",
f"- 标准工时:共 {s['timeTotal']} 道工序;待维护 {s['timePending']} 道;推断值 {s['timeInferred']} 道。",
]
if s["withWarnings"]:
lines.append(f"- 风险提示:{s['withWarnings']} 张订单包含推断工时或缺料告警。")
for gi in report["globalIssues"]:
lines.append(f"⚠ 先卡住了:{gi['detail']}")
lines.append(f"- **阻断项**:{gi['detail']}")
shown = 0
for r in report["orders"]:
if not r["issues"] or shown >= 6:
continue
mark = "还不行" if not r["ready"] else "要注意"
mark = "阻断" if not r["ready"] else "风险"
heads = ";".join(i["detail"] for i in r["issues"][:3])
more = f"(另外还有 {len(r['issues']) - 3} 处)" if len(r["issues"]) > 3 else ""
lines.append(f"· {mark} · 单号 {r['orderNo']}:{heads}{more}")
lines.append(f"- **{mark}** · 订单 {r['orderNo']}:{heads}{more}")
shown += 1
rest = sum(1 for r in report["orders"] if r["issues"]) - shown
if rest > 0:
lines.append(f"……还有 {rest} 张单子也有类似问题,需要的话我再细说。")
lines.append(f"- 另有 {rest} 张订单存在类似问题,详情可在订单齐备度明细中查看。")
if s["timePending"] or s["timeInferred"]:
lines.append("")
lines.append(
"工时不会填也没关系,你可以跟我说:"
"「把某某工序改成多少分钟」,我按你说的记下来。"
"**建议操作**:补充待维护工时,或提供实测工时(例如“将工序 OP-10 设置为 45 分钟/件”)。"
)
return "\n".join(lines)
def analyze_project_data(world: World, query: str = "") -> str:
"""分析当前项目/附件:用人话盘点 → 缺啥 → 下一步怎么补。"""
"""分析当前项目/附件:结论 → 数据概览 → 阻断项 → 建议操作。"""
lines: list[str] = []
q = query or ""
if "【文件解析】" in q:
@ -199,11 +205,12 @@ def analyze_project_data(world: World, query: str = "") -> str:
chunk = chunk.split("\n\n请", 1)[0].strip()
elif "\n\n给我" in chunk:
chunk = chunk.split("\n\n给我", 1)[0].strip()
lines.append("你刚附的表格,我帮你过了一遍:")
lines.append("## 附件解析结果")
lines.append("已完成附件的初步解析:")
lines.extend(chunk.splitlines()[:40])
lines.append("")
if "可入库" in chunk:
lines.append("里面有一部分已经核对过了,可以收进系统;有问题的行我标出来了,你改完再传也行。")
lines.append("部分记录已通过校验,可以提交入库;错误记录需要修正后重新上传。")
lines.append("")
active = [o for o in (world.get("flexOrders") or [])
@ -214,11 +221,11 @@ def analyze_project_data(world: World, query: str = "") -> str:
cal = world.get("flexCalendar") or []
bom = world.get("flexBom") or []
lines.append("**先说说咱们手头有啥:**")
lines.append("## 项目数据概览")
lines.append(
f"- 要排的单子 **{len(active)}** 张;材料/产品档案 **{len(mats)}** 条;"
f"用料清单 **{len(bom)}** 条;工艺步骤 **{len(routes)}** 条;"
f"能开着干活的设备 **{len(equip)}** 台;班次 **{len(cal)}** 条。"
f"- 待排订单:**{len(active)}** 张;产品/物料:**{len(mats)}** 条;"
f"BOM 明细:**{len(bom)}** 条;工艺步骤:**{len(routes)}** 条;"
f"可用设备:**{len(equip)}** 台;班次日历:**{len(cal)}** 条。"
)
if active:
preview = "、".join(
@ -226,9 +233,9 @@ def analyze_project_data(world: World, query: str = "") -> str:
for o in active[:5]
)
more = f"……一共 {len(active)} 张" if len(active) > 5 else ""
lines.append(f"- 比如这些单:{preview}{more}")
lines.append(f"- 订单示例:{preview}{more}")
else:
lines.append("- 这会儿还没有要排的单子。你可以附一张订单表,或者说「新建订单」。")
lines.append("- 待排订单为 0 张。请先导入订单表或新建订单。")
report = check_readiness(world)
lines.append("")
@ -236,20 +243,20 @@ def analyze_project_data(world: World, query: str = "") -> str:
asks: list[str] = []
if not active:
asks.append("来一份订单表(单号、做什么产品、做多少、什么时候要)")
asks.append("导入订单表(至少包含单号、产品、数量和交期)")
if not mats:
asks.append("来一份产品/材料名录(至少把要做的产品写上)")
asks.append("补充产品/物料主数据(至少包含待排产品)")
if not routes:
asks.append("告诉我产品怎么做(工艺步骤),或者说「带我排一版」让我按常用办法生成")
asks.append("补充工艺路线和工序顺序,或请求系统推荐行业模板")
if not equip:
asks.append("告诉我有哪些能干活的设备、各自会做什么工序")
asks.append("补充可用设备及其工序能力")
if not cal:
asks.append("告诉我一天上几班、几点开工;或者说「带我排一版」用常用班次")
asks.append("补充班次日历(工作日、班次和起止时间)")
s = report["summary"]
if s.get("timePending"):
asks.append(f"把还没填的工时补上(现在大概还有 {s['timePending']} 道)")
asks.append(f"补充 {s['timePending']} 道工序的标准工时")
if s.get("blocked"):
asks.append(f"把卡住的 {s['blocked']} 张单子问题处理掉(缺产品、缺工艺、或缺设备)")
asks.append(f"处理 {s['blocked']} 张订单的阻断项(产品、工艺或设备能力缺失)")
seen: set[str] = set()
uniq = []
@ -260,12 +267,13 @@ def analyze_project_data(world: World, query: str = "") -> str:
lines.append("")
if uniq:
lines.append("**想顺利开排,还差这些:**")
lines.append("## 阻断项与建议操作")
for i, a in enumerate(uniq, 1):
lines.append(f"{i}. {a}")
lines.append("你补一点,我就帮你看一点;齐了直接说「直接排一版」或「带我排一版」。")
lines.append("完成上述补充后,请再次执行“检查数据齐备度”;通过后即可生成试排方案。")
else:
lines.append("看起来东西比较齐了。你要是愿意,直接跟我说「直接排一版」,我马上给你出一稿。")
lines.append("## 建议操作")
lines.append("数据已满足基础排产条件。可以选择排产策略并生成试排方案。")
return "\n".join(lines)

View File

@ -151,7 +151,7 @@ def build_plan_report(world: dict[str, Any], order_no: str | None = None) -> dic
if not daily.get("reportId"):
return {
"reportId": None, "title": "排产方案报告",
"markdown": "尚无排产版本。请先说「跑一版柔性排产」或「试排一版」。",
"markdown": "尚无排产版本。请先生成柔性排产方案或交期优先试排方案。",
"snapshot": None, "xlsxBytes": None, "filename": None,
}
daily["title"] = daily["title"].replace("排产日报", "排产方案报告")

View File

@ -33,8 +33,8 @@ _HELP = "\n".join([
"可执行的排产能力:",
"· 查询现场数据:有哪些订单 / 查订单 102285668 / 这个订单有委外吗 / 查物料 / 查 BOM / 查工艺路线",
"· 知识库:机加工工艺怎么生成 / 车削工艺模式 / 装配工艺模式 / 知识清单 / 导入知识文档",
"· 生成排产草案:试排一版交期优先 / 产能均衡重排 / 用规则引擎试排",
"· 柔性排产(能力池+虚拟产线):跑一版柔性排产 / 把这个订单排产 / 正排 / 倒排 / 瓶颈锚",
"· 生成排产方案:交期优先试排 / 产能均衡重排 / 规则引擎试排",
"· 柔性排产(能力池+虚拟产线):生成柔性排产方案 / 按订单排产 / 正排 / 倒排 / 瓶颈锚",
"· 外部算法:用外部算法试排 / 查看已接入算法 / skill 健康检查",
"· 瓶颈产能:瓶颈产能 / 各工序能做多少(按能力池实时算)",
"· 交期承诺:某产品 N 套什么时候能交",
@ -884,7 +884,7 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
detail = "、".join(f"{k}×{v}" for k, v in (applied.get("summary") or {}).items()) or (
"SQL 已入库" if sql_already else "无行"
)
head = f"工程目录已导入 ✅({detail})。"
head = f"工程目录已导入({detail})。"
# 有订单+工艺+设备即可开排(工时推断/缺 BOM 不拦死)
has_core = (
bool(store.data.get("flexOrders"))
@ -897,7 +897,7 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
result = run_flex_schedule(
store, sort_mode=params.get("sortMode") or "BOTTLENECK", actor=actor, trial=True)
ver = (result or {}).get("versionNo") or ""
return (head + f"\n已按目录数据试排一版:**{ver}**"
return (head + f"\n已按目录数据生成试排方案:**{ver}**"
f"(虚拟产线 {(result or {}).get('vlCount', 0)} · "
f"冲突 {(result or {}).get('conflictCount', 0)})。"
"可在右侧柔性工作台看甘特,或再说「查看冲突」。")
@ -1542,7 +1542,7 @@ def _run_flex(store: WorldStore, intent: IntentResult, actor: str) -> AgentReply
)
else:
text = (
f"柔性排产完成 ✅ {focus_hint}版本 {result['versionNo']}({mode_cn}·{win_cn}):"
f"柔性排产完成。{focus_hint}版本 {result['versionNo']}({mode_cn}·{win_cn}):"
f"{result['vlCount']} 条虚拟产线 / {result['woCount']} 个工单,冲突 {result['conflictCount']} 项"
+ (f",窗外延期 {deferred}" if deferred else "")
+ f"。{dl_hint}"
@ -2965,16 +2965,16 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
if not report.get("batches") and not report.get("sqlApplied"):
return AgentReply(
text=(report.get("markdown") or "")
+ "\n\n目录里还没有可入库的有效行,我没法按这些表开排。"
"请先把订单/物料/工艺/设备表或 SQL 库补齐。",
+ "\n\n目录中没有可入库的有效记录,当前无法生成排产方案。"
"请补充订单、物料、工艺或设备数据。",
blocks=[analyze_block],
)
if report.get("missing") and not has_core:
return AgentReply(
text=(report.get("markdown") or "")
+ "\n\n**还不能排:**还缺 "
+ "\n\n**当前无法排产:**缺少 "
+ "、".join(report["missing"])
+ "。补齐后再说「根据这些数据排产」。",
+ "。补齐后请重新执行目录分析。",
blocks=[analyze_block],
)
title, lines = confirmation_for_folder_schedule(report)
@ -2997,7 +2997,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
store.save()
return AgentReply(
text=(report.get("markdown") or "")
+ "\n\n必备数据已齐。下面请确认:**导入这些表格并试排一版**(可回滚)。",
+ "\n\n必备数据已齐。请确认:**导入这些表格并生成试排方案**(支持回滚)。",
blocks=[analyze_block, block],
)
if name in ("assistant.reply", "unknown"):

View File

@ -45,6 +45,7 @@ from __future__ import annotations # 前向类型引用
import asyncio # 流式分片的微延时
import contextvars # 线程池任务继承登录/租户上下文
import json # SSE 载荷序列化
import logging # 记录异常,避免把内部细节返回给用户
from contextlib import asynccontextmanager # 自动化后台 tick 生命周期 # SSE 载荷序列化
import os
import uuid # 会话 ID 生成
@ -77,6 +78,9 @@ from server.auth.providers import AuthError, get_auth_provider
from server.gateway.plan_api import router as plan_router
logger = logging.getLogger(__name__)
# ---------------- 请求体契约 ----------------
class ChatRequest(BaseModel):
"""对话请求:会话 ID + 用户文本 + 可选多轮上文。"""
@ -1048,7 +1052,7 @@ def create_app() -> FastAPI:
from server.contracts import AgentReply as _AgentReply
from server.state.projects import get_project_store
emit_thinking("收到指令", (req.text or "")[:48], pct=1)
emit_thinking("收到请求", (req.text or "")[:48], pct=1)
for ev in drain_thinking():
yield _sse(ev)
@ -1071,11 +1075,11 @@ def create_app() -> FastAPI:
return
if follow.get("kind") in ("affirm", "refer") and follow.get("text"):
user_text = str(follow["text"])
emit_thinking("结合上文", follow.get("note") or user_text[:40], status="done")
emit_thinking("结合上下文", follow.get("note") or user_text[:40], status="done")
intent = None
reply = None
emit_thinking("理解意图", "正在识别你要做什么…", pct=5)
emit_thinking("确认目标", "正在确认需要执行的业务操作…", pct=5)
for ev in drain_thinking():
yield _sse(ev)
@ -1102,15 +1106,15 @@ def create_app() -> FastAPI:
dump.get("params", {}).pop("_history", None)
yield _sse({"type": "intent", "intent": dump})
_THINK_BY_INTENT = {
"data.analyze": "深度分析项目数据(可能含大 SQL,请稍候)",
"folder.analyze": "解析工程目录表格",
"folder.schedule": "导入目录数据并准备试排",
"flex.schedule": "运行柔性排产引擎",
"schedule.run": "运行排产引擎",
"schedule.wizard": "启动排产向导",
"data.analyze": "正在分析项目数据",
"folder.analyze": "正在解析工程目录",
"folder.schedule": "正在导入目录数据并准备试排",
"flex.schedule": "正在生成柔性排产方案",
"schedule.run": "正在生成排产方案",
"schedule.wizard": "正在检查排产条件",
}
tip = _THINK_BY_INTENT.get(intent.intent, f"执行 {intent.intent}")
emit_thinking("开始执行", tip, pct=8)
tip = _THINK_BY_INTENT.get(intent.intent, "正在执行已确认的业务操作")
emit_thinking("执行操作", tip, pct=8)
for ev in drain_thinking():
yield _sse(ev)
@ -1170,8 +1174,9 @@ def create_app() -> FastAPI:
store.save()
except Exception: # noqa: BLE001, S110 - 尽力而为:滚动摘要任何异常降级为不滚动,绝不阻断对话
pass
except Exception as exc: # 兜底:任何异常转为 error 事件(连接不中断)
yield _sse({"type": "error", "message": f"处理失败:{exc}"})
except Exception as exc: # 兜底:异常记录日志,用户只看到可执行提示
logger.exception("chat request failed", exc_info=exc)
yield _sse({"type": "error", "message": "本次操作未完成,数据未修改。请稍后重试;如持续发生,请联系管理员。"})
finally:
end_progress()
yield _sse({"type": "done"}) # ⑧ 结束标记

View File

@ -79,6 +79,15 @@ def test_simulate_schedule_does_not_hijack_other_intents():
assert parse_fast("帮我分析一下当前项目里面的数据", world).intent == "folder.analyze"
def test_professional_starter_commands_keep_expected_intents():
world = seed_world()
delivery = parse_fast("生成一版交期优先试排方案", world)
assert delivery is not None and delivery.intent == "schedule.run"
assert delivery.params.get("strategy") == "DELIVERY_FIRST"
flex = parse_fast("生成一版柔性排产方案", world)
assert flex is not None and flex.intent == "flex.schedule"
@pytest.mark.asyncio
async def test_what_time_is_it():
world = seed_world()
@ -103,7 +112,8 @@ async def test_unknown_handle_gives_project_brief_not_tip_spam():
async def test_explain_scheduling_not_inventory_dump():
world = seed_world()
reply = await assistant_reply(world, "你给我解释什么叫排产")
assert "排产是啥" in reply.text or "把这些事提前安排清楚" in reply.text
assert "什么是排产" in reply.text
assert "可执行的生产计划" in reply.text
assert "要排的单子" not in reply.text
assert "你可以任选一句" not in reply.text
assert not reply.blocks
@ -113,7 +123,8 @@ async def test_explain_scheduling_not_inventory_dump():
async def test_can_schedule_not_inventory_dump():
world = seed_world()
reply = await assistant_reply(world, "你会排产吗?")
assert "本职" in reply.text or "会" in reply.text
assert "可以" in reply.text
assert "排产阻断项" in reply.text
assert "要排的单子" not in reply.text
assert not reply.blocks
@ -122,7 +133,21 @@ async def test_can_schedule_not_inventory_dump():
async def test_assistant_offline_analyze_style():
world = seed_world()
reply = await assistant_reply(world, "分析一下现状")
assert "手头有啥" in reply.text or "我帮你看了看" in reply.text
assert "项目数据概览" in reply.text
assert "建议操作" in reply.text
@pytest.mark.asyncio
async def test_assistant_professional_copy_has_no_casual_markers():
world = seed_world()
replies = [
await assistant_reply(world, "你好"),
await assistant_reply(world, "什么是排产"),
await assistant_reply(world, "分析一下现状"),
]
text = "\n".join(r.text for r in replies)
for marker in ("啥", "咱们", "哟", "我在", "大白话", "冷笑话"):
assert marker not in text
@pytest.mark.asyncio

View File

@ -31,7 +31,7 @@ def test_guidance_empty_schedule():
g = build_guidance(world)
cmds = {s["command"] for s in g["suggestions"]}
assert any(s["id"] == "no_version" for s in g["signals"])
assert "试排一版交期优先" in cmds
assert "生成一版交期优先试排方案" in cmds
def test_guidance_knowledge_miss_context():

View File

@ -108,11 +108,11 @@ def test_intent_and_analyze_project_data():
r = parse_fast(text, world)
assert r is not None and r.intent == "data.analyze", text
text = analyze_project_data(world, "分析项目数据")
assert "手头有啥" in text or "要排的单子" in text
assert "我帮你看了看" in text or "能直接开排" in text
assert "项目数据概览" in text
assert "待排订单" in text and "可直接排产" in text
text2 = analyze_project_data(
world,
"【文件解析】\n文件:a.xlsx\n有效 2 行 · 错误 0 行(可入库)\n\n给我分析一下",
)
assert "你刚附的表格" in text2
assert "附件解析结果" in text2
assert "a.xlsx" in text2

View File

@ -58,7 +58,7 @@ def test_wizard_recommends_template_for_missing_routing():
"status": "RELEASED"})
reply = dialog.start_wizard(store, "w2")
assert "没有工艺路线" in reply.text
assert "尚未配置工艺路线" in reply.text
assert reply.blocks and reply.blocks[0].type == "wizard"
tpls = reply.blocks[0].props.get("templates") or []
assert tpls, "应推荐至少一个行业模板"
@ -115,7 +115,7 @@ def test_wizard_default_calendar():
assert "班次日历" in reply.text
reply2 = dialog.pre_route(store, "w4", "默认日历")
assert isinstance(reply2, AgentReply)
assert "默认日历" in reply2.text
assert "默认班次日历" in reply2.text
assert store.data["flexCalendar"], "应写入默认日历"