From b4c2d30898d39b66b930f5998fc550884fdf07af Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Fri, 16 Oct 2026 11:23:32 +0800
Subject: [PATCH] fix(ai): recover original analysis and copy results without
resubmission
---
.../jobs/[id]/recover/route.ts | 11 +
.../admin/AiModelManagementPanel.test.tsx | 33 +
components/admin/AiModelManagementPanel.tsx | 29 +-
.../DetailGeneratorWorkspace.tsx | 6 +-
.../DetailJobRecovery.test.tsx | 59 ++
.../detail-generator/DetailJobRecovery.tsx | 36 +
docs/ai-model-contracts-2026-10-16.md | 769 ++++++++++++++++++
docs/ai-model-management.md | 20 +-
...del-text-recovery-acceptance-2026-10-16.md | 68 ++
lib/__tests__/ai-model-contract-audit.test.ts | 37 +
lib/__tests__/ai-model-protocol.test.ts | 57 +-
lib/__tests__/ai-model-recovery.test.ts | 2 +-
lib/__tests__/ai-model-verification.test.ts | 39 +-
.../detail-generator-backend.test.ts | 36 +-
lib/__tests__/detail-generator-jobs.test.ts | 74 +-
lib/__tests__/detail-text-contract.test.ts | 30 +
.../detail-text-recovery-route.test.ts | 59 ++
lib/__tests__/detail-text-recovery.test.ts | 91 +++
lib/ai-model-contract-audit.ts | 91 +++
lib/ai-model-protocol.ts | 52 +-
lib/ai-model-service.ts | 3 +-
lib/ai-model-verification-state.ts | 7 +-
lib/ai-model-verification.ts | 13 +-
lib/detail-generator/jobs.ts | 41 +-
lib/detail-generator/provider.ts | 30 +-
lib/detail-generator/recovery.ts | 15 +
lib/detail-generator/repository.ts | 36 +-
lib/detail-generator/text-contract.ts | 34 +
lib/detail-generator/text-recovery.ts | 30 +
lib/detail-generator/types.ts | 1 +
scripts/audit-ai-model-contracts.ts | 26 +
scripts/verify-detail-text-recovery.ts | 62 ++
32 files changed, 1822 insertions(+), 75 deletions(-)
create mode 100644 app/api/detail-generator/jobs/[id]/recover/route.ts
create mode 100644 components/detail-generator/DetailJobRecovery.test.tsx
create mode 100644 components/detail-generator/DetailJobRecovery.tsx
create mode 100644 docs/ai-model-contracts-2026-10-16.md
create mode 100644 docs/ai-model-text-recovery-acceptance-2026-10-16.md
create mode 100644 lib/__tests__/ai-model-contract-audit.test.ts
create mode 100644 lib/__tests__/detail-text-contract.test.ts
create mode 100644 lib/__tests__/detail-text-recovery-route.test.ts
create mode 100644 lib/__tests__/detail-text-recovery.test.ts
create mode 100644 lib/ai-model-contract-audit.ts
create mode 100644 lib/detail-generator/recovery.ts
create mode 100644 lib/detail-generator/text-contract.ts
create mode 100644 lib/detail-generator/text-recovery.ts
create mode 100644 scripts/audit-ai-model-contracts.ts
create mode 100644 scripts/verify-detail-text-recovery.ts
diff --git a/app/api/detail-generator/jobs/[id]/recover/route.ts b/app/api/detail-generator/jobs/[id]/recover/route.ts
new file mode 100644
index 0000000..51d3a09
--- /dev/null
+++ b/app/api/detail-generator/jobs/[id]/recover/route.ts
@@ -0,0 +1,11 @@
+import { DetailGeneratorError, detailGeneratorRoute } from '@/lib/detail-generator/access'
+import { recoverDetailTextJob } from '@/lib/detail-generator/recovery'
+import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export async function POST(request: Request, { params }: { params: { id: string } }) {
+ return detailGeneratorRoute(async userId => {
+ if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') throw new DetailGeneratorError('恢复操作须使用 JSON 请求', 415, 'content_type_required')
+ return recoverDetailTextJob(userId, assertDetailId(params.id), await parseDetailJson(request))
+ })
+}
diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx
index fa33e24..b07731f 100644
--- a/components/admin/AiModelManagementPanel.test.tsx
+++ b/components/admin/AiModelManagementPanel.test.tsx
@@ -83,6 +83,39 @@ test('浏览器没有 randomUUID 时仍能提交一个有效 UUID 的联合测
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
} finally { if (original) Object.defineProperty(globalThis.crypto, 'randomUUID', original); else delete (globalThis.crypto as any).randomUUID }
})
+
+test('文字联合测试的原结果可只读取回,核查凭证在详情内,已固定请求号不能替换', async () => {
+ const next = jointConfiguration(), model = next.providers[0].models[2]
+ const requestId = 'a1111111-1111-4111-8111-111111111111'
+ model.capabilities.protocols = { analysis: { id: 'openai-chat', path: '/v1/chat/completions' }, copy: { id: 'openai-chat', path: '/v1/chat/completions' } }
+ for (const stage of ['analysis', 'copy'] as const) model.verification[stage] = { status: 'unknown', id: `text-${stage}`, requestId, checkedAt: null, message: '原结果待核查', purpose: 'analysis-copy', canResume: true, canReconcile: true }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ const row = await screen.findByLabelText('new-vision-model · 现有上游', { selector: 'div' })
+ fireEvent.click(within(row).getByRole('button', { name: '查询 new-vision-model · 现有上游 的分析与文案结果' }))
+ await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/refresh'))).toBe(true))
+ expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/verify'))).toBe(false)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ fireEvent.click(within(row).getByRole('button', { name: 'new-vision-model · 现有上游 分析与文案测试详情' }))
+ fireEvent.click(within(row).getByRole('button', { name: '核查 new-vision-model 的原请求凭证' }))
+ const field = screen.getByRole('textbox', { name: '客户请求编号' })
+ expect(field).toHaveValue(requestId)
+ expect(field).toHaveAttribute('readonly')
+ expect(screen.getByText(/未留存不代表执行失败/)).toBeVisible()
+})
+
+test.each([true, false])('展开联合结果时只合并相同请求号,保留不同请求的来源:shared=%s', async shared => {
+ const next = jointConfiguration(), model = next.providers[0].models[2]
+ const first = 'a1111111-1111-4111-8111-111111111111', second = shared ? first : 'b1111111-1111-4111-8111-111111111111'
+ model.verification.analysis = { ...model.verification.analysis, status: 'passed', requestId: first, checkedAt: '2026-10-16T02:00:00Z' }
+ model.verification.copy = { ...model.verification.copy, status: 'passed', requestId: second, checkedAt: '2026-10-16T02:00:00Z' }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ const row = await screen.findByLabelText('new-vision-model · 现有上游', { selector: 'div' })
+ fireEvent.click(within(row).getByRole('button', { name: 'new-vision-model · 现有上游 分析与文案测试详情' }))
+ expect(within(row).getAllByText(`请求编号:${first}`)).toHaveLength(1)
+ if (!shared) expect(within(row).getByText(`请求编号:${second}`)).toBeVisible()
+})
test('编号生成失败不会提交、刷新或伪造未知状态,恢复后可正常重试', async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')!, original = globalThis.crypto
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: {} })
diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx
index 1b89a27..29b826b 100644
--- a/components/admin/AiModelManagementPanel.tsx
+++ b/components/admin/AiModelManagementPanel.tsx
@@ -32,7 +32,13 @@ function needsReferenceAdaptation(model: AiModelCatalogEntry) {
}
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
-interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchImage: boolean; requestId: string }
+interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchOriginal: boolean; requestId: string; requestIdLocked: boolean }
+function recoveryFormFor(provider: AiModelProvider, model: AiModelCatalogEntry, purpose: AiModelTestPurpose, result: AiModelVerificationSummary): RecoveryForm {
+ const protocol = model.capabilities.protocols?.[purpose === 'analysis-copy' ? 'analysis' : purpose]
+ return { providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false,
+ canFetchOriginal: provider.enabled && provider.hasApiKey && model.present && ['openai-chat', 'anthropic-messages', 'openai-image-generate', 'openai-image-edit'].includes(protocol?.id || ''),
+ requestId: result.requestId || '', requestIdLocked: !!result.requestId }
+}
const pendingStorageKey = 'ai-model-pending-submissions-v1'
class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } }
@@ -258,9 +264,9 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
void run('reconcile', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/reconcile`, { method: 'POST', body: JSON.stringify({ outcome: recovery.outcome, reference: recovery.reference, note: recovery.note, confirmed: recovery.confirmed }) }), '核查已记录,可以重新测试', { preserveDraft: true })
}}>
核查原请求 · {recovery.modelId}
- {recovery.canFetchImage &&
-
有客户请求编号时,可取回原图并校验,不会重新生成。
-
+ {recovery.canFetchOriginal &&
+
取回并校验原响应,不会重新调用模型。未留存不代表执行失败。
+
}
先在上游核查并确认请求已结束。此操作不会通过接口测试。
@@ -314,6 +320,8 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const result = resultStage ? model.verification[resultStage] : null
const protocol = resultStage ? model.capabilities.protocols?.[resultStage] : null
const detailsId = `test-details-${model.key}-${selectedStage?.id}`
+ const sharedRequestId = result?.requestId && testedStages.length > 1 && testedStages.every(stage => model.verification[stage].requestId === result.requestId) ? result.requestId : null
+ const sharedCheckedAt = result?.checkedAt && testedStages.length > 1 && testedStages.every(stage => model.verification[stage].checkedAt === result.checkedAt) ? result.checkedAt : null
const canInspect = !!protocol || testedStages.some(stage => { const value = model.verification[stage]; return !!(value.message || value.checkedAt || value.requestId) })
const retest = testedStages.some(stage => { const value = model.verification[stage]; return value.status === 'passed' || value.status === 'failed' || value.status === 'stale' || value.reconciled })
const unsettled = Object.values(model.verification).some((entry) => entry.status === 'running' || entry.status === 'unknown')
@@ -330,7 +338,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const testStatusId = `test-status-${model.key}`
const blockedReasonId = `test-blocked-${model.key}`
const showBlockedReason = !!blockedReason && !testing && !attempt && !unsettled
- return
+ return
{model.id}
{capabilityLabels[model.capabilities.kind]} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}
{capabilityReason}
{selectedStage && result ? <>
-
-