289 lines
24 KiB
PHP
289 lines
24 KiB
PHP
<?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";
|
||
}
|