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

338 lines
23 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 hrtime(bool $asNumber = false): int|array {
$now = (int) round($GLOBALS['largeClock'] * 1000000000);
return $asNumber ? $now : [intdiv($now, 1000000000), $now % 1000000000];
}
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);
$streaming = ($payload['response_mode'] ?? '') === 'streaming';
$headerSent = false;
if ($streaming && $handle->code === 200) {
($options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 Synthetic\r\n");
$headerSent = true;
($options[CURLOPT_WRITEFUNCTION])($handle, 'data: ' . json_encode([
'event' => 'workflow_started', 'task_id' => 'large-task-' . count($GLOBALS['largeRequests']),
]) . "\n\n");
}
// Model the progress pump's monotonic generation deadline separately
// from libcurl's larger, bounded stop/terminal-drain transport cap.
$progress = $options[defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION] ?? null;
$advanceMs = min((int) round($duration * 1000), $limitMs);
for ($elapsedMs = 0; $elapsedMs < $advanceMs;) {
$stepMs = min(100, $advanceMs - $elapsedMs);
$GLOBALS['largeClock'] += $stepMs / 1000;
$elapsedMs += $stepMs;
if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) {
$handle->errno = CURLE_ABORTED_BY_CALLBACK;
return false;
}
}
if ($duration * 1000 >= $limitMs) {
$handle->errno = CURLE_OPERATION_TIMEDOUT; $handle->code = 0;
return false;
}
if (!$headerSent && isset($options[CURLOPT_HEADERFUNCTION])) {
($options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n");
}
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_once dirname(__DIR__) . '/app/common/service/DifyReasoningTextFilter.php';
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['largeTimeoutClock'] = null;
$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_timeout' => static function (): void { $GLOBALS['largeTimeoutClock'] = $GLOBALS['largeClock']; },
'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
&& ($streaming ? abs($largeTimeoutClock - 1090.0) < 0.01 && $largeClock <= 1092.51
: abs($largeClock - 1090.0) < 0.01),
'later batches share ninety seconds of generation; only bounded stop/terminal draining may follow');
largeExpect($largeRequests[count($largeRequests) - 1]['limitMs']
<= ($scenario === 'extraction-deadline' ? 28000 : 10000) + ($streaming ? 2500 : 0),
'curl cap is the remaining aggregate generation budget plus streaming cleanup, never a fresh 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";
}