$file) { $missing = $GLOBALS['missingFallbackScenario'] === 'all-missing' || $position === 0; if ($missing) { $skips[] = ['index' => $position + 1, 'kind' => $file['type'], 'reason' => 'not_found']; ($options['on_upload'])(['index' => $position + 1, 'status' => 'skipped', 'reason' => 'not_found']); } else { $wire[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'synthetic-id']; $indices[] = $position + 1; ($options['on_upload'])(['index' => $position + 1, 'status' => 'uploaded']); } } return ['ok' => true, 'files' => $wire, 'uploaded_indices' => $indices, 'skipped' => $skips]; } } function curl_init(): \stdClass { return new \stdClass(); } function curl_setopt_array(\stdClass $handle, array $options): bool { if (!str_starts_with($options[CURLOPT_URL], 'https://fixture.invalid/v1/')) throw new \RuntimeException('Fixture destinations only'); $handle->options = $options; return true; } function curl_exec(\stdClass $handle): string|bool { $payload = json_decode($handle->options[CURLOPT_POSTFIELDS], true); $GLOBALS['missingFallbackRequests'][] = $payload; $dify = str_ends_with($handle->options[CURLOPT_URL], '/chat-messages'); $handle->code = $dify ? 404 : 200; ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); if (isset($handle->options[CURLOPT_WRITEFUNCTION])) { $write = $handle->options[CURLOPT_WRITEFUNCTION]; if ($dify) $write($handle, '{"code":"not_found"}'); else { $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => 'synthetic answer']]]]) . "\n\n"); $write($handle, "data: [DONE]\n\n"); } return true; } return $dify ? '{"code":"not_found"}' : '{"choices":[{"message":{"content":"synthetic answer"}}]}'; } function curl_errno(\stdClass $handle): int { return 0; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; } function curl_close(\stdClass $handle): void {} } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\common\service\DifyChatService; function missingFallbackExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } $missingFallbackConfig = ['enable' => true, 'base_url' => 'https://fixture.invalid/v1', 'timeout' => 5, 'required_attachment_transport' => 'upload', 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; foreach ([false, true] as $stream) foreach (['all-missing', 'missing-image-available-document', 'missing-image-available-image', 'missing-document-available-image'] as $missingFallbackScenario) { $missingFallbackRequests = []; $events = []; $files = $missingFallbackScenario === 'all-missing' ? [['type' => 'document', 'url' => 'https://fixture.invalid/missing.pdf'], ['type' => 'audio', 'url' => 'https://fixture.invalid/missing.wav']] : [['type' => $missingFallbackScenario === 'missing-document-available-image' ? 'document' : 'image', 'url' => 'https://fixture.invalid/missing'], ['type' => $missingFallbackScenario === 'missing-image-available-document' ? 'document' : 'image', 'url' => 'https://fixture.invalid/available']]; $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]; $result = $stream ? DifyChatService::streamChat('qwen', [], 'complete synthetic clinical text', 'synthetic-user', static fn (string $text): bool => true, null, $files, $options) : DifyChatService::chat('qwen', [], 'complete synthetic clinical text', 'synthetic-user', $files, $options); $expectedMissing = $missingFallbackScenario === 'all-missing' ? 2 : 1; missingFallbackExpect($result['skipped_attachment_count'] === $expectedMissing && $result['attachment_count'] === 2, 'protocol fallback keeps original source and confirmed missing counts'); missingFallbackExpect($result['items'][0]['status'] === 'skipped' && $result['items'][0]['reason'] === 'not_found', 'known missing item survives every fallback outcome'); if ($missingFallbackScenario === 'missing-image-available-document') { missingFallbackExpect(empty($result['ok']) && $result['error_code'] === 'REQUIRED_FILES_UNSUPPORTED' && count($missingFallbackRequests) === 1 && $result['submitted_attachment_count'] === 0 && $result['attachment_transfer'] === 'uploaded_files', 'unsupported fallback retains the prior staged receipt'); continue; } missingFallbackExpect(!empty($result['ok']) && count($missingFallbackRequests) === 2 && $result['submitted_attachment_count'] === 2 - $expectedMissing, 'text or available images complete through protocol fallback'); $wire = json_encode($missingFallbackRequests[1], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); missingFallbackExpect(!str_contains($wire, 'https://fixture.invalid/missing') && !str_contains($wire, 'ATTACHMENTS_NOT_INLINE') && str_contains($wire, 'complete synthetic clinical text') && substr_count($wire, '【附件可用性说明】') === 1, 'fallback builds from available sources without resurrecting dead references'); missingFallbackExpect(($missingFallbackScenario === 'all-missing') === !str_contains($wire, 'https://fixture.invalid/available'), 'available images are preserved while all-missing requests send only text'); } echo "Missing attachment protocol fallback / truthful receipts / no revived references / blocking and stream: OK\n"; }