options = $options; $handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true); $handle->url = $options[CURLOPT_URL]; return true; } function curl_exec(\stdClass $handle): string|bool { $payload = $handle->payload; $stream = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); $GLOBALS['requiredRequests'][] = ['url' => $handle->url, 'payload' => $payload]; $scenario = $GLOBALS['requiredScenario']; $handle->code = $scenario === 'reject' ? 413 : ($scenario === 'input_rejected' && !empty($payload['inputs']) ? 400 : ($scenario === 'fallback' && str_ends_with($handle->url, '/chat-messages') ? 404 : 200)); if (isset($handle->options[CURLOPT_HEADERFUNCTION])) { ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); } if ($scenario === 'cancel') $GLOBALS['requiredCancelled'] = true; $progress = $handle->options[defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION] ?? null; if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false; if ($handle->code !== 200) { $errorBody = json_encode(['code' => $scenario === 'fallback' ? 'not_found' : 'invalid_param', 'message' => $scenario === 'reject' ? 'Number of files exceeds the limit.' : ($scenario === 'input_rejected' ? 'Unknown input variable: synthetic_undeclared' : 'Synthetic path not found')]); if ($stream) { ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $errorBody); return true; } return $errorBody; } if (!$stream) return json_encode(['answer' => '合成回答']); $write = $handle->options[CURLOPT_WRITEFUNCTION]; if (str_ends_with($handle->url, '/chat-messages')) { $write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => '合成回答']) . "\n\n"); $write($handle, "data: {\"event\":\"message_end\"}\n\n"); } else { $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => '合成回答']]]]) . "\n\n"); $write($handle, "data: [DONE]\n\n"); } return true; } 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 { ++$GLOBALS['requiredClosed']; } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\common\service\DifyChatService; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AssistantSseTiming; function requiredExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } $requiredConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, 'max_files' => 3, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; $reset = static function (string $scenario = 'success'): void { $GLOBALS['requiredScenario'] = $scenario; $GLOBALS['requiredRequests'] = []; $GLOBALS['requiredCancelled'] = false; $GLOBALS['requiredClosed'] = 0; }; $sections = []; foreach (['basic_info', 'vital_signs', 'chief_complaint', 'present_illness', 'past_history', 'other_history', 'diagnosis_info', 'doctor_image_notes', 'doctor_file_notes', 'daily_tracking', 'prescriptions', 'video_consultation_text', 'chat'] as $key) { $sections[] = ['key' => $key, 'label' => $key, 'count' => $key === 'chat' ? 0 : 1, 'availability' => $key === 'chat' ? 'empty' : 'present', 'status' => 'prepared']; } $files = []; for ($id = 1; $id <= 8; ++$id) $files[] = ['type' => $id <= 4 ? 'image' : 'document', 'url' => 'https://synthetic.invalid/fixture-' . $id . ($id <= 4 ? '.png' : '.pdf')]; $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'required_sections' => $sections, 'source_summary' => ['required_sections' => $sections]]; foreach ([false, true] as $streaming) { $reset(); $events = []; $withProgress = $options + ['on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]; $withVideos = array_merge($files, [['type' => 'video', 'url' => 'https://synthetic.invalid/record.mp4'], ['type' => 'document', 'url' => 'https://synthetic.invalid/disguised.MOV'], ['type' => 'image', 'url' => 'https://synthetic.invalid/opaque', 'mime_type' => 'video/webm']]); $result = $streaming ? DifyChatService::streamChat('qwen', [], '合成资料;聊天:无', 'synthetic-user', static fn (string $text): bool => true, null, $withVideos, $withProgress) : DifyChatService::chat('qwen', [], '合成资料;聊天:无', 'synthetic-user', $withVideos, $withProgress); requiredExpect(!empty($result['ok']) && count($requiredRequests) === 1, 'required request succeeds exactly once'); requiredExpect(count($requiredRequests[0]['payload']['files']) === 8 && $result['transmitted_file_count'] === 8, 'all eight images/documents reach serialized wire payload despite default max_files=3'); requiredExpect(array_column($requiredRequests[0]['payload']['files'], 'url') === array_column($files, 'url'), 'video references never enter payload'); requiredExpect($result['transfer_manifest_final'] === true && $result['attachment_count'] === 8 && array_unique(array_column($result['items'], 'status')) === ['submitted'], 'final attachment manifest reflects actual complete payload'); requiredExpect(count($result['required_sections']) === 13 && array_unique(array_column($result['required_sections'], 'status')) === ['submitted'] && $result['required_sections'][12]['availability'] === 'empty', 'empty required text section is explicitly submitted, not omitted'); requiredExpect($events[0]['required_sections'][0]['status'] === 'submitting' && $events[1]['source_summary']['required_sections'][0]['status'] === 'submitted', 'progress moves actual source manifest from submitting to submitted'); $reset('reject'); $result = $streaming ? DifyChatService::streamChat('qwen', [], '合成资料', 'synthetic-user', static fn (string $text): bool => true, null, $files, $options) : DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $options); requiredExpect(($result['error_code'] ?? '') === 'REQUIRED_FILES_REJECTED' && count($requiredRequests) === 1 && count($requiredRequests[0]['payload']['files']) === 8, 'hard provider limit fails explicitly without stripping files'); } foreach ([['type' => 'unknown', 'url' => 'https://synthetic.invalid/file'], ['type' => 'image', 'url' => 'invalid'], ['type' => 'custom', 'url' => 'https://synthetic.invalid/file'], ['type' => 'image', 'url' => 'https://synthetic.invalid/a.png', 'transfer_method' => 'local_file']] as $invalid) { $reset(); $result = DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', [$invalid], $options); requiredExpect(($result['error_code'] ?? '') === 'REQUIRED_FILES_INVALID' && $requiredRequests === [], 'invalid/unknown mandatory files fail before network'); } foreach ([false, true] as $streaming) { $reset('input_rejected'); $result = $streaming ? DifyChatService::streamChat('qwen', ['synthetic_undeclared' => 'value'], '合成正文', 'synthetic-user', static fn (string $text): bool => true, null, $files, $options) : DifyChatService::chat('qwen', ['synthetic_undeclared' => 'value'], '合成正文', 'synthetic-user', $files, $options); requiredExpect(!empty($result['ok']) && count($requiredRequests) === 2 && count($requiredRequests[0]['payload']['files']) === 8 && count($requiredRequests[1]['payload']['files']) === 8, 'undeclared Dify inputs retry preserves complete required files and query'); } $reset(); $fallbackOptions = array_replace($options, ['required_sections' => []]); $result = DifyChatService::chat('qwen', [], '合成正文', 'synthetic-user', [], $fallbackOptions); requiredExpect(count($result['required_sections']) === 13 && $result['attachment_count'] === 0, 'source_summary manifest fallback keeps all text sections when attachment list is empty'); $requiredConfig['base_url'] = 'https://synthetic.invalid/v1/chat/completions'; $reset(); $result = DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $options); requiredExpect(($result['error_code'] ?? '') === 'REQUIRED_FILES_UNSUPPORTED' && $requiredRequests === [], 'OpenAI documents fail before unsupported transmission'); $reset(); $result = DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', array_slice($files, 0, 4), $options); requiredExpect(!empty($result['ok']) && count($requiredRequests[0]['payload']['messages'][0]['content']) === 5, 'all four OpenAI image_url parts are serialized'); $requiredConfig['base_url'] = 'https://synthetic.invalid/v1'; $reset('fallback'); $result = DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $options); requiredExpect(($result['error_code'] ?? '') === 'REQUIRED_FILES_UNSUPPORTED' && count($requiredRequests) === 1, 'protocol fallback cannot fake document submission'); $requiredConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages'; $prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => '合成资料;聊天:无', 'user' => 'synthetic-user', 'files' => $files, 'required_sections' => $sections, 'source_summary' => ['required_sections' => $sections], 'upstream_timeout' => 5]; $run = static function () use ($prepared): array { $deltas = []; $events = []; $result = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $delta) use (&$deltas): bool { $deltas[] = $delta; return true; }, static fn (): bool => $GLOBALS['requiredCancelled'], new AssistantSseTiming(), ['knowledge_mode' => 'evidence', 'on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]); return compact('result', 'deltas', 'events'); }; $reset(); $test = $run(); requiredExpect($test['result']['transfer_manifest_final'] === true && $test['result']['attachment_count'] === 8 && $test['result']['required_sections'][0]['status'] === 'submitted' && $test['result']['source_summary']['required_sections'][0]['status'] === 'submitted', 'assistant done preserves actual final manifest instead of prepared status'); $reset('reject'); $test = $run(); requiredExpect($test['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'REQUIRED_FILES_REJECTED' && count($requiredRequests) === 1 && $test['deltas'] === [], 'assistant mandatory rejection does not trigger blocking compatibility retry'); $reset('cancel'); $test = $run(); requiredExpect($test['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED' && count($requiredRequests) === 1 && $requiredClosed === 1 && $test['deltas'] === [], 'cancellation still closes transport without late answer or retry'); echo "Dify required sources serialized payload / complete files / truthful manifests / cancellation: OK\n"; }