Files
zyt/server/tests/DifyRequiredFilesRejectionClassificationTest.php
T
2026-10-23 13:57:11 +08:00

224 lines
16 KiB
PHP

<?php
declare(strict_types=1);
// No application bootstrap, Composer, .env, network, database or reference files.
namespace think\facade {
class Log {
public static function warning(string $message): void { $GLOBALS['classificationLogs'][] = $message; }
public static function write(string $message, string $level): void {
if (!empty($GLOBALS['classificationThrowLog'])) throw new \RuntimeException('Synthetic log unavailable');
$GLOBALS['classificationLogs'][] = $message;
}
}
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
class NihaixiaClinicalSkill {
public static function knowledgeSource(): array { return ['title' => 'synthetic']; }
}
function config(string $name): array { return $GLOBALS['classificationConfig']; }
function curl_init(): \stdClass { return (object) ['errno' => 0]; }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->options = $options;
$handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true);
return true;
}
function curl_exec(\stdClass $handle): string|bool {
$options = $handle->options;
$payload = $handle->payload;
$streaming = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']);
$GLOBALS['classificationRequests'][] = [
'mode' => $streaming ? 'streaming' : 'blocking', 'payload' => $payload,
'raw' => $options[CURLOPT_POSTFIELDS], 'timeout' => $options[CURLOPT_TIMEOUT],
];
$fixture = array_shift($GLOBALS['classificationFixtures']);
if (!is_array($fixture)) throw new \RuntimeException('Unexpected extra upstream request');
$handle->code = $fixture['http'] ?? 200;
$handle->errno = $fixture['errno'] ?? 0;
if (!empty($fixture['delay_us'])) usleep($fixture['delay_us']);
if (!empty($fixture['cancel'])) $GLOBALS['classificationCancelled'] = true;
if (isset($options[CURLOPT_HEADERFUNCTION])) {
$options[CURLOPT_HEADERFUNCTION]($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n");
}
$progress = $options[defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION] ?? null;
if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) {
$handle->errno = CURLE_ABORTED_BY_CALLBACK;
return false;
}
if (isset($fixture['body'])) {
$body = $fixture['body'];
} elseif (isset($fixture['error'])) {
$body = $streaming && $handle->code === 200
? 'data: ' . json_encode(['event' => 'error'] + $fixture['error']) . "\n\n"
: json_encode($fixture['error']);
} else {
$body = !$streaming ? '{"answer":"synthetic answer"}'
: (isset($payload['messages'])
? "data: {\"choices\":[{\"delta\":{\"content\":\"synthetic answer\"}}]}\n\ndata: [DONE]\n\n"
: "data: {\"event\":\"message\",\"answer\":\"synthetic answer\"}\n\ndata: {\"event\":\"message_end\"}\n\n");
}
if (!$streaming) return $body;
// Exercise bounded HTTP error parsing across arbitrary network chunks.
$write = $options[CURLOPT_WRITEFUNCTION];
foreach (str_split($body, 11) as $chunk) {
if ($write($handle, $chunk) !== strlen($chunk)) { $handle->errno = CURLE_WRITE_ERROR; break; }
}
return true;
}
function curl_errno(\stdClass $handle): int { return $handle->errno; }
function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; }
function curl_close(\stdClass $handle): void { ++$GLOBALS['classificationClosed']; }
}
namespace {
$root = dirname(__DIR__, 2);
require $root . '/server/app/common/logic/BaseLogic.php';
require $root . '/server/app/common/service/DifyChatService.php';
require $root . '/server/app/common/service/DifyStreamTiming.php';
require $root . '/server/app/adminapi/service/AssistantSseTiming.php';
require $root . '/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php';
use app\common\service\DifyChatService;
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AssistantSseTiming;
function classificationExpect(bool $condition, string $message): void {
if (!$condition) throw new RuntimeException($message);
}
$classificationConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages',
'timeout' => 5, 'max_files' => 3,
'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-only']]];
$files = array_map(static fn (int $id): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $id . '.png'], [1, 2]);
$sections = array_map(static fn (int $id): array => ['key' => 'source_' . $id, 'label' => 'synthetic',
'count' => $id === 13 ? 0 : 1, 'availability' => $id === 13 ? 'empty' : 'present'], range(1, 13));
// Test the one-attempt classification boundary in isolation. Public-entry
// clinical batching/recovery is covered by DifyClinicalAttachmentBatchTest.
$options = ['required_files' => true, '_required_batch_internal' => true, 'knowledge_mode' => 'evidence', 'required_sections' => $sections];
$reset = static function (array $fixtures): void {
$GLOBALS['classificationFixtures'] = $fixtures;
$GLOBALS['classificationRequests'] = [];
$GLOBALS['classificationLogs'] = [];
$GLOBALS['classificationCancelled'] = false;
$GLOBALS['classificationClosed'] = 0;
$GLOBALS['classificationThrowLog'] = false;
};
$call = static function (bool $streaming, array $inputs = [], array $extra = []) use ($files, $options): array {
$selected = array_replace($options, $extra);
return $streaming
? DifyChatService::streamChat('qwen', $inputs, 'synthetic query', 'synthetic-user', static fn (): bool => true, null, $files, $selected)
: DifyChatService::chat('qwen', $inputs, 'synthetic query', 'synthetic-user', $files, $selected);
};
$private = static fn (string $method, array $args) => (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $args);
$inputError = ['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Unknown input variable: synthetic_field']];
$fileError = ['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Number of files exceeds the limit.']];
$secret = 'SYNTHETIC_PRIVATE_MARKER';
foreach ([false, true] as $streaming) {
// Mere status codes/invalid_param do not identify a file or an inputs error.
foreach ([400, 413, 415, 422] as $http) {
$reset([['http' => $http, 'error' => ['code' => 'invalid_param', 'message' => $secret . ' https://synthetic.invalid/private.png']]]);
$result = $call($streaming, ['synthetic_field' => 'value']);
classificationExpect($result['error_code'] === 'UPSTREAM_REJECTED' && count($classificationRequests) === 1,
'Generic validation does not invent attachment or inputs rejection');
classificationExpect(!str_contains(json_encode([$result, $classificationLogs]), $secret), 'Provider messages never enter errors or logs');
classificationExpect(count($classificationLogs) === 1
&& str_contains($classificationLogs[0], '"upstream_code":"invalid_param"')
&& !str_contains($classificationLogs[0], 'https://'), 'Safe failure code is logged immediately without hostnames or resource URLs');
}
foreach ([[401, 'CONFIG_INVALID'], [403, 'CONFIG_INVALID'], [429, 'UPSTREAM_BUSY'], [503, 'UPSTREAM_BUSY']] as [$http, $expected]) {
$reset([$inputError, ['http' => $http, 'error' => ['code' => 'invalid_param', 'message' => 'file type not supported']]]);
$result = $call($streaming, ['synthetic_field' => 'value']);
classificationExpect($result['error_code'] === $expected && count($classificationRequests) === 2,
'Latest auth/availability response is not overwritten by an earlier inputs rejection');
classificationExpect(array_map(static fn (array $request): int => count($request['payload']['files']), $classificationRequests) === [2, 2],
'Every compatibility request retains both mandatory files');
}
$reset([$inputError, []]);
$result = $call($streaming, ['synthetic_field' => 'value']);
classificationExpect(!empty($result['ok']) && count($classificationRequests) === 2
&& $classificationRequests[1]['payload']['inputs'] === []
&& str_contains($classificationRequests[1]['raw'], '"inputs":{}')
&& $classificationRequests[0]['payload']['query'] === $classificationRequests[1]['payload']['query'],
'Explicit inputs compatibility succeeds with identical query/files and object inputs');
foreach ([$fileError, ['http' => 413, 'error' => ['code' => 'file_too_large']],
['http' => 415, 'error' => ['code' => 'unsupported_file_type']],
['http' => 422, 'error' => ['code' => 'invalid_param', 'message' => 'File type is not supported.']]] as $fixture) {
$reset([$fixture]); $events = [];
$result = $call($streaming, ['synthetic_field' => 'value'],
['on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]);
classificationExpect($result['error_code'] === 'REQUIRED_FILES_REJECTED' && count($classificationRequests) === 1,
'Affirmative file count/type/size failure never strips mandatory files or retries inputs');
$last = $events[count($events) - 1];
foreach ([$result, $last] as $manifest) {
classificationExpect($manifest['transfer_manifest_final'] === true && $manifest['attachment_count'] === 2
&& count($manifest['required_sections']) === 13
&& array_unique(array_column($manifest['items'], 'status')) === ['failed']
&& array_unique(array_column($manifest['required_sections'], 'status')) === ['failed']
&& $manifest['required_sections'][12]['availability'] === 'empty', 'Failure results and progress carry a complete final failed manifest');
}
}
}
// Business SSE errors may be reported under HTTP 200. An ambiguous base must
// not re-submit quotas, credentials, cancellation or affirmative file failures.
$classificationConfig['base_url'] = 'https://synthetic.invalid/v1';
foreach ([['provider_quota_exceeded', 'UPSTREAM_BUSY'], ['invalid_api_key', 'CONFIG_INVALID'],
['provider_unavailable', 'UPSTREAM_BUSY'], ['task_cancelled', 'AI_ASSISTANT_CANCELLED'],
['file_download_failed', 'REQUIRED_FILES_REJECTED']] as [$code, $expected]) {
$reset([['http' => 200, 'error' => ['code' => $code, 'message' => $secret]]]);
$result = $call(true);
classificationExpect($result['error_code'] === $expected && count($classificationRequests) === 1,
'SSE business failure retains its diagnosis without cross-protocol replay');
}
$classificationConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages';
$reset([['http' => 200, 'error' => ['code' => 'unknown_provider_error', 'message' => $secret]]]);
classificationExpect($call(true)['error_code'] === 'UPSTREAM_REJECTED', 'Unknown SSE errors do not implicate files');
$modeError = ['http' => 400, 'delay_us' => 3000,
'error' => ['code' => 'invalid_param', 'message' => 'response_mode must be blocking. ' . $secret]];
$prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => 'synthetic query', 'user' => 'synthetic-user',
'files' => $files, 'inputs' => ['synthetic_field' => 'value'], 'required_sections' => $sections, 'upstream_timeout' => 5];
$reset([$modeError, []]);
$answer = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, null,
new AssistantSseTiming(), ['knowledge_mode' => 'evidence']);
classificationExpect(is_array($answer) && array_column($classificationRequests, 'mode') === ['streaming', 'blocking']
&& count($classificationRequests[1]['payload']['files']) === 2
&& $classificationRequests[0]['payload']['files'] === $classificationRequests[1]['payload']['files']
&& $classificationRequests[0]['payload']['inputs'] === $classificationRequests[1]['payload']['inputs']
&& $classificationRequests[1]['timeout'] <= 4, 'Actual assistant recovers response_mode with both files and the remaining shared budget');
// An emitted clinical delta prevents replay even if a later SSE error names inputs.
$reset([['http' => 200, 'body' => "data: {\"event\":\"message\",\"answer\":\"synthetic partial\"}\n\ndata: {\"event\":\"error\",\"code\":\"invalid_inputs\"}\n\n"]]);
$answer = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, null,
new AssistantSseTiming(), ['knowledge_mode' => 'evidence']);
classificationExpect($answer === null && count($classificationRequests) === 1, 'No inputs or blocking replay after delivered text');
$reset([['http' => 400, 'cancel' => true, 'error' => ['code' => 'file_too_large']]]);
$result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files,
$options + ['should_abort' => static fn (): bool => $GLOBALS['classificationCancelled']]);
classificationExpect($result['error_code'] === 'AI_ASSISTANT_CANCELLED' && count($classificationRequests) === 1
&& $classificationClosed === 1, 'Cancellation closes transport and wins over apparent file errors');
// Optional-file and generic strict callers retain their existing distinction.
$reset([['http' => 400], []]);
$result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, ['knowledge_mode' => 'evidence']);
classificationExpect(!empty($result['ok']) && !isset($classificationRequests[1]['payload']['files']), 'Legacy optional files keep the explicit text fallback');
$reset([['http' => 400]]);
$result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, ['knowledge_mode' => 'evidence', 'strict_files' => true]);
classificationExpect($result['error_code'] === 'UPSTREAM_REJECTED' && count($classificationRequests) === 1, 'Legacy strict never drops mandatory batch data');
$reset([['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Failed to download image ' . $secret]]]);
$transport = $private('sendStreamRequest', ['dify', 'https://synthetic.invalid/v1/chat-messages', ['response_mode' => 'streaming'], 'synthetic-only', 5, static fn (): bool => true, null]);
classificationExpect($transport['upstream_code'] === 'invalid_param' && $transport['upstream_reason'] === 'file_access'
&& !isset($transport['body']) && !str_contains(json_encode($transport), $secret), 'Split HTTP error JSON retains only a short code and fixed reason');
$reset([['http' => 400, 'error' => ['code' => 'file_too_large', 'message' => str_repeat($secret, 1500)]]]);
$transport = $private('sendStreamRequest', ['dify', 'https://synthetic.invalid/v1/chat-messages', ['response_mode' => 'streaming'], 'synthetic-only', 5, static fn (): bool => true, null]);
classificationExpect($transport['upstream_code'] === '' && $transport['upstream_reason'] === '' && !isset($transport['body']), 'Oversized error bodies are discarded instead of retained or partially decoded');
classificationExpect($private('upstreamErrorMetadata', [['error' => ['code' => 'insufficient_quota', 'message' => $secret]]])['upstream_reason'] === 'quota', 'Nested OpenAI JSON errors use the same safe classification');
$reset([['http' => 503]]); $classificationThrowLog = true;
classificationExpect($call(true)['error_code'] === 'UPSTREAM_BUSY', 'Logging failure cannot alter or throw away the upstream diagnosis');
echo "Dify mandatory-file error classification / compatibility / final manifests / cancellation: OK\n";
}