250 lines
16 KiB
PHP
250 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Real analysis + Dify client + cURL; authorization/records/config and local HTTP peer are synthetic. */
|
|
namespace think\facade {
|
|
class Log {
|
|
public static function warning(string $message, array $context = []): void {}
|
|
public static function write(string $message, string $level = ''): void {
|
|
$GLOBALS['analysisTimeoutLogs'][] = $message;
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace app\adminapi\logic\firstvisit {
|
|
class MyPatientLogic {
|
|
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool { return true; }
|
|
}
|
|
}
|
|
|
|
namespace app\adminapi\logic\tcm {
|
|
function config(string $name): array { return $GLOBALS['analysisTimeoutConfig']; }
|
|
function connection_aborted(): int {
|
|
++$GLOBALS['analysisAbortPolls'];
|
|
return $GLOBALS['analysisDisconnectAt'] !== null && microtime(true) >= $GLOBALS['analysisDisconnectAt'] ? 1 : 0;
|
|
}
|
|
class DiagnosisLogic {
|
|
public static function detail($params, $adminInfo): array { return $GLOBALS['analysisTimeoutCurrent']; }
|
|
}
|
|
class PatientAiReportLogic {
|
|
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
|
|
return $GLOBALS['analysisTimeoutLongitudinal'];
|
|
}
|
|
public static function compactSourceForPrompt($profile, $source, $options = []): array {
|
|
throw new \RuntimeException('Below-budget test data must never be compacted.');
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace app\common\service {
|
|
function config(string $name): array { return $GLOBALS['analysisTimeoutConfig']; }
|
|
|
|
// Observe bytes only after the production write callback consumes them. All
|
|
// connections, deadlines, payloads, stop calls and parsing remain real.
|
|
function curl_setopt_array($handle, array $options): bool {
|
|
if (isset($options[CURLOPT_WRITEFUNCTION])) {
|
|
$write = $options[CURLOPT_WRITEFUNCTION];
|
|
$options[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use ($write): int {
|
|
$consumed = $write($handle, $chunk);
|
|
if ($consumed === strlen($chunk)) $GLOBALS['analysisConsumedStream'] .= $chunk;
|
|
return $consumed;
|
|
};
|
|
}
|
|
return \curl_setopt_array($handle, $options);
|
|
}
|
|
|
|
class ClinicalKnowledgeReference {
|
|
public static function augmentCaseQuery(string $query): string {
|
|
++$GLOBALS['analysisCaseReferenceCalls'];
|
|
return $query;
|
|
}
|
|
public static function ancientBookSources(string $query): array {
|
|
throw new \RuntimeException('Analysis must retain reference_scope=case.');
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
|
use app\adminapi\service\AiRequiredSource;
|
|
|
|
function analysisTimeoutExpect(bool $ok, string $message): void {
|
|
++$GLOBALS['analysisTimeoutAssertions'];
|
|
if (!$ok) throw new RuntimeException($message);
|
|
}
|
|
|
|
function analysisTimeoutFrame(array $event): string {
|
|
return 'data: ' . json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
|
|
}
|
|
|
|
$validAnswer = json_encode(['diagnosis_advice' => '合成病例:待医师复核。', 'risk_assessment' => [],
|
|
'treatment_advice' => '合成病例:继续核对本次资料。'], JSON_UNESCAPED_UNICODE);
|
|
|
|
if (($argv[1] ?? '') === 'server') {
|
|
$scenario = $argv[2]; $profile = $argv[3];
|
|
$expectedRequests = str_starts_with($scenario, 'repair_') ? 2 : 1;
|
|
$listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
|
if ($listener === false) exit(2);
|
|
stream_set_blocking($listener, false);
|
|
echo json_encode(['address' => stream_socket_get_name($listener, false)]) . "\n";
|
|
flush();
|
|
$clients = []; $generation = null; $task = ''; $started = 0.0; $terminalAt = null;
|
|
$metrics = ['requests' => [], 'stops' => [], 'open_on_stop' => true, 'terminal_sent' => false];
|
|
$done = false; $deadline = microtime(true) + 7;
|
|
while (!$done && microtime(true) < $deadline) {
|
|
while (($socket = @stream_socket_accept($listener, 0)) !== false) {
|
|
stream_set_blocking($socket, false);
|
|
$clients[(int) $socket] = ['socket' => $socket, 'buffer' => '', 'handled' => false];
|
|
}
|
|
foreach (array_keys($clients) as $key) {
|
|
$socket = $clients[$key]['socket'];
|
|
$chunk = @fread($socket, 65536);
|
|
if ($chunk === '' && feof($socket)) {
|
|
fclose($socket); unset($clients[$key]);
|
|
if ($key === $generation && count($metrics['requests']) >= $expectedRequests) $done = true;
|
|
continue;
|
|
}
|
|
if ($clients[$key]['handled']) continue;
|
|
$clients[$key]['buffer'] .= is_string($chunk) ? $chunk : '';
|
|
$parts = explode("\r\n\r\n", $clients[$key]['buffer'], 2);
|
|
if (count($parts) !== 2) continue;
|
|
preg_match('/Content-Length:\s*(\d+)/i', $parts[0], $length);
|
|
if (strlen($parts[1]) < (int) ($length[1] ?? 0)) continue;
|
|
$clients[$key]['handled'] = true;
|
|
$payload = json_decode($parts[1], true);
|
|
if (preg_match('#^POST /v1/chat-messages/([^/ ]+)/stop #', $parts[0], $match)) {
|
|
$metrics['stops'][] = ['task' => $match[1], 'user' => $payload['user'] ?? '',
|
|
'after_ms' => (microtime(true) - $started) * 1000];
|
|
$metrics['open_on_stop'] = $metrics['open_on_stop'] && isset($clients[$generation]);
|
|
$body = '{"result":"success"}';
|
|
fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "
|
|
. strlen($body) . "\r\nConnection: close\r\n\r\n" . $body);
|
|
fclose($socket); unset($clients[$key]);
|
|
$terminalAt = microtime(true) + 0.06;
|
|
continue;
|
|
}
|
|
$index = count($metrics['requests']) + 1;
|
|
$metrics['requests'][] = ['request_path' => strtok($parts[0], "\r\n"), 'payload' => $payload,
|
|
'profile_key_matches' => str_contains($parts[0], 'Authorization: Bearer loopback-only-' . $profile)];
|
|
$generation = $key; $started = microtime(true); $task = 'analysis-' . $profile . '-task-' . $index;
|
|
fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"
|
|
. analysisTimeoutFrame(['event' => 'workflow_started', 'task_id' => $task]));
|
|
$repairFirst = str_starts_with($scenario, 'repair_') && $index === 1;
|
|
if ($scenario === 'success' || $scenario === 'repair_success' || $repairFirst) {
|
|
fwrite($socket, analysisTimeoutFrame(['event' => 'message', 'task_id' => $task,
|
|
'answer' => $repairFirst ? 'deliberately malformed JSON' : $validAnswer])
|
|
. analysisTimeoutFrame(['event' => 'workflow_finished', 'task_id' => $task, 'data' => ['status' => 'succeeded']])
|
|
. analysisTimeoutFrame(['event' => 'message_end', 'task_id' => $task]));
|
|
fclose($socket); unset($clients[$key]);
|
|
if ($index >= $expectedRequests) $done = true;
|
|
} elseif ($scenario === 'timeout_partial') {
|
|
// Even valid three-field bytes without a terminal are not a successful analysis.
|
|
fwrite($socket, analysisTimeoutFrame(['event' => 'message', 'task_id' => $task, 'answer' => $validAnswer]));
|
|
}
|
|
}
|
|
if ($terminalAt !== null && microtime(true) >= $terminalAt && isset($clients[$generation])) {
|
|
fwrite($clients[$generation]['socket'], analysisTimeoutFrame(['event' => 'message', 'task_id' => $task, 'answer' => $validAnswer])
|
|
. analysisTimeoutFrame(['event' => 'workflow_finished', 'task_id' => $task, 'data' => ['status' => 'stopped']])
|
|
. analysisTimeoutFrame(['event' => 'message_end', 'task_id' => $task]));
|
|
$metrics['terminal_sent'] = true;
|
|
fclose($clients[$generation]['socket']); unset($clients[$generation]); $done = true;
|
|
}
|
|
usleep(1000);
|
|
}
|
|
foreach ($clients as $client) fclose($client['socket']);
|
|
fclose($listener);
|
|
echo json_encode($metrics, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
exit($done ? 0 : 3);
|
|
}
|
|
|
|
$analysisTimeoutAssertions = 0; $rows = [];
|
|
$sections = []; $metadata = [];
|
|
foreach (AiRequiredSource::LABELS as $key => $label) {
|
|
$sections[] = ['key' => $key, 'label' => $label, 'value' => [['source_marker' => 'SOURCE_' . $key]]];
|
|
$metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => 1, 'status' => 'prepared'];
|
|
}
|
|
foreach (['qwen', 'openai'] as $profile) {
|
|
foreach (['success', 'repair_success', 'timeout_empty', 'timeout_partial', 'repair_timeout', 'restricted_timeout', 'cancel_known'] as $scenario) {
|
|
$process = proc_open([PHP_BINARY, __FILE__, 'server', $scenario, $profile],
|
|
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes,
|
|
null, null, ['bypass_shell' => true]);
|
|
analysisTimeoutExpect(is_resource($process), 'local peer starts');
|
|
fclose($pipes[0]); stream_set_timeout($pipes[1], 8);
|
|
$ready = json_decode((string) fgets($pipes[1]), true);
|
|
analysisTimeoutExpect(isset($ready['address']), 'local peer returns an ephemeral loopback port');
|
|
$analysisTimeoutConfig = ['enable' => true, 'timeout' => 1, 'max_files' => 3,
|
|
'base_url' => 'http://' . $ready['address'] . '/v1/chat-messages', 'models' => [
|
|
'qwen' => ['name' => 'synthetic-qwen', 'label' => '合成千问', 'api_key' => 'loopback-only-qwen'],
|
|
'openai' => ['name' => 'synthetic-openai', 'label' => '合成OpenAI', 'api_key' => 'loopback-only-openai'],
|
|
]];
|
|
$analysisTimeoutCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女',
|
|
'chief_complaint' => '合成病例,乏力', 'tongue' => '本次舌淡红,苔薄白', 'pulse' => '本次脉弦细'];
|
|
if ($scenario === 'restricted_timeout') unset($analysisTimeoutCurrent['pulse']);
|
|
$image = 'http://' . $ready['address'] . '/synthetic-not-fetched.png';
|
|
$analysisTimeoutLongitudinal = ['snapshot' => ['required_content' => $sections],
|
|
'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true],
|
|
'source_diagnosis_ids' => [3, 11], 'files' => [['type' => 'image', 'url' => $image]]];
|
|
$analysisAbortPolls = 0; $analysisConsumedStream = ''; $analysisTimeoutLogs = []; $analysisCaseReferenceCalls = 0;
|
|
$started = microtime(true); $analysisDisconnectAt = $scenario === 'cancel_known' ? $started + 0.18 : null;
|
|
$result = DiagnosisAiLogic::analysis(11, 7, ['root' => 1], $profile);
|
|
$elapsed = (microtime(true) - $started) * 1000;
|
|
$peer = json_decode((string) fgets($pipes[1]), true);
|
|
$stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
|
|
analysisTimeoutExpect(proc_close($process) === 0 && $stderr === '', $scenario . ': local peer exits cleanly');
|
|
$requestCount = str_starts_with($scenario, 'repair_') ? 2 : 1;
|
|
analysisTimeoutExpect(count($peer['requests']) === $requestCount, $scenario . ': only the existing bounded format repair may create a second generation');
|
|
analysisTimeoutExpect($analysisAbortPolls > 0 && $elapsed < 3750, $scenario . ': cancellable transport polls and remains bounded');
|
|
foreach ($peer['requests'] as $index => $request) {
|
|
$payload = $request['payload'];
|
|
analysisTimeoutExpect($request['request_path'] === 'POST /v1/chat-messages HTTP/1.1'
|
|
&& $payload['response_mode'] === 'streaming' && $request['profile_key_matches'],
|
|
$scenario . ': the selected profile stays on its Dify channel with a real internal stream');
|
|
analysisTimeoutExpect($payload['user'] === 'admin-diagnosis-analysis-7'
|
|
&& $payload['files'][0]['url'] === $image && count($payload['files']) === 1,
|
|
$scenario . ': original analysis user and image reference reach the actual HTTP payload');
|
|
analysisTimeoutExpect($payload['inputs']['prompt_version'] === 'patient-context-analysis-tcm-four-diagnoses-v43',
|
|
$scenario . ': current v43 prompt provenance reaches the actual payload');
|
|
foreach (array_keys(AiRequiredSource::LABELS) as $key) {
|
|
analysisTimeoutExpect(str_contains($payload['query'], 'SOURCE_' . $key), $scenario . ': full source survives: ' . $key);
|
|
}
|
|
analysisTimeoutExpect(str_contains($payload['query'], '格式校验提醒') === ($index === 1),
|
|
$scenario . ': only the established malformed-output repair prompt is used');
|
|
}
|
|
analysisTimeoutExpect($analysisCaseReferenceCalls === ($scenario === 'restricted_timeout' ? 0 : $requestCount),
|
|
$scenario . ': ordinary and repaired clinical requests retain case scope, restricted requests retain evidence mode');
|
|
if (in_array($scenario, ['success', 'repair_success'], true)) {
|
|
analysisTimeoutExpect(is_array($result) && $result['model_key'] === $profile
|
|
&& $result['model_name'] === 'synthetic-' . $profile
|
|
&& $result['diagnosis_advice'] === '合成病例:待医师复核。'
|
|
&& $result['risk_assessment'] === [] && $result['treatment_advice'] === '合成病例:继续核对本次资料。',
|
|
$scenario . ': private stream preserves the public three-field JSON and profile');
|
|
analysisTimeoutExpect(count($result['source_summary']['required_sections']) === 13 && $peer['stops'] === [],
|
|
$scenario . ': successful completion keeps all source receipts without a stop');
|
|
} else {
|
|
analysisTimeoutExpect($result === null && DiagnosisAiLogic::getError() === 'AI智能分析暂时不可用,请稍后重试',
|
|
$scenario . ': timeout/cancellation cannot become late or local success');
|
|
$expectedTask = 'analysis-' . $profile . '-task-' . $requestCount;
|
|
analysisTimeoutExpect(count($peer['stops']) === 1 && $peer['stops'][0]['task'] === $expectedTask
|
|
&& $peer['stops'][0]['user'] === 'admin-diagnosis-analysis-7' && $peer['open_on_stop'],
|
|
$scenario . ': stop is bound to the exact timed-out task and original user before transport closes');
|
|
analysisTimeoutExpect($peer['terminal_sent']
|
|
&& str_contains($analysisConsumedStream, '"task_id":"' . $expectedTask . '","data":{"status":"stopped"}'),
|
|
$scenario . ': production stream consumes the explicit stopped workflow terminal');
|
|
if ($scenario !== 'cancel_known') {
|
|
analysisTimeoutExpect($peer['stops'][0]['after_ms'] >= 900 && $peer['stops'][0]['after_ms'] < 1250,
|
|
$scenario . ': ordinary generation deadline itself initiates the stop');
|
|
analysisTimeoutExpect(str_contains(implode('', $analysisTimeoutLogs), '"error_code":"UPSTREAM_TIMEOUT"'),
|
|
$scenario . ': underlying failure remains an ordinary timeout');
|
|
}
|
|
}
|
|
$rows[] = ['profile' => $profile, 'scenario' => $scenario, 'requests' => $requestCount,
|
|
'stops' => count($peer['stops']), 'elapsed_ms' => round($elapsed, 1)];
|
|
}
|
|
}
|
|
echo 'Diagnosis AI analysis internal stream / timeout stop integration: ' . $analysisTimeoutAssertions
|
|
. ' assertions passed ' . json_encode($rows) . "\n";
|
|
}
|