Files
zyt/server/tests/PrescriptionAiGeneratorTest.php
2026-09-10 15:19:17 +08:00

733 lines
55 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\prescriptionai\PrescriptionAiGenerator;
use app\common\service\DifyChatService;
function rxGeneratorExpect(bool $ok, string $message): void
{
if (!$ok) {
throw new RuntimeException($message);
}
}
function rxGeneratorJson($value): string
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
$report = ['summary' => '资料显示症状需要复核。', 'diagnosis' => '辨证意见仅供医师核对。',
'risk_assessment' => [['label' => '需核对过敏记录', 'level' => 'unknown', 'evidence_references' => ['diagnoses:1']]],
'treatment_advice' => '核对病史及用药。', 'evidence_references' => ['diagnoses:1'], 'missing_information' => []];
$candidate = ['status' => 'available_for_review', 'reason' => '有完整临床资料,供医师复核。', 'prescription_name' => '测试候选',
'prescription_type' => '饮片', 'dose_basis' => 'per_dose',
'herbs' => [['name' => '测试药材', 'dosage' => 3.5, 'unit' => 'g', 'dose_basis' => 'per_dose', 'processing' => '明确炮制',
'formula_type' => '主方', 'instructions' => '明确煎服说明', 'evidence_references' => ['diagnoses:1']]],
'usage_instruction' => '测试用法', 'times_per_day' => 1, 'usage_days' => 3,
'rationale' => '测试方义', 'risk_warnings' => ['由医师核对'], 'evidence_references' => ['diagnoses:1']];
$final = ['report' => $report, 'candidate' => $candidate];
$context = ['source' => ['patient' => ['age' => 50, 'gender' => 1], 'records' => [
['source_id' => 'diagnoses:1', 'kind' => 'diagnoses', 'data' => ['chief_complaint' => '示例症状', 'allergy_history' => '示例阴性记录']],
]], 'source_hash' => hash('sha256', 'fixture'), 'missing' => [], 'files' => []];
for ($i = 1; $i <= 4; $i++) {
$context['files'][] = ['file_id' => 'file:' . $i, 'source_ids' => ['diagnoses:1'], 'url' => 'https://storage.example.test/image' . $i . '.png',
'type' => 'image', 'status' => 'pending', 'version_verified' => true, 'purpose' => 'tongue_image'];
}
$calls = [];
$stub = static function (string $model, string $prompt, array $files, string $user) use (&$calls, $final): array {
$calls[] = ['model' => $model, 'file_count' => count($files), 'user' => $user];
if (str_contains($prompt, 'EXPECTED_SOURCE_IDS=')) {
preg_match('/EXPECTED_SOURCE_IDS=([^\n]+)/', $prompt, $match);
$ids = json_decode($match[1], true);
return ['ok' => true, 'content' => rxGeneratorJson(['summary' => '本批证据完整保留临床数值和矛盾。', 'covered_source_ids' => $ids,
'evidence_references' => $ids, 'missing_information' => []])];
}
if (str_contains($prompt, 'FILE_MANIFEST=')) {
$manifest = json_decode(explode('FILE_MANIFEST=', $prompt, 2)[1], true);
$results = [];
foreach ($manifest as $file) {
$results[] = ['file_id' => $file['file_id'], 'status' => 'processed', 'findings' => '测试图片可读,结论供核对。',
'evidence_references' => [$file['file_id']]];
}
return ['ok' => true, 'content' => rxGeneratorJson(['files' => $results]), 'transmitted_file_count' => count($files)];
}
return ['ok' => true, 'content' => rxGeneratorJson($final), 'model_name' => 'stub-' . $model];
};
$saved = [];
$checkpoint = static function (array $progress) use (&$saved): void { $saved = $progress; };
$qwen = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $stub, $checkpoint, ['max_files' => 3]);
rxGeneratorExpect($qwen['ok'], 'qwen independent branch succeeds with stub');
rxGeneratorExpect(array_column($calls, 'file_count') === [0, 3, 1, 0], 'four attachments are delivered in 3+1 batches without a cap');
rxGeneratorExpect(count($qwen['coverage']['files']) === 4 && $qwen['coverage']['complete'], 'coverage accounts for all four model-processed versioned files');
rxGeneratorExpect($qwen['coverage']['status'] === 'complete', 'worker-compatible coverage status agrees with complete boolean');
rxGeneratorExpect($qwen['candidate']['herbs'][0]['dosage'] === 3.5, 'explicit decimal dosage is retained without defaults');
rxGeneratorExpect($qwen['usage']['total_calls'] === 4 && $saved['stage'] === 'completed', 'every child stage is durable and counted');
$before = count($calls);
$context['_progress'] = $saved;
$resumed = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $stub, null, ['max_files' => 3]);
rxGeneratorExpect($resumed['ok'] && count($calls) === $before, 'same model/hash/prompt resumes successful steps without new upstream calls');
$openai = PrescriptionAiGenerator::generateWithTransport('openai', $context, static fn (): array => ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT']);
rxGeneratorExpect(!$openai['ok'] && $openai['retryable'] && $qwen['ok'], 'openai failure does not call or invalidate qwen success');
unset($context['_progress']);
$openaiSuccess = PrescriptionAiGenerator::generateWithTransport('openai', $context, $stub, null, ['max_files' => 3]);
rxGeneratorExpect($openaiSuccess['ok'] && count($calls) === $before + 4, 'second model independently reads all four raw files');
// Each application declares its own attachment limit; a branch must use its own, not a shared guess.
$perModelCalls = count($calls);
$perModel = PrescriptionAiGenerator::generateWithTransport('openai', $context, $stub, null,
['max_files' => 3, 'models' => ['openai' => ['max_files' => 10], 'qwen' => ['max_files' => 3]]]);
rxGeneratorExpect($perModel['ok'] && count($perModel['coverage']['files']) === 4
&& array_slice(array_column($calls, 'file_count'), $perModelCalls) === [0, 4, 0],
'a branch batches attachments by its own application limit');
$unreadable = static function ($model, $prompt, $files, $user) use ($stub): array {
if ($files !== []) {
return ['ok' => false, 'error_code' => 'FILE_TYPE_UNSUPPORTED'];
}
return $stub($model, $prompt, $files, $user);
};
$partial = PrescriptionAiGenerator::generateWithTransport('openai', $context, $unreadable, null, ['max_files' => 3]);
rxGeneratorExpect($partial['ok'] && !$partial['coverage']['complete'], 'unsupported files produce an explicitly incomplete preliminary report');
rxGeneratorExpect($partial['coverage']['status'] === 'partial', 'worker-compatible coverage status identifies incomplete evidence');
rxGeneratorExpect($partial['candidate']['status'] === 'available_for_review' && $partial['candidate']['herbs'] === $candidate['herbs'], 'attachment coverage gaps alone retain an evidence-based candidate for doctor review');
rxGeneratorExpect(str_contains($partial['candidate']['reason'], '资料尚不完整') && count($partial['candidate']['risk_warnings']) > count($candidate['risk_warnings']), 'partial-data candidates explicitly retain their limitations and review requirement');
rxGeneratorExpect(\app\common\service\prescriptionai\PrescriptionAiComparison::compare($candidate, $partial['candidate'], [
['id' => 1, 'name' => '测试药材', 'processing' => '明确炮制'],
])['score'] === 100.0, 'a valid partial-data candidate remains eligible for structural comparison, without claiming medical accuracy');
rxGeneratorExpect(count($partial['coverage']['missing']) === 4, 'every unsupported attachment has an individual coverage gap');
$noncriticalContext = $context;
$noncriticalContext['files'] = [];
$noncriticalContext['missing'] = [['source_id' => 'chat_records', 'code' => 'ARCHIVE_SYNC_WATERMARK_UNAVAILABLE', 'critical' => false]];
$noncritical = PrescriptionAiGenerator::generateWithTransport('qwen', $noncriticalContext, $stub);
rxGeneratorExpect($noncritical['ok'] && $noncritical['coverage']['status'] === 'partial' && $noncritical['candidate']['status'] === 'available_for_review', 'noncritical archive/version coverage limitations alone do not permanently suppress candidates');
foreach (['TRANSCRIPT_NOT_FINAL', 'TRANSCRIPT_NOT_VERIFIED_COMPLETE', 'FILE_STORAGE_AUTHORIZATION_UNVERIFIED'] as $gapCode) {
$limitedContext = $noncriticalContext;
$limitedContext['missing'][] = ['source_id' => 'call_records:10', 'code' => $gapCode, 'critical' => true];
$limited = PrescriptionAiGenerator::generateWithTransport('qwen', $limitedContext, $stub);
rxGeneratorExpect($limited['ok'] && !$limited['coverage']['complete'] && $limited['candidate']['status'] === 'available_for_review',
'coverage-only limitation does not automatically prohibit a supported candidate: ' . $gapCode);
rxGeneratorExpect($limited['coverage']['missing'] === $limitedContext['missing'], 'candidate generation never hides or clears source limitations');
}
// Research comparison mode is the default: every model prescribes first, the server compares afterwards.
$withholdingConfig = ['manual_analysis' => ['require_candidate' => false]];
$safetyWarning = '缺少年龄、性别、过敏史、当前用药或妊娠哺乳等关键用药安全信息,本候选方按研究对照要求在假设下生成,医师须先核实上述事实。';
foreach (['age', 'gender', 'allergy_history', 'current_medications', 'pregnancy_history'] as $field) {
$unsafeContext = $noncriticalContext;
$unsafeContext['missing'][] = ['source_id' => 'clinical.' . $field, 'code' => 'CRITICAL_CLINICAL_FACT_MISSING', 'critical' => true];
$unsafe = PrescriptionAiGenerator::generateWithTransport('qwen', $unsafeContext, $stub);
rxGeneratorExpect($unsafe['ok'] && $unsafe['candidate']['status'] === 'available_for_review' && $unsafe['candidate']['herbs'] !== [],
'research comparison still obtains an independent candidate when a safety fact is missing: ' . $field);
rxGeneratorExpect(in_array($safetyWarning, $unsafe['candidate']['risk_warnings'], true)
&& !$unsafe['coverage']['complete'] && $unsafe['coverage']['missing'] === $unsafeContext['missing'],
'a forced candidate never hides the missing safety fact or claims complete coverage: ' . $field);
$blocked = PrescriptionAiGenerator::generateWithTransport('qwen', $unsafeContext, $stub, null, $withholdingConfig);
rxGeneratorExpect($blocked['ok'] && $blocked['candidate']['status'] === 'insufficient_data' && $blocked['candidate']['herbs'] === [],
'the withholding policy remains available behind configuration: ' . $field);
}
$unknownGap = $noncriticalContext;
$unknownGap['missing'][] = ['source_id' => 'future-source', 'code' => 'FUTURE_CRITICAL_CONDITION', 'critical' => true];
rxGeneratorExpect(PrescriptionAiGenerator::generateWithTransport('qwen', $unknownGap, $stub)['candidate']['status'] === 'available_for_review',
'unknown critical conditions stay listed as gaps without suppressing the research candidate');
rxGeneratorExpect(PrescriptionAiGenerator::generateWithTransport('qwen', $unknownGap, $stub, null, $withholdingConfig)['candidate']['status'] === 'insufficient_data',
'configured withholding still fails closed on unknown critical conditions');
$withheldFinal = $final;
$withheldFinal['candidate'] = ['status' => 'withheld_for_risk', 'reason' => '现有证据无法排除用药风险。', 'herbs' => []];
$finalCalls = 0;
$insistTransport = static function ($model, $prompt, $files, $user) use ($stub, $final, $withheldFinal, &$finalCalls): array {
if (str_contains($prompt, '阶段=final')) {
$finalCalls++;
return ['ok' => true, 'content' => rxGeneratorJson(str_contains($prompt, '上一次回答没有给出候选处方') ? $final : $withheldFinal)];
}
return $stub($model, $prompt, $files, $user);
};
$insisted = PrescriptionAiGenerator::generateWithTransport('qwen', $noncriticalContext, $insistTransport);
rxGeneratorExpect($insisted['ok'] && $insisted['candidate']['status'] === 'available_for_review' && $finalCalls === 2,
'a refusal is re-asked once with the model own reason before the branch gives up');
$alwaysWithheld = static function ($model, $prompt, $files, $user) use ($stub, $withheldFinal): array {
return str_contains($prompt, '阶段=final') ? ['ok' => true, 'content' => rxGeneratorJson($withheldFinal)] : $stub($model, $prompt, $files, $user);
};
$refusalProgress = [];
$withheld = PrescriptionAiGenerator::generateWithTransport('qwen', $noncriticalContext, $alwaysWithheld,
static function (array $progress) use (&$refusalProgress): void { $refusalProgress = $progress; });
rxGeneratorExpect(!$withheld['ok'] && $withheld['error_code'] === 'CANDIDATE_WITHHELD_BY_MODEL' && $withheld['retryable'],
'a model that keeps refusing is an explicit retryable task failure, not a silent empty plan');
rxGeneratorExpect(!isset($refusalProgress['steps']['final']) && !isset($refusalProgress['steps']['final:insist:1'])
&& !isset($refusalProgress['steps']['final:insist:2']),
'refusals are never cached, so a retry re-asks instead of replaying them');
$modelWithheld = PrescriptionAiGenerator::generateWithTransport('qwen', $noncriticalContext, $alwaysWithheld, null, $withholdingConfig);
rxGeneratorExpect($modelWithheld['ok'] && $modelWithheld['candidate']['status'] === 'withheld_for_risk',
'configured withholding still honours model-identified safety uncertainty');
$oldPolicyContext = $context;
$oldPolicyContext['_progress'] = $saved;
$oldPolicyContext['_progress']['prompt_version'] = 'manual-prescription-independent-v1';
$oldPolicyContext['_progress']['usage']['total_calls'] = 1;
$versionCalls = 0;
$oldPolicy = PrescriptionAiGenerator::generateWithTransport('qwen', $oldPolicyContext,
static function () use (&$versionCalls): array { $versionCalls++; return ['ok' => true, 'content' => '{}']; },
null, ['manual_analysis' => ['max_calls_per_model' => 1]]);
rxGeneratorExpect(!$oldPolicy['ok'] && $oldPolicy['error_code'] === 'TOTAL_CALL_BUDGET_EXCEEDED' && $versionCalls === 0,
'clinical policy version changes invalidate saved outputs without resetting lifetime call budget');
$interruptedProgress = [];
$interruptOnce = true;
$interruptedTransport = static function ($model, $prompt, $files, $user) use ($stub, &$interruptOnce): array {
if ($files !== [] && $interruptOnce) {
$interruptOnce = false;
return ['ok' => false, 'error_code' => 'UPSTREAM_BUSY'];
}
return $stub($model, $prompt, $files, $user);
};
$interrupted = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $interruptedTransport, static function ($p) use (&$interruptedProgress): void { $interruptedProgress = $p; });
rxGeneratorExpect(!$interrupted['ok'] && $interrupted['retryable'], 'temporary mid-pipeline provider failure is retryable');
$context['_progress'] = $interruptedProgress;
$before = count($calls);
$recovered = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $interruptedTransport);
rxGeneratorExpect($recovered['ok'] && count($calls) - $before === 3, 'recovery retains completed text stage and retries only remaining work');
unset($context['_progress']);
$distinctContext = $context;
$distinctContext['files'] = [];
foreach (['a', 'a', 'b', 'c', 'b', 'd', 'd'] as $index => $image) {
$file = $context['files'][0];
$file['file_id'] = 'file:' . ($index + 1);
$file['url'] = 'https://storage.example.test/' . $image . '.png';
$distinctContext['files'][] = $file;
}
$distinctBatches = [];
$distinctResult = PrescriptionAiGenerator::generateWithTransport('qwen', $distinctContext,
static function ($model, $prompt, $files, $user) use ($stub, &$distinctBatches): array {
if ($files !== []) {
$manifest = json_decode(explode('FILE_MANIFEST=', $prompt, 2)[1], true);
$urls = array_column($files, 'url');
rxGeneratorExpect(count($urls) <= 3 && count(array_unique($urls)) === count($urls), 'each actual attachment request has unique URLs within the configured limit');
$distinctBatches[] = ['ids' => array_column($manifest, 'file_id'), 'urls' => $urls];
}
return $stub($model, $prompt, $files, $user);
}, null, ['max_files' => 3]);
rxGeneratorExpect($distinctResult['ok'] && $distinctResult['coverage']['complete'], 'shared URLs in separate batches still produce complete logical-file coverage');
rxGeneratorExpect(array_map(static fn ($batch): int => count($batch['ids']), $distinctBatches) === [1, 3, 2, 1]
&& array_merge(...array_column($distinctBatches, 'ids')) === array_column($distinctContext['files'], 'file_id')
&& array_merge(...array_column($distinctBatches, 'urls')) === array_column($distinctContext['files'], 'url'),
'duplicate URLs start a new batch without reordering or dropping any logical attachment');
rxGeneratorExpect(array_column($distinctResult['coverage']['files'], 'file_id') === array_column($distinctContext['files'], 'file_id'),
'every separately transmitted logical file remains individually covered');
$evidence = ['summary' => '本批证据完整保留临床数值和矛盾。', 'covered_source_ids' => array_fill(0, 5, 'diagnoses:1'),
'evidence_references' => ['diagnoses:1', 'diagnoses:1'], 'missing_information' => []];
$evidenceJson = rxGeneratorJson($evidence);
$fencedEvidence = "```json\n" . $evidenceJson . "\n```";
$evidenceParser = (new ReflectionClass(PrescriptionAiGenerator::class))->getMethod('parseEvidence');
foreach ([$evidenceJson, " \r\n" . $fencedEvidence . "\r\n "] as $content) {
$parsedEvidence = $evidenceParser->invoke(null, $content, ['diagnoses:1']);
rxGeneratorExpect($parsedEvidence !== null && $parsedEvidence['covered_source_ids'] === ['diagnoses:1']
&& $parsedEvidence['evidence_references'] === ['diagnoses:1'], 'bare or wholly fenced evidence normalizes repeated known references to sets');
}
foreach (["说明\n" . $fencedEvidence, $fencedEvidence . "\n说明", $evidenceJson . $evidenceJson,
$fencedEvidence . "\n" . $fencedEvidence, "```json\n" . $evidenceJson . "\n" . $evidenceJson . "\n```",
"```text\n" . $evidenceJson . "\n```"] as $content) {
rxGeneratorExpect($evidenceParser->invoke(null, $content, ['diagnoses:1']) === null, 'wrappers never extract JSON from prose, multiple objects or another fence language');
}
foreach (['covered_source_ids', 'evidence_references'] as $field) {
foreach ([['diagnoses:1', 'diagnoses:9999', 'diagnoses:9999'], ['diagnoses:1', 1], ['diagnoses:1', null],
['diagnoses:1', false], ['diagnoses:1', ''], ['source' => 'diagnoses:1'], array_fill(0, 4097, 'diagnoses:1')] as $references) {
$invalidEvidence = $evidence;
$invalidEvidence[$field] = $references;
rxGeneratorExpect($evidenceParser->invoke(null, "```json\n" . rxGeneratorJson($invalidEvidence) . "\n```", ['diagnoses:1']) === null,
'reference normalization preserves source, string-list and size validation for ' . $field);
}
}
rxGeneratorExpect($evidenceParser->invoke(null, $fencedEvidence, ['diagnoses:1', 'diagnoses:2']) === null, 'duplicates cannot hide an omitted expected source');
$invalidEvidence = $evidence;
$invalidEvidence['covered_source_ids'] = [];
rxGeneratorExpect($evidenceParser->invoke(null, rxGeneratorJson($invalidEvidence), ['diagnoses:1']) === null, 'missing coverage is rejected without inventing a source');
$invalidEvidence = $evidence;
$invalidEvidence['extra'] = 'unexpected';
rxGeneratorExpect($evidenceParser->invoke(null, "```json\n" . rxGeneratorJson($invalidEvidence) . "\n```", ['diagnoses:1']) === null, 'fenced evidence retains strict schema validation');
$compatibilityContext = $context;
$compatibilityContext['files'] = [];
$compatibilityProgress = [];
$persisted = PrescriptionAiGenerator::generateWithTransport('qwen', $compatibilityContext,
static fn (): array => ['ok' => true, 'content' => $fencedEvidence],
static function (array $progress) use (&$compatibilityProgress): bool {
$compatibilityProgress = $progress;
return !isset($progress['steps']['text:0']);
});
rxGeneratorExpect(!$persisted['ok'] && $persisted['error_code'] === 'CHECKPOINT_REJECTED'
&& $compatibilityProgress['steps']['text:0']['value']['content'] === $fencedEvidence, 'checkpoint fixture retains the raw successful response before parsing');
$compatibilityContext['_progress'] = $compatibilityProgress;
$compatibilityCalls = 0;
$compatibilityResumed = PrescriptionAiGenerator::generateWithTransport('qwen', $compatibilityContext,
static function ($model, $prompt, $files) use (&$compatibilityCalls, $final): array {
$compatibilityCalls++;
rxGeneratorExpect(str_contains($prompt, 'BRANCH_EVIDENCE_JSON=') && $files === [], 'saved fenced text is reused and only final synthesis calls transport');
$summaries = json_decode(explode('BRANCH_EVIDENCE_JSON=', $prompt, 2)[1], true);
rxGeneratorExpect($summaries[0]['covered_source_ids'] === ['diagnoses:1'] && $summaries[0]['evidence_references'] === ['diagnoses:1'],
'synthesis receives normalized source sets from the saved raw response');
return ['ok' => true, 'content' => rxGeneratorJson($final)];
});
rxGeneratorExpect($compatibilityResumed['ok'] && $compatibilityCalls === 1 && $compatibilityResumed['usage']['total_calls'] === 2
&& $compatibilityResumed['coverage']['source_ids'] === ['diagnoses:1'], 'saved fenced evidence with five identical source IDs resumes without a new text request');
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($final), ['diagnoses:1']) !== null, 'valid clinical candidate is accepted');
$repeatedFinal = $final;
$repeatedFinal['report']['evidence_references'][] = 'diagnoses:1';
$repeatedFinal['report']['risk_assessment'][0]['evidence_references'][] = 'diagnoses:1';
$repeatedFinal['candidate']['evidence_references'][] = 'diagnoses:1';
$repeatedFinal['candidate']['herbs'][0]['evidence_references'][] = 'diagnoses:1';
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal("```json\r\n" . rxGeneratorJson($repeatedFinal) . "\r\n```", ['diagnoses:1']) === $final,
'fenced final reports normalize references at every supported level without changing clinical values');
$invalid = $final;
unset($invalid['candidate']['herbs'][0]['unit']);
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($invalid), ['diagnoses:1']) === null, 'missing dose unit is rejected, never defaulted');
$invalid = $final;
$invalid['candidate']['herbs'][0]['id'] = 123;
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($invalid), ['diagnoses:1']) === null, 'model-generated herb identifiers are rejected');
$invalid = $final;
$invalid['candidate']['audit_status'] = 1;
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($invalid), ['diagnoses:1']) === null, 'unauthorized clinical workflow fields are rejected');
$invalid = $final;
$invalid['report']['evidence_references'] = ['diagnoses:9999'];
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($invalid), ['diagnoses:1']) === null, 'fabricated evidence references are rejected');
$invalid = $final;
$invalid['candidate']['herbs'][0]['dosage'] = -1;
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($invalid), ['diagnoses:1']) === null, 'nonpositive dose is rejected');
$invalid = $final;
unset($invalid['candidate']['usage_days']);
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal(rxGeneratorJson($invalid), ['diagnoses:1']) === null, 'no default treatment duration is fabricated');
rxGeneratorExpect(PrescriptionAiGenerator::parseFinal('not json ' . rxGeneratorJson($final), ['diagnoses:1']) === null, 'output must be a strict JSON object without extraneous instructions');
$budget = 24000;
$budgetContext = $context;
$budgetContext['source']['records'] = [];
for ($i = 1; $i <= 3; $i++) {
$budgetContext['source']['records'][] = ['source_id' => 'diagnoses:' . $i, 'kind' => 'diagnoses',
'data' => ['chief_complaint' => str_repeat('x', 12000)]];
}
$budgetContext['source_hash'] = hash('sha256', rxGeneratorJson($budgetContext['source']));
$budgetContext['files'] = [];
for ($i = 1; $i <= 22; $i++) {
$budgetContext['files'][] = ['file_id' => 'file:' . $i . ':' . str_repeat('f', 200), 'source_ids' => ['diagnoses:1'],
'url' => 'https://storage.example.test/image' . $i . '.png', 'type' => 'image',
'status' => $i <= 2 ? 'restricted' : 'pending', 'version_verified' => true];
}
$budgetConfig = ['max_files' => 3, 'manual_analysis' => ['input_token_budget' => $budget]];
$budgetCalls = [];
$budgetTextSummaries = [];
$budgetReductions = 0;
$budgetTransport = static function ($model, $prompt, $files) use ($budget, $final, &$budgetCalls, &$budgetTextSummaries, &$budgetReductions): array {
preg_match('/阶段=(text|reduce|files|final)/u', $prompt, $stageMatch);
$stage = $stageMatch[1] ?? 'unknown';
$budgetCalls[] = ['stage' => $stage, 'bytes' => strlen($prompt)];
rxGeneratorExpect(strlen($prompt) <= $budget, 'every transport call respects the original input budget');
if ($files !== []) {
return ['ok' => false, 'error_code' => 'FILE_TYPE_UNSUPPORTED'];
}
if (str_contains($prompt, 'EXPECTED_SOURCE_IDS=')) {
preg_match('/EXPECTED_SOURCE_IDS=([^\n]+)/', $prompt, $match);
$ids = json_decode($match[1], true);
$summary = ['summary' => str_repeat('s', $stage === 'text' ? 4000 : (++$budgetReductions === 1 ? 11000 : 2000)),
'covered_source_ids' => $ids, 'evidence_references' => $ids, 'missing_information' => []];
if ($stage === 'text') {
$budgetTextSummaries[] = $summary;
}
return ['ok' => true, 'content' => rxGeneratorJson($summary)];
}
$coverageJson = explode("\nBRANCH_EVIDENCE_JSON=", explode("\nCOVERAGE_JSON=", $prompt, 2)[1], 2)[0];
$promptCoverage = json_decode($coverageJson, true);
$finalPrompt = (new ReflectionClass(PrescriptionAiGenerator::class))->getMethod('finalPrompt');
rxGeneratorExpect(strlen(rxGeneratorJson($budgetTextSummaries)) < $budget - 5500
&& strlen($finalPrompt->invoke(null, $budgetTextSummaries, $promptCoverage, true)) > $budget,
'fixture summaries fit the former threshold but full coverage makes the unreduced final prompt exceed budget');
rxGeneratorExpect($promptCoverage['source_ids'] === ['diagnoses:1', 'diagnoses:2', 'diagnoses:3']
&& count($promptCoverage['files']) === 22 && count($promptCoverage['missing']) === 22
&& count(array_filter($promptCoverage['missing'], static fn ($gap): bool => $gap['critical'])) === 22,
'final synthesis retains every source, attachment and critical coverage gap');
return ['ok' => true, 'content' => rxGeneratorJson($final)];
};
$budgetProgress = [];
$budgetPaused = PrescriptionAiGenerator::generateWithTransport('openai', $budgetContext, $budgetTransport,
static function (array $progress) use (&$budgetProgress): bool {
$budgetProgress = $progress;
return $progress['stage'] !== 'files:0';
}, $budgetConfig);
rxGeneratorExpect(!$budgetPaused['ok'] && $budgetPaused['error_code'] === 'CHECKPOINT_REJECTED'
&& array_keys($budgetProgress['steps']) === ['text:0', 'text:1', 'text:2'], 'budget fixture saves three unchanged text checkpoints before file processing');
$budgetContext['_progress'] = $budgetProgress;
$budgetResult = PrescriptionAiGenerator::generateWithTransport('openai', $budgetContext, $budgetTransport, null, $budgetConfig);
rxGeneratorExpect($budgetResult['ok'] && $budgetReductions === 2, 'full final prompt size drives repeated reduction until synthesis fits');
rxGeneratorExpect(array_count_values(array_column($budgetCalls, 'stage')) === ['text' => 3, 'files' => 7, 'reduce' => 2, 'final' => 1],
'resumption reuses all three text responses before seven unsupported batches and bounded synthesis');
rxGeneratorExpect($budgetResult['candidate']['status'] === 'available_for_review' && count($budgetResult['coverage']['missing']) === 22,
'budget reduction retains every coverage gap while allowing a supported review candidate');
$reduceCacheContext = $budgetContext;
$reduceCacheContext['files'] = [];
$reduceCacheContext['missing'] = [['source_id' => 'source-gap', 'code' => str_repeat('X', 14000), 'critical' => true]];
$reduceCacheProgress = [];
$reduceCacheTransport = static function ($model, $prompt, $files, $user) use ($stub): array {
if (str_contains($prompt, '阶段=reduce')) {
preg_match('/EXPECTED_SOURCE_IDS=([^\n]+)/', $prompt, $match);
$ids = json_decode($match[1], true);
return ['ok' => true, 'content' => rxGeneratorJson(['summary' => '压缩证据仍保留全部来源。', 'covered_source_ids' => $ids,
'evidence_references' => $ids, 'missing_information' => []])];
}
return $stub($model, $prompt, $files, $user);
};
$reduceCacheResult = PrescriptionAiGenerator::generateWithTransport('openai', $reduceCacheContext, $reduceCacheTransport,
static function (array $progress) use (&$reduceCacheProgress): void { $reduceCacheProgress = $progress; }, $budgetConfig);
rxGeneratorExpect($reduceCacheResult['ok'] && isset($reduceCacheProgress['steps']['reduce:0:0']), 'cache regression includes a completed reduction step');
$badFileTransport = static function ($model, $prompt, $files, $user) use ($stub): array {
if (str_contains($prompt, '阶段=final')) {
rxGeneratorExpect(!str_contains($prompt, 'MALFORMED_GROUP_FINDING'), 'malformed attachment findings never reach final synthesis');
}
$value = $stub($model, $prompt, $files, $user);
if ($files !== [] && str_ends_with($files[0]['url'], 'image1.png')) {
$body = json_decode($value['content'], true);
array_pop($body['files']);
$body['files'][0]['findings'] = 'MALFORMED_GROUP_FINDING';
$value['content'] = rxGeneratorJson($body);
}
return $value;
};
$fileManifestPrompts = [];
PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($stub, &$fileManifestPrompts): array {
if (str_contains($prompt, 'FILE_MANIFEST=')) {
$fileManifestPrompts[] = $prompt;
}
return $stub($model, $prompt, $files, $user);
}, null, ['max_files' => 3]);
rxGeneratorExpect($fileManifestPrompts !== [] && str_contains($fileManifestPrompts[0], 'ALLOWED_EVIDENCE_IDS=')
&& str_contains($fileManifestPrompts[0], 'file:1') && str_contains($fileManifestPrompts[0], 'diagnoses:1')
&& strpos($fileManifestPrompts[0], 'ALLOWED_EVIDENCE_IDS=') < strpos($fileManifestPrompts[0], 'FILE_MANIFEST='),
'the attachment stage is told which identifiers a finding may cite, before the manifest payload');
$badFileProgress = [];
$badFiles = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $badFileTransport,
static function (array $progress) use (&$badFileProgress): void { $badFileProgress = $progress; });
rxGeneratorExpect($badFiles['ok'] && $badFiles['candidate']['status'] === 'available_for_review'
&& !$badFiles['coverage']['complete'] && count($badFiles['coverage']['missing']) === 3,
'malformed attachment group produces an explicitly limited report while later groups and supported candidate continue');
foreach (array_slice($badFiles['coverage']['files'], 0, 3) as $fileCoverage) {
rxGeneratorExpect($fileCoverage['status'] === 'unreadable' && $fileCoverage['transmitted'] === true
&& $fileCoverage['version_verified'] === false && $fileCoverage['reason'] === 'MODEL_FILE_OUTPUT_INVALID',
'every member of the malformed group has honest delivery and unusable-evidence status');
}
rxGeneratorExpect($badFiles['coverage']['files'][3]['status'] === 'processed'
&& !isset($badFileProgress['steps']['files:0']) && isset($badFileProgress['steps']['files:1']),
'invalid group cache is cleared without discarding the next valid group');
// The pharmacy's dispensing convention and medicine names must reach the candidate stage so both
// models express one comparable plan; neither may carry the doctor's own herbs or dosages.
$conventionContext = $noncriticalContext;
$conventionContext['source']['dispensing'] = ['formulation' => '浓缩水丸', 'unit' => 'g', 'dose_basis' => 'per_dose'];
$conventionContext['_comparison_catalog'] = [['id' => 1, 'name' => '生麦冬', 'unit' => '克'],
['id' => 2, 'name' => '麸炒白术', 'unit' => '克'], ['id' => 3, 'name' => '测试药材', 'unit' => '克']];
$finalPrompts = [];
$conventionRun = PrescriptionAiGenerator::generateWithTransport('qwen', $conventionContext,
static function ($model, $prompt, $files, $user) use ($stub, &$finalPrompts): array {
if (str_contains($prompt, '阶段=final')) {
$finalPrompts[] = $prompt;
}
return $stub($model, $prompt, $files, $user);
});
rxGeneratorExpect($conventionRun['ok'] && count($finalPrompts) === 1, 'the dispensing convention does not add extra model calls');
rxGeneratorExpect(str_contains($finalPrompts[0], '浓缩水丸') && str_contains($finalPrompts[0], 'MEDICINE_CATALOG=')
&& str_contains($finalPrompts[0], '生麦冬') && str_contains($finalPrompts[0], '麸炒白术'),
'the candidate stage receives the dispensing form, unit, dose basis and the clinic medicine names');
rxGeneratorExpect(str_contains($finalPrompts[0], 'ALLOWED_EVIDENCE_IDS=["diagnoses:1"]'),
'the candidate stage is told exactly which evidence identifiers a citation may use');
$fileGapPrompts = [];
PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($unreadable, &$fileGapPrompts): array {
if (str_contains($prompt, '阶段=final')) {
$fileGapPrompts[] = $prompt;
}
return $unreadable($model, $prompt, $files, $user);
}, null, ['max_files' => 3]);
rxGeneratorExpect($fileGapPrompts !== [] && str_contains($fileGapPrompts[0], 'ALLOWED_EVIDENCE_IDS=')
&& !str_contains($fileGapPrompts[0], 'ALLOWED_EVIDENCE_IDS=["diagnoses:1","file:1"'),
'attachments this branch could not read are never offered as citable evidence');
$hugeCatalog = $conventionContext;
$hugeCatalog['_comparison_catalog'] = array_map(static fn (int $i): array => ['id' => $i, 'name' => str_repeat('药', 30) . $i], range(1, 400));
$hugePrompts = [];
PrescriptionAiGenerator::generateWithTransport('qwen', $hugeCatalog,
static function ($model, $prompt, $files, $user) use ($stub, &$hugePrompts): array {
if (str_contains($prompt, '阶段=final')) {
$hugePrompts[] = $prompt;
}
return $stub($model, $prompt, $files, $user);
});
rxGeneratorExpect($hugePrompts !== [] && !str_contains($hugePrompts[0], 'MEDICINE_CATALOG='),
'an oversized catalog is omitted instead of silently truncated or blowing the prompt budget');
// A medicine name outside the institution dictionary is named back to the model and re-asked;
// the server never substitutes a medicine itself, and an unfixed name stays visible to the doctor.
$outsideCatalog = $noncriticalContext;
$outsideCatalog['_comparison_catalog'] = [['id' => 1, 'name' => '生麦冬'], ['id' => 2, 'name' => '麸炒白术']];
$namePrompts = [];
$corrected = $final;
$corrected['candidate']['herbs'][0]['name'] = '生麦冬';
$nameFixRun = PrescriptionAiGenerator::generateWithTransport('qwen', $outsideCatalog,
static function ($model, $prompt, $files, $user) use ($stub, $corrected, &$namePrompts): array {
if (str_contains($prompt, '阶段=final')) {
$namePrompts[] = $prompt;
if (str_contains($prompt, '不在MEDICINE_CATALOG清单里')) {
return ['ok' => true, 'content' => rxGeneratorJson($corrected)];
}
}
return $stub($model, $prompt, $files, $user);
});
rxGeneratorExpect($nameFixRun['ok'] && count($namePrompts) === 2 && $nameFixRun['candidate']['herbs'][0]['name'] === '生麦冬',
'an unlisted medicine name is named back to the model and corrected from the institution catalog');
rxGeneratorExpect(str_contains($namePrompts[1], '测试药材') && strpos($namePrompts[1], '不在MEDICINE_CATALOG清单里') < strpos($namePrompts[1], '阶段=final'),
'the re-ask states exactly which names were unlisted and keeps the stage payload last');
$stubbornNames = PrescriptionAiGenerator::generateWithTransport('qwen', $outsideCatalog, $stub);
rxGeneratorExpect($stubbornNames['ok'] && $stubbornNames['candidate']['herbs'][0]['name'] === '测试药材'
&& count(array_filter($stubbornNames['candidate']['risk_warnings'],
static fn (string $warning): bool => str_contains($warning, '测试药材') && str_contains($warning, '药材字典'))) === 1,
'a name the model keeps using is never substituted by the server and is flagged for the doctor');
// One controlled format repair per stage: a malformed answer is re-asked immediately instead of
// failing the whole model branch, and a persistent format failure is still an explicit error.
foreach ([['阶段=text', 'INVALID_EVIDENCE_OUTPUT'], ['阶段=final', 'INVALID_REPORT_OUTPUT']] as [$stageMark, $stageError]) {
$repairCalls = 0;
$repairedRun = PrescriptionAiGenerator::generateWithTransport('qwen', $noncriticalContext,
static function ($model, $prompt, $files, $user) use ($stub, $stageMark, &$repairCalls): array {
if (str_contains($prompt, $stageMark) && !str_contains($prompt, '上一次回答未通过接口结构校验')) {
$repairCalls++;
return ['ok' => true, 'content' => '这是解释文字,不是JSON。'];
}
rxGeneratorExpect(!str_contains($prompt, $stageMark)
|| strpos($prompt, '上一次回答未通过接口结构校验') < strpos($prompt, $stageMark),
'the repair instruction is placed before the stage payload so the JSON block stays last');
return $stub($model, $prompt, $files, $user);
});
rxGeneratorExpect($repairedRun['ok'] && $repairCalls === 1 && $repairedRun['candidate']['status'] === 'available_for_review',
'a malformed ' . $stageMark . ' answer is repaired in place instead of failing the branch');
$persistentProgress = [];
$persistent = PrescriptionAiGenerator::generateWithTransport('qwen', $noncriticalContext,
static function ($model, $prompt, $files, $user) use ($stub, $stageMark): array {
return str_contains($prompt, $stageMark) ? ['ok' => true, 'content' => '这是解释文字,不是JSON。'] : $stub($model, $prompt, $files, $user);
}, static function (array $progress) use (&$persistentProgress): void { $persistentProgress = $progress; });
rxGeneratorExpect(!$persistent['ok'] && $persistent['error_code'] === $stageError && $persistent['retryable'],
'a persistent malformed ' . $stageMark . ' answer stays an explicit retryable failure');
$repairKey = $stageMark === '阶段=final' ? 'final' : 'text:0';
rxGeneratorExpect(!isset($persistentProgress['steps'][$repairKey]) && !isset($persistentProgress['steps'][$repairKey . ':repair'])
&& count($persistentProgress['format_rejects']) === 2
&& $persistentProgress['format_rejects'][0]['rule'] === 'json_syntax'
&& $persistentProgress['format_rejects'][0]['content_length'] > 0,
'neither the malformed answer nor its failed repair is cached, and both rejections record why and how long the answer was');
}
$badReferenceFinal = $final;
$badReferenceFinal['report']['evidence_references'] = ['file:1'];
$badReference = PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($badFileTransport, $badReferenceFinal): array {
return str_contains($prompt, '阶段=final') ? ['ok' => true, 'content' => rxGeneratorJson($badReferenceFinal)] : $badFileTransport($model, $prompt, $files, $user);
});
rxGeneratorExpect(!$badReference['ok'] && $badReference['error_code'] === 'INVALID_REPORT_OUTPUT',
'final report cannot cite any member of a malformed attachment group as read evidence');
$clinicalBadFiles = $context;
$clinicalBadFiles['missing'] = [['source_id' => 'clinical.allergy_history', 'code' => 'CRITICAL_CLINICAL_FACT_MISSING', 'critical' => true]];
$clinicalBadResult = PrescriptionAiGenerator::generateWithTransport('qwen', $clinicalBadFiles, $badFileTransport);
rxGeneratorExpect($clinicalBadResult['candidate']['status'] === 'available_for_review' && !$clinicalBadResult['coverage']['complete']
&& count($clinicalBadResult['coverage']['missing']) === 4,
'attachment degradation plus a missing safety fact still yields a candidate with every gap listed');
rxGeneratorExpect(PrescriptionAiGenerator::generateWithTransport('qwen', $clinicalBadFiles, $badFileTransport, null, ['manual_analysis' => ['require_candidate' => false]])['candidate']['status'] === 'insufficient_data',
'configured withholding is not relaxed by attachment degradation');
$failedBadFileCheckpoint = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $badFileTransport,
static fn (array $progress): bool => !($progress['stage'] === 'files:0' && $progress['usage']['total_calls'] >= 2 && !isset($progress['steps']['files:0'])));
rxGeneratorExpect(!$failedBadFileCheckpoint['ok'] && $failedBadFileCheckpoint['error_code'] === 'CHECKPOINT_REJECTED',
'failure to persist invalid-group removal stops the task before any degradation can continue');
$badDelivery = PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($stub): array {
$value = $stub($model, $prompt, $files, $user);
if ($files !== []) { $value['transmitted_file_count'] = 0; }
return $value;
});
rxGeneratorExpect(!$badDelivery['ok'] && $badDelivery['error_code'] === 'FILE_DELIVERY_UNVERIFIED', 'unverified delivery remains a strict failure');
$degradedRetryProgress = [];
$degradedInterrupted = PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($badFileTransport): array {
return str_contains($prompt, '阶段=final') ? ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT'] : $badFileTransport($model, $prompt, $files, $user);
}, static function (array $progress) use (&$degradedRetryProgress): void { $degradedRetryProgress = $progress; });
rxGeneratorExpect(!$degradedInterrupted['ok'] && $degradedInterrupted['retryable'] && $degradedInterrupted['usage']['total_calls'] === 5,
'a later timeout retains successful evidence and the cost of malformed attachment delivery, including its one format repair');
$degradedRetryContext = $context;
$degradedRetryContext['_progress'] = $degradedRetryProgress;
$degradedRetry = PrescriptionAiGenerator::generateWithTransport('qwen', $degradedRetryContext, $badFileTransport);
rxGeneratorExpect($degradedRetry['ok'] && $degradedRetry['usage']['total_calls'] === 8,
'resumption rereads the discarded group and retries final without rereading valid text or attachments');
$degradedExhausted = PrescriptionAiGenerator::generateWithTransport('qwen', $degradedRetryContext,
static function (): array { throw new RuntimeException('must not exceed lifetime budget'); }, null, ['manual_analysis' => ['max_calls_per_model' => 4]]);
rxGeneratorExpect(!$degradedExhausted['ok'] && $degradedExhausted['error_code'] === 'TOTAL_CALL_BUDGET_EXCEEDED',
'degraded attachment retry remains bounded by the original cumulative call budget');
foreach ([
['text:0', 'INVALID_EVIDENCE_OUTPUT', 'qwen', $context, $saved, $stub],
['final', 'INVALID_REPORT_OUTPUT', 'qwen', $context, $saved, $stub],
['reduce:0:0', 'INVALID_EVIDENCE_OUTPUT', 'openai', $reduceCacheContext, $reduceCacheProgress, $reduceCacheTransport],
] as [$invalidKey, $errorCode, $model, $retryContext, $validProgress, $validTransport]) {
$invalidProgress = $validProgress;
$invalidPayload = json_decode($invalidProgress['steps'][$invalidKey]['value']['content'], true);
if ($invalidKey === 'final') {
unset($invalidPayload['candidate']['herbs'][0]['unit']);
} else {
$invalidPayload['covered_source_ids'] = [];
}
$invalidProgress['steps'][$invalidKey]['value']['content'] = rxGeneratorJson($invalidPayload);
$retryContext['_progress'] = $invalidProgress;
$invalidatedProgress = [];
$invalidCacheCalls = 0;
$invalidCacheResult = PrescriptionAiGenerator::generateWithTransport($model, $retryContext,
static function () use (&$invalidCacheCalls): array { $invalidCacheCalls++; return ['ok' => false, 'error_code' => 'UNEXPECTED_TRANSPORT']; },
static function (array $progress) use (&$invalidatedProgress): void { $invalidatedProgress = $progress; });
$expectedSteps = $validProgress['steps'];
unset($expectedSteps[$invalidKey]);
// The invalid cache is dropped and re-asked once; here the repair call itself fails at the transport.
rxGeneratorExpect(!$invalidCacheResult['ok'] && $invalidCacheResult['error_code'] === 'UNEXPECTED_TRANSPORT'
&& $invalidCacheCalls === 1 && $invalidatedProgress['steps'] === $expectedSteps
&& $invalidatedProgress['usage']['total_calls'] === $validProgress['usage']['total_calls'] + 1,
'invalid cached ' . $invalidKey . ' is durably removed and re-asked once while valid steps and accumulated usage remain intact');
$retryContext['_progress'] = $invalidatedProgress;
$retryCalls = 0;
$retryResult = PrescriptionAiGenerator::generateWithTransport($model, $retryContext,
static function ($model, $prompt, $files, $user) use ($validTransport, &$retryCalls): array {
$retryCalls++;
return $validTransport($model, $prompt, $files, $user);
});
// The failed repair above is still charged, so the retry adds exactly one more call.
rxGeneratorExpect($retryResult['ok'] && $retryCalls === 1 && $retryResult['usage']['total_calls'] === $validProgress['usage']['total_calls'] + 2
&& array_slice($retryResult['usage']['calls'], 0, count($validProgress['usage']['calls'])) === $validProgress['usage']['calls'],
'normal retry requests only invalidated ' . $invalidKey . ' and preserves the previous call history');
}
$liveInvalidContext = $context;
$liveInvalidContext['files'] = [];
$liveInvalidProgress = [];
$sawRawInvalid = false;
$liveInvalid = PrescriptionAiGenerator::generateWithTransport('qwen', $liveInvalidContext,
static fn (): array => ['ok' => true, 'content' => '{}', 'error_code' => 'IGNORED_SUCCESS_CODE'],
static function (array $progress) use (&$liveInvalidProgress, &$sawRawInvalid): void {
$sawRawInvalid = $sawRawInvalid || isset($progress['steps']['text:0']);
$liveInvalidProgress = $progress;
});
rxGeneratorExpect(!$liveInvalid['ok'] && $liveInvalid['error_code'] === 'INVALID_EVIDENCE_OUTPUT' && $sawRawInvalid
&& !isset($liveInvalidProgress['steps']['text:0']) && !isset($liveInvalidProgress['steps']['text:0:repair'])
&& $liveInvalidProgress['usage']['total_calls'] === 2
&& $liveInvalidProgress['usage']['calls'][0]['error_code'] === '', 'new invalid responses and their failed repair are removed after persistence while the successful transport usage remains counted');
$exhaustedContext = $liveInvalidContext;
$exhaustedContext['_progress'] = $liveInvalidProgress;
$exhausted = PrescriptionAiGenerator::generateWithTransport('qwen', $exhaustedContext,
static function (): array { throw new RuntimeException('must not call upstream'); }, null, ['manual_analysis' => ['max_calls_per_model' => 1]]);
rxGeneratorExpect(!$exhausted['ok'] && $exhausted['error_code'] === 'TOTAL_CALL_BUDGET_EXCEEDED' && $exhausted['usage']['total_calls'] === 2,
'invalid response eviction never resets the cumulative model call budget');
$rejectedEviction = PrescriptionAiGenerator::generateWithTransport('qwen', $liveInvalidContext,
static fn (): array => ['ok' => true, 'content' => '{}'],
static fn (array $progress): bool => $progress['usage']['total_calls'] === 0 || isset($progress['steps']['text:0']));
rxGeneratorExpect(!$rejectedEviction['ok'] && $rejectedEviction['error_code'] === 'CHECKPOINT_REJECTED',
'eviction persistence must succeed before reporting the schema failure');
foreach (['UPSTREAM_TIMEOUT' => 'UPSTREAM_TIMEOUT', 'upstream timeout' => '', "UPSTREAM_TIMEOUT\n" => '',
'ERROR https://example.test/private' => '', str_repeat('X', 82) => ''] as $rawCode => $recordedCode) {
$diagnosticFailure = PrescriptionAiGenerator::generateWithTransport('qwen', $liveInvalidContext,
static fn (): array => ['ok' => false, 'error_code' => $rawCode]);
rxGeneratorExpect($diagnosticFailure['usage']['calls'][0]['error_code'] === $recordedCode,
'usage diagnostics retain only bounded uppercase error identifiers, never upstream prose or URLs');
}
$fixedContext = $context;
$fixedContext['files'] = [];
$fixedContext['missing'] = [['source_id' => 'source-gap', 'code' => str_repeat('X', $budget), 'critical' => true]];
$fixedCalls = [];
$fixedResult = PrescriptionAiGenerator::generateWithTransport('qwen', $fixedContext,
static function ($model, $prompt, $files, $user) use ($stub, &$fixedCalls): array {
$fixedCalls[] = $prompt;
return $stub($model, $prompt, $files, $user);
}, null, $budgetConfig);
rxGeneratorExpect(!$fixedResult['ok'] && $fixedResult['error_code'] === 'FINAL_CONTEXT_EXCEEDS_BUDGET'
&& count($fixedCalls) === 1 && str_contains($fixedCalls[0], '阶段=text')
&& $fixedResult['coverage']['missing'] === $fixedContext['missing'],
'fixed coverage that cannot fit fails explicitly without reduction, final calls or dropped gaps');
$longContext = $context;
$longContext['source']['records'][0]['data']['chief_complaint'] = str_repeat('长', 10000);
$tooLong = PrescriptionAiGenerator::generateWithTransport('qwen', $longContext, static function (): array { throw new RuntimeException('must not call upstream'); });
rxGeneratorExpect(!$tooLong['ok'] && $tooLong['error_code'] === 'SOURCE_UNIT_EXCEEDS_BUDGET', 'oversized indivisible source is an explicit error, not silent truncation');
$canceled = PrescriptionAiGenerator::generateWithTransport('qwen', $context, static function (): array { throw new RuntimeException('must not call upstream'); }, static fn (): bool => false);
rxGeneratorExpect(!$canceled['ok'] && $canceled['error_code'] === 'CHECKPOINT_REJECTED', 'lease/cancellation rejection stops the next model call');
$service = new ReflectionClass(DifyChatService::class);
$normalizer = $service->getMethod('normalizeFiles');
$strictComplete = $service->getMethod('strictFilesComplete');
$strictProtocol = $service->getMethod('strictProtocolSupportsFiles');
$normalized = $normalizer->invoke(null, $context['files'], 3);
rxGeneratorExpect(!$strictComplete->invoke(null, $context['files'], $normalized), 'strict service refuses truncated transport rather than declaring success');
$batch = array_slice($context['files'], 0, 3);
rxGeneratorExpect($strictComplete->invoke(null, $batch, $normalizer->invoke(null, $batch, 3)), 'strict service permits a complete valid batch');
rxGeneratorExpect(!$strictProtocol->invoke(null, 'openai', [['type' => 'document']]), 'OpenAI-compatible legacy transport cannot pretend a URL manifest is a parsed PDF');
rxGeneratorExpect($strictProtocol->invoke(null, 'dify', [['type' => 'document']]), 'Dify strict path transmits documents through its actual file parameter');
rxGeneratorExpect($strictProtocol->invoke(null, 'openai', [['type' => 'image']]), 'OpenAI strict path retains real multimodal image support');
$visibleEvents = [];
$latestPublic = [];
$cacheWrites = 0;
$durableCache = [];
$transportProgress = [];
$progressRun = PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($stub, &$latestPublic, &$transportProgress): array {
$transportProgress[] = $latestPublic;
rxGeneratorExpect($latestPublic['phase'] === 'waiting', 'upstream transport observes a published waiting phase');
return $stub($model, $prompt, $files, $user);
}, static function (array $progress, bool $persistCache) use (&$visibleEvents, &$latestPublic, &$cacheWrites, &$durableCache): void {
$latestPublic = \app\common\service\prescriptionai\PrescriptionAiProgress::sanitize($progress['public'] ?? null);
$visibleEvents[] = $latestPublic;
if ($persistCache) { $cacheWrites++; $durableCache = $progress; }
});
rxGeneratorExpect($progressRun['ok'] && $cacheWrites === 4, 'only four model responses persist the growing cache, not progress-only notifications');
rxGeneratorExpect(array_column($transportProgress, 'stage') === ['text', 'files', 'files', 'final']
&& array_column($transportProgress, 'completed_units') === [0, 0, 1, null]
&& array_column($transportProgress, 'total_units') === [1, 2, 2, null], 'transport sees honest completed group counts before each call');
rxGeneratorExpect($latestPublic['stage'] === 'validating' && !in_array('completed', array_column($visibleEvents, 'stage'), true),
'generator never reports task completion before comparison and result persistence');
$durableResume = $context;
$durableResume['_progress'] = $durableCache;
$resumeCalls = 0;
$resumeWithMetadata = PrescriptionAiGenerator::generateWithTransport('qwen', $durableResume,
static function () use (&$resumeCalls): array { $resumeCalls++; return ['ok' => false]; });
rxGeneratorExpect($resumeWithMetadata['ok'] && $resumeCalls === 0, 'metadata-only completion does not discard the durable resumable cache');
$rejectedCalls = 0;
$rejectCountAdvance = PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static function ($model, $prompt, $files, $user) use ($stub, &$rejectedCalls): array {
$rejectedCalls++; return $stub($model, $prompt, $files, $user);
}, static fn (array $progress): bool => !(($progress['public']['stage'] ?? '') === 'text' && ($progress['public']['completed_units'] ?? 0) === 1));
rxGeneratorExpect(!$rejectCountAdvance['ok'] && $rejectCountAdvance['error_code'] === 'CHECKPOINT_REJECTED' && $rejectedCalls === 1,
'rejected validated-group progress stops before the next model call');
$invalidPublic = [];
$invalidPublicResult = PrescriptionAiGenerator::generateWithTransport('qwen', $context,
static fn (): array => ['ok' => true, 'content' => '{}'],
static function (array $progress) use (&$invalidPublic): void { $invalidPublic[] = $progress['public']; });
rxGeneratorExpect(!$invalidPublicResult['ok'] && max(array_column($invalidPublic, 'completed_units')) === 0,
'invalid model evidence never increments completed text groups');
$unsupportedPublic = [];
$unsupportedProgressRun = PrescriptionAiGenerator::generateWithTransport('qwen', $context, $unreadable,
static function (array $progress) use (&$unsupportedPublic): void {
if (($progress['public']['stage'] ?? '') === 'files') { $unsupportedPublic[] = $progress['public']; }
});
rxGeneratorExpect($unsupportedProgressRun['ok'] && end($unsupportedPublic)['completed_units'] === 2
&& end($unsupportedPublic)['total_units'] === 2 && !$unsupportedProgressRun['coverage']['complete'],
'explicitly unsupported groups count as handled without claiming complete file coverage');
$reducePublic = [];
$reduceProgressRun = PrescriptionAiGenerator::generateWithTransport('openai', $reduceCacheContext, $reduceCacheTransport,
static function (array $progress) use (&$reducePublic): void {
if (($progress['public']['stage'] ?? '') === 'reduce') { $reducePublic[] = $progress['public']; }
}, $budgetConfig);
rxGeneratorExpect($reduceProgressRun['ok'] && $reducePublic !== [] && $reducePublic[0]['completed_units'] === 0
&& end($reducePublic)['completed_units'] === end($reducePublic)['total_units'], 'reduction exposes counts for its measured round');
echo "PrescriptionAiGeneratorTest passed\n";