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

289 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
// Offline transport fixtures only: no bootstrap, .env, credentials, PHI or network.
namespace think\facade {
class Log { public static function warning(string $message): void { $GLOBALS['batchLogs'][] = $message; } }
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
class ClinicalKnowledgeReference {
public static function ancientBookSources(string $query): array { $GLOBALS['batchReferenceQueries'][] = $query; return []; }
public static function augmentQueryWithAncientBookSources(string $query, array $sources): string { return $query . "\nSYNTHETIC_REFERENCE"; }
}
class DifyRequiredAttachmentUpload {
public static function prepare(string $endpoint, string $key, string $user, array $files, int $timeout,
?callable $shouldAbort = null, array $options = []): array {
$GLOBALS['batchUploads'][] = compact('endpoint', 'user', 'files', 'timeout');
$wire = []; $indices = []; $skipped = [];
foreach ($files as $index => $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('/(?<!()图片附件\d+源文件不存在/u', $prompt), 'no batch-local missing label can conflict with original source identity');
}
foreach ($selectedFiles as $global => $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";
}