Files
zyt/server/tests/DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php
2026-10-23 09:26:49 +08:00

126 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
namespace think\facade {
class Log {
public static function warning(string $message): void {}
}
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['integrationConfig']; }
function curl_init(): \stdClass { return new \stdClass(); }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->options = $options;
$handle->url = $options[CURLOPT_URL];
$handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true);
return true;
}
function curl_exec(\stdClass $handle) {
$payload = $handle->payload;
$streaming = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']);
$GLOBALS['integrationRequests'][] = ['url' => $handle->url, 'payload' => $payload, 'streaming' => $streaming];
$scenario = $GLOBALS['integrationScenario'];
if (($scenario === 'blocking_only' || $scenario === 'cancel_blocking') && $streaming) $handle->code = 400;
elseif ($scenario === 'file_rejected' && !empty($payload['files'])) $handle->code = 400;
elseif ($scenario === 'protocol_fallback' && str_ends_with($handle->url, '/chat-messages')) $handle->code = 404;
else $handle->code = 200;
if (isset($handle->options[CURLOPT_HEADERFUNCTION])) {
($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n");
}
if ($handle->code !== 200) return $streaming ? true : '{}';
$progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION;
$progress = $handle->options[$progressOption] ?? null;
if (!$streaming) {
if ($scenario === 'cancel_blocking') $GLOBALS['integrationCancelled'] = true;
if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false;
return json_encode(['answer' => '合成完整回答']);
}
if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false;
$write = $handle->options[CURLOPT_WRITEFUNCTION];
if (str_ends_with($handle->url, '/chat-messages')) {
$write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => '合成完整回答']) . "\n\n");
$write($handle, "data: {\"event\":\"message_end\"}\n\n");
} else {
$write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => '合成完整回答']]]]) . "\n\n");
$write($handle, "data: [DONE]\n\n");
}
return true;
}
function curl_errno(\stdClass $handle): int { return 0; }
function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; }
function curl_close(\stdClass $handle): void { ++$GLOBALS['integrationClosed']; }
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AssistantSseTiming;
function integrationExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); }
$integrationConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5,
'max_files' => 2, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]];
$reset = static function (string $scenario = 'success'): void {
$GLOBALS['integrationScenario'] = $scenario;
$GLOBALS['integrationRequests'] = [];
$GLOBALS['integrationCancelled'] = false;
$GLOBALS['integrationClosed'] = 0;
};
$abort = static fn (): bool => $GLOBALS['integrationCancelled'];
$strict = ['strict_files' => true, 'knowledge_mode' => 'evidence', 'should_abort' => $abort];
$images = array_map(static fn (int $id): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $id . '.png'], [1, 2, 3]);
$document = [['type' => 'document', 'url' => 'https://synthetic.invalid/report.pdf']];
$call = static fn (array $files): array => DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $strict);
$reset();
integrationExpect($call($images)['error_code'] === 'STRICT_FILES_INVALID_OR_LIMIT' && $integrationRequests === [], 'strict + abort rejects over-limit files before transport');
integrationExpect($call([['type' => 'image', 'url' => 'invalid']])['error_code'] === 'STRICT_FILES_INVALID_OR_LIMIT'
&& $integrationRequests === [], 'strict + abort rejects invalid files before transport');
$result = $call([$images[0], $images[0]]);
integrationExpect(!empty($result['ok']) && $result['transmitted_file_count'] === 2 && $result['attachment_transport'] === 'dify'
&& count($integrationRequests[0]['payload']['files']) === 2, 'strict stream retains logical duplicates and exact success metadata');
$reset('file_rejected');
$result = $call([$images[0]]);
integrationExpect(empty($result['ok']) && count($integrationRequests) === 1 && count($integrationRequests[0]['payload']['files']) === 1,
'strict rejected files never trigger a text-only success fallback');
$integrationConfig['base_url'] = 'https://synthetic.invalid/v1/chat/completions';
$reset();
integrationExpect($call($document)['error_code'] === 'FILE_TYPE_UNSUPPORTED' && $integrationRequests === [], 'strict documents cannot be omitted by OpenAI transport');
$result = $call([$images[0]]);
integrationExpect(!empty($result['ok']) && $result['transmitted_file_count'] === 1 && $result['attachment_transport'] === 'openai', 'strict supported OpenAI images retain success metadata');
$integrationConfig['base_url'] = 'https://synthetic.invalid/v1';
$reset('protocol_fallback');
integrationExpect($call($document)['error_code'] === 'FILE_TYPE_UNSUPPORTED' && count($integrationRequests) === 1,
'strict protocol fallback fails explicitly before an unsupported document request');
$integrationConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages';
$prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => '合成资料', 'inputs' => [], 'user' => 'synthetic-user', 'upstream_timeout' => 5];
$runAssistant = static function () use ($prepared, $abort): array {
$stops = []; $ends = 0; $deltas = []; $progress = [];
$result = DiagnosisAiLogic::streamPreparedAssistant($prepared,
static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; }, $abort, new AssistantSseTiming(), [
'knowledge_mode' => 'evidence',
'on_stop' => static function (bool $accepted) use (&$stops): void { $stops[] = $accepted; },
'on_transfer_end' => static function () use (&$ends): void { ++$ends; },
'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; },
]);
return compact('result', 'stops', 'ends', 'deltas', 'progress');
};
$reset('blocking_only');
$test = $runAssistant();
integrationExpect(is_array($test['result']) && $test['result']['answer'] === '合成完整回答', 'actual blocking-only gateway compatibility succeeds');
integrationExpect(array_column($integrationRequests, 'streaming') === [true, false]
&& array_column(array_column($integrationRequests, 'payload'), 'response_mode') === ['streaming', 'blocking'], 'compatibility sends streaming once then genuine blocking once');
integrationExpect($test['ends'] === 2 && $test['stops'] === [], 'completed compatibility transfer does not invent stop acceptance');
$reset('cancel_blocking');
$test = $runAssistant();
integrationExpect($test['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancellation during real blocking compatibility terminates safely');
integrationExpect(array_column($integrationRequests, 'streaming') === [true, false] && $integrationClosed === 2
&& $test['ends'] === 2 && $test['stops'] === [false] && $test['deltas'] === [], 'cancel closes blocking curl, leaves remote stop unconfirmed and sends no retry or late text');
echo "Diagnosis AI strict files + cancellation / actual blocking compatibility integration: OK\n";
}