$file) { $id = (int) pathinfo(parse_url($file['url'], PHP_URL_PATH), PATHINFO_FILENAME); if ($GLOBALS['batchScenario'] === 'upload-ambiguous') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED']; if ($GLOBALS['batchScenario'] === 'upload-size' && $id === 4) return ['ok' => false, 'error_code' => 'REQUIRED_FILES_TOO_LARGE']; if ($GLOBALS['batchScenario'] === 'all-missing' || ($GLOBALS['batchScenario'] === 'missing' && in_array($id, [1, 4, 7], true)) || in_array($id, $GLOBALS['batchMissingIds'] ?? [], true)) { $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' => 'synthetic-upload-' . $id]; $indices[] = $index + 1; } return ['ok' => true, 'files' => $wire, 'uploaded_indices' => $indices, 'skipped' => $skipped]; } } function config(string $name): array { return $GLOBALS['batchConfig']; } function batchFixtureQuery(array $payload): string { if (isset($payload['query'])) return $payload['query']; $texts = []; foreach ($payload['messages'] ?? [] as $message) { if (is_string($message['content'] ?? null)) $texts[] = $message['content']; elseif (is_array($message['content'] ?? null)) foreach ($message['content'] as $part) { if (($part['type'] ?? '') === 'text') $texts[] = $part['text']; } } return implode("\n", $texts); } function batchFixtureWireIds(array $payload): array { if (isset($payload['files'])) return array_column($payload['files'], 'upload_file_id'); $ids = []; foreach ($payload['messages'] ?? [] as $message) if (is_array($message['content'] ?? null)) foreach ($message['content'] as $part) { if (($part['type'] ?? '') === 'image_url') $ids[] = 'synthetic-upload-' . (int) pathinfo(parse_url($part['image_url']['url'], PHP_URL_PATH), PATHINFO_FILENAME); } return $ids; } function curl_init(): \stdClass { return (object) ['errno' => 0]; } function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; } function curl_exec(\stdClass $handle): string|bool { $options = $handle->options; $payload = json_decode($options[CURLOPT_POSTFIELDS], true); if (str_ends_with($options[CURLOPT_URL], '/stop')) { $handle->code = 200; return '{"result":"success"}'; } $GLOBALS['batchRequests'][] = ['payload' => $payload, 'timeout' => $options[CURLOPT_TIMEOUT], 'url' => $options[CURLOPT_URL]]; $scenario = $GLOBALS['batchScenario']; $extract = str_starts_with(batchFixtureQuery($payload), '【附件分批证据提取】'); $wire = $payload['files'] ?? []; $ids = batchFixtureWireIds($payload); $dify = str_ends_with($options[CURLOPT_URL], '/chat-messages'); $error = null; $handle->code = 200; if ($scenario === 'mapping-fallback' && $dify) { $handle->code = 404; $error = ['code' => 'not_found']; } elseif ($extract && (($scenario === 'count-two' && count($wire) > 2) || ($scenario === 'mapping-count-one' && count($ids) > 1))) { $handle->code = 400; $error = ['code' => 'too_many_files', 'message' => 'Number of files exceeds the limit']; } elseif ($extract && in_array($scenario, ['reject', 'reject-sse'], true) && in_array('synthetic-upload-4', $ids, true)) { $handle->code = $scenario === 'reject-sse' ? 200 : 415; $error = ['code' => 'unsupported_file_type', 'message' => 'SYNTHETIC_PRIVATE_ERROR https://private.invalid/file']; } elseif ($extract && in_array($scenario, ['ambiguous', 'authentication', 'quota', 'server'], true)) { $handle->code = ['ambiguous' => 413, 'authentication' => 401, 'quota' => 429, 'server' => 503][$scenario]; $error = ['code' => 'invalid_param', 'message' => 'SYNTHETIC_PRIVATE_ERROR']; } elseif ($scenario === 'partial' && !$extract) { $error = ['code' => 'unsupported_file_type']; } if ($extract && $scenario === 'deadline') usleep(1100000); if ($extract && $scenario === 'cancel') $GLOBALS['batchCancelled'] = true; if (isset($options[CURLOPT_HEADERFUNCTION])) ($options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); $stream = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); if ($error !== null && (!$stream || $handle->code !== 200)) { $body = json_encode($error); if ($stream) { ($options[CURLOPT_WRITEFUNCTION])($handle, $body); return true; } return $body; } $answer = 'SYNTHETIC_FINAL_ANSWER'; if ($extract) { preg_match_all('/实际附件\d+ => 原始附件#(\d+)/u', batchFixtureQuery($payload), $map); $rows = []; foreach ($ids as $position => $id) $rows[] = ['index' => (int) ($map[1][$position] ?? 0), 'status' => 'read', 'findings' => 'EVIDENCE_' . $id]; $answer = json_encode(['files' => $rows]); } if (!$stream) return json_encode($dify ? ['answer' => $answer] : ['choices' => [['message' => ['content' => $answer]]]]); $write = $options[CURLOPT_WRITEFUNCTION]; if (!$dify) { $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => $answer]]]]) . "\n\n"); $write($handle, "data: [DONE]\n\n"); return true; } $write($handle, 'data: ' . json_encode(['event' => 'message', 'task_id' => 'synthetic-task-' . count($GLOBALS['batchRequests']), 'answer' => $answer]) . "\n\n"); if ($error !== null) $write($handle, 'data: ' . json_encode(['event' => 'error'] + $error) . "\n\n"); else $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 batchExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); } $batchConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 20, 'required_attachment_transport' => 'upload', 'max_files' => 3, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'SYNTHETIC_KEY', 'max_files' => 3]]]; $files = array_map(static fn (int $index): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $index . '.png'], range(1, 7)); $sections = array_map(static fn (int $index): array => ['key' => 'source_' . $index, 'label' => '栏目' . $index, 'count' => 1, 'availability' => 'present', 'status' => 'prepared'], range(1, 13)); $query = implode("\n", array_map(static fn (int $index): string => 'ORIGINAL_SECTION_' . $index . ':' . str_repeat('合成正文', 120), range(1, 13))) . "\nORIGINAL_TASK_JSON_SCHEMA"; $inputs = ['original_task' => 'synthetic_task', 'full_context' => $query]; $reset = static function (string $scenario): void { $GLOBALS['batchScenario'] = $scenario; $GLOBALS['batchRequests'] = []; $GLOBALS['batchUploads'] = []; $GLOBALS['batchLogs'] = []; $GLOBALS['batchCancelled'] = false; $GLOBALS['batchReferenceQueries'] = []; $GLOBALS['batchMissingIds'] = []; }; foreach ([false, true] as $streaming) { foreach (array_merge(['success', 'missing', 'all-missing', 'reject', 'count-two', 'upload-size'], $streaming ? ['reject-sse'] : []) as $scenario) { $reset($scenario); $deltas = []; $events = []; $tasks = []; $ends = []; $options = ['required_files' => true, 'required_sections' => $sections, '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; }]; $result = $streaming ? DifyChatService::streamChat('qwen', $inputs, $query, 'same-synthetic-user', static function (string $delta) use (&$deltas): bool { $deltas[] = $delta; return true; }, null, $files, $options) : DifyChatService::chat('qwen', $inputs, $query, 'same-synthetic-user', $files, $options); batchExpect(!empty($result['ok']), 'every known attachment gap still permits final original task: ' . $scenario); batchExpect($result['attachment_count'] === 7 && $result['attachment_mode'] === 'batch_evidence' && $result['attachment_transfer'] === 'uploaded_files' && count($result['items']) === 7, 'full seven-item safe receipt survives synthesis'); $missing = $scenario === 'all-missing' ? 7 : ($scenario === 'missing' ? 3 : 0); $rejected = in_array($scenario, ['reject', 'reject-sse', 'upload-size'], true) ? 1 : 0; batchExpect($result['submitted_attachment_count'] === 7 - $missing - $rejected && $result['skipped_attachment_count'] === $missing && $result['rejected_attachment_count'] === $rejected, 'accepted, missing and rejected sources are disjoint and complete'); batchExpect(array_column($result['items'], 'index') === range(1, 7) && array_column($result['items'], 'label') === array_map(static fn (int $id): string => '图片附件' . $id, range(1, 7)), 'original labels and positions survive each batch'); batchExpect(count($result['required_sections']) === 13 && array_unique(array_column($result['required_sections'], 'status')) === ['submitted'], 'all thirteen original body sections finally submitted'); $final = $batchRequests[count($batchRequests) - 1]['payload']; batchExpect(str_starts_with($final['query'], $query) && $final['inputs'] === $inputs && ($final['files'] ?? []) === [], 'final original full text/schema/inputs are preserved without falsely retransmitting original files'); batchExpect(str_contains($final['query'], '最终单次请求未重新携带原始附件') && str_contains($result['attachment_notice'], '分批'), 'final task and envelope truthfully identify batch evidence'); if ($scenario === 'all-missing') batchExpect(!str_contains($final['query'], 'EVIDENCE_'), 'a response without any available source is never promoted into attachment evidence'); $covered = []; foreach ($batchRequests as $request) { batchExpect($request['url'] === 'https://synthetic.invalid/v1/chat-messages' && $request['payload']['user'] === 'same-synthetic-user', 'all calls retain same app and user'); batchExpect(count($request['payload']['files'] ?? []) <= 3, 'no request exceeds configured app cap'); if (str_starts_with($request['payload']['query'], '【附件分批证据提取】')) { batchExpect(!str_contains($request['payload']['query'], 'SYNTHETIC_REFERENCE'), 'intermediate evidence receives no ancient-book augmentation'); foreach ($request['payload']['files'] ?? [] as $wire) $covered[$wire['upload_file_id']] = true; } } if ($scenario === 'success') { batchExpect(count($batchRequests) === 4 && array_map(static fn (array $request): int => count($request['payload']['files'] ?? []), $batchRequests) === [3, 3, 1, 0], 'seven sources use exactly 3+3+1 and one synthesis'); batchExpect(array_keys($covered) === array_map(static fn (int $id): string => 'synthetic-upload-' . $id, range(1, 7)), 'every original source is covered exactly once'); foreach (range(1, 7) as $id) batchExpect(str_contains($final['query'], 'synthetic-upload-' . $id), 'each batch evidence is retained in full final query'); batchExpect(count($batchReferenceQueries) === 1, 'clinical reference is added to final synthesis only'); } if (in_array($scenario, ['reject', 'reject-sse', 'count-two'], true)) batchExpect(count($batchUploads) === 3, 'bisection reuses already uploaded app/user IDs'); if ($streaming) batchExpect($deltas === ['SYNTHETIC_FINAL_ANSWER'] && count($ends) === count($batchRequests) && count($tasks) >= 1, 'only final clinical delta is delivered and every child transfer ends with task callbacks preserved'); foreach ($events as $event) if (!str_contains($event['message'] ?? '', '分批提交提取证据')) { $isFinal = ($event['phase'] ?? '') === 'upstream' || str_contains($event['message'] ?? '', '资料正文与附件引用已提交'); if (!$isFinal && ($event['attachment_mode'] ?? '') === 'batch_evidence' && ($event['status'] ?? '') === 'uploading') { batchExpect(array_unique(array_column($event['required_sections'], 'status')) === ['prepared'], 'body sections stay prepared during attachment extraction uploads'); } } batchExpect(!str_contains(json_encode([$result, $events, $batchLogs]), 'SYNTHETIC_PRIVATE_ERROR') && !str_contains(json_encode([$result, $events, $batchLogs]), 'SYNTHETIC_KEY'), 'safe callbacks/results/logs never expose provider message, URL or key'); } foreach (['ambiguous', 'authentication', 'quota', 'server', 'upload-ambiguous'] as $scenario) { $reset($scenario); $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'required_sections' => $sections]; $result = $streaming ? DifyChatService::streamChat('qwen', $inputs, $query, 'same-synthetic-user', static fn (): bool => true, null, $files, $options) : DifyChatService::chat('qwen', $inputs, $query, 'same-synthetic-user', $files, $options); batchExpect(empty($result['ok']) && count($batchRequests) === ($scenario === 'upload-ambiguous' ? 0 : 1), 'ambiguous/auth/quota/server failure never removes files and synthesizes'); } foreach (['cancel', 'deadline'] as $scenario) { $reset($scenario); $stops = []; $ends = 0; $options = ['required_files' => true, 'timeout' => $scenario === 'deadline' ? 1 : 20, 'knowledge_mode' => 'evidence', 'should_abort' => static fn (): bool => $GLOBALS['batchCancelled'], 'on_stop' => static function (...$args) use (&$stops): void { $stops[] = $args; }, 'on_transfer_end' => static function (...$args) use (&$ends): void { $ends++; }]; $result = $streaming ? DifyChatService::streamChat('qwen', [], $query, 'same-synthetic-user', static fn (): bool => true, $options['should_abort'], $files, $options) : DifyChatService::chat('qwen', [], $query, 'same-synthetic-user', $files, $options); batchExpect($result['error_code'] === ($scenario === 'deadline' ? 'UPSTREAM_TIMEOUT' : 'AI_ASSISTANT_CANCELLED') && count($batchRequests) === 1 && count($batchUploads) === 1, 'shared cancellation/deadline prevents every later upload, extraction and synthesis'); batchExpect($ends === 1 && ($scenario !== 'cancel' || $stops !== []), 'cancel/end callbacks survive extraction transport'); } } $reset('success'); $duplicateFiles = $files; $duplicateFiles[1] = $files[0]; $result = DifyChatService::chat('qwen', [], $query, 'same-synthetic-user', $duplicateFiles, ['required_files' => true, 'knowledge_mode' => 'evidence']); batchExpect($result['submitted_attachment_count'] === 7 && array_column($batchRequests[0]['payload']['files'], 'upload_file_id') === ['synthetic-upload-1', 'synthetic-upload-1', 'synthetic-upload-3'], 'duplicate logical original positions remain independently submitted without deduplication'); DifyChatService::chat('qwen', [], $query, 'another-synthetic-user', $duplicateFiles, ['required_files' => true, 'knowledge_mode' => 'evidence']); batchExpect(count($batchUploads) === 6 && $batchUploads[3]['user'] === 'another-synthetic-user', 'upload IDs never persist into another request/user'); // Assert the model input's identity map, not only the final transport receipt. // The second batch carries global 4/5/6; filtering must not renumber a missing // source into "图片附件1", including when that set is later bisected. foreach ([false, true] as $streaming) foreach (['mapping', 'mapping-count-one', 'mapping-fallback', 'mapping-openai'] as $scenario) { foreach ([[1], [4], [5], [6], [7], [4, 5], [4, 5, 6]] as $missingIds) { foreach ([false, true] as $duplicates) { $reset($scenario); $batchMissingIds = $missingIds; $selectedFiles = $files; if ($duplicates) $selectedFiles[4] = $files[3]; $batchConfig['base_url'] = $scenario === 'mapping-fallback' ? 'https://synthetic.invalid/v1' : ($scenario === 'mapping-openai' ? 'https://synthetic.invalid/v1/chat/completions' : 'https://synthetic.invalid/v1/chat-messages'); // Direct OpenAI has no source availability/upload stage, so its // original references all remain physically present. $physicalMissing = $scenario === 'mapping-openai' ? [] : $missingIds; $opts = ['required_files' => true, 'knowledge_mode' => 'evidence']; $result = $streaming ? DifyChatService::streamChat('qwen', [], $query, 'same-synthetic-user', static fn (): bool => true, null, $selectedFiles, $opts) : DifyChatService::chat('qwen', [], $query, 'same-synthetic-user', $selectedFiles, $opts); batchExpect(!empty($result['ok']), 'physical mapping cases complete without clinical identity loss'); $seen = []; foreach ($batchRequests as $request) { $prompt = \app\common\service\batchFixtureQuery($request['payload']); if (!str_starts_with($prompt, '【附件分批证据提取】')) continue; $wireIds = \app\common\service\batchFixtureWireIds($request['payload']); preg_match_all('/实际附件(\d+) => 原始附件#(\d+)(([^)]+))/u', $prompt, $map, PREG_SET_ORDER); batchExpect(count($map) === count($wireIds), 'every actual physical wire entry has exactly one stable source mapping'); $mapped = []; foreach ($map as $position => $entry) { $global = (int) $entry[2]; $sourceId = (int) pathinfo(parse_url($selectedFiles[$global - 1]['url'], PHP_URL_PATH), PATHINFO_FILENAME); batchExpect((int) $entry[1] === $position + 1 && $entry[3] === '图片附件' . $global && $wireIds[$position] === 'synthetic-upload-' . $sourceId && !isset($mapped[$global]), 'wire order maps to correct original source/label even for duplicate URLs'); batchExpect(!in_array($sourceId, $physicalMissing, true), 'confirmed missing sources cannot appear in actual attachment map'); $mapped[$global] = true; $seen[$global] = true; } preg_match_all('/^原始附件#(\d+)(图片附件\d+)$/mu', $prompt, $listed, PREG_SET_ORDER); foreach ($listed as $entry) { $global = (int) $entry[1]; $sourceId = (int) pathinfo(parse_url($selectedFiles[$global - 1]['url'], PHP_URL_PATH), PATHINFO_FILENAME); if (in_array($sourceId, $physicalMissing, true)) batchExpect(str_contains($prompt, '原始附件#' . $global . '(图片附件' . $global . ')源文件不存在,已跳过,未提供给模型'), 'missing notice uses the same stable original identity as the extraction list'); } batchExpect(!preg_match('/(? $file) { $sourceId = (int) pathinfo(parse_url($file['url'], PHP_URL_PATH), PATHINFO_FILENAME); batchExpect(isset($seen[$global + 1]) === !in_array($sourceId, $physicalMissing, true), 'every available original logical position has a physical identity map'); } } } } $batchConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages'; $reset('success'); $batchMissingIds = [1]; $result = DifyChatService::chat('qwen', [], $query, 'same-synthetic-user', array_slice($files, 0, 2), ['required_files' => true, 'knowledge_mode' => 'evidence']); batchExpect(!empty($result['ok']) && count($batchRequests) === 1 && !str_contains($batchRequests[0]['payload']['query'], '【附件原始序号与实际顺序】') && str_contains($batchRequests[0]['payload']['query'], '图片附件1源文件不存在'), 'direct non-batch missing-source prompt keeps its prior contract'); // A rejection after a visible original clinical delta can never replay the task. $reset('partial'); $deltas = []; $result = DifyChatService::streamChat('qwen', [], $query, 'same-synthetic-user', static function (string $delta) use (&$deltas): bool { $deltas[] = $delta; return true; }, null, array_slice($files, 0, 2), ['required_files' => true, 'knowledge_mode' => 'evidence']); batchExpect(empty($result['ok']) && count($batchRequests) === 1 && count($deltas) === 1, 'no batch recovery/replay after a delivered original clinical delta'); echo "Clinical required attachment batching / 7 sources / stable physical source maps / gaps / shared identity and upload reuse / stream isolation / cancellation and deadline: OK\n"; }