Files
zyt/server/tests/AiLosslessLongitudinalContextTest.php
T
2026-10-27 14:03:35 +08:00

98 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
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 $key): array { return ['timeout' => 90, 'models' => ['qwen' => ['name' => 'synthetic']]]; }
class DiagnosisLogic {
public static function detail($params, $adminInfo): array {
return ['id' => 1, 'patient_id' => 7, 'gender' => '男', 'age' => 62];
}
}
class PatientAiReportLogic {
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
if (!empty($GLOBALS['slowAggregate'])) usleep(5000);
return $GLOBALS['longitudinalFixture'];
}
public static function compactSourceForPrompt($profile, $source, $options): array {
throw new \RuntimeException('A lossless below-threshold context must not invoke extraction');
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiLosslessSourceEncoding as Encoding;
use app\adminapi\service\AiRequiredSource;
$checks = 0;
function losslessExpect(bool $ok, string $message): void {
++$GLOBALS['checks'];
if (!$ok) throw new RuntimeException($message);
}
$variants = [[], ['literal' => ['columns' => ['x'], 'rows' => [[1], [2]]]],
['rows' => [['id' => 1, 'text' => null], ['id' => 2, 'text' => '']], 'floats' => [1.0, 1.05, -0.0]],
['mixed' => [['id' => 1], ['text' => '未记录']], 'nested' => [
['date' => '2026-01-01', 'herbs' => [['name' => '合成甲', 'dose' => 1], ['name' => '合成乙', 'dose' => 2]]],
['date' => '2026-02-01', 'herbs' => [['name' => '合成丙', 'dose' => 3], ['name' => '合成丁', 'dose' => 4]]]]],
['escaping' => [['x' => "\0|,\"<CASE_DATA>\\无"], ['x' => '未记录']], 'empty' => [[], null, false, 0]]];
foreach ($variants as $fixture) losslessExpect(Encoding::decode(Encoding::encode($fixture)) === $fixture,
'Round trip preserves every value, key, null, order, and literal columns/rows object');
$daily = [];
for ($i = 0; $i < 650; ++$i) $daily[] = ['id' => $i + 1, 'diagnosis_id' => 1,
'record_date' => '2026-01-01', 'record_time' => '07:00', 'record_time_estimated' => false,
'record_time_period' => 'fasting', 'blood_sugar' => '6.10', 'fasting_blood_sugar' => null,
'postprandial_blood_sugar' => '', 'systolic_pressure' => 120, 'diastolic_pressure' => 80,
'western_medicine' => '合成用药原文-' . $i, 'insulin' => null, 'remark' => '未记录-' . $i];
$sections = [];
foreach (AiRequiredSource::LABELS as $key => $label) $sections[] = ['key' => $key, 'label' => $label,
'value' => $key === 'daily_tracking' ? ['blood_glucose_pressure' => $daily, 'diet' => [], 'exercise' => []]
: ($key === 'chat' ? ['tencent_im' => [['id' => 1, 'text' => '最后一条原文及无明显限定词']]] : '无')];
$snapshot = ['snapshot_version' => 'synthetic', 'required_content' => $sections];
$metadata = array_map(static fn($s) => ['key' => $s['key'], 'label' => $s['label'],
'count' => $s['key'] === 'daily_tracking' ? 650 : ($s['key'] === 'chat' ? 1 : 0),
'availability' => in_array($s['key'], ['daily_tracking', 'chat'], true) ? 'present' : 'empty'], $sections);
$longitudinalFixture = ['snapshot' => $snapshot, 'source_summary' => ['required_sections' => $metadata,
'snapshot_complete' => true, 'may_be_truncated' => false], 'source_diagnosis_ids' => [1],
'files' => [['type' => 'image', 'url' => 'https://example.invalid/synthetic.png']]];
$context = (new ReflectionMethod(DiagnosisAiLogic::class, 'buildCaseContext'))->invoke(null,
['id' => 1, 'gender' => '男', 'age' => 62], 7, ['root' => 1]);
$raw = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
losslessExpect(strlen($raw) > 120000 && strlen($context['case_text']) < 120000, 'Repetitive full history crosses old threshold but fits losslessly');
$firstLine = $context['case_lines'][0];
$wire = substr($firstLine, strpos($firstLine, '{'));
losslessExpect(Encoding::decode($wire) === $snapshot, 'Actual prompt contains the full snapshot in reversible form');
$payload = json_decode($context['case_json'], true);
losslessExpect($payload['case_lines'] === $context['case_lines'], 'Structured and query channels use the same complete wire source');
losslessExpect($context['files'] === $longitudinalFixture['files'] && count($context['required_sections']) === 13,
'Attachments and all thirteen required categories remain intact');
$originalLines = $context['case_lines'];
$originalLines[0] = '患者纵向完整资料(服务端实时聚合,JSON):' . $raw;
$originalPayload = ['gender' => '男', 'age' => '62', 'case_lines' => $originalLines,
'source_diagnosis_ids' => [1], 'source_summary' => $context['source_summary'], 'file_count' => 1];
losslessExpect($context['fingerprint'] === hash('sha256', json_encode($originalPayload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)), 'Original source fingerprint meaning is unchanged');
$before = $context;
losslessExpect((new ReflectionMethod(DiagnosisAiLogic::class, 'fitContextForPrompt'))->invokeArgs(null, [&$context, 'qwen'])
&& $context === $before, 'Below-threshold packed source starts no model extraction and stays full-context');
$slowAggregate = true; $timeouts = 0;
$result = DiagnosisAiLogic::prepareAssistant(1, 'summary', '', 7, ['root' => 1], null,
['timeout_ms' => 1, 'on_timeout' => static function () use (&$timeouts): void { ++$timeouts; }]);
losslessExpect($result === null && DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT' && $timeouts === 1,
'Aggregation cannot silently restart the answer budget after the deadline');
$calls = 0;
losslessExpect(DiagnosisAiLogic::streamPreparedAssistant(['request_deadline_ns' => hrtime(true) - 1],
static function () use (&$calls): void { ++$calls; }) === null && $calls === 0
&& DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT', 'Expired prepared request never invokes final transport');
losslessExpect(DiagnosisAiLogic::streamPreparedAssistant(['request_deadline_ns' => hrtime(true) - 1],
static function (): void {}, null, null, ['on_timeout' => static function (): void { throw new RuntimeException('observer failure'); }]) === null
&& DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT', 'Observer failure cannot replace the terminal timeout classification');
echo "Lossless full-history context and shared preparation deadline: {$checks} checks OK\n";
}