133 lines
7.6 KiB
PHP
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";
|
|
}
|