733 lines
55 KiB
PHP
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";
|