Merge commit '5706e9a6a51227ebf798df46fabcbabf83f91ab2' into codex/followup-audio
This commit is contained in:
@@ -10,6 +10,9 @@ use app\common\logic\BaseLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisAiReport;
|
||||
use app\common\service\DifyChatService;
|
||||
use app\common\service\NihaixiaClinicalSkill;
|
||||
use app\common\service\TcmAncientBooksReference;
|
||||
use app\adminapi\service\AssistantSseTiming;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -21,11 +24,11 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
/** @var string Safe machine-readable code for the current assistant request. */
|
||||
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
|
||||
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
|
||||
private const PROMPT_VERSION = 'patient-context-case-explain-nihaixia-ancient-v4';
|
||||
|
||||
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
|
||||
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-nihaixia-ancient-v4';
|
||||
|
||||
private const ANALYSIS_PROMPT_VERSION = 'patient-context-analysis-v2';
|
||||
private const ANALYSIS_PROMPT_VERSION = 'patient-context-analysis-nihaixia-ancient-v5';
|
||||
|
||||
private const MAX_ASSISTANT_PROMPT_LENGTH = 500;
|
||||
|
||||
@@ -53,6 +56,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
|
||||
private const PERMISSION_ASSISTANT = 'tcm.diagnosis/aiassistant';
|
||||
|
||||
private const PERMISSION_GENERATE_PRESCRIPTION = 'tcm.diagnosis/aigenerateprescription';
|
||||
|
||||
private const PATIENT_OPTIONS_DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
private const PATIENT_OPTIONS_MAX_PAGE_SIZE = 50;
|
||||
@@ -355,72 +360,117 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
string $task,
|
||||
string $prompt,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
array $adminInfo,
|
||||
?AssistantSseTiming $timing = null
|
||||
): ?array {
|
||||
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||
$diagnosisId,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
self::PERMISSION_ASSISTANT,
|
||||
'权限不足,无法使用诊单 AI 助手'
|
||||
);
|
||||
if ($diagnosis === null) {
|
||||
return null;
|
||||
}
|
||||
$prepareStartedAt = hrtime(true);
|
||||
$aggregateMs = 0;
|
||||
$compactionMs = 0;
|
||||
$sourceBytes = 0;
|
||||
$queryBytes = 0;
|
||||
$attachmentCount = 0;
|
||||
$compactionAttempted = false;
|
||||
$status = 'error';
|
||||
try {
|
||||
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
$task = strtolower(trim($task));
|
||||
if (!isset(self::ASSISTANT_TASKS[$task])) {
|
||||
self::setError('不支持的 AI 助手任务');
|
||||
return null;
|
||||
}
|
||||
if ($task === 'prescription_generate'
|
||||
&& !self::hasPermission($adminId, $adminInfo, self::PERMISSION_GENERATE_PRESCRIPTION)) {
|
||||
self::setError('权限不足,无法使用 AI 生成处方');
|
||||
return null;
|
||||
}
|
||||
|
||||
$task = strtolower(trim($task));
|
||||
if (!isset(self::ASSISTANT_TASKS[$task])) {
|
||||
self::setError('不支持的 AI 助手任务');
|
||||
return null;
|
||||
}
|
||||
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
|
||||
if ($task === 'custom' && $prompt === '') {
|
||||
self::setError('请输入要咨询的问题');
|
||||
return null;
|
||||
}
|
||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||
$diagnosisId,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
self::PERMISSION_ASSISTANT,
|
||||
'权限不足,无法使用诊单 AI 助手'
|
||||
);
|
||||
if ($diagnosis === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
||||
if ($context['case_lines'] === []) {
|
||||
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
|
||||
return null;
|
||||
}
|
||||
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
|
||||
if ($task === 'custom' && $prompt === '') {
|
||||
self::setError('请输入要咨询的问题');
|
||||
return null;
|
||||
}
|
||||
|
||||
$profile = self::selectAssistantProfile($task, $prompt);
|
||||
$modelConfig = self::modelConfigs()[$profile] ?? [];
|
||||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||||
$modelLabel = trim((string) ($modelConfig['label'] ?? $profile));
|
||||
if ($model === '') {
|
||||
self::setError('AI 模型服务尚未完整配置');
|
||||
return null;
|
||||
}
|
||||
$aggregateStartedAt = hrtime(true);
|
||||
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
||||
$aggregateMs = AssistantSseTiming::elapsedMs($aggregateStartedAt);
|
||||
$sourceBytes = strlen((string) ($context['case_text'] ?? ''));
|
||||
$attachmentCount = count(is_array($context['files'] ?? null) ? $context['files'] : []);
|
||||
if ($context['case_lines'] === []) {
|
||||
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!self::fitContextForPrompt($context, $profile)) {
|
||||
return null;
|
||||
}
|
||||
$profile = self::selectAssistantProfile($task, $prompt);
|
||||
$modelConfig = self::modelConfigs()[$profile] ?? [];
|
||||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||||
$modelLabel = trim((string) ($modelConfig['label'] ?? $profile));
|
||||
if ($model === '') {
|
||||
self::setError('AI 模型服务尚未完整配置');
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'model_name' => $model,
|
||||
'model_label' => $modelLabel,
|
||||
'task' => $task,
|
||||
'inputs' => self::buildUpstreamInputs(
|
||||
$compactionAttempted = $sourceBytes > self::MAX_PROMPT_SOURCE_BYTES;
|
||||
$compactionStartedAt = hrtime(true);
|
||||
if (!self::fitContextForPrompt($context, $profile, $timing)) {
|
||||
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
|
||||
return null;
|
||||
}
|
||||
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
|
||||
|
||||
$query = self::buildAssistantPrompt($context, $task, $prompt);
|
||||
$inputs = self::buildUpstreamInputs(
|
||||
$context,
|
||||
'病例问诊助手',
|
||||
self::ASSISTANT_PROMPT_VERSION
|
||||
),
|
||||
'query' => self::buildAssistantPrompt($context, $task, $prompt),
|
||||
'user' => 'admin-diagnosis-assistant-' . $adminId,
|
||||
'admin_id' => $adminId,
|
||||
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
|
||||
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
|
||||
'context_version' => (string) ($context['context_version'] ?? self::ASSISTANT_PROMPT_VERSION),
|
||||
'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [],
|
||||
'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null)
|
||||
? $context['source_diagnosis_ids']
|
||||
: [],
|
||||
];
|
||||
);
|
||||
$knowledgeSource = NihaixiaClinicalSkill::knowledgeSource();
|
||||
$queryBytes = strlen($query);
|
||||
$status = 'ok';
|
||||
|
||||
return [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'model_name' => $model,
|
||||
'model_label' => $modelLabel,
|
||||
'task' => $task,
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'knowledge_source' => $knowledgeSource,
|
||||
'user' => 'admin-diagnosis-assistant-' . $adminId,
|
||||
'admin_id' => $adminId,
|
||||
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
|
||||
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
|
||||
'context_version' => (string) ($context['context_version'] ?? self::ASSISTANT_PROMPT_VERSION),
|
||||
'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [],
|
||||
'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null)
|
||||
? $context['source_diagnosis_ids']
|
||||
: [],
|
||||
];
|
||||
} finally {
|
||||
if ($timing !== null) {
|
||||
$timing->log('prepare', [
|
||||
'duration_ms' => AssistantSseTiming::elapsedMs($prepareStartedAt),
|
||||
'aggregate_ms' => $aggregateMs,
|
||||
'compaction_ms' => $compactionMs,
|
||||
'source_bytes' => $sourceBytes,
|
||||
'query_bytes' => $queryBytes,
|
||||
'attachment_count' => $attachmentCount,
|
||||
'compaction_attempted' => $compactionAttempted,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,74 +482,42 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
public static function streamPreparedAssistant(
|
||||
array $prepared,
|
||||
callable $onDelta,
|
||||
?callable $shouldAbort = null
|
||||
?callable $shouldAbort = null,
|
||||
?AssistantSseTiming $timing = null
|
||||
): ?array {
|
||||
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
||||
$profile = (string) ($prepared['profile'] ?? '');
|
||||
$adminId = (int) ($prepared['admin_id'] ?? 0);
|
||||
$deliveredDelta = false;
|
||||
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
|
||||
$deltaCount = 0;
|
||||
$deltaBytes = 0;
|
||||
$retryAttempted = false;
|
||||
$retryMs = 0;
|
||||
$status = 'error';
|
||||
$upstreamStartedAt = hrtime(true);
|
||||
$forwardDelta = static function (string $delta) use (&$deliveredDelta, &$deltaCount, &$deltaBytes, $onDelta) {
|
||||
$accepted = $onDelta($delta);
|
||||
if ($accepted !== false) {
|
||||
$deliveredDelta = true;
|
||||
++$deltaCount;
|
||||
$deltaBytes += strlen($delta);
|
||||
}
|
||||
return $accepted;
|
||||
};
|
||||
|
||||
try {
|
||||
$result = DifyChatService::streamChat(
|
||||
$profile,
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
$forwardDelta,
|
||||
$shouldAbort,
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
self::logAssistantFailure(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
$e,
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
|
||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
self::logAssistantUpstreamError(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
(string) ($prepared['task'] ?? ''),
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
|
||||
// Some Dify-compatible gateways accept blocking chat but reject or
|
||||
// incompletely terminate streaming responses. Before any delta has
|
||||
// reached the doctor it is safe to make one blocking compatibility
|
||||
// attempt; after a delta, retrying could duplicate clinical text.
|
||||
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
|
||||
if (
|
||||
!$deliveredDelta
|
||||
&& in_array(
|
||||
$streamErrorCode,
|
||||
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
|
||||
true
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$profile,
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$result = DifyChatService::streamChat(
|
||||
$profile,
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
$forwardDelta,
|
||||
$shouldAbort,
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($timing === null) {
|
||||
self::logAssistantFailure(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
@@ -508,7 +526,14 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
}
|
||||
if (empty($result['ok'])) {
|
||||
$status = 'exception';
|
||||
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
|
||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
if ($timing === null) {
|
||||
self::logAssistantUpstreamError(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
@@ -517,10 +542,72 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
}
|
||||
|
||||
// Some Dify-compatible gateways accept blocking chat but reject or
|
||||
// incompletely terminate streaming responses. Before any delta has
|
||||
// reached the doctor it is safe to make one blocking compatibility
|
||||
// attempt; after a delta, retrying could duplicate clinical text.
|
||||
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
|
||||
if (
|
||||
!$deliveredDelta
|
||||
&& in_array(
|
||||
$streamErrorCode,
|
||||
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
|
||||
true
|
||||
)
|
||||
) {
|
||||
$retryAttempted = true;
|
||||
$retryStartedAt = hrtime(true);
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$profile,
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($timing === null) {
|
||||
self::logAssistantFailure(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
$e,
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
$retryMs = AssistantSseTiming::elapsedMs($retryStartedAt);
|
||||
if (empty($result['ok'])) {
|
||||
if ($timing === null) {
|
||||
self::logAssistantUpstreamError(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
(string) ($prepared['task'] ?? ''),
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$formatted = self::formatAssistantResult($prepared, $result);
|
||||
$status = $formatted === null ? 'error' : 'ok';
|
||||
return $formatted;
|
||||
} finally {
|
||||
if ($timing !== null) {
|
||||
$timing->log('upstream_done', [
|
||||
'duration_ms' => AssistantSseTiming::elapsedMs($upstreamStartedAt),
|
||||
'retry_ms' => $retryMs,
|
||||
'retry_attempted' => $retryAttempted,
|
||||
'delta_count' => $deltaCount,
|
||||
'delta_bytes' => $deltaBytes,
|
||||
'status' => $status,
|
||||
'error_code' => $status !== 'ok' ? self::getAssistantErrorCode() : '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return self::formatAssistantResult($prepared, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,6 +638,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$payload = [
|
||||
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
||||
'answer' => $content,
|
||||
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
|
||||
'ancient_book_sources' => is_array($result['ancient_book_sources'] ?? null) ? $result['ancient_book_sources'] : [],
|
||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||
'model_label' => (string) ($prepared['model_label'] ?? ''),
|
||||
'model_name' => (string) ($prepared['model_name'] ?? ''),
|
||||
@@ -783,48 +872,54 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$profile,
|
||||
self::buildUpstreamInputs(
|
||||
$context,
|
||||
'诊单结构化分析',
|
||||
self::ANALYSIS_PROMPT_VERSION
|
||||
),
|
||||
self::buildAnalysisPrompt($context),
|
||||
'admin-diagnosis-analysis-' . $adminId,
|
||||
is_array($context['files'] ?? null) ? $context['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('diagnosis ai analysis upstream call failed', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'admin_id' => $adminId,
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
self::setError('AI智能分析暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
$inputs = self::buildUpstreamInputs($context, '诊单结构化分析', self::ANALYSIS_PROMPT_VERSION);
|
||||
$prompt = self::buildAnalysisPrompt($context);
|
||||
$files = is_array($context['files'] ?? null) ? $context['files'] : [];
|
||||
$analysis = null;
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$profile,
|
||||
$inputs,
|
||||
$attempt === 0 ? $prompt : self::buildAnalysisRepairPrompt($prompt),
|
||||
'admin-diagnosis-analysis-' . $adminId,
|
||||
$files
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('diagnosis ai analysis upstream call failed', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'admin_id' => $adminId,
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
self::setError('AI智能分析暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
self::setError('AI智能分析暂时不可用,请稍后重试');
|
||||
return null;
|
||||
if (empty($result['ok'])) {
|
||||
self::setError('AI智能分析暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
$analysis = self::parseAnalysisResponse((string) ($result['content'] ?? ''));
|
||||
if ($analysis !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$analysis = self::parseAnalysisResponse((string) ($result['content'] ?? ''));
|
||||
if ($analysis === null) {
|
||||
self::setError('AI返回的分析结构不符合要求,请重试');
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_merge($analysis, [
|
||||
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
|
||||
'ancient_book_sources' => is_array($result['ancient_book_sources'] ?? null) ? $result['ancient_book_sources'] : [],
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'model_key' => $profile,
|
||||
'model_label' => $modelLabel,
|
||||
'model_name' => $modelName,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
|
||||
'context_version' => (string) ($context['context_version'] ?? self::ANALYSIS_PROMPT_VERSION),
|
||||
'context_version' => self::ANALYSIS_PROMPT_VERSION,
|
||||
'source_summary' => is_array($context['source_summary'] ?? null)
|
||||
? $context['source_summary']
|
||||
: [],
|
||||
@@ -938,7 +1033,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$modelLabel,
|
||||
$content,
|
||||
(string) ($result['message_id'] ?? ''),
|
||||
$adminId
|
||||
$adminId,
|
||||
is_array($result['ancient_book_sources'] ?? null) ? $result['ancient_book_sources'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('diagnosis ai report persist failed', [
|
||||
@@ -1035,7 +1131,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
||||
return [
|
||||
'diagnosis_id' => $id,
|
||||
'report' => self::formatReportRow($report->toArray(), $context['fingerprint']),
|
||||
'report' => self::formatReportRow($report->toArray(), $context['fingerprint'], $context),
|
||||
'can_edit' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT),
|
||||
'can_refresh' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH),
|
||||
];
|
||||
@@ -1442,6 +1538,13 @@ PROMPT;
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private static function buildAnalysisRepairPrompt(string $originalPrompt): string
|
||||
{
|
||||
return $originalPrompt . "\n\n格式校验提醒:请重新独立作答,只输出一个可解析的 JSON 对象,且仅有 diagnosis_advice、risk_assessment、treatment_advice 三个键。"
|
||||
. '前后不要解释、代码块或参考资料版本标识;两个建议字段各不超过 500 字;risk_assessment 最多 5 项,'
|
||||
. '每项仅有 label(不超过 80 字)与小写 level(high、medium、low)。资料不足时说明缺口,不能编造风险或处方。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 让患者纵向资料适配单次上游请求的体积上限。
|
||||
*
|
||||
@@ -1450,7 +1553,11 @@ PROMPT;
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
private static function fitContextForPrompt(array &$context, string $profile): bool
|
||||
private static function fitContextForPrompt(
|
||||
array &$context,
|
||||
string $profile,
|
||||
?AssistantSseTiming $timing = null
|
||||
): bool
|
||||
{
|
||||
$caseText = (string) ($context['case_text'] ?? '');
|
||||
if ($caseText === '' || strlen($caseText) <= self::MAX_PROMPT_SOURCE_BYTES) {
|
||||
@@ -1460,12 +1567,12 @@ PROMPT;
|
||||
try {
|
||||
$compacted = PatientAiReportLogic::compactSourceForPrompt($profile, $caseText);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('diagnosis ai context compaction failed', [
|
||||
'diagnosis_id' => (int) ($context['diagnosis_id'] ?? 0),
|
||||
'profile' => $profile,
|
||||
'source_bytes' => strlen($caseText),
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
if ($timing === null) {
|
||||
Log::warning('diagnosis ai context compaction failed', [
|
||||
'source_bytes' => strlen($caseText),
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
}
|
||||
self::setError('患者纵向资料过大,AI 分片读取失败,请稍后重试');
|
||||
return false;
|
||||
}
|
||||
@@ -1593,7 +1700,8 @@ PROMPT;
|
||||
string $modelLabel,
|
||||
string $content,
|
||||
string $messageId,
|
||||
int $adminId
|
||||
int $adminId,
|
||||
array $ancientBookSources = []
|
||||
): int {
|
||||
$now = time();
|
||||
$row = [
|
||||
@@ -1603,6 +1711,7 @@ PROMPT;
|
||||
'model_label' => self::cleanText($modelLabel, 50),
|
||||
'report_content' => $content,
|
||||
'message_id' => self::cleanText($messageId, 191),
|
||||
'ancient_book_sources_json' => json_encode(TcmAncientBooksReference::localizeSources($ancientBookSources), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
|
||||
'prompt_version' => self::PROMPT_VERSION,
|
||||
'case_fingerprint' => (string) $context['fingerprint'],
|
||||
'generated_by' => $adminId,
|
||||
@@ -1618,6 +1727,7 @@ PROMPT;
|
||||
'model_label',
|
||||
'report_content',
|
||||
'message_id',
|
||||
'ancient_book_sources_json',
|
||||
'prompt_version',
|
||||
'case_fingerprint',
|
||||
'generated_by',
|
||||
@@ -1658,7 +1768,8 @@ PROMPT;
|
||||
if (isset($rowsByModel[$modelKey])) {
|
||||
$reports[] = self::formatReportRow(
|
||||
$rowsByModel[$modelKey],
|
||||
(string) $context['fingerprint']
|
||||
(string) $context['fingerprint'],
|
||||
$context
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1691,11 +1802,14 @@ PROMPT;
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function formatReportRow(array $row, string $currentFingerprint): array
|
||||
private static function formatReportRow(array $row, string $currentFingerprint, array $context = []): array
|
||||
{
|
||||
$content = (string) ($row['report_content'] ?? '');
|
||||
$generatedTime = (int) ($row['generated_time'] ?? 0);
|
||||
$editedTime = (int) ($row['edited_time'] ?? 0);
|
||||
// The fingerprint includes the source summary and diagnosis IDs. Reuse
|
||||
// current provenance only while it still matches the saved report.
|
||||
$sameSources = hash_equals($currentFingerprint, (string) ($row['case_fingerprint'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
@@ -1706,13 +1820,16 @@ PROMPT;
|
||||
'content' => $content,
|
||||
'report' => self::parseReport($content),
|
||||
'message_id' => (string) ($row['message_id'] ?? ''),
|
||||
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSourceForPromptVersion((string) ($row['prompt_version'] ?? '')),
|
||||
'ancient_book_sources' => self::decodeAncientBookSources($row['ancient_book_sources_json'] ?? ''),
|
||||
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
||||
'case_fingerprint' => (string) ($row['case_fingerprint'] ?? ''),
|
||||
'prescription_fingerprint' => (string) ($row['case_fingerprint'] ?? ''),
|
||||
'is_stale' => !hash_equals(
|
||||
$currentFingerprint,
|
||||
(string) ($row['case_fingerprint'] ?? '')
|
||||
),
|
||||
'is_stale' => !$sameSources,
|
||||
'source_summary' => $sameSources && is_array($context['source_summary'] ?? null)
|
||||
? $context['source_summary'] : [],
|
||||
'source_diagnosis_ids' => $sameSources && is_array($context['source_diagnosis_ids'] ?? null)
|
||||
? array_values(array_map('intval', $context['source_diagnosis_ids'])) : [],
|
||||
'generated_by' => (int) ($row['generated_by'] ?? 0),
|
||||
'generated_time' => $generatedTime,
|
||||
'generated_at' => $generatedTime > 0 ? date('Y-m-d H:i:s', $generatedTime) : '',
|
||||
@@ -1723,6 +1840,14 @@ PROMPT;
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private static function decodeAncientBookSources(mixed $value): array
|
||||
{
|
||||
$decoded = is_string($value) ? json_decode($value, true) : null;
|
||||
return is_array($decoded) && array_is_list($decoded)
|
||||
? TcmAncientBooksReference::localizeSources($decoded) : [];
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
private static function modelConfigs(): array
|
||||
{
|
||||
@@ -1760,8 +1885,24 @@ PROMPT;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Qwen occasionally drops letters from this key while keeping the other
|
||||
// two contract keys intact. Accept only the two observed spellings, and
|
||||
// never choose between conflicting diagnosis fields.
|
||||
if (!array_key_exists('diagnosis_advice', $decoded)) {
|
||||
$aliases = array_intersect(['diagnosis_adive', 'diagnosis_ad'], array_keys($decoded));
|
||||
if (count($aliases) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$diagnosisField = $decoded[reset($aliases)];
|
||||
} else {
|
||||
if (array_key_exists('diagnosis_adive', $decoded) || array_key_exists('diagnosis_ad', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
$diagnosisField = $decoded['diagnosis_advice'];
|
||||
}
|
||||
|
||||
$diagnosisAdvice = self::validatedAnalysisText(
|
||||
$decoded['diagnosis_advice'] ?? null,
|
||||
$diagnosisField,
|
||||
self::MAX_ANALYSIS_ADVICE_LENGTH,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ use app\common\model\auth\AdminDept;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\model\tcm\PatientAiReport;
|
||||
use app\common\service\DifyChatService;
|
||||
use app\common\service\NihaixiaClinicalSkill;
|
||||
use app\common\service\TcmAncientBooksReference;
|
||||
use app\common\service\FileService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
@@ -41,7 +43,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
|
||||
private const PERMISSION_GENERATE = 'tcm.diagnosis/generatepatientaireport';
|
||||
|
||||
private const PROMPT_VERSION = 'patient-longitudinal-report-v2';
|
||||
private const PROMPT_VERSION = 'patient-longitudinal-report-nihaixia-ancient-v4';
|
||||
|
||||
/** @var array<int,string> */
|
||||
private const MODEL_KEYS = ['qwen', 'openai'];
|
||||
@@ -246,6 +248,8 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'source_snapshot' => $sourceJson,
|
||||
'source_summary_json' => self::encodeJson($sourceSummary),
|
||||
'source_diagnosis_ids_json' => self::encodeJson($diagnosisIds),
|
||||
'ancient_book_sources_json' => self::encodeJson(TcmAncientBooksReference::localizeSources(
|
||||
is_array($upstream['ancient_book_sources'] ?? null) ? $upstream['ancient_book_sources'] : [])),
|
||||
'source_hash' => $sourceHash,
|
||||
'prompt_version' => self::PROMPT_VERSION,
|
||||
'message_id' => self::cleanText($upstream['message_id'] ?? '', 191),
|
||||
@@ -276,6 +280,8 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'report' => $generatedReport,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'source_summary' => $generatedReport['source_summary'] ?? self::emptySourceSummary(),
|
||||
'source_diagnosis_ids' => $generatedReport['source_diagnosis_ids'] ?? [],
|
||||
'knowledge_source' => $generatedReport['knowledge_source'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -988,6 +994,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
$chunkCount = count($chunks);
|
||||
$inputs = self::sourceInputsForUpstream($snapshot['source_summary'] ?? []);
|
||||
$files = self::collectUpstreamFiles($snapshot);
|
||||
$ancientBookSources = [];
|
||||
|
||||
if ($chunkCount === 1) {
|
||||
$result = DifyChatService::chat(
|
||||
@@ -1025,6 +1032,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
||||
throw new \RuntimeException('Patient evidence chunk analysis failed');
|
||||
}
|
||||
$ancientBookSources = self::mergeAncientBookSources($ancientBookSources, $part);
|
||||
$summaries[] = [
|
||||
'part' => $index + 1,
|
||||
'total' => $chunkCount,
|
||||
@@ -1057,6 +1065,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
||||
throw new \RuntimeException('Patient evidence reduction failed');
|
||||
}
|
||||
$ancientBookSources = self::mergeAncientBookSources($ancientBookSources, $part);
|
||||
$reduced[] = [
|
||||
'part' => $index + 1,
|
||||
'total' => count($summaryChunks),
|
||||
@@ -1084,11 +1093,49 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'patient-longitudinal-report'
|
||||
);
|
||||
$result['analysis_chunk_count'] = $chunkCount;
|
||||
$result['ancient_book_sources'] = self::mergeAncientBookSources($ancientBookSources, $result);
|
||||
$result['analysis_reduction_rounds'] = $reductionRounds;
|
||||
$result['analyzed_source_bytes'] = strlen($sourceJson);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $accumulated
|
||||
* @param array<string,mixed> $response
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private static function mergeAncientBookSources(array $accumulated, array $response): array
|
||||
{
|
||||
$sources = $response['ancient_book_sources'] ?? [];
|
||||
if (!is_array($sources)) {
|
||||
return $accumulated;
|
||||
}
|
||||
$seen = [];
|
||||
foreach ($accumulated as $source) {
|
||||
$seen[self::ancientBookSourceKey($source)] = true;
|
||||
}
|
||||
foreach ($sources as $source) {
|
||||
if (!is_array($source)) {
|
||||
continue;
|
||||
}
|
||||
$key = self::ancientBookSourceKey($source);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$accumulated[] = $source;
|
||||
if (count($accumulated) >= 12) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return TcmAncientBooksReference::localizeSources($accumulated);
|
||||
}
|
||||
|
||||
private static function ancientBookSourceKey(array $source): string
|
||||
{
|
||||
return (string) ($source['commit'] ?? '') . "\0" . (string) ($source['path'] ?? '')
|
||||
. "\0" . (string) ($source['line'] ?? '');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $snapshot */
|
||||
private static function buildPrompt(array $snapshot): string
|
||||
{
|
||||
@@ -1427,6 +1474,8 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'report' => $generatedReport,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'source_summary' => is_array($newest) ? ($newest['source_summary'] ?? self::emptySourceSummary()) : self::emptySourceSummary(),
|
||||
'source_diagnosis_ids' => is_array($newest) ? ($newest['source_diagnosis_ids'] ?? []) : [],
|
||||
'knowledge_source' => is_array($newest) ? ($newest['knowledge_source'] ?? null) : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1465,6 +1514,10 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'source_hash' => (string) ($row['source_hash'] ?? ''),
|
||||
'source_summary' => $sourceSummary !== [] ? $sourceSummary : self::emptySourceSummary(),
|
||||
'source_diagnosis_ids' => self::decodeJsonArray($row['source_diagnosis_ids_json'] ?? ''),
|
||||
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSourceForPromptVersion((string) ($row['prompt_version'] ?? '')),
|
||||
'ancient_book_sources' => TcmAncientBooksReference::localizeSources(
|
||||
self::decodeJsonArray($row['ancient_book_sources_json'] ?? '')),
|
||||
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
||||
'version' => max((int) ($row['version'] ?? 1), 1),
|
||||
'generated_time' => $generatedTime,
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\service\NihaixiaClinicalSkill;
|
||||
use app\common\service\TcmAncientBooksReference;
|
||||
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
|
||||
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
||||
use app\common\service\prescriptionai\PrescriptionAiDoctorSnapshot as DoctorSnapshot;
|
||||
@@ -298,6 +300,9 @@ final class PrescriptionAiLogic
|
||||
}
|
||||
foreach ($results as $result) {
|
||||
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
||||
if ($full) {
|
||||
$body = self::localizeSourceMetadata($body);
|
||||
}
|
||||
unset($result['body_cipher']);
|
||||
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
||||
$result['herb_score'] = $result['herb_score'] === null ? null : (float) $result['herb_score'];
|
||||
@@ -321,6 +326,15 @@ final class PrescriptionAiLogic
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** Existing encrypted results may still contain repository links. */
|
||||
private static function localizeSourceMetadata(array $body): array
|
||||
{
|
||||
$body['knowledge_source'] = NihaixiaClinicalSkill::localizeKnowledgeSource($body['knowledge_source'] ?? null);
|
||||
$body['ancient_book_sources'] = TcmAncientBooksReference::localizeSources(
|
||||
is_array($body['ancient_book_sources'] ?? null) ? $body['ancient_book_sources'] : []);
|
||||
return $body;
|
||||
}
|
||||
|
||||
private static function formatBatch(array $batch, array $models, ?array $rx = null): array
|
||||
{
|
||||
$validity = $batch['validity'];
|
||||
|
||||
@@ -16,7 +16,7 @@ use think\facade\Log;
|
||||
*/
|
||||
class PrescriptionLibraryAiLogic extends BaseLogic
|
||||
{
|
||||
private const PROMPT_VERSION = 'rx-explain-v1';
|
||||
private const PROMPT_VERSION = 'rx-explain-nihaixia-v2';
|
||||
|
||||
private const MAX_REPORT_LENGTH = 12000;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user