Files
zyt/server/tests/DifyRequiredSourcesTransportTest.php
T
2026-10-23 13:57:11 +08:00

186 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace think\facade {
class Log { public static function warning(string $message): void {} }
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['requiredConfig']; }
function curl_init(): \stdClass { return new \stdClass(); }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->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";
}