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' && !empty($payload['files']) ? 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; } $answer = ($payload['inputs']['synthetic_task'] ?? '') === 'prescription_generate' ? json_encode(['clinical_diagnosis' => '合成测试证候', 'herbs' => [['name' => '合成药名', 'dosage' => 1]], 'rationale' => '合成草稿,需人工复核'], JSON_UNESCAPED_UNICODE) : '合成回答'; if (!$stream) return json_encode(['answer' => $answer]); $write = $handle->options[CURLOPT_WRITEFUNCTION]; if (str_ends_with($handle->url, '/chat-messages')) { $write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => $answer]) . "\n\n"); $write($handle, "data: {\"event\":\"message_end\"}\n\n"); } else { $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => $answer]]]]) . "\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) === 4, 'three evidence batches finish with one original clinical task'); $wireFiles = array_merge(...array_map(static fn (array $request): array => $request['payload']['files'] ?? [], $requiredRequests)); requiredExpect(count($wireFiles) === 8 && $result['transmitted_file_count'] === 8, 'all eight images/documents reach bounded batches with max_files=3'); requiredExpect(array_column($wireFiles, 'url') === array_column($files, 'url'), 'video references never enter payload and original positions remain complete'); 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'] === 'prepared' && $events[count($events) - 1]['source_summary']['required_sections'][0]['status'] === 'submitted', 'full clinical body stays prepared until final original task submission'); $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(!empty($result['ok']) && $result['rejected_attachment_count'] === 8 && $result['submitted_attachment_count'] === 0 && count($requiredRequests) <= 17 && empty($requiredRequests[count($requiredRequests) - 1]['payload']['files']), 'confirmed per-file rejection finishes original task with eight explicit unread gaps'); } 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) === 5 && $result['submitted_attachment_count'] === 8 && $requiredRequests[3]['payload']['query'] === $requiredRequests[4]['payload']['query'], 'undeclared Dify inputs retry preserves final complete original body and all batch evidence'); } $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(!empty($result['ok']) && $result['submitted_attachment_count'] === 4 && $result['rejected_attachment_count'] === 4, 'OpenAI documents become explicit unread gaps while every supported image and original body continue'); $reset(); $result = DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', array_slice($files, 0, 4), $options); requiredExpect(!empty($result['ok']) && $result['submitted_attachment_count'] === 4 && count($requiredRequests) === 3 && count($requiredRequests[0]['payload']['messages'][0]['content']) === 4 && count($requiredRequests[1]['payload']['messages'][0]['content']) === 2, 'all four OpenAI image_url parts are serialized within the configured cap'); $requiredConfig['base_url'] = 'https://synthetic.invalid/v1'; $reset('fallback'); $result = DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $options); requiredExpect(!empty($result['ok']) && $result['submitted_attachment_count'] === 4 && $result['rejected_attachment_count'] === 4, 'protocol fallback cannot fake document submission and preserves explicit gaps'); $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 (array $overrides = []) use ($prepared): array { $prepared = array_replace($prepared, $overrides); $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'); foreach (['summary', 'tcm_pattern', 'prescription_review', 'prescription_generate', 'medication_review', 'exam_review', 'complication_risk', 'guideline_review', 'custom'] as $task) { $reset(); $taskQuery = '完整合成病历;TASK=' . $task . ';保留原始任务输出结构'; $taskInputs = ['synthetic_task' => $task, 'clinical_context' => $taskQuery]; $test = $run(['task' => $task, 'query' => $taskQuery, 'inputs' => $taskInputs, 'files' => array_slice($files, 0, 7)]); requiredExpect(is_array($test['result']) && $test['result']['task'] === $task && $test['result']['attachment_count'] === 7 && $test['result']['submitted_attachment_count'] === 7 && count($requiredRequests) === 4 && count($test['deltas']) === 1, 'all nine real assistant task routes retain all seven attachments and emit only final answer: ' . $task); $final = $requiredRequests[3]['payload']; requiredExpect(str_starts_with($final['query'], $taskQuery) && $final['inputs'] === $taskInputs, 'each original assistant task and all original inputs survive evidence synthesis'); if ($task === 'prescription_generate') requiredExpect(is_array($test['result']['prescription_draft'] ?? null), 'JSON prescription schema stays parseable without a prose notice prefix'); } $reset('reject'); $test = $run(); requiredExpect(is_array($test['result']) && $test['result']['submitted_attachment_count'] === 0 && count($requiredRequests) <= 17 && $test['deltas'] === ['合成回答'], 'assistant receives only final clinical answer after bounded affirmative file isolation'); $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"; }