309 lines
21 KiB
PHP
309 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// Standalone offline fault injection. No application bootstrap, credentials or I/O.
|
|
namespace think\facade {
|
|
class Log { public static function warning(string $message): void { $GLOBALS['largeLogs'][] = $message; } }
|
|
}
|
|
|
|
namespace app\common\service {
|
|
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
|
|
|
|
class ClinicalKnowledgeReference {
|
|
public static function ancientBookSources(string $query): array { return []; }
|
|
public static function augmentQueryWithAncientBookSources(string $query, array $sources): string { return $query; }
|
|
}
|
|
|
|
class DifyRequiredAttachmentUpload {
|
|
public static function prepare(string $endpoint, string $key, string $user, array $files, int $timeout,
|
|
?callable $shouldAbort = null, array $options = []): array {
|
|
$GLOBALS['largeUploads'][] = compact('user', 'files', 'timeout');
|
|
$wire = []; $indices = []; $skipped = [];
|
|
foreach ($files as $index => $file) {
|
|
if ($shouldAbort !== null && $shouldAbort()) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
|
|
$id = (int) pathinfo(parse_url($file['url'], PHP_URL_PATH), PATHINFO_FILENAME);
|
|
$missing = in_array($id, $GLOBALS['largeMissing'], true);
|
|
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, 'status' => 'skipped', 'reason' => 'not_found',
|
|
]);
|
|
continue;
|
|
}
|
|
foreach (['uploading', 'uploaded'] as $status) if (is_callable($options['on_upload'] ?? null)) {
|
|
($options['on_upload'])(['index' => $index + 1, 'status' => $status]);
|
|
}
|
|
$wire[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'SOURCE_' . $id];
|
|
$indices[] = $index + 1;
|
|
}
|
|
return ['ok' => true, 'files' => $wire, 'uploaded_indices' => $indices, 'skipped' => $skipped];
|
|
}
|
|
}
|
|
|
|
function config(string $name): array { return $GLOBALS['largeConfig']; }
|
|
function microtime(bool $asFloat = false): float { return $GLOBALS['largeClock']; }
|
|
function curl_init(): \stdClass { return (object) ['errno' => 0, 'code' => 200]; }
|
|
function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; }
|
|
function curl_exec(\stdClass $handle): string|bool {
|
|
$options = $handle->options;
|
|
if (str_ends_with($options[CURLOPT_URL], '/stop')) {
|
|
$GLOBALS['largeStops']++;
|
|
return '{"result":"success"}';
|
|
}
|
|
$payload = json_decode($options[CURLOPT_POSTFIELDS], true);
|
|
$query = $payload['query'];
|
|
$extract = str_starts_with($query, '【附件分批证据提取】');
|
|
$synthesis = str_contains($query, '【最终任务】');
|
|
if ($extract) $GLOBALS['largeExtracts']++;
|
|
$ids = array_column($payload['files'] ?? [], 'upload_file_id');
|
|
$limitMs = $options[CURLOPT_TIMEOUT_MS] ?? $options[CURLOPT_TIMEOUT] * 1000;
|
|
$GLOBALS['largeRequests'][] = compact('payload', 'extract', 'synthesis', 'ids', 'limitMs');
|
|
$scenario = $GLOBALS['largeScenario'];
|
|
$error = null;
|
|
if ($extract && $scenario === 'provider-cap-three' && count($ids) > 3) {
|
|
$handle->code = 400; $error = ['code' => 'too_many_files', 'message' => 'Number of files exceeds the limit'];
|
|
} elseif ($extract && $scenario === 'reject-documents'
|
|
&& in_array('document', array_column($payload['files'] ?? [], 'type'), true)) {
|
|
$handle->code = 415; $error = ['code' => 'unsupported_file_type', 'message' => 'PRIVATE_SYNTHETIC_ERROR'];
|
|
} elseif ($extract && $GLOBALS['largeExtracts'] === 2 && in_array($scenario, ['auth', 'quota', 'server', 'ambiguous', 'network'], true)) {
|
|
if ($scenario === 'network') { $handle->errno = CURLE_COULDNT_CONNECT; $handle->code = 0; return false; }
|
|
$handle->code = ['auth' => 401, 'quota' => 429, 'server' => 503, 'ambiguous' => 413][$scenario];
|
|
$error = ['code' => 'invalid_param', 'message' => 'PRIVATE_SYNTHETIC_ERROR'];
|
|
} elseif ($synthesis && in_array($scenario, ['context-limit', 'context-unique'], true) && strlen($query) > 120000) {
|
|
$handle->code = 400; $error = ['code' => 'context_length_exceeded', 'message' => 'maximum context length exceeded'];
|
|
}
|
|
$duration = $scenario === 'extraction-deadline' && $extract ? 31.0
|
|
: ($scenario === 'synthesis-deadline' ? 20.0 : 0.001);
|
|
if ($duration * 1000 >= $limitMs) {
|
|
$GLOBALS['largeClock'] += $limitMs / 1000;
|
|
$handle->errno = CURLE_OPERATION_TIMEDOUT; $handle->code = 0;
|
|
return false;
|
|
}
|
|
$GLOBALS['largeClock'] += $duration;
|
|
if (isset($options[CURLOPT_HEADERFUNCTION])) {
|
|
($options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n");
|
|
}
|
|
$streaming = ($payload['response_mode'] ?? '') === 'streaming';
|
|
if ($error !== null) {
|
|
$body = json_encode($error);
|
|
if ($streaming) { ($options[CURLOPT_WRITEFUNCTION])($handle, $body); return true; }
|
|
return $body;
|
|
}
|
|
$visibleIds = $scenario === 'sparse-evidence' && $extract ? array_slice($ids, 0, 1) : $ids;
|
|
$answer = 'SYNTHETIC_FINAL_ANSWER';
|
|
if ($extract) {
|
|
preg_match_all('/实际附件\d+ => 原始附件#(\d+)/u', $query, $map);
|
|
$rows = [];
|
|
foreach ($visibleIds as $position => $id) $rows[] = ['index' => (int) ($map[1][$position] ?? 0),
|
|
'status' => 'read', 'findings' => 'OBSERVATION_' . $id
|
|
. ($scenario === 'context-limit' ? str_repeat('Long synthetic extracted text. ', 1000) : '')
|
|
. ($scenario === 'context-unique' ? implode(' ', array_map(static fn (int $n): string => 'unique_' . $id . '_' . $n, range(1, 1000))) : '')];
|
|
$answer = json_encode(['files' => $rows]);
|
|
}
|
|
if (!$streaming) return json_encode(['answer' => $answer]);
|
|
$write = $options[CURLOPT_WRITEFUNCTION];
|
|
$write($handle, 'data: ' . json_encode(['event' => 'message', 'task_id' => 'large-task-' . count($GLOBALS['largeRequests']), 'answer' => $answer]) . "\n\n");
|
|
if ($extract && $scenario === 'cancel-second' && $GLOBALS['largeExtracts'] === 2) $GLOBALS['largeCancelled'] = true;
|
|
$write($handle, "data: {\"event\":\"message_end\"}\n\n");
|
|
return true;
|
|
}
|
|
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__) . '/app/common/service/DifyChatService.php';
|
|
use app\common\service\DifyChatService;
|
|
|
|
function largeExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); }
|
|
function largeHasObservation(string $query, int $id): bool { return preg_match('/OBSERVATION_SOURCE_' . $id . '(?![0-9])/', $query) === 1; }
|
|
function largeHasGap(string $query, int $id): bool {
|
|
preg_match_all('/原始附件#序号\/范围\[([0-9,-]+)\]/u', $query, $matches);
|
|
foreach ($matches[1] as $list) foreach (explode(',', $list) as $range) {
|
|
$ends = array_map('intval', explode('-', $range));
|
|
if ($id >= $ends[0] && $id <= ($ends[1] ?? $ends[0])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function largeAddedPayloadBytes(array $payload, string $original): int {
|
|
$baseline = $payload; $baseline['query'] = $original;
|
|
$flags = JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE;
|
|
return strlen(json_encode($payload, $flags)) - strlen(json_encode($baseline, $flags));
|
|
}
|
|
function largeFiles(int $count, bool $mixed = false): array {
|
|
return array_map(static function (int $id) use ($mixed): array {
|
|
$document = $mixed && $id % 5 === 0;
|
|
return ['type' => $document ? 'document' : 'image',
|
|
'url' => 'https://synthetic.invalid/' . $id . ($document ? '.pdf' : '.png')];
|
|
}, range(1, $count));
|
|
}
|
|
function largeReset(string $scenario, int $cap = 10): void {
|
|
$GLOBALS['largeScenario'] = $scenario; $GLOBALS['largeClock'] = 1000.0;
|
|
$GLOBALS['largeRequests'] = []; $GLOBALS['largeUploads'] = []; $GLOBALS['largeLogs'] = [];
|
|
$GLOBALS['largeExtracts'] = 0; $GLOBALS['largeStops'] = 0; $GLOBALS['largeCancelled'] = false;
|
|
$GLOBALS['largeMissing'] = [];
|
|
$GLOBALS['largeConfig'] = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages',
|
|
'timeout' => 90, 'required_attachment_transport' => 'upload', 'max_files' => 3,
|
|
'models' => ['qwen' => ['name' => 'synthetic-model', 'api_key' => 'SYNTHETIC_ONLY_KEY', 'max_files' => $cap]]];
|
|
}
|
|
function largeRun(array $files, bool $streaming, string $query = 'COMPLETE_ORIGINAL_CASE_AND_OUTPUT_SCHEMA'): array {
|
|
$deltas = []; $events = []; $tasks = []; $ends = [];
|
|
$options = ['required_files' => true, 'required_sections' => [
|
|
['key' => 'basic_info', 'status' => 'prepared', 'availability' => 'present'],
|
|
['key' => 'chat', 'status' => 'prepared', 'availability' => 'present'],
|
|
], 'on_progress' => static function (array $event) use (&$events): void { $events[] = $event; },
|
|
'on_task' => static function (...$args) use (&$tasks): void { $tasks[] = $args; },
|
|
'on_transfer_end' => static function (...$args) use (&$ends): void { $ends[] = $args; }];
|
|
$inputs = ['full_original_inputs' => $query];
|
|
if ($streaming) {
|
|
$result = DifyChatService::streamChat('qwen', $inputs, $query, 'same-synthetic-user',
|
|
static function (string $delta) use (&$deltas): bool { $deltas[] = $delta; return true; },
|
|
static fn (): bool => $GLOBALS['largeCancelled'], $files, $options);
|
|
} else $result = DifyChatService::chat('qwen', $inputs, $query, 'same-synthetic-user', $files, $options);
|
|
return compact('result', 'deltas', 'events', 'tasks', 'ends');
|
|
}
|
|
function largeSafe(array $result, array $events): void {
|
|
$encoded = json_encode([$result, $events, $GLOBALS['largeLogs']]);
|
|
largeExpect(!str_contains($encoded, 'PRIVATE_SYNTHETIC_ERROR') && !str_contains($encoded, 'SYNTHETIC_ONLY_KEY')
|
|
&& !str_contains($encoded, 'synthetic.invalid'), 'safe result/progress/log receipt must not expose source URL/key/provider message');
|
|
}
|
|
|
|
$cases = 0;
|
|
foreach ([false, true] as $streaming) foreach ([false, true] as $mixed) foreach ([10, 11, 20, 40] as $count) {
|
|
largeReset('success');
|
|
['result' => $result, 'deltas' => $deltas, 'events' => $events] = largeRun(largeFiles($count, $mixed), $streaming);
|
|
largeExpect(!empty($result['ok']) && count($result['items']) === $count && $result['submitted_attachment_count'] === $count,
|
|
'10/11/20/40 image/mixed sources must all remain accounted for');
|
|
$expectedCalls = $count <= 10 ? 1 : (int) ceil($count / 10) + 1;
|
|
largeExpect(count($largeRequests) === $expectedCalls, 'use configured cap and at most one final synthesis');
|
|
foreach ($largeRequests as $request) largeExpect(count($request['ids']) <= 10
|
|
&& $request['payload']['user'] === 'same-synthetic-user', 'same app/user and cap throughout pipeline');
|
|
if ($count > 10) {
|
|
$final = $largeRequests[count($largeRequests) - 1]['payload'];
|
|
largeExpect(str_starts_with($final['query'], 'COMPLETE_ORIGINAL_CASE_AND_OUTPUT_SCHEMA')
|
|
&& $final['inputs'] === ['full_original_inputs' => 'COMPLETE_ORIGINAL_CASE_AND_OUTPUT_SCHEMA']
|
|
&& ($final['files'] ?? []) === [], 'final task preserves complete original text/schema/inputs and truthfully uses extracted evidence');
|
|
foreach (range(1, $count) as $id) largeExpect(largeHasObservation($final['query'], $id), 'every source observation survives final query');
|
|
largeExpect($result['attachment_batch_count'] === (int) ceil($count / 10), 'receipt exposes actual extraction attempts');
|
|
}
|
|
if ($streaming) largeExpect($deltas === ['SYNTHETIC_FINAL_ANSWER'], 'private extraction text never reaches clinical answer');
|
|
largeSafe($result, $events); $cases++;
|
|
}
|
|
|
|
foreach ([false, true] as $streaming) foreach (['provider-cap-three', 'reject-documents', 'missing'] as $scenario) {
|
|
largeReset($scenario);
|
|
if ($scenario === 'missing') $largeMissing = [2, 17, 39];
|
|
['result' => $result, 'deltas' => $deltas, 'events' => $events] = largeRun(largeFiles(40, true), $streaming);
|
|
largeExpect(!empty($result['ok']) && count($result['items']) === 40, 'known source gaps must not erase full task or its complete manifest');
|
|
$rejected = $scenario === 'reject-documents' ? 8 : 0;
|
|
$missing = $scenario === 'missing' ? 3 : 0;
|
|
largeExpect($result['submitted_attachment_count'] === 40 - $rejected - $missing
|
|
&& $result['rejected_attachment_count'] === $rejected && $result['skipped_attachment_count'] === $missing,
|
|
'accepted, rejected and missing sources partition original forty sources');
|
|
$final = $largeRequests[count($largeRequests) - 1]['payload']['query'];
|
|
foreach ($result['items'] as $index => $item) {
|
|
$id = $index + 1;
|
|
if ($item['status'] === 'submitted') largeExpect(largeHasObservation($final, $id), 'available source evidence reaches final synthesis');
|
|
else largeExpect(largeHasGap($final, $id), 'every known unread source is covered by explicit compact source-range gaps');
|
|
}
|
|
largeExpect(count($largeRequests) <= 80, 'bisection stays below binary-tree source bound plus synthesis');
|
|
if ($streaming) largeExpect($deltas === ['SYNTHETIC_FINAL_ANSWER'], 'failed child batch content is never displayed');
|
|
largeSafe($result, $events); $cases++;
|
|
}
|
|
|
|
foreach ([false, true] as $streaming) foreach (['auth', 'quota', 'server', 'ambiguous', 'network'] as $scenario) {
|
|
largeReset($scenario);
|
|
['result' => $result, 'deltas' => $deltas, 'events' => $events] = largeRun(largeFiles(40), $streaming);
|
|
largeExpect(empty($result['ok']) && count($largeRequests) === 2 && count($result['items']) === 40,
|
|
'unclassified second-batch failure stops entire task and preserves complete source manifest');
|
|
largeExpect(!array_filter($largeRequests, static fn (array $request): bool => $request['synthesis']),
|
|
'unknown/auth/network faults must not silently become permission to drop sources and synthesize');
|
|
largeExpect(count(array_filter($result['items'], static fn (array $item): bool => $item['status'] === 'prepared')) >= 20,
|
|
'sources never attempted after failure remain unsubmitted');
|
|
largeExpect($deltas === [] && !isset($result['attachment_gaps']),
|
|
'early whole-task failure has no fabricated final answer or completed gap synthesis');
|
|
largeSafe($result, $events); $cases++;
|
|
}
|
|
|
|
foreach ([false, true] as $streaming) foreach (['extraction-deadline', 'synthesis-deadline'] as $scenario) {
|
|
largeReset($scenario);
|
|
['result' => $result, 'deltas' => $deltas, 'events' => $events] = largeRun(largeFiles(40), $streaming);
|
|
largeExpect(empty($result['ok']) && $result['error_code'] === 'UPSTREAM_TIMEOUT', 'whole shared 90-second budget also constrains final synthesis');
|
|
$expected = $scenario === 'extraction-deadline' ? 3 : 5;
|
|
largeExpect(count($largeRequests) === $expected && abs($largeClock - 1090.0) < 0.01,
|
|
'later batches do not receive fresh ninety-second budgets');
|
|
largeExpect($largeRequests[count($largeRequests) - 1]['limitMs'] <= ($scenario === 'extraction-deadline' ? 28000 : 10000),
|
|
'real curl millisecond option receives remaining aggregate budget');
|
|
largeExpect($deltas === [], 'no extracted intermediate evidence is promoted to partial clinical answer on timeout');
|
|
largeSafe($result, $events); $cases++;
|
|
}
|
|
|
|
largeReset('cancel-second');
|
|
['result' => $result, 'deltas' => $deltas, 'events' => $events, 'tasks' => $tasks] = largeRun(largeFiles(40), true);
|
|
largeExpect(empty($result['ok']) && $result['error_code'] === 'AI_ASSISTANT_CANCELLED'
|
|
&& count($largeRequests) === 2 && $deltas === [] && $tasks !== [], 'stop during child extraction prevents every later extraction/synthesis and preserves real task IDs');
|
|
largeSafe($result, $events); $cases++;
|
|
|
|
foreach ([false, true] as $streaming) {
|
|
largeReset('context-limit');
|
|
['result' => $result, 'deltas' => $deltas, 'events' => $events] = largeRun(largeFiles(40), $streaming,
|
|
'COMPLETE_ORIGINAL_CASE_' . str_repeat('Long original case. ', 4000));
|
|
$final = $largeRequests[count($largeRequests) - 1];
|
|
largeExpect(!empty($result['ok']) && $final['synthesis'] && strlen($final['payload']['query']) < 120000
|
|
&& count($largeRequests) === 5 && $result['submitted_attachment_count'] === 40,
|
|
'lossless repeated-text encoding prevents final context overflow after all forty attachments were extracted');
|
|
foreach (range(1, 40) as $id) largeExpect(largeHasObservation($final['payload']['query'], $id), 'all forty independent observations survive repeated-text encoding');
|
|
largeExpect($result['attachment_gaps'] === [], 'reversible repetition coding does not invent a missing source');
|
|
largeSafe($result, $events); $cases++;
|
|
}
|
|
|
|
largeReset('context-unique');
|
|
$GLOBALS['largeConfig']['required_attachment_input_bytes'] = 100000;
|
|
['result' => $result] = largeRun(largeFiles(40), false);
|
|
$final = $largeRequests[count($largeRequests) - 1]['payload']['query'];
|
|
largeExpect(!empty($result['ok']) && strlen($final) < 120000 && $result['submitted_attachment_count'] === 40
|
|
&& count($result['attachment_gaps']) > 0 && str_contains($final, '本轮综合容量不足'),
|
|
'unique oversized evidence becomes explicit per-source synthesis gaps and a bounded answer, never silent truncation or an oversized final request');
|
|
foreach ($result['items'] as $item) largeExpect(($item['status'] ?? '') === 'submitted', 'budget gaps do not rewrite the fact every original source was transmitted');
|
|
largeExpect(largeAddedPayloadBytes($largeRequests[count($largeRequests) - 1]['payload'], 'COMPLETE_ORIGINAL_CASE_AND_OUTPUT_SCHEMA') <= 100000,
|
|
'unique evidence plus its gap metadata obey the actual serialized additional byte budget');
|
|
$cases++;
|
|
|
|
foreach ([false, true] as $streaming) {
|
|
largeReset('success');
|
|
['result' => $result, 'deltas' => $deltas] = largeRun(largeFiles(700), $streaming);
|
|
$final = $largeRequests[count($largeRequests) - 1]['payload'];
|
|
largeExpect(!empty($result['ok']) && count($result['items']) === 700
|
|
&& $result['submitted_attachment_count'] === 700 && $result['attachment_gaps'] === []
|
|
&& count($largeRequests) === 71, 'seven hundred short observations remain complete without extra inference requests');
|
|
foreach (range(1, 700) as $id) largeExpect(largeHasObservation($final['query'], $id), 'all seven hundred independent source facts fit and survive');
|
|
largeExpect(largeAddedPayloadBytes($final, 'COMPLETE_ORIGINAL_CASE_AND_OUTPUT_SCHEMA') <= 196608,
|
|
'default 192KiB bounds actual serialized additions instead of multiplying per-source fallback prose');
|
|
if ($streaming) largeExpect($deltas === ['SYNTHETIC_FINAL_ANSWER'], 'large source count still emits only final clinical text');
|
|
$cases++;
|
|
}
|
|
largeReset('success');
|
|
$largeConfig['required_attachment_input_bytes'] = 32768;
|
|
$original = 'COMPLETE_ORIGINAL_CASE_' . str_repeat('original "quoted" \\ value' . "\n", 10000);
|
|
['result' => $result] = largeRun(largeFiles(11), false, $original);
|
|
$final = $largeRequests[count($largeRequests) - 1]['payload'];
|
|
largeExpect(!empty($result['ok']) && str_starts_with($final['query'], $original)
|
|
&& $final['inputs'] === ['full_original_inputs' => $original] && $result['attachment_gaps'] === []
|
|
&& largeAddedPayloadBytes($final, $original) <= 32768 && count($largeRequests) === 3,
|
|
'a large original query and inputs remain exact; only new serialized attachment evidence is bounded, with no hard failure or extra calls');
|
|
$cases++;
|
|
|
|
largeReset('sparse-evidence');
|
|
['result' => $result] = largeRun(largeFiles(40), false);
|
|
$final = $largeRequests[count($largeRequests) - 1]['payload']['query'];
|
|
largeExpect(!empty($result['ok']) && $result['submitted_attachment_count'] === 40
|
|
&& count($result['attachment_gaps']) === 36 && !largeHasObservation($final, 2)
|
|
&& $result['items'][1]['evidence_status'] === 'unverified'
|
|
&& largeHasGap($final, 2) && str_contains($final, '已提交,但未取得逐来源可核对观察'),
|
|
'a missing observation becomes an explicit source gap without failing the whole known-case answer');
|
|
$cases++;
|
|
echo "Large attachment offline contract: {$cases} scenarios OK (10/11/20/40; mixed documents; known gaps; second-batch faults; 90s budget; stop; context and extraction-completeness risks)\n";
|
|
}
|