false, 'error_code' => 'REQUIRED_FILES_REJECTED', 'error' => '合成附件失败', 'required_sections' => $sections, 'source_summary' => ['patient_name' => '不得回传的姓名', 'url' => 'https://private.invalid', 'required_sections' => $sections], 'items' => [['kind' => 'image', 'status' => 'failed', 'label' => '不得回传的姓名', 'url' => 'https://private.invalid/image.png', 'upload_file_id' => 'private-id'], ['kind' => 'video', 'status' => 'submitted', 'label' => 'private-video']], 'attachment_count' => 1, 'attachment_transfer' => 'uploaded_files', 'transfer_manifest_final' => true, 'upstream_http_status' => 400, 'upstream_message' => 'provider private message', 'api_key' => 'private-api-key']; errorReceiptExpect($format->invoke(null, [], $result) === null, 'real formatter retains failure'); $meta = DiagnosisAiLogic::getAssistantErrorMeta(); errorReceiptExpect(count($meta['required_sections']) === 13 && $meta['items'][0]['status'] === 'failed' && count($meta['items']) === 1 && $meta['attachment_transfer'] === 'uploaded_files', 'safe complete final receipt survives failure'); $encoded = json_encode($meta, JSON_UNESCAPED_UNICODE); foreach (['不得回传的姓名', 'private.invalid', 'private-id', 'private-api-key', 'provider private message', 'private-video'] as $private) { errorReceiptExpect(!str_contains($encoded, $private), 'error metadata excludes private fields'); } $protocol = new AssistantSseProtocol(); $protocol->encode('start', []); $frames = []; $emit = static function (string $event, array $payload) use ($protocol, &$frames): bool { $frames[] = $protocol->encode($event, $payload); return true; }; $controller = (new ReflectionClass(DiagnosisController::class))->newInstanceWithoutConstructor(); $finish = new ReflectionMethod(DiagnosisController::class, 'finishAssistantError'); $finish->invoke($controller, $emit, null, false); $frame = end($frames); errorReceiptExpect(str_contains($frame, 'REQUIRED_FILES_REJECTED') && str_contains($frame, '"transfer_manifest_final":true') && str_contains($frame, '"status":"failed"'), 'real controller SSE error carries authoritative failure receipt'); $frames = []; $cancelProtocol = new AssistantSseProtocol(); $cancelProtocol->encode('start', []); $cancelEmit = static function (string $event, array $payload) use ($cancelProtocol, &$frames): bool { $frames[] = $cancelProtocol->encode($event, $payload); return true; }; $finish->invoke($controller, $cancelEmit, null, true); errorReceiptExpect(!str_contains(end($frames), 'required_sections') && str_contains(end($frames), 'AI_ASSISTANT_CANCELLED'), 'cancel terminal does not reuse unrelated failed receipt'); $format->invoke(null, [], ['ok' => true, 'content' => '合成回复']); errorReceiptExpect(DiagnosisAiLogic::getAssistantErrorMeta() === [], 'success clears stale error metadata'); $skippedResult = $result; $skippedResult['items'] = [ ['kind' => 'image', 'status' => 'skipped', 'reason' => 'not_found', 'url' => 'https://private.invalid/missing'], ['kind' => 'image', 'status' => 'failed', 'reason' => 'private-error-body'], ]; $skippedResult['attachment_count'] = 2; $skippedResult['submitted_attachment_count'] = 0; $skippedResult['skipped_attachment_count'] = 1; $format->invoke(null, [], $skippedResult); $safeSkipped = DiagnosisAiLogic::getAssistantErrorMeta(); errorReceiptExpect($safeSkipped['items'][0]['status'] === 'skipped' && $safeSkipped['items'][0]['reason'] === 'not_found' && $safeSkipped['items'][1]['status'] === 'failed' && !isset($safeSkipped['items'][1]['reason']) && $safeSkipped['submitted_attachment_count'] === 0 && $safeSkipped['skipped_attachment_count'] === 1, 'final errors preserve safe missing-source facts and separate counters'); errorReceiptExpect(!str_contains(json_encode($safeSkipped), 'private.invalid') && !str_contains(json_encode($safeSkipped), 'private-error-body'), 'skipped error metadata never includes resource URLs or private reasons'); $imageEvidence = $result; $imageEvidence['items'] = [ ['kind' => 'image', 'status' => 'submitted', 'text_check_status' => 'withheld', 'evidence_status' => 'unverified', 'evidence_reason' => 'context_budget', 'raw_text' => 'PRIVATE_OCR_TEXT'], ['kind' => 'image', 'status' => 'submitted', 'text_check_status' => 'private-runtime-error', 'evidence_status' => 'unverified', 'evidence_reason' => 'private-provider-error'], ]; $format->invoke(null, [], $imageEvidence); $safeEvidence = DiagnosisAiLogic::getAssistantErrorMeta(); errorReceiptExpect($safeEvidence['items'][0]['text_check_status'] === 'withheld' && $safeEvidence['items'][0]['evidence_reason'] === 'context_budget' && !isset($safeEvidence['items'][1]['text_check_status'], $safeEvidence['items'][1]['evidence_reason']) && !str_contains(json_encode($safeEvidence), 'PRIVATE_OCR_TEXT') && !str_contains(json_encode($safeEvidence), 'private-provider-error'), 'only fixed image text and synthesis-gap enums survive the public receipt, never raw OCR or provider errors'); $success = $format->invoke(null, [], ['ok' => true, 'content' => '合成回复', 'transfer_manifest_final' => true, 'required_sections' => AiRequiredSource::manifest([]), 'items' => [['kind' => 'image', 'status' => 'skipped', 'reason' => 'not_found']], 'attachment_count' => 1, 'submitted_attachment_count' => 0, 'skipped_attachment_count' => 1, 'attachment_transfer' => 'uploaded_files']); errorReceiptExpect($success['answer'] === '合成回复' && $success['items'][0]['status'] === 'skipped' && $success['submitted_attachment_count'] === 0 && $success['skipped_attachment_count'] === 1, 'a text answer with all attachments missing keeps its successful final receipt'); echo "Assistant final error receipt / real controller SSE / private metadata filter / cancellation isolation: OK\n";