From e0cf0f30f65de6de8761218340bf695164849ec7 Mon Sep 17 00:00:00 2001 From: "z.zhang" Date: Tue, 22 Sep 2026 14:44:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E5=91=98=E7=BA=A6=E6=9D=9F=E8=A7=84=E5=88=99=E4=B8=8E=E6=80=A5?= =?UTF-8?q?=E5=8D=95=E5=A4=84=E7=90=86=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加默认通用约束、计划员扩充规则库及 P2 确认链路\n- 收口急单评估/应用口径,由排产引擎结论优先并改为计划员语言\n- 补充后端黄金测试、离线 E2E 与产品说明 --- apps/web/e2e/planner-constraint-rules.spec.ts | 238 ++++++++++++ apps/web/src/api/client.ts | 31 +- apps/web/src/gov/GovConsole.tsx | 224 +++++++++-- apps/web/src/styles.css | 59 +++ docs/architecture/harness.md | 5 + ...aps-planner-rush-natural-language-tests.md | 65 ++++ server/agent_core/fallback_lane.py | 56 +++ server/agent_core/harness.py | 4 + server/aps_domain/constraint_rules.py | 352 ++++++++++++++++++ server/aps_domain/lns.py | 70 +++- server/aps_domain/rush.py | 33 +- server/aps_domain/sop_rules.py | 57 ++- server/aps_domain/workflow.py | 100 +++-- server/contracts.py | 2 + server/gateway/app.py | 29 ++ server/integrations/pi_bridge.py | 8 + shared/schemas/intent.schema.json | 2 + tests/golden/test_constraint_rules.py | 303 +++++++++++++++ .../test_planner_rush_natural_language.py | 288 ++++++++++++++ tests/golden/test_rush_lns.py | 26 +- tests/golden/test_sop_rules.py | 17 + tests/golden/test_workflow_pi_contract.py | 2 +- 22 files changed, 1869 insertions(+), 102 deletions(-) create mode 100644 apps/web/e2e/planner-constraint-rules.spec.ts create mode 100644 docs/product/aps-planner-rush-natural-language-tests.md create mode 100644 server/aps_domain/constraint_rules.py create mode 100644 tests/golden/test_constraint_rules.py create mode 100644 tests/golden/test_planner_rush_natural_language.py diff --git a/apps/web/e2e/planner-constraint-rules.spec.ts b/apps/web/e2e/planner-constraint-rules.spec.ts new file mode 100644 index 0000000..ccf68f0 --- /dev/null +++ b/apps/web/e2e/planner-constraint-rules.spec.ts @@ -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 { + 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 { + 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> = []; + await page.route('**/api/constraints/rules/stage', route => { + staged.push(route.request().postDataJSON() as Record); + 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(); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3154586..ae13a0e 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -573,9 +573,38 @@ export async function postConstraintStage(sessionId: string, payload: Record; } +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('/api/constraints/library'); + +export async function postConstraintRuleStage(sessionId: string, payload: Record) { + 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 = () => diff --git a/apps/web/src/gov/GovConsole.tsx b/apps/web/src/gov/GovConsole.tsx index e59f920..e2a0305 100644 --- a/apps/web/src/gov/GovConsole.tsx +++ b/apps/web/src/gov/GovConsole.tsx @@ -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: { )} {section === 'settings.constraints' && ( - + )} {section === 'settings.integration' && } {section === 'settings.skills' && ( @@ -1052,12 +1056,41 @@ function ScheduleParamsView(props: { sessionId: string }) { ); } +const CONSTRAINT_DESCRIPTIONS: Record = { + 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(null); const [draft, setDraft] = useState>({}); + const [library, setLibrary] = useState(null); const [sops, setSops] = useState([]); const [sopsError, setSopsError] = useState(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(null); const [pending, setPending] = useState(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) => { + 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 ( <>

- 硬约束违反将阻止发布;软约束仅报告风险。内建项不可关闭。可将知识库 SOP 编译为规则包(IND-02)。 + 系统已预置通用约束。补充约束经确认后纳入规则库,并按业务类别与适用范围归档。 + 硬约束拦截不合规方案,软约束提示风险,系统内置规则不可停用。

+ {library && ( +
+
约束规则库
+
+ 通用规则 {library.summary.builtinTotal} 项 · 扩展规则 {library.summary.customTotal} 项 + (已用于排产 {library.summary.engineBound} 项 / 暂未用于排产 {library.summary.recordOnly} 项) +
+ {library.custom.length === 0 && ( +

+ 暂无扩展约束。可提交约束要求,系统整理并经确认后纳入规则库。 +

+ )} +
+
+
新增约束要求
+
描述业务要求和适用场景,系统将整理为待确认规则。
+
+