Compare commits

...

3 Commits

Author SHA1 Message Date
z.zhang e0cf0f30f6 feat: 收口计划员约束规则与急单处理闭环
- 增加默认通用约束、计划员扩充规则库及 P2 确认链路\n- 收口急单评估/应用口径,由排产引擎结论优先并改为计划员语言\n- 补充后端黄金测试、离线 E2E 与产品说明
2026-09-22 14:44:02 +08:00
z.zhang 263af1ee32 fix(web): 连续展示聊天备选项编号 2026-09-22 14:43:54 +08:00
z.zhang 3842250d14 chore: 刷新 GitNexus 索引元数据 2026-09-22 14:43:47 +08:00
26 changed files with 1965 additions and 108 deletions

View File

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **aps-agent** (16645 symbols, 68374 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **aps-agent** (17639 symbols, 65938 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).

View File

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **aps-agent** (16645 symbols, 68374 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **aps-agent** (17639 symbols, 65938 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).

View File

@ -0,0 +1,84 @@
// ============================================================
// 计划员聊天编号回归:模型常把多个选项都写成 1.,界面必须连续显示 1/2/3。
// 全部离线:只桩会话历史,不连接现场后端。
// ============================================================
import { test, expect, type Page } from '@playwright/test';
const json = (body: unknown, status = 200) => ({
status, contentType: 'application/json', body: JSON.stringify(body),
});
async function stubApi(page: Page): Promise<void> {
const message = {
role: 'agent',
text: [
'接下来你有三个选择:',
'',
'1. 修改现有订单',
'',
'把数量改成 50 套、交期改成 10 月 14 日。',
'',
'1. 新增一张急单',
'',
'换一个新订单号,按你给的数量和交期录入。',
'',
'1. 直接使用已有急单',
'',
'按现有急单的交期评估插入后的影响。',
].join('\n'),
};
await page.route((url: URL) => url.pathname.startsWith('/api/'), route => {
const path = new URL(route.request().url()).pathname;
switch (path) {
case '/api/health':
return route.fulfill(json({ interfaceVersion: '1.0', authProvider: 'local', licenseProvider: 'local' }));
case '/api/auth/me':
return route.fulfill(json({
user: {
user_id: 'user-1', username: 'e2e', fullname: 'E2E', tenant_uuid: 'tenant-1',
roles: ['admin'], auth_kind: 'user',
},
provider: 'local',
license: null,
}));
case '/api/features':
return route.fulfill(json({ version: 1, source: 'default', path: '', error: null, unknown: [], features: {} }));
case '/api/workspace':
return route.fulfill(json({
projects: [],
sessions: [{
id: 'session-1', projectId: '__personal__', title: '编号测试', status: 'done',
updatedAt: '2026-09-18 10:00:00', scope: 'personal',
}],
files: [],
messages: { 'session-1': [message] },
activeProjectId: '__personal__', activeSessionId: 'session-1',
}));
case '/api/sessions/session-1/messages':
return route.fulfill(json({ sessionId: 'session-1', messages: [message] }));
case '/api/skills':
return route.fulfill(json({ skills: [], skillsDir: '', mode: 'web' }));
case '/api/skills/health':
return route.fulfill(json({ health: [] }));
default:
return route.fulfill(json({ detail: 'e2e stub' }, 404));
}
});
}
test('多个备选项即使都写成 1.,页面也连续显示 1、2、3', async ({ page }) => {
await stubApi(page);
await page.goto('/');
const chat = page.locator('section.chat[data-session-ready="true"]');
await expect(chat).toBeVisible({ timeout: 30_000 });
const items = chat.locator('.chat-md ol.md-list > li');
await expect(items).toHaveCount(3);
await expect(items.nth(0)).toContainText('修改现有订单');
await expect(items.nth(1)).toContainText('新增一张急单');
await expect(items.nth(2)).toContainText('直接使用已有急单');
const values = await items.evaluateAll(nodes => nodes.map(node => (node as HTMLLIElement).value));
expect(values).toEqual([1, 2, 3]);
});

View File

@ -0,0 +1,238 @@
// ============================================================
// 约束规则库(设置 → 约束配置)离线 E2E
// 计划员反馈(2026-09-17):约束配置要有默认通用规则,后面靠对话里的一句话
// 不断扩充,并按类别 / 按项目归档;没接入排产引擎的规则必须如实标注。
// 全部离线:/api/** 一律本地桩,不连现场后端,不写任何真实数据。
// ============================================================
import { test, expect, type Page } from '@playwright/test';
import os from 'node:os';
import path from 'node:path';
const SHOT_DIR = process.env.E2E_SHOT_DIR || os.tmpdir();
const json = (body: unknown, status = 200) => ({
status, contentType: 'application/json', body: JSON.stringify(body),
});
const PROFILE = {
profileId: 'default',
name: '默认约束剖面',
constraints: [
{
id: 'C6_material_kit', code: 'C6', name: '物料齐套', group: 'material',
kind: 'soft', enabled: true, configurable: true, weight: 0.8,
description: '缺料:硬=阻断发布;软=带风险排入;关闭=不检查(硬/软可配)',
},
{
id: 'C8_due_date', code: 'C8', name: '订单交期', group: 'order',
kind: 'soft', enabled: true, configurable: true, weight: 0.6,
description: '延期进冲突与 KPI,默认不拦发布',
},
],
groups: [
{ id: 'material', label: '物料', items: ['C6_material_kit'] },
{ id: 'order', label: '订单', items: ['C8_due_date'] },
],
};
const BOUND_RULE = {
id: 'R-001', name: '缺料必须挡住发布', category: 'process', categoryLabel: '工艺',
scope: 'project', scopeLabel: '仅本项目', kindLabel: '必须满足', enabled: true,
boundConstraintId: 'C6_material_kit',
effect: { bound: true, enforced: true, text: '由内置规则《物料齐套》执行(已启用/硬约束)' },
};
const RECORD_ONLY_RULE = {
id: 'R-002', name: '喷涂线每天最多八小时', category: 'capacity', categoryLabel: '产能',
scope: 'tenant', scopeLabel: '全厂', kindLabel: '尽量满足', enabled: false,
effect: { bound: false, enforced: false, text: '已记录,暂未接入排产引擎' },
};
const LIBRARY = {
builtin: { total: 15, enabled: 14, configurable: 10 },
custom: [BOUND_RULE, RECORD_ONLY_RULE],
groups: [
{ id: 'process', label: '工艺', items: [BOUND_RULE] },
{ id: 'capacity', label: '产能', items: [RECORD_ONLY_RULE] },
],
categories: [
{ id: 'process', label: '工艺' },
{ id: 'capacity', label: '产能' },
{ id: 'order', label: '订单' },
],
scopes: [{ id: 'project', label: '仅本项目' }, { id: 'tenant', label: '全厂' }],
summary: { builtinTotal: 15, customTotal: 2, engineBound: 1, recordOnly: 1 },
};
const SOP_ITEMS = [{
assetId: 'sop-changeover-1',
title: '换线标准SOP',
tags: ['换线'],
effectCount: 3,
compilable: true,
summary: 'C10_changeover→开/soft;跨族换型默认60.0分;建议策略CHANGEOVER_MIN;C13_sop→开/soft',
plannerSummary: '将换型顺序纳入排产控制;跨产品族换型预留 60 分钟;优先连续生产同一产品族',
}];
async function stubApi(page: Page): Promise<void> {
await page.route((url: URL) => url.pathname.startsWith('/api/'), route => {
const path = new URL(route.request().url()).pathname;
switch (path) {
case '/api/health':
return route.fulfill(json({ interfaceVersion: '1.0', authProvider: 'local', licenseProvider: 'local' }));
case '/api/auth/me':
return route.fulfill(json({
user: {
user_id: 'user-1', username: 'e2e', fullname: 'E2E', tenant_uuid: 'tenant-1',
roles: ['admin'], auth_kind: 'user',
},
provider: 'local',
license: null,
}));
case '/api/features':
return route.fulfill(json({ version: 1, source: 'default', path: '', error: null, unknown: [], features: {} }));
case '/api/workspace':
return route.fulfill(json({
projects: [],
sessions: [{
id: 'session-1', projectId: '__personal__', title: 'E2E', status: 'running',
updatedAt: '2026-09-17 10:00:00', scope: 'personal',
}],
files: [], messages: { 'session-1': [] },
activeProjectId: '__personal__', activeSessionId: 'session-1',
}));
case '/api/sessions/session-1/messages':
return route.fulfill(json({ sessionId: 'session-1', messages: [] }));
case '/api/skills':
return route.fulfill(json({ skills: [], skillsDir: '', mode: 'web' }));
case '/api/skills/health':
return route.fulfill(json({ health: [] }));
case '/api/constraints':
return route.fulfill(json(PROFILE));
case '/api/constraints/library':
return route.fulfill(json(LIBRARY));
case '/api/sop/compilable':
return route.fulfill(json({ items: SOP_ITEMS, rulePacks: [] }));
default:
return route.fulfill(json({ detail: 'e2e stub' }, 404));
}
});
}
async function openSettingsSection(page: Page, label: string): Promise<void> {
await page.locator('.app-menubar > button').filter({ hasText: '设置' }).first().click();
await expect(page.locator('.gov-shell')).toBeVisible();
await page.locator('.gov-nav-item').filter({ hasText: label }).first().click();
}
test('约束配置显示默认通用规则与已扩充规则,并如实标注是否接入引擎', async ({ page }) => {
await stubApi(page);
await page.goto('/');
await openSettingsSection(page, '约束配置');
const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first();
await expect(card).toBeVisible();
await expect(card).toContainText('通用规则 15 项');
await expect(card).toContainText('扩展规则 2 项');
await expect(card).toContainText('已用于排产 1 项');
await expect(card).toContainText('暂未用于排产 1 项');
// 按类别归档
await expect(card).toContainText('工艺');
await expect(card).toContainText('产能');
// 按范围归档 + 一句计划员语言
await expect(card).toContainText('缺料必须挡住发布');
await expect(card).toContainText('仅本项目');
await expect(card).toContainText('由内置规则《物料齐套》执行');
await expect(card).toContainText('喷涂线每天最多八小时');
await expect(card).toContainText('全厂');
await expect(card).toContainText('已纳入规则库,暂未用于排产');
// 不给计划员看内部标识 / 配置语言
await expect(card).not.toContainText('C6_material_kit');
await expect(card).not.toContainText('JSON');
await expect(card).not.toContainText('G0');
const sopCard = page.locator('.gov-card').filter({ hasText: '作业标准规则' }).first();
await expect(sopCard).toBeVisible();
await expect(sopCard).toContainText('跨产品族换型预留 60 分钟');
await expect(sopCard).toContainText('应用到排产');
await expect(sopCard).not.toContainText('C10_changeover');
await expect(sopCard).not.toContainText('C13_sop');
await expect(sopCard).not.toContainText('soft');
// 停用状态如实回显
const recordOnlyRow = card.locator('.constraint-row').filter({ hasText: '喷涂线每天最多八小时' });
await expect(recordOnlyRow.locator('input[type=checkbox]')).not.toBeChecked();
await expect(recordOnlyRow).toContainText('已停用');
await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-rules.png'), fullPage: true });
await sopCard.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-sop-rules.png'), fullPage: true });
});
test('启停扩充规则走 P2 确认卡,不直接改库', async ({ page }) => {
await stubApi(page);
const staged: Array<Record<string, unknown>> = [];
await page.route('**/api/constraints/rules/stage', route => {
staged.push(route.request().postDataJSON() as Record<string, unknown>);
return route.fulfill(json({
message: '启用约束规则:喷涂线每天最多八小时 已进入 P2 确认队列。',
block: {
blockId: 'blk-1', type: 'confirm-card', interfaceVersion: '1.0',
props: {
title: '启用约束规则:喷涂线每天最多八小时', power: 'P2', confirmId: 'confirm-1',
summary: ['该操作只调整规则库里的状态,不改动内置约束的开关'],
},
actions: [], evidenceRefs: [],
},
}));
});
await page.goto('/');
await openSettingsSection(page, '约束配置');
const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first();
const recordOnlyRow = card.locator('.constraint-row').filter({ hasText: '喷涂线每天最多八小时' });
// 受控组件:勾选状态由服务端规则库决定,这里只发出暂存请求(不期待本地立刻勾上)
await recordOnlyRow.locator('input[type=checkbox]').click();
await expect.poll(() => staged.length).toBe(1);
expect(staged[0].payload).toMatchObject({ op: 'enable', ruleId: 'R-002' });
const confirm = page.locator('.order-confirm').first();
await expect(confirm).toBeVisible();
await expect(confirm).toContainText('启用约束规则:喷涂线每天最多八小时');
await expect(confirm).toContainText('需人工确认');
await expect(confirm).toContainText('该操作只调整规则库里的状态');
await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-rule-confirm.png'), fullPage: true });
});
test('计划员可提交新的约束要求,由主对话调用 Pi 生成确认项', async ({ page }) => {
await stubApi(page);
const commands: string[] = [];
await page.route('**/api/chat', async route => {
commands.push((route.request().postDataJSON() as { text: string }).text);
return route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: 'data: {"type":"done"}\n\n',
});
});
await page.goto('/');
await openSettingsSection(page, '约束配置');
const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first();
await card.getByLabel('约束要求').fill('关键客户订单延期前两个工作日提醒');
await card.locator('.constraint-form-field').filter({ hasText: '业务类别' }).locator('select').selectOption('order');
await card.locator('.constraint-form-field').filter({ hasText: '适用范围' }).locator('select').selectOption('tenant');
await card.locator('.constraint-form-field').filter({ hasText: '约束级别' }).locator('select').selectOption('hard');
await card.getByRole('button', { name: '提交约束要求' }).click();
await expect.poll(() => commands.length).toBe(1);
expect(commands[0]).toContain('约束内容:关键客户订单延期前两个工作日提醒');
expect(commands[0]).toContain('业务类别:订单');
expect(commands[0]).toContain('适用范围:全厂');
expect(commands[0]).toContain('约束级别:必须满足');
await expect(page.locator('.gov-shell')).toBeHidden();
});

View File

@ -573,9 +573,38 @@ export async function postConstraintStage(sessionId: string, payload: Record<str
return resp.json() as Promise<{ message?: string; error?: string; block?: UIBlock }>;
}
export type ConstraintRule = {
id: string; name: string; category: string; categoryLabel: string;
scope: string; scopeLabel: string; kindLabel: string; enabled?: boolean;
boundConstraintId?: string;
effect: { bound: boolean; enforced: boolean; text: string };
};
export type ConstraintLibrary = {
builtin: { total: number; enabled: number; configurable: number };
custom: ConstraintRule[];
groups: { id: string; label: string; items: ConstraintRule[] }[];
categories: { id: string; label: string }[];
scopes: { id: string; label: string }[];
summary: { builtinTotal: number; customTotal: number; engineBound: number; recordOnly: number };
};
export const fetchConstraintLibrary = () =>
getJson<ConstraintLibrary>('/api/constraints/library');
export async function postConstraintRuleStage(sessionId: string, payload: Record<string, unknown>) {
const resp = await apiFetch('/api/constraints/rules/stage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, payload }),
});
if (!resp.ok) throw new Error(`constraint rule stage → ${resp.status}`);
return resp.json() as Promise<{ message?: string; error?: string; block?: UIBlock }>;
}
export type SopCompilableItem = {
assetId?: string; title: string; tags: string[]; effectCount: number;
compilable: boolean; summary: string;
compilable: boolean; summary: string; plannerSummary?: string;
};
export const fetchSopCompilable = () =>

View File

@ -37,7 +37,10 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) {
const raw = props.text.replace(/\r\n/g, '\n');
const lines = raw.split('\n');
const blocks: ReactNode[] = [];
let list: { ordered: boolean; items: string[] } | null = null;
let list: { ordered: boolean; items: { text: string; value?: number }[] } | null = null;
// Pi 常把同一段回复里的多个备选项拆成几张短列表,模型又会把每张都写成 1.
// 对计划员来说这是一个连续选择,所以跨空行/说明段落继续编号;遇到新标题再重置。
let orderedCursor: number | null = null;
let key = 0;
const flushList = () => {
@ -46,7 +49,7 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) {
blocks.push(
<Tag key={`l${key++}`} className="md-list">
{list.items.map((it, i) => (
<li key={i}>{renderInline(it)}</li>
<li key={i} value={it.value}>{renderInline(it.text)}</li>
))}
</Tag>,
);
@ -94,6 +97,7 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) {
const ol = /^(\d+)[.)、]\s+(.+)$/.exec(line);
if (heading) {
flushList();
orderedCursor = null;
const level = heading[1].length;
blocks.push(<div key={`h${key++}`} className={`md-h md-h${level}`}>{renderInline(heading[2])}</div>);
i += 1;
@ -104,7 +108,7 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) {
flushList();
list = { ordered: false, items: [] };
}
list.items.push(ul[1]);
list.items.push({ text: ul[1] });
i += 1;
continue;
}
@ -113,7 +117,9 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) {
flushList();
list = { ordered: true, items: [] };
}
list.items.push(ol[2]);
const value: number = orderedCursor ?? Number(ol[1]);
orderedCursor = value + 1;
list.items.push({ text: ol[2], value });
i += 1;
continue;
}

View File

@ -7,7 +7,7 @@
// 全部数据源为 P0 只读端点;待审批的批准/驳回复用 P2 唯一确认通道
// ============================================================
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ThunderboltOutlined } from '@ant-design/icons';
import { DeleteOutlined, SendOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type {
ApprovalDecision, AuditAlert, AuditEvent, ChainStatus, GoldenStatus, LlmSettings, ModuleEntry, PendingAction, PolicyEntry,
PreferenceExplain,
@ -17,9 +17,10 @@ import type {
import {
fetchApprovalHistory, fetchAudit, fetchConstraintProfile, fetchGoldenTests, fetchLlmSettings, fetchModules,
fetchPreferenceExplain, resetPreferences,
fetchPending, fetchPolicy, fetchScheduleParams, fetchSensitivity, fetchSopCompilable, postConfirm,
postConfirmBatch, postConstraintStage, postParamsStage, postSopStage,
type ConstraintItem, type ConstraintProfile, type ScheduleParams, type SopCompilableItem,
fetchConstraintLibrary, fetchPending, fetchPolicy, fetchScheduleParams, fetchSensitivity, fetchSopCompilable,
postConfirm,
postConfirmBatch, postConstraintRuleStage, postConstraintStage, postParamsStage, postSopStage,
type ConstraintItem, type ConstraintLibrary, type ConstraintProfile, type ScheduleParams, type SopCompilableItem,
} from '../api/client';
import SkillConsole from '../skills/SkillConsole';
import KnowledgePanel from '../knowledge/KnowledgePanel';
@ -132,7 +133,10 @@ export default function GovConsole(props: {
<ScheduleParamsView sessionId={props.sessionId ?? 'web'} />
)}
{section === 'settings.constraints' && (
<ConstraintsView sessionId={props.sessionId ?? 'web'} />
<ConstraintsView
sessionId={props.sessionId ?? 'web'}
onSendCommand={props.onSendCommand}
/>
)}
{section === 'settings.integration' && <IntegrationView />}
{section === 'settings.skills' && (
@ -1052,12 +1056,41 @@ function ScheduleParamsView(props: { sessionId: string }) {
);
}
const CONSTRAINT_DESCRIPTIONS: Record<string, string> = {
C1_precedence: '前道工序完成后,后道工序才能开始。',
C2_no_overlap: '同一设备同一时间只安排一项任务。',
C3_calendar: '工单仅安排在有效班次内。',
C4_maintenance: '设备维保期间不安排生产。',
C5_capability: '任务仅分配到具备资格的产线或设备。',
C6_material_kit: '物料不齐套时,按所选级别拦截或提示风险。',
C7_capacity: '每日生产量不得超过产线可用产能。',
C8_due_date: '交期延误纳入冲突提示和绩效指标,不自动阻止发布。',
C9_customer_priority: '客户等级和订单优先级决定排产优先顺序。',
C10_changeover: '产品切换所需的换型时间计入生产计划。',
C11_freeze: '冻结期内保持已确认计划稳定,冻结期外允许滚动调整。',
C12_team: '同一班组承担的任务不超过可用人力。',
C12_tooling: '工装、模具与任务匹配,并考虑可用寿命。',
C13_sop: '由已审批作业标准生成并应用的排产规则。',
};
function constraintPlannerDescription(item: ConstraintItem): string {
return CONSTRAINT_DESCRIPTIONS[item.id] || item.description || '用于约束排产结果。';
}
// ---------------- 设置·约束配置(SC-04) ----------------
function ConstraintsView(props: { sessionId: string }) {
function ConstraintsView(props: {
sessionId: string;
onSendCommand?: (text: string) => void;
}) {
const [profile, setProfile] = useState<ConstraintProfile | null>(null);
const [draft, setDraft] = useState<Record<string, ConstraintItem>>({});
const [library, setLibrary] = useState<ConstraintLibrary | null>(null);
const [sops, setSops] = useState<SopCompilableItem[]>([]);
const [sopsError, setSopsError] = useState<string | null>(null);
const [ruleText, setRuleText] = useState('');
const [ruleCategory, setRuleCategory] = useState('other');
const [ruleScope, setRuleScope] = useState('project');
const [ruleKind, setRuleKind] = useState('soft');
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [pending, setPending] = useState<UIBlock | null>(null);
@ -1071,6 +1104,9 @@ function ConstraintsView(props: { sessionId: string }) {
setDraft(map);
})
.catch(() => setNotice('加载约束配置失败'));
void fetchConstraintLibrary()
.then(setLibrary)
.catch(() => setLibrary(null));
void fetchSopCompilable()
.then(r => {
setSops(r.items || []);
@ -1078,7 +1114,7 @@ function ConstraintsView(props: { sessionId: string }) {
})
.catch(() => {
setSops([]);
setSopsError('可编译 SOP 列表加载失败');
setSopsError('作业标准加载失败');
});
}, []);
useEffect(() => { load(); }, [load]);
@ -1091,7 +1127,7 @@ function ConstraintsView(props: { sessionId: string }) {
if (res.error) { setNotice(res.error); return; }
if (res.block) {
setPending(res.block);
setNotice(res.message ?? '已生成确认卡');
setNotice(res.message ?? '已生成待确认项');
} else setNotice(res.message ?? '已提交');
} catch (err) {
setNotice(err instanceof Error ? err.message : '提交失败');
@ -1108,15 +1144,52 @@ function ConstraintsView(props: { sessionId: string }) {
if (res.error) { setNotice(res.error); return; }
if (res.block) {
setPending(res.block);
setNotice(res.message ?? '已生成 SOP 应用确认卡');
setNotice(res.message ?? '已生成待确认项');
} else setNotice(res.message ?? '已提交');
} catch (err) {
setNotice(err instanceof Error ? err.message : 'SOP 应用失败');
setNotice(err instanceof Error ? err.message : '作业标准应用失败');
} finally {
setBusy(false);
}
};
const stageRule = async (payload: Record<string, unknown>) => {
setBusy(true);
setNotice(null);
try {
const res = await postConstraintRuleStage(props.sessionId, payload);
if (res.error) { setNotice(res.error); return; }
if (res.block) {
setPending(res.block);
setNotice(res.message ?? '已生成待确认项');
} else setNotice(res.message ?? '已提交');
} catch (err) {
setNotice(err instanceof Error ? err.message : '规则提交失败');
} finally {
setBusy(false);
}
};
const submitRuleRequirement = () => {
const content = ruleText.trim();
if (!content) {
setNotice('请填写约束内容。');
return;
}
if (!props.onSendCommand) {
setNotice('当前环境无法提交约束要求。');
return;
}
const categoryLabel = library?.categories.find(c => c.id === ruleCategory)?.label ?? '其他';
const scopeLabel = library?.scopes.find(s => s.id === ruleScope)?.label ?? '仅本项目';
const kindLabel = ruleKind === 'hard' ? '必须满足' : '尽量满足';
props.onSendCommand(
'请将以下排产要求整理为约束规则并生成待确认项:'
+ `约束内容:${content};业务类别:${categoryLabel};`
+ `适用范围:${scopeLabel};约束级别:${kindLabel}。`,
);
};
const decide = async (approve: boolean) => {
if (!pending) return;
const confirmId = String(pending.props.confirmId ?? '');
@ -1158,17 +1231,126 @@ function ConstraintsView(props: { sessionId: string }) {
return (
<>
<p className="gov-kv" style={{ marginBottom: 12 }}>
硬约束违反将阻止发布;软约束仅报告风险。内建项不可关闭。可将知识库 SOP 编译为规则包(IND-02)。
系统已预置通用约束。补充约束经确认后纳入规则库,并按业务类别与适用范围归档。
硬约束拦截不合规方案,软约束提示风险,系统内置规则不可停用。
</p>
{library && (
<div className="gov-card">
<div className="gov-card-title">约束规则库</div>
<div className="gov-kv">
通用规则 {library.summary.builtinTotal} 项 · 扩展规则 {library.summary.customTotal} 项
(已用于排产 {library.summary.engineBound} 项 / 暂未用于排产 {library.summary.recordOnly} 项)
</div>
{library.custom.length === 0 && (
<p className="gov-kv">
暂无扩展约束。可提交约束要求,系统整理并经确认后纳入规则库。
</p>
)}
<div className="constraint-form">
<div>
<div className="gov-card-title" style={{ fontSize: 12.5 }}>新增约束要求</div>
<div className="faint">描述业务要求和适用场景,系统将整理为待确认规则。</div>
</div>
<textarea
aria-label="约束要求"
value={ruleText}
maxLength={40}
rows={2}
placeholder="例如:同一产品族连续排产,减少换型次数"
onChange={e => setRuleText(e.target.value)}
/>
<div className="constraint-form-row">
<label className="constraint-form-field">
业务类别
<select value={ruleCategory} onChange={e => setRuleCategory(e.target.value)}>
{library.categories.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
<label className="constraint-form-field">
适用范围
<select value={ruleScope} onChange={e => setRuleScope(e.target.value)}>
{library.scopes.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</label>
<label className="constraint-form-field">
约束级别
<select value={ruleKind} onChange={e => setRuleKind(e.target.value)}>
<option value="soft">尽量满足</option>
<option value="hard">必须满足</option>
</select>
</label>
</div>
<div className="constraint-form-actions">
<span className="faint">批准前不影响现有计划。</span>
<button
type="button"
className="btn-primary"
disabled={busy || !!pending || !props.onSendCommand || !ruleText.trim()}
onClick={submitRuleRequirement}
>
<SendOutlined />
<span>提交约束要求</span>
</button>
</div>
</div>
{library.groups.map(g => (
<div key={g.id} style={{ marginTop: 8 }}>
<div className="faint">{g.label}</div>
{g.items.map(r => (
<div key={r.id} className="constraint-row">
<div className="constraint-main">
<strong>{r.name}</strong>
<span className="faint">
{r.scopeLabel} · {r.kindLabel} · {r.effect.enforced || r.effect.bound
? r.effect.text
: '已纳入规则库,暂未用于排产'}
{r.enabled === false ? ' · 已停用' : ''}
</span>
</div>
<div className="constraint-controls">
<label>
<input
type="checkbox"
checked={r.enabled !== false}
disabled={busy || !!pending}
onChange={e => void stageRule({
op: e.target.checked ? 'enable' : 'disable', ruleId: r.id,
})}
/>
启用
</label>
<button
type="button"
className="icon-btn danger"
disabled={busy || !!pending}
title={`删除规则 ${r.name}`}
onClick={() => void stageRule({ op: 'delete', ruleId: r.id })}
>
<DeleteOutlined />
</button>
</div>
</div>
))}
</div>
))}
</div>
)}
<div className="gov-card">
<div className="gov-card-title">SOP → 约束规则包</div>
<div className="gov-card-title">作业标准规则</div>
<p className="gov-kv" style={{ marginBottom: 4 }}>
系统可从已审批的作业标准中识别排产约束,应用前需确认。
</p>
{sopsError && <p className="gov-kv">{sopsError}</p>}
{!sopsError && sops.length === 0 && <p className="gov-kv">暂无可编译 SOP</p>}
{!sopsError && sops.length === 0 && <p className="gov-kv">暂无可应用的作业标准</p>}
{sops.map(s => (
<div key={s.assetId || s.title} className="constraint-row">
<div className="constraint-main">
<strong>{s.title}</strong>
<span className="faint">{s.summary || (s.compilable ? `${s.effectCount} 项效果` : '无可落地效果')}</span>
<span className="faint">
{s.compilable
? s.plannerSummary || `识别到 ${s.effectCount} 项可用于排产的约束`
: '当前作业标准暂未识别出可用于排产的约束'}
</span>
</div>
<div className="constraint-controls">
<button
@ -1177,10 +1359,10 @@ function ConstraintsView(props: { sessionId: string }) {
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
disabled={busy || !!pending || !s.compilable || !s.assetId}
onClick={() => s.assetId && void stageSop(s.assetId)}
title={s.assetId ? `使用 ${s.assetId} 编译并暂存` : '缺少 assetId,无法编译'}
title={s.assetId ? `应用《${s.title}》中的排产规则` : '当前作业标准暂不可应用'}
>
<ThunderboltOutlined />
<span>编译并暂存</span>
<span>应用到排产</span>
</button>
</div>
</div>
@ -1195,8 +1377,8 @@ function ConstraintsView(props: { sessionId: string }) {
return (
<div key={id} className="constraint-row">
<div className="constraint-main">
<strong>{c.code} {c.name}</strong>
<span className="faint">{c.description}</span>
<strong>{c.name}</strong>
<span className="faint">{constraintPlannerDescription(c)}</span>
</div>
<div className="constraint-controls">
<label>
@ -1219,8 +1401,8 @@ function ConstraintsView(props: { sessionId: string }) {
[id]: { ...c, kind: e.target.value as 'hard' | 'soft' },
})}
>
<option value="hard">硬约束</option>
<option value="soft">软约束</option>
<option value="hard">必须满足</option>
<option value="soft">尽量满足</option>
</select>
{!c.configurable && <span className="faint">内建</span>}
</div>
@ -1233,7 +1415,7 @@ function ConstraintsView(props: { sessionId: string }) {
<div className="order-confirm" style={{ marginBottom: 10 }}>
<div className="confirm-title">
{String(pending.props.title ?? '约束配置确认')}
<span className="power-badge">{String(pending.props.power ?? 'P2')} 需人工确认</span>
<span className="power-badge">需人工确认</span>
</div>
<ul className="confirm-summary">
{((pending.props.summary as string[]) ?? []).map((s, i) => <li key={i}>{s}</li>)}

View File

@ -1777,6 +1777,65 @@ html.aps-desktop .app-menubar-spacer,
padding: 2px 6px;
background: #fff;
}
.constraint-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.constraint-form textarea {
box-sizing: border-box;
width: 100%;
min-height: 56px;
resize: vertical;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12.5px;
line-height: 1.5;
}
.constraint-form textarea:focus {
outline: 2px solid rgba(196, 92, 38, .16);
border-color: var(--accent);
}
.constraint-form-row {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
}
.constraint-form-field {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted);
font-size: 12px;
}
.constraint-form-field select {
min-width: 112px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 6px;
background: #fff;
color: var(--text);
}
.constraint-form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.constraint-form-actions .btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
}
.gov-pre { background: #23221E; color: #C9E8B8; padding: 8px; border-radius: 8px;
font-size: 11px; overflow-x: auto; margin: 8px 0; }
.gov-chain { display: inline-block; border-radius: 999px; padding: 4px 14px; font-size: 12.5px;

View File

@ -33,6 +33,8 @@
| `param.observation.record` | P1 | 线上 KPI 观测回调(对比基线,连续劣化自动回滚) | 无需确认 | §9.8 |
| `constraint.profile.query` | P0 | 约束剖面只读 | 无需确认 | SC-04 |
| `constraint.profile.save` | P2 | 约束启停/硬软 | 变更摘要;写前自动建档 | SC-04 |
| `constraint.rule.query` | P0 | 约束规则库只读(默认通用规则 + 计划员扩充规则) | 无需确认 | SC-04 |
| `constraint.rule.save` | P2 | 计划员一句话扩充规则(新增/启用/停用/删除) | 类别/范围/强度/是否接入引擎;写前自动建档 | SC-04 |
| `governance.rule.create` | P2 | 创建规则会改变自动化执行输入 | 规则标识/房间/触发器;批准后写入并自动建档 | R71.6 |
| `governance.rule.update` | P2 | 更新规则会改变自动化执行语义 | 规则标识与 before/after;批准后写入并自动建档 | R71.6 |
| `governance.rule.delete` | P2 | 删除规则会停止后续自动化执行 | 规则标识;批准后删除并自动建档 | R71.6 |
@ -134,6 +136,7 @@
| 端点 | 用途 | 加入 |
| --- | --- | --- |
| `GET /api/world/summary·gantt·load·due` | 世界视图投影 | M1 |
| `GET /api/constraints` · `GET /api/constraints/library` | 约束剖面 / 约束规则库(默认通用规则 + 计划员扩充规则,只读) | SC-04 |
| `GET /api/timeline` | 时间线 | M2 |
| `GET /api/checkpoints/{pairId}` | 检查点世界侧投影(P0,summary+固定/柔性工单+产能池;缺失 404) | 矩阵 54 |
| `GET /api/checkpoints?pairIds=&branchIds=&sessionId=` | 分支锚定批量投影(P0,缺失项上报) | 矩阵 54 |
@ -235,6 +238,7 @@
| `POST /api/projects/{id}/files/upload` | 浏览器上传本地工程数据文件到项目目录(P1 直通) | `project.*` | AG-08 |
| `POST /api/master/stage` | 产线/物料/维保编辑生成确认卡 | `master.*` P2 → `/api/actions/confirm` 执行 | M4 首切片 |
| `POST /api/import/commit` | 导入批次生成确认卡 | `import.commit` P2 → `/api/actions/confirm` 执行 | MD-04 |
| `POST /api/constraints/rules/stage` | 计划员扩充约束规则(新增/启停/删除)生成确认卡 | `constraint.rule.save` P2 → `/api/actions/confirm` 执行 | SC-04 |
## 审批角色策略
@ -378,6 +382,7 @@
| 2026-07-23 | IND-02:`sop.compile` P0 / `sop.apply` P2 + `/api/sop*`;C13_sop |
| 2026-07-23 | 通用平台 M-A/B:`readiness.query` P0 / `flex.time.update` P2 + `/api/readiness` `/api/times*` `/api/projects` `/api/packs` |
| 2026-07-23 | 通用平台 M-D/E/F:`rag.query` P0 / `routing.template.apply` P2 / `schedule.wizard` P0 + skill 管理台端点与模板/索引端点 |
| 2026-09-17 | SC-04 扩充:`constraint.rule.query` P0 / `constraint.rule.save` P2 + `/api/constraints/library`、`/api/constraints/rules/stage`;默认通用规则保持内建目录,计划员扩充规则按类别/范围归档,未接入引擎的规则如实标注 |
## Round 65 P2/P3 排产发布与 MES 分离

View File

@ -0,0 +1,65 @@
# A0050101-00280 外壳 · 紧急插单计划员验收话术
适用对象:工厂计划员和现场验收人员。
产品口径:计划员直接用平常说的话提问;Pi 负责听懂原话、补齐下单所需信息并调用查排产的工具,真正的“能不能插、会顺延谁”由 APS 排产引擎按项目里的订单、设备、工艺、产能和日历算出来。Pi 不得自己猜结论,也不得在计划员确认前改计划。
产品:`A0050101-00280 外壳`
## 必测话术
1. `湖南工业控制有限公司追加 50 套 A0050101-00280 外壳,10月14日交货,先评估紧急插单影响,不要直接改计划。`
期望:系统先做一次试算,列出这张急单能安排到哪里、会顺延哪些订单、每张大概顺延多久;明确说明“当前计划没有改动”。
2. `检查 SO-001 这张单现在能不能加急到 50 套、10月14日交货,并告诉我会影响哪些订单。`
期望:系统按订单号找到真实订单,再按真实产能评估;如果订单号、数量或交期与系统记录不一致,要先说清楚差异,不能直接改单。
3. `如果不能直接插,请说明是设备、产能、物料还是工作日历影响,并给我一个可行的调整方案。`
期望:系统说清是局部调整还是需要重新编排整版计划;原因必须来自本次试算结果。
4. `这张 50 套外壳插进去以后,具体会顺延哪些订单?分别顺延多少小时?`
期望:列出受影响的真实订单号、客户、原计划完工日期和调整后预计日期;不能只说“有一点影响”。
5. `采用刚才的插单方案,但先给我确认。`
期望:系统只生成一张待确认事项,说明会加入哪张急单、会影响哪些订单;确认前订单池和已发布计划不变。
6. `不采用这次插单,保持原计划不变。`
期望:系统不新增急单,不生成新排产版本,原计划保持不动。
7. `采用后告诉我生成的是待发布草稿,还是已经正式发布。`
期望:系统明确说是“待发布的调整方案”,不能把试算或草稿说成已经发布、已经下发车间。
8. `这个产品没有真实订单、设备、工艺或产能证据时,不要告诉我可以插单。`
期望:系统如实说明缺哪类资料、无法判断或不能直接插,不得用默认数量、默认交期、演示数据或硬编码结论补成“可以”。
9. `把 A0050101-00280 外壳临时加一批,先看看能不能插进去。`
期望:如果计划员没有给数量或交期,系统先追问数量和最晚交货日期,不能自己填默认值后做结论。
10. `把不存在的订单 SO-NOT-EXIST 加急,10月14日交货。`
期望:系统明确回复订单不存在,不能编造一张订单,也不能说已经安排。
## 每轮必须核对
- 页面上的备选项和步骤连续显示 `1、2、3`,不能每一条都显示 `1.`。
- 回答中用计划员能看懂的中文,不出现 `P1`、`P2`、`LNS`、`DRAFT`、`evalId`、`APPROVED`、`isRush`、`沙盒` 等内部说法。
- 系统说“能插”或“不能插”时,本轮必须已经查过真实排产数据;没有查数据就不能下结论。
- “采用”只是待确认,确认前订单池、已发布计划和车间执行都不能变化。
- 确认采用后只生成待发布的调整方案;是否发布、是否下发车间要另外处理。
- 如果项目缺少订单、产品、工艺、设备、产能或工作日历证据,页面必须显示缺少什么,不能隐藏问题后给出成功结论。
## 自动化对应
- 计划员原话通过 Pi 调用真实插单评估:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_natural_language_uses_engine_and_ignores_pi_guess`
- 缺少数量或交期先追问:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_missing_quantity_asks_instead_of_guessing`
- 不存在的订单按引擎结果拒绝:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_unknown_order_returns_engine_rejection`
- 采用前必须人工确认:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_apply_waits_for_confirmation`
- 多个备选项连续编号:`apps/web/e2e/chat-numbering.spec.ts`
本地运行:
```powershell
.\.venv\Scripts\pytest.exe -q tests/golden/test_planner_rush_natural_language.py tests/golden/test_rush.py tests/golden/test_rush_lns.py
npm run test:e2e --prefix apps/web -- chat-numbering.spec.ts
```
自动化测试使用仓库自带的锐扬演示资料和固定时钟,只验证链路与真实计算口径,不代替客户项目数据验收。现场验收应在客户自己的项目、订单版本和设备日历上按上面十条话术逐条执行。

View File

@ -886,6 +886,44 @@ _PRIMARY_TOOL_PARAM_SCHEMAS: dict[str, dict[str, Any]] = {
examples=[{}, {"path": "D:/data/orders.xlsx"}],
),
"readiness.query": _EMPTY_PARAMS_SCHEMA,
"rush.evaluate": _obj_schema(
{"orderNo": {"type": "string"},
"customerName": {"type": "string"},
"customerLevel": {"type": "string"},
"productId": {"type": "integer", "minimum": 1},
"productCode": {"type": "string"},
"productName": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1},
"deliveryDate": {"type": "string"},
"priority": {"type": "integer", "minimum": 1, "maximum": 9},
"strategy": {"type": "string",
"enum": ["DELIVERY_FIRST", "CAPACITY_BALANCE", "COMPREHENSIVE"]}},
examples=[
{"customerName": "湖南工业控制有限公司", "productCode": "A0050101-00280",
"quantity": 50, "deliveryDate": "2026-10-14", "strategy": "DELIVERY_FIRST"},
{"orderNo": "SO-001", "quantity": 50, "deliveryDate": "2026-10-14"},
],
),
"rush.apply": _EMPTY_PARAMS_SCHEMA,
"constraint.rule.query": _EMPTY_PARAMS_SCHEMA,
"constraint.rule.save": _obj_schema(
{"op": {"type": "string", "enum": ["add", "enable", "disable", "delete"]},
"name": {"type": "string"},
"category": {"type": "string",
"enum": ["process", "capacity", "calendar", "equipment", "material",
"order", "personnel", "tooling", "other"]},
"scope": {"type": "string", "enum": ["project", "tenant"]},
"kind": {"type": "string", "enum": ["hard", "soft"]},
"boundConstraintId": {"type": "string"},
"ruleId": {"type": "string"},
"sourceText": {"type": "string"}},
required=["op"],
examples=[
{"op": "add", "name": "夜班不安排换型", "category": "process",
"scope": "project", "kind": "soft"},
{"op": "disable", "ruleId": "R-001"},
],
),
"folder.schedule": _obj_schema(
{"sortMode": {"type": "string"},
"window": {"type": "string", "enum": ["short", "mid", "long", "full"]},
@ -1125,6 +1163,10 @@ _PRIMARY_PLANNING_INSPECTION_TOOLS = frozenset({
"flex.schedule",
})
# These tools return an engine-derived business conclusion. Pi may explain the
# result, but must not replace it with a guess about feasibility or impact.
_PRIMARY_ENGINE_AUTHORITATIVE_TOOLS = frozenset({"rush.evaluate"})
def _planning_reply_is_unready(reply) -> bool:
for block in reply.blocks:
@ -1160,6 +1202,20 @@ def _finalize_primary_business_reply(
tool_results: list[tuple[str, Any]],
):
"""Keep unready planning conclusions authoritative over Pi-generated prose."""
engine_results = [
(name, reply) for name, reply in tool_results
if name in _PRIMARY_ENGINE_AUTHORITATIVE_TOOLS
]
if engine_results:
authoritative = engine_results[-1][1]
other_tool_replies = [
reply for name, reply in tool_results
if reply is not authoritative
]
return _productize_primary_reply(
_merge_primary_tool_replies(authoritative, other_tool_replies)
)
planning_results = [
(name, reply) for name, reply in tool_results
if name in _PRIMARY_PLANNING_INSPECTION_TOOLS

View File

@ -258,6 +258,8 @@ _POWER_MAP: dict[str, str] = {
"param.observation.record": "P1", # 矩阵 87:线上观测回调:观测落盘 + 连续劣化自动回滚
"constraint.profile.query": "P0", # SC-04:约束剖面只读
"constraint.profile.save": "P2", # SC-04:约束剖面保存
"constraint.rule.query": "P0", # SC-04:约束规则库只读
"constraint.rule.save": "P2", # SC-04:约束规则库扩充(新增/启停/删除)
"governance.rule.create": "P2", # 治理规则创建:确认后写规则注册表
"governance.rule.update": "P2", # 治理规则更新:确认后替换规则定义
"governance.rule.delete": "P2", # 治理规则删除:确认后移除规则定义
@ -391,6 +393,8 @@ _POLICY_DESC: dict[str, str] = {
"param.observation.record": "线上观测回调:生产 KPI 采样落盘,连续劣化自动回滚实验",
"constraint.profile.query": "约束配置查询:只读硬/软约束剖面",
"constraint.profile.save": "约束配置保存:启停/硬软切换(只影响新版本与发布门禁)",
"constraint.rule.query": "约束规则库查询:默认通用规则与计划员扩充规则概览",
"constraint.rule.save": "约束规则库扩充:把计划员一句话登记为可复用规则(新增/启用/停用/删除,需确认)",
"governance.rule.create": "创建治理规则:确认后写规则注册表",
"governance.rule.update": "更新治理规则:确认后替换规则定义",
"governance.rule.delete": "删除治理规则:确认后移除规则定义",

View File

@ -0,0 +1,352 @@
# ============================================================
# 计划员约束规则库(moduleId: domain-constraint-rules, 可重生 ✅)
# 默认通用规则 = constraints.py 的 C1..C13 内建目录(只读基线,本模块不改它)
# 本模块只管理「计划员用自然语言提出、经 P2 确认后扩充的规则」:
# * 按类别归档(沿用 GROUP_LABELS 的 8 类 + 其他)
# * 按范围归档(仅本项目 / 全厂)
# * 没有接入排产引擎的规则如实标注,不假装生效
# ============================================================
from __future__ import annotations
import json
import os
import re
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any
from server.aps_domain.constraints import (
DEFAULT_CONSTRAINT_DEFS,
GROUP_LABELS,
get_constraint,
get_constraint_profile,
)
World = dict[str, Any]
RULE_CATEGORIES: dict[str, str] = {**GROUP_LABELS, "other": "其他"}
RULE_SCOPES: dict[str, str] = {"project": "仅本项目", "tenant": "全厂"}
RULE_KINDS: dict[str, str] = {"hard": "必须满足", "soft": "尽量满足"}
RULE_OPS: tuple[str, ...] = ("add", "enable", "disable", "delete")
_ID_RE = re.compile(r"^R-(\d+)$")
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def _safe_tenant(tenant_uuid: str | None) -> str:
raw = (tenant_uuid or "platform").strip() or "platform"
return re.sub(r"[^\w\-]+", "_", raw)[:64] or "platform"
def tenant_rules_path(tenant_uuid: str | None) -> Path:
"""全厂规则落盘位置:与 world.json 同一数据根(尊重 APS_HOME / APS_DATA_DIR)。"""
from server.aps_home import data_dir
tenant = _safe_tenant(tenant_uuid)
if tenant == "platform":
return data_dir() / "constraint-rules.json"
return data_dir() / "tenants" / tenant / "constraint-rules.json"
def load_tenant_rules(tenant_uuid: str | None) -> list[dict[str, Any]]:
"""读取全厂规则(文件不存在或损坏时返回空列表,不抛错)。"""
path = tenant_rules_path(tenant_uuid)
if not path.exists():
return []
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
rules = raw.get("rules") if isinstance(raw, dict) else raw
if not isinstance(rules, list):
return []
return [r for r in rules if isinstance(r, dict)]
def save_tenant_rules(tenant_uuid: str | None, rules: list[dict[str, Any]]) -> str:
"""原子写入全厂规则文件(tmp + os.replace,与 WorldStore 同一写盘策略)。"""
path = tenant_rules_path(tenant_uuid)
path.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(
{"version": 1, "scope": "tenant", "rules": rules},
ensure_ascii=False, indent=2,
)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".constraint-rules-", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(body + "\n")
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return str(path)
def project_rules(world: World) -> list[dict[str, Any]]:
raw = world.get("constraintRules")
if not isinstance(raw, list):
return []
return [r for r in raw if isinstance(r, dict)]
def all_rules(world: World, tenant_uuid: str | None = None) -> list[dict[str, Any]]:
"""全厂规则 + 本项目规则,统一补 scope 字段(缺省按所属容器判定)。"""
out: list[dict[str, Any]] = []
for r in load_tenant_rules(tenant_uuid):
out.append({**r, "scope": r.get("scope") or "tenant"})
for r in project_rules(world):
out.append({**r, "scope": r.get("scope") or "project"})
return out
def next_rule_id(rules: list[dict[str, Any]]) -> str:
top = 0
for r in rules:
m = _ID_RE.match(str(r.get("id") or ""))
if m:
top = max(top, int(m.group(1)))
return f"R-{top + 1:03d}"
def rule_effect(world: World, rule: dict[str, Any]) -> dict[str, Any]:
"""如实说明这条规则当前靠谁执行;没有接入引擎就直说。"""
bound = str(rule.get("boundConstraintId") or "").strip()
if not bound:
return {"bound": False, "enforced": False, "text": "已记录,暂未接入排产引擎"}
c = get_constraint(world, bound)
if not c:
return {"bound": True, "enforced": False,
"text": f"绑定的内置规则 {bound} 不在当前约束目录,暂未执行"}
state = "已启用" if c.get("enabled", True) else "已关闭"
kind_label = "硬约束" if c.get("kind") == "hard" else "软约束"
enforced = bool(c.get("enabled", True))
if not enforced:
return {"bound": True, "enforced": False,
"text": f"内置规则《{c['name']}》当前已关闭,本条暂不生效"}
return {"bound": True, "enforced": True,
"text": f"由内置规则《{c['name']}》执行({state}/{kind_label})"}
def _default_ids() -> set[str]:
return {str(d["id"]) for d in DEFAULT_CONSTRAINT_DEFS}
def normalize_rule_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""校验规则库载荷;只接受计划员语言字段,不接受 JSON 片段。"""
raw = payload if isinstance(payload, dict) else {}
op = str(raw.get("op") or "add").strip().lower() or "add"
if op not in RULE_OPS:
raise ValueError("规则操作只能是 新增/启用/停用/删除")
if op != "add":
rule_id = str(raw.get("ruleId") or raw.get("id") or "").strip()
if not rule_id:
raise ValueError("请指定要操作的规则")
return {"op": op, "ruleId": rule_id}
name = str(raw.get("name") or raw.get("text") or "").strip()
if not name:
raise ValueError("规则内容不能为空,请用一句话描述")
if len(name) > 40:
raise ValueError("规则内容请控制在 40 字以内,说清「什么时候、什么不能排」即可")
category = str(raw.get("category") or "other").strip().lower() or "other"
if category not in RULE_CATEGORIES:
raise ValueError(f"类别只能是:{'、'.join(RULE_CATEGORIES.values())}")
scope = str(raw.get("scope") or "project").strip().lower() or "project"
if scope not in RULE_SCOPES:
raise ValueError("适用范围只能是:仅本项目 / 全厂")
kind = str(raw.get("kind") or "soft").strip().lower() or "soft"
if kind not in RULE_KINDS:
raise ValueError("强度只能是:必须满足 / 尽量满足")
bound = str(raw.get("boundConstraintId") or "").strip()
if bound and bound not in _default_ids():
raise ValueError(f"绑定的内置规则 {bound} 不存在,请留空或改用现有内置规则")
out: dict[str, Any] = {
"op": "add", "name": name, "category": category, "scope": scope, "kind": kind,
}
if bound:
out["boundConstraintId"] = bound
source = str(raw.get("sourceText") or "").strip()
if source:
out["sourceText"] = source[:200]
return out
def _bound_apply(world: World, norm: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
"""新增规则绑定内置可配项时,把强度真正落到排产引擎;返回 (补丁, 说明)。"""
bound = str(norm.get("boundConstraintId") or "")
if not bound:
return None, None
c = get_constraint(world, bound)
if not c or not c.get("kindConfigurable"):
return None, None
target = "hard" if norm.get("kind") == "hard" else "soft"
if c.get("kind") == target:
return None, None
label = "硬约束(违反阻止发布)" if target == "hard" else "软约束(只提示风险)"
return {"constraints": {bound: {"kind": target}}}, f"同时把内置规则《{c['name']}》调整为{label}"
def confirmation_for_rule_save(world: World, payload: dict[str, Any],
tenant_uuid: str | None = None) -> tuple[str, list[str]]:
"""P2 确认卡文案:计划员看得懂,写清范围、强度和是否真的接入引擎。"""
norm = normalize_rule_payload(payload)
if norm["op"] == "add":
title = f"登记约束规则:{norm['name']}"
lines = [
f"类别:{RULE_CATEGORIES[norm['category']]};适用范围:{RULE_SCOPES[norm['scope']]}",
f"强度:{RULE_KINDS[norm['kind']]}",
]
patch, note = _bound_apply(world, norm)
if patch:
lines.append(f"接入排产:{note}")
elif norm.get("boundConstraintId"):
c = get_constraint(world, str(norm["boundConstraintId"]))
lines.append(f"接入排产:由内置规则《{(c or {}).get('name') or norm['boundConstraintId']}》执行")
else:
lines.append("接入排产:暂未接入排产引擎,先进入规则库备查")
lines.append("确认后写入规则库并记审计;不影响历史版本")
return title, lines
rules = {str(r.get("id")): r for r in all_rules(world, tenant_uuid)}
rule = rules.get(norm["ruleId"])
if not rule:
raise ValueError(f"规则库中没有 {norm['ruleId']} 这条规则")
name = str(rule.get("name") or norm["ruleId"])
effect = rule_effect(world, rule)["text"]
if norm["op"] == "delete":
return f"删除约束规则:{name}", [
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')})",
f"当前执行:{effect}",
"删除后不再出现在规则库;不影响历史版本与已发布计划",
]
label = "启用" if norm["op"] == "enable" else "停用"
lines = [
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')}/"
f"{RULE_SCOPES.get(str(rule.get('scope')), '仅本项目')})",
f"当前执行:{effect}",
"该操作只调整规则库里的状态,不改动内置约束的开关",
]
return f"{label}约束规则:{name}", lines
def _locate(world: World, tenant_uuid: str | None,
rule_id: str) -> tuple[dict[str, Any] | None, str | None]:
for r in load_tenant_rules(tenant_uuid):
if str(r.get("id")) == rule_id:
return r, "tenant"
for r in project_rules(world):
if str(r.get("id")) == rule_id:
return r, "project"
return None, None
def _write_container(world: World, tenant_uuid: str | None, where: str,
rules: list[dict[str, Any]]) -> None:
if where == "tenant":
save_tenant_rules(tenant_uuid, rules)
else:
world["constraintRules"] = rules
def apply_rule_save(world: World, payload: dict[str, Any],
tenant_uuid: str | None = None) -> dict[str, Any]:
"""落库:新增/启用/停用/删除计划员扩充规则(调用方负责审计与 save)。"""
norm = normalize_rule_payload(payload)
if norm["op"] == "add":
existing = all_rules(world, tenant_uuid)
rule_id = next_rule_id(existing)
rec: dict[str, Any] = {
"id": rule_id,
"name": norm["name"],
"category": norm["category"],
"scope": norm["scope"],
"kind": norm["kind"],
"enabled": True,
"createdAt": _now(),
"updatedAt": _now(),
}
if norm.get("boundConstraintId"):
rec["boundConstraintId"] = norm["boundConstraintId"]
if norm.get("sourceText"):
rec["source"] = {"type": "PLANNER_CHAT", "text": norm["sourceText"]}
patch, note = _bound_apply(world, norm)
if patch:
from server.aps_domain.constraints import apply_profile_save
apply_profile_save(world, patch)
if norm["scope"] == "tenant":
save_tenant_rules(tenant_uuid, load_tenant_rules(tenant_uuid) + [rec])
else:
world["constraintRules"] = project_rules(world) + [rec]
effect = rule_effect(world, rec)["text"]
message = (f"约束规则已登记 ✅ {rule_id} {rec['name']}"
f"({RULE_CATEGORIES[norm['category']]}/{RULE_SCOPES[norm['scope']]})。"
f"\n执行情况:{effect}。" + (f"\n{note}。" if note else ""))
return {"ruleId": rule_id, "op": "add", "scope": norm["scope"],
"rule": rec, "message": message}
rule_id = norm["ruleId"]
rule, where = _locate(world, tenant_uuid, rule_id)
if not rule or not where:
raise ValueError(f"规则库中没有 {rule_id} 这条规则")
container = load_tenant_rules(tenant_uuid) if where == "tenant" else project_rules(world)
name = str(rule.get("name") or rule_id)
if norm["op"] == "delete":
kept = [r for r in container if str(r.get("id")) != rule_id]
_write_container(world, tenant_uuid, where, kept)
return {"ruleId": rule_id, "op": "delete", "scope": where,
"message": f"约束规则已删除 ✅ {rule_id} {name}。"}
enabled = norm["op"] == "enable"
updated = []
for r in container:
if str(r.get("id")) == rule_id:
updated.append({**r, "enabled": enabled, "updatedAt": _now()})
else:
updated.append(r)
_write_container(world, tenant_uuid, where, updated)
label = "启用" if enabled else "停用"
return {"ruleId": rule_id, "op": norm["op"], "scope": where,
"message": f"约束规则已{label} ✅ {rule_id} {name}。"}
def constraint_library(world: World, tenant_uuid: str | None = None) -> dict[str, Any]:
"""设置页数据:默认通用规则概览 + 计划员扩充规则(按类别分组)。"""
builtin = get_constraint_profile(world)["constraints"]
custom: list[dict[str, Any]] = []
for r in all_rules(world, tenant_uuid):
effect = rule_effect(world, r)
category = str(r.get("category") or "other")
custom.append({
**r,
"category": category,
"categoryLabel": RULE_CATEGORIES.get(category, "其他"),
"scopeLabel": RULE_SCOPES.get(str(r.get("scope")), "仅本项目"),
"kindLabel": RULE_KINDS.get(str(r.get("kind")), "尽量满足"),
"effect": effect,
})
groups = []
for gid, label in RULE_CATEGORIES.items():
items = [r for r in custom if r["category"] == gid]
if items:
groups.append({"id": gid, "label": label, "items": items})
return {
"builtin": {
"total": len(builtin),
"enabled": sum(1 for c in builtin if c.get("enabled", True)),
"configurable": sum(1 for c in builtin if c.get("configurable")),
},
"custom": custom,
"groups": groups,
"categories": [{"id": k, "label": v} for k, v in RULE_CATEGORIES.items()],
"scopes": [{"id": k, "label": v} for k, v in RULE_SCOPES.items()],
"summary": {
"builtinTotal": len(builtin),
"customTotal": len(custom),
"engineBound": sum(1 for r in custom if r["effect"]["enforced"]),
"recordOnly": sum(1 for r in custom if not r["effect"]["enforced"]),
},
}

View File

@ -522,7 +522,7 @@ def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str,
if k not in ("id", "versionNo", "versionName", "createdAt", "createdBy",
"publishedAt", "parentVersionId")},
"id": new_vid, "versionNo": ver_no,
"versionName": f"LNS 插单局部修复 · {rush.get('orderNo')}",
"versionName": f"急单局部调整 · {rush.get('orderNo')}",
"triggerType": "LNS_RUSH",
"status": "DRAFT", "parentVersionId": old_vid,
"orderCount": len(world.get("salesOrders", [])),
@ -532,8 +532,8 @@ def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str,
"totalTardiness": total_tardiness,
"conflictCount": overlap,
"createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None,
"note": (f"LNS 局部修复:移动 {applied_moves} 单 · 位移 {lns.get('disturbance', {}).get('shiftedHours', 0)}h · "
f"急单 {rush.get('orderNo')}(窗口 {win.get('start')}~{win.get('end')})"),
"note": (f"急单局部调整:{applied_moves} 张订单顺延,合计 {lns.get('disturbance', {}).get('shiftedHours', 0)} 小时;"
f"急单 {rush.get('orderNo')},可调整范围 {win.get('start')} 至 {win.get('end')}。"),
"lns": {
"lnsId": lns.get("lnsId"),
"status": lns.get("status"),
@ -556,52 +556,82 @@ def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str,
def format_lns_text(lns: dict[str, Any]) -> str:
"""LNS 评估结果中文摘要(对话侧)。"""
"""插单安排评估的计划员摘要(对话侧)。"""
rush = lns.get("rushOrder") or {}
win = lns.get("window") or {}
line_label = win.get("lineName") or win.get("lineId") or "可安排产线"
lines = [
f"LNS 插单局部修复(沙盒,未改主干)· 策略 {lns.get('strategy')}",
"插单安排评估完成。本次只做试算,现有计划没有改动。",
f"急单:{rush.get('customerName') or rush.get('orderNo')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}",
f"时间窗:{win.get('start')} ~ {win.get('end')}(产线 {win.get('lineName') or win.get('lineId')} · 上限 {win.get('maxAffectedOrders')} 单 · 容忍 {win.get('disturbanceTolerance'):.0%})"
if win else "时间窗:默认",
f"可调整范围:{win.get('start')} 至 {win.get('end')},优先安排到 {line_label}。"
if win else "可调整范围:按当前生产安排。",
]
if lns.get("status") == "LOCAL":
d = lns.get("disturbance") or {}
plan = lns.get("plan") or {}
before_due = "比交期提前" if (d.get("rushEndGapHours") or 0) <= 0 else "比交期晚"
lines += [
(f"方案:{plan.get('rushStart')} → {plan.get('rushEnd')}({'提前' if d.get('rushEndGapHours') is not None and d['rushEndGapHours'] <= 0 else '超期'} "
f"{abs(d.get('rushEndGapHours') or 0):.1f}h)"),
f"扰动:移动 {d.get('movedOrderCount')} 单 · 位移 {d.get('shiftedHours')}h · 延迟变化 {d.get('tardinessDelta'):+.1f}h · 窗口外 {d.get('windowOuterMovedCount')} 单",
f"可安排时间:{plan.get('rushStart')} 至 {plan.get('rushEnd')},{before_due} "
f"{abs(d.get('rushEndGapHours') or 0):.1f} 小时。",
f"调整后:{d.get('movedOrderCount')} 张订单需要顺延,合计顺延 {d.get('shiftedHours')} 小时,"
f"总延期变化 {d.get('tardinessDelta'):+.1f} 小时;调整范围外的订单保持不变。",
]
if lns.get("chainRisk", {}).get("flag"):
lines.append(f"提示:{lns['chainRisk']['reason']}")
lines.append("确认后说「采用插单」(LNS 局部方案入 DRAFT 试排草稿,P2 写前自动建档)。")
lines.append("确认后,我会生成一版待发布的调整方案;现有计划不会直接改变。")
else:
reasons = lns.get("escalateReasons") or []
lines.append("升级全量重排 ⚠️ " + ";".join(reasons))
lines.append("确认后说「采用插单」将执行全量重排并生成 DRAFT 版本(P2 人工确认)。")
lines.append(
"这张急单不能只调整少数订单,需要重新编排整版计划。"
+ ("原因:" + ";".join(_planner_lns_reasons(reasons)) if reasons else "")
)
lines.append("确认后,我会生成一版待发布的整版调整方案;现有计划不会直接改变。")
return "\n".join(lines)
def _planner_lns_reasons(reasons: list[str]) -> list[str]:
"""把求解器阈值原因翻成计划员能判断的影响描述。"""
out: list[str] = []
for reason in reasons:
text = str(reason or "").strip()
if not text:
continue
if text.startswith("受影响订单"):
out.append("需要顺延的订单已经超过可接受范围")
elif text.startswith("扰动率"):
out.append("局部调整会打乱太多现有订单")
elif "无可用产线" in text or "无法物化" in text:
out.append("按当前设备、工艺和日历,找不到能安排这张急单的生产线或时间段")
elif "超过窗口宽度" in text:
out.append("这张急单需要的生产时间超过可局部调整的时间范围")
elif "无可行插入点" in text:
out.append("在不对其他订单造成过大影响的前提下,找不到合适的生产时段")
else:
out.append(text)
return out
def lns_to_block(lns: dict[str, Any]) -> UIBlock:
"""对话侧 LNS 结果块(复用 rush 卡片样式)。"""
"""对话侧插单安排结果块(复用 rush 卡片样式)。"""
rush = lns.get("rushOrder") or {}
d = lns.get("disturbance") or {}
status = lns.get("status")
if status == "LOCAL":
plan = lns.get("plan") or {}
title = "LNS 插单局部修复(窗口内最小扰动)"
title = "插单安排评估"
text = (
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
f"落点:{plan.get('rushStart')} → {plan.get('rushEnd')}({plan.get('lineName') or plan.get('lineId')})\n"
f"扰动:移动 {d.get('movedOrderCount')} 单 · 位移 {d.get('shiftedHours')}h · 延迟 {d.get('tardinessDelta'):+.1f}h\n"
f"窗口外受影响:{d.get('windowOuterMovedCount')} 单(冻结 ✅)"
f"调整后:{d.get('movedOrderCount')} 张订单需要顺延,合计顺延 {d.get('shiftedHours')} 小时,"
f"总延期变化 {d.get('tardinessDelta'):+.1f} 小时\n"
"调整范围外的订单保持不变;确认后只生成待发布的调整方案。"
)
else:
title = "LNS 升级全量重排"
title = "插单需要整版调整"
reasons = _planner_lns_reasons(lns.get("escalateReasons") or [])
text = (f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
f"升级原因:{';'.join(lns.get('escalateReasons') or ['影响半径超过阈值'])}\n"
"确认后将执行全量重排并生成 DRAFT 版本(P2)。")
f"需要整版调整的原因:{';'.join(reasons or ['按当前生产安排无法只调整少数订单'])}\n"
"确认后只生成一版待发布的整版调整方案,现有计划不会直接改变。")
return UIBlock(
blockId=f"lns-{lns.get('lnsId')}",
type="text",

View File

@ -261,12 +261,12 @@ def impact_to_block(impact: dict[str, Any]) -> UIBlock:
props={
"kind": "rush-impact",
"impact": impact,
"title": "插单影响快评",
"title": "紧急插单影响",
"text": (
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
f"受影响订单 {impact['affectedOrderCount']} 条 · 延迟变化 {impact['delayDelta']:+}h · 冲突变化 {impact['conflictDelta']:+}\n"
f"策略:{impact['strategy']}(沙盒,未改主干)\n"
"确认后请说「采用插单」。"
f"预计影响 {impact['affectedOrderCount']} 张订单;调整后总延期 "
f"{impact['delayDelta']:+g} 小时,冲突变化 {impact['conflictDelta']:+d} 项。\n"
"这只是试算,现有计划没有改动;你确认后才生成一版待发布的调整方案。"
),
},
actions=[],
@ -276,18 +276,27 @@ def impact_to_block(impact: dict[str, Any]) -> UIBlock:
def format_impact_text(impact: dict[str, Any]) -> str:
rush = impact["rushOrder"]
lines = [
f"插单快评完成(沙盒,未改主干)· 策略 {_strategy_cn(impact['strategy'])}",
"插单影响评估完成。本次只做试算,现有计划没有改动。",
f"急单:{rush.get('customerName')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}"
+ (f" · 预计完工 {rush.get('promisedEnd')}" if rush.get("promisedEnd") else ""),
f"影响:受波及订单 {impact['affectedOrderCount']} 条 · 总延迟 {impact['delayDelta']:+}h · 冲突 {impact['conflictDelta']:+}",
f"基准 → 插单后:订单 {impact['baseline']['orderCount']}→{impact['after']['orderCount']} · "
f"延迟 {impact['baseline']['totalTardiness']}→{impact['after']['totalTardiness']}h · "
f"冲突 {impact['baseline']['conflictCount']}→{impact['after']['conflictCount']}",
f"影响:预计涉及 {impact['affectedOrderCount']} 张订单;总延期变化 "
f"{impact['delayDelta']:+g} 小时,冲突变化 {impact['conflictDelta']:+d} 项。",
f"调整前 → 调整后:订单 {impact['baseline']['orderCount']}→{impact['after']['orderCount']} 张 · "
f"总延期 {impact['baseline']['totalTardiness']}→{impact['after']['totalTardiness']} 小时 · "
f"冲突 {impact['baseline']['conflictCount']}→{impact['after']['conflictCount']} 项。",
]
if impact["affectedOrders"]:
sample = "、".join(a["orderNo"] for a in impact["affectedOrders"][:5])
lines.append(f"延期样例:{sample}" + ("…" if len(impact["affectedOrders"]) > 5 else ""))
lines.append("确认无误后说「采用插单」或点「采用此插单」(P2,写前自动建档)。")
lines.append("可能受影响的订单:")
for item in impact["affectedOrders"][:5]:
lines.append(
f"· {item['orderNo']}({item['customerName']}):原计划 {item['beforeEnd']},"
f"调整后预计 {item['afterEnd']}"
)
if len(impact["affectedOrders"]) > 5:
lines.append(f"· 其余 {len(impact['affectedOrders']) - 5} 张订单也会有排期变化。")
else:
lines.append("按当前订单、设备、工艺和日历数据,没有发现其他订单被顺延。")
lines.append("如果确认采用,我会先生成一版待发布的调整方案,不会直接改车间执行。")
return "\n".join(lines)

View File

@ -39,6 +39,7 @@ def list_compilable_sops(kb_assets: list[dict[str, Any]]) -> list[dict[str, Any]
"effectCount": len(pack.get("effects") or []),
"compilable": bool(pack.get("effects")),
"summary": pack.get("summary") or "",
"plannerSummary": pack.get("plannerSummary") or "",
})
return out
@ -109,7 +110,7 @@ def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
})
if not effects:
warnings.append("未识别可落地的约束模板;可扩展编译器或改用约束配置中心手工调整")
warnings.append("当前作业标准未识别出可用于排产的约束,可在约束规则库中补充要求。")
# C13 标记:有可应用效果时启用
if any(e.get("op") in ("constraint.set", "changeover.policy") for e in effects):
@ -126,15 +127,37 @@ def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
})
summary_bits = []
planner_bits: list[str] = []
for e in effects:
if e["op"] == "constraint.set":
summary_bits.append(f"{e['id']}→{'开' if e.get('enabled') else '关'}/{e.get('kind', '')}")
constraint_id = str(e.get("id") or "")
if constraint_id == "C10_changeover":
planner_bits.append("将换型顺序纳入排产控制")
elif constraint_id == "C6_material_kit":
planner_bits.append("物料不齐套时提示风险,不直接阻断排产草案")
elif constraint_id != "C13_sop":
planner_bits.append("将作业标准中的约束条件纳入排产")
elif e["op"] == "changeover.policy":
summary_bits.append(f"跨族换型默认{e.get('defaultCrossFamilyMin')}分")
policy_bits: list[str] = []
cross_min = e.get("defaultCrossFamilyMin")
standard_min = e.get("standardSetupMin")
if cross_min is not None:
policy_bits.append(f"跨产品族换型预留 {float(cross_min):.0f} 分钟")
if standard_min is not None:
policy_bits.append(f"常规换型预留 {float(standard_min):.0f} 分钟")
if e.get("noChangeoverAfter"):
policy_bits.append(f"{e['noChangeoverAfter']} 后不安排换型")
planner_bits.append(";".join(policy_bits) if policy_bits else "按作业标准控制换型时间")
elif e["op"] == "strategy.suggest":
summary_bits.append(f"建议策略{e.get('strategy')}")
planner_bits.append("优先连续生产同一产品族,减少换型次数")
elif e["op"] == "process.note":
summary_bits.append("流程提示")
planner_bits.append("急单插单先进行影响评估,并经主管确认后执行")
planner_bits = list(dict.fromkeys(planner_bits))
return {
"packId": f"pack-{asset.get('assetId') or uuid.uuid4().hex[:8]}",
@ -147,6 +170,8 @@ def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
},
"effects": effects,
"summary": ";".join(summary_bits) if summary_bits else "无可应用效果",
"plannerSummary": (";".join(planner_bits) if planner_bits
else "当前作业标准暂未识别出可用于排产的约束"),
"warnings": warnings,
}
@ -193,17 +218,15 @@ def compile_sop_by_query(kb_assets: list[dict[str, Any]], query: str | None = No
def confirmation_for_sop_apply(pack: dict[str, Any]) -> tuple[str, list[str]]:
title = f"应用 SOP 规则包:{pack.get('title') or pack.get('packId')}"
lines = [pack.get("summary") or "应用编译效果"]
title = f"应用作业标准:{pack.get('title') or pack.get('packId')}"
summary = str(pack.get("plannerSummary") or "当前作业标准暂未识别出可用于排产的约束")
lines = [bit.strip() for bit in summary.split(";") if bit.strip()]
src = (pack.get("source") or {}).get("title")
if src:
lines.append(f"出处:{src}")
for e in pack.get("effects") or []:
if e.get("rationale"):
lines.append(f"· {e['op']}: {e['rationale']}")
lines.append("P2 写主干;执行前自动建档")
lines.append("批准后应用到后续排产,历史版本保持不变。")
for w in pack.get("warnings") or []:
lines.append(f"⚠ {w}")
lines.append(f"提示:{w}")
return title, lines
@ -291,7 +314,7 @@ def sop_as_block(pack: dict[str, Any]) -> Any:
blockId=f"sop-pack-{uuid.uuid4().hex[:8]}",
type="report",
props={
"title": f"SOP 编译:{pack.get('title')}",
"title": f"作业标准规则:{pack.get('title')}",
"reportType": "sop-rule-pack",
"markdown": _pack_markdown(pack),
"pack": pack,
@ -300,20 +323,20 @@ def sop_as_block(pack: dict[str, Any]) -> Any:
def _pack_markdown(pack: dict[str, Any]) -> str:
summary = str(pack.get("plannerSummary") or "当前作业标准暂未识别出可用于排产的约束")
lines = [
f"# {pack.get('title')}",
f"# 作业标准:{pack.get('title')}",
"",
f"**摘要**:{pack.get('summary')}",
"",
"## 效果",
"## 排产规则",
]
for e in pack.get("effects") or []:
lines.append(f"- `{e.get('op')}` — {e.get('rationale') or e}")
for bit in summary.split(";"):
if bit.strip():
lines.append(f"- {bit.strip()}")
if pack.get("warnings"):
lines.append("")
lines.append("## 警告")
lines.append("## 提示")
for w in pack["warnings"]:
lines.append(f"- {w}")
src = pack.get("source") or {}
lines += ["", f"出处:{src.get('title')}({src.get('assetId')})"]
lines += ["", f"出处:{src.get('title')}"]
return "\n".join(lines)

View File

@ -1382,9 +1382,10 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
"tardinessDelta": d.get("tardinessDelta")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
_LAST_RUSH_EVAL.clear()
return (f"LNS 局部插单已采用 ✅ {order['orderNo']}(急单)已入池;"
f"窗口内最小扰动:移动 {d.get('movedOrderCount', 0)} 单 · 位移 {d.get('shiftedHours', 0)}h · "
f"延迟变化 {d.get('tardinessDelta', 0):+.1f}h;草稿版本 {result.versionNo}。可用检查点回滚到采用前。")
return (f"插单方案已采用 ✅ {order['orderNo']}(急单)已加入订单池;"
f"{d.get('movedOrderCount', 0)} 张订单顺延,合计顺延 {d.get('shiftedHours', 0)} 小时,"
f"总延期变化 {d.get('tardinessDelta', 0):+.1f} 小时。"
f"已生成待发布的调整方案 {result.versionNo},可用检查点回滚到采用前。")
applied = apply_rush(store.data, store.next_id, params) # 升级(ESCALATE)/ 直接采用 → 全量重排
order = applied["order"]
result = applied["result"]
@ -1398,10 +1399,10 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
"escalateReasons": lns.get("escalateReasons")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
_LAST_RUSH_EVAL.clear()
prefix = "LNS 超阈值升级全量重排 ✅ " if lns.get("status") == "ESCALATE" else "插单已采用 ✅ "
return (prefix + f"{order['orderNo']}(急单)已入池;"
f"草稿版本 {result.versionNo} · 订单 {result.orderCount} · "
f"冲突 {result.conflictCount} · 延迟 {round(result.totalTardiness)}h。"
prefix = "整版调整方案已采用 ✅ " if lns.get("status") == "ESCALATE" else "插单方案已采用 ✅ "
return (prefix + f"{order['orderNo']}(急单)已加入订单池;"
f"已生成待发布的调整方案 {result.versionNo},共安排 {result.orderCount} 张订单,"
f"冲突 {result.conflictCount} 项,总延期 {round(result.totalTardiness)} 小时。"
f"可用检查点回滚到采用前。")
if action in ("forecast.upsert", "forecast.delete", "forecast.convert"):
from server.aps_domain.forecast import apply_forecast_action
@ -1967,6 +1968,19 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
hard_n = sum(1 for c in applied["after"]["constraints"] if c["kind"] == "hard" and c["enabled"])
return (f"约束配置已更新 ✅ 启用 {enabled} 条 / 其中硬约束 {hard_n} 条。"
"硬约束违反将阻止发布(变更前已自动建档)。")
if action == "constraint.rule.save": # ---- 批准:计划员扩充规则(SC-04) ----
from server.aps_domain.constraint_rules import apply_rule_save
cp = _create_checkpoint(store, label="约束规则库变更前基线", reason="auto:constraint.rule.save",
conversation_note="批准扩充约束规则")
applied = apply_rule_save(store.data, params, getattr(store, "tenant_uuid", "platform"))
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
action="constraint.rule.save",
target={"type": "CONSTRAINT_RULE", "id": applied["ruleId"]}, power="P2",
rationale={"confirmId": confirm_id, "approver": actor, "op": applied["op"],
"scope": applied.get("scope")},
before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
store.save()
return str(applied.get("message") or f"约束规则 {applied['ruleId']} 已更新 ✅")
if action == "sop.apply": # ---- 批准:SOP 规则包(IND-02) ----
from server.aps_domain.sop_rules import apply_sop_pack, compile_sop_by_asset
pack = params.get("pack")
@ -3127,17 +3141,29 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
}
rush = p.get("payload") or p
label = rush.get("orderNo") or f"{rush.get('customerName', '急单')}×{rush.get('quantity', '?')}"
strategy_name = {
"DELIVERY_FIRST": "交期优先",
"CAPACITY_BALANCE": "产能均衡",
"COMPREHENSIVE": "综合优化",
}.get(str(p.get("strategy") or "DELIVERY_FIRST").upper(), "交期优先")
if p.get("lns"):
from server.aps_domain.lns import _planner_lns_reasons
escalation_reasons = ";".join(
_planner_lns_reasons(p["lns"].get("escalateReasons") or [])
) or "按当前生产安排无法只调整少数订单"
else:
escalation_reasons = ""
block = harness.stage_confirmation(
session_id, "rush.apply", p,
title=f"采用紧急插单 · {label}",
summary_lines=[
f"策略:{p.get('strategy') or 'DELIVERY_FIRST'}",
"将写入主干订单池(APPROVED + isRush),并生成 DRAFT 排产版本",
"基准已发布版本不回写;执行前自动建档,可回滚",
f"快评 ID:{p.get('evalId') or '(直接采用)'}",
*([f"LNS:局部修复(移动 {p['lns']['disturbance']['movedOrderCount']} 单,窗口内最小扰动,入 DRAFT 草稿)"]
f"排产方式:{strategy_name}",
"确认后会把这笔急单加入订单池,并生成一版待发布的调整方案",
"现有已发布计划不改动;执行前会自动留存可回滚点",
*([f"局部调整:{p['lns']['disturbance']['movedOrderCount']} 张订单需要顺延,"
f"调整范围外的订单保持不变"]
if p.get("lns") and p["lns"].get("status") == "LOCAL"
else [f"LNS:升级全量重排({';'.join(p['lns'].get('escalateReasons') or [])})"]
else [f"整版调整:{escalation_reasons}"]
if p.get("lns") else []),
],
)
@ -3145,7 +3171,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
target={"type": "SALES_ORDER", "id": rush.get("orderNo") or "NEW"}, power="P2",
rationale={"confirmId": block.props["confirmId"]})
store.save()
return AgentReply(text="采用插单属于 P2 写操作,需要你确认(见下方确认卡)。", blocks=[block])
return AgentReply(text="采用这项插单方案前需要你确认,具体影响见下方确认卡。", blocks=[block])
# ---- OR-05 预测 / 长周期订单 ----
if name == "forecast.query":
from server.aps_domain.forecast import forecast_summary, list_forecasts
@ -4554,6 +4580,38 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
rationale={"confirmId": block.props["confirmId"]})
store.save()
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
# ---- 约束规则库:默认通用规则 + 计划员自然语言扩充(SC-04) ----
if name == "constraint.rule.query":
from server.aps_domain.constraint_rules import constraint_library
lib = constraint_library(store.data, getattr(store, "tenant_uuid", "platform"))
s = lib["summary"]
lines = [
f"· {r['name']}({r['categoryLabel']}/{r['scopeLabel']}/{r['kindLabel']})"
f"→ {r['effect']['text']}"
for r in lib["custom"]
]
text = (f"默认通用约束 {s['builtinTotal']} 条(无需逐条登记);"
f"已扩充 {s['customTotal']} 条,其中接入排产引擎 {s['engineBound']} 条、"
f"仅记录 {s['recordOnly']} 条。")
text += "\n" + ("\n".join(lines) if lines
else "还没有扩充规则。直接用一句话说你的要求,我来整理成规则。")
return AgentReply(text=text)
if name == "constraint.rule.save":
from server.aps_domain.constraint_rules import confirmation_for_rule_save
raw = dict(intent.params or {})
try:
title, lines = confirmation_for_rule_save(
store.data, raw, getattr(store, "tenant_uuid", "platform"))
except ValueError as exc:
return AgentReply(text=f"规则没有登记:{exc}")
block = harness.stage_confirmation(
session_id, "constraint.rule.save", raw, title=title, summary_lines=lines)
write_audit(store.data, store.next_id, actor=actor, category="GATE",
action="constraint.rule.save.stage",
target={"type": "CONSTRAINT_RULE", "id": str(raw.get("ruleId") or "new")},
power="P2", rationale={"confirmId": block.props["confirmId"]})
store.save()
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
# ---- IND-02 SOP→约束 ----
if name == "sop.compile":
from server.aps_domain.sop_rules import compile_sop_by_asset, list_compilable_sops, sop_as_block
@ -4561,7 +4619,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
or (intent.params or {}).get("asset_id") or "").strip()
if not asset_id:
return AgentReply(
text="SOP 编译需要结构化参数 assetId;本次未从 query 文本猜测 SOP。"
text="请先选择要查看的作业标准,本次未从问句自动选择。"
)
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
if not compiled.get("ok"):
@ -4574,9 +4632,9 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
rationale={"assetId": asset_id, "effects": len(pack.get("effects") or [])})
store.save()
text = (
f"已编译 SOP《{pack.get('title')}》→ 规则包预览。\n"
f"{pack.get('summary')}\n"
"确认落地请说「应用换线SOP」或「应用该规则包」(P2)。"
f"已从作业标准《{pack.get('title')}》识别出以下排产规则:\n"
f"{pack.get('plannerSummary') or '当前作业标准暂未识别出可用于排产的约束'}\n"
"需要应用时请回复「应用该作业标准」,确认后生效。"
)
return AgentReply(text=text, blocks=[sop_as_block(pack)])
if name == "sop.apply":
@ -4589,11 +4647,11 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
if pack is None:
if not asset_id:
return AgentReply(
text="应用 SOP 需要结构化参数 pack,或 assetId 精确指定 SOP;本次未生成确认卡。"
text="请先选择要应用的作业标准,本次未生成确认项。"
)
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
if not compiled.get("ok"):
return AgentReply(text=compiled.get("error") or "SOP 编译失败")
return AgentReply(text="作业标准读取失败,请稍后重试。")
pack = compiled["pack"]
title, lines = confirmation_for_sop_apply(pack)
block = harness.stage_confirmation(
@ -4604,7 +4662,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
target={"type": "RULE_PACK", "id": pack.get("packId")}, power="P2",
rationale={"confirmId": block.props["confirmId"]})
store.save()
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
return AgentReply(text=f"{title},需要你确认后生效。", blocks=[block])
# ---- 帮助(P0) ----
if name == "help":
return AgentReply(text=_HELP)

View File

@ -79,6 +79,8 @@ IntentName = Literal[
"params.query", # OR-02:排产参数查询(P0)
"constraint.profile.query", # SC-04:约束剖面查询(P0)
"constraint.profile.save", # SC-04:约束剖面保存(P2)
"constraint.rule.query", # SC-04:规则库概览(P0)
"constraint.rule.save", # SC-04:计划员扩充规则(P2)
"data.import", # 自然语言批量导入订单/物料(P2)
"import.commit", # Excel/CSV 导入入库(P2)
"folder.analyze", # 工程目录深度解析(P0)

View File

@ -3253,6 +3253,35 @@ def create_app() -> FastAPI:
store.save()
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
@app.get("/api/constraints/library")
async def constraints_library() -> dict:
"""计划员约束规则库(SC-04 扩充,P0):默认通用规则概览 + 已扩充规则。"""
from server.aps_domain.constraint_rules import constraint_library
store = get_store()
return constraint_library(store.data, getattr(store, "tenant_uuid", "platform"))
@app.post("/api/constraints/rules/stage")
async def constraints_rule_stage(req: ConstraintStageRequest) -> dict:
"""计划员扩充规则新增/启停/删除暂存确认卡(P2 → /api/actions/confirm)。"""
from server.aps_domain.constraint_rules import confirmation_for_rule_save
store = get_store()
tenant = getattr(store, "tenant_uuid", "platform")
try:
title, lines = confirmation_for_rule_save(store.data, req.payload, tenant)
except ValueError as exc:
return {"error": str(exc)}
block = harness.stage_confirmation(
req.sessionId or "web", "constraint.rule.save", req.payload,
title=title, summary_lines=lines)
from server.agent_core.audit import write_audit
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
action="constraint.rule.save.stage",
target={"type": "CONSTRAINT_RULE",
"id": str(req.payload.get("ruleId") or "new")},
power="P2", rationale={"confirmId": block.props["confirmId"]})
store.save()
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
@app.get("/api/sop/compilable")
async def sop_compilable() -> dict:
"""可编译 SOP 列表(IND-02,P0)。"""

View File

@ -791,6 +791,13 @@ def render_plan_task_brief(run_id: str, query: str, snapshot_files: list[str],
6. 工具结果要求核对、确认或选择哪一份文件时,必须原样转达这个问题,不得改写成
「项目没有数据」之类的结论。
7. 单轮最多调用 8 次,序号从 1 递增;同一个请求不要重复调用。
8. 紧急插单只认工具结论:只要用户在问一张急单能不能插、会延期哪些订单、是否要把
整版计划重排,必须先调用 `rush.evaluate`,不得凭订单数量、交期或快照内容自行判断。
缺少“已有订单号,或产品编码/名称 + 数量 + 交期”时先向用户追问,不要把整句话塞进参数,
也不要替用户猜默认数量或交期。工具返回后才解释结论:能局部调整就说清急单安排、受影响的
订单和顺延时间;不能局部调整就说清需要重新编排全部订单及主要原因。用户确认“采用刚才的
插单方案”时,调用 `rush.apply` 且 params 传空对象;它只会生成待确认事项,确认前不得说
计划已经修改、方案已经发布或工单已经下发。
"""
brief = brief.replace("【输出契约】", tool_protocol + "\n【输出契约】", 1)
@ -807,6 +814,7 @@ def render_plan_task_brief(run_id: str, query: str, snapshot_files: list[str],
- 需要写系统数据时,只说明已准备待确认的操作,不得声称已经执行或已经获批;
- 失败或信息不足时如实说明,不泄露内部运行机制;
- 不向用户展示工具名、协议字段或文件邮箱路径,统一改用中文业务名称;
- 同一次回复里的备选项或步骤使用连续的 1、2、3 编号;不要每个选项都写成 1.;
- 不要引用或编造任何凭证编号(callId)。
"""
return brief.replace(legacy_output, primary_output, 1)

View File

@ -47,6 +47,8 @@
"params.query",
"constraint.profile.query",
"constraint.profile.save",
"constraint.rule.query",
"constraint.rule.save",
"data.import",
"import.commit",
"folder.analyze",

View File

@ -0,0 +1,303 @@
# ============================================================
# SC-04 扩充:计划员约束规则库黄金测试(可重生 ✅)
# 1) 默认通用规则 = 内建 C1..C13 目录,扩充规则不动它
# 2) 计划员一句话 → 规则(按类别 / 按范围归档)
# 3) 没接入排产引擎的规则如实标注,不假装生效
# 4) 绑定可配内置项时真正落到排产引擎,并说清执行来源
# 5) 新增 / 启用 / 停用 / 删除 全生命周期 + 非法载荷拒绝
# ============================================================
from __future__ import annotations
import pytest
from server.aps_domain.constraint_rules import (
all_rules,
apply_rule_save,
confirmation_for_rule_save,
constraint_library,
normalize_rule_payload,
rule_effect,
tenant_rules_path,
)
from server.aps_domain.constraints import (
DEFAULT_CONSTRAINT_DEFS,
apply_profile_save,
get_constraint,
get_constraint_profile,
)
from server.state.seed import seed_world
@pytest.fixture
def rules_home(tmp_path, monkeypatch):
"""每条用例独占一个数据根,避免全厂规则文件互相串。"""
monkeypatch.setenv("APS_HOME", str(tmp_path))
return tmp_path
def _add(world, tenant, **kw):
payload = {"op": "add", "name": kw.pop("name"), "category": kw.pop("category", "other")}
payload.update(kw)
return apply_rule_save(world, payload, tenant)
# ---------------- 1) 默认通用规则是基线,扩充不动它 ----------------
def test_default_library_is_builtin_catalog_only(rules_home):
world = seed_world()
lib = constraint_library(world, "t1")
assert lib["summary"]["builtinTotal"] == len(DEFAULT_CONSTRAINT_DEFS)
assert lib["summary"]["customTotal"] == 0
assert lib["summary"]["engineBound"] == 0
assert lib["groups"] == []
def test_adding_unbound_rule_does_not_touch_builtin_profile(rules_home):
world = seed_world()
before = [(c["kind"], c["enabled"]) for c in get_constraint_profile(world)["constraints"]]
_add(world, "t1", name="夜班不安排换型", category="process", scope="project", kind="soft")
after = [(c["kind"], c["enabled"]) for c in get_constraint_profile(world)["constraints"]]
assert before == after
assert len(world["constraintRules"]) == 1
# ---------------- 2) 计划员一句话 → 规则,按类别/范围归档 ----------------
def test_project_rule_is_archived_with_category_and_scope(rules_home):
world = seed_world()
out = _add(world, "t1", name="夜班不安排换型", category="process", scope="project",
kind="soft", sourceText="以后夜班别安排换型")
assert out["ruleId"] == "R-001"
lib = constraint_library(world, "t1")
assert [g["id"] for g in lib["groups"]] == ["process"]
rule = lib["custom"][0]
assert rule["name"] == "夜班不安排换型"
assert rule["categoryLabel"] == "工艺"
assert rule["scopeLabel"] == "仅本项目"
assert rule["kindLabel"] == "尽量满足"
assert rule["source"]["text"] == "以后夜班别安排换型"
def test_tenant_rule_lands_in_tenant_file_and_stays_tenant_scoped(rules_home):
world = seed_world()
_add(world, "tenant-a", name="全厂周末不排产", category="calendar", scope="tenant", kind="hard")
assert tenant_rules_path("tenant-a").exists()
assert not world.get("constraintRules")
assert constraint_library(world, "tenant-a")["summary"]["customTotal"] == 1
assert constraint_library(world, "tenant-b")["summary"]["customTotal"] == 0
assert constraint_library(world, "tenant-a")["custom"][0]["scopeLabel"] == "全厂"
def test_rule_ids_increment_across_scopes(rules_home):
world = seed_world()
_add(world, "t1", name="本项目第一条", category="other", scope="project")
_add(world, "t1", name="全厂第二条", category="other", scope="tenant")
rules = all_rules(world, "t1")
assert sorted(r["id"] for r in rules) == ["R-001", "R-002"]
assert {r["scope"] for r in rules} == {"project", "tenant"}
# ---------------- 3) 没接入引擎就直说 ----------------
def test_unbound_rule_is_reported_as_record_only(rules_home):
world = seed_world()
out = _add(world, "t1", name="喷涂线每天最多八小时", category="capacity", scope="project")
rule = world["constraintRules"][0]
effect = rule_effect(world, rule)
assert effect["bound"] is False and effect["enforced"] is False
assert effect["text"] == "已记录,暂未接入排产引擎"
assert "暂未接入排产引擎" in out["message"]
assert constraint_library(world, "t1")["summary"]["recordOnly"] == 1
# ---------------- 4) 绑定内置项时真正生效,并说清来源 ----------------
def test_binding_to_kind_configurable_constraint_applies_to_engine(rules_home):
world = seed_world()
assert get_constraint(world, "C6_material_kit")["kind"] == "soft"
_add(world, "t1", name="缺料必须挡住发布", category="material", scope="project",
kind="hard", boundConstraintId="C6_material_kit")
assert get_constraint(world, "C6_material_kit")["kind"] == "hard"
rule = world["constraintRules"][0]
effect = rule_effect(world, rule)
assert effect["enforced"] is True
assert "物料齐套" in effect["text"] and "硬约束" in effect["text"]
assert constraint_library(world, "t1")["summary"]["engineBound"] == 1
def test_binding_to_non_configurable_hard_rule_never_downgrades_it(rules_home):
world = seed_world()
_add(world, "t1", name="前序没做完不能开下道", category="process", scope="project",
kind="soft", boundConstraintId="C1_precedence")
c1 = get_constraint(world, "C1_precedence")
assert c1["kind"] == "hard" and c1["enabled"] is True
assert rule_effect(world, world["constraintRules"][0])["enforced"] is True
def test_disabled_builtin_marks_bound_rule_as_not_effective(rules_home):
world = seed_world()
_add(world, "t1", name="缺料先提示我", category="material", scope="project",
kind="soft", boundConstraintId="C6_material_kit")
apply_profile_save(world, {"constraints": {"C6_material_kit": {"enabled": False}}})
effect = rule_effect(world, world["constraintRules"][0])
assert effect["enforced"] is False
assert "已关闭" in effect["text"]
# ---------------- 5) 生命周期与校验 ----------------
def test_rule_lifecycle_enable_disable_delete(rules_home):
world = seed_world()
_add(world, "t1", name="插单必须先经我确认", category="order", scope="project")
rid = world["constraintRules"][0]["id"]
apply_rule_save(world, {"op": "disable", "ruleId": rid}, "t1")
assert world["constraintRules"][0]["enabled"] is False
assert constraint_library(world, "t1")["custom"][0]["enabled"] is False
apply_rule_save(world, {"op": "enable", "ruleId": rid}, "t1")
assert world["constraintRules"][0]["enabled"] is True
out = apply_rule_save(world, {"op": "delete", "ruleId": rid}, "t1")
assert world["constraintRules"] == []
assert "已删除" in out["message"]
def test_tenant_rule_toggle_writes_back_to_tenant_file(rules_home):
world = seed_world()
_add(world, "tenant-a", name="全厂周末不排产", category="calendar", scope="tenant")
rid = all_rules(world, "tenant-a")[0]["id"]
apply_rule_save(world, {"op": "disable", "ruleId": rid}, "tenant-a")
assert all_rules(world, "tenant-a")[0]["enabled"] is False
assert not world.get("constraintRules")
@pytest.mark.parametrize("payload", [
{"op": "add", "name": ""},
{"op": "add", "name": "超" * 41},
{"op": "add", "name": "合法内容", "category": "unknown"},
{"op": "add", "name": "合法内容", "scope": "site"},
{"op": "add", "name": "合法内容", "kind": "must"},
{"op": "add", "name": "合法内容", "boundConstraintId": "C99_nope"},
{"op": "enable"},
{"op": "archive", "ruleId": "R-001"},
])
def test_invalid_payloads_are_rejected(payload):
with pytest.raises(ValueError):
normalize_rule_payload(payload)
def test_missing_rule_id_is_rejected_on_apply(rules_home):
world = seed_world()
with pytest.raises(ValueError):
apply_rule_save(world, {"op": "delete", "ruleId": "R-404"}, "t1")
with pytest.raises(ValueError):
confirmation_for_rule_save(world, {"op": "disable", "ruleId": "R-404"}, "t1")
# ---------------- 6) 确认卡说人话 ----------------
def test_confirmation_card_states_scope_strength_and_engine_linkage(rules_home):
world = seed_world()
title, lines = confirmation_for_rule_save(
world, {"op": "add", "name": "夜班不换型", "category": "process",
"scope": "tenant", "kind": "soft"}, "t1")
body = "\n".join(lines)
assert "夜班不换型" in title
assert "工艺" in body and "全厂" in body and "尽量满足" in body
assert "暂未接入排产引擎" in body
_, lines_bound = confirmation_for_rule_save(
world, {"op": "add", "name": "缺料挡发布", "category": "material",
"kind": "hard", "boundConstraintId": "C6_material_kit"}, "t1")
assert "物料齐套" in "\n".join(lines_bound)
# ---------------- 7) Pi 工具目录接线 + 确认后落库 ----------------
def test_pi_tool_catalog_exposes_constraint_rule_tools():
from server.agent_core import fallback_lane
catalog = {t["name"]: t for t in fallback_lane._primary_tool_catalog()}
assert catalog["constraint.rule.query"]["power"] == "P0"
assert catalog["constraint.rule.save"]["power"] == "P2"
schema = catalog["constraint.rule.save"]["paramsSchema"]
assert schema["additionalProperties"] is False
assert "add" in schema["properties"]["op"]["enum"]
assert "tenant" in schema["properties"]["scope"]["enum"]
assert "工艺" in catalog["constraint.rule.query"]["description"] or \
"规则库" in catalog["constraint.rule.query"]["description"]
async def test_pi_intent_stages_rule_and_confirmation_writes_it(rules_home, tmp_path):
from server.aps_domain.workflow import execute_confirmed, handle_intent
from server.contracts import IntentResult
from server.state.store import WorldStore
store = WorldStore(path=tmp_path / "world.json")
reply = await handle_intent(
store, "s-rules",
IntentResult(
intent="constraint.rule.save",
params={"op": "add", "name": "夜班不安排换型", "category": "process",
"scope": "project", "kind": "soft", "sourceText": "以后夜班别安排换型"},
confidence=1.0, source="LLM",
),
)
assert reply.blocks, "P2 动作必须出确认卡"
block = reply.blocks[0]
assert block.props["power"] == "P2"
assert not store.data.get("constraintRules"), "确认前不得写规则库"
message = execute_confirmed(store, str(block.props["confirmId"]), True, actor="planner-1")
assert "R-001" in message
assert store.data["constraintRules"][0]["name"] == "夜班不安排换型"
audits = [e for e in store.data["auditEvents"]
if e.get("action") == "constraint.rule.save" and e.get("result") == "SUCCESS"]
assert audits, "批准执行必须落审计"
async def test_pi_rule_query_lists_defaults_and_extensions(rules_home, tmp_path):
from server.aps_domain.workflow import handle_intent
from server.contracts import IntentResult
from server.state.store import WorldStore
world = seed_world()
_add(world, "platform", name="喷涂线每天最多八小时", category="capacity", scope="project")
store = WorldStore(path=tmp_path / "world.json")
store.data = world
reply = await handle_intent(
store, "s-rules",
IntentResult(intent="constraint.rule.query", params={}, confidence=1.0, source="LLM"),
)
assert "默认通用约束" in reply.text
assert "喷涂线每天最多八小时" in reply.text
assert "暂未接入排产引擎" in reply.text
def test_gateway_endpoints_expose_and_apply_planner_rules(rules_home, monkeypatch):
from fastapi.testclient import TestClient
import server.gateway.app as gateway_module
from tests.auth_provider import install_test_auth
install_test_auth(monkeypatch, "tenant-rule-library")
client = TestClient(gateway_module.create_app())
assert client.post("/api/auth/login", json={"username": "planner"}).status_code == 200
lib = client.get("/api/constraints/library")
assert lib.status_code == 200
summary = lib.json()["summary"]
assert summary["builtinTotal"] == len(DEFAULT_CONSTRAINT_DEFS)
assert summary["customTotal"] == 0
staged = client.post("/api/constraints/rules/stage", json={
"sessionId": "s-http-rules",
"payload": {"op": "add", "name": "夜班不安排换型", "category": "process",
"scope": "project", "kind": "soft"},
})
assert staged.status_code == 200
body = staged.json()
assert not body.get("error"), body
confirm_id = body["block"]["props"]["confirmId"]
assert client.get("/api/constraints/library").json()["summary"]["customTotal"] == 0, \
"暂存阶段不得写规则库"
approved = client.post("/api/actions/confirm", json={
"sessionId": "s-http-rules", "confirmId": confirm_id, "approve": True,
})
assert approved.status_code == 200
assert "R-001" in approved.json()["message"]
after = client.get("/api/constraints/library").json()
assert after["summary"]["customTotal"] == 1
assert after["custom"][0]["name"] == "夜班不安排换型"
assert after["custom"][0]["categoryLabel"] == "工艺"

View File

@ -0,0 +1,288 @@
# ============================================================
# A0050101-00280 外壳 · 紧急插单计划员自然语言验收
#
# 目标:用计划员原话验证 Pi 只负责理解和调用,能否插单、影响哪些订单
# 必须来自真实 RULE 引擎;采用前不得写入订单或生成排产版本。
# ============================================================
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from server.agent_core import fallback_lane
from server.aps_domain import workflow as wf
from server.aps_domain.project_analyze import analyze_project_deep
from server.contracts import IntentResult
from server.state.seed import empty_world
from server.timeutil import fmt_date
REPO_ROOT = Path(__file__).resolve().parents[2]
DEMO_DIR = REPO_ROOT / "demo-data" / "锐扬APS演示数据"
PRODUCT_CODE = "A0050101-00280"
FIXED_TODAY = datetime(2026, 9, 18) # noqa: DTZ001 - 业务日历统一使用本地无时区时间
class FakeStore:
def __init__(self, world: dict):
self.data = world
self._counters: dict[str, int] = {}
def next_id(self, kind: str) -> int:
self._counters[kind] = self._counters.get(kind, 0) + 1
return self._counters[kind]
def save(self) -> None:
pass
def _primary_intent(query: str) -> IntentResult:
return IntentResult(
intent="assistant.reply",
params={"query": query, "_history": [], "_piPrimary": True},
confidence=1.0,
source="LLM",
)
def _planner_world(monkeypatch) -> dict:
"""加载仓库里的锐扬演示资料,并用固定时钟保证测试可复现。"""
monkeypatch.setattr("server.aps_domain.rush.today0", lambda: FIXED_TODAY)
monkeypatch.setattr("server.aps_domain.lns.today0", lambda: FIXED_TODAY)
world = empty_world()
counter = {"n": 0}
def next_id(_kind: str) -> int:
counter["n"] += 1
return counter["n"]
report = analyze_project_deep(
world,
input_path=str(DEMO_DIR),
next_id=next_id,
apply=True,
)
assert report["canSchedule"] is True
assert any(m.get("code") == PRODUCT_CODE for m in world["materials"])
# 演示包的交期是历史固定日期;测试只平移交期,保留产品、BOM、工艺和设备原样。
for offset, order in enumerate(world["salesOrders"]):
order["deliveryDate"] = fmt_date(FIXED_TODAY + timedelta(days=12 + offset))
world["scheduleParams"]["planningHorizonDays"] = 60
return world
def _install_real_tool_runtime(monkeypatch) -> list[IntentResult]:
calls: list[IntentResult] = []
async def fake_run_tool(store, session_id, intent, actor="planner"):
calls.append(intent)
return await wf.handle_intent(store, session_id, intent, actor)
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
return calls
def _pi_runner(captured: dict, tool: str | None, params: dict, model_text: str):
def runner(task: str, work_dir: Path):
captured["task"] = task
if tool is None:
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": model_text}],
},
}
yield {"type": "agent_end", "messages": []}
return
mailbox = work_dir.parent / "outbox" / "chat-tools"
mailbox.mkdir(parents=True, exist_ok=True)
request = mailbox / f"01-{tool.replace('.', '-')}.json"
request.write_text(
json.dumps({"seq": 1, "tool": tool, "params": params}, ensure_ascii=False),
encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
result = request.with_name(request.stem + ".result.json")
captured["tool_result"] = json.loads(result.read_text(encoding="utf-8"))
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": model_text}],
},
}
yield {"type": "agent_end", "messages": []}
return runner
def _assert_planner_language(text: str, summary: list[str] | None = None) -> None:
visible = text + "\n" + "\n".join(summary or [])
for term in ("P1", "P2", "DRAFT", "LNS", "evalId", "APPROVED", "isRush", "沙盒"):
assert term not in visible
async def test_planner_rush_natural_language_uses_engine_and_ignores_pi_guess(
tmp_path, monkeypatch,
):
"""计划员原话进入 Pi 后,必须调用 rush.evaluate,且引擎结论覆盖模型猜测。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
store = FakeStore(_planner_world(monkeypatch))
calls = _install_real_tool_runtime(monkeypatch)
query = (
"湖南工业控制有限公司追加 50 套 A0050101-00280 外壳,10月14日交货,"
"先评估紧急插单影响,不要直接改计划。"
)
params = {
"customerName": "湖南工业控制有限公司",
"customerLevel": "A",
"productCode": PRODUCT_CODE,
"quantity": 50,
"deliveryDate": "2026-10-14",
"priority": 1,
"strategy": "DELIVERY_FIRST",
}
captured: dict = {}
reply = await fallback_lane.propose_reply(
store,
"s-planner-rush",
_primary_intent(query),
runner=_pi_runner(
captured,
"rush.evaluate",
params,
"可以插单,而且不会影响任何订单,直接改计划就行。",
),
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert [call.intent for call in calls] == ["rush.evaluate"]
assert calls[0].params["productCode"] == PRODUCT_CODE
assert calls[0].params["quantity"] == 50
assert calls[0].params["deliveryDate"] == "2026-10-14"
assert "必须先调用 `rush.evaluate`" in captured["task"]
assert reply.text.startswith("插单影响评估完成")
assert "直接改计划就行" not in reply.text
assert "现有计划没有改动" in reply.text
assert {block.props.get("kind") for block in reply.blocks} >= {"rush-impact"}
actual = wf._LAST_RUSH_EVAL
assert actual["rushOrder"]["productCode"] == PRODUCT_CODE
assert actual["rushOrder"]["quantity"] == 50
assert actual["rushOrder"]["deliveryDate"] == "2026-10-14"
assert actual["affectedOrderCount"] == len(actual["affectedOrders"])
assert actual["lns"]["status"] in {"LOCAL", "ESCALATE"}
_assert_planner_language(reply.text)
wf._LAST_RUSH_EVAL.clear()
async def test_planner_rush_missing_quantity_asks_instead_of_guessing(tmp_path, monkeypatch):
"""缺少数量或交期时,Pi 先追问,不拿默认值代替计划员的真实要求。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
store = FakeStore(_planner_world(monkeypatch))
calls = _install_real_tool_runtime(monkeypatch)
captured: dict = {}
question = "请补充这批 A0050101-00280 外壳的交货数量和最晚交货日期,我再按真实产能评估。"
reply = await fallback_lane.propose_reply(
store,
"s-planner-rush-missing",
_primary_intent("客户临时要加一批 A0050101-00280 外壳,先看能不能插进去。"),
runner=_pi_runner(captured, None, {}, question),
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert calls == []
assert "交货数量" in reply.text and "最晚交货日期" in reply.text
assert "缺少“已有订单号,或产品编码/名称 + 数量 + 交期”时先向用户追问" in captured["task"]
async def test_planner_rush_unknown_order_returns_engine_rejection(tmp_path, monkeypatch):
"""订单号不存在时,Pi 不能改口说可以插,必须转达引擎的真实拒绝原因。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
store = FakeStore(_planner_world(monkeypatch))
calls = _install_real_tool_runtime(monkeypatch)
captured: dict = {}
reply = await fallback_lane.propose_reply(
store,
"s-planner-rush-unknown",
_primary_intent("把 SO-NOT-EXIST 加急,10月14日交,直接告诉我能不能插。"),
runner=_pi_runner(
captured,
"rush.evaluate",
{"orderNo": "SO-NOT-EXIST"},
"这张单可以加急,我已经安排进去了。",
),
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert [call.intent for call in calls] == ["rush.evaluate"]
assert reply.text == "订单 SO-NOT-EXIST 不存在"
assert "已经安排进去" not in reply.text
wf._LAST_RUSH_EVAL.clear()
async def test_planner_rush_apply_waits_for_confirmation(tmp_path, monkeypatch):
"""“采用刚才方案”只生成确认事项;确认前订单和排产版本都不变。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
store = FakeStore(_planner_world(monkeypatch))
payload = {
"customerName": "湖南工业控制有限公司",
"customerLevel": "A",
"productCode": PRODUCT_CODE,
"quantity": 50,
"deliveryDate": "2026-10-14",
"priority": 1,
"strategy": "DELIVERY_FIRST",
}
await wf.handle_intent(
store,
"s-planner-rush-apply",
IntentResult(intent="rush.evaluate", params=payload, confidence=1.0, source="LLM"),
"planner",
)
before_orders = len(store.data["salesOrders"])
before_versions = len(store.data["scheduleVersions"])
calls = _install_real_tool_runtime(monkeypatch)
captured: dict = {}
reply = await fallback_lane.propose_reply(
store,
"s-planner-rush-apply",
_primary_intent("采用刚才的插单方案,但先给我确认。"),
runner=_pi_runner(
captured,
"rush.apply",
{},
"已经采用,订单也写好并发布了。",
),
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert [call.intent for call in calls] == ["rush.apply"]
assert calls[0].params == {}
assert len(store.data["salesOrders"]) == before_orders
assert len(store.data["scheduleVersions"]) == before_versions
assert "已经写好并发布" not in reply.text
cards = [block for block in reply.blocks if block.type == "confirm-card"]
assert len(cards) == 1
_assert_planner_language(reply.text, cards[0].props.get("summary") or [])
message = wf.execute_confirmed(
store,
str(cards[0].props["confirmId"]),
approve=True,
actor="planner",
)
assert len(store.data["salesOrders"]) == before_orders + 1
assert len(store.data["scheduleVersions"]) == before_versions + 1
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"
_assert_planner_language(message)
wf._LAST_RUSH_EVAL.clear()

View File

@ -52,7 +52,7 @@ def _scheduled_world():
def _small_rush_payload():
return {
"customerName": "LNS客户",
"customerName": "华东装备客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 100,
@ -104,7 +104,7 @@ def test_lns_escalate_when_move_count_exceeds_threshold():
"""超过影响阈值:受影响订单数 / 扰动率超限 → 升级全量重排信号。"""
world = _scheduled_world()
payload = {
"customerName": "LNS客户",
"customerName": "华东装备客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 200,
@ -126,7 +126,7 @@ def test_lns_escalate_when_duration_exceeds_window():
"""急单工时超过窗口宽度 → 窗口内不可行,升级全量重排。"""
world = _scheduled_world()
lns = lns_local_repair(world, {
"customerName": "LNS客户",
"customerName": "华东装备客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 5000,
@ -204,7 +204,7 @@ def test_lns_existing_order_apply_moves_po_to_plan_slot():
assert lns["plan"]["rushOrder"]["isNew"] is False
baseline_versions = len(world["scheduleVersions"])
applied = apply_lns_local(world, _next_id_factory(world), {
apply_lns_local(world, _next_id_factory(world), {
"payload": lns["payload"],
"lns": lns,
"strategy": lns["strategy"],
@ -222,7 +222,7 @@ def test_lns_escalation_falls_back_to_full_reschedule_compat():
"""升级场景 → 复用 apply_rush 全量重排(走 P2 确认后执行),生成 DRAFT 版本。"""
world = _scheduled_world()
lns = lns_local_repair(world, {
"customerName": "LNS客户",
"customerName": "华东装备客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 5000,
@ -292,12 +292,16 @@ def _run(intent_name, store, params):
def test_workflow_rush_evaluate_attaches_lns_block():
"""rush.evaluate 接线:回复附带 LNS 摘要与 lns-repair 块。"""
"""rush.evaluate 接线:回复附带计划员可读的插单安排摘要与结果块。"""
wf._LAST_RUSH_EVAL.clear()
store = FakeStore()
get_engine("RULE").solve(store.data, _engine_params(store.data, "DELIVERY_FIRST"), store.next_id)
reply = _run("rush.evaluate", store, _small_rush_payload())
assert "LNS" in reply.text
assert "插单安排评估完成" in reply.text
assert "本次只做试算,现有计划没有改动" in reply.text
assert "LNS" not in reply.text
assert "沙盒" not in reply.text
assert "DRAFT" not in reply.text
kinds = [b.props.get("kind") for b in reply.blocks]
assert "rush-impact" in kinds
assert "lns-repair" in kinds
@ -317,11 +321,15 @@ def test_workflow_rush_apply_local_routes_to_lns_draft():
confirm_blocks = [b for b in staged.blocks if b.type == "confirm-card"]
assert confirm_blocks, "rush.apply 必须出 P2 确认卡"
confirm_id = confirm_blocks[0].props["confirmId"]
assert any("LNS" in line for line in (confirm_blocks[0].props.get("summary") or []))
assert any("局部调整" in line for line in (confirm_blocks[0].props.get("summary") or []))
assert not any("LNS" in line or "DRAFT" in line or "P2" in line
for line in (confirm_blocks[0].props.get("summary") or []))
baseline_versions = len(store.data["scheduleVersions"])
msg = wf.execute_confirmed(store, confirm_id, approve=True, actor="tester")
assert "LNS 局部插单已采用" in msg
assert "插单方案已采用" in msg
assert "LNS" not in msg
assert "DRAFT" not in msg
assert len(store.data["scheduleVersions"]) == baseline_versions + 1
assert store.data["scheduleVersions"][-1]["triggerType"] == "LNS_RUSH"
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"

View File

@ -10,6 +10,8 @@ from server.aps_domain.sop_rules import (
compile_sop_by_asset,
compile_sop_by_query,
compile_sop_asset,
confirmation_for_sop_apply,
sop_as_block,
)
from server.knowledge import get_knowledge
from server.state.seed import seed_world
@ -26,6 +28,19 @@ def test_compile_changeover_sop():
cross = next(e for e in pack["effects"] if e["op"] == "changeover.policy")
assert cross["defaultCrossFamilyMin"] == 60
assert cross.get("noChangeoverAfter") == "16:00"
assert "换型" in pack["plannerSummary"]
assert "C10_changeover" not in pack["plannerSummary"]
assert "soft" not in pack["plannerSummary"]
title, lines = confirmation_for_sop_apply(pack)
confirmation_text = "\n".join(lines)
assert "应用作业标准" in title
assert "C10_changeover" not in confirmation_text
assert "changeover.policy" not in confirmation_text
assert "P2" not in confirmation_text
markdown = sop_as_block(pack).props["markdown"]
assert "作业标准规则" in sop_as_block(pack).props["title"]
assert "changeover.policy" not in markdown
assert "C13_sop" not in markdown
def test_compile_by_asset_and_apply():
@ -54,6 +69,8 @@ def test_compile_kit_sop():
assert compiled["ok"]
ids = [e.get("id") for e in compiled["pack"]["effects"] if e.get("op") == "constraint.set"]
assert "C6_material_kit" in ids
assert "物料不齐套" in compiled["pack"]["plannerSummary"]
assert "C6_material_kit" not in compiled["pack"]["plannerSummary"]
def test_query_shell_requires_explicit_asset_id():

View File

@ -122,7 +122,7 @@ def test_sop_compile_requires_explicit_selection(tmp_path: Path):
),
)
assert "结构化参数" in reply.text
assert "请先选择要查看的作业标准" in reply.text
assert not any(block.type == "confirm-card" for block in reply.blocks)