221 lines
16 KiB
PHP
221 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/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));
|
|
$options = ['required_files' => 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";
|
|
}
|