143 lines
11 KiB
PHP
143 lines
11 KiB
PHP
<?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";
|
||
}
|