diff --git a/.env.example b/.env.example index 9d868cda..10d31a04 100644 --- a/.env.example +++ b/.env.example @@ -79,7 +79,12 @@ VITE_APP_NAME="${APP_NAME}" BASE_OLD_API_URL=http://www.xk888.com BASE_OLD_SHOP_API_URL=https://shop.xiaokang88.com -# 密钥 +# 密钥(库内敏感字段 AES,前缀 nl_ase_256_) ENCRYPT_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a # JWT HS256 密钥(至少 32 字符;生产环境务必改成随机长串) JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b + +# AI 供应商环境回落(库内未配置密钥时可用) +AI_DEFAULT_PROVIDER=deepseek +SPARK_API_PASSWORD= +DEEPSEEK_API_KEY= diff --git a/app/Core/DatabaseEncryptor.php b/app/Core/DatabaseEncryptor.php new file mode 100644 index 00000000..2c480378 --- /dev/null +++ b/app/Core/DatabaseEncryptor.php @@ -0,0 +1,128 @@ +secretKey = hash('sha256', $key, true); + $ivLength = openssl_cipher_iv_length($this->cipherMethod); + if ($ivLength === false) { + throw new RuntimeException('不支持的加密算法:' . $this->cipherMethod); + } + $this->ivLength = $ivLength; + } + + /** + * 加密后入库;空串不加密,已是密文则原样返回避免二次加密 + * + * @param string|null $data 明文 + * @throws Exception + */ + public function encrypt($data): string + { + if ($data === null || $data === '') { + return ''; + } + $data = (string) $data; + if (self::isEncrypted($data)) { + return $data; + } + $iv = openssl_random_pseudo_bytes($this->ivLength); + if ($iv === false) { + throw new RuntimeException('生成 IV 失败'); + } + $encryptedData = openssl_encrypt($data, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv); + if ($encryptedData === false) { + throw new RuntimeException('加密失败:' . (openssl_error_string() ?: 'unknown')); + } + return self::PREFIX . base64_encode($iv . $encryptedData); + } + + /** + * 从库内密文解密;无已知前缀视为明文原样返回 + * + * @param string|null $base64EncodedEncryptedData 库内值 + * @throws Exception + */ + public function decrypt($base64EncodedEncryptedData): string + { + if ($base64EncodedEncryptedData === null || $base64EncodedEncryptedData === '') { + return ''; + } + $base64EncodedEncryptedData = (string) $base64EncodedEncryptedData; + $prefix = self::detectPrefix($base64EncodedEncryptedData); + if ($prefix === null) { + return $base64EncodedEncryptedData; + } + $payload = substr($base64EncodedEncryptedData, strlen($prefix)); + $decodedData = base64_decode($payload, true); + if ($decodedData === false) { + throw new RuntimeException('Base64 解码失败'); + } + if (strlen($decodedData) <= $this->ivLength) { + throw new RuntimeException('密文长度非法'); + } + $iv = substr($decodedData, 0, $this->ivLength); + $encryptedData = substr($decodedData, $this->ivLength); + $decryptedData = openssl_decrypt($encryptedData, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv); + if ($decryptedData === false) { + throw new RuntimeException('解密失败:' . (openssl_error_string() ?: 'unknown')); + } + return $decryptedData; + } + + /** + * 是否已是库内密文(含历史前缀) + */ + public static function isEncrypted(?string $value): bool + { + return self::detectPrefix($value) !== null; + } + + /** + * 识别密文前缀;新前缀优先 + */ + public static function detectPrefix(?string $value): ?string + { + if (!is_string($value) || $value === '') { + return null; + } + if (str_starts_with($value, self::PREFIX)) { + return self::PREFIX; + } + if (str_starts_with($value, self::LEGACY_PREFIX)) { + return self::LEGACY_PREFIX; + } + return null; + } +} diff --git a/app/Enum/AiGenerationSceneEnum.php b/app/Enum/AiGenerationSceneEnum.php new file mode 100644 index 00000000..56fda16d --- /dev/null +++ b/app/Enum/AiGenerationSceneEnum.php @@ -0,0 +1,22 @@ + '代码生成', + self::REQUIREMENT_PROMPT => '需求概览/提示词', + }; + } +} diff --git a/app/Enum/AiGenerationStatusEnum.php b/app/Enum/AiGenerationStatusEnum.php new file mode 100644 index 00000000..0df91132 --- /dev/null +++ b/app/Enum/AiGenerationStatusEnum.php @@ -0,0 +1,22 @@ + '进行中', + self::SUCCESS => '成功', + self::FAIL => '失败', + }; + } +} diff --git a/app/Http/Controllers/Api/AiConfigController.php b/app/Http/Controllers/Api/AiConfigController.php new file mode 100644 index 00000000..5301a068 --- /dev/null +++ b/app/Http/Controllers/Api/AiConfigController.php @@ -0,0 +1,168 @@ +service = AiConfigService::getInstance(); + } + + /** + * 禁用基类通用列表(本模块用 keyList) + * @Method NO + */ + public function list(): JsonResponse + { + return jerr('不支持'); + } + + /** + * @Method NO + */ + public function option(): mixed + { + return jerr('不支持'); + } + + /** + * @Method NO + */ + public function detail(): JsonResponse + { + return jerr('不支持'); + } + + /** + * @Method NO + */ + public function create(): JsonResponse + { + return jerr('不支持'); + } + + /** + * @Method NO + */ + public function update(): JsonResponse + { + return jerr('不支持'); + } + + /** + * @Method NO + */ + public function delete(): JsonResponse + { + return jerr('不支持'); + } + + /** + * 获取 AI 管理页选项(平台/模型/密钥卡片数据 + 当前激活项) + * @Method GET + */ + public function runtimeOptions(): JsonResponse + { + return jok($this->service->getRuntimeOptions(), '获取成功'); + } + + /** + * 保存默认 AI 平台/模型/密钥组合 + * @Method POST + */ + public function saveRuntime(): JsonResponse + { + $params = request()->post(); + return jok($this->service->saveRuntime($params), '保存成功'); + } + + /** + * 密钥列表(卡片展示) + * @Method GET + */ + public function keyList(): JsonResponse + { + return jok($this->service->listKeys(), '获取成功'); + } + + /** + * 平台下拉 + * @Method GET + */ + public function platformOption(): JsonResponse + { + return jok($this->service->platformOptions(), '获取成功'); + } + + /** + * 新增密钥 + * @Method POST + */ + public function createKey(): JsonResponse + { + $params = request()->post(); + return jok($this->service->createKey($params), '创建成功'); + } + + /** + * 更新密钥 + * @Method POST + */ + public function updateKey(): JsonResponse + { + $params = request()->post(); + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + return jerr('参数错误'); + } + unset($params['id']); + return jok($this->service->updateKey($id, $params), '更新成功'); + } + + /** + * 删除密钥 + * @Method POST + */ + public function deleteKey(): JsonResponse + { + $ids = request()->post('ids'); + if (empty($ids)) { + return jerr('参数错误'); + } + if (!is_array($ids)) { + $ids = [$ids]; + } + return jok($this->service->deleteKeys($ids), '删除成功'); + } + + /** + * AI 生成历史列表 + * @Method GET + */ + public function generationList(): JsonResponse + { + return jok($this->service->generationList(), '获取成功'); + } + + /** + * AI 生成历史详情 + * @Method GET + */ + public function generationDetail(): JsonResponse + { + $id = (int) request()->get('id', 0); + if ($id <= 0) { + return jerr('参数错误'); + } + return jok($this->service->generationDetail($id), '获取成功'); + } +} diff --git a/app/Http/Controllers/core/CodeGenerationController.php b/app/Http/Controllers/core/CodeGenerationController.php index 410bacb0..83e30d62 100644 --- a/app/Http/Controllers/core/CodeGenerationController.php +++ b/app/Http/Controllers/core/CodeGenerationController.php @@ -3,8 +3,9 @@ namespace App\Http\Controllers\core; use App\BaseApp\BaseController; +use App\Service\common\ai\AiCodeGenService; +use App\Service\common\ai\AiRequirementPromptService; use App\Service\core\CodeGenerationService; -use App\Service\LoginService; use Illuminate\Http\JsonResponse; class CodeGenerationController extends BaseController @@ -156,8 +157,34 @@ class CodeGenerationController extends BaseController } $codeGenData = $this->service->convertTableToCodeGenData($tableName, $className, $classComment, $icon, $sort, $pid); - + return jok($codeGenData); } + /** + * AI 提示词生成代码结构(调用工厂 Agent,返回可灌入表单的 JSON) + * @Method POST + */ + public function aiGenerate(): JsonResponse + { + $params = request()->post(); + return jok( + AiCodeGenService::getInstance()->generate($params), + 'AI 生成成功' + ); + } + + /** + * 根据模块名生成 / 优化需求概览与代码生成提示词(写入生成历史,供前端替换导入) + * @Method POST + */ + public function aiRequirementPrompt(): JsonResponse + { + $params = request()->post(); + return jok( + AiRequirementPromptService::getInstance()->generate($params), + '需求概览生成成功' + ); + } + } diff --git a/app/Models/SystemConfigModel.php b/app/Models/SystemConfigModel.php new file mode 100644 index 00000000..d645c0a0 --- /dev/null +++ b/app/Models/SystemConfigModel.php @@ -0,0 +1,15 @@ +model = AiApiKeyModel::class; + $this->selectField = ['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'created_at', 'updated_at']; + $this->queryField = [ + 'platform_id' => '=', + 'name' => 'like', + 'status' => '=', + ]; + $this->orderBy = [ + 'name' => 'sort', + 'sort' => 'desc', + ]; + } + + /** + * 获取 AI 管理页选项(平台卡片 + 当前激活组合) + */ + public function getRuntimeOptions(): array + { + return AiRuntimeConfigService::getInstance()->optionsForConfig(); + } + + /** + * 保存默认平台/模型/密钥 + */ + public function saveRuntime(array $params): array + { + $provider = (string) ($params['provider'] ?? ''); + $model = (string) ($params['model'] ?? ''); + $apiKeyId = (int) ($params['api_key_id'] ?? 0); + return AiRuntimeConfigService::getInstance()->saveActiveConfig($provider, $model, $apiKeyId); + } + + /** + * 密钥列表(卡片展示用,不返回明文密钥) + * 直接查表,避免单例 where 被 update 污染导致列表为空 + */ + public function listKeys(): array + { + $items = AiApiKeyModel::where('deleted_at', 0) + ->orderByDesc('sort') + ->orderByDesc('id') + ->get(['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'api_key', 'created_at', 'updated_at']) + ->toArray(); + $platforms = AiPlatformModel::where('deleted_at', 0) + ->get(['id', 'code', 'name', 'logo']) + ->keyBy('id') + ->toArray(); + foreach ($items as &$item) { + $pid = (int) ($item['platform_id'] ?? 0); + $item['platform'] = $platforms[$pid] ?? null; + $item['has_key'] = trim((string) ($item['api_key'] ?? '')) !== ''; + unset($item['api_key']); + } + unset($item); + return [ + 'page' => 1, + 'size' => count($items), + 'page_count' => 1, + 'total' => count($items), + 'items' => $items, + ]; + } + + /** + * 平台下拉(密钥表单用) + */ + public function platformOptions(): array + { + return AiPlatformModel::where('deleted_at', 0) + ->where('status', 1) + ->orderByDesc('sort') + ->orderBy('id') + ->get(['id', 'code', 'name', 'logo']) + ->toArray(); + } + + /** + * 新增密钥(明文入库前加密) + */ + public function createKey(array $params): mixed + { + $platformId = (int) ($params['platform_id'] ?? 0); + $name = trim((string) ($params['name'] ?? '')); + $apiKey = trim((string) ($params['api_key'] ?? '')); + if ($platformId <= 0) { + $this->utils->errorThrow('请选择所属平台'); + } + if ($name === '') { + $this->utils->errorThrow('请填写密钥别名'); + } + if ($apiKey === '') { + $this->utils->errorThrow('请填写 API Key'); + } + $platform = AiPlatformModel::where('id', $platformId) + ->where('deleted_at', 0) + ->first(); + if (!$platform) { + $this->utils->errorThrow('平台不存在'); + } + $isDefault = (int) ($params['is_default'] ?? 0); + if ($isDefault === 1) { + // 同平台只保留一个默认密钥 + AiApiKeyModel::where('platform_id', $platformId) + ->where('deleted_at', 0) + ->update(['is_default' => 0, 'updated_at' => time()]); + } + return $this->insert([ + 'platform_id' => $platformId, + 'name' => $name, + 'api_key' => FieldEncryptService::getInstance()->encryptForStorage($apiKey), + 'remark' => trim((string) ($params['remark'] ?? '')), + 'is_default' => $isDefault, + 'status' => (int) ($params['status'] ?? 1), + 'sort' => (int) ($params['sort'] ?? 0), + 'created_at' => time(), + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + } + + /** + * 更新密钥;api_key 为空表示不改密钥本身 + */ + public function updateKey(int $id, array $params): mixed + { + $info = AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->first(); + if (!$info) { + $this->utils->notFound('密钥不存在'); + } + $data = []; + if (array_key_exists('name', $params)) { + $name = trim((string) $params['name']); + if ($name === '') { + $this->utils->errorThrow('密钥别名不能为空'); + } + $data['name'] = $name; + } + if (array_key_exists('remark', $params)) { + $data['remark'] = trim((string) $params['remark']); + } + if (array_key_exists('status', $params)) { + $data['status'] = (int) $params['status']; + } + if (array_key_exists('sort', $params)) { + $data['sort'] = (int) $params['sort']; + } + if (array_key_exists('platform_id', $params)) { + $platformId = (int) $params['platform_id']; + if ($platformId <= 0) { + $this->utils->errorThrow('请选择所属平台'); + } + $data['platform_id'] = $platformId; + } + $platformId = (int) ($data['platform_id'] ?? $info->platform_id); + if (array_key_exists('is_default', $params)) { + $isDefault = (int) $params['is_default']; + $data['is_default'] = $isDefault; + if ($isDefault === 1) { + AiApiKeyModel::where('platform_id', $platformId) + ->where('deleted_at', 0) + ->where('id', '<>', $id) + ->update(['is_default' => 0, 'updated_at' => time()]); + } + } + $apiKey = trim((string) ($params['api_key'] ?? '')); + if ($apiKey !== '') { + $data['api_key'] = FieldEncryptService::getInstance()->encryptForStorage($apiKey); + } + if (empty($data)) { + return true; + } + $data['updated_at'] = time(); + // 直接 update,避免 BaseService::save 污染单例 where 导致后续列表为空 + return AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->update($data); + } + + /** + * 软删除密钥 + */ + public function deleteKeys(array $ids): mixed + { + $ids = array_values(array_filter(array_map('intval', $ids))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + return AiApiKeyModel::whereIn('id', $ids)->where('deleted_at', 0)->update([ + 'deleted_at' => time(), + 'updated_at' => time(), + ]); + } + + /** + * AI 生成历史列表 + */ + public function generationList(): array + { + return AiGenerationLogService::getInstance()->getPageListForAdmin(); + } + + /** + * AI 生成历史详情 + */ + public function generationDetail(int $id): array + { + return AiGenerationLogService::getInstance()->getDetailForAdmin($id); + } +} diff --git a/app/Service/MenuService.php b/app/Service/MenuService.php index e0ba5420..f0601357 100755 --- a/app/Service/MenuService.php +++ b/app/Service/MenuService.php @@ -9,7 +9,7 @@ use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; /** - * 角色服务类 + * 菜单服务:列表树、搜索(含子菜单)、下拉 */ class MenuService extends BaseService { @@ -29,29 +29,122 @@ class MenuService extends BaseService } /** - * 获取列表 + * 获取菜单列表(扁平树,前端 transform) + * 有 title 搜索时:命中节点 + 祖先 + 子孙,保证子菜单可搜到并完整展示路径 + * 无搜索时:仍按根菜单分页,再挂两级子节点 + * * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ public function list() { - // /order/product-order + $title = trim((string) request()->get('title', '')); + if ($title !== '') { + return $this->listByTitleSearch($title); + } + $this->where[] = ['pid', '=', 0]; $list = $this->getPageList(); $ids = array_column($list['items'], 'id'); - $items = $this->model::whereIn('pid', $ids)->get($this->selectField); + if (empty($ids)) { + return $list; + } + $items = $this->model::where('deleted_at', 0)->whereIn('pid', $ids)->orderBy('sort')->get($this->selectField); $childrenIds = []; foreach ($items as $v) { $list['items'][] = $v; $childrenIds[] = $v['id']; } - $items = $this->model::whereIn('pid', $childrenIds)->get($this->selectField); - foreach ($items as $v) { - $list['items'][] = $v; + if (!empty($childrenIds)) { + $items = $this->model::where('deleted_at', 0)->whereIn('pid', $childrenIds)->orderBy('sort')->get($this->selectField); + foreach ($items as $v) { + $list['items'][] = $v; + } } return $list; } + /** + * 按标题模糊搜索:返回命中项及其祖先、子孙,供树表正确拼装 + * 为什么要带祖先:仅返回子节点时 pid 找不到父级,树会丢节点或挂到根外 + */ + private function listByTitleSearch(string $title): array + { + $all = $this->model::where('deleted_at', 0) + ->orderBy('sort') + ->get($this->selectField) + ->keyBy('id'); + if ($all->isEmpty()) { + return $this->emptyPageList(); + } + + $matchedIds = []; + foreach ($all as $id => $row) { + $rowTitle = (string) ($row['title'] ?? ''); + if ($rowTitle !== '' && mb_stripos($rowTitle, $title) !== false) { + $matchedIds[] = (int) $id; + } + } + if (empty($matchedIds)) { + return $this->emptyPageList(); + } + + // 子节点按 pid 分组,便于向下展开子孙 + $childrenMap = []; + foreach ($all as $id => $row) { + $pid = (int) ($row['pid'] ?? 0); + $childrenMap[$pid][] = (int) $id; + } + + $needIds = []; + foreach ($matchedIds as $mid) { + // 向上:自身 + 祖先 + $cur = $mid; + while ($cur > 0 && $all->has($cur) && !isset($needIds[$cur])) { + $needIds[$cur] = true; + $cur = (int) ($all[$cur]['pid'] ?? 0); + } + // 向下:命中节点的全部子孙(搜父级时仍能看到子树) + $stack = [$mid]; + while (!empty($stack)) { + $nodeId = array_pop($stack); + foreach ($childrenMap[$nodeId] ?? [] as $cid) { + if (!isset($needIds[$cid])) { + $needIds[$cid] = true; + $stack[] = $cid; + } + } + } + } + + $items = []; + foreach ($all as $id => $row) { + if (isset($needIds[(int) $id])) { + $items[] = $row->toArray(); + } + } + + return [ + 'items' => $items, + 'page' => 1, + 'size' => count($items), + 'total' => count($items), + ]; + } + + /** + * 空分页结构(与 getPageList 对齐) + */ + private function emptyPageList(): array + { + return [ + 'items' => [], + 'page' => 1, + 'size' => 0, + 'total' => 0, + ]; + } + /** * 获取树形结构的菜单下拉 * @param bool $isSelect @@ -119,7 +212,6 @@ class MenuService extends BaseService * @param $id * @param $params * @return mixed - * @throws Exception */ public function update($id, $params): mixed { @@ -131,15 +223,11 @@ class MenuService extends BaseService /** * 删除数据 - * @param $id - * @return mixed|true - * @throws Exception + * @param $ids + * @return mixed */ - public function delete($id): mixed + public function delete($ids): mixed { - if (in_array($id, [1, 2])) { - $this->utils->errorThrow('管理员角色禁止删除'); - } - return $this->del($id); + return $this->del($ids); } } diff --git a/app/Service/SystemConfigService.php b/app/Service/SystemConfigService.php new file mode 100644 index 00000000..1483213b --- /dev/null +++ b/app/Service/SystemConfigService.php @@ -0,0 +1,92 @@ +isAuth = false; + parent::__construct(); + $this->model = SystemConfigModel::class; + } + + /** + * 读取配置值;不存在时返回默认值 + * 为什么:业务侧只关心 value,不关心行结构 + */ + public function getValue(string $key, mixed $default = null): mixed + { + $key = trim($key); + if ($key === '') { + return $default; + } + $row = SystemConfigModel::where('key', $key) + ->where('deleted_at', 0) + ->first(); + if (!$row) { + return $default; + } + $value = $row->value; + if ($value === null) { + return $default; + } + return $value; + } + + /** + * 写入/更新配置;存在则更新,不存在则插入 + * 为什么用 upsert:配置项数量少,按 key 幂等更稳 + */ + public function setValue(string $key, mixed $value, string $remark = ''): bool + { + $key = trim($key); + if ($key === '') { + $this->utils->errorThrow('配置键不能为空'); + } + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE); + } + $value = (string) $value; + $now = time(); + $row = SystemConfigModel::where('key', $key) + ->where('deleted_at', 0) + ->first(); + if ($row) { + $row->value = $value; + if ($remark !== '') { + $row->remark = $remark; + } + $row->updated_at = $now; + return (bool) $row->save(); + } + return (bool) SystemConfigModel::insert([ + 'key' => $key, + 'value' => $value, + 'remark' => $remark, + 'created_at' => $now, + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + } + + /** + * 批量写入配置 + * + * @param array $pairs key => value + */ + public function setValues(array $pairs): bool + { + foreach ($pairs as $key => $value) { + $this->setValue((string) $key, $value); + } + return true; + } +} diff --git a/app/Service/common/FieldEncryptService.php b/app/Service/common/FieldEncryptService.php new file mode 100644 index 00000000..8bcd0265 --- /dev/null +++ b/app/Service/common/FieldEncryptService.php @@ -0,0 +1,72 @@ +encryptor === null) { + $key = (string) config('nl.encrypt_key', ''); + if ($key === '') { + $this->utils->errorThrow('未配置 ENCRYPT_KEY,无法加解密敏感字段'); + } + $this->encryptor = new DatabaseEncryptor($key); + } + return $this->encryptor; + } + + /** + * 入库前加密:明文 → nl_ase_256_... + * + * @param string|null $plain 明文 + */ + public function encryptForStorage(?string $plain): string + { + try { + return $this->encryptor()->encrypt($plain); + } catch (\Throwable $e) { + $this->utils->errorThrow('敏感字段加密失败:' . $e->getMessage()); + } + return ''; + } + + /** + * 读取后解密:nl_ase_256_ / 历史 *** → 明文 + * + * @param string|null $cipher 库内值 + * @param bool $silentFail true 时解密失败返回空串;false 抛业务异常 + */ + public function decryptFromStorage(?string $cipher, bool $silentFail = false): string + { + try { + return $this->encryptor()->decrypt($cipher); + } catch (\Throwable $e) { + if ($silentFail) { + return ''; + } + $this->utils->errorThrow('敏感字段解密失败:' . $e->getMessage()); + } + return ''; + } + + /** + * 是否已是库内密文 + */ + public function isEncrypted(?string $value): bool + { + return DatabaseEncryptor::isEncrypted($value); + } +} diff --git a/app/Service/common/ai/AiAgentFactory.php b/app/Service/common/ai/AiAgentFactory.php new file mode 100644 index 00000000..2270dbcc --- /dev/null +++ b/app/Service/common/ai/AiAgentFactory.php @@ -0,0 +1,41 @@ +isAuth = false; + parent::__construct(); + } + + /** + * 获取 Agent 实例 + * + * @param string|null $provider 为空则用系统配置 ai_active_provider / env 默认 + */ + public function make(?string $provider = null): AiAgentInterface + { + $resolved = AiRuntimeConfigService::getInstance()->resolve($provider); + $name = strtolower(trim((string) ($resolved['provider'] ?? self::PROVIDER_DEEPSEEK))); + if ($name === self::PROVIDER_DEEPSEEK) { + return DeepSeekAiAgent::getInstance(); + } + if ($name === self::PROVIDER_SPARK) { + return SparkAiAgent::getInstance(); + } + $this->utils->errorThrow('不支持的 AI 供应商:' . $name); + return SparkAiAgent::getInstance(); + } +} diff --git a/app/Service/common/ai/AiAgentInterface.php b/app/Service/common/ai/AiAgentInterface.php new file mode 100644 index 00000000..931b7b8b --- /dev/null +++ b/app/Service/common/ai/AiAgentInterface.php @@ -0,0 +1,23 @@ +'system|user|assistant','content'=>'...'], ...] + * @param array $options 可选覆盖:model / temperature / max_tokens 等 + */ + public function chatCompletions(array $messages, array $options = []): AiChatResult; + + /** + * 供应商标识(spark / deepseek) + */ + public function provider(): string; +} diff --git a/app/Service/common/ai/AiChatResult.php b/app/Service/common/ai/AiChatResult.php new file mode 100644 index 00000000..3add58d5 --- /dev/null +++ b/app/Service/common/ai/AiChatResult.php @@ -0,0 +1,66 @@ +content = $content; + $this->raw = $raw; + $this->provider = $provider; + $this->model = $model; + $this->usage = $usage; + $this->apiKeyId = $apiKeyId; + } + + /** + * 转为数组(落库 / 接口返回用) + */ + public function toArray(): array + { + return [ + 'content' => $this->content, + 'provider' => $this->provider, + 'model' => $this->model, + 'usage' => $this->usage, + 'api_key_id' => $this->apiKeyId, + ]; + } +} diff --git a/app/Service/common/ai/AiCodeGenService.php b/app/Service/common/ai/AiCodeGenService.php new file mode 100644 index 00000000..62acc139 --- /dev/null +++ b/app/Service/common/ai/AiCodeGenService.php @@ -0,0 +1,261 @@ +utils->errorThrow('请输入模块名'); + } + if ($description === '') { + $this->utils->errorThrow('请输入功能描述'); + } + + $runtime = AiRuntimeConfigService::getInstance()->resolve(); + $log = AiGenerationLogService::getInstance(); + $generationId = $log->begin([ + 'scene' => AiGenerationSceneEnum::CODE_GENERATION->value, + 'name' => $moduleName, + 'provider' => (string) ($runtime['provider'] ?? ''), + 'model' => (string) ($runtime['model'] ?? ''), + 'api_key_id' => (int) ($runtime['api_key_id'] ?? 0), + 'admin_id' => $this->userId, + 'input_snapshot' => [ + 'module_name' => $moduleName, + 'description' => $description, + 'pid' => $pid, + ], + ]); + + try { + $agent = AiAgentFactory::getInstance()->make(); + $messages = [ + ['role' => 'system', 'content' => $this->buildSystemPrompt()], + ['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $pid)], + ]; + $result = $agent->chatCompletions($messages, [ + 'temperature' => 0.2, + 'max_tokens' => 4096, + ]); + $parsed = $this->parseJsonContent($result->content); + // 强制回填用户选择的父级菜单,避免模型改写 pid + $parsed['pid'] = $pid; + if (empty($parsed['class_comment'])) { + $parsed['class_comment'] = $moduleName; + } + if (empty($parsed['icon'])) { + $parsed['icon'] = 'lucide:box'; + } + if (!isset($parsed['sort']) || $parsed['sort'] === '') { + $parsed['sort'] = 9999; + } + $this->validateResult($parsed); + $log->markSuccess($generationId, [ + 'provider' => $result->provider, + 'model' => $result->model, + 'api_key_id' => $result->apiKeyId, + 'usage' => $result->usage, + 'result_json' => $parsed, + 'raw_response' => $result->content, + 'name' => (string) ($parsed['class_comment'] ?? $moduleName), + ]); + return $parsed; + } catch (Throwable $e) { + $log->markFail($generationId, $e->getMessage(), [ + 'provider' => (string) ($runtime['provider'] ?? ''), + 'model' => (string) ($runtime['model'] ?? ''), + 'api_key_id' => (int) ($runtime['api_key_id'] ?? 0), + ]); + throw $e; + } + } + + /** + * 系统提示词:用「完整示例」约束输出,避免模型把说明文字当成字段值 + * 为什么不用 schema 占位描述:模型常把 "snake_case 字段名,不要 id..." 原样抄进 name,导致建表 SQL 语法错误 + */ + private function buildSystemPrompt(): string + { + return <<<'PROMPT' +你是 nl-admin 后台代码生成助手。根据用户的模块名与功能描述,输出【仅一个】合法 JSON 对象。 +禁止输出 markdown、代码围栏、解释文字。禁止把规则说明文字当作字段值。 + +【输出示例】(请按同样结构填写真实业务字段,不要照抄示例字段名): +{ + "class_name": "VipMember", + "class_comment": "VIP会员", + "icon": "lucide:crown", + "sort": 9999, + "pid": 0, + "field": [ + { + "name": "title", + "type": "varchar", + "type_length": "64", + "default": "", + "comment": "标题", + "not_null": true, + "formShow": true, + "tableShow": true, + "formType": "VbenInput", + "search": true, + "searchValue": "like" + }, + { + "name": "level", + "type": "tinyint", + "type_length": "1", + "default": "0", + "comment": "等级", + "not_null": true, + "formShow": true, + "tableShow": true, + "formType": "InputNumber", + "search": true, + "searchValue": "=" + }, + { + "name": "remark", + "type": "varchar", + "type_length": "255", + "default": "", + "comment": "备注", + "not_null": false, + "formShow": true, + "tableShow": false, + "formType": "Textarea", + "search": false, + "searchValue": "=" + } + ] +} + +硬性规则: +1. class_name:仅英文 PascalCase(如 User、OrderItem、VipMember),禁止中文、空格、下划线、连字符。 +2. field[].name:仅小写 snake_case(如 user_name、phone),禁止中文、空格、说明性长句。 +3. 禁止生成这些字段名:id、status、created_at、updated_at、deleted_at(系统自动加)。 +4. type 只能是:varchar、int、tinyint、text、decimal、bigint、char、json。 +5. formType 只能是:VbenInput、Textarea、InputNumber、VbenSelect、Upload、Avatar。 +6. searchValue 只能是:=、like、>、<、between。 +7. field 至少 2 个、最多 20 个业务字段;按描述合理推断,不要编造无关字段。 +8. 只输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }。 +PROMPT; + } + + /** + * 用户提示词:带上模块上下文 + */ + private function buildUserPrompt(string $moduleName, string $description, int $pid): string + { + return "模块名:{$moduleName}\n功能描述:{$description}\n父级菜单ID:{$pid}\n请按系统示例结构输出 JSON(填真实业务字段,不要照抄示例)。"; + } + + /** + * 从模型回复中提取 JSON(兼容偶发的 ```json 包裹) + */ + private function parseJsonContent(string $content): array + { + $content = trim($content); + if ($content === '') { + $this->utils->errorThrow('AI 未返回内容'); + } + if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $content, $m)) { + $content = trim($m[1]); + } + $start = strpos($content, '{'); + $end = strrpos($content, '}'); + if ($start === false || $end === false || $end <= $start) { + $this->utils->errorThrow('AI 返回不是有效 JSON'); + } + $json = substr($content, $start, $end - $start + 1); + $data = json_decode($json, true); + if (!is_array($data)) { + $this->utils->errorThrow('AI 返回 JSON 解析失败'); + } + return $data; + } + + /** + * 严格校验 AI 结果,避免脏数据进入建表 SQL + * 为什么要严:模型偶发把提示词说明抄进 name/class_name,会导致 CREATE TABLE 语法错误 + */ + private function validateResult(array $data): void + { + $className = trim((string) ($data['class_name'] ?? '')); + if ($className === '') { + $this->utils->errorThrow('AI 未生成有效类名'); + } + if (!preg_match('/^[A-Z][A-Za-z0-9]{0,63}$/', $className)) { + $this->utils->errorThrow("类名非法「{$className}」,须为英文 PascalCase(如 VipMember)"); + } + if (empty($data['field']) || !is_array($data['field'])) { + $this->utils->errorThrow('AI 未生成字段列表'); + } + if (count($data['field']) < 1) { + $this->utils->errorThrow('AI 字段列表为空'); + } + + $names = []; + foreach ($data['field'] as $i => $field) { + if (!is_array($field)) { + $this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项格式错误'); + } + $name = trim((string) ($field['name'] ?? '')); + $type = strtolower(trim((string) ($field['type'] ?? ''))); + if ($name === '') { + $this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项缺少 name'); + } + // 过滤模型把整段说明塞进 name 的情况 + if (mb_strlen($name) > 64 || preg_match('/\s|,|。|:|:|\||(|)|\(|\)/u', $name)) { + $this->utils->errorThrow("字段名非法「{$name}」,须为短小写 snake_case"); + } + if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $name)) { + $this->utils->errorThrow("字段名非法「{$name}」,须为小写 snake_case(如 user_name)"); + } + if (in_array($name, self::RESERVED_FIELDS, true)) { + $this->utils->errorThrow("字段「{$name}」为系统保留字段,请勿生成"); + } + if (!in_array($type, self::ALLOWED_TYPES, true)) { + $this->utils->errorThrow("字段「{$name}」类型非法「{$type}」"); + } + if (isset($names[$name])) { + $this->utils->errorThrow("字段名重复「{$name}」"); + } + $names[$name] = true; + } + } +} diff --git a/app/Service/common/ai/AiDisclaimerService.php b/app/Service/common/ai/AiDisclaimerService.php new file mode 100644 index 00000000..aa024cae --- /dev/null +++ b/app/Service/common/ai/AiDisclaimerService.php @@ -0,0 +1,197 @@ +orderByDesc('sort') + ->orderBy('id') + ->get() + ->toArray(); + $nameMap = VipFeatureModel::where('deleted_at', 0) + ->whereIn('code', array_column($rows, 'feature_code')) + ->get(['code', 'name']) + ->keyBy('code') + ->toArray(); + foreach ($rows as &$row) { + $code = (string) ($row['feature_code'] ?? ''); + $row['feature_name'] = $nameMap[$code]['name'] ?? $code; + $row['disclaimer_text'] = (string) ($row['disclaimer_text'] ?? ''); + $row['consent_text'] = (string) ($row['consent_text'] ?? ''); + } + unset($row); + return $rows; + } + + /** + * 批量保存文案(系统配置 AI 模型 Tab) + * + * @param list $items + */ + public function saveCopies(array $items): bool + { + if (empty($items)) { + $this->utils->errorThrow('无保存内容'); + } + $now = time(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $id = (int) ($item['id'] ?? 0); + $code = trim((string) ($item['feature_code'] ?? '')); + $row = null; + if ($id > 0) { + $row = AiFeatureCopyModel::where('id', $id)->where('deleted_at', 0)->first(); + } + if (!$row && $code !== '') { + $row = AiFeatureCopyModel::where('feature_code', $code)->where('deleted_at', 0)->first(); + } + if (!$row) { + continue; + } + if (array_key_exists('record_label', $item)) { + $row->record_label = trim((string) $item['record_label']); + } + if (array_key_exists('disclaimer_text', $item)) { + $row->disclaimer_text = (string) $item['disclaimer_text']; + } + if (array_key_exists('consent_text', $item)) { + $row->consent_text = (string) $item['consent_text']; + } + $row->updated_at = $now; + $row->save(); + } + return true; + } + + /** + * 按功能码取文案;不存在则回落内置默认 + */ + public function getCopyByFeature(string $featureCode): array + { + $featureCode = trim($featureCode); + if ($featureCode === '') { + $this->utils->errorThrow('功能码不能为空'); + } + $row = AiFeatureCopyModel::where('feature_code', $featureCode) + ->where('deleted_at', 0) + ->where('status', 1) + ->first(); + if ($row) { + return [ + 'id' => (int) $row->id, + 'feature_code' => (string) $row->feature_code, + 'record_label' => (string) ($row->record_label ?: $this->defaultRecordLabel($featureCode)), + 'disclaimer_text' => (string) ($row->disclaimer_text ?: $this->defaultDisclaimer($featureCode)), + 'consent_text' => (string) ($row->consent_text ?: $this->defaultConsent()), + ]; + } + return [ + 'id' => 0, + 'feature_code' => $featureCode, + 'record_label' => $this->defaultRecordLabel($featureCode), + 'disclaimer_text' => $this->defaultDisclaimer($featureCode), + 'consent_text' => $this->defaultConsent(), + ]; + } + + /** + * 门闸:文案 + 是否已签署(按医生+功能码) + */ + public function getGate(string $featureCode, int $doctorId): array + { + if ($doctorId <= 0) { + $this->utils->errorThrow('无法识别医生身份'); + } + $copy = $this->getCopyByFeature($featureCode); + $consented = AiDoctorConsentModel::where('doctor_id', $doctorId) + ->where('feature_code', $featureCode) + ->where('deleted_at', 0) + ->exists(); + return array_merge($copy, [ + 'consented' => $consented, + ]); + } + + /** + * 医生签署知情同意(同一功能幂等) + */ + public function agree(string $featureCode, int $doctorId, int $storeId = 0): array + { + if ($doctorId <= 0) { + $this->utils->errorThrow('无法识别医生身份'); + } + $featureCode = trim($featureCode); + if ($featureCode === '') { + $this->utils->errorThrow('功能码不能为空'); + } + $copy = $this->getCopyByFeature($featureCode); + $exists = AiDoctorConsentModel::where('doctor_id', $doctorId) + ->where('feature_code', $featureCode) + ->where('deleted_at', 0) + ->first(); + if ($exists) { + return [ + 'consented' => true, + 'feature_code' => $featureCode, + 'already' => true, + ]; + } + $now = time(); + AiDoctorConsentModel::query()->insert([ + 'doctor_id' => $doctorId, + 'feature_code' => $featureCode, + 'copy_id' => (int) ($copy['id'] ?? 0), + 'store_id' => $storeId, + 'created_at' => $now, + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + return [ + 'consented' => true, + 'feature_code' => $featureCode, + 'already' => false, + ]; + } + + private function defaultRecordLabel(string $featureCode): string + { + return $featureCode === self::FEATURE_RX ? '处方' : '病历'; + } + + private function defaultDisclaimer(string $featureCode): string + { + $label = $this->defaultRecordLabel($featureCode); + return '本' . $label . '由 AI 根据问诊/语音转写内容辅助生成,仅供起草参考。' + . "\nAI 可能存在漏记、误读或“幻觉”,不能替代医师专业判断。" + . "\n诊断、处置、用药、知情告知等关键内容须由你逐条核对、修改后确认。"; + } + + private function defaultConsent(): string + { + return "我已知悉:\n" + . "1. 本平台 AI 仅用于病历起草、信息归纳、术语规范化;\n" + . "2. 不得用于自动诊断、自动开嘱、自动出报告;\n" + . "3. 所有 AI 输出须由具备资质的医师审核、修改、电子签名;\n" + . "4. 因未审核直接使用 AI 内容导致的医疗差错,由操作医师及医疗机构承担责任。"; + } +} diff --git a/app/Service/common/ai/AiGenerationLogService.php b/app/Service/common/ai/AiGenerationLogService.php new file mode 100644 index 00000000..452e5781 --- /dev/null +++ b/app/Service/common/ai/AiGenerationLogService.php @@ -0,0 +1,230 @@ +value)); + $adminId = (int) ($params['admin_id'] ?? $this->userId); + return (int) AiGenerationModel::insertGetId([ + 'admin_id' => $adminId, + 'scene' => $scene, + 'name' => mb_substr(trim((string) ($params['name'] ?? '')), 0, 128), + 'provider' => trim((string) ($params['provider'] ?? '')), + 'model' => trim((string) ($params['model'] ?? '')), + 'status' => AiGenerationStatusEnum::RUNNING->value, + 'api_key_id' => (int) ($params['api_key_id'] ?? 0), + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'total_tokens' => 0, + 'usage_json' => null, + 'started_at' => $now, + 'finished_at' => 0, + 'duration_ms' => 0, + 'error_msg' => '', + 'input_snapshot' => $this->encodeJson($params['input_snapshot'] ?? null), + 'result_json' => null, + 'raw_response' => null, + 'created_at' => $now, + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + } + + /** + * 标记成功并写入结果 / token + * + * @param array{ + * provider?:string, + * model?:string, + * api_key_id?:int, + * usage?:array, + * result_json?:array|null, + * raw_response?:string|null, + * name?:string + * } $payload + */ + public function markSuccess(int $id, array $payload = []): bool + { + if ($id <= 0) { + return false; + } + $row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first(); + if (!$row) { + return false; + } + $usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : []; + $finishedAt = time(); + $startedAt = (int) ($row->getRawOriginal('started_at') ?? 0); + $data = [ + 'status' => AiGenerationStatusEnum::SUCCESS->value, + 'finished_at' => $finishedAt, + 'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt), + 'prompt_tokens' => (int) ($usage['prompt_tokens'] ?? 0), + 'completion_tokens' => (int) ($usage['completion_tokens'] ?? 0), + 'total_tokens' => (int) ($usage['total_tokens'] ?? 0), + 'usage_json' => $this->encodeJson($usage ?: null), + 'result_json' => $this->encodeJson($payload['result_json'] ?? null), + 'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000), + 'error_msg' => '', + 'updated_at' => $finishedAt, + ]; + if (!empty($payload['provider'])) { + $data['provider'] = (string) $payload['provider']; + } + if (!empty($payload['model'])) { + $data['model'] = (string) $payload['model']; + } + if (array_key_exists('api_key_id', $payload)) { + $data['api_key_id'] = (int) $payload['api_key_id']; + } + if (!empty($payload['name'])) { + $data['name'] = mb_substr(trim((string) $payload['name']), 0, 128); + } + return (bool) AiGenerationModel::where('id', $id)->update($data); + } + + /** + * 标记失败 + */ + public function markFail(int $id, string $errorMsg, array $payload = []): bool + { + if ($id <= 0) { + return false; + } + $row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first(); + if (!$row) { + return false; + } + $finishedAt = time(); + $startedAt = (int) ($row->getRawOriginal('started_at') ?? 0); + $data = [ + 'status' => AiGenerationStatusEnum::FAIL->value, + 'finished_at' => $finishedAt, + 'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt), + 'error_msg' => mb_substr(trim($errorMsg), 0, 1000), + 'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000), + 'updated_at' => $finishedAt, + ]; + if (!empty($payload['provider'])) { + $data['provider'] = (string) $payload['provider']; + } + if (!empty($payload['model'])) { + $data['model'] = (string) $payload['model']; + } + if (array_key_exists('api_key_id', $payload)) { + $data['api_key_id'] = (int) $payload['api_key_id']; + } + $usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : []; + if (!empty($usage)) { + $data['prompt_tokens'] = (int) ($usage['prompt_tokens'] ?? 0); + $data['completion_tokens'] = (int) ($usage['completion_tokens'] ?? 0); + $data['total_tokens'] = (int) ($usage['total_tokens'] ?? 0); + $data['usage_json'] = $this->encodeJson($usage); + } + return (bool) AiGenerationModel::where('id', $id)->update($data); + } + + /** + * 分页列表(管理端生成记录) + */ + public function getPageListForAdmin(): array + { + $this->model = AiGenerationModel::class; + $this->selectField = [ + 'id', 'admin_id', 'scene', 'name', 'provider', 'model', 'status', + 'api_key_id', 'prompt_tokens', 'completion_tokens', 'total_tokens', + 'started_at', 'finished_at', 'duration_ms', 'error_msg', 'created_at', + ]; + $this->queryField = [ + 'scene' => '=', + 'status' => '=', + 'provider' => '=', + 'name' => 'like', + ]; + $this->orderBy = [ + 'name' => 'id', + 'sort' => 'desc', + ]; + // 重置 where,避免单例污染 + $this->where = [['deleted_at', '=', 0]]; + $list = $this->getPageList(); + foreach ($list['items'] as &$item) { + $status = (int) ($item['status'] ?? 0); + $scene = (string) ($item['scene'] ?? ''); + $item['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知'; + $item['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene; + } + unset($item); + return $list; + } + + /** + * 详情(含 input/result,不含超大 raw 时可按需) + */ + public function getDetailForAdmin(int $id): array + { + $row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first(); + if (!$row) { + $this->utils->notFound('记录不存在'); + } + $data = $row->toArray(); + $status = (int) ($data['status'] ?? 0); + $scene = (string) ($data['scene'] ?? ''); + $data['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知'; + $data['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene; + return $data; + } + + private function calcDurationMs(int $startedAt, int $finishedAt): int + { + if ($startedAt <= 0 || $finishedAt <= 0 || $finishedAt < $startedAt) { + return 0; + } + return ($finishedAt - $startedAt) * 1000; + } + + private function encodeJson(mixed $value): ?string + { + if ($value === null) { + return null; + } + if (is_string($value)) { + return $value; + } + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + + private function clipText(string $text, int $max): string + { + if ($text === '' || mb_strlen($text) <= $max) { + return $text; + } + return mb_substr($text, 0, $max); + } +} diff --git a/app/Service/common/ai/AiMedicalAssistService.php b/app/Service/common/ai/AiMedicalAssistService.php new file mode 100644 index 00000000..b1a58935 --- /dev/null +++ b/app/Service/common/ai/AiMedicalAssistService.php @@ -0,0 +1,3019 @@ + + */ + private function resolveMrOutputFields(): array + { + return AiRuntimeConfigService::getInstance()->getMedicalRecordOutputFields(); + } + + /** + * 中医三典 prompt 约束:只提示按「中医临床诊疗术语」填写,不塞字典样本(省 token) + * 治法限定为中药内服相关,避免模型返回针灸/推拿等外治名称 + * 后端仍用本地字典校正名称并映射 diseases_id / method_id / syndrome_id + * + * @param list $needAiFields + */ + private function buildTcmDictPromptHint(array $needAiFields): string + { + $need = array_intersect(['tcm_disease', 'tcm_syndrome', 'tcm_method'], $needAiFields); + if ($need === []) { + return ''; + } + $parts = []; + if (in_array('tcm_syndrome', $need, true)) { + $parts[] = 'tcm_syndrome(中医证候)'; + } + if (in_array('tcm_method', $need, true)) { + $parts[] = 'tcm_method(中医治法)'; + } + if (in_array('tcm_disease', $need, true)) { + $parts[] = 'tcm_disease(中医疾病)'; + } + $hint = '以下字段必须严格采用国家标准《中医临床诊疗术语》中的证候、治法、疾病标准名称填写(可多项用中文逗号分隔,禁止自造俗称):' + . implode('、', $parts) . '。'; + // 本系统用于中药开方场景:治法须是药物内治,不能填针灸推拿等外治 + if (in_array('tcm_method', $need, true)) { + $hint .= 'tcm_method 只能填中药内服相关治法(如消食导滞、健脾益气、温阳利水、活血化瘀、疏肝和胃等),' + . '且必须与本次 tcm_disease、tcm_syndrome 辨证一致,禁止随便套用与证候无关的治法(如食积胃痛却写疏风清热);' + . '禁止填写针灸疗法、针刺、艾灸、推拿、按摩、刮痧、拔罐、理疗、外敷、熏洗等非药物/外治名称。'; + } + return $hint; + } + + /** + * 临床诊断 prompt:强制从 ICD-10 医保版 2.0 / 国临版标准库取病名 + * 不塞全量字典(省 token);后端再用 yii_disease 校正为系统标准名 + * 额外强调:诊断须紧扣主诉,禁止把既往慢病/无关病种写成首诊、禁止臆造分型 + * + * @param list $needAiFields + */ + private function buildIcdDiagnosisPromptHint(array $needAiFields): string + { + if (! in_array('diagnosis', $needAiFields, true)) { + return ''; + } + return 'diagnosis(临床诊断)必须从以下两个国家标准疾病诊断库中选取正式病名(可多项,用中文逗号分隔),' + . '须与主诉、现病史及患者年龄性别相符,禁止自造俗称、口语或非标准缩写:' + . '① ICD-10 医保版 2.0(国家医保局《医保疾病诊断分类与代码》,医院 HIS、医保结算单实际使用);' + . '② ICD-10 国临版(原国家卫健委《疾病分类与代码》GB/T 14396 体系,国标版 2016,含 x001 这类扩展后缀编码)。' + . '推荐写法:每项「ICD编码 标准病名」(编码与名称之间一个空格,例如 J06.900x001 急性上呼吸道感染);' + . '编码优先写医保版或国临版中与临床最贴切的一条;若同一诊断两库名称略有差异,以标准病名为准并带上对应编码。' + . '禁止只写症状描述代替诊断,禁止输出未收录于上述两库的随意名称。' + . '诊断排序与取舍必须谨慎:第一诊断必须直接解释本次主诉;' + . '现病史/既往史中已明确的慢病可作后续诊断,但不得替代、覆盖与主诉无关的首诊;' + . '证据不足以分型时禁止擅自改写分型(如把「2型糖尿病」写成「1型糖尿病」),禁止臆造主诉未支持的病种。'; + } + + /** + * 写病历整体谨慎约束:避免诊断、治法、医嘱与主诉脱节或互相矛盾 + * 针对模型易把病史慢病当成本次主诊、中医三典自相矛盾等问题 + * + * @param list $needAiFields + */ + private function buildMedicalRecordCautionPromptHint(array $needAiFields): string + { + $need = array_flip($needAiFields); + $parts = [ + '【临床谨慎原则】本次生成是医生草稿辅助,必须保守、可复核,禁止脑补与主诉无关的重病或跨系统诊断。', + '信息优先级:主诉 > 现病史中与主诉相关的描述 > 体征/辅助检查 > 既往史/个人史/家族史(后者仅作背景,不得喧宾夺主)。', + ]; + if (isset($need['diagnosis']) || isset($need['tcm_disease']) || isset($need['tcm_syndrome']) || isset($need['tcm_method'])) { + $parts[] = '中西医诊断均须紧扣主诉对应的本次就诊问题;病史里的慢病若需写入诊断,只能作为次要/伴随诊断,且原文分型、病名不得擅自改写。'; + } + if (isset($need['tcm_disease']) || isset($need['tcm_syndrome']) || isset($need['tcm_method'])) { + $parts[] = '中医病、证、治三者必须同一辨证体系内自洽:' + . 'tcm_disease 填病名、tcm_syndrome 填证候、tcm_method 填与该证候对应的内治法;' + . '禁止病/证/治错位或互不相关(例如食积类证候却配疏风清热)。'; + } + if (isset($need['treatment_advice']) || isset($need['doctor_order'])) { + $parts[] = 'treatment_advice、doctor_order 必须围绕「主诉相关的第一诊断/证候」给出,' + . '禁止只围绕无关慢病写降糖、复查血糖等与本次主诉脱节的医嘱。'; + } + $parts[] = '证据不足时选最贴近主诉的常见、保守表述,明确不确定处用审慎措辞;禁止为凑字数编造阳性检查或未提及的症状。'; + return implode('', $parts); + } + + /** + * 读取模型原始 created_at 时间戳 + * BaseModel 访问器会把 int 格式化成「Y-m-d H:i:s」,再 (int) 会变成年份(如 2026),故必须读 raw + */ + private function rawCreatedAtTimestamp(object $row): int + { + if (method_exists($row, 'getRawOriginal')) { + $raw = $row->getRawOriginal('created_at'); + if ($raw !== null && $raw !== '') { + return (int) $raw; + } + } + $attrs = method_exists($row, 'getAttributes') ? $row->getAttributes() : []; + return (int) ($attrs['created_at'] ?? 0); + } + + /** + * 将 AI 返回的中医三典名称校正为字典标准名,并映射开方旧字段 id + * 映射与开方 UI 一致:证候→diseases_id,疾病→syndrome_id,治法→method_id + * + * @param array $fields + * @return array{fields: array, tcm_ids: array{diseases_id:int|null,method_id:int|null,syndrome_id:int|null}} + */ + private function normalizeTcmFieldsAgainstDict(array $fields): array + { + $dict = DoctorReceptionService::getInstance()->getTraditionalChineseMedicineAll(); + $diseases = $dict['diseases'] ?? []; + $method = $dict['method'] ?? []; + $syndrome = $dict['syndrome'] ?? []; + $tcmIds = [ + 'diseases_id' => null, + 'method_id' => null, + 'syndrome_id' => null, + ]; + if (!empty($fields['tcm_syndrome'])) { + // 证候 → diseases 表(开方 diseases_id) + $hit = $this->matchTcmDictRow($diseases, (string) $fields['tcm_syndrome']); + if ($hit) { + $fields['tcm_syndrome'] = (string) ($hit['name'] ?? $fields['tcm_syndrome']); + $tcmIds['diseases_id'] = isset($hit['id']) ? (int) $hit['id'] : null; + } + } + if (!empty($fields['tcm_method'])) { + $hit = $this->matchTcmDictRow($method, (string) $fields['tcm_method']); + if ($hit) { + $fields['tcm_method'] = (string) ($hit['name'] ?? $fields['tcm_method']); + $tcmIds['method_id'] = isset($hit['id']) ? (int) $hit['id'] : null; + } + } + if (!empty($fields['tcm_disease'])) { + // 疾病 → syndrome 表(开方 syndrome_id) + $hit = $this->matchTcmDictRow($syndrome, (string) $fields['tcm_disease']); + if ($hit) { + $fields['tcm_disease'] = (string) ($hit['name'] ?? $fields['tcm_disease']); + $tcmIds['syndrome_id'] = isset($hit['id']) ? (int) $hit['id'] : null; + } + } + return ['fields' => $fields, 'tcm_ids' => $tcmIds]; + } + + /** + * 名称/别名匹配字典行(取逗号分隔第一项) + * + * @param list $rows + */ + private function matchTcmDictRow(array $rows, string $raw): ?array + { + $first = trim(explode(',', str_replace(',', ',', $raw))[0] ?? ''); + if ($first === '') { + return null; + } + foreach ($rows as $row) { + $name = trim((string) ($row['name'] ?? '')); + if ($name !== '' && $name === $first) { + return $row; + } + } + foreach ($rows as $row) { + $alias = (string) ($row['alias'] ?? ''); + if ($alias === '') { + continue; + } + $parts = preg_split('/[,,]/', $alias) ?: []; + foreach ($parts as $p) { + if (trim((string) $p) === $first) { + return $row; + } + } + } + foreach ($rows as $row) { + $name = trim((string) ($row['name'] ?? '')); + if ($name !== '' && (str_contains($name, $first) || str_contains($first, $name))) { + return $row; + } + } + return null; + } + + /** + * 将 AI 返回的临床诊断校正为本系统疾病字典(yii_disease)标准名 + * 字典收录 ICD-10 医保版 / 国临版编码与病名;匹配不上则去掉编码只留病名 + * + * @param array $fields + * @return array{fields: array, diagnosis_ids: list} + */ + private function normalizeDiagnosisAgainstDict(array $fields): array + { + $raw = trim((string) ($fields['diagnosis'] ?? '')); + if ($raw === '') { + return ['fields' => $fields, 'diagnosis_ids' => []]; + } + $parts = preg_split('/[,,、;;]+/u', $raw) ?: []; + $names = []; + $ids = []; + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part === '') { + continue; + } + [$code, $name] = $this->parseIcdDiagnosisToken($part); + $hit = $this->matchDiseaseDictRow($code, $name); + if ($hit !== null) { + $stdName = trim((string) ($hit['name'] ?? '')); + if ($stdName !== '' && ! in_array($stdName, $names, true)) { + $names[] = $stdName; + } + $hid = (int) ($hit['id'] ?? 0); + if ($hid > 0 && ! in_array($hid, $ids, true)) { + $ids[] = $hid; + } + continue; + } + // 未命中字典:去掉编码,保留病名文本,避免把乱码编码带进开方诊断 + $fallback = $name !== '' ? $name : $part; + if ($fallback !== '' && ! in_array($fallback, $names, true)) { + $names[] = $fallback; + } + } + if ($names !== []) { + $fields['diagnosis'] = implode(',', $names); + } + return ['fields' => $fields, 'diagnosis_ids' => $ids]; + } + + /** + * 解析「编码 名称」或纯名称/纯编码 + * + * @return array{0:string,1:string} [code, name] + */ + private function parseIcdDiagnosisToken(string $token): array + { + $token = trim($token); + // ICD-10:如 J06.900 / J06.900x001 / A01.000x005+J17.0* + $pattern = '/^([A-Za-z]\d{2}(?:\.\d{1,4})?(?:x\d{3})?(?:\+[A-Za-z]\d{2}(?:\.\d+)?\*?)?)\s+(.+)$/u'; + if (preg_match($pattern, $token, $m)) { + return [strtoupper($m[1]), trim($m[2])]; + } + if (preg_match('/^([A-Za-z]\d{2}(?:\.\d{1,4})?(?:x\d{3})?(?:\+[A-Za-z]\d{2}(?:\.\d+)?\*?)?)$/u', $token, $m)) { + return [strtoupper($m[1]), '']; + } + return ['', $token]; + } + + /** + * 在 yii_disease 中按编码/名称匹配一条标准诊断 + * + * @return array|null + */ + private function matchDiseaseDictRow(string $code, string $name): ?array + { + $code = strtoupper(trim($code)); + $name = trim($name); + if ($code === '' && $name === '') { + return null; + } + // 1) 编码精确匹配(diagnose_code / major_number / ref_number,覆盖医保版与国临版编号位) + if ($code !== '') { + $byCode = DiseaseModel::query() + ->where('is_delete', 0) + ->where(function ($q) use ($code) { + $q->where('diagnose_code', $code) + ->orWhere('major_number', $code) + ->orWhere('ref_number', $code); + }) + ->orderBy('id') + ->first(['id', 'name', 'diagnose_code', 'major_number', 'ref_number']); + if ($byCode !== null) { + return $byCode->toArray(); + } + } + // 2) 名称精确匹配 + if ($name !== '') { + $byName = DiseaseModel::query() + ->where('is_delete', 0) + ->where('name', $name) + ->orderBy('id') + ->first(['id', 'name', 'diagnose_code', 'major_number', 'ref_number']); + if ($byName !== null) { + return $byName->toArray(); + } + // 3) 名称模糊:优先「字典名包含 AI 名」或「AI 名包含字典名」,取最短更具体者 + $candidates = DiseaseModel::query() + ->where('is_delete', 0) + ->where(function ($q) use ($name) { + $q->where('name', 'like', '%' . $name . '%') + ->orWhere('name', 'like', $name . '%'); + }) + ->orderByRaw('CHAR_LENGTH(name) asc') + ->limit(8) + ->get(['id', 'name', 'diagnose_code', 'major_number', 'ref_number']); + if ($candidates->isNotEmpty()) { + $best = null; + $bestScore = -1; + foreach ($candidates as $row) { + $dn = trim((string) $row->name); + if ($dn === '') { + continue; + } + $score = 0; + if ($dn === $name) { + $score = 100; + } elseif (str_starts_with($dn, $name) || str_starts_with($name, $dn)) { + $score = 80; + } elseif (str_contains($dn, $name) || str_contains($name, $dn)) { + $score = 60; + } + // 同名更短更具体(避免过宽父类) + $score += max(0, 40 - mb_strlen($dn)); + if ($score > $bestScore) { + $bestScore = $score; + $best = $row; + } + } + if ($best !== null && $bestScore >= 60) { + return $best->toArray(); + } + } + } + return null; + } + + /** + * 生成前可选上下文字段白名单(主诉外非必填) + * + * @return list + */ + private function mrContextFieldCodes(): array + { + return [ + 'present_illness', + 'past_history', + 'allergy_history', + 'menstrual_history', + 'family_history', + 'marital_history', + 'personal_history', + 'physical_exam', + 'auxiliary_exam', + 'tongue', + 'pulse', + ]; + } + + /** + * 规范化可选上下文:前端非空优先,否则用库内已有非空值 + * + * @return array + */ + private function normalizeMrContextFields(array $fromFront, array $fromDb): array + { + $out = []; + foreach ($this->mrContextFieldCodes() as $code) { + $front = isset($fromFront[$code]) && is_scalar($fromFront[$code]) + ? trim((string) $fromFront[$code]) + : ''; + $db = isset($fromDb[$code]) && is_scalar($fromDb[$code]) + ? trim((string) $fromDb[$code]) + : ''; + $val = $front !== '' ? $front : $db; + if ($val !== '') { + $out[$code] = $val; + } + } + return $out; + } + + /** + * 把 AI 可能返回的中文键名映射回配置编码(如「医嘱」→ doctor_order) + * + * @param array $parsed + * @return array + */ + private function normalizeMrAiParsedKeys(array $parsed): array + { + if ($parsed === []) { + return []; + } + $labelToCode = []; + foreach (AiRuntimeConfigService::medicalRecordFieldWhitelist() as $code) { + $labelToCode[AiRuntimeConfigService::medicalRecordFieldLabel($code)] = $code; + } + // 常见别名 + $labelToCode['医嘱建议'] = 'doctor_order'; + $labelToCode['临床诊断'] = 'diagnosis'; + $labelToCode['诊断'] = 'diagnosis'; + $out = []; + foreach ($parsed as $key => $val) { + if (!is_string($key) && !is_int($key)) { + continue; + } + $k = trim((string) $key); + if ($k === '') { + continue; + } + if (isset($labelToCode[$k])) { + $k = $labelToCode[$k]; + } + // 已有英文键优先,避免中文键覆盖 + if (isset($out[$k]) && is_scalar($out[$k]) && trim((string) $out[$k]) !== '') { + continue; + } + $out[$k] = $val; + } + return $out; + } + + /** + * VIP 校验 + */ + public function assertVip(int $storeId, string $code): void + { + if ($storeId <= 0) { + $this->utils->errorThrow('无法识别门店,无法使用 AI 功能'); + } + if (!VipGateService::getInstance()->hasPermission($storeId, $code)) { + $label = $code === self::VIP_MR ? 'AI写病历' : 'AI辅助出方'; + $this->utils->errorThrow('当前门店未开通' . $label . 'VIP功能'); + } + } + + /** + * AI 写病历:校验主诉 → 请求前落库 → 调模型 → 成败回写 → 返回草稿字段(不自动保存病历) + * 主诉优先用前端传入;AI/运行时异常软失败(ok=false),校验仍硬抛 + * + * @param int $registerId 挂号 ID + * @param int $storeId 门店 + * @param int $doctorId 医生 + * @param string|null $provider 可选覆盖供应商 + * @param string $chiefComplaint 前端表单主诉 + * @param array $contextFields 生成前可选填字段(现病史/既往史等,非必填) + */ + public function generateMedicalRecord( + int $registerId, + int $storeId, + int $doctorId = 0, + ?string $provider = null, + string $chiefComplaint = '', + array $contextFields = [] + ): array { + $this->assertVip($storeId, self::VIP_MR); + if ($registerId <= 0) { + $this->utils->errorThrow('挂号ID无效'); + } + $ctx = $this->loadPatientContext($registerId); + // 年龄/性别缺失时生成易偏离临床,必须先完善就诊人再写病历 + $this->assertPatientDemographicsForAi($ctx); + $mr = MedicalRecordModel::where('register_id', $registerId)->where('deleted_at', 0)->first(); + // 优先用前端传入的表单主诉,避免为生成而强制先保存 + $chief = trim($chiefComplaint); + if ($chief === '') { + $chief = trim((string) ($mr->chief_complaint ?? '')); + } + if ($chief === '') { + $this->utils->errorThrow('请先填写主诉后再生成'); + } + // 规范化可选上下文:仅白名单内非空字段(前端优先,库值兜底) + $context = $this->normalizeMrContextFields($contextFields, $mr ? $mr->toArray() : []); + $inputSnapshot = [ + 'age' => $ctx['age'], + // 快照存中文性别,避免下游/排障看到库内枚举 1/2 + 'sex' => (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)), + 'chief_complaint' => $chief, + 'patient_name' => $ctx['name'], + 'context_fields' => $context, + ]; + // 校验通过后再 begin:请求前落库,保证失败也可追溯 + $row = $this->beginGeneration( + $storeId, + $registerId, + $doctorId, + self::SCENE_MR, + 0, + $provider, + $inputSnapshot + ); + $chat = null; + try { + $sexLabel = (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)); + // 仅要求系统配置勾选的字段,减少无关输出、加快生成 + $outputFields = $this->resolveMrOutputFields(); + if (empty($outputFields)) { + throw new \RuntimeException('未配置 AI 写病历输出字段'); + } + // 医生已填的配置字段不再要求 AI 生成,最终结果优先保留医生原文 + $needAiFields = []; + foreach ($outputFields as $field) { + if (!isset($context[$field]) || $context[$field] === '') { + $needAiFields[] = $field; + } + } + $fields = []; + foreach ($outputFields as $field) { + $fields[$field] = $context[$field] ?? ''; + } + if (empty($needAiFields)) { + $chat = new AiChatResult( + '{}', + [], + (string) ($row->provider ?? ''), + (string) ($row->model ?? ''), + [], + (int) ($row->api_key_id ?? 0) + ); + } else { + // 提示词带中文名,避免模型漏填 doctor_order 等编码键 + $fieldList = implode(',', array_map(static function (string $code) { + $label = AiRuntimeConfigService::medicalRecordFieldLabel($code); + return $code === $label ? $code : ($code . '(' . $label . ')'); + }, $needAiFields)); + $fieldKeysOnly = implode(',', $needAiFields); + $system = '你是一名中医临床助手。必须结合患者真实年龄、性别与主诉、病史生成规范病历草稿,禁止忽略人口学信息按通用模板套写。' + . $this->buildDemographicsPromptHint($ctx) + . $this->buildMedicalRecordCautionPromptHint($needAiFields) + . '必须只输出一个 JSON 对象,不要 markdown,不要解释。' + . 'JSON 必须包含且仅包含以下全部键(键名用英文编码,值为字符串):' . $fieldKeysOnly . '。' + . '字段含义:' . $fieldList . '。' + . '硬性要求:上述每一个键都必须出现,且每一个值都必须是非空有临床意义的内容;' + . '禁止省略任何键,禁止任何键填空字符串 "",禁止输出未列出的键。' + . '不要输出 chief_complaint 字段(主诉已由医生提供)。' + . '医生已填写的字段已作为参考,请与之保持一致、互补,不要无故矛盾;' + . '若医生填写内容与主诉明显无关,仍以主诉为本次生成核心,病史字段仅作背景参考。' + . $this->buildIcdDiagnosisPromptHint($needAiFields) + . (in_array('doctor_order', $needAiFields, true) + ? 'doctor_order(医嘱)必须填写具体医嘱建议(如饮食禁忌、复诊、用药注意事项等),且须对应主诉相关诊断,禁止留空。' + : '') + . (in_array('menstrual_history', $needAiFields, true) && (int) ($ctx['sex'] ?? 0) === 1 + ? '患者为男性时 menstrual_history 填「无」。' + : '') + . $this->buildTcmDictPromptHint($needAiFields) + . '语言简洁专业。'; + $userLines = [ + '【必读】患者人口学:' . ($ctx['age'] !== '' ? $ctx['age'] . '岁' : '未知') . ' / ' . $sexLabel + . '(生成时必须体现该年龄段与性别特征,禁止按未知处理)', + '年龄:' . ($ctx['age'] !== '' ? $ctx['age'] . '岁' : '未知'), + '性别:' . $sexLabel, + '主诉(本次生成核心,诊断与医嘱必须围绕它):' . $chief, + ]; + foreach ($context as $code => $text) { + $userLines[] = AiRuntimeConfigService::medicalRecordFieldLabel($code) . ':' . $text; + } + $userLines[] = '请输出包含全部键(' . $fieldKeysOnly . ')的完整病历 JSON,每个键都必须有非空内容;' + . '第一诊断/证候/治法/治疗意见/医嘱须解释本次主诉,勿把病史慢病写成唯一或首要结论。'; + $user = implode("\n", $userLines); + $agent = AiAgentFactory::getInstance()->make($provider); + $messages = [ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ]; + // 温度略降:写病历更保守,减少跳跃诊断与矛盾治法 + $parsedPack = $this->chatAndParseJsonObject($agent, $messages, ['temperature' => 0.25]); + $chat = $parsedPack['chat']; + $parsed = $this->normalizeMrAiParsedKeys($parsedPack['data']); + // 勾选字段缺键或为空:全部纳入补全重试(不是只补核心字段) + $needRetry = []; + foreach ($needAiFields as $field) { + if (!array_key_exists($field, $parsed)) { + $needRetry[] = $field; + continue; + } + $v = is_scalar($parsed[$field]) ? trim((string) $parsed[$field]) : ''; + if ($v === '') { + $needRetry[] = $field; + } + } + if (!empty($needRetry)) { + $retryMessages = array_merge($messages, [ + ['role' => 'assistant', 'content' => $chat->content], + [ + 'role' => 'user', + 'content' => '以下键缺失或内容为空:' . implode(',', $needRetry) + . '。请重新只输出一个 JSON 对象,必须包含全部键(' . $fieldKeysOnly + . '),且每个键都必须是非空临床内容(禁止 "");其它键可沿用上次合理内容。不要输出其它键。', + ], + ]); + $chat2 = $agent->chatCompletions($retryMessages, ['temperature' => 0.3]); + $parsed2 = $this->normalizeMrAiParsedKeys($this->tryParseJsonObject($chat2->content) ?? []); + if ($parsed2 !== []) { + $chat = $chat2; + // 合并:补全空缺,保留已有非空 + foreach ($needAiFields as $field) { + $newVal = isset($parsed2[$field]) && is_scalar($parsed2[$field]) + ? trim((string) $parsed2[$field]) + : ''; + $oldVal = isset($parsed[$field]) && is_scalar($parsed[$field]) + ? trim((string) $parsed[$field]) + : ''; + $parsed[$field] = $newVal !== '' ? $newVal : $oldVal; + } + } + } + // 仅回填本次需 AI 生成的字段;医生已填字段保持原值 + foreach ($needAiFields as $field) { + $val = $parsed[$field] ?? ''; + $fields[$field] = is_scalar($val) ? trim((string) $val) : ''; + } + // 仍有空字段:再针对空缺键补一轮,确保「勾选的必须全部有内容」 + $stillEmpty = []; + foreach ($needAiFields as $field) { + if (trim((string) ($fields[$field] ?? '')) === '') { + $stillEmpty[] = $field; + } + } + if (!empty($stillEmpty)) { + $emptyLabels = array_map(static function (string $code) { + $label = AiRuntimeConfigService::medicalRecordFieldLabel($code); + return $code === $label ? $code : ($code . '(' . $label . ')'); + }, $stillEmpty); + $fillMessages = array_merge($messages, [ + ['role' => 'assistant', 'content' => $chat->content], + [ + 'role' => 'user', + 'content' => '仍有字段为空:' . implode(',', $emptyLabels) + . '。请只输出一个 JSON 对象,必须包含键 ' . implode(',', $stillEmpty) + . ',且每个值都必须是非空有临床意义的内容,禁止空字符串。', + ], + ]); + $chat3 = $agent->chatCompletions($fillMessages, [ + 'temperature' => 0.2, + 'max_tokens' => max(512, count($stillEmpty) * 256), + ]); + $parsed3 = $this->normalizeMrAiParsedKeys($this->tryParseJsonObject($chat3->content) ?? []); + $filledAny = false; + foreach ($stillEmpty as $field) { + $v = isset($parsed3[$field]) && is_scalar($parsed3[$field]) + ? trim((string) $parsed3[$field]) + : ''; + if ($v !== '') { + $fields[$field] = $v; + $filledAny = true; + } + } + if ($filledAny) { + $chat = $chat3; + } + } + } // end needAiFields + // 中医三典:按字典库校正名称,并给出开方用旧字段 id(监管上传取 code) + $tcmResolved = $this->normalizeTcmFieldsAgainstDict($fields); + $fields = $tcmResolved['fields']; + // 临床诊断:按 ICD(医保版/国临版)约束后,再对齐本系统 yii_disease 标准病名 + $diagnosisResolved = $this->normalizeDiagnosisAgainstDict($fields); + $fields = $diagnosisResolved['fields']; + // 列表展示名:优先临床诊断,否则 AI病历 + $listName = trim((string) ($fields['diagnosis'] ?? '')); + if ($listName === '') { + $listName = 'AI病历'; + } + $fields['name'] = mb_substr($listName, 0, 128); + // name 仅用于列表展示;tcm_ids / diagnosis_ids 一并落库,历史导入可回填 + $resultFields = $fields; + unset($resultFields['name']); + $storePayload = $fields; + $storePayload['tcm_ids'] = $tcmResolved['tcm_ids']; + $storePayload['diagnosis_ids'] = $diagnosisResolved['diagnosis_ids']; + $row = $this->finishGenerationSuccess($row, $chat, $storePayload); + return [ + 'ok' => true, + 'generation_id' => (int) $row->id, + 'fields' => $resultFields, + // 与开方提交字段名对齐:diseases_id=证候,syndrome_id=疾病,method_id=治法 + 'tcm_ids' => $tcmResolved['tcm_ids'], + 'diagnosis_ids' => $diagnosisResolved['diagnosis_ids'], + 'provider' => $chat->provider, + 'model' => $chat->model, + 'duration_ms' => (int) $row->duration_ms, + ]; + } catch (\Throwable $e) { + $row = $this->finishGenerationFail($row, $e->getMessage(), $chat); + return [ + 'ok' => false, + 'generation_id' => (int) $row->id, + 'duration_ms' => (int) $row->duration_ms, + 'message' => 'AI生成未完成,请稍后重试', + 'fields' => [], + ]; + } + } + + /** + * AI 给处方:确认主诉后按病历 + 处方类型生成药名,落库并返回对照预览 + * 中药 / 西药使用独立专业提示词;中药额外走十八反十九畏规则检测 + * 中药可委托调剂:processOptions.use_entrusted_process + process_rule_id 时要求 AI 选煎法 + * AI/运行时异常软失败(ok=false),VIP/参数校验仍硬抛 + * + * @param int $registerId 挂号 + * @param int $storeId 门店 + * @param int $prescriptionType ProductTypeEnum + * @param int $doctorId 医生 + * @param string|null $provider 供应商覆盖 + * @param string $chiefComplaint 前端确认过的主诉(可编辑) + * @param array $medicalRecordOverride 前端病历表单快照(可未保存),有内容字段覆盖库值 + * @param array $processOptions keys: use_entrusted_process (bool), process_rule_id (int) + */ + public function generatePrescription( + int $registerId, + int $storeId, + int $prescriptionType, + int $doctorId = 0, + ?string $provider = null, + string $chiefComplaint = '', + array $medicalRecordOverride = [], + array $processOptions = [] + ): array { + $this->assertVip($storeId, self::VIP_RX); + if ($registerId <= 0) { + $this->utils->errorThrow('挂号ID无效'); + } + $typeEnum = ProductTypeEnum::tryFrom($prescriptionType); + if ($typeEnum === null || $prescriptionType <= 0) { + $this->utils->errorThrow('处方类型无效'); + } + $ctx = $this->loadPatientContext($registerId); + // 年龄/性别缺失时出方易出现剂量/禁忌错误,必须先完善就诊人 + $this->assertPatientDemographicsForAi($ctx); + $mr = MedicalRecordModel::where('register_id', $registerId)->where('deleted_at', 0)->first(); + $mrArr = $mr ? $mr->toArray() : []; + // 前端当前病历表单优先(可能尚未点保存),再与库内记录合并 + $mrArr = $this->mergeMedicalRecordForAi($mrArr, $medicalRecordOverride); + // 主诉优先用确认步可编辑值 + $chief = trim($chiefComplaint); + if ($chief === '') { + $chief = trim((string) ($mrArr['chief_complaint'] ?? '')); + } + if ($chief === '') { + $this->utils->errorThrow('请先填写主诉后再生成'); + } + $mrArr['chief_complaint'] = $chief; + $typeLabel = $typeEnum->description(); + $isTcm = $prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value; + $useEntrusted = $isTcm && !empty($processOptions['use_entrusted_process']); + $processRuleId = $useEntrusted ? (int) ($processOptions['process_rule_id'] ?? 0) : 0; + // 委托时:是否同时出推荐剂数/每日次数/二级详细制剂规则(前端本地记忆) + $returnDosageProcessDetail = $useEntrusted + && (!array_key_exists('return_dosage_process_detail', $processOptions) + || !empty($processOptions['return_dosage_process_detail'])); + // 勾选委托但未选制剂要求:硬校验,避免空列表误导 AI + if ($useEntrusted && $processRuleId <= 0) { + $this->utils->errorThrow('请选择制剂要求后再生成'); + } + // 委托调剂:加载子煎法 + 备注供 AI 选择;未勾选则明确禁止输出加工字段 + $processCtx = $this->loadEntrustedProcessContext($useEntrusted, $processRuleId); + $processCtx['return_detail'] = $returnDosageProcessDetail; + $inputSnapshot = [ + 'age' => $ctx['age'], + // 快照存中文性别,避免下游/排障看到库内枚举 1/2 + 'sex' => (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)), + 'patient_name' => $ctx['name'], + 'prescription_type' => $prescriptionType, + 'type_label' => $typeLabel, + 'medical_record' => $this->pickMedicalRecordSnapshot($mrArr), + 'use_entrusted_process' => $useEntrusted ? 1 : 0, + 'process_rule_id' => $processRuleId, + 'return_dosage_process_detail' => $returnDosageProcessDetail ? 1 : 0, + ]; + $row = $this->beginGeneration( + $storeId, + $registerId, + $doctorId, + self::SCENE_RX, + $prescriptionType, + $provider, + $inputSnapshot + ); + $chat = null; + try { + // 中药先煎/后下等必须来自 yii_drug_use_way,与接诊开方词典一致,避免 AI 自造无法对照 + $useWayNames = $isTcm ? $this->loadDrugUseWayNames() : []; + $system = $this->buildPrescriptionSystemPrompt($prescriptionType, $typeLabel, $useWayNames, $processCtx); + $user = $this->buildPrescriptionUserPrompt($ctx, $mrArr, $typeLabel, $isTcm, $useWayNames, $processCtx); + $processHint = $isTcm ? $this->buildEntrustedProcessPromptHint($processCtx) : ''; + if ($processHint !== '') { + $system .= $processHint; + $user .= "\n" . $processHint; + } + $agent = AiAgentFactory::getInstance()->make($provider); + $messages = [ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ]; + // 中药:解析完整方对象;西药等:解析药品数组 + $parsedPack = $isTcm + ? $this->chatAndParseTcmPrescription($agent, $messages, ['temperature' => 0.3, 'max_tokens' => 4096], $useWayNames) + : $this->chatAndParseWestPrescription($agent, $messages, ['temperature' => 0.3, 'max_tokens' => 2048]); + $chat = $parsedPack['chat']; + $items = $this->normalizePrescriptionItems($parsedPack['items'], $isTcm ? $useWayNames : []); + $dosage = (int) ($parsedPack['dosage'] ?? 0); + $dayDosage = (int) ($parsedPack['day_dosage'] ?? 0); + $reason = trim((string) ($parsedPack['reason'] ?? '')); + $basis = trim((string) ($parsedPack['basis'] ?? '')); + $prescriptionName = trim((string) ($parsedPack['prescription_name'] ?? '')); + // 中药剂数/每日次数必须有可用默认值,否则前端预览为「—」且导入剂数缺失 + if ($isTcm && $dosage <= 0) { + $dosage = 7; + } + if ($isTcm && $dayDosage <= 0) { + $dayDosage = 2; + } + if (empty($items)) { + // 已是合法 JSON,但没有有效药名——当作 AI 软失败 + throw new \RuntimeException('AI 未生成有效药品'); + } + // 中药:服务端再跑一遍十八反/十九畏/有毒检测,给医生预览警示 + $conflict = ['is_exist' => false, 'messages' => []]; + if ($isTcm) { + $conflict = $this->detectChineseMedicineConflict($items); + } + // 从原始响应解析委托调剂字段并映射到规则 ID + $processFields = $this->resolveEntrustedProcessFromChat( + $chat->content, + $processCtx + ); + $resultJson = [ + 'items' => $items, + 'dosage' => $dosage, + 'day_dosage' => $dayDosage, + 'reason' => $reason, + 'basis' => $basis, + 'prescription_name' => $prescriptionName, + 'conflict' => $conflict, + ]; + if (!empty($processFields)) { + $resultJson = array_merge($resultJson, $processFields); + } + $row = $this->finishGenerationSuccess($row, $chat, $resultJson); + $match = $this->matchPrescriptionDrugs($items, $storeId, $prescriptionType, $registerId); + return array_merge([ + 'ok' => true, + 'generation_id' => (int) $row->id, + 'items' => $items, + 'dosage' => $dosage, + 'day_dosage' => $dayDosage, + 'reason' => $reason, + 'basis' => $basis, + 'prescription_name' => $prescriptionName, + 'prescription_type' => $prescriptionType, + 'prescription_type_label' => $typeLabel, + 'match' => $match, + 'conflict' => $conflict, + 'provider' => $chat->provider, + 'model' => $chat->model, + 'created_at' => $this->rawCreatedAtTimestamp($row), + 'duration_ms' => (int) $row->duration_ms, + ], $processFields); + } catch (\Throwable $e) { + $row = $this->finishGenerationFail($row, $e->getMessage(), $chat); + return [ + 'ok' => false, + 'generation_id' => (int) $row->id, + 'duration_ms' => (int) $row->duration_ms, + 'message' => 'AI生成未完成,请稍后重试', + 'items' => [], + 'dosage' => 0, + 'day_dosage' => 0, + 'reason' => '', + 'basis' => '', + 'prescription_name' => '', + 'prescription_type' => $prescriptionType, + 'prescription_type_label' => $typeLabel, + 'match' => ['matched' => [], 'unmatched' => []], + 'conflict' => ['is_exist' => false, 'messages' => []], + ]; + } + } + + /** + * 本挂号 AI 生成历史列表(轻量:不含 result_json / 原始响应,详情点选后再取) + * 接诊端只展示成功记录(status=1),失败不进历史抽屉 + * + * @param int $registerId 挂号 + * @param string $scene medical_record|prescription|空=全部 + * @param int $limit 条数 + */ + public function listGenerations(int $registerId, string $scene = '', int $limit = 30): array + { + if ($registerId <= 0) { + $this->utils->errorThrow('挂号ID无效'); + } + $limit = max(1, min(100, $limit)); + $q = AiGenerationModel::query() + ->select([ + 'id', + 'scene', + 'prescription_type', + 'name', + 'provider', + 'model', + 'status', + 'duration_ms', + 'created_at', + // 仅取味数,避免把整份 result_json 拉回 PHP + DB::raw( + 'IFNULL(JSON_LENGTH(JSON_EXTRACT(result_json, \'$.items\')), 0) as item_count' + ), + ]) + ->where('register_id', $registerId) + ->where('deleted_at', 0) + // 接诊历史只看成功,失败记录留给后台排障 + ->where('status', 1) + ->orderByDesc('id') + ->limit($limit); + if ($scene !== '') { + $q->where('scene', $scene); + } + return $q->get()->map(function ($row) { + $type = (int) ($row->prescription_type ?? 0); + $typeEnum = ProductTypeEnum::tryFrom($type); + $name = trim((string) ($row->name ?? '')); + return [ + 'id' => (int) $row->id, + 'scene' => (string) ($row->scene ?? ''), + 'prescription_type' => $type, + 'prescription_type_label' => $typeEnum ? $typeEnum->description() : ($type > 0 ? '类型' . $type : ''), + 'name' => $name, + 'prescription_name' => $name, + 'provider' => (string) ($row->provider ?? ''), + 'model' => (string) ($row->model ?? ''), + 'status' => (int) ($row->status ?? 0), + 'duration_ms' => (int) ($row->duration_ms ?? 0), + 'item_count' => (int) ($row->item_count ?? 0), + // 必须读 raw:访问器已格式化成日期串,(int) 会截成年份 + 'created_at' => $this->rawCreatedAtTimestamp($row), + ]; + })->values()->all(); + } + + /** + * AI 生成详情(点选历史时拉取完整结果,不含 raw_response) + */ + public function getGenerationDetail(int $generationId): array + { + if ($generationId <= 0) { + $this->utils->errorThrow('记录ID无效'); + } + $row = AiGenerationModel::where('id', $generationId)->where('deleted_at', 0)->first(); + if (!$row) { + $this->utils->errorThrow('AI 生成记录不存在'); + } + $result = is_array($row->result_json) ? $row->result_json : []; + $type = (int) $row->prescription_type; + $typeEnum = ProductTypeEnum::tryFrom($type); + $name = trim((string) ($row->name ?? '')); + if ($name === '') { + $name = trim((string) ($result['prescription_name'] ?? $result['name'] ?? '')); + } + $items = is_array($result['items'] ?? null) ? $result['items'] : []; + // 病历场景:按结果里实际有的白名单字段回传(兼容历史全量记录) + $fields = []; + $tcmIds = null; + if ((string) $row->scene === self::SCENE_MR) { + $allow = array_flip(self::MR_OUTPUT_FIELDS); + foreach ($result as $field => $val) { + if (!is_string($field) || !isset($allow[$field])) { + continue; + } + $fields[$field] = is_scalar($val) ? trim((string) $val) : ''; + } + // 优先读落库的 tcm_ids;老记录没有则按名称再匹配一次,保证历史导入也能带三项 id + if (isset($result['tcm_ids']) && is_array($result['tcm_ids'])) { + $tcmIds = [ + 'diseases_id' => isset($result['tcm_ids']['diseases_id']) + ? (int) $result['tcm_ids']['diseases_id'] ?: null + : null, + 'method_id' => isset($result['tcm_ids']['method_id']) + ? (int) $result['tcm_ids']['method_id'] ?: null + : null, + 'syndrome_id' => isset($result['tcm_ids']['syndrome_id']) + ? (int) $result['tcm_ids']['syndrome_id'] ?: null + : null, + ]; + } else { + $tcmIds = $this->normalizeTcmFieldsAgainstDict($fields)['tcm_ids']; + } + } + return [ + 'id' => (int) $row->id, + 'scene' => (string) $row->scene, + 'prescription_type' => $type, + 'prescription_type_label' => $typeEnum ? $typeEnum->description() : '', + 'name' => $name, + 'prescription_name' => $name, + 'provider' => (string) $row->provider, + 'model' => (string) $row->model, + 'status' => (int) ($row->status ?? 0), + 'duration_ms' => (int) ($row->duration_ms ?? 0), + 'item_count' => count($items), + 'items' => $items, + 'fields' => $fields, + 'tcm_ids' => $tcmIds, + 'dosage' => (int) ($result['dosage'] ?? 0), + 'day_dosage' => (int) ($result['day_dosage'] ?? 0), + 'reason' => trim((string) ($result['reason'] ?? '')), + 'basis' => trim((string) ($result['basis'] ?? '')), + 'conflict' => is_array($result['conflict'] ?? null) ? $result['conflict'] : ['is_exist' => false, 'messages' => []], + // 委托调剂字段:导入中药配置时用 + 'process_rule_id' => (int) ($result['process_rule_id'] ?? 0), + 'process_rule_name' => trim((string) ($result['process_rule_name'] ?? '')), + 'child_process_rule_id' => (int) ($result['child_process_rule_id'] ?? 0), + 'child_process_rule_name' => trim((string) ($result['child_process_rule_name'] ?? '')), + 'process_rule_note_id' => (int) ($result['process_rule_note_id'] ?? 0), + 'process_rule_note' => trim((string) ($result['process_rule_note'] ?? '')), + 'input_snapshot' => is_array($row->input_snapshot) ? $row->input_snapshot : [], + 'created_at' => $this->rawCreatedAtTimestamp($row), + ]; + } + + /** + * 对某次生成或临时 items 做药名模糊对照 + * + * @param int $storeId 门店 + * @param int $prescriptionType 类型 + * @param int $registerId 挂号(取价) + * @param int $generationId 历史 ID(优先) + * @param array $items 临时 items(无 generationId 时) + */ + public function matchPrescriptionDrugsByRequest( + int $storeId, + int $prescriptionType, + int $registerId = 0, + int $generationId = 0, + array $items = [] + ): array { + $this->assertVip($storeId, self::VIP_RX); + $savedConflict = null; + $dosage = 0; + $dayDosage = 0; + $reason = ''; + $basis = ''; + $prescriptionName = ''; + $processFields = []; + $durationMs = 0; + if ($generationId > 0) { + $row = AiGenerationModel::where('id', $generationId) + ->where('deleted_at', 0) + ->first(); + if (!$row || $row->scene !== self::SCENE_RX) { + $this->utils->errorThrow('AI 出方记录不存在'); + } + $result = is_array($row->result_json) ? $row->result_json : []; + $items = is_array($result['items'] ?? null) ? $result['items'] : []; + $dosage = (int) ($result['dosage'] ?? 0); + $dayDosage = (int) ($result['day_dosage'] ?? 0); + $reason = trim((string) ($result['reason'] ?? '')); + $basis = trim((string) ($result['basis'] ?? '')); + $prescriptionName = trim((string) ($row->name ?? '')); + if ($prescriptionName === '') { + $prescriptionName = trim((string) ($result['prescription_name'] ?? $result['name'] ?? '')); + } + if (isset($result['conflict']) && is_array($result['conflict'])) { + $savedConflict = $result['conflict']; + } + $durationMs = (int) ($row->duration_ms ?? 0); + foreach ([ + 'process_rule_id', 'process_rule_name', + 'child_process_rule_id', 'child_process_rule_name', + 'process_rule_note_id', 'process_rule_note', + ] as $pk) { + if (array_key_exists($pk, $result)) { + $processFields[$pk] = $result[$pk]; + } + } + if ($prescriptionType <= 0) { + $prescriptionType = (int) $row->prescription_type; + } + if ($registerId <= 0) { + $registerId = (int) $row->register_id; + } + } + // 历史对照时中药 usage 也按词典规范化,保证先煎后下能映射 way_id + $useWayNames = $prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value + ? $this->loadDrugUseWayNames() + : []; + $items = $this->normalizePrescriptionItems($items, $useWayNames); + if (empty($items)) { + $this->utils->errorThrow('没有可对照的药品'); + } + if ($prescriptionType <= 0) { + $this->utils->errorThrow('处方类型无效'); + } + $match = $this->matchPrescriptionDrugs($items, $storeId, $prescriptionType, $registerId); + // 历史记录优先带出当时落库的相冲结果;老数据则现场重检 + $conflict = ['is_exist' => false, 'messages' => []]; + if (is_array($savedConflict)) { + $conflict = [ + 'is_exist' => !empty($savedConflict['is_exist']), + 'messages' => array_values(array_filter(array_map( + static fn ($x) => trim((string) $x), + is_array($savedConflict['messages'] ?? null) ? $savedConflict['messages'] : [] + ))), + ]; + } elseif ( + $generationId > 0 + && $prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value + ) { + $conflict = $this->detectChineseMedicineConflict($items); + } + $typeEnum = ProductTypeEnum::tryFrom($prescriptionType); + return array_merge($match, [ + 'conflict' => $conflict, + 'dosage' => $dosage, + 'day_dosage' => $dayDosage, + 'reason' => $reason, + 'basis' => $basis, + 'prescription_name' => $prescriptionName, + 'prescription_type' => $prescriptionType, + 'prescription_type_label' => $typeEnum ? $typeEnum->description() : '', + 'duration_ms' => $durationMs, + ], $processFields); + } + + /** + * 药名模糊对照:复用接诊 productList + * + * @return array{matched: array, unmatched: array} + */ + public function matchPrescriptionDrugs(array $items, int $storeId, int $prescriptionType, int $registerId = 0): array + { + $matched = []; + $unmatched = []; + $recv = DoctorReceptionService::getInstance(); + // 西药检索含中成药 type=4,与开方页一致 + $types = [$prescriptionType]; + if ($prescriptionType === ProductTypeEnum::WESTERN_MEDICINE->value) { + $types = [2, 4]; + } + foreach ($items as $itemIndex => $item) { + $name = trim((string) ($item['name'] ?? '')); + if ($name === '') { + continue; + } + $list = $recv->productList($storeId, $prescriptionType, $name, $registerId, $types); + $candidates = []; + foreach ($list as $row) { + $arr = is_object($row) ? (array) $row : (array) $row; + // Collection 可能是 stdClass / Model + if (is_object($row) && method_exists($row, 'toArray')) { + $arr = $row->toArray(); + } elseif (is_object($row)) { + $arr = json_decode(json_encode($row), true) ?: []; + } + $drugId = (int) ($arr['drug_id'] ?? $arr['id'] ?? 0); + if ($drugId <= 0) { + continue; + } + $drug = $arr['drug'] ?? []; + if (is_object($drug)) { + $drug = json_decode(json_encode($drug), true) ?: []; + } + $candidates[] = [ + 'id' => (int) ($arr['id'] ?? $drugId), + 'drug_id' => $drugId, + 'drug_name' => (string) ($arr['drug_name'] ?? $drug['drug_name'] ?? ''), + 'price' => (float) ($arr['price'] ?? 0), + 'specification' => (string) ($arr['specification'] ?? $drug['specification'] ?? ''), + 'image' => (string) ($arr['image'] ?? $drug['image'] ?? ''), + 'unit' => $arr['unit'] ?? ($drug['unit'] ?? null), + 'time_id' => (int) ($drug['time_id'] ?? 0), + 'type_id' => (int) ($drug['type_id'] ?? 0), + 'frequency_id' => (int) ($drug['frequency_id'] ?? 0), + 'unit_id' => (int) ($drug['unit_id'] ?? 0), + 'number' => $drug['number'] ?? 1, + 'drug' => $drug, + ]; + // 单味药候选上限,避免一次返回过大 + if (count($candidates) >= 8) { + break; + } + } + $payload = [ + 'ai_name' => $name, + 'dose' => (string) ($item['dose'] ?? ''), + 'unit' => (string) ($item['unit'] ?? ''), + 'usage' => (string) ($item['usage'] ?? ''), + 'frequency' => (string) ($item['frequency'] ?? ''), + // 回传落库改选标记,前端展示「已改」;item_index 对应 result_json.items 下标 + 'is_modified' => !empty($item['is_modified']) ? 1 : 0, + 'item_index' => (int) $itemIndex, + ]; + if (empty($candidates)) { + $unmatched[] = $payload; + continue; + } + $payload['candidates'] = $candidates; + // 优先还原医生已保存的 selected_drug_id;不在候选里则回退第一项 + $savedId = (int) ($item['selected_drug_id'] ?? 0); + $pickId = (int) $candidates[0]['drug_id']; + if ($savedId > 0) { + foreach ($candidates as $c) { + if ((int) ($c['drug_id'] ?? 0) === $savedId) { + $pickId = $savedId; + break; + } + } + } + $payload['selected_drug_id'] = $pickId; + if ($savedId > 0 && $pickId === $savedId) { + $payload['is_modified'] = !empty($item['is_modified']) ? 1 : 0; + } + $matched[] = $payload; + } + return [ + 'matched' => $matched, + 'unmatched' => $unmatched, + ]; + } + + /** + * 挂号患者上下文:年龄、性别、姓名 + */ + private function loadPatientContext(int $registerId): array + { + $reg = RegisterOrderModel::with(['userPatient:id,name,age,sex']) + ->where('id', $registerId) + ->first(['id', 'user_patient_id', 'store_id']); + if (!$reg) { + $this->utils->errorThrow('挂号不存在'); + } + $p = $reg->userPatient; + $sex = $p ? (int) ($p->sex ?? 0) : 0; + return [ + 'age' => $p ? (string) ($p->age ?? '') : '', + 'sex' => $sex, + // 预计算中文,拼 prompt / 快照时直接用,避免漏映射 + 'sex_label' => $this->formatSexLabel($sex), + 'name' => $p ? (string) ($p->name ?? '') : '', + 'user_patient_id' => (int) ($reg->user_patient_id ?? 0), + 'store_id' => (int) ($reg->store_id ?? 0), + ]; + } + + /** + * AI 生成前校验年龄/性别:缺失时模型会按「未知成人」套写,易与预期不符 + * + * @param array{age?:string,sex?:int} $ctx + */ + private function assertPatientDemographicsForAi(array $ctx): void + { + $age = (int) ($ctx['age'] ?? 0); + $sex = (int) ($ctx['sex'] ?? 0); + if ($age <= 0) { + $this->utils->errorThrow('患者年龄缺失,请先完善就诊人信息后再生成'); + } + if ($sex !== 1 && $sex !== 2) { + $this->utils->errorThrow('患者性别缺失,请先完善就诊人信息后再生成'); + } + } + + /** + * 按年龄/性别拼人口学硬性约束,避免小儿/老年/男女辨证用药串台 + * + * @param array{age?:string,sex?:int,sex_label?:string} $ctx + */ + private function buildDemographicsPromptHint(array $ctx): string + { + $age = (int) ($ctx['age'] ?? 0); + $sex = (int) ($ctx['sex'] ?? 0); + $sexLabel = (string) ($ctx['sex_label'] ?? $this->formatSexLabel($sex)); + $parts = [ + '【人口学硬性约束】必须结合患者年龄(' . ($age > 0 ? $age . '岁' : '未知') + . ')与性别(' . $sexLabel . ')生成内容,禁止按「未知」或通用成人模板敷衍。', + ]; + if ($sex === 1) { + $parts[] = '患者为男性:月经史相关内容填「无」;勿按妇科思路辨证用药。'; + } elseif ($sex === 2) { + $parts[] = '患者为女性:辨证与用药须考虑月经/孕产可能;育龄期慎用妊娠禁忌药(除非病历已排除)。'; + } + if ($age > 0 && $age < 14) { + $parts[] = '患者为小儿:药味、剂量、禁忌须按儿科规范,避免成人剂量与峻烈药。'; + } elseif ($age >= 65) { + $parts[] = '患者为老年:注意体虚、肝肾功能与减量、配伍安全性。'; + } + return implode('', $parts); + } + + /** + * 就诊人性别枚举转中文(yii_user_patient.sex:0默认 1男 2女) + * AI prompt / 生成快照必须用中文,禁止把 1/2 原样塞给模型 + */ + private function formatSexLabel(mixed $sex): string + { + if (is_string($sex)) { + $trim = trim($sex); + if ($trim === '男' || $trim === '女' || $trim === '未知') { + return $trim; + } + } + $n = (int) $sex; + return match ($n) { + 1 => '男', + 2 => '女', + default => '未知', + }; + } + + /** + * 按处方类型组装 system prompt(中药 / 西药 / 其他分开,避免混用) + * 中药委托调剂时按一级制剂要求(汤剂/丸剂/颗粒等)约束,不写死汤剂 + * + * @param list $useWayNames 中药煎法词典(yii_drug_use_way.name) + * @param array{enabled?:bool,process_rule_id?:int,process_rule_name?:string} $processCtx + */ + private function buildPrescriptionSystemPrompt( + int $prescriptionType, + string $typeLabel, + array $useWayNames = [], + array $processCtx = [] + ): string { + $usageHint = !empty($useWayNames) + ? ('只能从词典中选填:' . implode('、', $useWayNames) . ';普通煎服填空字符串') + : '仅写煎煮特殊要求;普通煎服填空字符串'; + $formName = trim((string) ($processCtx['process_rule_name'] ?? '')); + $entrusted = !empty($processCtx['enabled']) && $formName !== ''; + // 中药:完整方对象;含药方名 + reason/basis + $sampleTcm = '{' + . '"prescription_name":"金匮肾气丸加减",' + . '"reason":"肾阴亏虚夹寒,宜温补肾阳、滋阴填精,用金匮肾气丸化裁",' + . '"basis":"主诉腰膝酸软畏寒;辨证肾阳虚;参考《金匮要略》肾气丸与《本草纲目》附子干姜温阳配伍",' + . '"dosage":7,' + . '"day_dosage":2,' + . '"drug":[' + . '{"name":"熟地黄","dose":"15","unit":"g","usage":""},' + . '{"name":"山茱萸","dose":"12","unit":"g","usage":""},' + . '{"name":"山药","dose":"15","unit":"g","usage":""},' + . '{"name":"泽泻","dose":"10","unit":"g","usage":""},' + . '{"name":"茯苓","dose":"15","unit":"g","usage":""},' + . '{"name":"牡丹皮","dose":"10","unit":"g","usage":""},' + . '{"name":"附子","dose":"9","unit":"g","usage":"先煎"},' + . '{"name":"干姜","dose":"6","unit":"g","usage":""},' + . '{"name":"炙甘草","dose":"6","unit":"g","usage":""},' + . '{"name":"桂枝","dose":"9","unit":"g","usage":""},' + . '{"name":"白芍","dose":"12","unit":"g","usage":""},' + . '{"name":"薄荷","dose":"6","unit":"g","usage":"后下"}' + . ']}'; + $sampleWest = '{' + . '"prescription_name":"上感对症方案",' + . '"reason":"上呼吸道感染伴发热疼痛,予抗感染与解热镇痛对症",' + . '"basis":"主诉发热咽痛;无青霉素过敏史;参考常见社区获得性感染经验用药",' + . '"drug":[' + . '{"name":"阿莫西林胶囊","dose":"0.5","unit":"g","usage":"口服","frequency":"一日三次"},' + . '{"name":"布洛芬缓释胶囊","dose":"0.3","unit":"g","usage":"口服","frequency":"一日两次"}' + . ']}'; + $jsonRuleTcm = '必须只输出一个 JSON 对象,不要 markdown,不要解释。' + . '字段:prescription_name(药方名字,如经典方名或「某某方加减」,字符串)、' + . 'reason(为什么出这个方,辨证立法与选方思路,字符串)、' + . 'basis(根据哪些,列出依据的病历要点/证候/典籍或指南,字符串)、' + . 'dosage(开多少剂,顶层整数,常用7,禁止写成对象)、' + . 'day_dosage(每天几次,顶层整数,常用2,禁止嵌套进 dosage,禁止写「每日2-3次」文案)、' + . 'drug(药品数组,必须在顶层且不能省略,禁止写进 dosage;必须完整君臣佐使,一般10~18味,不得只给2~5味)。' + . 'drug 元素字段:name(药名)、dose(克数数字,不要带单位)、unit(固定g)、' + . 'usage(' . $usageHint . ';禁止写每日1剂)。' + . '禁止在药品上输出 frequency 字段。prescription_name、reason、basis、dosage、day_dosage、drug 必须全部是顶层字段。' + . 'reason 与 basis 各控制在80字以内,优先保证 drug 数组完整输出。' + . '正确样例:' . $sampleTcm + . '。错误示例(禁止):漏掉 drug、dosage 写成对象并把 drug 嵌进去、day_dosage 写成「每日2次」、只输出药品数组 []、单个药对象、用药名当 key、药品里写 frequency。'; + $jsonRuleWest = '必须只输出一个 JSON 对象,不要 markdown,不要解释。' + . '字段:prescription_name(方案/处方简称)、reason(为什么开这些药)、basis(根据哪些:症状/诊断/禁忌与指南要点)、' + . 'drug(药品数组)。' + . 'drug 元素字段:name, dose, unit, usage(给药途径), frequency(频次)。' + . '正确样例:' . $sampleWest + . '。错误示例(禁止):最外层直接是数组、用药名当 key。'; + $jsonRuleOther = '必须只输出一个 JSON 对象,含 prescription_name、reason、basis、drug 数组。' + . '正确样例:{"prescription_name":"...","reason":"...","basis":"...","drug":[{"name":"商品名","dose":"1","unit":"盒","usage":"","frequency":""}]}。'; + if ($prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value) { + $formLine = $entrusted + ? ('本方委托调剂一级制剂要求为「' . $formName . '」(汤剂/丸剂/颗粒/膏方等一级均可能),' + . '组方、药味多少与剂量须符合「' . $formName . '」的临床常用习惯,不要擅自改成其他剂型。') + : '开出一副完整中药饮片方(君臣佐使齐全);未指定委托制剂时按临床常用饮片配伍出方,不限于汤剂一种剂型表述。'; + return '你是资深中医师与方剂学专家,擅长辨证论治与经典方化裁。' + . '必须综合患者年龄、性别与完整病历(主诉、现病史、四诊、中医病案/证候/治法、诊断、过敏史、既往史、体征与辅助检查等)辨证论治,' + . '不得仅凭主诉开方,也不得忽略年龄性别按成人通用方套写;有内容的字段都要纳入依据,basis 中写明用到了哪些病历要点与人口学要点。' + . '组方时须参考《伤寒论》《金匮要略》《本草纲目》《温病条辨》《神农本草经》等典籍的方义、性味归经与配伍原则,' + . $formLine + . '必须严格自检并规避「十八反」「十九畏」配伍禁忌,以及孕妇、小儿、老年体弱等特殊人群禁忌药;' + . '若某药与方中他药相反相畏,须剔除或改用安全替代药,不得输出禁忌药对。' + . '处方类型固定为中药饮片(' . $typeLabel . '):drug 只能是中药饮片药名,' + . '禁止输出针灸、针刺、艾灸、穴位、推拿、理疗、外治手法等非饮片内容,也禁止把「针灸疗法」等当作药名或方名。' + . $jsonRuleTcm; + } + if ($prescriptionType === ProductTypeEnum::WESTERN_MEDICINE->value) { + return '你是临床药学与全科处方助手,擅长西药与中成药合理用药。' + . '必须综合患者年龄、性别与完整病历(主诉、现病史、诊断、过敏史、既往史、体征检查、辅助检查、生命体征等)给出用药建议,' + . '不得仅凭主诉开药,也不得忽略年龄性别;basis 中写明依据的病历要点与人口学要点。' + . '依据适应证、禁忌证、常用剂量、肝肾功能与药物相互作用给出建议,语言专业、剂量规范。' + . '须按年龄体重调整剂量,结合性别相关禁忌(如育龄女性慎用致畸药),并规避过敏史与明显相互作用、重复用药。' + . '处方类型为西药/中成药(' . $typeLabel . ')。不要输出中药饮片生药名。' + . $jsonRuleWest; + } + return '你是医疗机构处方助手。根据患者年龄、性别、完整病历与处方类型「' . $typeLabel . '」给出可开具的商品/药品建议。' + . '必须参考主诉以外的现病史、诊断、过敏史等已填字段,不得仅凭主诉,也不得忽略年龄性别。' + . '剂量与单位须符合该品类常见开方习惯及该年龄段剂量习惯。' + . $jsonRuleOther; + } + + /** + * 组装处方 user prompt:输出完整病历(有内容的字段),要求综合参考而非只看主诉 + * + * @param list $useWayNames 中药煎法词典 + * @param array{enabled?:bool,process_rule_name?:string} $processCtx + */ + private function buildPrescriptionUserPrompt( + array $ctx, + array $mr, + string $typeLabel, + bool $isTcm, + array $useWayNames = [], + array $processCtx = [] + ): string { + $sexLabel = (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)); + $ageText = $ctx['age'] !== '' ? $ctx['age'] . '岁' : '未知'; + $lines = [ + '请根据下列「完整病历」与患者年龄/性别综合出方(不得只看主诉,不得忽略人口学信息)。', + '处方类型:' . $typeLabel, + '患者姓名:' . (trim((string) ($ctx['name'] ?? '')) !== '' ? $ctx['name'] : '未知'), + '【必读】患者人口学:' . $ageText . ' / ' . $sexLabel . '(剂量、禁忌、辨证须与之匹配)', + '年龄:' . $ageText, + '性别:' . $sexLabel, + $this->buildDemographicsPromptHint($ctx), + ]; + $formName = trim((string) ($processCtx['process_rule_name'] ?? '')); + if ($isTcm && !empty($processCtx['enabled']) && $formName !== '') { + $lines[] = '委托调剂一级制剂要求:' . $formName . '(须按此剂型出方,勿改成其他一级制剂)'; + } + foreach ($this->formatMedicalRecordPromptLines($mr) as $line) { + $lines[] = $line; + } + if ($isTcm) { + $usageRule = !empty($useWayNames) + ? ('药品 usage 只能从词典选填:' . implode('、', $useWayNames) . ';无需特殊煎法填空') + : '药品 usage 只填先煎/后下等;无需特殊煎法填空'; + $formReq = ($formName !== '' && !empty($processCtx['enabled'])) + ? ('按委托一级制剂「' . $formName . '」开出完整中药饮片方(10~18味,君臣佐使齐全)') + : '开出一副完整中药饮片方(10~18味,君臣佐使齐全;剂型不限于汤剂)'; + $lines[] = '要求:' . $formReq . ',填写 prescription_name(药方名字),' + . '并给出剂数 dosage(常用7)、每日次数 day_dosage(常用2);' + . '必须单独填写 reason(为什么出这个方,须点明年龄/性别相关考量,≤80字)' + . '与 basis(根据哪些病历要点、人口学要点与典籍,≤80字);' + . 'drug 数组不能省略,且只能是中药饮片;禁止针灸/推拿/理疗等非饮片方案;' + . $usageRule . ';输出前再次核查十八反、十九畏及特殊人群禁忌。'; + } else { + $lines[] = '要求:给出临床常用用药清单,填写 prescription_name,并单独填写 reason、basis' + . '(写明依据的病历字段与年龄/性别相关剂量或禁忌);注意相互作用与禁忌。'; + } + return implode("\n", $lines); + } + + /** + * 病历字段中文标签(出方 prompt / 快照共用) + * + * @return array + */ + private function medicalRecordFieldLabels(): array + { + return [ + 'chief_complaint' => '主诉', + 'present_illness' => '现病史', + 'tongue' => '舌象', + 'pulse' => '脉象', + 'tcm_case' => '中医病案', + 'tcm_disease' => '中医疾病', + 'tcm_syndrome' => '中医证候', + 'tcm_method' => '中医治法', + 'diagnosis' => '临床诊断', + 'treatment_advice' => '治疗意见', + 'doctor_order' => '医嘱', + 'allergy_history' => '过敏史', + 'past_history' => '既往史', + 'family_history' => '家族史', + 'epidemic_history' => '流行病学史', + 'personal_history' => '个人史', + 'menstrual_history' => '月经史', + 'marital_history' => '婚育史', + 'physical_exam' => '体征检查', + 'auxiliary_exam' => '辅助检查', + ]; + } + + /** + * 将病历格式化为 prompt 行;空字段跳过,生命体征单独拼一行 + * + * @return list + */ + private function formatMedicalRecordPromptLines(array $mr): array + { + $lines = []; + foreach ($this->medicalRecordFieldLabels() as $key => $label) { + $val = trim((string) ($mr[$key] ?? '')); + if ($val === '') { + continue; + } + $lines[] = $label . ':' . $val; + } + $vitals = []; + $temp = (float) ($mr['temperature'] ?? 0); + if ($temp > 0) { + $vitals[] = '体温' . $temp . '℃'; + } + $height = (float) ($mr['height'] ?? 0); + if ($height > 0) { + $vitals[] = '身高' . $height . 'cm'; + } + $weight = (float) ($mr['weight'] ?? 0); + if ($weight > 0) { + $vitals[] = '体重' . $weight . 'kg'; + } + $rr = (int) ($mr['respiratory_rate'] ?? 0); + if ($rr > 0) { + $vitals[] = '呼吸' . $rr . '次/分'; + } + $sys = (int) ($mr['bp_systolic'] ?? 0); + $dia = (int) ($mr['bp_diastolic'] ?? 0); + if ($sys > 0 || $dia > 0) { + $vitals[] = '血压' . ($sys > 0 ? (string) $sys : '—') . '/' . ($dia > 0 ? (string) $dia : '—') . 'mmHg'; + } + if (!empty($vitals)) { + $lines[] = '生命体征:' . implode(',', $vitals); + } + return $lines; + } + + /** + * 合并库内病历与前端未保存表单:前端有内容的字段覆盖库值 + */ + private function mergeMedicalRecordForAi(array $dbMr, array $override): array + { + if (empty($override)) { + return $dbMr; + } + $keys = array_merge( + array_keys($this->medicalRecordFieldLabels()), + ['temperature', 'height', 'weight', 'respiratory_rate', 'bp_systolic', 'bp_diastolic'] + ); + foreach ($keys as $key) { + if (!array_key_exists($key, $override)) { + continue; + } + $raw = $override[$key]; + if (is_array($raw) || is_object($raw)) { + continue; + } + if (is_string($raw)) { + $trimmed = trim($raw); + if ($trimmed !== '') { + $dbMr[$key] = $trimmed; + } + continue; + } + if (is_numeric($raw) && (float) $raw != 0.0) { + $dbMr[$key] = $raw; + } + } + return $dbMr; + } + + /** + * 生成快照:只保留有内容的病历字段,便于追溯出方依据 + */ + private function pickMedicalRecordSnapshot(array $mr): array + { + $out = []; + foreach (array_keys($this->medicalRecordFieldLabels()) as $key) { + $val = trim((string) ($mr[$key] ?? '')); + if ($val !== '') { + $out[$key] = $val; + } + } + foreach (['temperature', 'height', 'weight', 'respiratory_rate', 'bp_systolic', 'bp_diastolic'] as $key) { + $n = (float) ($mr[$key] ?? 0); + if ($n != 0.0) { + $out[$key] = $mr[$key]; + } + } + return $out; + } + + /** + * 中药十八反/十九畏/有毒检测(复用开方相冲规则) + * + * @return array{is_exist: bool, messages: string[]} + */ + private function detectChineseMedicineConflict(array $items): array + { + $names = []; + foreach ($items as $item) { + $n = trim((string) ($item['name'] ?? '')); + if ($n !== '') { + $names[] = $n; + } + } + if (empty($names)) { + return ['is_exist' => false, 'messages' => []]; + } + $raw = PrescriptionService::getInstance()->checkChineseMedicineConflict($names); + $messages = []; + $msgBag = $raw['message'] ?? null; + if (is_array($msgBag)) { + foreach ($msgBag as $group) { + if (!is_array($group)) { + continue; + } + foreach ($group as $line) { + $line = trim((string) $line); + if ($line !== '') { + $messages[] = $line; + } + } + } + } elseif (is_string($msgBag) && trim($msgBag) !== '') { + $messages[] = trim($msgBag); + } + return [ + 'is_exist' => !empty($raw['is_exist']), + 'messages' => $messages, + ]; + } + + /** + * 中药:解析完整方对象;失败则按结构问题定向重试(最多补 2 次) + * 若仅缺 drug,第三次只补药品数组并与上次元数据合并 + * + * @param list $useWayNames 煎法词典,写入纠正提示 + * @return array{chat: AiChatResult, items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string} + */ + private function chatAndParseTcmPrescription($agent, array $messages, array $options = [], array $useWayNames = []): array + { + $sample = '{' + . '"prescription_name":"金匮肾气丸加减",' + . '"reason":"辨证立法说明",' + . '"basis":"依据病历要点与典籍",' + . '"dosage":7,"day_dosage":2,"drug":[' + . '{"name":"熟地黄","dose":"15","unit":"g","usage":""},' + . '{"name":"山茱萸","dose":"12","unit":"g","usage":""},' + . '{"name":"茯苓","dose":"15","unit":"g","usage":""},' + . '{"name":"附子","dose":"9","unit":"g","usage":"先煎"},' + . '{"name":"薄荷","dose":"6","unit":"g","usage":"后下"},' + . '{"name":"炙甘草","dose":"6","unit":"g","usage":""},' + . '{"name":"桂枝","dose":"9","unit":"g","usage":""},' + . '{"name":"白芍","dose":"12","unit":"g","usage":""},' + . '{"name":"干姜","dose":"6","unit":"g","usage":""},' + . '{"name":"泽泻","dose":"10","unit":"g","usage":""}' + . ']}'; + $usageHint = !empty($useWayNames) + ? ('usage 只能从词典选:' . implode('、', $useWayNames) . ';普通煎服填空。') + : 'usage 填先煎/后下等,普通煎服填空。'; + $chat = $agent->chatCompletions($messages, $options); + $parsed = $this->tryParseTcmPrescriptionPayload($chat->content); + if ($parsed !== null) { + return array_merge(['chat' => $chat], $parsed); + } + // 第2次:按第1次结构问题定向纠正 + $hint1 = $this->diagnoseTcmPrescriptionStructure($chat->content); + $retryUser = $this->buildTcmFormatRetryPrompt($hint1, $sample, $usageHint); + $retryMessages = array_merge($messages, [ + ['role' => 'assistant', 'content' => $chat->content], + ['role' => 'user', 'content' => $retryUser], + ]); + $chat2 = $agent->chatCompletions($retryMessages, $options); + $parsed2 = $this->tryParseTcmPrescriptionPayload($chat2->content); + if ($parsed2 !== null) { + return array_merge(['chat' => $chat2], $parsed2); + } + // 第3次:若已有方名/理由等但缺 drug,只补药品数组再合并 + $meta = $this->extractTcmMetaWithoutDrug($chat2->content) + ?: $this->extractTcmMetaWithoutDrug($chat->content); + $hint2 = $this->diagnoseTcmPrescriptionStructure($chat2->content); + if ($meta !== null && $this->isMissingDrugOnly($chat2->content)) { + $drugOnlyPrompt = '你上次几乎正确,但漏了最关键的顶层 drug 药品数组。' + . '现在只输出一个 JSON 数组(不要对象、不要 markdown),' + . '约10~18味中药,元素字段 name/dose/unit/usage;' . $usageHint + . '样例:[{"name":"桂枝","dose":"9","unit":"g","usage":"先煎"},{"name":"白芍","dose":"15","unit":"g","usage":""}]'; + $retry3 = array_merge($retryMessages, [ + ['role' => 'assistant', 'content' => $chat2->content], + ['role' => 'user', 'content' => $drugOnlyPrompt], + ]); + $chat3 = $agent->chatCompletions($retry3, $options); + $drugs = $this->tryParseJsonArray($chat3->content); + if ($drugs === null) { + // 也兼容又包了一层对象 + $pack = $this->tryParseTcmPrescriptionPayload($chat3->content); + if ($pack !== null) { + return array_merge(['chat' => $chat3], $pack); + } + } else { + $normalized = $this->tryNormalizeDrugList($drugs); + if ($normalized !== null && !empty($normalized)) { + return [ + 'chat' => $chat3, + 'items' => $normalized, + 'dosage' => $meta['dosage'] > 0 ? $meta['dosage'] : 7, + 'day_dosage' => $meta['day_dosage'] > 0 ? $meta['day_dosage'] : 2, + 'reason' => $meta['reason'], + 'basis' => $meta['basis'], + 'prescription_name' => $meta['prescription_name'], + ]; + } + } + $raw = "【第1次】\n" . $chat->content + . "\n\n【第2次】\n" . $chat2->content + . "\n\n【第3次】\n" . $chat3->content; + $hint3 = $this->diagnoseTcmPrescriptionStructure($chat3->content); + $structureHint = '第1次:' . $hint1 . ';第2次:' . $hint2 + . '(已修正部分字段但仍缺药品);第3次补 drug 仍失败:' . ($hint3 !== '' ? $hint3 : '未能得到有效药品数组'); + $this->throwAiFormatError( + '中药处方 JSON 对象{prescription_name,reason,basis,dosage,day_dosage,drug}', + $raw, + $structureHint + ); + } + $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; + $structureHint = '第1次:' . $hint1; + if ($hint2 !== '' && $hint2 !== $hint1) { + $structureHint .= ';第2次:' . $hint2; + } + $this->throwAiFormatError( + '中药处方 JSON 对象{prescription_name,reason,basis,dosage,day_dosage,drug}', + $raw, + $structureHint + ); + return [ + 'chat' => $chat2, + 'items' => [], + 'dosage' => 7, + 'day_dosage' => 2, + 'reason' => '', + 'basis' => '', + 'prescription_name' => '', + ]; + } + + /** + * 根据诊断结果生成定向纠正提示(比泛泛的「格式错误」更有效) + */ + private function buildTcmFormatRetryPrompt(string $structureHint, string $sample, string $usageHint): string + { + $parts = ['格式错误,请按下列要求重输出完整 JSON(不要 markdown)。']; + if ($structureHint !== '') { + $parts[] = '当前问题:' . $structureHint . '。'; + } + $parts[] = '强制规则:' + . '1) dosage 必须是顶层整数(开几剂,如7),禁止对象/文案;' + . '2) day_dosage 必须是顶层整数(每天几次,如2),禁止「每日2-3次」这类字符串;' + . '3) drug 必须是顶层数组,约10~18味,每味含 name/dose/unit/usage,' . $usageHint + . '禁止漏掉 drug;' + . '4) reason/basis 各控制在80字以内,优先保证 drug 完整输出。' + . '正确样例:' . $sample; + return implode('', $parts); + } + + /** + * 是否「其它字段大致有了,主要缺 drug」 + */ + private function isMissingDrugOnly(string $content): bool + { + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data) || array_is_list($data)) { + return false; + } + $data = $this->flattenMisnestedTcmPrescription($data); + foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { + if (isset($data[$k]) && is_array($data[$k]) && array_is_list($data[$k]) && !empty($data[$k])) { + return false; + } + } + // 至少有方名或理由之一,说明模型在出方语境中,只是漏了药品 + $name = trim((string) ($data['prescription_name'] ?? $data['formula_name'] ?? '')); + $reason = trim((string) ($data['reason'] ?? '')); + return $name !== '' || $reason !== ''; + } + + /** + * 从半成品 JSON 提取药方元数据(无 drug 时仍可用于合并) + * + * @return array{prescription_name:string,reason:string,basis:string,dosage:int,day_dosage:int}|null + */ + private function extractTcmMetaWithoutDrug(string $content): ?array + { + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data) || array_is_list($data)) { + return null; + } + $data = $this->flattenMisnestedTcmPrescription($data); + $prescriptionName = trim((string) ( + $data['prescription_name'] ?? $data['formula_name'] ?? $data['方名'] ?? $data['药方名字'] ?? '' + )); + $reason = trim((string) ($data['reason'] ?? $data['why'] ?? '')); + $basis = trim((string) ($data['basis'] ?? $data['according'] ?? '')); + if ($prescriptionName === '' && $reason === '' && $basis === '') { + return null; + } + return [ + 'prescription_name' => $prescriptionName, + 'reason' => $reason, + 'basis' => $basis, + 'dosage' => $this->toPositiveInt($data['dosage'] ?? $data['dose_count'] ?? $data['剂数'] ?? 0), + 'day_dosage' => $this->parseDayDosageValue( + $data['day_dosage'] ?? $data['day_times'] ?? $data['consumption'] ?? $data['每天几次'] ?? 0 + ), + ]; + } + + /** + * 西药等:解析 {prescription_name,reason,basis,drug} 对象(兼容旧版纯数组) + * + * @return array{chat: AiChatResult, items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string} + */ + private function chatAndParseWestPrescription($agent, array $messages, array $options = []): array + { + $sample = '{' + . '"prescription_name":"上感对症方案",' + . '"reason":"对症抗感染与解热",' + . '"basis":"发热咽痛,无过敏史",' + . '"drug":[{"name":"阿莫西林胶囊","dose":"0.5","unit":"g","usage":"口服","frequency":"一日三次"}]' + . '}'; + $chat = $agent->chatCompletions($messages, $options); + $parsed = $this->tryParseWestPrescriptionPayload($chat->content); + if ($parsed !== null) { + return array_merge(['chat' => $chat], $parsed); + } + $retryMessages = array_merge($messages, [ + ['role' => 'assistant', 'content' => $chat->content], + [ + 'role' => 'user', + 'content' => '格式错误。必须输出 JSON 对象,含 prescription_name、reason、basis、drug 数组。样例:' . $sample, + ], + ]); + $chat2 = $agent->chatCompletions($retryMessages, $options); + $parsed2 = $this->tryParseWestPrescriptionPayload($chat2->content); + if ($parsed2 !== null) { + return array_merge(['chat' => $chat2], $parsed2); + } + $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; + $this->throwAiFormatError('西药处方 JSON 对象{prescription_name,reason,basis,drug}', $raw); + return [ + 'chat' => $chat2, + 'items' => [], + 'dosage' => 0, + 'day_dosage' => 0, + 'reason' => '', + 'basis' => '', + 'prescription_name' => '', + ]; + } + + /** + * 解析中药完整方:优先对象,兼容旧版纯数组与常见嵌套误结构 + * + * @return array{items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string}|null + */ + private function tryParseTcmPrescriptionPayload(string $content): ?array + { + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data)) { + return null; + } + $reason = ''; + $basis = ''; + $prescriptionName = ''; + $dosage = 0; + $dayDosage = 0; + $items = null; + if (array_is_list($data)) { + $items = $data; + $dosage = 7; + $dayDosage = 2; + } else { + // 兼容 dosage:{day_dosage,drug} 等嵌套误结构,先拍平再取字段 + $data = $this->flattenMisnestedTcmPrescription($data); + $prescriptionName = trim((string) ( + $data['prescription_name'] ?? $data['formula_name'] ?? $data['方名'] ?? $data['药方名字'] ?? '' + )); + // 部分模型用顶层 name 当方名;仅在已有 drug 数组且非单味药对象时采用 + if ( + $prescriptionName === '' + && isset($data['name'], $data['drug']) + && is_string($data['name']) + && is_array($data['drug']) + && !$this->looksLikeDrugItem($data) + ) { + $prescriptionName = trim($data['name']); + } + $reason = trim((string) ($data['reason'] ?? $data['why'] ?? $data['出方理由'] ?? '')); + $basis = trim((string) ($data['basis'] ?? $data['according'] ?? $data['依据'] ?? '')); + $dosage = $this->toPositiveInt($data['dosage'] ?? $data['dose_count'] ?? $data['剂数'] ?? 0); + $dayDosage = $this->parseDayDosageValue( + $data['day_dosage'] ?? $data['day_times'] ?? $data['consumption'] ?? $data['每天几次'] ?? 0 + ); + foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $key) { + if (isset($data[$key]) && is_array($data[$key])) { + $list = $data[$key]; + if (array_is_list($list)) { + $items = $list; + break; + } + } + } + if ($items === null && $this->looksLikeDrugItem($data)) { + $items = [$data]; + } + } + if ($items === null) { + return null; + } + $normalizedList = $this->tryNormalizeDrugList($items); + if ($normalizedList === null) { + return null; + } + return [ + 'items' => $normalizedList, + 'dosage' => $dosage > 0 ? $dosage : 7, + 'day_dosage' => $dayDosage > 0 ? $dayDosage : 2, + 'reason' => $reason, + 'basis' => $basis, + 'prescription_name' => $prescriptionName, + ]; + } + + /** + * 拍平常见误结构:dosage 被写成对象,把 day_dosage/drug 嵌在里面 + * 例:{"dosage":{"day_dosage":7,"drug":[...]}} → 顶层 dosage/day_dosage/drug + * 例:{"dosage":{"dose":"10-15g","unit":"g"}} → 丢弃非法 dosage,留给默认值 + */ + private function flattenMisnestedTcmPrescription(array $data): array + { + if (!isset($data['dosage']) || !is_array($data['dosage']) || array_is_list($data['dosage'])) { + return $data; + } + $nested = $data['dosage']; + // dosage 被误写成「单味药剂量」对象(含 dose/unit,无 drug)——直接丢弃 + if ( + (isset($nested['dose']) || isset($nested['unit'])) + && !isset($nested['drug']) + && !isset($nested['drugs']) + && !isset($nested['day_dosage']) + && !isset($nested['dosage']) + ) { + unset($data['dosage']); + return $data; + } + $hadTopDayDosage = array_key_exists('day_dosage', $data) + || array_key_exists('day_times', $data) + || array_key_exists('consumption', $data); + $hadTopDrug = false; + foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { + if (isset($data[$k]) && is_array($data[$k]) && array_is_list($data[$k])) { + $hadTopDrug = true; + break; + } + } + // 把嵌套里的药品/每日次数提升到顶层(顶层已有则不覆盖) + foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { + if (!$hadTopDrug && isset($nested[$k]) && is_array($nested[$k]) && array_is_list($nested[$k])) { + $data[$k] = $nested[$k]; + $hadTopDrug = true; + } + } + if (!$hadTopDayDosage) { + foreach (['day_dosage', 'day_times', 'consumption', '每天几次'] as $k) { + if (isset($nested[$k]) && (is_numeric($nested[$k]) || is_int($nested[$k]) || is_string($nested[$k]))) { + $parsedDay = $this->parseDayDosageValue($nested[$k]); + // 常见误写:dosage:{day_dosage:7, drug:[]} —— 这里的 7 多半是「剂数」不是「每天几次」 + if ( + isset($nested['drug']) + && is_array($nested['drug']) + && !isset($nested['dosage']) + && !isset($nested['剂数']) + && is_numeric($nested[$k]) + ) { + $data['dosage'] = (int) $nested[$k]; + } elseif ($parsedDay > 0) { + $data['day_dosage'] = $parsedDay; + } + break; + } + } + } + // 嵌套内若另有真正的剂数 + if (isset($nested['dosage']) && (is_numeric($nested['dosage']) || is_int($nested['dosage']))) { + $data['dosage'] = (int) $nested['dosage']; + } elseif (isset($nested['剂数']) && is_numeric($nested['剂数'])) { + $data['dosage'] = (int) $nested['剂数']; + } elseif (isset($data['dosage']) && is_array($data['dosage'])) { + // 对象形态的 dosage 已处理完,去掉非法对象,留给后续默认值 + unset($data['dosage']); + } + return $data; + } + + /** + * 诊断中药处方 JSON 结构问题,给医生可读说明 + */ + private function diagnoseTcmPrescriptionStructure(string $content): string + { + $data = $this->decodeJsonPayloadQuiet($content); + if ($data === null) { + return '无法解析为合法 JSON(可能夹杂 markdown/解释文字,或括号不完整)'; + } + if (!is_array($data)) { + return '解析结果不是 JSON 对象/数组'; + } + if (array_is_list($data)) { + return '最外层是数组[],期望对象{},且需含 prescription_name、reason、basis、dosage、day_dosage、drug'; + } + $problems = []; + if (isset($data['dosage']) && is_array($data['dosage']) && !array_is_list($data['dosage'])) { + if (isset($data['dosage']['dose']) || isset($data['dosage']['unit'])) { + $problems[] = 'dosage 被误写成单味药剂量对象(含 dose/unit),它必须是整数「开几剂」如 7'; + } else { + $problems[] = 'dosage 应为顶层整数(开几剂),不能写成对象;你把字段嵌进了 dosage 里' + . (isset($data['dosage']['drug']) ? '(drug 应在顶层)' : '') + . (isset($data['dosage']['day_dosage']) ? '(day_dosage 应在顶层)' : ''); + } + } elseif (isset($data['dosage']) && !is_numeric($data['dosage']) && !is_int($data['dosage'])) { + $problems[] = 'dosage 类型错误,应为整数,当前为 ' . gettype($data['dosage']); + } + $hasDrug = false; + foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { + if (isset($data[$k]) && is_array($data[$k]) && array_is_list($data[$k]) && count($data[$k]) > 0) { + $hasDrug = true; + break; + } + } + if (!$hasDrug) { + if (isset($data['dosage']['drug']) && is_array($data['dosage']['drug'])) { + $problems[] = '缺少顶层 drug 数组:药品写在了 dosage.drug 内,应提升为顶层 drug'; + } else { + $problems[] = '缺少顶层 drug 药品数组(这是处方主体,不能省略)'; + } + } + if (isset($data['day_dosage']) && is_array($data['day_dosage'])) { + $problems[] = 'day_dosage 应为整数(每天几次),不能是对象/数组'; + } elseif ( + isset($data['day_dosage']) + && is_string($data['day_dosage']) + && $this->parseDayDosageValue($data['day_dosage']) <= 0 + ) { + $problems[] = 'day_dosage 应为整数如 2,不能写「' . mb_substr(trim($data['day_dosage']), 0, 20) . '」这类文案'; + } + if (empty($problems)) { + if ($hasDrug) { + $drug = $data['drug'] ?? $data['drugs'] ?? $data['items'] ?? []; + if (is_array($drug) && array_is_list($drug) && empty($drug)) { + $problems[] = 'drug 数组为空'; + } elseif (is_array($drug) && !$this->tryNormalizeDrugList($drug)) { + $problems[] = 'drug 数组内药品字段不完整(每味需有 name)'; + } + } + if (empty($problems)) { + $problems[] = '字段齐全性或类型不符合约定,请按样例输出扁平结构'; + } + } + return implode(';', $problems); + } + + /** 宽松转正整数,对象/数组视为 0 */ + private function toPositiveInt(mixed $value): int + { + if (is_int($value)) { + return $value > 0 ? $value : 0; + } + if (is_float($value) || (is_string($value) && is_numeric($value))) { + $n = (int) $value; + return $n > 0 ? $n : 0; + } + return 0; + } + + /** + * 解析每日次数:支持整数,或「每日2次」「一日2-3次」等文案取下限 + */ + private function parseDayDosageValue(mixed $value): int + { + $n = $this->toPositiveInt($value); + if ($n > 0) { + return $n; + } + if (!is_string($value)) { + return 0; + } + $text = trim($value); + if ($text === '') { + return 0; + } + if (preg_match('/(\d+)\s*[-~~到至]\s*(\d+)/u', $text, $m)) { + return max(1, (int) $m[1]); + } + if (preg_match('/(\d+)\s*次/u', $text, $m)) { + return max(1, (int) $m[1]); + } + if (preg_match('/(\d+)/u', $text, $m)) { + return max(1, (int) $m[1]); + } + return 0; + } + + /** + * 解析西药处方对象(兼容旧版数组) + * + * @return array{items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string}|null + */ + private function tryParseWestPrescriptionPayload(string $content): ?array + { + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data)) { + return null; + } + if (array_is_list($data)) { + $list = $this->tryNormalizeDrugList($data); + if ($list === null) { + return null; + } + return [ + 'items' => $list, + 'dosage' => 0, + 'day_dosage' => 0, + 'reason' => '', + 'basis' => '', + 'prescription_name' => '', + ]; + } + $prescriptionName = trim((string) ( + $data['prescription_name'] ?? $data['formula_name'] ?? $data['方名'] ?? $data['药方名字'] ?? '' + )); + $reason = trim((string) ($data['reason'] ?? $data['why'] ?? '')); + $basis = trim((string) ($data['basis'] ?? $data['according'] ?? '')); + $items = null; + foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $key) { + if (isset($data[$key]) && is_array($data[$key]) && array_is_list($data[$key])) { + $items = $data[$key]; + break; + } + } + if ($items === null) { + return null; + } + $normalizedList = $this->tryNormalizeDrugList($items); + if ($normalizedList === null) { + return null; + } + return [ + 'items' => $normalizedList, + 'dosage' => 0, + 'day_dosage' => 0, + 'reason' => $reason, + 'basis' => $basis, + 'prescription_name' => $prescriptionName, + ]; + } + + /** + * 将候选结构规范为药品列表(失败返回 null) + */ + private function tryNormalizeDrugList(array $data): ?array + { + if (!array_is_list($data)) { + if ($this->looksLikeDrugItem($data)) { + return [$data]; + } + $values = array_values($data); + $allItems = true; + foreach ($values as $v) { + if (!is_array($v) || !$this->looksLikeDrugItem($v)) { + $allItems = false; + break; + } + } + if ($allItems && !empty($values)) { + return $values; + } + return null; + } + return $data; + } + + /** + * 调用模型并解析 JSON 数组:先宽松读取,失败则纠正提示重试一次 + * + * @return array{chat: AiChatResult, data: array} + */ + private function chatAndParseJsonArray($agent, array $messages, array $options = []): array + { + $chat = $agent->chatCompletions($messages, $options); + $data = $this->tryParseJsonArray($chat->content); + if ($data !== null) { + return ['chat' => $chat, 'data' => $data]; + } + $sample = '[{"name":"阿莫西林胶囊","dose":"0.5","unit":"g","usage":"口服","frequency":"一日三次"},' + . '{"name":"布洛芬缓释胶囊","dose":"0.3","unit":"g","usage":"口服","frequency":"一日两次"}]'; + $retryMessages = array_merge($messages, [ + ['role' => 'assistant', 'content' => $chat->content], + [ + 'role' => 'user', + 'content' => '格式错误:最外层必须是 JSON 数组 []。请严格按样例只输出 JSON 数组,不要 markdown:' + . $sample, + ], + ]); + $chat2 = $agent->chatCompletions($retryMessages, $options); + $data2 = $this->tryParseJsonArray($chat2->content); + if ($data2 !== null) { + return ['chat' => $chat2, 'data' => $data2]; + } + $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; + $this->throwAiFormatError('JSON 数组', $raw); + return ['chat' => $chat2, 'data' => []]; + } + + /** + * 调用模型并解析 JSON 对象:先宽松读取,失败则纠正提示重试一次 + * + * @return array{chat: AiChatResult, data: array} + */ + private function chatAndParseJsonObject($agent, array $messages, array $options = []): array + { + $chat = $agent->chatCompletions($messages, $options); + $data = $this->tryParseJsonObject($chat->content); + if ($data !== null) { + return ['chat' => $chat, 'data' => $data]; + } + $retryMessages = array_merge($messages, [ + ['role' => 'assistant', 'content' => $chat->content], + [ + 'role' => 'user', + 'content' => '你上次的输出无法解析为 JSON 对象。请严格只输出一个 JSON 对象,' + . '不要 markdown、不要解释、不要前后缀文字。', + ], + ]); + $chat2 = $agent->chatCompletions($retryMessages, $options); + $data2 = $this->tryParseJsonObject($chat2->content); + if ($data2 !== null) { + return ['chat' => $chat2, 'data' => $data2]; + } + $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; + $this->throwAiFormatError('JSON 对象', $raw); + return ['chat' => $chat2, 'data' => []]; + } + + /** + * 尝试解析为 JSON 对象(失败返回 null,不抛异常) + */ + private function tryParseJsonObject(string $content): ?array + { + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data) || array_is_list($data)) { + return null; + } + return $data; + } + + /** + * 尝试解析为 JSON 数组(失败返回 null) + * 兼容:{items:[]}、单个药品对象、{"药名":{...}} 映射结构 + */ + private function tryParseJsonArray(string $content): ?array + { + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data)) { + return null; + } + if (!array_is_list($data)) { + foreach (['items', 'drugs', 'medicines', 'list', 'data', 'prescription'] as $key) { + if (isset($data[$key]) && is_array($data[$key]) && array_is_list($data[$key])) { + $data = $data[$key]; + break; + } + } + } + // 模型常返回单个药品对象:{"name":"熟地黄",...} → 包成数组 + if (!array_is_list($data) && $this->looksLikeDrugItem($data)) { + $data = [$data]; + } + // 模型返回 {"熟地黄":{"name":"熟地黄",...}, "茯苓":{...}} → 取 values + if (!array_is_list($data)) { + $values = array_values($data); + $allItems = true; + foreach ($values as $v) { + if (!is_array($v) || !$this->looksLikeDrugItem($v)) { + $allItems = false; + break; + } + } + if ($allItems && !empty($values)) { + $data = $values; + } + } + if (!array_is_list($data)) { + return null; + } + return $data; + } + + /** + * 判断是否像一条药品 JSON(含 name / drug_name) + */ + private function looksLikeDrugItem(array $row): bool + { + $name = trim((string) ($row['name'] ?? $row['drug_name'] ?? '')); + return $name !== ''; + } + + /** + * 宽松解码:剥 markdown、截取括号、修常见脏字符;失败返回 null + */ + private function decodeJsonPayloadQuiet(string $content): mixed + { + $text = trim($content); + if ($text === '') { + return null; + } + if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $text, $m)) { + $text = trim($m[1]); + } + $startObj = strpos($text, '{'); + $startArr = strpos($text, '['); + $start = false; + if ($startObj === false) { + $start = $startArr; + } elseif ($startArr === false) { + $start = $startObj; + } else { + $start = min($startObj, $startArr); + } + if ($start !== false && $start > 0) { + $text = substr($text, $start); + } + // 按括号配对截到闭合处,去掉尾部解释文字 + $extracted = $this->extractBalancedJson($text); + if ($extracted !== null) { + $text = $extracted; + } + $candidates = [$text, $this->sanitizeJsonText($text)]; + foreach ($candidates as $candidate) { + $decoded = json_decode($candidate, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + return null; + } + + /** + * 从文本中按括号深度截取完整 JSON 片段 + */ + private function extractBalancedJson(string $text): ?string + { + $text = trim($text); + if ($text === '') { + return null; + } + $open = $text[0]; + $close = $open === '[' ? ']' : ($open === '{' ? '}' : ''); + if ($close === '') { + return null; + } + $depth = 0; + $inStr = false; + $escape = false; + $len = strlen($text); + for ($i = 0; $i < $len; $i++) { + $ch = $text[$i]; + if ($inStr) { + if ($escape) { + $escape = false; + continue; + } + if ($ch === '\\') { + $escape = true; + continue; + } + if ($ch === '"') { + $inStr = false; + } + continue; + } + if ($ch === '"') { + $inStr = true; + continue; + } + if ($ch === $open) { + $depth++; + } elseif ($ch === $close) { + $depth--; + if ($depth === 0) { + return substr($text, 0, $i + 1); + } + } + } + return null; + } + + /** + * 修常见脏 JSON:中文引号、尾逗号 + */ + private function sanitizeJsonText(string $text): string + { + $text = str_replace(["\u{201c}", "\u{201d}", '「', '」', '『', '』'], '"', $text); + $text = preg_replace('/,\s*([}\]])/', '$1', $text) ?? $text; + return $text; + } + + /** + * 格式错误抛错:优先说明具体结构问题;超管(role_id=1)附带原始返回便于排障 + * + * @param string $structureHint 诊断出的结构问题说明(可空) + */ + private function throwAiFormatError(string $expectLabel, string $rawContent, string $structureHint = ''): void + { + $msg = 'AI 返回格式错误,期望 ' . $expectLabel; + if ($structureHint !== '') { + $msg .= '。结构问题:' . $structureHint; + } + if ($this->isSuperAdminViewer()) { + $snippet = trim($rawContent); + if (mb_strlen($snippet, 'UTF-8') > 4000) { + $snippet = mb_substr($snippet, 0, 4000, 'UTF-8') . '…(已截断)'; + } + $msg .= '。原始返回:' . ($snippet !== '' ? $snippet : '(空)'); + } else { + $msg .= ',请重试'; + } + $this->utils->errorThrow($msg); + } + + /** + * 当前登录是否超管(可看 AI 原始失败返回) + */ + private function isSuperAdminViewer(): bool + { + return (int) $this->roleId === RoleEnum::SUPPER_ADMIN->value; + } + + /** + * @deprecated 保留兼容;请用 tryParse + chatAndParse + */ + private function parseJsonObject(string $content): array + { + $data = $this->tryParseJsonObject($content); + if ($data === null) { + $this->throwAiFormatError('JSON 对象', $content); + } + return $data; + } + + /** + * @deprecated 保留兼容;请用 tryParse + chatAndParse + */ + private function parseJsonArray(string $content): array + { + $data = $this->tryParseJsonArray($content); + if ($data === null) { + $this->throwAiFormatError('JSON 数组', $content); + } + return $data; + } + + /** + * AI 请求前落库:status=0 进行中;provider/model/api_key_id 来自运行时配置 + * started_at 存 unix 秒;毫秒起点挂在模型动态属性供 finish 算 duration_ms + * + * @param int $storeId 门店 + * @param int $registerId 挂号 + * @param int $doctorId 医生 + * @param string $scene medical_record|prescription + * @param int $prescriptionType 处方类型(病历为 0) + * @param string|null $provider 供应商覆盖 + * @param array $inputSnapshot 输入快照 + */ + private function beginGeneration( + int $storeId, + int $registerId, + int $doctorId, + string $scene, + int $prescriptionType, + ?string $provider, + array $inputSnapshot + ): AiGenerationModel { + $now = time(); + $startedMs = (int) (microtime(true) * 1000); + $cfg = AiRuntimeConfigService::getInstance()->resolve($provider); + $row = new AiGenerationModel(); + $row->fill([ + 'store_id' => $storeId, + 'register_id' => $registerId, + 'doctor_id' => $doctorId, + 'scene' => $scene, + 'prescription_type' => $prescriptionType, + 'name' => '', + 'provider' => (string) ($cfg['provider'] ?? ''), + 'model' => (string) ($cfg['model'] ?? ''), + 'status' => 0, + 'api_key_id' => (int) ($cfg['api_key_id'] ?? 0), + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'total_tokens' => 0, + 'usage_json' => null, + 'input_snapshot' => $inputSnapshot, + 'result_json' => null, + 'raw_response' => '', + 'started_at' => $now, + 'finished_at' => 0, + 'duration_ms' => 0, + 'error_msg' => '', + 'created_at' => $now, + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + $row->save(); + // 仅内存:用模型已声明属性,避免 Eloquent 当列落库 + $row->runtimeStartedMs = $startedMs; + return $row; + } + + /** + * AI 成功回写:status=1、tokens、结果、finished_at、duration_ms + * + * @param AiGenerationModel $row begin 返回的行 + * @param AiChatResult $chat 对话结果 + * @param array $resultJson 业务结果 JSON + */ + private function finishGenerationSuccess( + AiGenerationModel $row, + AiChatResult $chat, + array $resultJson + ): AiGenerationModel { + $now = time(); + $usage = is_array($chat->usage) ? $chat->usage : []; + $promptTokens = (int) ($usage['prompt_tokens'] ?? 0); + $completionTokens = (int) ($usage['completion_tokens'] ?? 0); + $totalTokens = (int) ($usage['total_tokens'] ?? ($promptTokens + $completionTokens)); + $row->fill([ + 'name' => mb_substr(trim((string) ( + $resultJson['prescription_name'] + ?? $resultJson['name'] + ?? $resultJson['diagnosis'] + ?? '' + )), 0, 128), + 'provider' => $chat->provider !== '' ? $chat->provider : (string) $row->provider, + 'model' => $chat->model !== '' ? $chat->model : (string) $row->model, + 'status' => 1, + 'api_key_id' => $chat->apiKeyId > 0 ? $chat->apiKeyId : (int) $row->api_key_id, + 'prompt_tokens' => $promptTokens, + 'completion_tokens' => $completionTokens, + 'total_tokens' => $totalTokens, + 'usage_json' => $usage ?: null, + 'result_json' => $resultJson, + 'raw_response' => $chat->content, + 'finished_at' => $now, + 'duration_ms' => $this->calcGenerationDurationMs($row), + 'error_msg' => '', + 'updated_at' => $now, + ]); + $row->save(); + return $row; + } + + /** + * AI 失败回写:status=2、error_msg(截断 1000)、finished_at、duration_ms;有 chat 则尽量落 raw + * + * @param AiGenerationModel $row begin 返回的行 + * @param string $errorMsg 失败原因 + * @param AiChatResult|null $chat 若已有部分响应用于排障 + */ + private function finishGenerationFail( + AiGenerationModel $row, + string $errorMsg, + ?AiChatResult $chat = null + ): AiGenerationModel { + $now = time(); + $patch = [ + 'status' => 2, + 'finished_at' => $now, + 'duration_ms' => $this->calcGenerationDurationMs($row), + 'error_msg' => mb_substr(trim($errorMsg), 0, 1000), + 'updated_at' => $now, + ]; + if ($chat) { + $usage = is_array($chat->usage) ? $chat->usage : []; + $promptTokens = (int) ($usage['prompt_tokens'] ?? 0); + $completionTokens = (int) ($usage['completion_tokens'] ?? 0); + $totalTokens = (int) ($usage['total_tokens'] ?? ($promptTokens + $completionTokens)); + $patch['provider'] = $chat->provider !== '' ? $chat->provider : (string) $row->provider; + $patch['model'] = $chat->model !== '' ? $chat->model : (string) $row->model; + $patch['api_key_id'] = $chat->apiKeyId > 0 ? $chat->apiKeyId : (int) $row->api_key_id; + $patch['prompt_tokens'] = $promptTokens; + $patch['completion_tokens'] = $completionTokens; + $patch['total_tokens'] = $totalTokens; + $patch['usage_json'] = $usage ?: null; + $patch['raw_response'] = $chat->content; + } + $row->fill($patch); + $row->save(); + return $row; + } + + /** + * 计算生成耗时(毫秒):优先用 begin 时挂载的 runtimeStartedMs,否则回落 started_at 秒 + */ + private function calcGenerationDurationMs(AiGenerationModel $row): int + { + $startedMs = (int) ($row->runtimeStartedMs ?? 0); + // 兼容旧写法误入 attributes 的 _started_ms(若有则读完后剔除,避免 save 报列不存在) + if ($startedMs <= 0 && array_key_exists('_started_ms', $row->getAttributes())) { + $startedMs = (int) $row->getAttribute('_started_ms'); + } + if (array_key_exists('_started_ms', $row->getAttributes())) { + $row->offsetUnset('_started_ms'); + } + $nowMs = (int) (microtime(true) * 1000); + if ($startedMs > 0) { + return max(0, $nowMs - $startedMs); + } + $startedAt = (int) ($row->started_at ?? 0); + if ($startedAt > 0) { + return max(0, $nowMs - $startedAt * 1000); + } + return 0; + } + + /** + * 加载委托调剂上下文:父规则 + 子煎法列表 + 备注 + * + * @return array{enabled:bool,process_rule_id:int,process_rule_name:string,children:list,notes:list} + */ + private function loadEntrustedProcessContext(bool $useEntrusted, int $processRuleId): array + { + $empty = [ + 'enabled' => false, + 'process_rule_id' => 0, + 'process_rule_name' => '', + 'children' => [], + 'notes' => [], + ]; + if (!$useEntrusted || $processRuleId <= 0) { + return $empty; + } + $parent = ProcessRuleModel::where('id', $processRuleId)->first(['id', 'name', 'pid']); + if (!$parent) { + return $empty; + } + $children = ProcessRuleModel::where('pid', $processRuleId) + ->orderBy('id') + ->get(['id', 'name']) + ->map(static fn ($r) => [ + 'id' => (int) $r->id, + 'name' => trim((string) $r->name), + ]) + ->filter(static fn ($r) => $r['name'] !== '') + ->values() + ->all(); + $childIds = array_column($children, 'id'); + $notes = []; + if (!empty($childIds)) { + $notes = ProcessRuleNoteModel::whereIn('rule_id', $childIds) + ->orderBy('id') + ->get(['id', 'rule_id', 'note']) + ->map(static fn ($n) => [ + 'id' => (int) $n->id, + 'rule_id' => (int) $n->rule_id, + 'note' => trim((string) $n->note), + ]) + ->filter(static fn ($n) => $n['note'] !== '') + ->values() + ->all(); + } + return [ + 'enabled' => true, + 'process_rule_id' => (int) $parent->id, + 'process_rule_name' => trim((string) $parent->name), + 'children' => $children, + 'notes' => $notes, + ]; + } + + /** + * 组装委托调剂 / 非委托 的提示词约束 + * return_detail=false 时:只限定一级制剂,禁止输出剂数/每日次数/二级详细规则 + */ + private function buildEntrustedProcessPromptHint(array $processCtx): string + { + if (empty($processCtx['enabled'])) { + return '禁止输出 child_process_rule_name、process_rule_note、child_process_rule_id、process_rule_id 等加工/煎法字段。'; + } + $formName = (string) ($processCtx['process_rule_name'] ?: ('ID' . ($processCtx['process_rule_id'] ?? 0))); + $returnDetail = !array_key_exists('return_detail', $processCtx) || !empty($processCtx['return_detail']); + if (!$returnDetail) { + // 不需要详细规则:仍须出剂数/每日次数供导入;仅禁止二级制剂字段 + return '本方启用「委托调剂」,一级制剂要求为「' . $formName . '」,' + . '组方须符合该剂型;仍须输出 dosage、day_dosage(常用 7 与 2);' + . '禁止输出 child_process_rule_name、process_rule_note、child_process_rule_id、process_rule_id 等详细制剂字段。'; + } + $names = array_column($processCtx['children'] ?? [], 'name'); + if (empty($names)) { + return '当前制剂要求下无可用煎法说明,不要输出 child_process_rule_name。' + . '仍须输出 dosage、day_dosage。'; + } + $hint = '本方启用「委托调剂」,一级制剂要求为「' + . $formName + . '」(须严格按该一级剂型出方,如汤剂/丸剂/颗粒/膏方等均以医生所选为准)。' + . '必须额外输出顶层字段 child_process_rule_name:只能从下列二级说明中选且仅选一个——' + . implode('、', $names) + . '。'; + // 备注挂在二级煎法上:按煎法分组列出,有备注时强制 AI 选一个,避免导入丢「附加备注」 + $notesByChild = []; + foreach ($processCtx['notes'] ?? [] as $note) { + $rid = (int) ($note['rule_id'] ?? 0); + $text = trim((string) ($note['note'] ?? '')); + if ($rid <= 0 || $text === '') { + continue; + } + $notesByChild[$rid][] = $text; + } + $noteHintParts = []; + foreach ($processCtx['children'] ?? [] as $child) { + $cid = (int) ($child['id'] ?? 0); + $cname = (string) ($child['name'] ?? ''); + if ($cid <= 0 || $cname === '' || empty($notesByChild[$cid])) { + continue; + } + $noteHintParts[] = '「' . $cname . '」→ ' . implode('、', array_values(array_unique($notesByChild[$cid]))); + } + if (!empty($noteHintParts)) { + $hint .= '必须额外输出顶层字段 process_rule_note:须与所选 child_process_rule_name 对应,' + . '只能从该煎法下列备注中选且仅选一个——' + . implode(';', $noteHintParts) + . '。禁止留空。'; + } else { + $hint .= '当前二级煎法下无附加备注,process_rule_note 输出空字符串。'; + } + return $hint; + } + + /** + * 从 AI 响应解析委托调剂字段,并映射到 process_rule_id / child_process_rule_id / note_id + * return_detail=false 时只回传一级制剂 id/name + * + * @return array + */ + private function resolveEntrustedProcessFromChat(string $content, array $processCtx): array + { + if (empty($processCtx['enabled'])) { + return []; + } + $base = [ + 'process_rule_id' => (int) ($processCtx['process_rule_id'] ?? 0), + 'process_rule_name' => (string) ($processCtx['process_rule_name'] ?? ''), + ]; + $returnDetail = !array_key_exists('return_detail', $processCtx) || !empty($processCtx['return_detail']); + if (!$returnDetail) { + return array_merge($base, [ + 'child_process_rule_id' => 0, + 'child_process_rule_name' => '', + 'process_rule_note_id' => 0, + 'process_rule_note' => '', + ]); + } + $data = $this->decodeJsonPayloadQuiet($content); + if (!is_array($data) || array_is_list($data)) { + return array_merge($base, [ + 'child_process_rule_id' => 0, + 'child_process_rule_name' => '', + 'process_rule_note_id' => 0, + 'process_rule_note' => '', + ]); + } + $childName = trim((string) ( + $data['child_process_rule_name'] ?? $data['煎法说明'] ?? $data['煎法'] ?? '' + )); + $noteText = trim((string) ( + $data['process_rule_note'] ?? $data['加工备注'] ?? $data['附加备注'] ?? $data['备注'] ?? '' + )); + $childId = 0; + $matchedChildName = ''; + foreach ($processCtx['children'] as $child) { + $n = (string) ($child['name'] ?? ''); + if ($n !== '' && ($n === $childName || mb_strpos($childName, $n) !== false || mb_strpos($n, $childName) !== false)) { + $childId = (int) $child['id']; + $matchedChildName = $n; + break; + } + } + // 二级未匹配到时,尽量用列表第一项兜底,保证导入能级联出备注 + if ($childId <= 0 && !empty($processCtx['children'][0])) { + $childId = (int) ($processCtx['children'][0]['id'] ?? 0); + $matchedChildName = (string) ($processCtx['children'][0]['name'] ?? ''); + } + $noteId = 0; + $matchedNote = ''; + $childNotes = []; + foreach ($processCtx['notes'] as $note) { + if ((int) ($note['rule_id'] ?? 0) !== $childId) { + continue; + } + $n = (string) ($note['note'] ?? ''); + if ($n === '') { + continue; + } + $childNotes[] = $note; + if ($noteText !== '' && ($n === $noteText || mb_strpos($noteText, $n) !== false || mb_strpos($n, $noteText) !== false)) { + $noteId = (int) $note['id']; + $matchedNote = $n; + } + } + // AI 漏写/写错备注时:取该二级煎法下第一条备注,避免导入后「附加备注」空白 + if ($noteId <= 0 && !empty($childNotes[0])) { + $noteId = (int) ($childNotes[0]['id'] ?? 0); + $matchedNote = (string) ($childNotes[0]['note'] ?? ''); + } + return array_merge($base, [ + 'child_process_rule_id' => $childId, + 'child_process_rule_name' => $matchedChildName !== '' ? $matchedChildName : $childName, + 'process_rule_note_id' => $noteId, + 'process_rule_note' => $matchedNote !== '' ? $matchedNote : $noteText, + ]); + } + + /** + * 从 yii_drug_use_way 读取煎法 name 列表,供 AI 约束 usage 与落库对照 + * + * @return list + */ + private function loadDrugUseWayNames(): array + { + return DrugUseWayModel::query() + ->orderBy('id') + ->pluck('name') + ->map(static fn ($n) => trim((string) $n)) + ->filter(static fn ($n) => $n !== '') + ->unique() + ->values() + ->all(); + } + + /** + * 将 AI 返回的 usage 对齐到词典 name(精确或包含匹配);对不上则清空,避免无法映射 way_id + * + * @param list $allowedNames + */ + private function normalizeUsageAgainstDict(string $usage, array $allowedNames): string + { + $usage = trim($usage); + if ($usage === '' || empty($allowedNames)) { + return $usage; + } + if (in_array($usage, $allowedNames, true)) { + return $usage; + } + // 长短优先:先匹配更长的词典项,减少「先煎」被「先」误伤 + $sorted = $allowedNames; + usort($sorted, static fn ($a, $b) => mb_strlen($b) <=> mb_strlen($a)); + foreach ($sorted as $name) { + if ($name !== '' && mb_strpos($usage, $name) !== false) { + return $name; + } + } + return ''; + } + + /** + * 规范化处方 items + * dose 去掉尾部单位;中药 usage 对齐 yii_drug_use_way;不再依赖 frequency + * + * @param list $useWayNames 中药煎法词典;非空时对 usage 做对齐 + */ + private function normalizePrescriptionItems(array $items, array $useWayNames = []): array + { + $out = []; + foreach ($items as $row) { + if (!is_array($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? $row['drug_name'] ?? '')); + if ($name === '') { + continue; + } + $dose = trim((string) ($row['dose'] ?? $row['number'] ?? '')); + // 模型常把单位写进 dose:15g / 15克 + $dose = preg_replace('/\s*(g|克|G)\s*$/u', '', $dose) ?? $dose; + $unit = trim((string) ($row['unit'] ?? '')); + if ($unit === '' && preg_match('/g|克/u', (string) ($row['dose'] ?? ''))) { + $unit = 'g'; + } + $usage = trim((string) ($row['usage'] ?? $row['decoct'] ?? $row['way'] ?? '')); + // 过滤无意义的频次文案误入 usage + if (preg_match('/每日\s*\d+\s*剂|一天\s*\d+\s*剂/u', $usage)) { + $usage = ''; + } + if (in_array($usage, ['煎服', '常规', '普通', '水煎服', '煎汤服'], true)) { + $usage = ''; + } + if (!empty($useWayNames)) { + $usage = $this->normalizeUsageAgainstDict($usage, $useWayNames); + } + $item = [ + 'name' => $name, + 'dose' => $dose, + 'unit' => $unit !== '' ? $unit : 'g', + 'usage' => $usage, + 'frequency' => trim((string) ($row['frequency'] ?? '')), + ]; + // 保留医生改选落库字段,供历史对照还原 selected_drug_id + $savedDrugId = (int) ($row['selected_drug_id'] ?? 0); + if ($savedDrugId > 0) { + $item['selected_drug_id'] = $savedDrugId; + $item['selected_drug_name'] = trim((string) ($row['selected_drug_name'] ?? '')); + $item['is_modified'] = !empty($row['is_modified']) ? 1 : 0; + } + $out[] = $item; + } + return $out; + } + + /** + * 医生在 AI 出方对照中改选药品:写回 result_json 并标记已修改 + * 为什么:对照结果原先只在前端内存,历史重开会丢回默认候选;落库后可还原 + * + * @param int $generationId AI 生成记录 ID + * @param int $itemIndex items 下标 + * @param int $selectedDrugId 选中的药品 ID + * @param string $selectedDrugName 选中药名(展示用) + * @return array{ok: bool, item: array, has_manual_modify: int} + */ + public function savePrescriptionDrugSelection( + int $generationId, + int $itemIndex, + int $selectedDrugId, + string $selectedDrugName = '' + ): array { + if ($generationId <= 0) { + $this->utils->errorThrow('generation_id 无效'); + } + if ($itemIndex < 0) { + $this->utils->errorThrow('item_index 无效'); + } + if ($selectedDrugId <= 0) { + $this->utils->errorThrow('请选择药品'); + } + $row = AiGenerationModel::where('id', $generationId) + ->where('deleted_at', 0) + ->first(); + if (!$row || $row->scene !== self::SCENE_RX) { + $this->utils->errorThrow('AI 出方记录不存在'); + } + $result = is_array($row->result_json) ? $row->result_json : []; + $items = is_array($result['items'] ?? null) ? $result['items'] : []; + if (!isset($items[$itemIndex]) || !is_array($items[$itemIndex])) { + $this->utils->errorThrow('药品行不存在'); + } + $prevId = (int) ($items[$itemIndex]['selected_drug_id'] ?? 0); + $items[$itemIndex]['selected_drug_id'] = $selectedDrugId; + $items[$itemIndex]['selected_drug_name'] = trim($selectedDrugName); + // 相对「从未改选」或「改成了别的药」均标记;首次写入也视为医生确认过选择 + $items[$itemIndex]['is_modified'] = 1; + $result['items'] = $items; + $result['has_manual_modify'] = 1; + $row->result_json = $result; + $row->updated_at = time(); + $row->save(); + return [ + 'ok' => true, + 'item' => $items[$itemIndex], + 'has_manual_modify' => 1, + 'prev_selected_drug_id' => $prevId, + ]; + } +} diff --git a/app/Service/common/ai/AiRequirementPromptService.php b/app/Service/common/ai/AiRequirementPromptService.php new file mode 100644 index 00000000..285f7ecf --- /dev/null +++ b/app/Service/common/ai/AiRequirementPromptService.php @@ -0,0 +1,162 @@ +utils->errorThrow('请输入模块名'); + } + if ($mode === '') { + $mode = $description !== '' ? 'optimize' : 'generate'; + } + if (!in_array($mode, ['generate', 'optimize'], true)) { + $this->utils->errorThrow('mode 仅支持 generate 或 optimize'); + } + if ($mode === 'optimize' && $description === '') { + $this->utils->errorThrow('优化模式请先填写当前功能描述'); + } + + $runtime = AiRuntimeConfigService::getInstance()->resolve(); + $log = AiGenerationLogService::getInstance(); + $generationId = $log->begin([ + 'scene' => AiGenerationSceneEnum::REQUIREMENT_PROMPT->value, + 'name' => $moduleName, + 'provider' => (string) ($runtime['provider'] ?? ''), + 'model' => (string) ($runtime['model'] ?? ''), + 'api_key_id' => (int) ($runtime['api_key_id'] ?? 0), + 'admin_id' => $this->userId, + 'input_snapshot' => [ + 'module_name' => $moduleName, + 'description' => $description, + 'mode' => $mode, + ], + ]); + + try { + $agent = AiAgentFactory::getInstance()->make(); + $messages = [ + ['role' => 'system', 'content' => $this->buildSystemPrompt()], + ['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $mode)], + ]; + $result = $agent->chatCompletions($messages, [ + 'temperature' => 0.35, + 'max_tokens' => 2048, + ]); + $text = $this->normalizePlainText($result->content); + $normalized = [ + // text 为主字段;prompt/overview 兼容旧前端「替换导入」 + 'text' => $text, + 'prompt' => $text, + 'overview' => $text, + 'mode' => $mode, + 'module_name' => $moduleName, + ]; + $log->markSuccess($generationId, [ + 'provider' => $result->provider, + 'model' => $result->model, + 'api_key_id' => $result->apiKeyId, + 'usage' => $result->usage, + 'result_json' => $normalized, + 'raw_response' => $result->content, + 'name' => $moduleName, + ]); + return $normalized; + } catch (Throwable $e) { + $log->markFail($generationId, $e->getMessage(), [ + 'provider' => (string) ($runtime['provider'] ?? ''), + 'model' => (string) ($runtime['model'] ?? ''), + 'api_key_id' => (int) ($runtime['api_key_id'] ?? 0), + ]); + throw $e; + } + } + + /** + * 系统提示:只要中文功能描述文案,禁止 JSON / markdown 代码块 + */ + private function buildSystemPrompt(): string + { + return <<<'PROMPT' +你是 nl-admin(Vben Admin + Laravel 三层 CRUD 代码生成器)的需求分析助手。 +任务:写出一段可直接填入「功能描述」输入框的中文文案,供下一步 AI 生成表结构与 CRUD 代码使用。 + +必须贴合本系统: +1. 面向后台管理 CRUD:列表、搜索、新增/编辑、软删除、启停。 +2. 不要要求 id / status / created_at / updated_at / deleted_at(系统自动加)。 +3. 说明要管理哪些业务数据、建议字段(用中文含义即可)、哪些适合检索、哪些适合下拉/上传等。 +4. 不要写接口、鉴权、微服务、路由实现细节。 +5. 200~500 字为宜,条理清晰,可用分点,但不要标题堆砌。 + +输出要求(非常重要): +- 只输出中文正文文案本身。 +- 禁止输出 JSON、禁止用 ``` 代码块、禁止前言后语(如「好的」「如下」)。 +PROMPT; + } + + /** + * 用户提示:生成 vs 优化 + */ + private function buildUserPrompt(string $moduleName, string $description, string $mode): string + { + if ($mode === 'optimize') { + return "请优化下面的功能描述,使其更适合 nl-admin 代码生成(保留原意,补全字段与管理动作)。\n模块名:{$moduleName}\n当前描述:\n{$description}"; + } + return "请根据模块名「{$moduleName}」写一段后台管理功能描述文案(适配 nl-admin 代码生成)。"; + } + + /** + * 清洗模型回复为纯文案(去掉偶发的 markdown / JSON 包裹) + */ + private function normalizePlainText(string $content): string + { + $content = trim($content); + if ($content === '') { + $this->utils->errorThrow('AI 未返回内容'); + } + // 去掉 ``` 包裹 + if (preg_match('/```(?:\w+)?\s*([\s\S]*?)```/i', $content, $m)) { + $content = trim($m[1]); + } + // 若模型仍返回 JSON,尽量抽出 prompt/text/overview + if (str_starts_with($content, '{')) { + $decoded = json_decode($content, true); + if (is_array($decoded)) { + $content = trim((string) ($decoded['text'] ?? $decoded['prompt'] ?? $decoded['overview'] ?? '')); + } + } + $content = trim($content); + // 去掉常见客套开头 + $content = preg_replace('/^(好的[,,。]?|如下[::]?|以下是[^\\n]*[::]?)\\s*/u', '', $content) ?? $content; + $content = trim($content); + if ($content === '') { + $this->utils->errorThrow('AI 未生成有效文案'); + } + return $content; + } +} diff --git a/app/Service/common/ai/AiRuntimeConfigService.php b/app/Service/common/ai/AiRuntimeConfigService.php new file mode 100644 index 00000000..e4febb5c --- /dev/null +++ b/app/Service/common/ai/AiRuntimeConfigService.php @@ -0,0 +1,286 @@ +isAuth = false; + parent::__construct(); + } + + /** + * 解析指定供应商的调用配置 + * + * @return array{provider:string,api_url:string,api_key:string,model:string,timeout:float,platform_id:int,api_key_id:int} + */ + public function resolve(?string $providerOverride = null): array + { + $sys = SystemConfigService::getInstance(); + $provider = strtolower(trim((string) ( + $providerOverride + ?: $sys->getValue(self::CFG_PROVIDER, '') + ?: config('ai.default_provider', 'deepseek') + ))); + $modelOverride = trim((string) $sys->getValue(self::CFG_MODEL, '')); + $keyId = (int) $sys->getValue(self::CFG_API_KEY_ID, '0'); + + $platform = AiPlatformModel::where('code', $provider) + ->where('deleted_at', 0) + ->where('status', 1) + ->first(); + + $apiUrl = ''; + $model = ''; + $apiKey = ''; + $platformId = 0; + $timeout = 60.0; + + if ($platform) { + $platformId = (int) $platform->id; + $apiUrl = trim((string) $platform->api_url); + // 模型:系统配置覆盖 → 模型表默认 → 模型表首个启用 + if ($modelOverride !== '') { + $model = $modelOverride; + } else { + $defaultModel = AiModelModel::where('platform_id', $platformId) + ->where('deleted_at', 0) + ->where('status', 1) + ->where('is_default', 1) + ->value('code'); + if ($defaultModel) { + $model = trim((string) $defaultModel); + } else { + $firstModel = AiModelModel::where('platform_id', $platformId) + ->where('deleted_at', 0) + ->where('status', 1) + ->orderByDesc('sort') + ->orderBy('id') + ->value('code'); + $model = trim((string) ($firstModel ?: '')); + } + } + // 指定密钥优先;否则默认密钥;再否则取该平台启用中的第一条 + $keyQuery = AiApiKeyModel::where('platform_id', $platformId) + ->where('deleted_at', 0) + ->where('status', 1); + if ($keyId > 0) { + $keyRow = (clone $keyQuery)->where('id', $keyId)->first(); + } else { + $keyRow = null; + } + if (!$keyRow) { + $keyRow = (clone $keyQuery)->where('is_default', 1)->first() + ?: $keyQuery->orderByDesc('sort')->orderBy('id')->first(); + } + if ($keyRow) { + $apiKey = $this->decryptApiKey((string) $keyRow->api_key); + $keyId = (int) $keyRow->id; + } + } + + // 回落 env,保证未录入密钥时仍可用旧配置 + $envCfg = config('ai.' . $provider, []); + if ($apiUrl === '') { + $apiUrl = (string) ($envCfg['api_url'] ?? ''); + } + if ($model === '') { + $model = (string) ($envCfg['model'] ?? ''); + } + if ($apiKey === '') { + if ($provider === 'spark') { + $apiKey = (string) ($envCfg['password'] ?? ''); + } else { + $apiKey = (string) ($envCfg['api_key'] ?? ''); + } + } + $timeout = (float) ($envCfg['timeout'] ?? 60); + + return [ + 'provider' => $provider, + 'api_url' => $apiUrl, + 'api_key' => $apiKey, + 'model' => $model, + 'timeout' => $timeout, + 'platform_id' => $platformId, + 'api_key_id' => $keyId, + ]; + } + + /** + * 库内 AES 解密 api_key;非加密串原样返回(兼容过渡) + * 委托 FieldEncryptService,与 nl_ai_api_key 密文格式一致 + */ + public function decryptApiKey(string $cipher): string + { + return FieldEncryptService::getInstance()->decryptFromStorage($cipher, true); + } + + /** + * 明文入库前 AES 加密 + * 委托 FieldEncryptService,写出 nl_ase_256_ 前缀密文 + */ + public function encryptApiKey(string $plain): string + { + return FieldEncryptService::getInstance()->encryptForStorage($plain); + } + + /** + * 系统配置页:启用中的平台卡片 + 模型 + 密钥选项 + * 为什么组装成卡片结构:前端 C 端风格按平台展示,减少二次请求 + */ + public function optionsForConfig(): array + { + $platforms = AiPlatformModel::where('deleted_at', 0) + ->where('status', 1) + ->orderByDesc('sort') + ->orderBy('id') + ->get() + ->toArray(); + $models = AiModelModel::where('deleted_at', 0) + ->where('status', 1) + ->orderByDesc('is_default') + ->orderByDesc('sort') + ->orderBy('id') + ->get(['id', 'platform_id', 'code', 'name', 'description', 'is_default']) + ->toArray(); + $keys = AiApiKeyModel::where('deleted_at', 0) + ->where('status', 1) + ->orderByDesc('is_default') + ->orderByDesc('sort') + ->orderBy('id') + ->get(['id', 'platform_id', 'name', 'is_default']) + ->toArray(); + $modelMap = []; + foreach ($models as $m) { + $pid = (int) $m['platform_id']; + $modelMap[$pid][] = [ + 'id' => (int) $m['id'], + 'label' => $m['name'] . '(' . $m['code'] . ')', + 'value' => (string) $m['code'], + 'code' => (string) $m['code'], + 'name' => (string) $m['name'], + 'description' => (string) ($m['description'] ?? ''), + 'is_default' => (int) ($m['is_default'] ?? 0), + ]; + } + $keyMap = []; + foreach ($keys as $k) { + $pid = (int) $k['platform_id']; + $name = trim((string) ($k['name'] ?? '')); + if ($name === '') { + $name = '密钥#' . (int) $k['id']; + } + $keyMap[$pid][] = [ + 'id' => (int) $k['id'], + 'key_name' => $name, + 'name' => $name, + 'label' => $name, + 'value' => (int) $k['id'], + 'is_default' => (int) ($k['is_default'] ?? 0), + ]; + } + $cards = []; + foreach ($platforms as $p) { + $id = (int) $p['id']; + $platModels = $modelMap[$id] ?? []; + $defaultModel = ''; + foreach ($platModels as $pm) { + if ((int) $pm['is_default'] === 1) { + $defaultModel = $pm['code']; + break; + } + } + if ($defaultModel === '' && !empty($platModels[0]['code'])) { + $defaultModel = (string) $platModels[0]['code']; + } + $cards[] = [ + 'id' => $id, + 'code' => $p['code'], + 'name' => $p['name'], + 'logo' => $p['logo'], + 'description' => $p['description'], + 'default_model' => $defaultModel, + 'api_url' => $p['api_url'], + 'models' => $platModels, + 'keys' => $keyMap[$id] ?? [], + ]; + } + $sys = SystemConfigService::getInstance(); + return [ + 'platforms' => $cards, + 'active' => [ + 'provider' => (string) $sys->getValue(self::CFG_PROVIDER, config('ai.default_provider', 'deepseek')), + 'api_key_id' => (int) $sys->getValue(self::CFG_API_KEY_ID, '0'), + 'model' => (string) $sys->getValue(self::CFG_MODEL, ''), + ], + ]; + } + + /** + * 保存默认运行时组合(平台 / 模型 / 密钥) + * 为什么校验平台存在:避免写入无效编码导致代码生成 AI 调用失败 + */ + public function saveActiveConfig(string $provider, string $model, int $apiKeyId): array + { + $provider = strtolower(trim($provider)); + $model = trim($model); + if ($provider === '') { + $this->utils->errorThrow('请选择 AI 平台'); + } + $platform = AiPlatformModel::where('code', $provider) + ->where('deleted_at', 0) + ->where('status', 1) + ->first(); + if (!$platform) { + $this->utils->errorThrow('AI 平台不存在或已禁用'); + } + if ($model === '') { + $this->utils->errorThrow('请选择模型'); + } + $modelExists = AiModelModel::where('platform_id', (int) $platform->id) + ->where('code', $model) + ->where('deleted_at', 0) + ->where('status', 1) + ->exists(); + if (!$modelExists) { + $this->utils->errorThrow('模型不属于当前平台或已禁用'); + } + if ($apiKeyId > 0) { + $keyExists = AiApiKeyModel::where('id', $apiKeyId) + ->where('platform_id', (int) $platform->id) + ->where('deleted_at', 0) + ->where('status', 1) + ->exists(); + if (!$keyExists) { + $this->utils->errorThrow('密钥不属于当前平台或已禁用'); + } + } + $sys = SystemConfigService::getInstance(); + $sys->setValue(self::CFG_PROVIDER, $provider, '当前启用的 AI 平台编码'); + $sys->setValue(self::CFG_MODEL, $model, '当前启用的模型编码'); + $sys->setValue(self::CFG_API_KEY_ID, (string) $apiKeyId, '当前启用的 API 密钥 ID'); + return [ + 'provider' => $provider, + 'model' => $model, + 'api_key_id' => $apiKeyId, + ]; + } +} diff --git a/app/Service/common/ai/DeepSeekAiAgent.php b/app/Service/common/ai/DeepSeekAiAgent.php new file mode 100644 index 00000000..a3541fc6 --- /dev/null +++ b/app/Service/common/ai/DeepSeekAiAgent.php @@ -0,0 +1,95 @@ +isAuth = false; + parent::__construct(); + } + + public function provider(): string + { + return 'deepseek'; + } + + /** + * 调用 DeepSeek chat completions + */ + public function chatCompletions(array $messages, array $options = []): AiChatResult + { + $cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider()); + $apiUrl = (string) ($cfg['api_url'] ?? ''); + $apiKey = (string) ($cfg['api_key'] ?? ''); + $defaultModel = (string) ($cfg['model'] ?? 'deepseek-chat'); + $timeout = (float) ($cfg['timeout'] ?? 60); + if ($apiKey === '') { + $this->utils->errorThrow('DeepSeek 暂未开通(请在「AI密钥管理」或 .env DEEPSEEK_API_KEY 配置)'); + } + if ($apiUrl === '') { + $this->utils->errorThrow('未配置 DeepSeek 接口地址'); + } + if (empty($messages)) { + $this->utils->errorThrow('AI 对话消息不能为空'); + } + $model = (string) ($options['model'] ?? $defaultModel); + $body = [ + 'model' => $model, + 'messages' => array_values($messages), + 'stream' => false, + ]; + foreach (['temperature', 'max_tokens', 'top_p'] as $key) { + if (array_key_exists($key, $options)) { + $body[$key] = $options[$key]; + } + } + try { + $client = new Client([ + 'timeout' => $timeout, + 'connect_timeout' => min(10.0, $timeout), + ]); + $response = $client->request('POST', $apiUrl, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + $raw = json_decode((string) $response->getBody(), true); + if (!is_array($raw)) { + $this->utils->errorThrow('DeepSeek 返回非 JSON'); + } + $content = (string) data_get($raw, 'choices.0.message.content', ''); + if ($content === '') { + $errMsg = (string) ($raw['error']['message'] ?? ''); + $this->utils->errorThrow($errMsg !== '' ? ('DeepSeek 调用失败:' . $errMsg) : 'DeepSeek 未返回有效内容'); + } + $usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : []; + return new AiChatResult( + $content, + $raw, + $this->provider(), + $model, + $usage, + (int) ($cfg['api_key_id'] ?? 0) + ); + } catch (Throwable $e) { + if ($e instanceof GuzzleException) { + $this->utils->errorThrow('DeepSeek 请求失败:' . $e->getMessage()); + } + throw $e; + } + } +} diff --git a/app/Service/common/ai/SparkAiAgent.php b/app/Service/common/ai/SparkAiAgent.php new file mode 100644 index 00000000..518bb88e --- /dev/null +++ b/app/Service/common/ai/SparkAiAgent.php @@ -0,0 +1,100 @@ +isAuth = false; + parent::__construct(); + } + + public function provider(): string + { + return 'spark'; + } + + /** + * 调用星火 chat completions + * Authorization 使用 Bearer + API Key(与官方 OpenAPI 一致) + */ + public function chatCompletions(array $messages, array $options = []): AiChatResult + { + $cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider()); + $apiUrl = (string) ($cfg['api_url'] ?? ''); + $password = (string) ($cfg['api_key'] ?? ''); + $defaultModel = (string) ($cfg['model'] ?? 'lite'); + $timeout = (float) ($cfg['timeout'] ?? 60); + if ($password === '') { + $this->utils->errorThrow('未配置讯飞星火 API 密钥(请在「AI密钥管理」或 .env SPARK_API_PASSWORD 配置)'); + } + if ($apiUrl === '') { + $this->utils->errorThrow('未配置讯飞星火接口地址'); + } + if (empty($messages)) { + $this->utils->errorThrow('AI 对话消息不能为空'); + } + $model = (string) ($options['model'] ?? $defaultModel); + $body = [ + 'model' => $model, + 'messages' => array_values($messages), + ]; + // 可选参数透传,便于业务微调温度等 + foreach (['temperature', 'max_tokens', 'top_p', 'stream'] as $key) { + if (array_key_exists($key, $options)) { + $body[$key] = $options[$key]; + } + } + // 默认关闭流式,业务侧解析整包 JSON 更简单 + if (!isset($body['stream'])) { + $body['stream'] = false; + } + try { + $client = new Client([ + 'timeout' => $timeout, + 'connect_timeout' => min(10.0, $timeout), + ]); + $response = $client->request('POST', $apiUrl, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $password, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + $raw = json_decode((string) $response->getBody(), true); + if (!is_array($raw)) { + $this->utils->errorThrow('讯飞星火返回非 JSON'); + } + $content = (string) data_get($raw, 'choices.0.message.content', ''); + if ($content === '') { + $errMsg = (string) ($raw['error']['message'] ?? $raw['message'] ?? ''); + $this->utils->errorThrow($errMsg !== '' ? ('讯飞星火调用失败:' . $errMsg) : '讯飞星火未返回有效内容'); + } + $usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : []; + return new AiChatResult( + $content, + $raw, + $this->provider(), + $model, + $usage, + (int) ($cfg['api_key_id'] ?? 0) + ); + } catch (Throwable $e) { + if ($e instanceof GuzzleException) { + $this->utils->errorThrow('讯飞星火请求失败:' . $e->getMessage()); + } + throw $e; + } + } +} diff --git a/app/Service/core/CodeGenerationService.php b/app/Service/core/CodeGenerationService.php index b0d793ad..0f65d287 100755 --- a/app/Service/core/CodeGenerationService.php +++ b/app/Service/core/CodeGenerationService.php @@ -312,12 +312,13 @@ class CodeGenerationService $v['default'] = (strpos($v['type'], 'INT') || in_array($v['type'], self::INT_TYPE)) ? 'DEFAULT ' . 0 : 'DEFAULT ' . '\'\''; } + // 字段名加反引号,避免非法标识符直接破坏 CREATE TABLE 语法 + $col = '`' . str_replace('`', '', (string) $v['name']) . '`'; + $comment = str_replace("'", "\\'", (string) $v['comment']); if (in_array($v['type'], self::TEXT_TYPE)) { - // 编写sql语句 - $sql .= " {$v['name']} {$v['type']} {$v['not_null']} NULL COMMENT '{$v['comment']}', "; + $sql .= " {$col} {$v['type']} {$v['not_null']} NULL COMMENT '{$comment}', "; } else { - // 编写sql语句 - $sql .= " {$v['name']} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$v['comment']}', "; + $sql .= " {$col} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$comment}', "; } } diff --git a/config/ai.php b/config/ai.php new file mode 100644 index 00000000..fab564f7 --- /dev/null +++ b/config/ai.php @@ -0,0 +1,20 @@ + env('AI_DEFAULT_PROVIDER', 'deepseek'), + 'spark' => [ + 'api_url' => env('SPARK_API_URL', 'https://spark-api-open.xf-yun.com/v1/chat/completions'), + 'password' => env('SPARK_API_PASSWORD', ''), + 'model' => env('SPARK_API_MODEL', 'lite'), + 'timeout' => (float) env('SPARK_API_TIMEOUT', 60), + ], + 'deepseek' => [ + 'api_url' => env('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'), + 'api_key' => env('DEEPSEEK_API_KEY', ''), + 'model' => env('DEEPSEEK_API_MODEL', 'deepseek-chat'), + 'timeout' => (float) env('DEEPSEEK_API_TIMEOUT', 60), + ], +]; diff --git a/config/helpers.php b/config/helpers.php index aef698f7..6fc8addf 100755 --- a/config/helpers.php +++ b/config/helpers.php @@ -397,7 +397,7 @@ if(!function_exists('ase_encode')) { function ase_encode($str) { if (empty($str)) return $str; - return base64_encode(\Illuminate\Support\Str::random(config('ase.len')). base64_encode($str)); + return base64_encode(\Illuminate\Support\Str::random(config('nl.ase_len', 30)). base64_encode($str)); } } @@ -411,7 +411,7 @@ if (!function_exists('ase_decode')) { { if (empty($str)) return $str?? ''; $result = base64_decode($str); - $result = base64_decode(substr($result, config('ase.len'))); + $result = base64_decode(substr($result, config('nl.ase_len', 30))); $pattern = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]|[\x{E000}-\x{F8FF}]/u'; if (preg_match($pattern, $result)) return $str; return $result; diff --git a/config/nl.php b/config/nl.php index 47e06a66..889c6de3 100755 --- a/config/nl.php +++ b/config/nl.php @@ -24,17 +24,26 @@ return [ 'jwt' => [ 'secret' => env('JWT_SECRET', 'nl_admin_jwt_secret_key_change_me_32b'), ], + /* + * 库内敏感字段 AES 密钥(密文前缀 nl_ase_256_,读取兼容历史 ***) + * 对应 .env:ENCRYPT_KEY + */ + 'encrypt_key' => env('ENCRYPT_KEY', ''), + /* + * 传输层 ase_encode / ase_decode 随机盐长度(与历史 helpers 兼容) + */ + 'ase_len' => (int) env('ASE_LEN', 30), /* * redis配置 */ 'redis' => [ - 'jwt' => 'xk_login_', + 'jwt' => 'nl_login_', 'jwt_ttl' => 360000, // 'jwt_ttl' => 360, - 'email' => 'xk_email_', - 'phone' => 'xk_phone_', - 'login_out_key' => 'xk_login_out_', - 'menu_key' => 'xk_menu_', + 'email' => 'nl_email_', + 'phone' => 'nl_phone_', + 'login_out_key' => 'nl_login_out_', + 'menu_key' => 'nl_menu_', ], /* * api白名单 @@ -49,7 +58,7 @@ return [ * 阿里云oss配置 */ 'oss' => [ - 'redis_key' => 'xk_oss_url_', + 'redis_key' => 'nl_oss_url_', 'ali' => [ 'accessKeyId' => env('OSS_ACCESS_KEY_ID'), 'accessKeySecret' => env('OSS_ACCESS_KEY_SECRET'), diff --git a/routes/api.php b/routes/api.php index 7f60813f..1df08366 100644 --- a/routes/api.php +++ b/routes/api.php @@ -33,6 +33,7 @@ Route::group([], function () { 'menu' => \App\Http\Controllers\Api\MenuController::class, // 菜单管理 'upload' => \App\Http\Controllers\Api\UploadController::class, // 上传文件 'database' => \App\Http\Controllers\Api\DatabaseController::class, // 数据库管理 + 'ai-config' => \App\Http\Controllers\Api\AiConfigController::class, // AI 配置(平台/模型/密钥) // 需要登录的路由生成地址 ]); });