Files
zyt/server/tests/DiagnosisAiAssistantTimeoutReceiptTest.php
2026-10-27 14:03:35 +08:00

143 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace think\facade {
class Log { public static function warning(string $message): void {} public static function write(string $message, string $level): void {} }
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['timeoutReceiptConfig']; }
final class DifyRequiredAttachmentUpload {
public static function prepare(string $endpoint, string $key, string $user, array $files, int $timeout,
?callable $abort = null, array $options = []): array {
if ($GLOBALS['timeoutReceiptScenario'] === 'upload_timeout') {
return ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => '合成上传超时'];
}
$uploaded = []; $indices = []; $skipped = [];
foreach ($files as $index => $file) {
$missing = $GLOBALS['timeoutReceiptScenario'] === 'all_missing'
|| (in_array($GLOBALS['timeoutReceiptScenario'], ['missing', 'deadline_after_upload', 'deadline_during_upload'], true) && $index === 0);
if ($missing) {
$skipped[] = ['index' => $index + 1, 'kind' => $file['type'], 'reason' => 'not_found'];
if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])([
'index' => $index + 1, 'total' => count($files), 'status' => 'skipped', 'reason' => 'not_found']);
} else {
$uploaded[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'private-upload-' . $index];
$indices[] = $index + 1;
if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])([
'index' => $index + 1, 'total' => count($files), 'status' => 'uploaded']);
}
}
if (str_starts_with($GLOBALS['timeoutReceiptScenario'], 'deadline_')) {
$GLOBALS['timeoutReceiptContext']->deadline = microtime(true) - 1;
if ($GLOBALS['timeoutReceiptScenario'] === 'deadline_during_upload') {
return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED', 'error' => '合成共享期限中止'];
}
}
return ['ok' => true, 'files' => $uploaded, 'uploaded_indices' => $indices, 'skipped' => $skipped];
}
}
function curl_init(): \stdClass { return (object) ['errno' => 0, 'code' => 0]; }
function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; }
function curl_exec(\stdClass $handle): string|bool {
$payload = json_decode($handle->options[CURLOPT_POSTFIELDS], true);
$GLOBALS['timeoutReceiptRequests'][] = $payload;
$scenario = $GLOBALS['timeoutReceiptScenario'];
$stream = ($payload['response_mode'] ?? '') === 'streaming';
$extract = str_starts_with($payload['query'] ?? '', '【附件分批证据提取】');
if ($scenario === 'unsent') { $handle->errno = CURLE_OPERATION_TIMEDOUT; return false; }
$handle->code = 200;
($handle->options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 OK\r\n");
if ($extract && $scenario !== 'batch_extract_timeout') {
preg_match_all('/原始附件#(\d+)(/u', $payload['query'], $matches);
$answer = json_encode(['files' => array_map(static fn (string $index): array =>
['index' => (int) $index, 'status' => 'read', 'findings' => '合成来源观察'], $matches[1])], JSON_UNESCAPED_UNICODE);
if (!$stream) return json_encode(['answer' => $answer]);
($handle->options[CURLOPT_WRITEFUNCTION])($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => $answer]) . "\n\n");
($handle->options[CURLOPT_WRITEFUNCTION])($handle, "data: {\"event\":\"message_end\"}\n\n");
return true;
}
if ($stream && $scenario === 'partial') {
($handle->options[CURLOPT_WRITEFUNCTION])($handle, "data: {\"event\":\"message\",\"answer\":\"截止前合成片段\"}\n\n");
}
$handle->errno = CURLE_OPERATION_TIMEDOUT;
return false;
}
function curl_errno(\stdClass $handle): int { return $handle->errno; }
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\adminapi\service\AiRequiredSource;
use app\common\service\DifyChatService;
function timeoutReceiptExpect(bool $ok, string $message): void {
if (!$ok) throw new RuntimeException($message);
}
$cases = 0;
foreach ([false, true] as $stream) foreach (['submitted', 'unsent', 'missing', 'all_missing', 'partial',
'batch_final', 'batch_extract_timeout', 'upload_timeout', 'deadline_after_upload', 'deadline_during_upload'] as $timeoutReceiptScenario) {
$upload = in_array($timeoutReceiptScenario, ['missing', 'all_missing', 'upload_timeout', 'deadline_after_upload', 'deadline_during_upload'], true);
$timeoutReceiptConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages',
'timeout' => 5, 'max_files' => 2, 'required_attachment_transport' => $upload ? 'upload' : 'remote_url',
'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]];
$files = array_map(static fn (int $index): array => ['type' => 'image', 'url' => 'https://private.invalid/' . $index . '.png'],
range(1, str_starts_with($timeoutReceiptScenario, 'batch_') ? 3 : ($upload ? 2 : 1)));
$events = []; $deltas = []; $timeoutReceiptRequests = [];
$options = ['required_files' => true, 'knowledge_mode' => 'evidence',
'required_sections' => AiRequiredSource::manifest([]),
'source_summary' => ['diagnosis_count' => 3],
'on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }];
if (str_starts_with($timeoutReceiptScenario, 'deadline_')) {
$timeoutReceiptContext = (object) ['deadline' => microtime(true) + 5, 'uploads' => []];
$options['_required_batch_internal'] = true;
$options['_required_batch_context'] = $timeoutReceiptContext;
}
$result = $stream
? DifyChatService::streamChat('qwen', [], '合成超时资料回执', 'same-original-user',
static function (string $delta) use (&$deltas): bool { $deltas[] = $delta; return true; }, null, $files, $options)
: DifyChatService::chat('qwen', [], '合成超时资料回执', 'same-original-user', $files, $options);
$context = ($stream ? 'stream/' : 'blocking/') . $timeoutReceiptScenario . ': ';
timeoutReceiptExpect(($result['error_code'] ?? '') === 'UPSTREAM_TIMEOUT' && empty($result['ok']), $context . 'model failure remains timeout');
timeoutReceiptExpect(count($result['required_sections'] ?? []) === 13 && count($result['items'] ?? []) === count($files)
&& !empty($result['transfer_manifest_final']), $context . 'timeout carries complete authoritative source receipt');
timeoutReceiptExpect(($result['source_summary']['diagnosis_count'] ?? 0) === 3
&& $result['source_summary']['required_sections'] === $result['required_sections'], $context . 'summary mirrors the final source receipt');
$sectionStatuses = array_values(array_unique(array_column($result['required_sections'], 'status')));
$attachments = array_values(array_filter($events, static fn (array $event): bool => ($event['phase'] ?? '') === 'attachments'));
$upstreamFailures = array_filter($events, static fn (array $event): bool => ($event['phase'] ?? '') === 'upstream' && ($event['status'] ?? '') === 'failed');
if (str_starts_with($timeoutReceiptScenario, 'deadline_')) {
timeoutReceiptExpect($sectionStatuses === ['prepared'] && $result['submitted_attachment_count'] === 0
&& $result['items'][0]['status'] === 'skipped' && $result['skipped_attachment_count'] === 1
&& $timeoutReceiptRequests === [], $context . 'shared deadline before generation preserves missing-source facts without claiming submission');
} elseif (in_array($timeoutReceiptScenario, ['unsent', 'upload_timeout'], true)) {
timeoutReceiptExpect($sectionStatuses === ['failed'] && ($result['submitted_attachment_count'] ?? -1) === 0,
$context . 'unsent sources are never promoted to submitted');
timeoutReceiptExpect(in_array('failed', array_column($attachments, 'status'), true), $context . 'actual transmission failure remains explicit');
if ($timeoutReceiptScenario === 'upload_timeout') timeoutReceiptExpect($timeoutReceiptRequests === [], 'upload failure starts no generation');
} elseif ($timeoutReceiptScenario === 'batch_extract_timeout') {
timeoutReceiptExpect($sectionStatuses === ['prepared'] && $result['submitted_attachment_count'] === 2
&& $result['items'][2]['status'] !== 'submitted' && count($timeoutReceiptRequests) === 1,
$context . 'only the sent extraction batch is submitted; unsent final clinical body stays prepared');
} else {
$skipped = $timeoutReceiptScenario === 'all_missing' ? 2 : ($timeoutReceiptScenario === 'missing' ? 1 : 0);
timeoutReceiptExpect($sectionStatuses === ['submitted'] && $result['submitted_attachment_count'] === count($files) - $skipped
&& $result['skipped_attachment_count'] === $skipped, $context . 'submitted and missing source facts survive model timeout');
timeoutReceiptExpect(!in_array('failed', array_column($attachments, 'status'), true) && count($upstreamFailures) === 1,
$context . 'model timeout is distinct from successful transmission');
if (str_starts_with($timeoutReceiptScenario, 'batch_')) timeoutReceiptExpect(count($timeoutReceiptRequests) === 3
&& end($attachments)['status'] === 'submitted', 'batch synthesis timeout retains final submitted source status');
}
timeoutReceiptExpect(!str_contains(json_encode($result), 'private.invalid') && !str_contains(json_encode($result), 'private-upload-'),
$context . 'receipt discloses no source URL or upload ID');
timeoutReceiptExpect($deltas === ($stream && $timeoutReceiptScenario === 'partial' ? ['截止前合成片段'] : []),
$context . 'receipt handling neither manufactures nor repeats clinical text');
++$cases;
}
echo 'Diagnosis AI timeout source receipt / submitted versus generation failure: ' . $cases . " offline scenarios OK\n";
}