This commit is contained in:
Your Name
2026-10-08 18:03:11 +08:00
parent 4dac39c4c2
commit 19ea2dadeb
26 changed files with 1131 additions and 28 deletions
@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace app\common\service\prescriptionai;
use app\common\service\DifyChatService;
use app\common\service\ClinicalKnowledgeReference;
use app\common\service\NihaixiaClinicalSkill;
use app\common\service\TcmAncientBooksReference;
/** One model branch. It receives a saved context and never queries patient data. */
final class PrescriptionAiGenerator
{
public const PROMPT_VERSION = 'manual-prescription-required-candidate-nihaixia-v5';
public const PROMPT_VERSION = 'manual-prescription-required-candidate-nihaixia-ancient-v6';
private const REPORT_KEYS = ['summary', 'diagnosis', 'risk_assessment', 'treatment_advice', 'evidence_references', 'missing_information'];
private const RETRYABLE = ['UPSTREAM_TIMEOUT', 'UPSTREAM_BUSY', 'UPSTREAM_UNAVAILABLE', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE',
'CANDIDATE_WITHHELD_BY_MODEL', 'INVALID_EVIDENCE_OUTPUT', 'INVALID_REPORT_OUTPUT'];
@@ -213,12 +215,12 @@ final class PrescriptionAiGenerator
// model prescribes plain names such as 麦冬 while the clinic stocks 生麦冬, and every
// row is then an unmappable identity rather than a comparable one.
$catalogNames = self::catalogNames($context, $inputBudget);
if (strlen(NihaixiaClinicalSkill::augmentQuery(self::finalPrompt([], $coverage, $candidateBlocked, $requireCandidate, $dispensing, $catalogNames, $readIds))) + ($insistRounds > 0 ? self::INSIST_RESERVE : 0) > $inputBudget) {
if (strlen(ClinicalKnowledgeReference::augmentQuery(self::finalPrompt([], $coverage, $candidateBlocked, $requireCandidate, $dispensing, $catalogNames, $readIds))) + ($insistRounds > 0 ? self::INSIST_RESERVE : 0) > $inputBudget) {
throw new \RuntimeException('FINAL_CONTEXT_EXCEEDS_BUDGET');
}
// Include the complete coverage and prompt overhead when deciding to reduce evidence.
$prompt = self::finalPrompt($summaries, $coverage, $candidateBlocked, $requireCandidate, $dispensing, $catalogNames, $readIds);
for ($round = 0; strlen(NihaixiaClinicalSkill::augmentQuery($prompt)) + ($insistRounds > 0 ? self::INSIST_RESERVE : 0) > $inputBudget; $round++) {
for ($round = 0; strlen(ClinicalKnowledgeReference::augmentQuery($prompt)) + ($insistRounds > 0 ? self::INSIST_RESERVE : 0) > $inputBudget; $round++) {
if ($round >= 8) {
throw new \RuntimeException('SYNTHESIS_BUDGET_EXCEEDED');
}
@@ -267,6 +269,7 @@ final class PrescriptionAiGenerator
throw new \RuntimeException('INVALID_REPORT_OUTPUT');
}
$parsed = $retried;
$value = $asked['value'];
}
// A single medicine name outside the institution dictionary makes the whole plan
// unmappable, so name it and re-ask instead of accepting an uncomparable candidate.
@@ -324,7 +327,10 @@ final class PrescriptionAiGenerator
self::checkpoint($checkpoint, $progress, false);
return ['ok' => true, 'report' => $parsed['report'], 'candidate' => $parsed['candidate'], 'coverage' => $coverage,
'usage' => $progress['usage'], 'model_name' => $value['model_name'] ?? $modelName,
'configured_model_name' => $config['models'][$modelKey]['name'] ?? null, 'prompt_version' => self::PROMPT_VERSION];
'configured_model_name' => $config['models'][$modelKey]['name'] ?? null, 'prompt_version' => self::PROMPT_VERSION,
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
'ancient_book_sources' => is_array($value['ancient_book_sources'] ?? null)
? $value['ancient_book_sources'] : TcmAncientBooksReference::sourcesForText($prompt)];
} catch (\Throwable $e) {
$code = preg_match('/^[A-Z][A-Z0-9_]{2,80}$/', $e->getMessage()) ? $e->getMessage() : 'GENERATION_FAILED';
return self::failure($code, in_array($code, self::RETRYABLE, true), $coverage, $progress['usage']);
@@ -355,7 +361,7 @@ final class PrescriptionAiGenerator
$progress['format_rejects'][count($progress['format_rejects'] ?? []) - 1]['content_length'] = $reject['content_length'];
self::invalidateStep($key, $progress, $checkpoint);
$repairPrompt = self::repairPrompt($prompt, $reject['rule']);
if (strlen(NihaixiaClinicalSkill::augmentQuery($repairPrompt)) > $inputBudget) {
if (strlen(ClinicalKnowledgeReference::augmentQuery($repairPrompt)) > $inputBudget) {
return ['value' => $value, 'parsed' => null];
}
$repaired = self::step($key . ':repair', $repairPrompt, $files, $modelKey, $context, $progress, $transport, $checkpoint, $inputBudget, $maxCalls);
@@ -383,7 +389,7 @@ final class PrescriptionAiGenerator
private static function step(string $key, string $prompt, array $files, string $model, array $context, array &$progress, callable $transport, ?callable $checkpoint, int $inputBudget, int $maxCalls): array
{
$prompt = NihaixiaClinicalSkill::augmentQuery($prompt);
$prompt = ClinicalKnowledgeReference::augmentQuery($prompt);
// UTF-8 byte length is a conservative upper bound for byte-fallback tokenizers. File
// vision tokens depend on provider preprocessing and are tracked as unknown usage.
if (strlen($prompt) > $inputBudget) {