*/ private const MODEL_KEYS = ['qwen', 'openai']; /** @var array */ private const TEXT_REPORT_SECTIONS = [ '核心判断' => 'summary', '可能症状与证候' => 'possible_symptoms', '主治方向' => 'main_indications', '主要功效' => 'efficacy', '可能适用人群' => 'suitable_people', '配伍分析' => 'compatibility_analysis', '用药与复核提醒' => 'cautions', '免责声明' => 'disclaimer', ]; /** @var array */ private const TEXT_REPORT_LIST_FIELDS = [ 'possible_symptoms', 'efficacy', 'suitable_people', 'cautions', ]; /** * 读取已经持久化的报告,不调用 Dify。 * * @param array $adminInfo * @return array|null */ public static function getSavedReports(int $id, int $adminId, array $adminInfo): ?array { $prescription = self::loadAuthorizedPrescription( $id, $adminId, $adminInfo, self::PERMISSION_READ, '权限不足,无法查看处方 AI 解释' ); if ($prescription === null) { return null; } $context = self::buildPrescriptionContext($prescription); return self::buildReportsPayload($context, $adminId, $adminInfo); } /** * 返回当前管理员数据范围内尚无任何 AI 报告的有效处方,不调用 Dify。 * * @param array $adminInfo * @return array{total:int,items:array>}|null */ public static function getMissingReports( int $limit, int $adminId, array $adminInfo ): ?array { if (!self::hasPermission($adminId, $adminInfo, self::PERMISSION_MISSING)) { self::setError('权限不足,无法查看待生成 AI 报告的处方'); return null; } $limit = max(1, min(500, $limit)); $reportTable = Db::name('prescription_library_ai_report')->getTable(); $query = Db::name('prescription_library') ->alias('library') ->whereNull('library.delete_time') ->whereNotExists( "SELECT 1 FROM {$reportTable} AS ai_report " . 'WHERE ai_report.prescription_id = library.id' ); if (!PrescriptionLibraryLogic::canManageAllPrescriptions($adminId, $adminInfo)) { $query->where(function ($scope) use ($adminId) { $scope->where('library.creator_id', $adminId) ->whereOr('library.is_public', 1); }); } $total = (int) (clone $query)->count('library.id'); $rows = $query ->field([ 'library.id', 'library.prescription_name', 'library.formula_type', 'library.herbs', ]) ->order('library.id', 'asc') ->limit($limit) ->select() ->toArray(); $items = []; foreach ($rows as $row) { $herbs = $row['herbs'] ?? []; if (is_string($herbs)) { $herbs = json_decode($herbs, true); } $herbCount = is_array($herbs) ? count($herbs) : 0; $items[] = [ 'id' => (int) ($row['id'] ?? 0), 'prescription_name' => self::cleanText($row['prescription_name'] ?? '', 100), 'formula_type' => self::cleanText($row['formula_type'] ?? '', 20), 'herb_count' => $herbCount, ]; } return [ 'total' => $total, 'items' => $items, ]; } /** * 固定刷新 qwen/openai 两份报告;仅成功项 upsert,失败项保留旧内容。 * * @param array $adminInfo * @return array|null */ public static function generateAll(int $id, int $adminId, array $adminInfo): ?array { $prescription = self::loadAuthorizedPrescription( $id, $adminId, $adminInfo, self::PERMISSION_REFRESH, '权限不足,无法刷新处方 AI 解释' ); if ($prescription === null) { return null; } $context = self::buildPrescriptionContext($prescription); if ($context['herbs'] === []) { self::setError('该处方暂无有效药材,无法生成解释'); return null; } $modelConfigs = self::modelConfigs(); $results = []; $successCount = 0; $failureCount = 0; foreach (self::MODEL_KEYS as $modelKey) { $modelConfig = $modelConfigs[$modelKey] ?? []; $modelName = (string) ($modelConfig['name'] ?? $modelKey); $modelLabel = (string) ($modelConfig['label'] ?? $modelKey); $resultBase = [ 'model_key' => $modelKey, 'model_name' => $modelName, 'model_label' => $modelLabel, ]; try { $result = DifyChatService::chat( $modelKey, [ 'prescription_name' => $context['prescription_name'], 'formula_type' => $context['formula_type'], 'herbs_json' => $context['herbs_json'], 'prompt_version' => self::PROMPT_VERSION, ], self::buildPrompt($context), 'admin-prescription-' . $adminId ); } catch (\Throwable $e) { Log::warning('prescription ai upstream call failed', [ 'prescription_id' => $id, 'model_key' => $modelKey, 'admin_id' => $adminId, 'exception_class' => get_class($e), ]); $result = [ 'ok' => false, 'error_code' => 'UPSTREAM_EXCEPTION', 'error' => '模型调用异常,请稍后重试', 'latency_ms' => 0, ]; } if (empty($result['ok'])) { $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => (string) ($result['error_code'] ?? 'AI_ERROR'), 'error_message' => (string) ($result['error'] ?? '报告生成失败,请稍后重试'), 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); continue; } $content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true); if ($content === '') { $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => 'EMPTY_RESPONSE', 'error_message' => '模型未返回报告内容,请重试', 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); continue; } try { $reportId = self::upsertGeneratedReport( $context, $modelKey, $modelName, $modelLabel, $content, (string) ($result['message_id'] ?? ''), $adminId ); } catch (\Throwable $e) { Log::warning('prescription ai report persist failed', [ 'prescription_id' => $id, 'model_key' => $modelKey, 'admin_id' => $adminId, 'exception_class' => get_class($e), ]); $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => 'PERSIST_FAILED', 'error_message' => '报告已生成但保存失败,请稍后重试', 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); continue; } $successCount++; $results[] = array_merge($resultBase, [ 'report_id' => $reportId, 'status' => 'success', 'message_id' => (string) ($result['message_id'] ?? ''), 'prompt_version' => self::PROMPT_VERSION, 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); } $payload = self::buildReportsPayload($context, $adminId, $adminInfo); $payload['status'] = $successCount === count(self::MODEL_KEYS) ? 'success' : ($successCount > 0 ? 'partial' : 'error'); $payload['partial'] = $successCount > 0 && $failureCount > 0; $payload['success_count'] = $successCount; $payload['failure_count'] = $failureCount; $payload['results'] = $results; return $payload; } /** * 编辑一份报告。report_id 必须属于 id 对应且当前账号可查看的处方。 * * @param mixed $content * @param array $adminInfo * @return array|null */ public static function editReport( int $id, int $reportId, $content, int $adminId, array $adminInfo ): ?array { $prescription = self::loadAuthorizedPrescription( $id, $adminId, $adminInfo, self::PERMISSION_EDIT, '权限不足,无法编辑处方 AI 解释' ); if ($prescription === null) { return null; } if (!is_string($content)) { self::setError('报告内容格式错误'); return null; } $content = trim(str_replace("\0", '', strip_tags($content))); if ($content === '') { self::setError('报告内容不能为空'); return null; } if (mb_strlen($content) > self::MAX_REPORT_LENGTH) { self::setError('报告内容最多12000个字符'); return null; } $report = PrescriptionLibraryAiReport::where('id', $reportId) ->where('prescription_id', $id) ->findOrEmpty(); if ($report->isEmpty()) { self::setError('报告不存在或不属于当前处方'); return null; } $now = time(); $report->save([ 'report_content' => $content, 'edited_by' => $adminId, 'edited_time' => $now, 'update_time' => $now, ]); $context = self::buildPrescriptionContext($prescription); return [ 'prescription_id' => $id, 'report' => self::formatReportRow($report->toArray(), $context['fingerprint']), 'can_edit' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT), 'can_refresh' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH), ]; } /** * @param array $adminInfo */ private static function hasPermission( int $adminId, array $adminInfo, string $permission ): bool { if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { return true; } $uris = (new AdminAuthCache($adminId))->getAdminUri() ?? []; $uris = array_map( static fn ($uri): string => strtolower(trim((string) $uri)), is_array($uris) ? $uris : [] ); return in_array(strtolower($permission), $uris, true); } /** * @param array $adminInfo * @return array|null */ private static function loadAuthorizedPrescription( int $id, int $adminId, array $adminInfo, string $permission, string $permissionError ): ?array { if ($id <= 0) { self::setError('处方ID必须大于0'); return null; } if (!self::hasPermission($adminId, $adminInfo, $permission)) { self::setError($permissionError); return null; } $canManageAll = PrescriptionLibraryLogic::canManageAllPrescriptions($adminId, $adminInfo); $prescription = PrescriptionLibraryLogic::detail($id, $adminId, $canManageAll); if (!$prescription) { self::setError('处方不存在或无权限查看'); return null; } return $prescription; } /** * @param array $prescription * @return array */ private static function buildPrescriptionContext(array $prescription): array { $herbs = self::normalizeHerbs($prescription['herbs'] ?? []); $prescriptionName = self::cleanText($prescription['prescription_name'] ?? '未命名处方', 100); $formulaType = self::cleanText($prescription['formula_type'] ?? '主方', 20); $fingerprintPayload = [ 'prescription_name' => $prescriptionName, 'formula_type' => $formulaType, 'herbs' => $herbs, ]; $fingerprintJson = json_encode( $fingerprintPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) ?: '{}'; return [ 'prescription_id' => (int) ($prescription['id'] ?? 0), 'prescription_name' => $prescriptionName, 'formula_type' => $formulaType, 'herbs' => $herbs, 'herbs_json' => json_encode( $herbs, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) ?: '[]', 'fingerprint' => hash('sha256', $fingerprintJson), 'prescription_updated_at' => (string) ($prescription['update_time'] ?? ''), ]; } /** @param array $context */ private static function buildPrompt(array $context): string { $herbLine = implode('、', array_map( static fn (array $herb): string => $herb['name'] . ' ' . $herb['dosage'] . $herb['unit'], $context['herbs'] )); return << $context */ private static function upsertGeneratedReport( array $context, string $modelKey, string $modelName, string $modelLabel, string $content, string $messageId, int $adminId ): int { $now = time(); $row = [ 'prescription_id' => (int) $context['prescription_id'], 'model_key' => $modelKey, 'model_name' => self::cleanText($modelName, 100), 'model_label' => self::cleanText($modelLabel, 50), 'report_content' => $content, 'message_id' => self::cleanText($messageId, 191), 'prompt_version' => self::PROMPT_VERSION, 'prescription_fingerprint' => (string) $context['fingerprint'], 'generated_by' => $adminId, 'generated_time' => $now, 'edited_by' => 0, 'edited_time' => 0, 'create_time' => $now, 'update_time' => $now, ]; Db::name('prescription_library_ai_report')->duplicate([ 'model_name', 'model_label', 'report_content', 'message_id', 'prompt_version', 'prescription_fingerprint', 'generated_by', 'generated_time', 'edited_by', 'edited_time', 'update_time', ])->insert($row); return (int) Db::name('prescription_library_ai_report') ->where('prescription_id', (int) $context['prescription_id']) ->where('model_key', $modelKey) ->value('id'); } /** * @param array $context * @param array $adminInfo * @return array */ private static function buildReportsPayload(array $context, int $adminId, array $adminInfo): array { $rows = PrescriptionLibraryAiReport::where( 'prescription_id', (int) $context['prescription_id'] )->order('id', 'asc')->select()->toArray(); $rowsByModel = []; foreach ($rows as $row) { $modelKey = (string) ($row['model_key'] ?? ''); if (in_array($modelKey, self::MODEL_KEYS, true)) { $rowsByModel[$modelKey] = $row; } } $reports = []; foreach (self::MODEL_KEYS as $modelKey) { if (isset($rowsByModel[$modelKey])) { $reports[] = self::formatReportRow( $rowsByModel[$modelKey], (string) $context['fingerprint'] ); } } $canView = self::hasPermission($adminId, $adminInfo, self::PERMISSION_READ); $canRefresh = self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH); $canEdit = self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT); return [ 'prescription_id' => (int) $context['prescription_id'], 'prescription_name' => (string) $context['prescription_name'], 'formula_type' => (string) $context['formula_type'], 'prescription_updated_at' => (string) $context['prescription_updated_at'], 'prescription_fingerprint' => (string) $context['fingerprint'], 'prompt_version' => self::PROMPT_VERSION, 'reports' => $reports, 'missing_model_keys' => array_values(array_diff(self::MODEL_KEYS, array_keys($rowsByModel))), 'can_view' => $canView, 'can_refresh' => $canRefresh, 'can_edit' => $canEdit, 'capabilities' => [ 'can_view' => $canView, 'can_refresh' => $canRefresh, 'can_edit' => $canEdit, ], ]; } /** * @param array $row * @return array */ private static function formatReportRow(array $row, string $currentFingerprint): array { $content = (string) ($row['report_content'] ?? ''); $generatedTime = (int) ($row['generated_time'] ?? 0); $editedTime = (int) ($row['edited_time'] ?? 0); return [ 'id' => (int) ($row['id'] ?? 0), 'report_id' => (int) ($row['id'] ?? 0), 'model_key' => (string) ($row['model_key'] ?? ''), 'model_name' => (string) ($row['model_name'] ?? ''), 'model_label' => (string) ($row['model_label'] ?? ''), 'content' => $content, 'report' => self::parseReport($content), 'message_id' => (string) ($row['message_id'] ?? ''), 'prompt_version' => (string) ($row['prompt_version'] ?? ''), 'prescription_fingerprint' => (string) ($row['prescription_fingerprint'] ?? ''), 'is_stale' => !hash_equals( $currentFingerprint, (string) ($row['prescription_fingerprint'] ?? '') ), 'generated_by' => (int) ($row['generated_by'] ?? 0), 'generated_time' => $generatedTime, 'generated_at' => $generatedTime > 0 ? date('Y-m-d H:i:s', $generatedTime) : '', 'edited_by' => (int) ($row['edited_by'] ?? 0), 'edited_time' => $editedTime, 'edited_at' => $editedTime > 0 ? date('Y-m-d H:i:s', $editedTime) : '', 'is_edited' => $editedTime > 0, ]; } /** @return array> */ private static function modelConfigs(): array { $config = config('prescription_ai') ?: []; return is_array($config['models'] ?? null) ? $config['models'] : []; } /** * @param mixed $herbs * @return array */ private static function normalizeHerbs($herbs): array { if (!is_array($herbs)) { return []; } $normalized = []; foreach (array_slice($herbs, 0, 80) as $herb) { if (!is_array($herb)) { continue; } $name = self::cleanText($herb['name'] ?? '', 50); $dosage = is_numeric($herb['dosage'] ?? null) ? (float) $herb['dosage'] : 0.0; if ($name === '' || $dosage <= 0) { continue; } $normalized[] = [ 'medicine_id' => (int) ($herb['medicine_id'] ?? 0), 'name' => $name, 'dosage' => rtrim(rtrim(number_format($dosage, 2, '.', ''), '0'), '.'), 'unit' => self::cleanText($herb['unit'] ?? 'g', 10) ?: 'g', ]; } return $normalized; } /** @return array|null */ private static function parseReport(string $content): ?array { $textCandidate = trim($content); $candidate = $textCandidate; $candidate = preg_replace('/^```(?:json)?\s*|\s*```$/iu', '', $candidate) ?? $candidate; $start = strpos($candidate, '{'); $end = strrpos($candidate, '}'); if ($start !== false && $end !== false && $end >= $start) { $candidate = substr($candidate, $start, $end - $start + 1); } $decoded = json_decode($candidate, true); if (!is_array($decoded)) { $decoded = self::parseStructuredTextReport($textCandidate); } if (!is_array($decoded)) { return null; } $report = [ 'summary' => self::cleanText($decoded['summary'] ?? '', 500), 'possible_symptoms' => self::cleanList($decoded['possible_symptoms'] ?? []), 'main_indications' => self::cleanText($decoded['main_indications'] ?? '', 800), 'efficacy' => self::cleanList($decoded['efficacy'] ?? []), 'suitable_people' => self::cleanList($decoded['suitable_people'] ?? []), 'compatibility_analysis' => self::cleanText($decoded['compatibility_analysis'] ?? '', 1500), 'cautions' => self::cleanList($decoded['cautions'] ?? []), 'disclaimer' => self::cleanText( $decoded['disclaimer'] ?? '仅供专业人员辅助审方,不替代辨证、诊断和处方审核。', 500 ), ]; $hasContent = $report['summary'] !== '' || $report['main_indications'] !== '' || $report['efficacy'] !== [] || $report['possible_symptoms'] !== []; return $hasContent ? $report : null; } /** * 兼容旧前端 structuredReportToText 保存的固定八章节纯文本。 * 标题必须完整且顺序一致,避免把任意自由文本误识别为结构化报告。 * * @return array|null */ private static function parseStructuredTextReport(string $content): ?array { $content = preg_replace('/^\x{FEFF}/u', '', trim($content)) ?? trim($content); if ($content === '') { return null; } $lines = preg_split('/\R/u', $content) ?: []; $expectedTitles = array_keys(self::TEXT_REPORT_SECTIONS); $sections = array_fill_keys($expectedTitles, []); $seenTitles = []; $currentTitle = null; foreach ($lines as $line) { $trimmed = trim((string) $line); $possibleTitle = preg_replace('/[::]\s*$/u', '', $trimmed) ?? $trimmed; if (array_key_exists($possibleTitle, self::TEXT_REPORT_SECTIONS)) { $expectedTitle = $expectedTitles[count($seenTitles)] ?? null; if ($possibleTitle !== $expectedTitle || isset($seenTitles[$possibleTitle])) { return null; } $seenTitles[$possibleTitle] = true; $currentTitle = $possibleTitle; continue; } if ($currentTitle === null) { if ($trimmed !== '') { return null; } continue; } $sections[$currentTitle][] = (string) $line; } if (array_keys($seenTitles) !== $expectedTitles) { return null; } $decoded = []; foreach (self::TEXT_REPORT_SECTIONS as $title => $field) { $sectionLines = $sections[$title]; if (in_array($field, self::TEXT_REPORT_LIST_FIELDS, true)) { $decoded[$field] = self::parseStructuredTextList($sectionLines); continue; } $value = trim(implode("\n", $sectionLines)); $decoded[$field] = $value === '暂无' ? '' : $value; } return $decoded; } /** * @param array $lines * @return array */ private static function parseStructuredTextList(array $lines): array { $items = []; foreach ($lines as $line) { $item = trim((string) $line); if ($item === '' || $item === '暂无' || $item === '-' || $item === '•') { continue; } $item = preg_replace('/^(?:-\s+|•\s*)/u', '', $item) ?? $item; $item = trim($item); if ($item !== '' && $item !== '暂无') { $items[] = $item; } } return $items; } /** * @param mixed $value * @return array */ private static function cleanList($value): array { if (is_string($value) && trim($value) !== '') { $value = preg_split('/[\r\n;;]+/u', $value) ?: []; } if (!is_array($value)) { return []; } $items = []; foreach (array_slice($value, 0, 10) as $item) { $text = self::cleanText($item, 300); if ($text !== '') { $items[] = $text; } } return $items; } /** @param mixed $value */ private static function cleanText($value, int $maxLength, bool $preserveLines = false): string { if (!is_scalar($value)) { return ''; } $text = trim((string) $value); if (!$preserveLines) { $text = preg_replace('/\s+/u', ' ', $text) ?? $text; } return mb_substr($text, 0, $maxLength); } }