$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"; }