feat(docx): enhance readback validation and add new tests for docx handling
This commit is contained in:
1 parent
b050ffb188
commit
dbfb783320
5 files changed
+399
-1
No files matched your search
@@ -15,6 +15,8 @@ import type {
|
||||
HumanWatchHint
|
||||
} from '../../types'
|
||||
|
||||
const DOCX_READ_TOOL_NAMES = new Set(['docx_outline', 'docx_read_blocks'])
|
||||
|
||||
const docxReadSatisfiesWatchHint = (input: {
|
||||
decision: HumanDecision
|
||||
watchHint: Extract<HumanWatchHint, { code: 'docx_write_requires_readback' }>
|
||||
@@ -25,7 +27,7 @@ const docxReadSatisfiesWatchHint = (input: {
|
||||
const spec = getToolSpec(call.toolName)
|
||||
if (!spec) return false
|
||||
const meta = spec.resolveExecutionMeta(call.args)
|
||||
if (meta.mutability !== 'read_only' || meta.resourceKey !== 'docx') return false
|
||||
if (meta.mutability !== 'read_only' || !DOCX_READ_TOOL_NAMES.has(call.toolName)) return false
|
||||
const requestedPath =
|
||||
typeof call.args.sourcePath === 'string' ? call.args.sourcePath : undefined
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { describe, it } from 'vitest'
|
||||
import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/blockIndex'
|
||||
import { XMLSerializer } from '@xmldom/xmldom'
|
||||
import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../harness/backend/llmConfig'
|
||||
import { createHumanTestRunner } from '../../../harness/backend/humanHarness'
|
||||
import { runBackendCase } from '../../../harness/backend/run-backend-case'
|
||||
@@ -153,6 +155,34 @@ describe('ai human live backend', () => {
|
||||
const referencesTxtEvidence = result.workspaceSeed.relevantTxtRelativePaths.some((relativePath) =>
|
||||
deliveryText.includes(path.basename(relativePath))
|
||||
)
|
||||
const watchHints = filterAiRunEventsByType(events, 'watch_hint')
|
||||
const writeStartedEvents = filterAiRunEventsByType(events, 'tool_call_started').filter((event) =>
|
||||
[
|
||||
'docx_paragraph_edit',
|
||||
'docx_heading_edit',
|
||||
'docx_table_edit',
|
||||
'docx_text_edit',
|
||||
'docx_text_style_edit'
|
||||
].includes(event.toolName)
|
||||
)
|
||||
const readStartedEvents = filterAiRunEventsByType(events, 'tool_call_started').filter((event) =>
|
||||
['docx_outline', 'docx_read_blocks'].includes(event.toolName)
|
||||
)
|
||||
const hasReadbackAfterWrite =
|
||||
writeStartedEvents.length === 0 ||
|
||||
writeStartedEvents.some((writeEvent) =>
|
||||
readStartedEvents.some(
|
||||
(readEvent) =>
|
||||
readEvent.createdAt >= writeEvent.createdAt &&
|
||||
readEvent.input.sourcePath === writeEvent.input.sourcePath
|
||||
)
|
||||
)
|
||||
const reviewArtifact = result.output?.artifacts?.find((artifact) => artifact.kind === 'draft_review')
|
||||
let reviewXml = ''
|
||||
if (reviewArtifact?.review.draftArtifactPath) {
|
||||
const reviewBundle = await loadDocxBundle(await fs.readFile(reviewArtifact.review.draftArtifactPath))
|
||||
reviewXml = new XMLSerializer().serializeToString(reviewBundle.documentDom)
|
||||
}
|
||||
|
||||
runtime.assertThat(
|
||||
result.output?.status === 'completed' &&
|
||||
@@ -243,6 +273,31 @@ describe('ai human live backend', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
runtime.assertThat(
|
||||
watchHints.some((event) => event.code === 'docx_write_requires_readback') &&
|
||||
hasReadbackAfterWrite,
|
||||
{
|
||||
name: 'human-live-risk-report-writes-then-reads-back-latest-docx',
|
||||
message: '同文件 docx 写后,live case 事件流里应出现 docx_write_requires_readback,并在之后出现对应 readback。',
|
||||
data: {
|
||||
watchHints,
|
||||
writeStartedEvents,
|
||||
readStartedEvents
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
runtime.assertThat(
|
||||
!reviewXml.includes('review_artifact_invalid_generated'),
|
||||
{
|
||||
name: 'human-live-risk-report-review-hides-generated-discarded-draft',
|
||||
message: '最终 review 不应包含 generated 中间废稿的删除项痕迹。',
|
||||
data: {
|
||||
reviewXmlSnippet: reviewXml.slice(0, 2000)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}, 900_000)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import { describe, it } from 'vitest'
|
||||
import { XMLSerializer } from '@xmldom/xmldom'
|
||||
import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/blockIndex'
|
||||
import {
|
||||
createDocxToolHarness
|
||||
} from '../../../harness/backend/aiToolHarness'
|
||||
import { runBackendCase } from '../../../harness/backend/run-backend-case'
|
||||
import type { DocxCreateResult, DocxMutationResult, DocxOutlineResult } from './testUtils'
|
||||
|
||||
describe('ai tool docx backend', () => {
|
||||
it('docx-create-target-review-shows-net-additions-only', async () => {
|
||||
await runBackendCase({
|
||||
caseId: 'docx-create-target-review-shows-net-additions-only',
|
||||
harnessMode: 'tool-real',
|
||||
domain: 'ai',
|
||||
suite: 'tool',
|
||||
groups: ['docx'],
|
||||
description: '验证 create-target 场景多轮草稿往返后,review 只展示最终存活新增内容。',
|
||||
fixtures: [],
|
||||
runMode: 'single',
|
||||
execute: async (runtime) => {
|
||||
const harness = createDocxToolHarness(runtime)
|
||||
try {
|
||||
harness.ctx.beginDocxReviewRun()
|
||||
await harness.invoke<DocxCreateResult>('docx_create', {
|
||||
sourcePath: 'net-additions-only.docx',
|
||||
blocks: [
|
||||
{ type: 'heading', level: 1, text: 'Final Report' },
|
||||
{ type: 'paragraph', text: 'Keep this final paragraph.' }
|
||||
]
|
||||
})
|
||||
|
||||
const outline = await harness.invoke<DocxOutlineResult>('docx_outline', {
|
||||
sourcePath: 'net-additions-only.docx'
|
||||
})
|
||||
const finalParagraphBlockId =
|
||||
outline.blocks.find(
|
||||
(block) => block.type === 'paragraph' && block.preview.includes('Keep this final paragraph.')
|
||||
)?.blockId || ''
|
||||
|
||||
const inserted = await harness.invoke<DocxMutationResult>('docx_paragraph_edit', {
|
||||
sourcePath: 'net-additions-only.docx',
|
||||
action: 'insert_after',
|
||||
anchorBlockId: finalParagraphBlockId,
|
||||
text: 'Temporary draft that should disappear from review.'
|
||||
})
|
||||
const temporaryBlockId = inserted.createdBlocks?.[0]?.blockId || ''
|
||||
|
||||
await harness.invoke<DocxMutationResult>('docx_paragraph_edit', {
|
||||
sourcePath: 'net-additions-only.docx',
|
||||
action: 'delete',
|
||||
blockId: temporaryBlockId
|
||||
})
|
||||
|
||||
const details = await harness.ctx.finalizeDocxReviewRun()
|
||||
const reviewPath = details[0]?.review.draft_artifact_path
|
||||
if (!reviewPath) {
|
||||
throw new Error('expected review artifact path')
|
||||
}
|
||||
|
||||
const reviewBundle = await loadDocxBundle(await fs.readFile(reviewPath))
|
||||
const reviewXml = new XMLSerializer().serializeToString(reviewBundle.documentDom)
|
||||
return {
|
||||
reviewXml
|
||||
}
|
||||
} finally {
|
||||
await harness.release()
|
||||
}
|
||||
},
|
||||
assert: async (result, runtime) => {
|
||||
runtime.assertThat(
|
||||
result.reviewXml.includes('Final Report') &&
|
||||
result.reviewXml.includes('Keep this final paragraph.') &&
|
||||
!result.reviewXml.includes('Temporary draft that should disappear from review.'),
|
||||
{
|
||||
name: 'create-target-review-only-shows-final-net-additions',
|
||||
message: 'create-target review 应只保留最终存活新增内容,不展示 AI 中途删掉的废稿。',
|
||||
data: {
|
||||
reviewXmlSnippet: result.reviewXml.slice(0, 1800)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache'
|
||||
import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema'
|
||||
import { applyBrainDecisionTransition } from '../../../../src/main/services/ai/human/node/utils/stateTransitions'
|
||||
import type { AiRunContext } from '../../../../src/main/services/ai/types'
|
||||
import type { HumanDecision } from '../../../../src/main/services/ai/human/types'
|
||||
|
||||
const createStubAiRunContext = (): AiRunContext => ({
|
||||
runId: 'run-brain-docx-readback-gate',
|
||||
referenceScope: {
|
||||
workspace: {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'workspace',
|
||||
rootPath: '/tmp/workspace-root'
|
||||
},
|
||||
selectedReferences: [],
|
||||
hasExplicitSelectedReferences: false,
|
||||
hasDirectDocumentNarrowing: false,
|
||||
defaultKnowledgeScope: 'current_workspace'
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'workspace',
|
||||
rootPath: '/tmp/workspace-root'
|
||||
},
|
||||
checkCancelled: () => {},
|
||||
chatStructured: async () => {
|
||||
throw new Error('chatStructured should not be called in this unit test')
|
||||
},
|
||||
chatText: async () => {
|
||||
throw new Error('chatText should not be called in this unit test')
|
||||
},
|
||||
sendEvent: () => {},
|
||||
requestApproval: async () => ({ approved: false }),
|
||||
loadHistory: async () => ({
|
||||
recentTurnsText: '',
|
||||
sessionSummaryText: ''
|
||||
}),
|
||||
trackDocxOpen: () => {},
|
||||
trackDocxChanges: () => {},
|
||||
resolveDocxOpenTarget: async () => {
|
||||
throw new Error('resolveDocxOpenTarget should not be called in this unit test')
|
||||
},
|
||||
beginDocxReviewRun: () => {},
|
||||
finalizeDocxReviewRun: async () => [],
|
||||
persistDocxReviewRun: async () => {},
|
||||
discardDocxReviewRun: () => {},
|
||||
documentsStore: undefined as never,
|
||||
embeddingService: undefined as never
|
||||
})
|
||||
|
||||
const buildStateWithReadbackHint = () => ({
|
||||
...createInitialHumanState({
|
||||
taskId: 'task-brain-docx-readback-gate',
|
||||
userInput: 'test'
|
||||
}),
|
||||
execution: {
|
||||
...createInitialHumanState({
|
||||
taskId: 'task-brain-docx-readback-gate',
|
||||
userInput: 'test'
|
||||
}).execution,
|
||||
watchHint: {
|
||||
code: 'docx_write_requires_readback' as const,
|
||||
detail: {
|
||||
sourcePaths: ['/tmp/workspace-root/report.docx'],
|
||||
latestRevisionRefs: ['v3'],
|
||||
suggestedToolNames: ['docx_outline', 'docx_read_blocks']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('ai tool hand backend', () => {
|
||||
it('brain-docx-readback-gate', () => {
|
||||
const ctx = createStubAiRunContext()
|
||||
const cache = createHumanRuntimeCache()
|
||||
const baseState = buildStateWithReadbackHint()
|
||||
|
||||
const mouthDecision: HumanDecision = {
|
||||
decision: {
|
||||
summary: '直接结束',
|
||||
reason: '故意测试 gate',
|
||||
expectedOutcome: '应被阻断'
|
||||
},
|
||||
nextAction: {
|
||||
action: 'mouth'
|
||||
}
|
||||
}
|
||||
|
||||
const blockedMouth = applyBrainDecisionTransition({
|
||||
ctx,
|
||||
cache,
|
||||
state: baseState,
|
||||
decision: mouthDecision,
|
||||
stableMemory: baseState.memory.stable
|
||||
})
|
||||
|
||||
expect(blockedMouth.execution.stage).toBe('brain')
|
||||
expect(blockedMouth.execution.watchHint?.code).toBe('docx_write_requires_readback')
|
||||
|
||||
const readDecision: HumanDecision = {
|
||||
decision: {
|
||||
summary: '先 readback',
|
||||
reason: '满足 gate',
|
||||
expectedOutcome: '允许进入 hand'
|
||||
},
|
||||
nextAction: {
|
||||
action: 'hand',
|
||||
toolUsePlan: {
|
||||
mode: 'serial',
|
||||
calls: [
|
||||
{
|
||||
toolName: 'docx_outline',
|
||||
args: {
|
||||
sourcePath: 'report.docx'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowedRead = applyBrainDecisionTransition({
|
||||
ctx,
|
||||
cache,
|
||||
state: baseState,
|
||||
decision: readDecision,
|
||||
stableMemory: baseState.memory.stable
|
||||
})
|
||||
|
||||
expect(allowedRead.execution.stage).toBe('hand')
|
||||
expect(allowedRead.execution.watchHint).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it } from 'vitest'
|
||||
import { createHandNode } from '../../../../src/main/services/ai/human/node/handNode'
|
||||
import { ensureSkillTools } from '../../../../src/main/services/ai/human/node/learningNode/skillLoader'
|
||||
import { createHumanRuntimeCache } from '../../../../src/main/services/ai/human/node/utils/runtimeCache'
|
||||
import { createInitialHumanState } from '../../../../src/main/services/ai/human/state/schema'
|
||||
import { writeTestDocxFile } from '../../../harness/backend/aiToolHarness'
|
||||
import { runBackendCase } from '../../../harness/backend/run-backend-case'
|
||||
import { executeToolCall } from '../../../../src/main/services/ai/human/node/handNode/toolExecutor'
|
||||
import type { DocxOutlineResult } from '../docx/testUtils'
|
||||
|
||||
describe('ai tool hand backend', () => {
|
||||
it('hand-docx-serial-snapshot-bound-edit-plan-invalid-target', async () => {
|
||||
await runBackendCase({
|
||||
caseId: 'hand-docx-serial-snapshot-bound-edit-plan-invalid-target',
|
||||
harnessMode: 'tool-real',
|
||||
domain: 'ai',
|
||||
suite: 'tool',
|
||||
groups: ['hand'],
|
||||
description: '验证事务段中若后续目标已被前序操作删除,应明确失败并中止,不允许 silent fallback。',
|
||||
fixtures: [],
|
||||
runMode: 'single',
|
||||
execute: async (runtime) => {
|
||||
await writeTestDocxFile(runtime.workspaceRoot, 'invalid-target.docx', [
|
||||
{ type: 'paragraph', text: 'Alpha' },
|
||||
{ type: 'paragraph', text: 'Beta' }
|
||||
])
|
||||
|
||||
const ctx = runtime.createAiRunContext()
|
||||
const cache = createHumanRuntimeCache()
|
||||
|
||||
ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-read' })
|
||||
ensureSkillTools({ cache, ctx, runId: runtime.runId, taskId: runtime.taskId, skillId: 'docx-write' })
|
||||
|
||||
const initialOutline = await executeToolCall({
|
||||
ctx,
|
||||
cache,
|
||||
taskId: runtime.taskId,
|
||||
learnedSkillIds: ['docx-read', 'docx-write'],
|
||||
toolName: 'docx_outline',
|
||||
args: {
|
||||
sourcePath: 'invalid-target.docx'
|
||||
}
|
||||
})
|
||||
const outlineData = initialOutline.data as DocxOutlineResult
|
||||
const alphaBlockId =
|
||||
outlineData.blocks.find((block) => block.type === 'paragraph' && block.preview.includes('Alpha'))?.blockId || ''
|
||||
|
||||
const handNode = createHandNode({ ctx, cache })
|
||||
const state = await handNode({
|
||||
...createInitialHumanState({
|
||||
taskId: runtime.taskId,
|
||||
userInput: '请连续删除再修改同一段'
|
||||
}),
|
||||
skills: {
|
||||
learnedSkillIds: ['docx-read', 'docx-write']
|
||||
},
|
||||
execution: {
|
||||
...createInitialHumanState({
|
||||
taskId: runtime.taskId,
|
||||
userInput: '请连续删除再修改同一段'
|
||||
}).execution,
|
||||
currentDecision: {
|
||||
decision: {
|
||||
summary: '先删再改同一目标',
|
||||
reason: '验证目标失效时会中止事务段。',
|
||||
expectedOutcome: '第二步明确失败。'
|
||||
},
|
||||
nextAction: {
|
||||
action: 'hand',
|
||||
toolUsePlan: {
|
||||
mode: 'serial',
|
||||
calls: [
|
||||
{
|
||||
toolName: 'docx_paragraph_edit',
|
||||
args: {
|
||||
sourcePath: 'invalid-target.docx',
|
||||
action: 'delete',
|
||||
blockId: alphaBlockId
|
||||
}
|
||||
},
|
||||
{
|
||||
toolName: 'docx_paragraph_edit',
|
||||
args: {
|
||||
sourcePath: 'invalid-target.docx',
|
||||
action: 'replace_text',
|
||||
blockId: alphaBlockId,
|
||||
text: 'Should fail'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
state
|
||||
}
|
||||
},
|
||||
assert: async (result, runtime) => {
|
||||
const results = result.state.execution.lastToolResults || []
|
||||
runtime.assertThat(
|
||||
results.length === 2 &&
|
||||
results[0]?.code === 'ok' &&
|
||||
results[1]?.code === 'invalid_input' &&
|
||||
results[1]?.diagnostic?.message.includes('already been removed'),
|
||||
{
|
||||
name: 'invalid-target-aborts-transaction-with-explicit-error',
|
||||
message: '事务段内后续目标若已被前序操作删除,应明确失败并中止,而不是 fallback 到别的块。',
|
||||
data: {
|
||||
lastToolResults: results,
|
||||
watchHint: result.state.execution.watchHint
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user