244 lines
16 KiB
PHP
244 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace think\facade {
|
|
class Cache {
|
|
public static array $items = [];
|
|
public static function get(string $key) { return self::$items[$key] ?? null; }
|
|
public static function set(string $key, $value, int $ttl): void { self::$items[$key] = $value; }
|
|
}
|
|
}
|
|
|
|
namespace app\adminapi\service {
|
|
function config(string $name): array { return ['context_compaction_cache_ttl' => 300]; }
|
|
}
|
|
|
|
namespace app\adminapi\logic\tcm {
|
|
// Deterministic monotonic clock: no waiting, model, database, or transport calls.
|
|
function hrtime(bool $asNumber = false) { return $GLOBALS['compactionNowNs']; }
|
|
}
|
|
|
|
namespace app\common\service {
|
|
class DifyChatService {
|
|
public static array $calls = [];
|
|
public static $respond;
|
|
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
|
|
self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options');
|
|
return (self::$respond)($inputs, $options, count(self::$calls));
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
|
use app\adminapi\service\AiContextCompactionCache;
|
|
use app\common\service\DifyChatService;
|
|
use think\facade\Cache;
|
|
|
|
$assertions = 0;
|
|
function compactionBudgetExpect(bool $ok, string $message): void {
|
|
++$GLOBALS['assertions'];
|
|
if (!$ok) throw new RuntimeException($message);
|
|
}
|
|
function compactionBudgetReset(?callable $respond = null): void {
|
|
$GLOBALS['compactionNowNs'] = 1000000000000;
|
|
DifyChatService::$calls = [];
|
|
DifyChatService::$respond = $respond ?? static fn (): array => ['ok' => true, 'content' => 'complete evidence'];
|
|
Cache::$items = [];
|
|
}
|
|
function compactionBudgetFailure(callable $run, int $code, string $message): void {
|
|
try { $run(); } catch (RuntimeException $failure) {
|
|
compactionBudgetExpect($failure->getCode() === $code && $failure->getMessage() === $message,
|
|
'failure has the fixed public classification');
|
|
return;
|
|
}
|
|
throw new RuntimeException('Expected compaction failure');
|
|
}
|
|
function compactionBudgetFragment(array $call, string $tag): string {
|
|
$start = '<' . $tag . ">\n";
|
|
$end = "\n</" . $tag . '>';
|
|
$offset = strpos($call['query'], $start);
|
|
compactionBudgetExpect($offset !== false && str_ends_with($call['query'], $end), 'source frame is complete');
|
|
return substr($call['query'], $offset + strlen($start), -strlen($end));
|
|
}
|
|
|
|
$source = str_repeat('a', 119999) . '中' . str_repeat('临床资料;', 9000) . 'TAIL-UNIQUE';
|
|
compactionBudgetReset();
|
|
$short = str_repeat('a', 120000);
|
|
$untouched = PatientAiReportLogic::compactSourceForPrompt('qwen', $short);
|
|
compactionBudgetExpect($untouched['text'] === $short && !$untouched['compacted'] && DifyChatService::$calls === [],
|
|
'exact boundary never calls the model or changes source bytes');
|
|
$boundary = PatientAiReportLogic::compactSourceForPrompt('qwen', $short . 'z');
|
|
compactionBudgetExpect($boundary['chunk_count'] === 2 && count(DifyChatService::$calls) === 2,
|
|
'one byte above boundary submits both complete source fragments');
|
|
|
|
compactionBudgetReset(static function (array $inputs, array $options): array {
|
|
($options['on_progress'])(['phase' => 'analysis', 'status' => 'submitted', 'completed' => 13,
|
|
'total' => 13, 'message' => 'PRIVATE-PROVIDER-TEXT', 'required_sections' => ['PRIVATE-MANIFEST']]);
|
|
$GLOBALS['compactionNowNs'] += 125000000;
|
|
return ['ok' => true, 'content' => 'complete evidence'];
|
|
});
|
|
$progress = [];
|
|
$noop = static function (): void {};
|
|
$abort = static fn (): bool => false;
|
|
$gate = static fn (): bool => true;
|
|
$options = ['timeout' => 3, 'timeout_ms' => 1600, 'upstream_user' => 'synthetic-context',
|
|
'should_abort' => $abort, 'can_start_attempt' => $gate,
|
|
'on_task' => $noop, 'on_stop' => $noop, 'on_transfer_end' => $noop, 'on_cancel_task' => $noop,
|
|
'on_terminal' => $noop, 'on_upstream_timing' => $noop,
|
|
'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; }];
|
|
$produce = static fn (): array => PatientAiReportLogic::compactSourceForPrompt('qwen', $source, $options);
|
|
$cold = AiContextCompactionCache::remember('synthetic-authorized-scope', 'qwen', $source, $produce, $abort);
|
|
compactionBudgetExpect($cold['source_bytes'] === strlen($source) && $cold['chunk_count'] === 3 && !$cold['cache_hit'],
|
|
'actual cache producer records complete original bytes and chunk count');
|
|
$covered = '';
|
|
foreach (DifyChatService::$calls as $index => $call) {
|
|
$fragment = compactionBudgetFragment($call, 'PATIENT_SOURCE_FRAGMENT');
|
|
$covered .= $fragment;
|
|
compactionBudgetExpect(strlen($fragment) <= 120000 && preg_match('//u', $fragment) === 1,
|
|
'every source fragment fits the UTF-8 byte boundary');
|
|
compactionBudgetExpect($call['options']['timeout_ms'] === 1600 - 125 * $index
|
|
&& $call['options']['timeout'] === 2, 'all cached producer attempts use remaining shared milliseconds');
|
|
compactionBudgetExpect($call['options']['request_deadline_ns'] === 1001600000000,
|
|
'the same absolute deadline reaches all internal Dify attempts');
|
|
foreach (['should_abort', 'can_start_attempt', 'on_task', 'on_stop', 'on_transfer_end',
|
|
'on_cancel_task', 'on_terminal', 'on_upstream_timing'] as $callback) {
|
|
compactionBudgetExpect($call['options'][$callback] === $options[$callback], "$callback reaches the actual evidence transport unchanged");
|
|
}
|
|
compactionBudgetExpect($call['user'] === 'synthetic-context' && $call['files'] === []
|
|
&& $call['options']['knowledge_mode'] === 'evidence', 'extractor identity and evidence-only mode are preserved');
|
|
}
|
|
compactionBudgetExpect($covered === $source, 'all submitted source fragments concatenate to every exact original byte');
|
|
compactionBudgetExpect(count($progress) === 9, 'before, hidden transfer, and after progress remain visible per chunk');
|
|
foreach ($progress as $index => $event) {
|
|
compactionBudgetExpect($event['phase'] === 'preparing' && $event['status'] === 'compacting'
|
|
&& $event['total'] === 3 && $event['completed'] === intdiv($index, 3) + ($index % 3 === 2 ? 1 : 0)
|
|
&& array_keys($event) === ['phase', 'status', 'message', 'completed', 'total'],
|
|
'hidden transfer progress reports only safe completed preparation counters');
|
|
}
|
|
compactionBudgetExpect(!str_contains(json_encode($progress), 'PRIVATE-'), 'provider text and manifests never escape hidden extraction');
|
|
$warm = AiContextCompactionCache::remember('synthetic-authorized-scope', 'qwen', $source, $produce, $abort);
|
|
compactionBudgetExpect($warm['cache_hit'] && count(DifyChatService::$calls) === 3 && count(Cache::$items) === 1,
|
|
'only complete evidence is cached and reused without new hidden calls');
|
|
|
|
foreach ([[], ['timeout' => 300], ['timeout' => 1], ['request_deadline_ns' => 1002500000000]] as $budgetOptions) {
|
|
compactionBudgetReset(static function (): array {
|
|
$GLOBALS['compactionNowNs'] += 100000000;
|
|
return ['ok' => true, 'content' => 'complete evidence'];
|
|
});
|
|
PatientAiReportLogic::compactSourceForPrompt('qwen', $source, $budgetOptions);
|
|
$initialMs = isset($budgetOptions['request_deadline_ns']) ? 2500 : (isset($budgetOptions['timeout']) && $budgetOptions['timeout'] === 1 ? 1000 : 90000);
|
|
foreach (DifyChatService::$calls as $index => $call) {
|
|
compactionBudgetExpect($call['options']['timeout_ms'] === $initialMs - $index * 100,
|
|
'default, capped, seconds, and inherited deadlines are shared rather than reset');
|
|
compactionBudgetExpect($call['options']['request_deadline_ns'] === 1000000000000 + $initialMs * 1000000,
|
|
'default, capped, seconds, and inherited absolute deadlines reach the transport');
|
|
}
|
|
}
|
|
|
|
$timedOut = 0;
|
|
compactionBudgetReset();
|
|
$expiryOptions = ['request_deadline_ns' => 1000000000000,
|
|
'on_timeout' => static function () use (&$timedOut): void { ++$timedOut; }];
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source,
|
|
$expiryOptions),
|
|
408, 'Patient context preparation timed out');
|
|
compactionBudgetExpect($timedOut === 1 && DifyChatService::$calls === [], 'expired request never starts a chunk and notifies timeout');
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source,
|
|
['request_deadline_ns' => 1000000000000, 'on_timeout' => static function (): void { throw new RuntimeException('PRIVATE-TIMEOUT'); }]),
|
|
408, 'Patient context preparation timed out');
|
|
compactionBudgetReset();
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source,
|
|
['request_deadline_ns' => 1000000000000, 'should_abort' => static fn (): bool => true]),
|
|
0, 'Context operation cancelled');
|
|
compactionBudgetExpect(DifyChatService::$calls === [], 'user cancellation retains its existing classification');
|
|
|
|
foreach ([static fn (): bool => false, static function (): bool { throw new RuntimeException('PRIVATE-GATE'); }] as $notReady) {
|
|
compactionBudgetReset();
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source,
|
|
['can_start_attempt' => $notReady]), 409, 'Patient context cleanup pending');
|
|
compactionBudgetExpect(DifyChatService::$calls === [], 'pending or failed cleanup gate never starts evidence');
|
|
}
|
|
compactionBudgetReset();
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source,
|
|
['can_start_attempt' => static fn (): bool => count(DifyChatService::$calls) === 0]),
|
|
409, 'Patient context cleanup pending');
|
|
compactionBudgetExpect(count(DifyChatService::$calls) === 1, 'a pending first task blocks every subsequent chunk');
|
|
|
|
$timedOut = 0; $timeoutTask = '';
|
|
compactionBudgetReset(static function (array $inputs, array $options): array {
|
|
($options['on_timeout'])('synthetic-task');
|
|
return ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => 'PRIVATE-UPSTREAM'];
|
|
});
|
|
$timeoutOptions = ['on_timeout' => static function (string $task) use (&$timedOut, &$timeoutTask): void {
|
|
++$timedOut; $timeoutTask = $task;
|
|
}];
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source,
|
|
$timeoutOptions),
|
|
408, 'Patient context preparation timed out');
|
|
compactionBudgetExpect($timedOut === 1 && $timeoutTask === 'synthetic-task' && count(DifyChatService::$calls) === 1,
|
|
'Dify timeout receives its task and is reported once without launching another source chunk');
|
|
foreach (['UPSTREAM_TIMEOUT' => 408, 'UPSTREAM_CLEANUP_PENDING' => 409] as $failureCode => $exceptionCode) {
|
|
compactionBudgetReset(static fn (): array => ['ok' => false, 'error_code' => $failureCode]);
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source),
|
|
$exceptionCode, $exceptionCode === 408 ? 'Patient context preparation timed out' : 'Patient context cleanup pending');
|
|
compactionBudgetExpect(count(DifyChatService::$calls) === 1, 'upstream terminal failures never advance to another source chunk');
|
|
}
|
|
compactionBudgetReset(static function (): array {
|
|
$GLOBALS['compactionNowNs'] += 1000000000;
|
|
return ['ok' => true, 'content' => 'late incomplete evidence'];
|
|
});
|
|
compactionBudgetFailure(static fn () => PatientAiReportLogic::compactSourceForPrompt('qwen', $source, ['timeout' => 1]),
|
|
408, 'Patient context preparation timed out');
|
|
compactionBudgetExpect(count(DifyChatService::$calls) === 1, 'late success cannot advance past the shared deadline');
|
|
compactionBudgetReset(static fn (): array => ['ok' => false, 'error_code' => 'UPSTREAM_REJECTED', 'error' => 'PRIVATE-FAILURE']);
|
|
compactionBudgetFailure(static fn () => AiContextCompactionCache::remember('synthetic-scope', 'qwen', $source,
|
|
static fn (): array => PatientAiReportLogic::compactSourceForPrompt('qwen', $source)),
|
|
0, 'Patient context chunk analysis failed');
|
|
compactionBudgetExpect(count(DifyChatService::$calls) === 1 && Cache::$items === [],
|
|
'a failed initial source fragment aborts the complete-only cache producer');
|
|
|
|
$reductionSource = str_repeat('s', 120001);
|
|
compactionBudgetReset(static function (array $inputs): array {
|
|
$GLOBALS['compactionNowNs'] += 100000000;
|
|
return ['ok' => true, 'content' => $inputs['analysis_stage'] === 'evidence_chunk' ? str_repeat('e', 70000) : 'reduced complete evidence'];
|
|
});
|
|
$reduced = PatientAiReportLogic::compactSourceForPrompt('qwen', $reductionSource, ['timeout_ms' => 1500]);
|
|
compactionBudgetExpect($reduced['reduction_rounds'] === 1 && count(DifyChatService::$calls) === 4,
|
|
'oversized evidence is fully reduced without discarding chunks');
|
|
$reductionCovered = '';
|
|
foreach (DifyChatService::$calls as $index => $call) {
|
|
compactionBudgetExpect($call['options']['timeout_ms'] === 1500 - $index * 100, 'reduction continues the original source deadline');
|
|
if ($index >= 2) $reductionCovered .= compactionBudgetFragment($call, 'EVIDENCE_SUMMARY_FRAGMENT');
|
|
}
|
|
$expectedSummaries = json_encode([
|
|
['part' => 1, 'total' => 2, 'summary' => str_repeat('e', 70000)],
|
|
['part' => 2, 'total' => 2, 'summary' => str_repeat('e', 70000)],
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
compactionBudgetExpect($reductionCovered === $expectedSummaries, 'reduction submits every exact intermediate evidence byte');
|
|
|
|
foreach (['timeout', 'cancel', 'cleanup', 'failure'] as $mode) {
|
|
$cancelled = false; $timedOut = 0;
|
|
compactionBudgetReset(static function (array $inputs) use ($mode, &$cancelled): array {
|
|
if ($inputs['analysis_stage'] === 'evidence_chunk') return ['ok' => true, 'content' => str_repeat('e', 70000)];
|
|
if ($mode === 'cancel') $cancelled = true;
|
|
return ['ok' => false, 'error_code' => $mode === 'timeout' ? 'UPSTREAM_TIMEOUT'
|
|
: ($mode === 'cleanup' ? 'UPSTREAM_CLEANUP_PENDING' : 'UPSTREAM_REJECTED')];
|
|
});
|
|
$failureOptions = ['should_abort' => static function () use (&$cancelled): bool { return $cancelled; },
|
|
'on_timeout' => static function () use (&$timedOut): void { ++$timedOut; }];
|
|
$run = static fn (): array => AiContextCompactionCache::remember('synthetic-scope', 'qwen', $reductionSource,
|
|
static fn (): array => PatientAiReportLogic::compactSourceForPrompt('qwen', $reductionSource, $failureOptions));
|
|
compactionBudgetFailure($run, $mode === 'timeout' ? 408 : ($mode === 'cleanup' ? 409 : 0),
|
|
['timeout' => 'Patient context preparation timed out', 'cancel' => 'Context operation cancelled',
|
|
'cleanup' => 'Patient context cleanup pending', 'failure' => 'Patient context reduction failed'][$mode]);
|
|
compactionBudgetExpect(count(DifyChatService::$calls) === 3 && Cache::$items === [] && $timedOut === ($mode === 'timeout' ? 1 : 0),
|
|
'failed, cancelled, or timed-out reductions never cache partial evidence or start another attempt');
|
|
}
|
|
|
|
echo "Patient context compaction shared budget / progress / complete cache: {$assertions} assertions passed (offline stubs).\n";
|
|
}
|