Files
zyt/server/tests/DifyRequiredSourcesTransportTest.php
T
2026-10-23 10:17:09 +08:00

150 lines
12 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' ? 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) return $stream ? true : '{}';
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";
}