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
@@ -10,6 +10,7 @@ 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 think\facade\Db;
use think\facade\Log;
@@ -21,11 +22,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-nihaixia-v3';
private const PROMPT_VERSION = 'patient-context-case-explain-nihaixia-ancient-v4';
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-nihaixia-v3';
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-nihaixia-ancient-v4';
private const ANALYSIS_PROMPT_VERSION = 'patient-context-analysis-nihaixia-v4';
private const ANALYSIS_PROMPT_VERSION = 'patient-context-analysis-nihaixia-ancient-v5';
private const MAX_ASSISTANT_PROMPT_LENGTH = 500;
@@ -419,6 +420,7 @@ class DiagnosisAiLogic extends BaseLogic
self::ASSISTANT_PROMPT_VERSION
),
'query' => self::buildAssistantPrompt($context, $task, $prompt),
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
'user' => 'admin-diagnosis-assistant-' . $adminId,
'admin_id' => $adminId,
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
@@ -559,6 +561,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'] ?? ''),
@@ -830,6 +834,8 @@ class DiagnosisAiLogic extends BaseLogic
}
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,
@@ -950,7 +956,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', [
@@ -1047,7 +1054,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),
];
@@ -1612,7 +1619,8 @@ PROMPT;
string $modelLabel,
string $content,
string $messageId,
int $adminId
int $adminId,
array $ancientBookSources = []
): int {
$now = time();
$row = [
@@ -1622,6 +1630,7 @@ PROMPT;
'model_label' => self::cleanText($modelLabel, 50),
'report_content' => $content,
'message_id' => self::cleanText($messageId, 191),
'ancient_book_sources_json' => json_encode($ancientBookSources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
'prompt_version' => self::PROMPT_VERSION,
'case_fingerprint' => (string) $context['fingerprint'],
'generated_by' => $adminId,
@@ -1637,6 +1646,7 @@ PROMPT;
'model_label',
'report_content',
'message_id',
'ancient_book_sources_json',
'prompt_version',
'case_fingerprint',
'generated_by',
@@ -1677,7 +1687,8 @@ PROMPT;
if (isset($rowsByModel[$modelKey])) {
$reports[] = self::formatReportRow(
$rowsByModel[$modelKey],
(string) $context['fingerprint']
(string) $context['fingerprint'],
$context
);
}
}
@@ -1710,11 +1721,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),
@@ -1725,13 +1739,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) : '',
@@ -1742,6 +1759,13 @@ 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) ? array_values(array_filter($decoded, 'is_array')) : [];
}
/** @return array<string,array<string,mixed>> */
private static function modelConfigs(): array
{
@@ -1779,8 +1803,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,7 @@ 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\FileService;
use think\facade\Db;
use think\facade\Log;
@@ -41,7 +42,7 @@ class PatientAiReportLogic extends BaseLogic
private const PERMISSION_GENERATE = 'tcm.diagnosis/generatepatientaireport';
private const PROMPT_VERSION = 'patient-longitudinal-report-nihaixia-v3';
private const PROMPT_VERSION = 'patient-longitudinal-report-nihaixia-ancient-v4';
/** @var array<int,string> */
private const MODEL_KEYS = ['qwen', 'openai'];
@@ -246,6 +247,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(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 +279,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 +993,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 +1031,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 +1064,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 +1092,39 @@ 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[(string) ($source['url'] ?? '')] = true;
}
foreach ($sources as $source) {
if (!is_array($source) || !is_string($source['url'] ?? null) || isset($seen[$source['url']])) {
continue;
}
$seen[$source['url']] = true;
$accumulated[] = $source;
if (count($accumulated) >= 12) {
break;
}
}
return $accumulated;
}
/** @param array<string,mixed> $snapshot */
private static function buildPrompt(array $snapshot): string
{
@@ -1427,6 +1463,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 +1503,9 @@ 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' => self::decodeJsonArray($row['ancient_book_sources_json'] ?? ''),
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
'version' => max((int) ($row['version'] ?? 1), 1),
'generated_time' => $generatedTime,