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

133 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace think\facade {
class Cache {
public static array $items = [];
public static int $lastTtl = 0;
public static bool $unavailable = false;
public static function get(string $key) {
if (self::$unavailable) throw new \RuntimeException('offline');
return self::$items[$key] ?? null;
}
public static function set(string $key, $value, int $ttl): void {
if (self::$unavailable) throw new \RuntimeException('offline');
self::$items[$key] = $value;
self::$lastTtl = $ttl;
}
}
}
namespace app\adminapi\service {
function config(string $name): array { return $GLOBALS['compactionConfig']; }
}
namespace app\common\service {
class DifyChatService {
public static array $calls = [];
public static bool $fail = false;
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'options');
if (self::$fail) throw new \RuntimeException('fixture failure');
return ['ok' => true, 'content' => '合成证据片段 ' . $inputs['chunk_index']];
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AssistantSseTiming;
use app\common\service\DifyChatService;
use think\facade\Cache;
function compactExpect(bool $ok, string $message): void {
if (!$ok) throw new \RuntimeException($message);
}
$compactionConfig = ['context_compaction_cache_ttl' => 300,
'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-secret']]];
$fit = new \ReflectionMethod(DiagnosisAiLogic::class, 'fitContextForPrompt');
$inputs = new \ReflectionMethod(DiagnosisAiLogic::class, 'buildUpstreamInputs');
$source = 'BEGIN-unique ' . str_repeat('临床合成来源;', 11000) . ' END-unique';
$original = ['case_text' => $source, 'case_lines' => [$source],
'case_json' => json_encode(['case_lines' => [$source]], JSON_UNESCAPED_UNICODE),
'fingerprint' => hash('sha256', $source), 'compaction_cache_scope' => 'authorized-scope-a',
'demographics' => '合成病例', 'source_diagnosis_ids' => [1, 2],
'source_summary' => ['diagnosis_count' => 2, 'snapshot_complete' => true],
'files' => [['type' => 'image', 'url' => 'https://example.test/synthetic.png']]];
$apply = static function (array &$context, string $profile = 'qwen') use ($fit): bool {
return $fit->invokeArgs(null, [&$context, $profile, new AssistantSseTiming()]);
};
$cold = $original;
compactExpect($apply($cold), 'cold compaction succeeds');
compactExpect(count(DifyChatService::$calls) === 2, 'every original chunk is read');
$covered = '';
foreach (DifyChatService::$calls as $call) {
preg_match('/<PATIENT_SOURCE_FRAGMENT>\n(.*)\n<\/PATIENT_SOURCE_FRAGMENT>/su', $call['query'], $match);
$covered .= $match[1];
compactExpect($call['options']['knowledge_mode'] === 'evidence', 'extractor excludes clinical reference synthesis');
}
compactExpect($covered === $source, 'UTF-8 chunks cover the entire exact source in order');
$wire = $inputs->invoke(null, $cold, 'case', 'test');
compactExpect(strlen($wire['herbs_json']) < 2000 && !str_contains($wire['herbs_json'], $source), 'Dify no longer resends the original large source');
compactExpect($cold['fingerprint'] === $original['fingerprint'], 'original provenance stays intact');
compactExpect($cold['files'] === $original['files'] && $cold['source_diagnosis_ids'] === [1, 2], 'attachments and authorization scope are preserved');
compactExpect($cold['source_summary']['analyzed_source_bytes'] === strlen($source), 'complete source byte count is preserved');
$warm = $original;
compactExpect($apply($warm) && $warm['compaction_cache_hit'] === true, 'identical freshly authorized source is reused');
compactExpect(count(DifyChatService::$calls) === 2, 'warm request makes zero new compaction calls');
foreach (['source', 'scope', 'model', 'credential'] as $change) {
$next = $original;
$profile = 'qwen';
if ($change === 'source') $next['case_text'] = 'X' . substr($source, 1);
if ($change === 'scope') $next['compaction_cache_scope'] = 'authorized-scope-b';
if ($change === 'model') $profile = 'openai';
if ($change === 'credential') $compactionConfig['models']['qwen']['api_key'] = 'rotated-fixture';
$count = count(DifyChatService::$calls);
compactExpect($apply($next, $profile) && count(DifyChatService::$calls) === $count + 2, "$change invalidates cache");
}
foreach (array_keys(Cache::$items) as $key) compactExpect(!str_contains($key, 'fixture') && !str_contains($key, '来源'), 'cache keys contain no credentials or source text');
Cache::$items = [];
DifyChatService::$fail = true;
$broken = $original;
compactExpect(!$apply($broken) && Cache::$items === [] && $broken === $original, 'failed extraction cannot poison cache or mutate context');
DifyChatService::$fail = false;
Cache::$unavailable = true;
$uncached = $original;
compactExpect($apply($uncached) && !$uncached['compaction_cache_hit'], 'cache outages fall back to full fresh compaction');
Cache::$unavailable = false;
$compactionConfig['context_compaction_cache_ttl'] = 10000;
$fresh = $original;
compactExpect($apply($fresh) && Cache::$lastTtl === 600, 'cache retention has a hard upper bound');
$compactionConfig['context_compaction_cache_ttl'] = 0;
$fresh = $original;
$count = count(DifyChatService::$calls);
compactExpect($apply($fresh) && count(DifyChatService::$calls) === $count + 2, 'cache can be disabled');
$short = $original;
$short['case_text'] = str_repeat('a', 120000);
$copy = $short;
compactExpect($apply($short) && $short === $copy, 'exact threshold keeps the source untouched');
Cache::$items = [];
$compactionConfig['context_compaction_cache_ttl'] = 300;
DifyChatService::$calls = [];
$cancelledContext = $original;
$progress = [];
$cancelOptions = ['should_abort' => static fn (): bool => count(DifyChatService::$calls) >= 1,
'on_cancel_task' => static fn (string $taskId, callable $stop): array => ['accepted' => false, 'pending' => true],
'on_terminal' => static function (string $taskId, string $status): void {},
'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; }];
compactExpect(!$fit->invokeArgs(null, [&$cancelledContext, 'qwen', new AssistantSseTiming(), $cancelOptions]), 'cancel during preprocessing terminates preparation');
compactExpect(count(DifyChatService::$calls) === 1 && Cache::$items === [] && $cancelledContext === $original, 'cancelled partial evidence never enters cache or final prompt');
compactExpect(DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence'
&& is_callable(DifyChatService::$calls[0]['options']['should_abort'])
&& DifyChatService::$calls[0]['options']['on_cancel_task'] === $cancelOptions['on_cancel_task']
&& DifyChatService::$calls[0]['options']['on_terminal'] === $cancelOptions['on_terminal'],
'intermediate evidence request preserves abort, shared stop and terminal observers');
compactExpect(count($progress) === 1 && $progress[0]['completed'] === 0 && $progress[0]['total'] === 2, 'chunk progress counts only completed evidence results');
echo "AI context compaction latency and isolation: OK\n";
}