From be7d765018a7c3e5cdfa4631d962327457cf33bd Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 10 Sep 2026 15:58:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=A2=9E=E5=8A=A0=E5=8E=9F=E5=A7=8B=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...10-leader-summary-input-fields-01a0858b.md | 50 +++++++ .../src/leader-notification-service.ts | 13 ++ control-plane/src/leadership-task-summary.ts | 47 ++++-- .../test/leader-notification-contract.test.ts | 2 + .../test/leadership-task-summary.test.ts | 141 +++++++++++++++++- 5 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260910-leader-summary-input-fields-01a0858b.md diff --git a/.project-docs/30-worklog/tasks/20260910-leader-summary-input-fields-01a0858b.md b/.project-docs/30-worklog/tasks/20260910-leader-summary-input-fields-01a0858b.md new file mode 100644 index 0000000..5bf3e05 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260910-leader-summary-input-fields-01a0858b.md @@ -0,0 +1,50 @@ +# Leader summaries with original input text + +## Scope and authorization + +- User requested removing the public task ID from the notification body and adding input. Latest correction explicitly selects the original input text, superseding the earlier Chinese business-field preference. +- Feature task: owns this record, leader-summary formatter/input projection, dedicated notification worker reads, and relevant tests. Shared canonical documents remain unchanged; contract updates are promotion candidates. +- Local implementation and validation only. No deployment, service restart, ERP access, or external message sending. +- No subagents: repository instructions require explicit user consent, which has not been provided. + +## Worktree safety + +- Branch: `main` +- Worktree: `/Users/andy/IdeaProjects/LWLT-AIBOT` +- Base commit: `7ea7c211cc8e543a4089b1efd212aca2a838e1b7` +- Existing changes: untracked `.idea/`, unrelated and preserved. +- Other worktrees: none. Overlap result: Clear for notification source/tests. + +## Implementation plan + +- Remove task-number text while retaining internal task association, deduplication and diagnostic IDs. +- Render the task's original submitted text from `original_text_ciphertext`, preserving multiline content. No parsed-field extraction or attachment expansion. +- Bound the entire message to an internal 4,000-byte UTF-8 display budget, visibly abbreviating only excessive input while retaining the result. This is a local display safeguard, not an assertion about the external provider's documented limit. +- Missing/invalid encrypted input must not prevent other task notifications; show an unavailable-input message instead. +- Cover exact input preservation, all stable outcomes, missing/invalid ciphertext, result updates, multibyte boundaries and worker data plumbing with local regression tests. +- Run repository-required checks and build; document unrelated local hygiene limitations separately. + +## Status + +- Complete locally; production deployment and actual WeChat delivery remain for the user. +- Notification body now shows employee, business, status, submission time, original input and result. The generated task-number line is removed; task IDs still link internal delivery records, logs and deduplication. +- Input is the first submitted text stored in `tasks.original_text_ciphertext`, shared by manual and AgentBus intake. Later conversational supplements and attachment contents are not fetched. Parse failures can therefore still show original input without a parsed operation. +- Original punctuation, tabs and line layout are retained; line endings are normalized and nonprinting controls/outer whitespace removed. Only excessive original input is abbreviated with a visible notice under the 4,000-byte total display budget; the outcome remains complete. +- Missing or undecryptable original text displays `原始输入暂不可用,请在平台查看。` and does not abort the projection batch. No input plaintext, ciphertext or response body is newly logged. +- Existing encrypted outbox, future-only scope, one-attempt sends, fixed recipient configuration, task execution and database schema are unchanged. Previously materialized delivery text is not rewritten or resent. +- Deployment requires a new server image/restart, with no extension update or SQL migration for this change. + +## Validation and final review + +- Targeted summary/transport contract checks: 20/20 PASS, including 9 new behavioral regressions for exact multiline text, failed parsing, result updates, unavailable input, UTF-8 length boundaries and decryption failure isolation. +- `node --run check`: PASS; `node --run build`: PASS in the original checkout. +- `node --run check:repo` in the original checkout: 8/10 PASS, with only pre-existing `.idea/`, root `.DS_Store` and `chrome-extension/.DS_Store` hygiene violations. All existing local files preserved. +- Clean validation snapshot `/private/tmp/ltjt-leader-original-input-verify-w90er9zg` contains tracked files with current edits plus this task record, excludes local secrets and unrelated untracked/Finder files, and reuses installed dependencies through a symlink. Repository checks 10/10 PASS; full control-plane tests 192/192 PASS. +- Full legacy suite in that snapshot: 285/287 PASS under the sandbox; two existing localhost HTTP tests failed solely on `listen EPERM: 127.0.0.1`. Both were rerun with the required localhost permission and PASS 2/2. They use mock parsers and no external services. No other test failures remain. +- Final local read-only review: PASS. Worker selects the original encrypted column and decrypts only for summary rendering; formatter has no generated task ID; dispatcher still validates encrypted payload fingerprints and retains internal IDs. Scope is two production TypeScript files, two tests and this record. +- `git diff --check`: PASS. No deployment, restart, ERP action or external notification was performed. + +## Promotion candidates + +- Update `agent设计规范/leader-summary-webhook-contract.md` and AUTH-005/canonical notification documentation during integration: task ID no longer appears as a generated message field, and original submitted text is included. Internal IDs, deduplication, delivery scope and one-attempt transport are unchanged. +- The explicit request to display raw input supersedes the old body's exclusion of raw instructions and any content the user puts in them. No additional customer/passenger records, attachments, credentials or technical internals are fetched or appended. Notification text/ciphertext is not written to logs. diff --git a/control-plane/src/leader-notification-service.ts b/control-plane/src/leader-notification-service.ts index e14de30..ac5311c 100644 --- a/control-plane/src/leader-notification-service.ts +++ b/control-plane/src/leader-notification-service.ts @@ -98,6 +98,17 @@ function jsonObject(value: unknown): Record { : {}; } +export function readLeaderSummaryOriginalText(config: AppConfig, ciphertext: unknown): string | null { + if (typeof ciphertext !== 'string' || !ciphertext) return null; + try { + return decryptText(config, ciphertext); + } catch { + // One unreadable historical input must not stop this batch's notifications. + // Neither ciphertext nor decrypted instructions belong in diagnostics. + return null; + } +} + export class LeaderNotificationService { private projectorTimer: NodeJS.Timeout | null = null; private projectorInFlight: Promise | null = null; @@ -433,6 +444,7 @@ export class LeaderNotificationService { task.business_route_id, task.success_receipt, task.created_at, + task.original_text_ciphertext, assignee.username AS assignee_username, source_event.id AS source_outbox_event_id, COALESCE(delivery_state.has_exposed_needs_review, false) AS has_exposed_needs_review @@ -545,6 +557,7 @@ export class LeaderNotificationService { businessRouteId: text(row.business_route_id) || null, assigneeUsername: text(row.assignee_username), createdAt: String(row.created_at), + originalText: readLeaderSummaryOriginalText(this.config, row.original_text_ciphertext), successReceipt: jsonObject(row.success_receipt), hadNeedsReview: hadExposedNeedsReview }); diff --git a/control-plane/src/leadership-task-summary.ts b/control-plane/src/leadership-task-summary.ts index 4c45cf6..748624c 100644 --- a/control-plane/src/leadership-task-summary.ts +++ b/control-plane/src/leadership-task-summary.ts @@ -9,6 +9,7 @@ export interface LeaderTaskSummaryInput { businessRouteId: string | null; assigneeUsername: string; createdAt: string | Date; + originalText?: string | null; successReceipt?: Record | null; hadNeedsReview?: boolean; } @@ -38,6 +39,29 @@ const NEEDS_REVIEW_STATUSES = new Set([ 'uncertain' ]); +// A local display budget; only the original input may be abbreviated. Reserve +// space for the outcome so a long instruction cannot hide the actual result. +export const LEADER_SUMMARY_MAX_MESSAGE_BYTES = 4_000; +const INPUT_TRUNCATION_NOTICE = '\n…(原文过长,剩余内容请在平台查看)'; + +function originalInputText(value: string | null | undefined, byteBudget: number): string { + const original = typeof value === 'string' + ? value.replace(/\r\n?/gu, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').trim() + : ''; + if (!original) return '原始输入暂不可用,请在平台查看。'; + if (Buffer.byteLength(original, 'utf8') <= byteBudget) return original; + const prefixBudget = Math.max(0, byteBudget - Buffer.byteLength(INPUT_TRUNCATION_NOTICE, 'utf8')); + let prefix = ''; + let bytes = 0; + for (const character of original) { + const nextBytes = Buffer.byteLength(character, 'utf8'); + if (bytes + nextBytes > prefixBudget) break; + prefix += character; + bytes += nextBytes; + } + return prefix + INPUT_TRUNCATION_NOTICE; +} + function singleLine(value: unknown, maxLength: number): string { return String(value ?? '') .normalize('NFKC') @@ -131,23 +155,26 @@ export function buildLeaderTaskSummary( const route = businessRouteById(input.businessRouteId); const businessLabel = singleLine(route?.directive || '其他任务', 120) || '其他任务'; const assignee = singleLine(input.assigneeUsername, 160) || '未知员工'; - const taskId = singleLine(input.taskId, 200) || '任务编号未记录'; const header = milestone === 'resolved' ? '【员工任务摘要·结果更新】' : '【员工任务摘要】'; + const prefix = [ + header, + `员工:${assignee}`, + `业务:${businessLabel}`, + `状态:${statusLabel}`, + `提交:${formatShanghaiTimestamp(input.createdAt)}`, + '', + '原始输入:', + '' + ].join('\n'); + const suffix = `\n\n结果:${resultText.slice(0, 300)}`; + const inputBudget = LEADER_SUMMARY_MAX_MESSAGE_BYTES - Buffer.byteLength(prefix + suffix, 'utf8'); return { milestone, deliveryStatus, statusLabel, businessLabel, resultText: resultText.slice(0, 300), - messageText: [ - header, - `员工:${assignee}`, - `业务:${businessLabel}`, - `状态:${statusLabel}`, - `任务:${taskId}`, - `提交:${formatShanghaiTimestamp(input.createdAt)}`, - `结果:${resultText.slice(0, 300)}` - ].join('\n') + messageText: prefix + originalInputText(input.originalText, inputBudget) + suffix }; } diff --git a/control-plane/test/leader-notification-contract.test.ts b/control-plane/test/leader-notification-contract.test.ts index 30ecd5e..5b2fb19 100644 --- a/control-plane/test/leader-notification-contract.test.ts +++ b/control-plane/test/leader-notification-contract.test.ts @@ -61,6 +61,8 @@ test('projection is organization-scoped, future-only, role-safe, source-limited assert.match(service, /assignee\.organization_id = task\.organization_id/); assert.match(service, /assignee\.role <> 'admin'/); assert.match(service, /task\.source IN \('manual', 'agentbus'\)/); + assert.match(service, /task\.original_text_ciphertext,/); + assert.match(service, /originalText: readLeaderSummaryOriginalText\(this\.config, row\.original_text_ciphertext\)/); assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s); assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s); assert.match(service, /pg_try_advisory_xact_lock/); diff --git a/control-plane/test/leadership-task-summary.test.ts b/control-plane/test/leadership-task-summary.test.ts index a6f3ee5..d88cd55 100644 --- a/control-plane/test/leadership-task-summary.test.ts +++ b/control-plane/test/leadership-task-summary.test.ts @@ -3,8 +3,12 @@ import test from 'node:test'; import { buildLeaderTaskSummary, formatShanghaiTimestamp, - isLeaderTaskSummaryStatus + isLeaderTaskSummaryStatus, + LEADER_SUMMARY_MAX_MESSAGE_BYTES } from '../src/leadership-task-summary.js'; +import { loadConfig } from '../src/config.js'; +import { encryptText } from '../src/crypto.js'; +import { readLeaderSummaryOriginalText } from '../src/leader-notification-service.js'; const base = { taskId: 'TASK-20260907-001', @@ -13,6 +17,141 @@ const base = { createdAt: '2026-09-07T00:05:06.000Z' }; +const originalHotelInput = [ + '安排酒店', + '团号:LW-260915A-B', + '入住日期:2026-09-16', + '离店日期:2026-09-18', + '酒店搜索:示例酒店 twn', + '间数:9', + '备注:请保留原话、全角标点及 两个空格。' +].join('\n'); + +test('notification shows the original multiline input and omits the generated task-number field', () => { + const summary = buildLeaderTaskSummary({ + ...base, + status: 'completed', + originalText: originalHotelInput + }); + assert.equal(summary?.messageText, [ + '【员工任务摘要】', + '员工:employee-a', + '业务:安排酒店', + '状态:已完成', + '提交:2026-09-07 08:05:06', + '', + '原始输入:', + originalHotelInput, + '', + '结果:业务处理已完成,结果已记录。' + ].join('\n')); + assert.doesNotMatch(summary?.messageText || '', /TASK-20260907-001|^任务:/mu); +}); + +test('original input is available even when parsing failed or the business route is unknown', () => { + for (const status of ['parse_failed', 'blocked', 'cancelled', 'saved_unverified', 'dry_run']) { + const summary = buildLeaderTaskSummary({ + ...base, + businessRouteId: null, + status, + originalText: '这段输入未能解析:日期稍后补充。\n第二行仍须展示。' + }); + assert.match(summary?.messageText || '', /业务:其他任务/); + assert.match(summary?.messageText || '', /原始输入:\n这段输入未能解析:日期稍后补充。\n第二行仍须展示。/); + assert.doesNotMatch(summary?.messageText || '', /TASK-20260907-001|^任务:/mu); + } +}); + +test('result-update messages retain the original input and full result', () => { + const summary = buildLeaderTaskSummary({ + ...base, + status: 'completed', + hadNeedsReview: true, + originalText: originalHotelInput, + successReceipt: { group_number: 'LW-260915A-B' } + }); + assert.equal(summary?.milestone, 'resolved'); + assert.ok(summary?.messageText.startsWith('【员工任务摘要·结果更新】')); + assert.ok(summary?.messageText.includes(originalHotelInput)); + assert.ok(summary?.messageText.endsWith('结果:业务处理已完成。团号:LW-260915A-B')); +}); + +test('input formatting preserves punctuation and tabs, normalizes newlines and removes nonprinting controls', () => { + const summary = buildLeaderTaskSummary({ + ...base, + status: 'completed', + originalText: ' 安排酒店\r\n酒店:A酒店\tTWN\r间数:9\u0000\u0007 ' + }); + assert.ok(summary?.messageText.includes('原始输入:\n安排酒店\n酒店:A酒店\tTWN\n间数:9\n\n结果:')); + assert.doesNotMatch(summary?.messageText || '', /\r|\u0000|\u0007/); +}); + +test('missing input has an explicit fallback without inventing input from a receipt', () => { + for (const originalText of [undefined, null, '', ' \n\t ']) { + const summary = buildLeaderTaskSummary({ + ...base, + status: 'failed', + originalText, + successReceipt: { original_text: '不能作为原始输入的回执内容' } + }); + assert.match(summary?.messageText || '', /原始输入:\n原始输入暂不可用,请在平台查看。/); + assert.doesNotMatch(summary?.messageText || '', /不能作为原始输入的回执内容/); + } +}); + +test('UTF-8 budget retains fitting inputs and visibly truncates only overflowing input', () => { + const options = { ...base, status: 'completed', originalText: 'x' }; + const fixedBytes = Buffer.byteLength(buildLeaderTaskSummary(options)!.messageText, 'utf8') - 1; + const available = LEADER_SUMMARY_MAX_MESSAGE_BYTES - fixedBytes; + const fittingInput = 'a'.repeat(available); + const fitting = buildLeaderTaskSummary({ ...options, originalText: fittingInput })!; + assert.equal(Buffer.byteLength(fitting.messageText, 'utf8'), LEADER_SUMMARY_MAX_MESSAGE_BYTES); + assert.ok(fitting.messageText.includes(fittingInput)); + assert.doesNotMatch(fitting.messageText, /原文过长/); + const overflowing = buildLeaderTaskSummary({ ...options, originalText: fittingInput + 'a' })!; + assert.ok(Buffer.byteLength(overflowing.messageText, 'utf8') <= LEADER_SUMMARY_MAX_MESSAGE_BYTES); + assert.match(overflowing.messageText, /原文过长,剩余内容请在平台查看/); + assert.ok(overflowing.messageText.endsWith('结果:业务处理已完成,结果已记录。')); +}); + +test('very long Chinese and emoji input cannot break Unicode or displace the outcome', () => { + const summary = buildLeaderTaskSummary({ + ...base, + assigneeUsername: '员'.repeat(200), + status: 'completed', + hadNeedsReview: true, + originalText: '酒店🏨中文\n'.repeat(20_000), + successReceipt: { group_numbers: Array.from({ length: 10 }, (_, index) => `GROUP-${index}-${'A'.repeat(60)}`) } + })!; + assert.ok(Buffer.byteLength(summary.messageText, 'utf8') <= LEADER_SUMMARY_MAX_MESSAGE_BYTES); + assert.equal(Buffer.from(summary.messageText).toString('utf8'), summary.messageText); + assert.doesNotMatch(summary.messageText, /\uFFFD/); + assert.match(summary.messageText, /原文过长,剩余内容请在平台查看/); + assert.ok(summary.messageText.endsWith(`结果:${summary.resultText}`)); +}); + +test('worker reads the same encrypted original input for manual and AgentBus tasks', () => { + const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 17).toString('base64') }); + const ciphertext = encryptText(config, originalHotelInput); + const originalText = readLeaderSummaryOriginalText(config, ciphertext); + assert.equal(originalText, originalHotelInput); + const summary = buildLeaderTaskSummary({ ...base, status: 'completed', originalText }); + assert.ok(summary?.messageText.includes(originalHotelInput)); + assert.ok(!summary?.messageText.includes(ciphertext)); +}); + +test('missing, invalid or unreadable ciphertext does not prevent a notification', () => { + const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 17).toString('base64') }); + const otherConfig = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 18).toString('base64') }); + for (const ciphertext of [null, undefined, '', {}, 'not-encrypted', encryptText(otherConfig, originalHotelInput)]) { + const originalText = readLeaderSummaryOriginalText(config, ciphertext); + assert.equal(originalText, null); + const summary = buildLeaderTaskSummary({ ...base, status: 'completed', originalText }); + assert.match(summary?.messageText || '', /原始输入暂不可用/); + assert.ok(summary?.messageText.endsWith('结果:业务处理已完成,结果已记录。')); + } +}); + test('leader summary projects only stable outcomes', () => { assert.equal(buildLeaderTaskSummary({ ...base, status: 'running' }), null); assert.equal(buildLeaderTaskSummary({ ...base, status: 'awaiting_confirmation' }), null);