'资料显示症状需要复核。', '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";