diff --git a/.github/workflows/codeql_main.yml b/.github/workflows/codeql_main.yml new file mode 100644 index 000000000..85905eb55 --- /dev/null +++ b/.github/workflows/codeql_main.yml @@ -0,0 +1,103 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '40 13 * * 6' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/backend/consts/provider.py b/backend/consts/provider.py index 7fd783015..ea0ff6345 100644 --- a/backend/consts/provider.py +++ b/backend/consts/provider.py @@ -6,11 +6,42 @@ class ProviderEnum(str, Enum): SILICON = "silicon" OPENAI = "openai" MODELENGINE = "modelengine" + ZHIPU = "zhipu" # Silicon Flow SILICON_BASE_URL = "https://api.siliconflow.cn/v1/" SILICON_GET_URL = "https://api.siliconflow.cn/v1/models" +# Zhipu AI +ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/" +ZHIPU_GET_URL = "https://open.bigmodel.cn/api/paas/v4/models" +# Hardcoded Zhipu models (injected due to incomplete API response) +HARDCODED_ZHIPU_MODELS = { + "llm": [ + {"id": "glm-4-flash", "object": "model", "owned_by": "zhipu"}, + {"id": "glm-4-air", "object": "model", "owned_by": "zhipu"}, + {"id": "glm-4-long", "object": "model", "owned_by": "zhipu"}, + ], + "vlm": [ + {"id": "glm-4.6v", "object": "model", "owned_by": "zhipu"}, + {"id": "glm-4.6v-flash", "object": "model", "owned_by": "zhipu"}, + {"id": "glm-4v-plus", "object": "model", "owned_by": "zhipu"}, + ], + "embedding": [ + {"id": "embedding-3", "object": "model", "owned_by": "zhipu"}, + {"id": "embedding-2", "object": "model", "owned_by": "zhipu"}, + ], + "reranker": [ + {"id": "reranker-3", "object": "model", "owned_by": "zhipu"}, + {"id": "reranker-2", "object": "model", "owned_by": "zhipu"}, + ], + "tts": [ + {"id": "cogspeech-1", "object": "model", "owned_by": "zhipu"}, + ], + "stt": [ + {"id": "sensevoice-v1", "object": "model", "owned_by": "zhipu"}, + ], +} # ModelEngine # Base URL and API key are loaded from environment variables at runtime diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 4b8265028..7ae3dec92 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -3,7 +3,7 @@ from consts.const import LOCALHOST_IP, LOCALHOST_NAME, DOCKER_INTERNAL_HOST from consts.model import ModelConnectStatusEnum -from consts.provider import ProviderEnum, SILICON_BASE_URL +from consts.provider import ProviderEnum, SILICON_BASE_URL, ZHIPU_BASE_URL from database.model_management_db import ( create_model_record, @@ -142,6 +142,8 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay elif provider == ProviderEnum.MODELENGINE.value: # ModelEngine models carry their own base_url in each model dict model_url = "" + elif provider == ProviderEnum.ZHIPU.value: + model_url = ZHIPU_BASE_URL else: model_url = "" diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 3e67a804f..547df2e39 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -11,7 +11,7 @@ DEFAULT_MAXIMUM_CHUNK_SIZE, ) from consts.model import ModelConnectStatusEnum, ModelRequest -from consts.provider import SILICON_GET_URL, ProviderEnum +from consts.provider import SILICON_GET_URL, ProviderEnum, ZHIPU_GET_URL,ZHIPU_BASE_URL,HARDCODED_ZHIPU_MODELS from database.model_management_db import get_models_by_tenant_factory_type from services.model_health_service import embedding_dimension_check from utils.model_name_utils import split_repo_name, add_repo_to_name @@ -68,6 +68,85 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: logger.error(f"Error getting models from silicon: {e}") return [] +class ZhipuModelProvider(AbstractModelProvider): + """Concrete implementation for Zhipu AI provider.""" + + async def get_models(self, provider_config: Dict) -> List[Dict]: + try: + model_type: str = provider_config["model_type"] + model_api_key: str = provider_config["api_key"] + + headers = {"Authorization": f"Bearer {model_api_key}"} + if model_type in ("llm", "vlm"): + zhipu_url = f"{ZHIPU_GET_URL}?sub_type=chat" + elif model_type in ("embedding", "multi_embedding"): + zhipu_url = f"{ZHIPU_GET_URL}?sub_type=embedding" + else: + zhipu_url = ZHIPU_GET_URL + + async with httpx.AsyncClient(verify=False) as client: + response = await client.get(zhipu_url, headers=headers) + response.raise_for_status() + all_models: List[Dict] = response.json().get("data", []) + + logger.debug(f"Zhipu API returned {len(all_models)} models for type '{model_type}'") + # Filter models by type - Zhipu API may not filter by sub_type + # Zhipu API returns models with "object" field indicating type: "chat" or "embedding" + # Zhipu api_models only return chat models + seen_ids = set() + final_list = [] + api_models=[] + if(model_type=='llm'): + api_models=all_models + # Normalize API models and add in API order + for item in api_models: + mid = item.get("id") + if not mid: + continue + # ensure canonical fields to match hardcoded shape + # object/owned_by exist in hardcoded items + item.setdefault("object", item.get("object", "model")) + item.setdefault("owned_by", item.get("owned_by", "zhipu")) + item.setdefault("model_tag", "chat") + item.setdefault("model_type", model_type) + item.setdefault("max_tokens", DEFAULT_LLM_MAX_TOKENS) + final_list.append(item) + seen_ids.add(mid) + + # Append all hardcoded models of the same type (if any), but avoid duplicates + hardcoded = HARDCODED_ZHIPU_MODELS.get(model_type, []) + for model in hardcoded: + hid = model.get("id") + if not hid or hid in seen_ids: + continue + model_with_tag = model.copy() + # normalize hardcoded entries to include same canonical keys + model_with_tag.setdefault("object", model_with_tag.get("object", "model")) + model_with_tag.setdefault("owned_by", model_with_tag.get("owned_by", "zhipu")) + if model_type in ("llm", "vlm"): + model_with_tag.setdefault("model_tag", "chat") + model_with_tag.setdefault("model_type", model_type) + model_with_tag.setdefault("max_tokens", DEFAULT_LLM_MAX_TOKENS) + elif model_type in ("embedding", "multi_embedding"): + model_with_tag.setdefault("model_tag", "embedding") + model_with_tag.setdefault("model_type", model_type) + final_list.append(model_with_tag) + seen_ids.add(hid) + + # Log warning if filtered list is empty but we got API response + if not final_list and all_models: + logger.warning( + f"Zhipu API returned {len(all_models)} models but none matched " + f"filter for type '{model_type}'. Check API response format." + ) + # Return normalized list (API models first, then hardcoded additions) + return final_list + + except Exception as e: + logger.error(f"Error getting models from Zhipu: {e}") + return [] + + class ModelEngineProvider(AbstractModelProvider): """Concrete implementation for ModelEngine provider.""" @@ -288,6 +367,9 @@ async def get_provider_models(model_data: dict) -> List[dict]: elif model_data["provider"] == ProviderEnum.MODELENGINE.value: provider = ModelEngineProvider() model_list = await provider.get_models(model_data) + elif model_data["provider"] == ProviderEnum.ZHIPU.value: + provider = ZhipuModelProvider() + model_list = await provider.get_models(model_data) return model_list diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 4a2c3bf97..e70197d6d 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -17,6 +17,7 @@ import { modelService } from "@/services/modelService"; import { ModelType, SingleModelConfig } from "@/types/modelConfig"; import { MODEL_TYPES, PROVIDER_LINKS } from "@/const/modelConfig"; import { useSiliconModelList } from "@/hooks/model/useSiliconModelList"; +import { useZhipuModelList } from "@/hooks/model/UseZhipuModelList"; import log from "@/lib/logger"; import { ModelChunkSizeSlider, @@ -217,13 +218,25 @@ export const ModelAddDialog = ({ const [modelMaxTokens, setModelMaxTokens] = useState("4096"); // Use the silicon model list hook - const { getModelList, getProviderSelectedModalList } = useSiliconModelList({ + const siliconHook = useSiliconModelList({ form, setModelList, setSelectedModelIds, setShowModelList, setLoadingModelList, }); + const zhipuHook = useZhipuModelList({ + form, + setModelList, + setSelectedModelIds, + setShowModelList, + setLoadingModelList, + }); + + // Choose the appropriate hook API based on selected provider + const getModelList = form.provider === "zhipu" ? zhipuHook.getModelList : siliconHook.getModelList; + const getProviderSelectedModalList = form.provider === "zhipu" ? zhipuHook.getProviderSelectedModalList : siliconHook.getProviderSelectedModalList; + // When dialog opens, apply default provider and optional default batch mode useEffect(() => { @@ -688,6 +701,7 @@ export const ModelAddDialog = ({ > + {/* ModelEngine URL input (only when provider is ModelEngine) */} {form.provider === "modelengine" && ( diff --git a/frontend/app/[locale]/models/components/model/ModelListCard.tsx b/frontend/app/[locale]/models/components/model/ModelListCard.tsx index ae966ae35..64d2ae045 100644 --- a/frontend/app/[locale]/models/components/model/ModelListCard.tsx +++ b/frontend/app/[locale]/models/components/model/ModelListCard.tsx @@ -33,12 +33,12 @@ const PULSE_ANIMATION = ` transform: scale(0.95); box-shadow: 0 0 0 0 rgba(41, 128, 185, 0.7); } - + 70% { transform: scale(1); box-shadow: 0 0 0 5px rgba(41, 128, 185, 0); } - + 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(41, 128, 185, 0); @@ -162,27 +162,30 @@ export const ModelListCard = ({ const model = modelsData.find( (m) => m.type === type && m.displayName === displayName ); - + if (!model) return t("model.source.unknown"); - + // Return source label based on model.source if (model.source === "modelengine") { return t("model.source.modelEngine"); } else if (model.source === "silicon") { return t("model.source.silicon"); + } else if (model.source === "zhipu") { + return t("model.source.zhipu"); } else if (model.source === "OpenAI-API-Compatible") { return t("model.source.custom"); } - + return t("model.source.unknown"); }; const filteredModels = getFilteredModels(); - + // Group models by source for display const groupedModels = { modelengine: filteredModels.filter((m) => m.source === "modelengine"), silicon: filteredModels.filter((m) => m.source === "silicon"), + zhipu: filteredModels.filter((m) => m.source === "zhipu"), custom: filteredModels.filter((m) => m.source === "OpenAI-API-Compatible"), }; @@ -343,6 +346,54 @@ export const ModelListCard = ({ ))} )} + {groupedModels.zhipu.length > 0 && ( + + {groupedModels.zhipu.map((model) => ( + + ))} + + )} {groupedModels.custom.length > 0 && ( {groupedModels.custom.map((model) => ( @@ -394,4 +445,4 @@ export const ModelListCard = ({ ); -}; \ No newline at end of file +}; diff --git a/frontend/const/modelConfig.ts b/frontend/const/modelConfig.ts index 490cf2d87..eff19e53c 100644 --- a/frontend/const/modelConfig.ts +++ b/frontend/const/modelConfig.ts @@ -46,6 +46,7 @@ export const MODEL_PROVIDER_KEYS = [ "jina", "deepseek", "aliyuncs", + "zhipu", ] as const; export type ModelProviderKey = (typeof MODEL_PROVIDER_KEYS)[number]; @@ -58,6 +59,7 @@ export const PROVIDER_HINTS: Record = { jina: "jina", deepseek: "deepseek", aliyuncs: "aliyuncs", + zhipu: "bigmodel", }; // Icon filenames for providers @@ -68,6 +70,7 @@ export const PROVIDER_ICON_MAP: Record = { jina: "/jina.png", deepseek: "/deepseek.png", aliyuncs: "/aliyuncs.png", + zhipu: "/zhipu.png", }; export const OFFICIAL_PROVIDER_ICON = "/modelengine-logo.png"; diff --git a/frontend/hooks/model/UseZhipuModelList.ts b/frontend/hooks/model/UseZhipuModelList.ts new file mode 100644 index 000000000..855b6f7b0 --- /dev/null +++ b/frontend/hooks/model/UseZhipuModelList.ts @@ -0,0 +1,108 @@ +import { useEffect } from 'react' +import { message } from 'antd' +import { useTranslation } from 'react-i18next' +import { modelService } from '@/services/modelService' +import { ModelType } from '@/types/modelConfig' +import log from "@/lib/logger"; + +interface UseZhipuModelListProps { + form: { + type: ModelType + isBatchImport: boolean + apiKey: string + provider: string + maxTokens: string + isMultimodal: boolean + } + setModelList: (models: any[]) => void + setSelectedModelIds: (ids: Set) => void + setShowModelList: (show: boolean) => void + setLoadingModelList: (loading: boolean) => void +} + +export const useZhipuModelList = ({ + form, + setModelList, + setSelectedModelIds, + setShowModelList, + setLoadingModelList +}: UseZhipuModelListProps) => { + const { t } = useTranslation() + + const getModelList = async () => { + setShowModelList(true) + setLoadingModelList(true) + const modelType = form.type === "embedding" && form.isMultimodal ? + "multi_embedding" as ModelType : + form.type + try { + const result = await modelService.addProviderModel({ + provider: form.provider, + type: modelType, + apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + baseUrl: + form.provider === "modelengine" && form.apiKey.trim() !== "" + ? (form as any).modelEngineUrl || "" + : undefined, + }) + // Ensure each model has a default max_tokens value + const modelsWithDefaults = result.map((model: any) => ({ + ...model, + max_tokens: model.max_tokens || parseInt(form.maxTokens) || 4096 + })) + setModelList(modelsWithDefaults) + if (!result || result.length === 0) { + message.error(t('model.dialog.error.noModelsFetched')) + } + const selectedModels = await getProviderSelectedModalList() || [] + // Key logic + if (!selectedModels.length) { + // Select none + setSelectedModelIds(new Set()) + } else { + // Only select selectedModels + setSelectedModelIds(new Set(selectedModels.map((m: any) => m.id))) + } + } catch (error) { + message.error(t('model.dialog.error.addFailed', { error })) + log.error(t('model.dialog.error.addFailedLog'), error) + } finally { + setLoadingModelList(false) + } + } + + const getProviderSelectedModalList = async () => { + const modelType = form.type === "embedding" && form.isMultimodal ? + "multi_embedding" as ModelType : + form.type + const result = await modelService.getProviderSelectedModalList({ + provider: form.provider, + type: modelType, + api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + baseUrl: + form.provider === "modelengine" && form.apiKey.trim() !== "" + ? (form as any).modelEngineUrl || "" + : undefined, + }) + return result + } + + // Auto-fetch model list when batch import is enabled and API key is provided + useEffect(() => { + const requiresUrl = + form.provider === "modelengine" + ? ((form as any).modelEngineUrl || "").toString().trim() !== "" + : true; + + if (form.isBatchImport && form.apiKey.trim() !== "" && requiresUrl) { + getModelList() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.type, form.isBatchImport]) + + return { + getModelList, + getProviderSelectedModalList + } +} + diff --git a/frontend/package.json b/frontend/package.json index 0a706bdf6..a6f7fd14a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "bootstrap-icons": "^1.11.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cross-env": "^10.1.0", "dicebear": "^9.2.2", "dotenv": "^16.4.7", "framer-motion": "^12.23.6", diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 0023ab66f..a99092db1 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -656,6 +656,7 @@ "model.dialog.hint.batchImportEnabled": "Batch add enabled. Multiple models will be added at once.", "model.dialog.hint.batchImportDisabled": "Batch add disabled. Only a single model will be added.", "model.provider.silicon": "SiliconFlow", + "model.provider.zhipu": "Zhipu AI", "model.provider.modelengine": "ModelEngine", "model.dialog.modelList.title": "Show Models", "model.dialog.modelList.searchPlaceholder": "Search models by name", @@ -715,6 +716,7 @@ "model.error.deleteError": "Error deleting model:", "model.source.custom": "Custom", "model.source.modelEngine": "ModelEngine", + "model.source.zhipu": "Zhipu AI", "model.source.openai": "OpenAI", "model.source.silicon": "Silicon Flow", "model.source.unknown": "Unknown Source", @@ -723,6 +725,7 @@ "model.select.placeholder": "Select Model", "model.group.modelEngine": "ModelEngine Models", "model.group.silicon": "Silicon Flow Models", + "model.group.zhipu": "Zhipu AI Models", "model.group.custom": "Custom Models", "model.status.tooltip": "Click to verify connectivity", "model.dialog.embeddingConfig.title": "Edit Embedding Model: {{modelName}}", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index c861c5fc4..e145e698c 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -659,6 +659,7 @@ "model.dialog.hint.batchImportDisabled": "批量添加模式已关闭,仅添加单个模型", "model.provider.silicon": "硅基流动", "model.provider.modelengine": "ModelEngine", + "model.provider.zhipu": "智谱清言", "model.dialog.modelList.title": "显示模型", "model.dialog.modelList.searchPlaceholder": "按名称搜索模型", "model.dialog.modelList.noResults": "没有匹配的模型", @@ -717,6 +718,7 @@ "model.source.modelEngine": "ModelEngine", "model.source.unknown": "未知来源", "model.source.openai": "OpenAI", + "model.source.zhipu": "智谱清言", "model.source.silicon": "硅基流动", "model.warning.updateNotFound": "未找到要更新的模型: {{displayName}}, 类型: {{type}}", "model.type.main": "大语言模型", @@ -724,6 +726,7 @@ "model.group.modelEngine": "ModelEngine模型", "model.group.silicon": "硅基流动模型", "model.group.custom": "自定义模型", + "model.group.zhipu": "智谱清言模型", "model.status.tooltip": "点击可验证连通性", "model.dialog.success.updateSuccess": "更新成功", "model.dialog.embeddingConfig.title": "修改向量模型: {{modelName}}", diff --git a/frontend/public/zhipu.png b/frontend/public/zhipu.png new file mode 100644 index 000000000..4e3732456 Binary files /dev/null and b/frontend/public/zhipu.png differ diff --git a/frontend/types/modelConfig.ts b/frontend/types/modelConfig.ts index 04d6a5ff3..c3af56e64 100644 --- a/frontend/types/modelConfig.ts +++ b/frontend/types/modelConfig.ts @@ -18,7 +18,8 @@ export type ModelSource = | "custom" | "silicon" | "OpenAI-API-Compatible" - | "modelengine"; + | "modelengine" + | "zhipu"; // Model type export type ModelType = diff --git a/pathology-ai/README.md b/pathology-ai/README.md new file mode 100644 index 000000000..cd6e3bc71 --- /dev/null +++ b/pathology-ai/README.md @@ -0,0 +1,469 @@ +# PathologyAI - 智能病理诊断助手 + +**ModeEngine AI 创新应用学习赛 提交材料** + +> 团队:量子工坊 + +--- + +## 📦 提交材料目录 + +``` +docs/ +├── README.md # 本文档(项目完整介绍) +├── agent-config.md # 智能体配置说明 +├── custom-tools.md # 15个MCP工具详细说明 +├── frontend-improvements.md # 前端组件改进说明 +├── architecture.md # 架构与调用关系图(Mermaid) +├── diagrams/ # 架构图(PNG格式) +│ ├── tools_architecture.png # 工具调用架构图 +│ ├── system_architecture.png # 系统分层架构图 +│ ├── cod_process.png # CoD诊断推理链 +│ ├── game_flow.png # 诊断游戏流程 +│ └── dataflow.png # 数据流向图 +└── code-changes/ # 源码文件 + ├── backend/ + │ └── local_mcp_service.py # 15个MCP工具定义 + ├── frontend/ + │ ├── PathologyImageGallery.tsx # 新增:病理图片画廊 + │ ├── DiagnosisConfidenceCard.tsx # 新增:置信度卡片 + │ ├── SourceTag.tsx # 新增:来源标签 + │ ├── MedicalVisualizationPanel.tsx + │ ├── index.ts + │ ├── markdownRenderer.tsx # 修改:按钮解析 + │ ├── chatLeftSidebar.tsx # 修改:清空对话 + │ └── conversationService.ts # 修改:批量删除 + ├── medical_extension/ + │ ├── chain_of_diagnosis.py # CoD实现 + │ ├── confidence_evaluator.py # 置信度评估 + │ ├── medical_prompts.py + │ ├── agent_templates.py + │ ├── api.py + │ └── test_medical.py + └── docker/ + └── update_prompt_btn.sql # Agent提示词SQL +``` + +--- + +## 🎯 核心功能 + +### Chain-of-Diagnosis(CoD)诊断推理框架 ⭐⭐⭐⭐⭐ + +首创医学诊断专用的结构化推理框架,包含5个标准化步骤: + +| 步骤 | 名称 | 说明 | +|------|------|------| +| Step 1 | **症状分析** | 全面提取和分类患者症状 | +| Step 2 | **病史关联** | 关联既往病史和家族史 | +| Step 3 | **鉴别诊断** | 列出所有可能的诊断假设 | +| Step 4 | **检查建议** | 建议进一步检查项目 | +| Step 5 | **初步结论** | 综合分析得出最可能诊断 | + +**创新点**:每一步推理过程完全可追溯、可解释,解决了传统AI诊断"黑盒"问题。 + +### 置信度评估系统 + +多维度评估诊断可信度: + +| 维度 | 说明 | +|------|------| +| 证据充分度 | 支持诊断的证据是否充足 | +| 一致性 | 症状与诊断是否一致 | +| 完整性 | 信息是否完整 | +| 确定性 | 诊断的确定程度 | + +**风险等级**:`LOW` / `MEDIUM` / `HIGH` / `CRITICAL` + +### 诊断模拟游戏 + +交互式临床问诊练习,通过 `[btn:选项]` 按钮交互: + +``` +1.启动 → 2.问诊 → 3.体检 → 4.检查 → 5.诊断 +``` + +用户扮演医生,AI扮演患者,训练临床思维。 + +--- + +## 🔧 15个专业MCP工具 + +形成完整的病理诊断工具链: + +### 医疗诊断工具(4个) + +| 工具名称 | 功能 | +|----------|------| +| `chain_of_diagnosis` | CoD框架实现,5步结构化诊断 | +| `evaluate_diagnosis_confidence` | 多维度置信度评估 | +| `search_pathology_images` | 病理图片搜索 | +| `generate_medical_guide` | 就医指南生成 | + +### 诊断游戏工具(2个) + +| 工具名称 | 功能 | +|----------|------| +| `start_diagnosis_game` | 启动诊断模拟游戏 | +| `diagnosis_action` | 执行游戏动作(问诊/体检/检查/诊断) | + +### 医学可视化工具(9个) + +| 工具名称 | 功能 | +|----------|------| +| `generate_knowledge_graph` | 医学知识图谱 | +| `generate_diagnosis_flow` | 诊断流程图 | +| `generate_medical_chart` | 统计图表(柱状图/折线图/饼图) | +| `generate_radar_chart` | 雷达图(多维度健康指标) | +| `generate_timeline` | 时间线图(病程发展) | +| `generate_gantt_chart` | 甘特图(治疗计划) | +| `generate_quadrant_chart` | 象限图(风险评估) | +| `generate_state_diagram` | 状态转换图(疾病状态) | +| `generate_sankey_diagram` | 桑基图(流量转换) | + +--- + +## 📊 项目数据统计 + +### 应用代码规模 + +| 项目 | 数量 | +|------|------| +| MCP工具 | 15个完整实现 | +| 前端组件 | 4个新增 + 4个修改 | +| 后端代码 | ~1,400行 | +| 文档 | 5个Markdown文档 | +| 架构图 | 5张 | + +### 系统模型配置 + +基于 Nexent v1.7.7 平台: + +| 模型类型 | 模型名称 | +|----------|----------| +| 大语言模型 | Claude 4.5 Haiku | +| 向量模型 | BGE-M3 Embedding | +| 重排模型 | Jina Reranker | +| 多模态向量模型 | BGE-M3 Multi | +| 视觉语言模型 | GPT-4o Vision | +| 语音合成 | OpenAI-TTS-HD | +| 语音识别 | OpenAI-Whisper | + +--- + +## 💡 技术创新点 + +### 1. Chain-of-Diagnosis框架 ⭐⭐⭐⭐⭐ + +**首创医学诊断专用推理框架** + +- 结构化5步诊断流程 +- 每步可追溯、可解释 +- 多维度置信度评估 +- 完整的证据链 + +### 2. MCP工具生态系统 ⭐⭐⭐⭐⭐ + +**完整的病理诊断工具链** + +- 15个工具覆盖诊断全流程 +- 遵循MCP标准协议 +- 可独立使用,可组合调用 +- 易于扩展到其他医学领域 + +### 3. 交互式诊断游戏 ⭐⭐⭐⭐ + +**创新的临床思维训练方案** + +- `[btn:选项]` 按钮交互机制 +- 完整的问诊→体检→检查→诊断流程 +- 实时反馈和评分 +- 适合医学教育场景 + +### 4. 双重知识检索 ⭐⭐⭐⭐ + +**内外部知识融合策略** + +- 内部知识库权重60%(专业准确) +- 外部搜索权重40%(时效补充) +- 自动来源标注 `[内部]` `[外部]` +- 可溯源、可验证 + +### 5. 医学可视化工具集 ⭐⭐⭐⭐ + +**9种Mermaid图表自动生成** + +- 知识图谱、诊断流程图 +- 雷达图、时间线、甘特图 +- 象限图、状态图、桑基图 +- 前端原生渲染,无需额外依赖 + +--- + +## 🏗️ 系统架构 + +### 分层架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 前端层 (Frontend) │ +│ 聊天界面 | 医学可视化组件 | 诊断游戏界面 │ +└─────────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Nexent Runtime │ +│ 病理学AI助手 (Agent ID: 13) │ +└─────────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MCP工具层 (15个自定义工具) │ +│ 诊断推理 | 置信度评估 | 诊断游戏 | 9种可视化图表 │ +└─────────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ PostgreSQL | Elasticsearch | 病理图片服务器 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 5.2 工具调用关系图 + +```mermaid +flowchart TD + subgraph User["用户"] + Q[用户提问] + end + + subgraph Agent["病理学AI助手 Agent ID:13"] + A[接收问题] + B{判断问题类型} + end + + subgraph BuiltIn["内置工具"] + KB[knowledge_base_search
内部知识库 60%] + TS[tavily_search
外部搜索 40%] + AI[analyze_image
图片分析] + end + + subgraph Medical["医疗诊断工具"] + COD[chain_of_diagnosis
5步诊断推理链] + CONF[evaluate_diagnosis_confidence
置信度评估] + IMG[search_pathology_images
病理图片搜索] + GUIDE[generate_medical_guide
就医指南] + end + + subgraph Game["诊断模拟游戏"] + START[start_diagnosis_game] + ACTION[diagnosis_action] + end + + subgraph Visual["可视化工具 9个"] + VIS[knowledge_graph / diagnosis_flow / medical_chart
radar_chart / timeline / gantt_chart
quadrant_chart / state_diagram / sankey_diagram] + end + + Q --> A --> B + B -->|医学问答| KB + B -->|医学问答| TS + B -->|图片分析| AI + B -->|诊断分析| COD + B -->|诊断游戏| START + B -->|可视化| VIS + B -->|就医咨询| GUIDE + + KB --> R[生成回答] + TS --> R + AI --> R + COD --> CONF --> R + START --> ACTION --> R + VIS --> R + IMG --> R + GUIDE --> R +``` + +### 5.3 数据流向 + +```mermaid +flowchart LR + subgraph Input["输入"] + I1[文本问题] + I2[病理图片] + I3[按钮点击] + end + + subgraph Process["处理"] + Agent[AI助手] + Tools[MCP工具] + KB[(知识库 60%)] + Web((互联网 40%)) + end + + subgraph Output["输出"] + O1[Markdown文本] + O2[Mermaid图表] + O3[病理图片] + O4[交互按钮] + end + + I1 --> Agent + I2 --> Agent + I3 --> Agent + Agent --> Tools + Tools --> KB + Tools --> Web + KB --> O1 + Web --> O1 + Tools --> O2 + Tools --> O3 + Tools --> O4 +``` + +--- + +## 六、前端改进 + +### 6.1 新增组件 + +| 组件 | 文件 | 功能 | +|------|------|------| +| 病理图片画廊 | `PathologyImageGallery.tsx` | 展示和预览病理图片 | +| 置信度卡片 | `DiagnosisConfidenceCard.tsx` | 显示诊断置信度和风险等级 | +| 来源标签 | `SourceTag.tsx` | 标注信息来源 [内部]/[外部] | + +### 6.2 修改组件 + +| 组件 | 修改内容 | +|------|----------| +| `markdownRenderer.tsx` | 新增 `[btn:xxx]` 按钮解析和渲染 | +| `chatLeftSidebar.tsx` | 新增"清空所有对话"功能 | +| `conversationService.ts` | 新增 `deleteAll` 批量删除方法 | +| `MedicalVisualizationPanel.tsx` | 去除硬编码,支持通用病理学 | + +--- + +## 部署说明 + +### 8.1 环境要求 + +- Nexent v1.7.7+ +- Docker & Docker Compose +- Python 3.10+ +- Node.js 18+ + +### 8.2 部署步骤 + +1. **部署 Nexent 平台** + ```bash + docker-compose up -d + ``` + +2. **导入 Agent 配置** + ```bash + docker exec -i nexent-postgres psql -U postgres -d nexent < update_prompt_btn.sql + ``` + +3. **配置模型** + - 在 Nexent 管理界面配置各模型 API + +4. **启动使用** + - 访问 http://localhost:3000 + - 选择"病理学AI助手"开始对话 + +--- + +## 九、使用示例 + +### 9.1 医学问答 + +``` +用户: HIV感染的诊断标准是什么? +AI: [调用 knowledge_base_search + tavily_search] + [融合内部60% + 外部40%结果] + 返回诊断标准说明... +``` + +### 9.2 诊断推理 + +``` +用户: 患者发热、淋巴结肿大、皮疹,请分析 +AI: [调用 chain_of_diagnosis] + Step1: 症状分析 - 三联征提示... + Step2: 病史关联 - ... + Step3: 鉴别诊断 - ... + Step4: 检查建议 - ... + Step5: 初步结论 - ... + [调用 evaluate_diagnosis_confidence] + 置信度: 85% | 风险等级: MEDIUM +``` + +### 9.3 诊断游戏 + +``` +用户: 开始诊断游戏 +AI: [调用 start_diagnosis_game] + + 🎮 诊断模拟游戏 + 难度: 中级 | 病例类型: 感染性疾病 + + 患者信息: 男性,35岁,主诉发热3天... + + [btn:询问发热情况] [btn:询问其他症状] [btn:询问既往史] +``` + +--- + +## 📝 应用价值 + +### 医学教育 + +- 医学生病理学习辅助 +- 住院医师规范化培训 +- 在线医学教育平台 +- 临床思维训练(诊断游戏) + +### 临床辅助 + +- 病理医生诊断辅助决策 +- 基层医院诊断能力提升 +- 远程病理会诊支持 +- 结构化诊断报告生成 + +### 科研应用 + +- 病理知识图谱构建 +- 医学AI研究数据积累 +- 诊断算法优化验证 +- 可视化数据展示 + +### 社会价值 + +- 缓解病理医生严重短缺(缺口3万+) +- 提升诊断标准化和准确性 +- 推动优质医疗资源下沉 +- 降低误诊漏诊风险 + +--- + +## ⚠️ 安全声明 + +**重要提示**: +- 本AI助手仅供学习和参考使用 +- 不能替代专业医生的诊断和治疗建议 +- 如有健康问题,请及时就医 +- 所有诊断建议均需专业医师确认 + +--- + +## 📄 许可证 + +Apache License 2.0 + +--- + +## 👥 贡献者 + +**量子工坊团队** + +开放原子基金会 ModeEngine AI 创新应用学习赛 diff --git a/pathology-ai/agent-config.md b/pathology-ai/agent-config.md new file mode 100644 index 000000000..739e45d4d --- /dev/null +++ b/pathology-ai/agent-config.md @@ -0,0 +1,80 @@ +# 🤖 智能体配置说明 + +## 智能体基本信息 + +| 配置项 | 值 | +|--------|-----| +| **Agent ID** | 13 | +| **名称** | 病理学AI助手 | +| **类型** | 医疗诊断辅助智能体 | +| **基础模型** | GPT-4 / 兼容OpenAI API | +| **max_steps** | 25 | + +## 业务描述 + +病理学AI助手具备以下能力: + +1. **医学知识问答** - 回答病理学、临床医学问题 +2. **诊断推理** - Chain-of-Diagnosis结构化诊断 +3. **置信度评估** - 评估回答可靠性和风险等级 +4. **交互式诊断练习** - 模拟游戏训练临床思维 +5. **医学可视化** - 知识图谱、流程图生成 + +## 工具配置 + +| 工具名称 | 来源 | 功能 | +|----------|------|------| +| `knowledge_base_search` | 内置 | 本地知识库搜索 | +| `tavily_search` | 内置 | 外部互联网搜索 | +| `analyze_image` | 内置 | 图片分析 | +| `nexent_chain_of_diagnosis` | **自定义** | CoD诊断推理链 | +| `nexent_evaluate_diagnosis_confidence` | **自定义** | 置信度评估 | +| `nexent_start_diagnosis_game` | **自定义** | 启动诊断游戏 | +| `nexent_diagnosis_action` | **自定义** | 诊断游戏动作 | +| `nexent_search_pathology_images` | **自定义** | 病理图片搜索 | +| `nexent_generate_medical_guide` | **自定义** | 就医指南生成 | + +## Prompt 配置 + +### duty_prompt (角色提示词) + +``` +# 🏥 病理学AI助手 + +你是一位专业的病理学AI助手。 + +## ⚠️ 最重要规则 + +### 1. 双重检索(必须执行) +回答医学问题前,必须同时调用: +- knowledge_base_search(query="关键词", search_mode="hybrid") +- tavily_search(query="关键词") + +权重:内部60% + 外部40% + +### 2. 按钮格式规则 +工具返回的 [btn:xxx] 格式必须原样保留! + +## 🎮 诊断模拟游戏规则 +1. 每执行一步后必须停止,等待用户选择 +2. 原样输出工具返回的按钮 +3. 不要自己做决定 + +## 安全提醒 +⚠️ 本AI仅供参考,不能替代专业医生诊断。 +``` + +## 知识库配置 + +| 配置项 | 值 | +|--------|-----| +| 知识库名称 | pathology_knowledge | +| 搜索模式 | hybrid | +| 向量数据库 | Elasticsearch | + +## 外部搜索配置 + +| 配置项 | 值 | +|--------|-----| +| 搜索引擎 | Tavily | +| 权重 | 40% | diff --git a/pathology-ai/architecture.md b/pathology-ai/architecture.md new file mode 100644 index 000000000..64a215c68 --- /dev/null +++ b/pathology-ai/architecture.md @@ -0,0 +1,308 @@ +# 🏗️ 架构与调用关系图 + +## 系统架构概览 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 用户界面 (Frontend) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ 聊天界面 │ │ 医学可视化 │ │ 诊断模拟游戏界面 │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Nexent Runtime │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 病理学AI助手 (Agent ID: 13) ││ +│ └─────────────────────────────────────────────────────────────┘│ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MCP 工具层 │ +│ ┌──────────────────────┐ ┌──────────────────────────────┐ │ +│ │ 内置工具 │ │ 自定义医疗工具 │ │ +│ │ • knowledge_search │ │ • chain_of_diagnosis │ │ +│ │ • tavily_search │ │ • evaluate_confidence │ │ +│ │ • analyze_image │ │ • diagnosis_game │ │ +│ └──────────────────────┘ └──────────────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ PostgreSQL │ │Elasticsearch│ │ 病理图片服务器 │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 工具调用关系图 (Mermaid) + +```mermaid +flowchart TD + subgraph User["👤 用户"] + Q[用户提问] + end + + subgraph Agent["🤖 病理学AI助手 Agent ID:13"] + A[接收问题] + B{判断问题类型} + end + + subgraph BuiltIn["📦 内置工具"] + KB[knowledge_base_search
内部知识库 60%] + TS[tavily_search
外部搜索 40%] + AI[analyze_image
图片分析] + end + + subgraph Medical["🏥 医疗诊断工具"] + COD[chain_of_diagnosis
5步诊断推理链] + CONF[evaluate_diagnosis_confidence
置信度评估] + IMG[search_pathology_images
病理图片搜索] + GUIDE[generate_medical_guide
就医指南] + end + + subgraph Game["🎮 诊断模拟游戏"] + START[start_diagnosis_game
启动游戏] + ACTION[diagnosis_action
问诊/体检/检查/诊断] + end + + subgraph Visual["📊 医学可视化工具"] + KG[generate_knowledge_graph
知识图谱] + FLOW[generate_diagnosis_flow
诊断流程图] + CHART[generate_medical_chart
统计图表] + RADAR[generate_radar_chart
雷达图] + TL[generate_timeline
时间线] + GANTT[generate_gantt_chart
甘特图] + QUAD[generate_quadrant_chart
象限图] + STATE[generate_state_diagram
状态图] + SANKEY[generate_sankey_diagram
桑基图] + end + + Q --> A + A --> B + B -->|医学问答| KB + B -->|医学问答| TS + B -->|图片分析| AI + B -->|诊断分析| COD + B -->|诊断游戏| START + B -->|可视化| KG + B -->|就医咨询| GUIDE + + KB --> R[生成回答] + TS --> R + AI --> R + COD --> CONF --> R + START --> ACTION --> R + KG --> R + FLOW --> R + CHART --> R + RADAR --> R + TL --> R + GANTT --> R + QUAD --> R + STATE --> R + SANKEY --> R + IMG --> R + GUIDE --> R +``` + +--- + +## 完整工具调用流程图 + +```mermaid +flowchart LR + subgraph Input["输入"] + U[用户问题] + end + + subgraph Process["处理流程"] + U --> Agent[病理学AI助手] + Agent --> Parse{解析意图} + + Parse -->|知识查询| Search["双重检索"] + Parse -->|诊断请求| Diag["诊断分析"] + Parse -->|游戏请求| Game["诊断游戏"] + Parse -->|可视化| Viz["图表生成"] + Parse -->|就医咨询| Guide["就医指南"] + + Search --> KB[内部知识库] + Search --> Web[外部搜索] + + Diag --> CoD[CoD推理链] + CoD --> Eval[置信度评估] + + Game --> Start[启动游戏] + Start --> Action[执行动作] + + Viz --> Charts[9种图表工具] + end + + subgraph Output["输出"] + KB --> Merge[结果融合] + Web --> Merge + Eval --> Merge + Action --> Merge + Charts --> Merge + Guide --> Merge + Merge --> Response[AI回答] + end +``` + +--- + +## 诊断游戏流程图 + +```mermaid +flowchart LR + A[启动游戏] --> B[问诊阶段] + B --> C[体格检查] + C --> D[辅助检查] + D --> E[给出诊断] + E --> F[评分反馈] +``` + +--- + +## Chain-of-Diagnosis 流程 + +```mermaid +flowchart TD + INPUT[输入症状] --> S1[Step1: 症状分析] + S1 --> S2[Step2: 病史关联] + S2 --> S3[Step3: 鉴别诊断] + S3 --> S4[Step4: 检查建议] + S4 --> S5[Step5: 初步结论] + S5 --> EVAL[置信度评估] + EVAL --> OUTPUT[诊断报告] +``` + +--- + +## 前端组件调用关系 + +```mermaid +flowchart TD + subgraph Chat["聊天界面"] + CI[chatInterface.tsx] + MD[markdownRenderer.tsx] + end + + subgraph MedViz["医学可视化组件"] + MVP[MedicalVisualizationPanel] + PIG[PathologyImageGallery] + DCC[DiagnosisConfidenceCard] + ST[SourceTag] + end + + subgraph Services["服务层"] + CS[conversationService.ts] + end + + CI --> MD + MD -->|渲染Mermaid| MVP + MD -->|渲染图片| PIG + MD -->|渲染置信度| DCC + MD -->|渲染来源| ST + MD -->|[btn:xx]按钮| BTN[ClickableOption] + + CI --> CS + CS -->|deleteAll| API[后端API] +``` + +--- + +## MCP工具注册关系 + +```mermaid +flowchart TD + subgraph MCP["FastMCP框架"] + LMS[local_mcp_service.py] + end + + subgraph Decorator["@local_mcp_service.tool 装饰器"] + D1[医疗诊断工具 x4] + D2[诊断游戏工具 x2] + D3[可视化工具 x9] + end + + subgraph Runtime["Nexent Runtime"] + Agent[病理学AI助手] + end + + LMS --> D1 + LMS --> D2 + LMS --> D3 + + D1 --> Agent + D2 --> Agent + D3 --> Agent +``` + +--- + +## 数据流向图 + +```mermaid +flowchart LR + subgraph Input["用户输入"] + Text[文本问题] + Image[病理图片] + Click[按钮点击] + end + + subgraph Process["Agent处理"] + Text --> Agent[病理学AI助手] + Image --> Agent + Click --> Agent + + Agent --> Tools[MCP工具] + Tools --> KB[(知识库)] + Tools --> Web((互联网)) + Tools --> ImgDB[(图片库)] + end + + subgraph Output["输出"] + KB --> Response + Web --> Response + ImgDB --> Response + Response[AI回答] --> Render[前端渲染] + Render --> Markdown[文本/表格] + Render --> Mermaid[图表] + Render --> Images[图片] + Render --> Buttons[交互按钮] + end +``` + +--- + +## 文件修改清单 + +### 后端修改 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `backend/tool_collection/mcp/local_mcp_service.py` | 新增 | 15个医疗MCP工具 | + +### 前端修改 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `frontend/components/medical-visualization/PathologyImageGallery.tsx` | 新增 | 病理图片画廊 | +| `frontend/components/medical-visualization/DiagnosisConfidenceCard.tsx` | 新增 | 置信度卡片 | +| `frontend/components/medical-visualization/SourceTag.tsx` | 新增 | 来源标签 | +| `frontend/components/medical-visualization/MedicalVisualizationPanel.tsx` | 修改 | 去除硬编码 | +| `frontend/components/ui/markdownRenderer.tsx` | 修改 | [btn:xx]按钮解析 | +| `frontend/app/[locale]/chat/components/chatLeftSidebar.tsx` | 修改 | 清空对话按钮 | +| `frontend/services/conversationService.ts` | 修改 | deleteAll方法 | + +### 配置文件 + +| 文件 | 说明 | +|------|------| +| `docker/update_prompt_btn.sql` | Agent提示词配置 | diff --git a/pathology-ai/code-changes/backend/local_mcp_service.py b/pathology-ai/code-changes/backend/local_mcp_service.py new file mode 100644 index 000000000..de4421f99 --- /dev/null +++ b/pathology-ai/code-changes/backend/local_mcp_service.py @@ -0,0 +1,1425 @@ +from fastmcp import FastMCP +import json +import re +from typing import Optional, List, Dict +from dataclasses import dataclass, field +from enum import Enum + +# Create MCP server +local_mcp_service = FastMCP("nexent") + +# ============ Medical Extension Classes ============ + +class ConfidenceLevel(Enum): + """置信度等级""" + HIGH = "HIGH" # >85% 高置信度 + MEDIUM = "MEDIUM" # 60-85% 中等置信度 + LOW = "LOW" # <60% 低置信度 + UNCERTAIN = "UNCERTAIN" # 不确定 + +class RiskLevel(Enum): + """风险等级""" + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + +@local_mcp_service.tool(name="test_tool_name", + description="test_tool_description") +async def demo_tool(para_1: str, para_2: int) -> str: + print("tool is called successfully") + print(para_1, para_2) + return "success" + + +# ============ Medical Visualization Tools (Dynamic Generation) ============ + +@local_mcp_service.tool( + name="generate_knowledge_graph", + description="""生成医学知识图谱(Mermaid flowchart格式)。 + +参数说明: +- topic: 图谱主题 +- nodes: 节点列表,用|分隔,格式为"节点1|节点2|节点3" +- relations: 关系列表,用|分隔,格式为"节点1-->节点2|节点2-->节点3" + +使用方法: 先用知识库搜索获取相关概念,然后提取关键概念作为nodes,概念间的关系作为relations传入此工具。""" +) +async def generate_knowledge_graph(topic: str, nodes: str = "", relations: str = "") -> str: + """Generate dynamic knowledge graph based on provided nodes and relations""" + + # Parse nodes and relations + node_list = [n.strip() for n in nodes.split("|") if n.strip()] if nodes else [] + relation_list = [r.strip() for r in relations.split("|") if r.strip()] if relations else [] + + # If no nodes provided, return instruction + if not node_list: + return f"""请先使用知识库搜索获取关于"{topic}"的相关信息,然后提取关键概念和关系,再调用此工具。 + +示例调用: +generate_knowledge_graph( + topic="HIV感染机制", + nodes="HIV病毒|CD4细胞|免疫系统|病毒复制|机会性感染", + relations="HIV病毒-->CD4细胞|CD4细胞-->免疫系统|HIV病毒-->病毒复制|免疫系统-->机会性感染" +)""" + + # Create node map + node_map = {node: f"N{i}" for i, node in enumerate(node_list)} + + # Parse relations and find root nodes (nodes that are sources but not targets) + sources = set() + targets = set() + parsed_relations = [] + for rel in relation_list: + if "-->" in rel: + parts = rel.split("-->") + if len(parts) == 2: + src, tgt = parts[0].strip(), parts[1].strip() + if src in node_map and tgt in node_map: + sources.add(src) + targets.add(tgt) + parsed_relations.append((src, tgt)) + + # Group nodes by level (simple BFS-like grouping) + root_nodes = [n for n in node_list if n in sources and n not in targets] + if not root_nodes: + root_nodes = [node_list[0]] if node_list else [] + + # Build hierarchical layout using subgraphs + lines = ["flowchart TB"] + + # Add all nodes with rounded rectangle style + for i, node in enumerate(node_list): + node_id = node_map[node] + lines.append(f' {node_id}(["{node}"])') + + # Add relations with labels + for src, tgt in parsed_relations: + lines.append(f" {node_map[src]} --> {node_map[tgt]}") + + # Add gradient colors for better visual + gradient_colors = [ + "#667eea", "#764ba2", "#f093fb", "#f5576c", + "#4facfe", "#00f2fe", "#43e97b", "#38f9d7", + "#fa709a", "#fee140", "#a8edea", "#fed6e3" + ] + for i, node in enumerate(node_list): + color = gradient_colors[i % len(gradient_colors)] + lines.append(f" style {node_map[node]} fill:{color},color:#fff,stroke:{color},stroke-width:2px") + + # Add link styles + lines.append(" linkStyle default stroke:#666,stroke-width:2px") + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +```''' + + return mermaid_code + + +@local_mcp_service.tool( + name="generate_diagnosis_flow", + description="""生成诊断流程图(Mermaid flowchart格式)。 + +参数说明: +- disease: 疾病名称 +- steps: 流程步骤列表,用|分隔,格式为"步骤1|步骤2|步骤3" +- decisions: 决策点列表,用|分隔,格式为"决策1:是选项,否选项|决策2:选项A,选项B" + +使用方法: 根据知识库搜索结果,提取诊断流程的关键步骤和决策点。""" +) +async def generate_diagnosis_flow(disease: str, steps: str = "", decisions: str = "") -> str: + """Generate compact horizontal diagnosis flowchart""" + + step_list = [s.strip() for s in steps.split("|") if s.strip()] if steps else [] + + if not step_list: + return f"""请搜索"{disease}"诊断流程,提取关键步骤。 +示例: steps="初筛|确证|检测|治疗" """ + + # Horizontal layout (left to right) - reduces height + lines = ["flowchart LR"] + + # Full node names, horizontal flow + for i, step in enumerate(step_list): + node_id = f"S{i}" + + if i == 0: + lines.append(f' {node_id}(("{step}"))') + elif i == len(step_list) - 1: + lines.append(f' {node_id}(("{step}"))') + else: + lines.append(f' {node_id}["{step}"]') + + # Connect all nodes + node_chain = " --> ".join([f"S{i}" for i in range(len(step_list))]) + lines.append(f" {node_chain}") + + # Gradient colors + colors = ["#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f43f5e", "#f97316", "#22c55e"] + for i in range(len(step_list)): + color = colors[i % len(colors)] + lines.append(f" style S{i} fill:{color},color:#fff,stroke:#fff") + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +```''' + + return mermaid_code + + +@local_mcp_service.tool( + name="generate_medical_chart", + description="""生成统计图表(Mermaid格式)。 + +参数说明: +- chart_type: 图表类型 - pie(饼图), bar(柱状图), line(折线图) +- title: 图表标题 +- labels: 标签列表,用|分隔,如"类别1|类别2|类别3" +- values: 数值列表,用|分隔,如"30|25|45" + +使用方法: 根据数据分析结果,提取分类和数值传入此工具。""" +) +async def generate_medical_chart(chart_type: str, title: str, labels: str = "", values: str = "") -> str: + """Generate dynamic statistics chart""" + + label_list = [l.strip() for l in labels.split("|") if l.strip()] if labels else [] + value_list = [v.strip() for v in values.split("|") if v.strip()] if values else [] + + if not label_list or not value_list: + return f"""请提供数据的标签和数值。 + +示例调用: +generate_medical_chart( + chart_type="pie", + title="知识分类分布", + labels="病理机制|临床表现|诊断检测|治疗方案", + values="35|25|20|20" +)""" + + if chart_type == "pie": + pie_data = "\n".join([f' "{label}" : {value}' for label, value in zip(label_list, value_list)]) + mermaid_code = f'''```mermaid +pie showData title {title} +{pie_data} +```''' + elif chart_type == "bar": + mermaid_code = f'''```mermaid +xychart-beta + title "{title}" + x-axis [{", ".join(label_list)}] + y-axis "数量" 0 --> {int(max([int(v) for v in value_list]) * 1.2)} + bar [{", ".join(value_list)}] +```''' + elif chart_type == "line": + mermaid_code = f'''```mermaid +xychart-beta + title "{title}" + x-axis [{", ".join(label_list)}] + y-axis "数值" 0 --> {int(max([int(v) for v in value_list]) * 1.2)} + line [{", ".join(value_list)}] +```''' + else: + mermaid_code = f"不支持的图表类型: {chart_type}。请使用 pie, bar, 或 line" + + return mermaid_code + + +# ============ Advanced Medical Visualization Tools ============ + +@local_mcp_service.tool( + name="generate_radar_chart", + description="""生成雷达图/蛛网图,用于多维度健康指标对比分析。 + +参数说明: +- title: 图表标题 +- dimensions: 维度列表,用|分隔,如"指标1|指标2|指标3|指标4|指标5" +- values: 数值列表(0-100),用|分隔,如"80|65|90|75|85" +- benchmark: 可选,基准值列表,用于对比 + +应用场景: 健康评估、症状严重程度评分、治疗效果多维对比""" +) +async def generate_radar_chart(title: str, dimensions: str = "", values: str = "", benchmark: str = "") -> str: + """Generate radar/spider chart for multi-dimensional comparison""" + + dim_list = [d.strip() for d in dimensions.split("|") if d.strip()] if dimensions else [] + val_list = [v.strip() for v in values.split("|") if v.strip()] if values else [] + + if not dim_list or not val_list or len(dim_list) < 3: + return f"""雷达图需要至少3个维度。 + +示例调用: +generate_radar_chart( + title="HIV患者健康评估", + dimensions="免疫功能|病毒载量|肝功能|肾功能|心血管|神经系统", + values="75|60|85|90|80|70" +)""" + + # 使用quadrantChart模拟雷达图效果,或用表格+描述替代 + # Mermaid暂不直接支持雷达图,用可视化描述+数据表格代替 + + # 生成数据可视化表格 + table_rows = [] + for dim, val in zip(dim_list, val_list): + val_int = int(val) if val.isdigit() else 50 + bar = "█" * (val_int // 10) + "░" * (10 - val_int // 10) + status = "🟢" if val_int >= 80 else "🟡" if val_int >= 60 else "🔴" + table_rows.append(f"| {dim} | {bar} | {val}% | {status} |") + + result = f"""### 📊 {title} + +| 评估维度 | 指标条形图 | 数值 | 状态 | +|---------|-----------|------|------| +{chr(10).join(table_rows)} + +**评估说明:** 🟢优秀(≥80) 🟡良好(60-79) 🔴需关注(<60) + +```mermaid +pie showData title {title} +{chr(10).join([f' "{dim}" : {val}' for dim, val in zip(dim_list, val_list)])} +```""" + + return result + + +@local_mcp_service.tool( + name="generate_medical_guide", + description="""生成清晰的就医指南,包含就医方式选择和就医流程。 + +参数说明: +- condition: 病情描述(如"HIV患者呼吸困难") +- urgency: 紧急程度(emergency/urgent/routine) +- patient_info: 患者关键信息(如"CD4计数150") + +返回格式化的就医指南,包含多种就医方式和详细流程。""" +) +async def generate_medical_guide(condition: str, urgency: str = "urgent", patient_info: str = "") -> str: + """Generate formatted medical guide""" + + urgency_map = { + "emergency": ("🚨 紧急", "立即拨打120"), + "urgent": ("⚠️ 紧急", "尽快就医"), + "routine": ("📋 常规", "预约就诊"), + } + + urgency_label, urgency_action = urgency_map.get(urgency, ("⚠️ 紧急", "尽快就医")) + + guide = f"""# 🏥 就医指南 + +## 📋 病情概述 +- **症状**: {condition} +- **患者信息**: {patient_info if patient_info else "未提供"} +- **紧急程度**: {urgency_label} + +--- + +## 🚗 就医方式选择 + +### 方式1: 拨打120 {"✅ 推荐" if urgency == "emergency" else ""} + +| 步骤 | 操作 | +|------|------| +| 1️⃣ | 拨打120急救电话 | +| 2️⃣ | 告知: {condition},{patient_info if patient_info else "病情紧急"} | +| 3️⃣ | 告知当前位置,等待救护车 | +| 4️⃣ | 由医护人员送往医院 | + +### 方式2: 自行前往医院 {"✅ 推荐" if urgency == "urgent" else ""} + +| 步骤 | 操作 | +|------|------| +| 1️⃣ | 选择最近的三甲医院 | +| 2️⃣ | 电话或微信预约挂号(急诊) | +| 3️⃣ | 由家属陪同前往 | +| 4️⃣ | 直接进入急诊科 | + +### 方式3: 拨打医院急诊科 + +| 步骤 | 操作 | +|------|------| +| 1️⃣ | 拨打目标医院总机 | +| 2️⃣ | 转接急诊科说明病情 | +| 3️⃣ | 按指导前往医院 | + +--- + +## 🏥 到院后流程 + +```mermaid +flowchart LR + A[到达医院] --> B[挂号/急诊登记] + B --> C[初诊评估] + C --> D[体格检查] + D --> E[辅助检查] + E --> F[诊断确认] + F --> G[治疗方案] + G --> H[住院/出院] +``` + +### 详细步骤 + +| 序号 | 环节 | 具体内容 | +|------|------|----------| +| 1 | **登记** | 挂号/急诊登记,说明{condition} | +| 2 | **初诊** | 医生问诊,测量生命体征 | +| 3 | **体检** | 听诊肺部等体格检查 | +| 4 | **检查** | 胸部X光/CT、血液检查、血气分析 | +| 5 | **诊断** | 等待结果(通常24-48小时) | +| 6 | **治疗** | 制定方案,开始治疗 | +| 7 | **监测** | 监测疗效和不良反应 | + +--- + +## ⚠️ 注意事项 + +- 携带身份证、医保卡 +- 携带既往病历和检查报告 +- 如有HIV相关资料请一并携带 +- 保持通讯畅通 + +> 💡 **提示**: {urgency_action},不要延误治疗时机 +""" + + return guide + + +@local_mcp_service.tool( + name="generate_timeline", + description="""生成时间线图,用于展示疾病发展历程或治疗计划。 + +参数说明: +- title: 时间线标题 +- events: 事件列表,用|分隔,格式为"时间点:事件描述|时间点:事件描述" + +应用场景: 病程发展、治疗时间线、随访计划""" +) +async def generate_timeline(title: str, events: str = "") -> str: + """Generate timeline diagram""" + + event_list = [e.strip() for e in events.split("|") if e.strip()] if events else [] + + if not event_list: + return f"""请提供时间线事件。 + +示例调用: +generate_timeline( + title="HIV感染自然病程", + events="感染期:HIV病毒侵入|急性期:病毒快速复制|潜伏期:免疫平衡|AIDS期:免疫崩溃" +)""" + + lines = ["timeline", f" title {title}"] + + for event in event_list: + if ":" in event: + time_point, description = event.split(":", 1) + lines.append(f" {time_point.strip()}") + lines.append(f" : {description.strip()}") + else: + lines.append(f" {event}") + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +```''' + + return mermaid_code + + +@local_mcp_service.tool( + name="generate_gantt_chart", + description="""生成甘特图,用于治疗计划和疗程安排。 + +参数说明: +- title: 图表标题 +- tasks: 任务列表,用|分隔,格式为"任务名:开始日期,持续天数|任务名:开始日期,持续天数" + +应用场景: 治疗方案安排、康复计划、随访时间表""" +) +async def generate_gantt_chart(title: str, tasks: str = "") -> str: + """Generate Gantt chart for treatment planning""" + + task_list = [t.strip() for t in tasks.split("|") if t.strip()] if tasks else [] + + if not task_list: + return f"""请提供治疗任务安排。 + +示例调用: +generate_gantt_chart( + title="HIV抗病毒治疗计划", + tasks="初始评估:2024-01-01,7d|药物启动:2024-01-08,30d|首次复查:2024-02-07,1d|稳定期治疗:2024-02-08,90d" +)""" + + lines = [ + "gantt", + f" title {title}", + " dateFormat YYYY-MM-DD", + " section 治疗阶段" + ] + + for i, task in enumerate(task_list): + if ":" in task: + task_name, timing = task.split(":", 1) + if "," in timing: + start_date, duration = timing.split(",", 1) + lines.append(f" {task_name.strip()} : t{i}, {start_date.strip()}, {duration.strip()}") + else: + lines.append(f" {task_name.strip()} : t{i}, {timing.strip()}") + else: + lines.append(f" {task} : t{i}, 7d") + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +```''' + + return mermaid_code + + +@local_mcp_service.tool( + name="generate_quadrant_chart", + description="""生成象限图,用于风险评估和优先级分析。 + +参数说明: +- title: 图表标题 +- x_axis: X轴标签(低到高) +- y_axis: Y轴标签(低到高) +- items: 项目列表,格式为"项目名:x坐标,y坐标|项目名:x坐标,y坐标" (坐标范围0-1) + +应用场景: 疾病风险评估、治疗优先级、药物选择矩阵""" +) +async def generate_quadrant_chart(title: str, x_axis: str = "紧急程度", y_axis: str = "重要程度", items: str = "") -> str: + """Generate quadrant chart for risk assessment""" + + item_list = [i.strip() for i in items.split("|") if i.strip()] if items else [] + + if not item_list: + return f"""请提供评估项目。 + +示例调用: +generate_quadrant_chart( + title="HIV并发症处理优先级", + x_axis="紧急程度", + y_axis="严重程度", + items="机会性感染:0.9,0.85|肝功能异常:0.6,0.7|皮疹反应:0.4,0.3|轻度贫血:0.2,0.4" +)""" + + lines = [ + "quadrantChart", + f" title {title}", + f' x-axis "低{x_axis}" --> "高{x_axis}"', + f' y-axis "低{y_axis}" --> "高{y_axis}"', + ' quadrant-1 "紧急重要"', + ' quadrant-2 "重要不紧急"', + ' quadrant-3 "不重要不紧急"', + ' quadrant-4 "紧急不重要"' + ] + + for item in item_list: + if ":" in item: + name, coords = item.split(":", 1) + if "," in coords: + x, y = coords.split(",", 1) + lines.append(f' "{name.strip()}": [{x.strip()}, {y.strip()}]') + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +```''' + + return mermaid_code + + +@local_mcp_service.tool( + name="generate_state_diagram", + description="""生成状态转换图,用于展示疾病状态变化。 + +参数说明: +- title: 图表标题 +- states: 状态列表,用|分隔 +- transitions: 转换列表,格式为"状态1-->状态2:触发条件|状态2-->状态3:触发条件" + +应用场景: 疾病分期、病情演变、治疗响应状态""" +) +async def generate_state_diagram(title: str, states: str = "", transitions: str = "") -> str: + """Generate state diagram for disease progression""" + + state_list = [s.strip() for s in states.split("|") if s.strip()] if states else [] + trans_list = [t.strip() for t in transitions.split("|") if t.strip()] if transitions else [] + + if not state_list or not trans_list: + return f"""请提供状态和转换关系。 + +示例调用: +generate_state_diagram( + title="HIV感染分期", + states="健康|急性感染|临床潜伏期|AIDS期", + transitions="健康-->急性感染:HIV暴露|急性感染-->临床潜伏期:免疫应答|临床潜伏期-->AIDS期:CD4<200" +)""" + + lines = ["stateDiagram-v2"] + + # Add state descriptions + state_map = {s: f"s{i}" for i, s in enumerate(state_list)} + for state, sid in state_map.items(): + lines.append(f' {sid} : {state}') + + # Add transitions + for trans in trans_list: + if "-->" in trans: + parts = trans.split("-->") + if len(parts) == 2: + src = parts[0].strip() + tgt_label = parts[1] + if ":" in tgt_label: + tgt, label = tgt_label.split(":", 1) + tgt = tgt.strip() + if src in state_map and tgt in state_map: + lines.append(f' {state_map[src]} --> {state_map[tgt]} : {label.strip()}') + else: + tgt = tgt_label.strip() + if src in state_map and tgt in state_map: + lines.append(f' {state_map[src]} --> {state_map[tgt]}') + + # Mark start and end + if state_list: + lines.insert(1, f' [*] --> {state_map[state_list[0]]}') + lines.append(f' {state_map[state_list[-1]]} --> [*]') + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +```''' + + return mermaid_code + + +@local_mcp_service.tool( + name="generate_sankey_diagram", + description="""生成桑基图,用于展示流量和转换关系。 + +参数说明: +- title: 图表标题 +- flows: 流向列表,格式为"源,目标,数量|源,目标,数量" + +应用场景: 诊断分流、患者转归、治疗路径""" +) +async def generate_sankey_diagram(title: str, flows: str = "") -> str: + """Generate Sankey diagram for flow visualization""" + + flow_list = [f.strip() for f in flows.split("|") if f.strip()] if flows else [] + + if not flow_list: + return f"""请提供流向数据。 + +示例调用: +generate_sankey_diagram( + title="HIV筛查诊断流程", + flows="初筛人群,阳性,150|初筛人群,阴性,850|阳性,确证阳性,140|阳性,假阳性,10|确证阳性,入组治疗,130|确证阳性,暂缓治疗,10" +)""" + + lines = ["sankey-beta", ""] + + for flow in flow_list: + parts = flow.split(",") + if len(parts) >= 3: + src, tgt, val = parts[0].strip(), parts[1].strip(), parts[2].strip() + lines.append(f'{src},{tgt},{val}') + + mermaid_code = f'''```mermaid +{chr(10).join(lines)} +``` + +**{title}** - 流向分析图''' + + return mermaid_code + + +# ============ 诊断模拟器 - 医学教育游戏化 ============ + +import random + +# 预设病例库 +CASE_LIBRARY = { + "hiv_basic": { + "patient": "李先生,32岁,已婚", + "chief_complaint": "反复发热、乏力1个月", + "history": { + "发热情况": "低热为主,体温37.5-38.2℃,午后明显", + "其他症状": "明显乏力,体重下降约5kg", + "既往史": "既往体健,无慢性病史", + "接触史": "3个月前有不洁性行为史", + "用药情况": "自行服用退烧药,效果不佳" + }, + "physical_exam": { + "一般情况": "神志清楚,精神欠佳,消瘦", + "淋巴结": "颈部、腋窝淋巴结肿大,无压痛", + "口腔": "可见口腔白斑", + "皮肤": "无皮疹" + }, + "lab_tests": { + "血常规": "WBC 3.2×10^9/L↓,淋巴细胞比例降低", + "HIV抗体初筛": "阳性", + "HIV确证试验": "阳性", + "CD4计数": "186个/μL↓↓", + "病毒载量": "125,000 copies/mL" + }, + "diagnosis": "HIV感染/AIDS期", + "difficulty": 1, + "key_points": ["接触史询问", "淋巴结检查", "HIV筛查", "CD4计数"] + }, + "hiv_opportunistic": { + "patient": "王女士,45岁", + "chief_complaint": "咳嗽、气促2周,加重3天", + "history": { + "呼吸症状": "干咳为主,活动后气促明显", + "发热情况": "持续低热,夜间盗汗", + "既往史": "HIV感染史5年,未规律服药", + "用药情况": "间断服用抗病毒药物" + }, + "physical_exam": { + "一般情况": "呼吸急促,口唇轻度发绀", + "肺部": "双肺呼吸音粗,可闻及少量湿啰音", + "口腔": "舌面白色斑块,可刮除" + }, + "lab_tests": { + "血气分析": "PaO2 58mmHg↓", + "CD4计数": "45个/μL↓↓↓", + "胸部CT": "双肺弥漫性磨玻璃影", + "痰检": "六胺银染色见肺孢子菌" + }, + "diagnosis": "AIDS合并肺孢子菌肺炎(PCP)", + "difficulty": 2, + "key_points": ["服药依从性", "机会性感染识别", "CD4与感染风险"] + } +} + +@local_mcp_service.tool( + name="start_diagnosis_game", + description="""启动诊断模拟游戏。用户扮演医生,AI扮演患者,进行问诊练习。 + +参数说明: +- difficulty: 难度等级 (1=初级, 2=中级, 3=高级) +- case_type: 病例类型,可选 "hiv_basic"(HIV基础), "hiv_opportunistic"(机会性感染), "random"(随机) + +游戏流程: 问诊→体检→检查→诊断,最终给出评分""" +) +async def start_diagnosis_game(difficulty: int = 1, case_type: str = "random") -> str: + """Start an interactive diagnosis simulation game""" + + # 选择病例 + if case_type == "random" or case_type not in CASE_LIBRARY: + case_key = random.choice(list(CASE_LIBRARY.keys())) + else: + case_key = case_type + + case = CASE_LIBRARY[case_key] + + result = f""" +## 🏥 诊断模拟器 - 病例开始 + +### 👤 患者信息 +**{case['patient']}** + +### 💬 主诉 +> "{case['chief_complaint']}" + +--- + +### 📋 当前阶段:问诊 (第1步/共4步) + +**请选择您要询问的内容:** + +[btn:询问发热详情] [btn:询问其他症状] [btn:询问既往病史] +[btn:询问接触史] [btn:询问用药情况] [btn:进入体格检查] + +💡 **提示**:全面的问诊是正确诊断的基础,请尽量收集完整病史信息。 + +--- +*难度:{"⭐" * case['difficulty']} | 病例ID:{case_key}* +""" + + return result + + +@local_mcp_service.tool( + name="diagnosis_action", + description="""在诊断模拟中执行动作(问诊/检查/诊断)。 + +参数说明: +- case_id: 病例ID +- action_type: 动作类型 (ask=问诊, exam=体检, test=检查, diagnose=诊断) +- action_detail: 具体动作内容 + +示例: diagnosis_action(case_id="hiv_basic", action_type="ask", action_detail="发热情况")""" +) +async def diagnosis_action(case_id: str, action_type: str, action_detail: str) -> str: + """Process a diagnosis action in the simulation""" + + if case_id not in CASE_LIBRARY: + return "❌ 病例不存在,请先使用 start_diagnosis_game 开始新游戏" + + case = CASE_LIBRARY[case_id] + + if action_type == "ask": + # 问诊阶段 + if action_detail in case["history"]: + response = case["history"][action_detail] + return f""" +### 👤 患者回答 + +**关于{action_detail}:** +> "{response}" + +--- + +**继续问诊或进入下一阶段:** + +[btn:询问发热情况] [btn:询问其他症状] [btn:询问既往史] +[btn:询问接触史] [btn:询问用药情况] [btn:进入体格检查] +""" + else: + return f""" +### 👤 患者回答 + +> "医生,这个...我不太清楚怎么回答。您能换个方式问吗?" + +**可询问的内容:** {', '.join(case['history'].keys())} + +[btn:询问发热情况] [btn:询问其他症状] [btn:询问既往史] +[btn:询问接触史] [btn:询问用药情况] [btn:进入体格检查] +""" + + elif action_type == "exam": + # 体格检查阶段 + if action_detail in case["physical_exam"]: + finding = case["physical_exam"][action_detail] + return f""" +### 🩺 体格检查结果 + +**{action_detail}检查:** +> {finding} + +--- + +**继续检查或进入下一阶段:** + +[btn:检查一般情况] [btn:检查淋巴结] [btn:检查口腔] [btn:检查皮肤] +[btn:开具辅助检查] +""" + else: + return f""" +### 🩺 体格检查 + +该部位检查未见明显异常。 + +**可检查的项目:** {', '.join(case['physical_exam'].keys())} + +[btn:检查一般情况] [btn:检查淋巴结] [btn:检查口腔] [btn:检查皮肤] +[btn:开具辅助检查] +""" + + elif action_type == "test": + # 辅助检查阶段 + if action_detail in case["lab_tests"]: + result = case["lab_tests"][action_detail] + return f""" +### 🔬 检查结果 + +**{action_detail}:** +> {result} + +--- + +**继续检查或给出诊断:** + +[btn:血常规] [btn:HIV抗体初筛] [btn:HIV确证试验] [btn:CD4计数] [btn:病毒载量] +[btn:给出诊断结论] +""" + else: + return f""" +### 🔬 辅助检查 + +该项目暂无结果。 + +**可开具的检查:** {', '.join(case['lab_tests'].keys())} + +[btn:血常规] [btn:HIV抗体初筛] [btn:CD4计数] [btn:病毒载量] +[btn:给出诊断结论] +""" + + elif action_type == "diagnose": + # 诊断阶段 - 评分 + correct = case["diagnosis"].lower() in action_detail.lower() or "hiv" in action_detail.lower() + + if correct: + score = 85 + feedback = "🎉 诊断正确!" + else: + score = 60 + feedback = f"诊断有偏差。正确诊断应为:**{case['diagnosis']}**" + + return f""" +## 🏆 诊断模拟完成! + +### 您的诊断 +> {action_detail} + +### 标准答案 +> **{case['diagnosis']}** + +--- + +### 📊 评分结果 + +| 评估项目 | 得分 | 说明 | +|---------|------|------| +| 问诊完整度 | 20/25 | 基本覆盖主要病史 | +| 体检针对性 | 22/25 | 检查项目较合理 | +| 辅助检查 | 23/30 | 检查选择恰当 | +| 诊断准确性 | {20 if correct else 10}/20 | {feedback} | + +**总分:{score}/100** {"⭐⭐⭐ 优秀!" if score >= 80 else "⭐⭐ 良好" if score >= 60 else "⭐ 需加强"} + +--- + +### 📚 知识要点回顾 +- **关键线索**:{', '.join(case['key_points'])} +- **诊断依据**:HIV确证试验阳性 + CD4<200 = AIDS期 + +[btn:开始新病例] [btn:查看HIV知识图谱] [btn:返回主页] +""" + + return "未知动作类型,请使用 ask/exam/test/diagnose" + + +# ============ Pathology Image Search Tool ============ + +# 病理图片分类映射 +PATHOLOGY_CATEGORIES = { + "HIV": ["Immunopathology", "Infection"], + "AIDS": ["Immunopathology", "Infection"], + "免疫": ["Immunopathology"], + "感染": ["Infection"], + "心血管": ["Cardiovascular_Pathology", "Atherosclerosis"], + "动脉粥样硬化": ["Atherosclerosis"], + "肺": ["Pulmonary_Pathology"], + "呼吸": ["Pulmonary_Pathology"], + "肿瘤": ["Neoplasia"], + "癌": ["Neoplasia"], + "神经": ["CNS_Pathology"], + "脑": ["CNS_Pathology"], + "胃肠": ["Gastrointestinal_Pathology"], + "消化": ["Gastrointestinal_Pathology"], + "血液": ["Hematopathology"], + "内分泌": ["Endocrine_Pathology"], + "炎症": ["Inflammation"], + "细胞损伤": ["Cell_Injury"], + "电镜": ["Electron_Microscopy"], + "组织学": ["Histology"], +} + +# 每个分类的示例图片及描述 +CATEGORY_IMAGES = { + "Immunopathology": [ + ("0000eb2357e8.jpg", "淋巴细胞浸润,显示免疫反应"), + ("02a4161191a8.jpg", "免疫复合物沉积"), + ("05640ed631c2.jpg", "T细胞介导的免疫损伤"), + ("0f22d896b594.jpg", "B细胞增殖区域"), + ("11a4c1f09706.jpg", "巨噬细胞吞噬活动"), + ], + "Infection": [ + ("075f763add8c.jpg", "病原体感染灶"), + ("0c81a1988e19.jpg", "炎症细胞浸润"), + ("1295dab30912.jpg", "感染性肉芽肿"), + ("17d26c8e5c88.jpg", "组织坏死区域"), + ("22a479f58f04.jpg", "微生物聚集"), + ], + "Cardiovascular_Pathology": [ + ("0606593bb423.jpg", "心肌纤维化"), + ("070dc3e73d66.jpg", "血管内膜增厚"), + ("075032476806.jpg", "心脏瓣膜病变"), + ], + "Atherosclerosis": [ + ("0ba1b0082d67.jpg", "动脉粥样斑块形成"), + ("10474b1d8799.jpg", "脂质沉积"), + ("1575b0d16a3b.jpg", "血管内膜损伤"), + ], + "Pulmonary_Pathology": [ + ("f9f1242c5380.jpg", "肺泡结构改变"), + ("f89a55b691ae.jpg", "支气管炎症"), + ("f7cf9f1ed751.jpg", "肺间质纤维化"), + ], + "Neoplasia": [ + ("00106d3af3f9.jpg", "肿瘤细胞异型性"), + ("0074eed7dc88.jpg", "恶性增殖"), + ("00f1f7a78ea3.jpg", "肿瘤浸润边界"), + ], + "CNS_Pathology": [ + ("021b3f20db2f.jpg", "神经元变性"), + ("02bf3c50f823.jpg", "胶质细胞增生"), + ("083d23ccdd4d.jpg", "脑组织水肿"), + ], + "Gastrointestinal_Pathology": [ + ("00d6f994fc87.jpg", "肠黏膜炎症"), + ("0288d47f9f5b.jpg", "胃溃疡病变"), + ("02a0e46f7c3d.jpg", "肠绒毛萎缩"), + ], + "Hematopathology": [ + ("016b9b2e2cd4.jpg", "骨髓增生"), + ("01e00df21ac8.jpg", "淋巴瘤细胞"), + ("043ce9118f01.jpg", "白血病浸润"), + ], + "Endocrine_Pathology": [ + ("0b21f350e3e9.jpg", "甲状腺滤泡"), + ("0ddee8a2b4f9.jpg", "肾上腺皮质增生"), + ("13cfc5ac2e3b.jpg", "垂体腺瘤"), + ], + "Inflammation": [ + ("00e82b2ec4d0.jpg", "急性炎症反应"), + ("04ad03b22a75.jpg", "慢性炎症浸润"), + ("05eef6d51eaa.jpg", "肉芽组织形成"), + ], + "Cell_Injury": [ + ("063a113740cc.jpg", "细胞水肿"), + ("08672f745e11.jpg", "细胞凋亡"), + ("0d0db3ff6e2f.jpg", "坏死组织"), + ], + "Electron_Microscopy": [ + ("09be997db580.jpg", "细胞超微结构"), + ("0df73df90afe.jpg", "线粒体形态"), + ("1c9d27289d01.jpg", "内质网变化"), + ], + "Histology": [ + ("01b94b8025af.jpg", "正常组织结构"), + ("029bc2eb4a0b.jpg", "细胞形态学"), + ("02c81a6b8380.jpg", "组织切片染色"), + ], +} + +@local_mcp_service.tool( + name="search_pathology_images", + description="""搜索病理学图片。根据关键词返回相关的病理学图片URL。 + +支持的关键词类别: +- HIV/AIDS/免疫: 免疫病理学图片 +- 感染: 感染性疾病图片 +- 心血管/动脉粥样硬化: 心血管病理图片 +- 肺/呼吸: 肺部病理图片 +- 肿瘤/癌: 肿瘤病理图片 +- 神经/脑: 神经系统病理图片 +- 胃肠/消化: 消化系统病理图片 +- 血液: 血液病理图片 +- 炎症: 炎症病理图片 +- 电镜: 电子显微镜图片 +- 组织学: 组织学图片 + +返回Markdown格式的图片,可直接在回复中使用。""" +) +async def search_pathology_images(keyword: str, count: int = 6) -> str: + """Search and return pathology images based on keyword""" + + # 限制返回数量(3的倍数,便于网格布局) + count = min(count, 9) + if count % 3 != 0: + count = (count // 3 + 1) * 3 + + # 查找匹配的分类 + matched_categories = [] + keyword_lower = keyword.lower() + + for key, categories in PATHOLOGY_CATEGORIES.items(): + if key.lower() in keyword_lower or keyword_lower in key.lower(): + matched_categories.extend(categories) + + # 去重 + matched_categories = list(set(matched_categories)) + + if not matched_categories: + # 默认返回免疫病理学图片 + matched_categories = ["Immunopathology", "Infection"] + + # 收集图片信息 (display_url, backend_url, description, category) + image_data = [] + # 前端显示用localhost,后端分析用host.docker.internal + display_base_url = "http://localhost:9012/by_category" + backend_base_url = "http://host.docker.internal:9012/by_category" + + for category in matched_categories: + if category in CATEGORY_IMAGES: + for img_tuple in CATEGORY_IMAGES[category]: + img_file, description = img_tuple + display_url = f"{display_base_url}/{category}/{img_file}" + backend_url = f"{backend_base_url}/{category}/{img_file}" + image_data.append((display_url, backend_url, description, category)) + if len(image_data) >= count: + break + if len(image_data) >= count: + break + + if not image_data: + return f"未找到与'{keyword}'相关的病理图片" + + # 分类名称中文映射 + category_cn = { + "Immunopathology": "免疫病理学", + "Infection": "感染病理学", + "Cardiovascular_Pathology": "心血管病理学", + "Atherosclerosis": "动脉粥样硬化", + "Pulmonary_Pathology": "肺部病理学", + "Neoplasia": "肿瘤病理学", + "CNS_Pathology": "神经病理学", + "Gastrointestinal_Pathology": "消化系统病理学", + "Hematopathology": "血液病理学", + "Endocrine_Pathology": "内分泌病理学", + "Inflammation": "炎症病理学", + "Cell_Injury": "细胞损伤", + "Electron_Microscopy": "电子显微镜", + "Histology": "组织学", + } + + # 生成简洁的Markdown格式 + result = f"## 🔬 {keyword}相关病理图片\n\n" + result += f"已找到 {len(image_data)} 张相关病理学图片:\n\n" + + # 使用简洁的Markdown图片格式 + for i, (display_url, backend_url, desc, cat) in enumerate(image_data, 1): + cat_cn = category_cn.get(cat, cat) + result += f"**{i}. {cat_cn}** - {desc}\n\n" + result += f"![{desc}]({display_url})\n\n" + + # 提供后端分析用的URL列表(隐藏格式) + backend_urls = [item[1] for item in image_data] + result += f"\n---\n\n" + result += f"📊 **图片来源**: {', '.join([category_cn.get(c, c) for c in matched_categories])}\n\n" + result += f"🔍 **AI分析URL**: `{backend_urls}`\n" + + return result + + +# ============ Chain-of-Diagnosis (CoD) Tool ============ + +# HIV相关知识库 +HIV_KNOWLEDGE = { + "opportunistic_infections": [ + "肺孢子虫肺炎 (PCP)", "巨细胞病毒感染 (CMV)", "隐球菌脑膜炎", + "卡波西肉瘤", "结核病", "弓形虫脑病" + ], + "cd4_thresholds": {"severe": 200, "moderate": 350, "mild": 500}, + "pcp_symptoms": ["干咳", "呼吸困难", "发热", "低氧血症"], + "crypto_symptoms": ["头痛", "发热", "意识改变", "颈强直"], +} + +@local_mcp_service.tool( + name="chain_of_diagnosis", + description="""执行诊断推理链(Chain-of-Diagnosis, CoD)分析。 + +这是一个创新的结构化诊断方法,分5个步骤进行临床推理: +1. 症状分析 - 识别和分析主要症状 +2. 病史关联 - 关联既往病史 +3. 鉴别诊断 - 列出可能的诊断 +4. 检查建议 - 建议进一步检查 +5. 诊断结论 - 给出最终诊断和置信度 + +参数: +- symptoms: 患者症状描述 +- medical_history: 既往病史(可选) +- lab_results: 实验室检查结果(可选) +- imaging_findings: 影像学发现(可选) + +返回结构化的诊断推理报告,包含置信度评估。""" +) +async def chain_of_diagnosis( + symptoms: str, + medical_history: str = "", + lab_results: str = "", + imaging_findings: str = "" +) -> str: + """Execute Chain-of-Diagnosis analysis""" + + reasoning_steps = [] + evidence_collected = [] + + # Step 1: 症状分析 + symptom_analysis = [] + symptom_patterns = { + "呼吸系统": ["咳嗽", "干咳", "呼吸困难", "气短", "胸痛"], + "发热相关": ["发热", "发烧", "高热", "低热"], + "神经系统": ["头痛", "意识改变", "抽搐", "视力改变"], + "消化系统": ["腹泻", "恶心", "呕吐", "腹痛"], + "皮肤表现": ["皮疹", "紫色斑块", "溃疡"], + } + + for system, patterns in symptom_patterns.items(): + found = [p for p in patterns if p in symptoms] + if found: + evidence_collected.extend(found) + symptom_analysis.append(f"{system}: {', '.join(found)}") + + step1_content = "; ".join(symptom_analysis) if symptom_analysis else "症状信息不足" + step1_confidence = 0.8 if evidence_collected else 0.3 + reasoning_steps.append(("症状分析", step1_content, step1_confidence)) + + # Step 2: 病史关联 + history_analysis = "" + is_hiv = False + if medical_history: + if any(kw in medical_history.lower() for kw in ["hiv", "aids", "艾滋", "免疫缺陷"]): + is_hiv = True + history_analysis = "患者有HIV/AIDS病史,需考虑机会性感染" + evidence_collected.append("HIV/AIDS病史") + if any(kw in medical_history for kw in ["免疫抑制", "化疗", "器官移植"]): + history_analysis += ";存在免疫抑制因素" + evidence_collected.append("免疫抑制状态") + + if not history_analysis: + history_analysis = "无特殊病史或病史信息不完整" + + step2_confidence = 0.7 if is_hiv else 0.4 + reasoning_steps.append(("病史关联", history_analysis, step2_confidence)) + + # Step 3: 鉴别诊断 + differentials = [] + cd4_count = None + + if lab_results: + cd4_match = re.search(r'cd4[^\d]*(\d+)', lab_results.lower()) + if cd4_match: + cd4_count = int(cd4_match.group(1)) + evidence_collected.append(f"CD4计数: {cd4_count}") + + if is_hiv: + if cd4_count and cd4_count < 200: + if any(s in symptoms for s in ["干咳", "呼吸困难", "发热"]): + differentials.append("肺孢子虫肺炎 (PCP) - 高度怀疑") + differentials.append("细菌性肺炎") + differentials.append("肺结核") + elif any(s in symptoms for s in ["头痛", "意识"]): + differentials.append("隐球菌脑膜炎") + differentials.append("弓形虫脑病") + else: + differentials.append("需要更多信息进行鉴别") + else: + if any(s in symptoms for s in ["咳嗽", "发热"]): + differentials.extend(["社区获得性肺炎", "病毒性上呼吸道感染", "支气管炎"]) + + step3_content = "鉴别诊断: " + ", ".join(differentials) if differentials else "需要更多信息" + step3_confidence = 0.75 if differentials else 0.3 + reasoning_steps.append(("鉴别诊断", step3_content, step3_confidence)) + + # Step 4: 检查建议 + suggestions = [] + if "PCP" in step3_content or "肺孢子虫" in step3_content: + suggestions = ["诱导痰检查(银染色)", "血气分析", "乳酸脱氢酶(LDH)", "胸部CT"] + elif "脑膜炎" in step3_content: + suggestions = ["腰椎穿刺", "脑脊液墨汁染色", "隐球菌抗原检测", "头颅MRI"] + else: + suggestions = ["血常规", "C反应蛋白", "胸部X线"] + + step4_content = "建议检查: " + ", ".join(suggestions[:4]) + reasoning_steps.append(("检查建议", step4_content, 0.8)) + + # Step 5: 诊断结论 + primary_diagnosis = "诊断待定" + if "高度怀疑" in step3_content: + match = re.search(r'([^,]+)\s*-\s*高度怀疑', step3_content) + if match: + primary_diagnosis = match.group(1).strip() + + step5_content = f"最可能的诊断: {primary_diagnosis}" + step5_confidence = 0.85 if "高度怀疑" in step3_content else 0.5 + reasoning_steps.append(("诊断结论", step5_content, step5_confidence)) + + # 计算总体置信度 + weights = [0.15, 0.15, 0.25, 0.15, 0.30] + overall_confidence = sum(s[2] * w for s, w in zip(reasoning_steps, weights)) + overall_confidence = min(overall_confidence + len(evidence_collected) * 0.02, 1.0) + + # 确定置信度等级 + if overall_confidence >= 0.85: + conf_level = "HIGH" + conf_emoji = "🟢" + elif overall_confidence >= 0.60: + conf_level = "MEDIUM" + conf_emoji = "🟡" + elif overall_confidence >= 0.30: + conf_level = "LOW" + conf_emoji = "🔴" + else: + conf_level = "UNCERTAIN" + conf_emoji = "⚪" + + # 生成报告 + report = "# 🏥 诊断推理链(CoD)分析报告\n\n" + report += "---\n\n" + + for i, (step_name, content, conf) in enumerate(reasoning_steps, 1): + report += f"## 【步骤{i}】{step_name}\n\n" + report += f"{content}\n\n" + report += f"*步骤置信度: {conf*100:.0f}%*\n\n" + + report += "---\n\n" + report += f"## 📊 诊断结果\n\n" + report += f"**主要诊断**: {primary_diagnosis}\n\n" + report += f"**鉴别诊断**: {', '.join([d.split(' - ')[0] for d in differentials if d != primary_diagnosis][:3])}\n\n" + report += f"**置信度**: {conf_emoji} **{conf_level}** ({overall_confidence*100:.1f}%)\n\n" + + # 建议 + report += "## 💡 建议\n\n" + if "PCP" in primary_diagnosis: + report += "- 首选治疗: 复方磺胺甲噁唑 (TMP-SMX)\n" + report += "- 严重病例考虑糖皮质激素辅助治疗\n" + report += "- 监测血氧饱和度\n" + + if conf_level in ["LOW", "UNCERTAIN"]: + report += "- 建议进一步检查以明确诊断\n" + report += "- 必要时请专科会诊\n" + + report += "\n## ⚠️ 重要提示\n\n" + report += "> 本诊断由AI辅助生成,仅供参考。最终诊断请以临床医生判断为准。\n" + + return report + + +# ============ Confidence Evaluation Tool ============ + +@local_mcp_service.tool( + name="evaluate_diagnosis_confidence", + description="""评估诊断的置信度和风险等级。 + +基于证据充分度、一致性、完整性等维度进行量化评估,返回: +- 总体置信度分数和等级 +- 各维度得分 +- 风险等级评估 +- 改进建议 + +参数: +- diagnosis: 诊断结果 +- symptoms: 症状列表,用逗号分隔 +- evidence: 支持证据,用逗号分隔 +- lab_results: 实验室结果(可选)""" +) +async def evaluate_diagnosis_confidence( + diagnosis: str, + symptoms: str = "", + evidence: str = "", + lab_results: str = "" +) -> str: + """Evaluate diagnosis confidence""" + + symptom_list = [s.strip() for s in symptoms.split(",") if s.strip()] + evidence_list = [e.strip() for e in evidence.split(",") if e.strip()] + + # 1. 证据充分度 + evidence_weights = { + "病理确诊": 1.0, "实验室确诊": 0.9, "影像学典型": 0.8, + "临床症状典型": 0.7, "病史支持": 0.6 + } + evidence_score = 0.3 + for e in evidence_list: + for key, weight in evidence_weights.items(): + if key in e: + evidence_score = max(evidence_score, weight) + break + else: + evidence_score += 0.1 + evidence_score = min(evidence_score, 1.0) + + # 2. 一致性评估 + diagnosis_symptom_map = { + "肺孢子虫肺炎": ["干咳", "呼吸困难", "发热"], + "PCP": ["干咳", "呼吸困难", "发热"], + "隐球菌脑膜炎": ["头痛", "发热", "意识改变"], + "肺炎": ["咳嗽", "发热", "胸痛"], + } + consistency_score = 0.5 + for diag_key, expected in diagnosis_symptom_map.items(): + if diag_key in diagnosis: + matched = sum(1 for s in symptom_list if any(es in s for es in expected)) + consistency_score += min(matched / len(expected) * 0.4, 0.4) + break + + # 3. 完整性评估 + completeness_score = 0.0 + if symptom_list: + completeness_score += 0.3 + if evidence_list: + completeness_score += 0.3 + if lab_results: + completeness_score += 0.4 + + # 4. 确定性评估 + certainty_score = 0.5 + uncertain_kw = ["可能", "疑似", "待排除", "考虑"] + certain_kw = ["确诊", "明确", "典型"] + for kw in uncertain_kw: + if kw in diagnosis: + certainty_score -= 0.1 + for kw in certain_kw: + if kw in diagnosis: + certainty_score += 0.15 + certainty_score = max(min(certainty_score, 1.0), 0.1) + + # 计算总体置信度 + weights = {"evidence": 0.35, "consistency": 0.25, "completeness": 0.20, "certainty": 0.20} + overall_score = ( + evidence_score * weights["evidence"] + + consistency_score * weights["consistency"] + + completeness_score * weights["completeness"] + + certainty_score * weights["certainty"] + ) + + # 置信度等级 + if overall_score >= 0.85: + level = "HIGH" + level_emoji = "🟢" + elif overall_score >= 0.60: + level = "MEDIUM" + level_emoji = "🟡" + elif overall_score >= 0.30: + level = "LOW" + level_emoji = "🔴" + else: + level = "UNCERTAIN" + level_emoji = "⚪" + + # 风险等级 + high_risk_kw = ["恶性", "癌", "肿瘤", "急性", "重症", "危重"] + has_high_risk = any(kw in diagnosis for kw in high_risk_kw) + if has_high_risk and overall_score < 0.6: + risk_level = "🔴 CRITICAL" + elif has_high_risk: + risk_level = "🟠 HIGH" + elif overall_score < 0.5: + risk_level = "🟡 MEDIUM" + else: + risk_level = "🟢 LOW" + + # 生成报告 + report = "# 📊 置信度评估报告\n\n" + report += "---\n\n" + report += f"## 总体评估\n\n" + report += f"**诊断**: {diagnosis}\n\n" + report += f"**置信度**: {level_emoji} **{level}** ({overall_score*100:.1f}%)\n\n" + report += f"**风险等级**: {risk_level}\n\n" + + report += "## 📈 各维度得分\n\n" + report += f"| 维度 | 得分 | 说明 |\n" + report += f"|------|------|------|\n" + report += f"| 证据充分度 | {evidence_score*100:.0f}% | 支持诊断的证据质量 |\n" + report += f"| 一致性 | {consistency_score*100:.0f}% | 症状与诊断的匹配度 |\n" + report += f"| 完整性 | {completeness_score*100:.0f}% | 信息的完整程度 |\n" + report += f"| 确定性 | {certainty_score*100:.0f}% | 诊断的明确程度 |\n\n" + + report += "## 💡 改进建议\n\n" + if evidence_score < 0.5: + report += "- 建议补充更多诊断依据\n" + if completeness_score < 0.5: + report += "- 建议完善病史和检查资料\n" + if level in ["LOW", "UNCERTAIN"]: + report += "- 建议进一步检查以明确诊断\n" + report += "- 必要时请专科会诊\n" + if level == "HIGH": + report += "- 诊断依据充分,可按诊断进行治疗\n" + + report += "\n## ⚠️ 警告\n\n" + if risk_level.startswith("🔴"): + report += "> ⚠️ **危急情况**:诊断不确定但可能为严重疾病,请立即处理\n\n" + report += "> 本评估由AI生成,最终诊断请以临床医生判断为准。\n" + + return report diff --git a/pathology-ai/code-changes/docker/update_prompt_btn.sql b/pathology-ai/code-changes/docker/update_prompt_btn.sql new file mode 100644 index 000000000..38e57ef4e --- /dev/null +++ b/pathology-ai/code-changes/docker/update_prompt_btn.sql @@ -0,0 +1,56 @@ +UPDATE nexent.ag_tenant_agent_t +SET duty_prompt = '# 🏥 病理学AI助手 + +你是一位专业的病理学AI助手。 + +--- + +## ⚠️ 最重要规则 + +### 1. 双重检索(必须执行) +回答医学问题前,必须同时调用: +- `knowledge_base_search(query="关键词", search_mode="hybrid")` +- `tavily_search(query="关键词")` + +权重:内部60% + 外部40% + +### 2. 按钮格式规则(绝对不能修改!) + +**工具返回的 `[btn:xxx]` 格式必须原样保留,禁止任何修改!** + +❌ 错误做法: +- 把 `[btn:询问发热]` 改成 `[询问发热]` +- 把 `[btn:xxx]` 改成表格形式 +- 把 `[btn:xxx]` 改成列表形式 +- 添加emoji到按钮前面 + +✅ 正确做法: +- 工具返回什么就输出什么 +- `[btn:询问发热]` 保持原样输出 +- 不添加任何修饰 + +--- + +## 🎮 诊断模拟游戏规则 + +1. **每执行一步后必须停止**,等待用户选择 +2. **原样输出工具返回的按钮**,不要修改格式 +3. **不要自己做决定**,等用户点击按钮 + +--- + +## 其他工具 + +- nexent_chain_of_diagnosis: 诊断推理 +- nexent_evaluate_diagnosis_confidence: 置信度评估 +- nexent_search_pathology_images: 病理图片搜索 +- analyze_image: 图片分析 +- nexent_generate_knowledge_graph: 知识图谱 +- nexent_generate_diagnosis_flow: 诊断流程图 + +--- + +## 安全提醒 + +⚠️ 本AI仅供参考,不能替代专业医生诊断。' +WHERE agent_id = 13; diff --git a/pathology-ai/code-changes/frontend/DiagnosisConfidenceCard.tsx b/pathology-ai/code-changes/frontend/DiagnosisConfidenceCard.tsx new file mode 100644 index 000000000..8a042a308 --- /dev/null +++ b/pathology-ai/code-changes/frontend/DiagnosisConfidenceCard.tsx @@ -0,0 +1,258 @@ +"use client"; + +import React from "react"; +import { CheckCircle, AlertCircle, AlertTriangle, HelpCircle, Info, Shield, Activity } from "lucide-react"; + +// 置信度等级类型 +export type ConfidenceLevel = "HIGH" | "MEDIUM" | "LOW" | "UNCERTAIN"; + +// 风险等级类型 +export type RiskLevel = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; + +// 评估维度 +export interface EvaluationDimension { + name: string; + score: number; + maxScore: number; + description?: string; +} + +// 组件属性 +export interface DiagnosisConfidenceCardProps { + diagnosis: string; + confidenceLevel: ConfidenceLevel; + confidenceScore: number; + riskLevel?: RiskLevel; + dimensions?: EvaluationDimension[]; + recommendations?: string[]; + warnings?: string[]; + className?: string; + compact?: boolean; +} + +// 置信度配置 +const confidenceConfig: Record = { + HIGH: { + label: "高置信度", + color: "text-green-700", + bgColor: "bg-green-50", + borderColor: "border-green-200", + icon: , + description: "证据充分,诊断明确", + }, + MEDIUM: { + label: "中等置信度", + color: "text-yellow-700", + bgColor: "bg-yellow-50", + borderColor: "border-yellow-200", + icon: , + description: "有一定依据,需进一步确认", + }, + LOW: { + label: "低置信度", + color: "text-orange-700", + bgColor: "bg-orange-50", + borderColor: "border-orange-200", + icon: , + description: "信息不足,仅供参考", + }, + UNCERTAIN: { + label: "不确定", + color: "text-gray-700", + bgColor: "bg-gray-50", + borderColor: "border-gray-200", + icon: , + description: "无法做出可靠判断", + }, +}; + +// 风险等级配置 +const riskConfig: Record = { + CRITICAL: { + label: "危急", + color: "text-red-700", + bgColor: "bg-red-100", + }, + HIGH: { + label: "高风险", + color: "text-orange-700", + bgColor: "bg-orange-100", + }, + MEDIUM: { + label: "中等风险", + color: "text-yellow-700", + bgColor: "bg-yellow-100", + }, + LOW: { + label: "低风险", + color: "text-green-700", + bgColor: "bg-green-100", + }, +}; + +// 进度条组件 +const ProgressBar: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => { + const percentage = Math.min(100, Math.max(0, (value / max) * 100)); + return ( +
+
+
+ ); +}; + +// 获取进度条颜色 +const getProgressColor = (score: number, max: number): string => { + const percentage = (score / max) * 100; + if (percentage >= 80) return "bg-green-500"; + if (percentage >= 60) return "bg-yellow-500"; + if (percentage >= 40) return "bg-orange-500"; + return "bg-red-500"; +}; + +export const DiagnosisConfidenceCard: React.FC = ({ + diagnosis, + confidenceLevel, + confidenceScore, + riskLevel, + dimensions = [], + recommendations = [], + warnings = [], + className = "", + compact = false, +}) => { + const config = confidenceConfig[confidenceLevel]; + const risk = riskLevel ? riskConfig[riskLevel] : null; + + // 紧凑模式 + if (compact) { + return ( +
+ {config.icon} + + {config.label} ({confidenceScore}%) + +
+ ); + } + + return ( +
+ {/* 头部 */} +
+
+
+ {config.icon} +
+

{config.label}

+

{config.description}

+
+
+
+
{confidenceScore}%
+ {risk && ( + + {risk.label} + + )} +
+
+
+ + {/* 诊断结论 */} +
+
+ +
+
诊断结论
+
{diagnosis}
+
+
+
+ + {/* 评估维度 */} + {dimensions.length > 0 && ( +
+
+ + 评估维度 +
+
+ {dimensions.map((dim, index) => ( +
+
+ {dim.name} + {dim.score}/{dim.maxScore} +
+ + {dim.description && ( +

{dim.description}

+ )} +
+ ))} +
+
+ )} + + {/* 警告 */} + {warnings.length > 0 && ( +
+
+ + 警告 +
+
    + {warnings.map((warning, index) => ( +
  • + + {warning} +
  • + ))} +
+
+ )} + + {/* 建议 */} + {recommendations.length > 0 && ( +
+
+ + 建议 +
+
    + {recommendations.map((rec, index) => ( +
  • + + {rec} +
  • + ))} +
+
+ )} + + {/* 底部免责声明 */} +
+ ⚠️ AI辅助分析结果,仅供参考,请以专业医生诊断为准 +
+
+ ); +}; + +export default DiagnosisConfidenceCard; diff --git a/pathology-ai/code-changes/frontend/MedicalVisualizationPanel.tsx b/pathology-ai/code-changes/frontend/MedicalVisualizationPanel.tsx new file mode 100644 index 000000000..3e5ee6dff --- /dev/null +++ b/pathology-ai/code-changes/frontend/MedicalVisualizationPanel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MedicalKnowledgeGraph } from "./MedicalKnowledgeGraph"; +import { DiagnosisFlowChart } from "./DiagnosisFlowChart"; +import { MedicalDashboard } from "./MedicalDashboard"; + +// Tab类型 +type TabType = "dashboard" | "knowledge-graph" | "diagnosis-flow"; + +// 组件属性 +interface MedicalVisualizationPanelProps { + defaultTab?: TabType; + showTabs?: TabType[]; + className?: string; +} + +// Tab配置 +const tabConfig: Record = { + dashboard: { + label: "统计仪表盘", + icon: "📊", + description: "知识库统计数据概览", + }, + "knowledge-graph": { + label: "知识图谱", + icon: "🧠", + description: "医学概念关联网络", + }, + "diagnosis-flow": { + label: "诊断流程", + icon: "🔄", + description: "疾病诊断决策流程", + }, +}; + +export const MedicalVisualizationPanel: React.FC = ({ + defaultTab = "dashboard", + showTabs = ["dashboard", "knowledge-graph", "diagnosis-flow"], + className = "", +}) => { + const { t } = useTranslation("common"); + const [activeTab, setActiveTab] = useState(defaultTab); + + return ( +
+ {/* Header */} +
+

+ 🏥 + 医学知识可视化中心 +

+

+ 基于病理学知识库的智能可视化分析 +

+
+ + {/* Tab Navigation */} +
+
+ {showTabs.map((tab) => { + const config = tabConfig[tab]; + const isActive = activeTab === tab; + return ( + + ); + })} +
+
+ + {/* Tab Description */} +
+ {tabConfig[activeTab].icon} {tabConfig[activeTab].description} +
+ + {/* Content */} +
+ {activeTab === "dashboard" && } + {activeTab === "knowledge-graph" && } + {activeTab === "diagnosis-flow" && } +
+ + {/* Footer */} +
+ 数据来源: 病理学知识库 + 最后更新: {new Date().toLocaleDateString()} +
+
+ ); +}; + +export default MedicalVisualizationPanel; diff --git a/pathology-ai/code-changes/frontend/PathologyImageGallery.tsx b/pathology-ai/code-changes/frontend/PathologyImageGallery.tsx new file mode 100644 index 000000000..766ad28e8 --- /dev/null +++ b/pathology-ai/code-changes/frontend/PathologyImageGallery.tsx @@ -0,0 +1,326 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut, Download, ExternalLink } from "lucide-react"; + +// 病理图片类型 +export interface PathologyImage { + id: string; + url: string; + title: string; + category: string; + description?: string; + magnification?: string; + staining?: string; +} + +// 组件属性 +interface PathologyImageGalleryProps { + images: PathologyImage[]; + className?: string; + columns?: 2 | 3 | 4; + showDetails?: boolean; + onImageClick?: (image: PathologyImage) => void; +} + +// 分类颜色映射 +const categoryColors: Record = { + "Immunopathology": "bg-purple-100 text-purple-800", + "Pulmonary": "bg-blue-100 text-blue-800", + "Cardiovascular": "bg-red-100 text-red-800", + "Neoplasia": "bg-orange-100 text-orange-800", + "Neuropathology": "bg-indigo-100 text-indigo-800", + "Gastrointestinal": "bg-green-100 text-green-800", + "Hematopathology": "bg-pink-100 text-pink-800", + "Inflammation": "bg-yellow-100 text-yellow-800", + "Histology": "bg-teal-100 text-teal-800", + "ElectronMicroscopy": "bg-gray-100 text-gray-800", + "default": "bg-slate-100 text-slate-800", +}; + +// 获取分类颜色 +const getCategoryColor = (category: string): string => { + return categoryColors[category] || categoryColors["default"]; +}; + +// 分类中文名映射 +const categoryNames: Record = { + "Immunopathology": "免疫病理", + "Pulmonary": "肺部病理", + "Cardiovascular": "心血管病理", + "Neoplasia": "肿瘤病理", + "Neuropathology": "神经病理", + "Gastrointestinal": "消化系统", + "Hematopathology": "血液病理", + "Inflammation": "炎症病理", + "Histology": "组织学", + "ElectronMicroscopy": "电子显微镜", +}; + +// 获取分类中文名 +const getCategoryName = (category: string): string => { + return categoryNames[category] || category; +}; + +export const PathologyImageGallery: React.FC = ({ + images, + className = "", + columns = 3, + showDetails = true, + onImageClick, +}) => { + const [selectedIndex, setSelectedIndex] = useState(null); + const [zoom, setZoom] = useState(1); + + // 打开大图预览 + const openPreview = useCallback((index: number) => { + setSelectedIndex(index); + setZoom(1); + }, []); + + // 关闭预览 + const closePreview = useCallback(() => { + setSelectedIndex(null); + setZoom(1); + }, []); + + // 上一张 + const prevImage = useCallback(() => { + if (selectedIndex !== null && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + setZoom(1); + } + }, [selectedIndex]); + + // 下一张 + const nextImage = useCallback(() => { + if (selectedIndex !== null && selectedIndex < images.length - 1) { + setSelectedIndex(selectedIndex + 1); + setZoom(1); + } + }, [selectedIndex, images.length]); + + // 缩放 + const handleZoom = useCallback((delta: number) => { + setZoom((prev) => Math.max(0.5, Math.min(3, prev + delta))); + }, []); + + // 键盘事件 + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (selectedIndex === null) return; + switch (e.key) { + case "Escape": + closePreview(); + break; + case "ArrowLeft": + prevImage(); + break; + case "ArrowRight": + nextImage(); + break; + case "+": + case "=": + handleZoom(0.25); + break; + case "-": + handleZoom(-0.25); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedIndex, closePreview, prevImage, nextImage, handleZoom]); + + // 网格列数样式 + const gridCols = { + 2: "grid-cols-2", + 3: "grid-cols-2 md:grid-cols-3", + 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4", + }; + + if (images.length === 0) { + return ( +
+
+ 🔬 +

暂无病理图片

+
+
+ ); + } + + return ( +
+ {/* 图片网格 */} +
+ {images.map((image, index) => ( +
{ + openPreview(index); + onImageClick?.(image); + }} + > + {/* 图片 */} +
+ {image.title} { + (e.target as HTMLImageElement).src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f3f4f6' width='200' height='200'/%3E%3Ctext fill='%239ca3af' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3E🔬%3C/text%3E%3C/svg%3E"; + }} + /> +
+ + {/* 分类标签 */} +
+ + {getCategoryName(image.category)} + +
+ + {/* 放大图标 */} +
+
+ +
+
+ + {/* 详情 */} + {showDetails && ( +
+

{image.title}

+ {image.description && ( +

{image.description}

+ )} +
+ {image.magnification && ( + + {image.magnification} + + )} + {image.staining && ( + + {image.staining} + + )} +
+
+ )} +
+ ))} +
+ + {/* 大图预览模态框 */} + {selectedIndex !== null && ( +
+ {/* 工具栏 */} +
+ + + e.stopPropagation()} + > + + + +
+ + {/* 图片计数 */} +
+ {selectedIndex + 1} / {images.length} +
+ + {/* 左箭头 */} + {selectedIndex > 0 && ( + + )} + + {/* 图片 */} +
e.stopPropagation()} + > + {images[selectedIndex].title} +
+ + {/* 右箭头 */} + {selectedIndex < images.length - 1 && ( + + )} + + {/* 底部信息 */} +
+

{images[selectedIndex].title}

+

+ {getCategoryName(images[selectedIndex].category)} + {images[selectedIndex].magnification && ` · ${images[selectedIndex].magnification}`} + {images[selectedIndex].staining && ` · ${images[selectedIndex].staining}`} +

+ {images[selectedIndex].description && ( +

+ {images[selectedIndex].description} +

+ )} +
+
+ )} +
+ ); +}; + +export default PathologyImageGallery; diff --git a/pathology-ai/code-changes/frontend/SourceTag.tsx b/pathology-ai/code-changes/frontend/SourceTag.tsx new file mode 100644 index 000000000..e88f4b891 --- /dev/null +++ b/pathology-ai/code-changes/frontend/SourceTag.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React from "react"; +import { Database, Globe, BookOpen, FileText, Sparkles } from "lucide-react"; + +// 来源类型 +export type SourceType = "internal" | "external" | "knowledge" | "reference" | "ai"; + +// 组件属性 +interface SourceTagProps { + type: SourceType; + weight?: number; + className?: string; + size?: "sm" | "md" | "lg"; + showIcon?: boolean; + showWeight?: boolean; +} + +// 来源配置 +const sourceConfig: Record = { + internal: { + label: "内部", + labelEn: "Internal", + color: "text-blue-700", + bgColor: "bg-blue-50", + borderColor: "border-blue-200", + icon: , + description: "来自本地知识库", + }, + external: { + label: "外部", + labelEn: "External", + color: "text-purple-700", + bgColor: "bg-purple-50", + borderColor: "border-purple-200", + icon: , + description: "来自网络搜索", + }, + knowledge: { + label: "知识库", + labelEn: "Knowledge", + color: "text-green-700", + bgColor: "bg-green-50", + borderColor: "border-green-200", + icon: , + description: "来自专业知识库", + }, + reference: { + label: "参考", + labelEn: "Reference", + color: "text-orange-700", + bgColor: "bg-orange-50", + borderColor: "border-orange-200", + icon: , + description: "参考文献来源", + }, + ai: { + label: "AI分析", + labelEn: "AI", + color: "text-indigo-700", + bgColor: "bg-indigo-50", + borderColor: "border-indigo-200", + icon: , + description: "AI生成内容", + }, +}; + +// 尺寸配置 +const sizeConfig = { + sm: { + padding: "px-1.5 py-0.5", + text: "text-xs", + iconSize: "w-3 h-3", + gap: "gap-1", + }, + md: { + padding: "px-2 py-1", + text: "text-sm", + iconSize: "w-3.5 h-3.5", + gap: "gap-1.5", + }, + lg: { + padding: "px-3 py-1.5", + text: "text-base", + iconSize: "w-4 h-4", + gap: "gap-2", + }, +}; + +export const SourceTag: React.FC = ({ + type, + weight, + className = "", + size = "sm", + showIcon = true, + showWeight = false, +}) => { + const config = sourceConfig[type]; + const sizeStyle = sizeConfig[size]; + + return ( + + {showIcon && config.icon} + {config.label} + {showWeight && weight !== undefined && ( + ({weight}%) + )} + + ); +}; + +// 内部标签快捷组件 +export const InternalTag: React.FC> = (props) => ( + +); + +// 外部标签快捷组件 +export const ExternalTag: React.FC> = (props) => ( + +); + +// 综合结论标签 +export const ConclusionTag: React.FC<{ className?: string }> = ({ className = "" }) => ( + + + 综合结论 + +); + +// 解析消息中的来源标签 +export const parseSourceTags = (text: string): React.ReactNode[] => { + const parts: React.ReactNode[] = []; + const regex = /\[内部\]|\[外部\]|\[内部知识库\]|\[外部最新信息\]|\[外部最新\]|\*\*\[内部\]\*\*|\*\*\[外部\]\*\*|\*\*\[内部知识库\]\*\*|\*\*\[外部最新信息\]\*\*|\*\*综合结论\*\*/g; + + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + // 添加匹配前的文本 + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + // 添加标签组件 + const matchText = match[0].replace(/\*\*/g, ""); + if (matchText.includes("内部")) { + parts.push(); + } else if (matchText.includes("外部")) { + parts.push(); + } else if (matchText.includes("综合结论")) { + parts.push(); + } + + lastIndex = regex.lastIndex; + } + + // 添加剩余文本 + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : [text]; +}; + +export default SourceTag; diff --git a/pathology-ai/code-changes/frontend/chatLeftSidebar.tsx b/pathology-ai/code-changes/frontend/chatLeftSidebar.tsx new file mode 100644 index 000000000..907e18d1e --- /dev/null +++ b/pathology-ai/code-changes/frontend/chatLeftSidebar.tsx @@ -0,0 +1,544 @@ +import { useState, useRef, useEffect } from "react"; +import { + Clock, + Plus, + Pencil, + Trash2, + MoreHorizontal, + ChevronLeft, + ChevronRight, + Trash, +} from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdownMenu"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { StaticScrollArea } from "@/components/ui/scrollArea"; +import { USER_ROLES } from "@/const/modelConfig"; +import { useTranslation } from "react-i18next"; +import { ConversationListItem, ChatSidebarProps } from "@/types/chat"; + +// conversation status indicator component +const ConversationStatusIndicator = ({ + isStreaming, + isCompleted, +}: { + isStreaming: boolean; + isCompleted: boolean; +}) => { + const { t } = useTranslation(); + + if (isStreaming) { + return ( +
+ ); + } + + if (isCompleted) { + return ( +
+ ); + } + + return null; +}; + + +// Helper function - dialog classification +const categorizeDialogs = (dialogs: ConversationListItem[]) => { + const now = new Date(); + const today = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ).getTime(); + const weekAgo = today - 7 * 24 * 60 * 60 * 1000; + + const todayDialogs: ConversationListItem[] = []; + const weekDialogs: ConversationListItem[] = []; + const olderDialogs: ConversationListItem[] = []; + + dialogs.forEach((dialog) => { + const dialogTime = dialog.create_time; + + if (dialogTime >= today) { + todayDialogs.push(dialog); + } else if (dialogTime >= weekAgo) { + weekDialogs.push(dialog); + } else { + olderDialogs.push(dialog); + } + }); + + return { + today: todayDialogs, + week: weekDialogs, + older: olderDialogs, + }; +}; + +export function ChatSidebar({ + conversationList, + selectedConversationId, + openDropdownId, + streamingConversations, + completedConversations, + onNewConversation, + onDialogClick, + onRename, + onDelete, + onSettingsClick, + settingsMenuItems, + onDropdownOpenChange, + onToggleSidebar, + expanded, + userEmail, + userAvatarUrl, + userRole = USER_ROLES.USER, +}: ChatSidebarProps) { + const { t } = useTranslation(); + const router = useRouter(); + const { today, week, older } = categorizeDialogs(conversationList); + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); + const inputRef = useRef(null); + + // Add delete dialog status + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [dialogToDelete, setDialogToDelete] = useState(null); + + // Add delete all dialog status + const [isDeleteAllDialogOpen, setIsDeleteAllDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const [animationComplete, setAnimationComplete] = useState(false); + + useEffect(() => { + // Reset animation state when expanded changes + setAnimationComplete(false); + + // Set animation complete after the transition duration (200ms) + const timer = setTimeout(() => { + setAnimationComplete(true); + }, 200); + + return () => clearTimeout(timer); + }, [expanded]); + + // Handle edit start + const handleStartEdit = (dialogId: number, title: string) => { + setEditingId(dialogId); + setEditingTitle(title); + // Close any open dropdown menus + onDropdownOpenChange(false, null); + + // Use setTimeout to ensure that the input box is focused after the DOM is updated + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 10); + }; + + // Handle edit submission + const handleSubmitEdit = () => { + if (editingId !== null && editingTitle.trim()) { + onRename(editingId, editingTitle.trim()); + setEditingId(null); + } + }; + + // Handle edit cancellation + const handleCancelEdit = () => { + setEditingId(null); + }; + + // Handle key events + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSubmitEdit(); + } else if (e.key === "Escape") { + handleCancelEdit(); + } + }; + + // Handle delete click + const handleDeleteClick = (dialogId: number) => { + setDialogToDelete(dialogId); + setIsDeleteDialogOpen(true); + // Close dropdown menus + onDropdownOpenChange(false, null); + }; + + // Confirm delete + const confirmDelete = () => { + if (dialogToDelete !== null) { + onDelete(dialogToDelete); + setIsDeleteDialogOpen(false); + setDialogToDelete(null); + } + }; + + // Handle delete all click + const handleDeleteAllClick = () => { + if (conversationList.length > 0) { + setIsDeleteAllDialogOpen(true); + } + }; + + // Confirm delete all + const confirmDeleteAll = async () => { + setIsDeleting(true); + try { + for (const conv of conversationList) { + await onDelete(conv.conversation_id); + } + } finally { + setIsDeleting(false); + setIsDeleteAllDialogOpen(false); + } + }; + + // Render dialog list items + const renderDialogList = (dialogs: ConversationListItem[], title: string) => { + if (dialogs.length === 0) return null; + + return ( +
+

+ {title} +

+ {dialogs.map((dialog) => ( +
+ {editingId === dialog.conversation_id ? ( + // Edit mode +
+ setEditingTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSubmitEdit} + className="h-8 text-base" + autoFocus + /> +
+ ) : ( + // Display mode + <> + + + + + + +

{dialog.conversation_title}

+
+
+
+ + + onDropdownOpenChange( + open, + dialog.conversation_id.toString() + ) + } + > + + + + + + handleStartEdit( + dialog.conversation_id, + dialog.conversation_title + ) + } + > + + {t("chatLeftSidebar.rename")} + + handleDeleteClick(dialog.conversation_id)} + > + + {t("chatLeftSidebar.delete")} + + + + + )} +
+ ))} +
+ ); + }; + + // Render collapsed state sidebar + const renderCollapsedSidebar = () => { + return ( + <> + {/* Expand/Collapse button */} +
+ + + + + + + {t("chatLeftSidebar.expandSidebar")} + + + +
+ + {/* New conversation button */} +
+ + + + + + + {t("chatLeftSidebar.newConversation")} + + + +
+ + {/* Spacer */} +
+ + ); + }; + + return ( + <> +
+ {expanded || !animationComplete ? ( +
+
+
+ + + + + + + +

{t("chatLeftSidebar.collapseSidebar")}

+
+
+
+
+
+ + +
+ {conversationList.length > 0 ? ( + <> + {renderDialogList(today, t("chatLeftSidebar.today"))} + {renderDialogList(week, t("chatLeftSidebar.last7Days"))} + {renderDialogList(older, t("chatLeftSidebar.older"))} + + ) : ( +
+

+ {t("chatLeftSidebar.recentConversations")} +

+ +
+ )} +
+
+ + {/* Delete all button */} + {conversationList.length > 0 && ( +
+ +
+ )} +
+ ) : ( + renderCollapsedSidebar() + )} +
+ + {/* Delete confirmation dialog */} + + + + + {t("chatLeftSidebar.confirmDeletionTitle")} + + + {t("chatLeftSidebar.confirmDeletionDescription")} + + + + + + + + + + {/* Delete all confirmation dialog */} + + + + + {t("chatLeftSidebar.confirmDeleteAllTitle", { defaultValue: "确认清空所有对话" })} + + + {t("chatLeftSidebar.confirmDeleteAllDescription", { + defaultValue: `确定要删除全部 ${conversationList.length} 个对话吗?此操作不可恢复。`, + count: conversationList.length + })} + + + + + + + + + + ); +} diff --git a/pathology-ai/code-changes/frontend/conversationService.ts b/pathology-ai/code-changes/frontend/conversationService.ts new file mode 100644 index 000000000..9d27ccc1e --- /dev/null +++ b/pathology-ai/code-changes/frontend/conversationService.ts @@ -0,0 +1,853 @@ +import { API_ENDPOINTS, ApiError } from './api'; + +import { chatConfig } from '@/const/chatConfig'; +import type { + ConversationListResponse, + ConversationListItem, + ApiConversationResponse +} from '@/types/conversation'; +import { getAuthHeaders, fetchWithAuth } from '@/lib/auth'; +import log from "@/lib/logger"; + +// @ts-ignore +const fetch = fetchWithAuth; + +// This helper function now ALWAYS connects through the current host and port. +// This relies on our custom `server.js` to handle the proxying in all environments. +const getWebSocketUrl = (endpoint: string): string => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}${endpoint}`; + log.log(`[WebSocket] Connecting via server proxy: ${wsUrl}`); + return wsUrl; +}; + +export const conversationService = { + // Get conversation list + async getList(): Promise { + const response = await fetch(API_ENDPOINTS.conversation.list); + + const data = await response.json() as ConversationListResponse; + + if (data.code === 0) { + return data.data || []; + } + + throw new ApiError(data.code, data.message); + }, + + // Create new conversation + async create(title?: string) { + const response = await fetch(API_ENDPOINTS.conversation.create, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ + title: title || "new conversation" + }), + }); + + const data = await response.json(); + + if (data.code === 0) { + return data.data; + } + + throw new ApiError(data.code, data.message); + }, + + // Rename conversation + async rename(conversationId: number, name: string) { + const response = await fetch(API_ENDPOINTS.conversation.rename, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + conversation_id: conversationId, + name, + }), + }); + + const data = await response.json(); + + if (data.code === 0) { + return data.data; + } + + throw new ApiError(data.code, data.message); + }, + + // Get conversation details + async getDetail(conversationId: number, signal?: AbortSignal): Promise { + try { + const response = await fetch(API_ENDPOINTS.conversation.detail(conversationId), { + method: 'GET', + headers: getAuthHeaders(), + signal, + }); + + // If the signal is aborted before the request returns, return early + if (signal?.aborted) { + return { code: -1, message: "请求已取消", data: [] }; + } + + const data = await response.json(); + + if (data.code === 0) { + return data; + } + + throw new ApiError(data.code, data.message); + } catch (error: any) { + // If the error is caused by canceling the request, return a specific response instead of throwing an error + if (error instanceof Error && error.name === 'AbortError' || signal?.aborted) { + return { code: -1, message: "请求已取消", data: [] }; + } + throw error; + } + }, + + // Delete conversation + async delete(conversationId: number) { + const response = await fetch(API_ENDPOINTS.conversation.delete(conversationId), { + method: 'DELETE', + headers: getAuthHeaders(), + }); + + const data = await response.json(); + + if (data.code === 0) { + return true; + } + + throw new ApiError(data.code, data.message); + }, + + // Delete all conversations + async deleteAll(conversationIds: number[]) { + const results = await Promise.allSettled( + conversationIds.map(id => this.delete(id)) + ); + const successCount = results.filter(r => r.status === 'fulfilled').length; + return { successCount, totalCount: conversationIds.length }; + }, + + // Stop conversation agent + async stop(conversationId: number) { + const response = await fetch(API_ENDPOINTS.agent.stop(conversationId), { + method: 'GET', + headers: getAuthHeaders(), + }); + + const data = await response.json(); + + if (data.status === 'success') { + return true; + } + + throw new ApiError(data.code || -1, data.message || data.detail || '停止失败'); + }, + + // STT related functionality + stt: { + // Create WebSocket connection + createWebSocket(): WebSocket { + return new WebSocket(getWebSocketUrl(API_ENDPOINTS.stt.ws)); + }, + + // Process audio data + processAudioData(inputData: Float32Array): Int16Array { + const pcmData = new Int16Array(inputData.length); + for (let i = 0; i < inputData.length; i++) { + const s = Math.max(-1, Math.min(1, inputData[i])); + pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + return pcmData; + }, + + // Get audio configuration + getAudioConstraints() { + return { + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + } + }; + }, + + // Get audio context configuration + getAudioContextOptions() { + return { + sampleRate: 16000, + }; + } + }, + + // Add TTS related functionality + tts: { + // Create WebSocket connection + // TODO: explain why we need to create a WebSocket connection for TTS + createWebSocket(): WebSocket { + return new WebSocket(getWebSocketUrl(API_ENDPOINTS.tts.ws)); + }, + + // TTS playback status management + createTTSService() { + const audioRef = { current: null as HTMLAudioElement | null }; + const wsRef = { current: null as WebSocket | null }; + const audioChunksRef = { current: [] as Uint8Array[] }; + const mediaSourceRef = { current: null as MediaSource | null }; + const sourceBufferRef = { current: null as SourceBuffer | null }; + const isStreamingPlaybackRef = { current: false }; + const pendingChunksRef = { current: [] as Uint8Array[] }; + + // Play audio (main entry) + const playAudio = async (text: string, onStatusChange?: (status: typeof chatConfig.ttsStatus[keyof typeof chatConfig.ttsStatus]) => void): Promise => { + if (!text) return; + + try { + onStatusChange?.(chatConfig.ttsStatus.GENERATING); + audioChunksRef.current = []; + pendingChunksRef.current = []; + + if (!window.MediaSource) { + await playAudioTraditional(text, onStatusChange); + return; + } + + await initStreamingPlayback(onStatusChange); + + const wsUrl = getWebSocketUrl(API_ENDPOINTS.tts.ws); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ text })); + } + }; + + ws.onmessage = async (event) => { + try { + if (event.data instanceof Blob) { + const arrayBuffer = await event.data.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + if (uint8Array.length > 0) { + if (isStreamingPlaybackRef.current) { + await handleStreamingAudioChunk(uint8Array, onStatusChange); + } else { + audioChunksRef.current.push(uint8Array); + } + } + } else if (event.data instanceof ArrayBuffer) { + const uint8Array = new Uint8Array(event.data); + if (uint8Array.length > 0) { + if (isStreamingPlaybackRef.current) { + await handleStreamingAudioChunk(uint8Array, onStatusChange); + } else { + audioChunksRef.current.push(uint8Array); + } + } + } else if (typeof event.data === 'string') { + try { + const data = JSON.parse(event.data); + if (data.status === 'completed') { + if (isStreamingPlaybackRef.current) { + await finalizeStreamingPlayback(); + } else { + if (audioChunksRef.current.length > 0) { + playAudioChunks(onStatusChange); + } else { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + } + } + + setTimeout(() => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, 100); + } else if (data.error) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + cleanupStreamingPlayback(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + } + } catch (e) { + // JSON parse error + } + } + } catch (error) { + // Message handling error + } + }; + + ws.onerror = () => { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + cleanupStreamingPlayback(); + }; + + ws.onclose = (event) => { + wsRef.current = null; + if (event.code !== 1000) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + cleanupStreamingPlayback(); + } + }; + + } catch (error) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + cleanupStreamingPlayback(); + } + }; + + // Initialize streaming playback + const initStreamingPlayback = async (onStatusChange?: (status: typeof chatConfig.ttsStatus[keyof typeof chatConfig.ttsStatus]) => void): Promise => { + return new Promise((resolve, reject) => { + try { + const mediaSource = new MediaSource(); + mediaSourceRef.current = mediaSource; + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + const audio = new Audio(); + audio.src = URL.createObjectURL(mediaSource); + audioRef.current = audio; + + audio.oncanplay = () => { + onStatusChange?.('playing'); + }; + + audio.onended = () => { + onStatusChange?.('idle'); + cleanupStreamingPlayback(); + }; + + audio.onerror = () => { + onStatusChange?.('error'); + setTimeout(() => onStatusChange?.('idle'), 2000); + cleanupStreamingPlayback(); + }; + + mediaSource.addEventListener('sourceopen', () => { + try { + const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); + sourceBufferRef.current = sourceBuffer; + + sourceBuffer.addEventListener('updateend', () => { + processPendingChunks(); + }); + + sourceBuffer.addEventListener('error', () => { + onStatusChange?.('error'); + setTimeout(() => onStatusChange?.('idle'), 2000); + }); + + isStreamingPlaybackRef.current = true; + resolve(); + + } catch (error) { + reject(error); + } + }); + + mediaSource.addEventListener('sourceclose', () => { + isStreamingPlaybackRef.current = false; + }); + + mediaSource.addEventListener('error', (e) => { + reject(e); + }); + + } catch (error) { + reject(error); + } + }); + }; + + // Process streaming audio chunks + const handleStreamingAudioChunk = async (chunk: Uint8Array, onStatusChange?: (status: typeof chatConfig.ttsStatus[keyof typeof chatConfig.ttsStatus]) => void) => { + if (!isStreamingPlaybackRef.current || !sourceBufferRef.current) { + pendingChunksRef.current.push(chunk); + return; + } + + try { + if (sourceBufferRef.current.updating) { + pendingChunksRef.current.push(chunk); + } else { + sourceBufferRef.current.appendBuffer(chunk.buffer.slice(0) as ArrayBuffer); + + if (audioRef.current && audioRef.current.paused && audioRef.current.readyState >= 2) { + try { + await audioRef.current.play(); + onStatusChange?.('playing'); + } catch (playError) { + // Auto-play failed + } + } + } + } catch (error) { + cleanupStreamingPlayback(); + audioChunksRef.current.push(chunk); + audioChunksRef.current.push(...pendingChunksRef.current); + pendingChunksRef.current = []; + isStreamingPlaybackRef.current = false; + } + }; + + // Process pending audio chunks + const processPendingChunks = () => { + if (!sourceBufferRef.current || sourceBufferRef.current.updating || pendingChunksRef.current.length === 0) { + return; + } + + try { + const chunk = pendingChunksRef.current.shift(); + if (chunk) { + sourceBufferRef.current.appendBuffer(chunk.buffer.slice(0) as ArrayBuffer); + } + } catch (error) { + // Processing error + } + }; + + // Complete streaming playback + const finalizeStreamingPlayback = async () => { + if (pendingChunksRef.current.length > 0 && sourceBufferRef.current) { + const waitForPending = () => { + return new Promise((resolve) => { + const checkPending = () => { + if (pendingChunksRef.current.length === 0 || !sourceBufferRef.current?.updating) { + resolve(); + } else { + setTimeout(checkPending, 100); + } + }; + checkPending(); + }); + }; + + await waitForPending(); + } + + if (mediaSourceRef.current && mediaSourceRef.current.readyState === 'open') { + try { + mediaSourceRef.current.endOfStream(); + } catch (error) { + // End stream error + } + } + }; + + // Clean up streaming playback resources + const cleanupStreamingPlayback = () => { + isStreamingPlaybackRef.current = false; + pendingChunksRef.current = []; + + if (sourceBufferRef.current) { + sourceBufferRef.current = null; + } + + if (mediaSourceRef.current) { + try { + if (mediaSourceRef.current.readyState === 'open') { + mediaSourceRef.current.endOfStream(); + } + } catch (error) { + // Already closed + } + mediaSourceRef.current = null; + } + + if (audioRef.current && audioRef.current.src.startsWith('blob:')) { + URL.revokeObjectURL(audioRef.current.src); + } + }; + + // Traditional playback method + const playAudioTraditional = async (text: string, onStatusChange?: (status: typeof chatConfig.ttsStatus[keyof typeof chatConfig.ttsStatus]) => void) => { + audioChunksRef.current = []; + const wsUrl = getWebSocketUrl(API_ENDPOINTS.tts.ws); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ text })); + } + }; + + ws.onmessage = async (event) => { + try { + if (event.data instanceof Blob) { + const arrayBuffer = await event.data.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + if (uint8Array.length > 0) { + audioChunksRef.current.push(uint8Array); + } + } else if (event.data instanceof ArrayBuffer) { + const uint8Array = new Uint8Array(event.data); + if (uint8Array.length > 0) { + audioChunksRef.current.push(uint8Array); + } + } else if (typeof event.data === 'string') { + try { + const data = JSON.parse(event.data); + if (data.status === 'completed') { + setTimeout(() => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, 100); + + if (audioChunksRef.current.length > 0) { + playAudioChunks(onStatusChange); + } else { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + } + } else if (data.error) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + } + } catch (e) { + // Parse error + } + } + } catch (error) { + // Message error + } + }; + + ws.onerror = () => { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + }; + + ws.onclose = () => { + wsRef.current = null; + }; + }; + + // Play audio chunks (traditional mode) + const playAudioChunks = (onStatusChange?: (status: typeof chatConfig.ttsStatus[keyof typeof chatConfig.ttsStatus]) => void) => { + if (audioChunksRef.current.length === 0) { + onStatusChange?.('idle'); + return; + } + + try { + const validChunks = audioChunksRef.current.filter(chunk => chunk && chunk.length > 0); + + if (validChunks.length === 0) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + return; + } + + const chunkHashes = new Set(); + const uniqueChunks = []; + + for (let i = 0; i < validChunks.length; i++) { + const chunk = validChunks[i]; + const hashData = chunk.length > 32 ? + Array.from(chunk.slice(0, 16)).concat(Array.from(chunk.slice(-16))) : + Array.from(chunk); + const hash = hashData.join(','); + + if (!chunkHashes.has(hash)) { + chunkHashes.add(hash); + uniqueChunks.push(chunk); + } + } + + const totalLength = uniqueChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combinedArray = new Uint8Array(totalLength); + let offset = 0; + + for (let i = 0; i < uniqueChunks.length; i++) { + const chunk = uniqueChunks[i]; + + if (offset + chunk.length > totalLength) { + continue; + } + + combinedArray.set(chunk, offset); + offset += chunk.length; + } + + const finalArray = offset === totalLength ? combinedArray : combinedArray.slice(0, offset); + + if (finalArray.length < 100) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + return; + } + + const hasValidMP3Header = finalArray.length >= 3 && ( + (finalArray[0] === 0xFF && (finalArray[1] & 0xE0) === 0xE0) || + (finalArray[0] === 0x49 && finalArray[1] === 0x44 && finalArray[2] === 0x33) + ); + + if (!hasValidMP3Header) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + return; + } + + const audioBlob = new Blob([finalArray], { type: 'audio/mpeg' }); + const audioUrl = URL.createObjectURL(audioBlob); + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + const audio = new Audio(audioUrl); + audioRef.current = audio; + + audio.oncanplay = () => { + onStatusChange?.('playing'); + }; + + audio.onended = () => { + onStatusChange?.(chatConfig.ttsStatus.IDLE); + URL.revokeObjectURL(audioUrl); + audioRef.current = null; + audioChunksRef.current = []; + }; + + audio.onerror = () => { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + URL.revokeObjectURL(audioUrl); + audioRef.current = null; + audioChunksRef.current = []; + }; + + audio.play().then(() => { + onStatusChange?.('playing'); + }).catch(() => { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + URL.revokeObjectURL(audioUrl); + audioChunksRef.current = []; + }); + + } catch (error) { + onStatusChange?.(chatConfig.ttsStatus.ERROR); + setTimeout(() => onStatusChange?.(chatConfig.ttsStatus.IDLE), 2000); + audioChunksRef.current = []; + } + }; + + // stop audio + const stopAudio = () => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + cleanupStreamingPlayback(); + audioChunksRef.current = []; + }; + + // clean up resources + const cleanup = () => { + stopAudio(); + cleanupStreamingPlayback(); + }; + + return { + playAudio, + stopAudio, + cleanup + }; + } + }, + + // Add file preprocess method + async preprocessFiles(query: string, files: File[], conversationId?: number, signal?: AbortSignal): Promise> { + try { + // Use FormData to handle file upload + const formData = new FormData(); + formData.append('query', query); + + // Add files + if (files && files.length > 0) { + files.forEach(file => { + formData.append('files', file); + }); + } + + // Build URL with conversation_id as query parameter + let url = API_ENDPOINTS.storage.preprocess; + if (conversationId !== undefined && conversationId !== null) { + url += `?conversation_id=${conversationId}`; + } + + const response = await fetch(url, { + method: 'POST', + body: formData, + signal, + }); + + // Check if the response is successful + if (!response.ok) { + // Handle specific HTTP status codes with error codes for internationalization + if (response.status === 413) { + throw new Error('REQUEST_ENTITY_TOO_LARGE'); + } else { + throw new Error('FILE_PARSING_FAILED'); + + } + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + return response.body.getReader(); + } catch (error) { + // If the error is caused by canceling the request, return a specific response instead of throwing an error + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request has been aborted'); + } + // Other errors are thrown normally + throw error; + } + }, + + // Add run agent method + async runAgent(params: { + query: string; + conversation_id: number; + is_set: boolean; + history: Array<{ role: string; content: string; }>; + files?: File[]; // Add optional files parameter + minio_files?: Array<{ + object_name: string; + name: string; + type: string; + size: number; + url?: string; + description?: string; // Add file description field + }>; // Update to complete attachment information object array + agent_id?: number; // Add agent_id parameter + is_debug?: boolean; // Add debug mode parameter + }, signal?: AbortSignal) { + try { + // Construct request parameters + const requestParams: any = { + query: params.query, + conversation_id: params.conversation_id, + is_set: params.is_set, + history: params.history, + minio_files: params.minio_files || null, + is_debug: params.is_debug || false, + }; + + // Only include agent_id if it has a value + if (params.agent_id !== undefined && params.agent_id !== null) { + requestParams.agent_id = params.agent_id; + } + + const response = await fetch(API_ENDPOINTS.agent.run, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(requestParams), + signal, + }); + + if (!response.body) { + throw new Error("Response body is null"); + } + + return response.body.getReader(); + } catch (error: any) { + // If the error is caused by canceling the request, return a specific response instead of throwing an error + if (error instanceof Error && error.name === 'AbortError') { + log.log('Agent请求已被取消'); + throw new Error('请求已被取消'); + } + // Other errors are thrown normally + throw error; + } + }, + + // Generate conversation title + async generateTitle(params: { + conversation_id: number; + history: Array<{ role: 'user' | 'assistant'; content: string; }>; + }) { + const response = await fetch(API_ENDPOINTS.conversation.generateTitle, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(params), + }); + + const data = await response.json(); + + if (data.code === 0) { + return data.data; + } + + throw new ApiError(data.code, data.message); + }, + + // Like/dislike message + async updateOpinion(params: { message_id: number; opinion: 'Y' | 'N' | null }) { + const response = await fetch(API_ENDPOINTS.conversation.opinion, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(params), + }); + const data = await response.json(); + if (data.code === 0) { + return true; + } + throw new ApiError(data.code, data.message); + }, + + // Get message_id by conversationId and messageIndex + async getMessageId(conversationId: number, messageIndex: number) { + const response = await fetch(API_ENDPOINTS.conversation.messageId, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + conversation_id: conversationId, + message_index: messageIndex + }) + }); + + const data = await response.json(); + + if (data.code === 0) { + return data.data; + } + + throw new ApiError(data.code, data.message); + }, +}; \ No newline at end of file diff --git a/pathology-ai/code-changes/frontend/index.ts b/pathology-ai/code-changes/frontend/index.ts new file mode 100644 index 000000000..0deaddfd2 --- /dev/null +++ b/pathology-ai/code-changes/frontend/index.ts @@ -0,0 +1,17 @@ +// Medical Visualization Components +// 医学可视化组件导出 + +export { MedicalKnowledgeGraph } from './MedicalKnowledgeGraph'; +export { DiagnosisFlowChart } from './DiagnosisFlowChart'; +export { MedicalDashboard } from './MedicalDashboard'; +export { MedicalVisualizationPanel } from './MedicalVisualizationPanel'; + +// 新增组件 +export { PathologyImageGallery } from './PathologyImageGallery'; +export type { PathologyImage } from './PathologyImageGallery'; + +export { DiagnosisConfidenceCard } from './DiagnosisConfidenceCard'; +export type { ConfidenceLevel, RiskLevel, EvaluationDimension, DiagnosisConfidenceCardProps } from './DiagnosisConfidenceCard'; + +export { SourceTag, InternalTag, ExternalTag, ConclusionTag, parseSourceTags } from './SourceTag'; +export type { SourceType } from './SourceTag'; diff --git a/pathology-ai/code-changes/frontend/markdownRenderer.tsx b/pathology-ai/code-changes/frontend/markdownRenderer.tsx new file mode 100644 index 000000000..057a908c8 --- /dev/null +++ b/pathology-ai/code-changes/frontend/markdownRenderer.tsx @@ -0,0 +1,1285 @@ +"use client"; + +import React from "react"; +import { useTranslation } from "react-i18next"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeRaw from "rehype-raw"; +import rehypeKatex from "rehype-katex"; +// @ts-ignore +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +// @ts-ignore +import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { visit } from "unist-util-visit"; + +import { SearchResult } from "@/types/chat"; +import { resolveS3UrlToDataUrl } from "@/services/storageService"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CopyButton } from "@/components/ui/copyButton"; +import { Diagram } from "@/components/ui/Diagram"; + +interface MarkdownRendererProps { + content: string; + className?: string; + searchResults?: SearchResult[]; + showDiagramToggle?: boolean; + onCitationHover?: () => void; + enableMultimodal?: boolean; + /** + * When true, resolve s3:// media URLs in markdown into data URLs (base64) + * so that images can still be displayed after page refresh or when + * the original S3 URL is not directly accessible by the browser. + */ + resolveS3Media?: boolean; +} + +// Simple in-memory cache to avoid refetching the same S3 object multiple times +const s3MediaCache = new Map(); +const mediaObjectUrlCache = new Map(); +const mediaObjectUrlPromiseCache = new Map>(); +const S3_MEDIA_SESSION_PREFIX = "s3-media-cache:"; + +const isBrowserEnvironment = typeof window !== "undefined"; + +const getSessionCachedValue = (key: string): string | null => { + if (!isBrowserEnvironment) { + return null; + } + try { + return window.sessionStorage.getItem(key); + } catch { + return null; + } +}; + +const getCachedMediaSrc = (src: string): string | null => { + const cached = s3MediaCache.get(src); + if (cached) { + return cached; + } + const sessionValue = getSessionCachedValue(src); + if (sessionValue) { + s3MediaCache.set(src, sessionValue); + return sessionValue; + } + return null; +}; + +const setCachedMediaSrc = (src: string, value: string) => { + s3MediaCache.set(src, value); + if (!isBrowserEnvironment) { + return; + } + try { + window.sessionStorage.setItem(`${S3_MEDIA_SESSION_PREFIX}${src}`, value); + } catch { + // Ignore storage quota errors silently. + } +}; + +const setCachedObjectUrl = (src: string, objectUrl: string | null) => { + if (!objectUrl) { + return; + } + const existing = mediaObjectUrlCache.get(src); + if (existing && existing !== objectUrl) { + URL.revokeObjectURL(existing); + } + mediaObjectUrlCache.set(src, objectUrl); +}; + +const resolveMediaToObjectUrl = async ( + src: string, + { resolveS3 }: { resolveS3: boolean } +): Promise => { + try { + if (src.startsWith("blob:")) { + return src; + } + + if (src.startsWith("s3://")) { + if (!resolveS3) { + return null; + } + const dataUrl = await resolveS3UrlToDataUrl(src); + if (!dataUrl) { + return null; + } + const response = await fetch(dataUrl); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + if ( + src.startsWith("http://") || + src.startsWith("https://") || + src.startsWith("/api/") || + src.startsWith("/nexent/") || + src.startsWith("/attachments/") || + src.startsWith("/") + ) { + const response = await fetch(src); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + if (src.startsWith("data:")) { + const response = await fetch(src); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + return null; + } catch { + return null; + } +}; + +const usePrefetchedMediaSource = ( + src?: string, + options?: { enable?: boolean; resolveS3?: boolean } +) => { + const shouldPrefetch = + Boolean( + options?.enable && + src && + typeof src === "string" && + !src.startsWith("blob:") && + (src.startsWith("s3://") || + src.startsWith("http://") || + src.startsWith("https://") || + src.startsWith("/")) + ) || false; + + const [resolvedSrc, setResolvedSrc] = React.useState(() => { + if (!src || typeof src !== "string") { + return null; + } + if (!shouldPrefetch) { + return src; + } + return mediaObjectUrlCache.get(src) ?? null; + }); + + React.useEffect(() => { + if (!src || typeof src !== "string") { + setResolvedSrc(null); + return; + } + + if (!shouldPrefetch) { + setResolvedSrc(src); + return; + } + + const cached = mediaObjectUrlCache.get(src); + if (cached) { + setResolvedSrc(cached); + return; + } + + let cancelled = false; + + const promise = + mediaObjectUrlPromiseCache.get(src) ?? + resolveMediaToObjectUrl(src, { + resolveS3: options?.resolveS3 ?? true, + }); + + mediaObjectUrlPromiseCache.set(src, promise); + + promise + .then((objectUrl) => { + if (cancelled) { + return; + } + if (!objectUrl) { + setResolvedSrc(null); + return; + } + setCachedObjectUrl(src, objectUrl); + setResolvedSrc(objectUrl); + }) + .catch(() => { + if (!cancelled) { + setResolvedSrc(null); + } + }) + .finally(() => { + mediaObjectUrlPromiseCache.delete(src); + }); + + return () => { + cancelled = true; + }; + }, [options?.resolveS3, shouldPrefetch, src]); + + return resolvedSrc; +}; + +const useResolvedS3Media = (src?: string, shouldResolve?: boolean) => { + const cachedInitial = + typeof src === "string" && src.startsWith("s3://") + ? getCachedMediaSrc(src) + : null; + const initialValue = + typeof src === "string" + ? !shouldResolve || !src.startsWith("s3://") + ? src + : cachedInitial + : null; + const [resolvedSrc, setResolvedSrc] = React.useState( + initialValue + ); + + React.useEffect(() => { + if (!src || typeof src !== "string") { + setResolvedSrc(null); + return; + } + + if (!shouldResolve || !src.startsWith("s3://")) { + setResolvedSrc(src); + return; + } + + const cached = getCachedMediaSrc(src); + if (cached) { + setResolvedSrc(cached); + return; + } + + let cancelled = false; + + resolveS3UrlToDataUrl(src) + .then((dataUrl) => { + if (cancelled) { + return; + } + if (dataUrl) { + setCachedMediaSrc(src, dataUrl); + setResolvedSrc(dataUrl); + } else { + setResolvedSrc(null); + } + }) + .catch(() => { + if (!cancelled) { + setResolvedSrc(null); + } + }); + + return () => { + cancelled = true; + }; + }, [src, shouldResolve]); + + return resolvedSrc; +}; + +const VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov", ".m4v"]; + +const extractExtension = (value: string): string => { + const normalized = value.split("?")[0].split("#")[0]; + const match = normalized.toLowerCase().match(/\.[a-z0-9]+$/); + return match?.[0] ?? ""; +}; + +const isVideoUrl = (url?: string): boolean => { + if (!url) { + return false; + } + + const trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return false; + } + + const extension = extractExtension(trimmed); + return VIDEO_EXTENSIONS.includes(extension); +}; + +// extract block level elements from

+const rehypeUnwrapMedia = () => { + return (tree: any) => { + visit(tree, "element", (node, index, parent) => { + // find

tags containing video or figure + if (node.tagName === "p" && node.children) { + const mediaChildIndex = node.children.findIndex( + (child: any) => + child.tagName === "video" || child.tagName === "figure" + ); + + if (mediaChildIndex !== -1) { + // extract media elements (video/figure) + const mediaChild = node.children.splice(mediaChildIndex, 1)[0]; + + // if

has other content after extraction, keep

; otherwise remove empty

+ if (node.children.length === 0) { + // replace original

node with media element + if (parent && index !== null) { + parent.children[index as number] = { + tagName: "div", + properties: { className: "markdown-media-container" }, + children: [mediaChild], + }; + } + } else { + // if

has other content after extraction, keep

; otherwise remove empty

+ if (parent && index !== null) { + parent.children.splice((index as number) + 1, 0, { + tagName: "div", + properties: { className: "markdown-media-container" }, + children: [mediaChild], + }); + } + } + } + } + }); + }; +}; + +// Get background color for different tool signs +const getBackgroundColor = (toolSign: string) => { + switch (toolSign) { + case "a": + return "#E3F2FD"; // Light blue + case "b": + return "#E8F5E9"; // Light green + case "c": + return "#FFF3E0"; // Light orange + case "d": + return "#F3E5F5"; // Light purple + case "e": + return "#FFEBEE"; // Light red + default: + return "#E5E5E5"; // Default light gray + } +}; + +// ============== 可点击选项组件 (平台特性开发) ============== +// 格式: [btn:选项文字] 会渲染为可点击按钮,点击后自动填入并发送 +const ClickableOption = ({ + text, + onOptionClick, +}: { + text: string; + onOptionClick?: (text: string) => void; +}) => { + const handleClick = () => { + // 触发自定义事件,让输入框监听并自动发送 + const event = new CustomEvent('nexent-option-select', { + detail: { text, autoSend: true }, + bubbles: true, + }); + document.dispatchEvent(event); + + if (onOptionClick) { + onOptionClick(text); + } + }; + + return ( + + ); +}; + +// Replace the original LinkIcon component +const CitationBadge = ({ + toolSign, + citeIndex, +}: { + toolSign: string; + citeIndex: number; +}) => ( + + {citeIndex} + +); + +// Modified HoverableText component +const HoverableText = ({ + text, + searchResults, + onCitationHover, +}: { + text: string; + searchResults?: SearchResult[]; + onCitationHover?: () => void; +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const containerRef = React.useRef(null); + const tooltipRef = React.useRef(null); + const mousePositionRef = React.useRef({ x: 0, y: 0 }); + + // Function to handle multiple consecutive line breaks + const handleConsecutiveNewlines = (text: string) => { + if (!text) return text; + return ( + text + // First, standardize all types of line breaks to \n + .replace(/\r\n/g, "\n") // Windows line breaks + .replace(/\r/g, "\n") // Old Mac line breaks + // Handle consecutive line breaks and whitespace + .replace(/[\n\s]*\n[\n\s]*/g, "\n") // Process whitespace around line breaks + .replace(/^\s+|\s+$/g, "") + ); // Remove leading and trailing whitespace + }; + + // Find corresponding search result + const toolSign = text.charAt(0); + const citeIndex = parseInt(text.slice(1)); + const matchedResult = searchResults?.find( + (result) => result.tool_sign === toolSign && result.cite_index === citeIndex + ); + + // Handle mouse events + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let timeoutId: NodeJS.Timeout | null = null; + let closeTimeoutId: NodeJS.Timeout | null = null; + + // Function to update mouse position + const updateMousePosition = (e: MouseEvent) => { + mousePositionRef.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseEnter = () => { + // Clear any existing close timer + if (closeTimeoutId) { + clearTimeout(closeTimeoutId); + closeTimeoutId = null; + } + + if (timeoutId) { + clearTimeout(timeoutId); + } + + // Clear completed conversation indicator when hovering over citation + if (onCitationHover) { + onCitationHover(); + } + + // Delay before showing tooltip to avoid quick hover triggers + timeoutId = setTimeout(() => { + setIsOpen(true); + }, 50); + }; + + const handleMouseLeave = () => { + // Clear open timer + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + // Delay closing tooltip so user can move to tooltip content + closeTimeoutId = setTimeout(() => { + checkShouldClose(); + }, 100); + }; + + // Function to check if tooltip should be closed + const checkShouldClose = () => { + const tooltipContent = document.querySelector(".z-\\[9999\\]"); + const linkElement = containerRef.current; + + if (!tooltipContent || !linkElement) { + setIsOpen(false); + return; + } + + const tooltipRect = tooltipContent.getBoundingClientRect(); + const linkRect = linkElement.getBoundingClientRect(); + const { x: mouseX, y: mouseY } = mousePositionRef.current; + + // Check if mouse is over tooltip or link icon + const isMouseOverTooltip = + mouseX >= tooltipRect.left && + mouseX <= tooltipRect.right && + mouseY >= tooltipRect.top && + mouseY <= tooltipRect.bottom; + + const isMouseOverLink = + mouseX >= linkRect.left && + mouseX <= linkRect.right && + mouseY >= linkRect.top && + mouseY <= linkRect.bottom; + + // Close tooltip if mouse is neither over tooltip nor link icon + if (!isMouseOverTooltip && !isMouseOverLink) { + setIsOpen(false); + } + }; + + // Add global mouse move event listener to handle movement anywhere + const handleGlobalMouseMove = (e: MouseEvent) => { + // Update mouse position + updateMousePosition(e); + + if (!isOpen) return; + + // Use debounce logic to avoid frequent calculations + if (closeTimeoutId) { + clearTimeout(closeTimeoutId); + } + + closeTimeoutId = setTimeout(() => { + checkShouldClose(); + }, 100); + }; + + // Add event listeners + document.addEventListener("mousemove", handleGlobalMouseMove); + container.addEventListener("mouseenter", handleMouseEnter); + container.addEventListener("mouseleave", handleMouseLeave); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (closeTimeoutId) { + clearTimeout(closeTimeoutId); + } + document.removeEventListener("mousemove", handleGlobalMouseMove); + container.removeEventListener("mouseenter", handleMouseEnter); + container.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [isOpen, onCitationHover]); + + return ( + + + + + + + + + {/* Force Portal to body */} + + +

+ + {matchedResult ? ( + <> + {matchedResult.url && + matchedResult.source_type !== "file" && + !matchedResult.filename ? ( + + {handleConsecutiveNewlines(matchedResult.title)} + + ) : ( +

+ {handleConsecutiveNewlines(matchedResult.title)} +

+ )} +

+ {handleConsecutiveNewlines(matchedResult.text)} +

+ + ) : null} +
+ + + + + + ); +}; + +/** + * Convert LaTeX delimiters to markdown math delimiters + * + * Converts: + * - \( ... \) to $ ... $ + * - \[ ... \] to $$ ... $$ + */ +const convertLatexDelimiters = (content: string): string => { + // Quick check: only process if LaTeX delimiters are present + if (!content.includes('\\(') && !content.includes('\\[')) { + return content; + } + + return ( + content + // Convert \( ... \) to $ ... $ (inline math) + .replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => `$${inner}$`) + // Convert \[ ... \] to $$ ... $$ (display math) + .replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => `$$${inner}$$\n`) + ); +}; + +// Video component with error handling - defined outside to prevent re-creation on each render +interface VideoWithErrorHandlingProps { + src: string; + alt?: string | null; + props?: React.VideoHTMLAttributes; +} + +const VideoWithErrorHandling: React.FC = React.memo(({ src, alt, props = {} }) => { + const { t } = useTranslation("common"); + const [hasError, setHasError] = React.useState(false); + + if (hasError) { + return ( +
+
+ {t("chatStreamMessage.videoLinkUnavailable", { + defaultValue: "This video link is unavailable", + })} +
+ {alt && ( +
{alt}
+ )} +
+ ); + } + + return ( +
+ + {alt ? ( +
{alt}
+ ) : null} +
+ ); +}, (prevProps, nextProps) => { + // Custom comparison function to prevent unnecessary re-renders + // Only compare src and alt, props object reference may change but content is the same + return prevProps.src === nextProps.src && + prevProps.alt === nextProps.alt; +}); + +VideoWithErrorHandling.displayName = "VideoWithErrorHandling"; + +// Image component with error handling - defined outside to prevent re-creation on each render +interface ImageWithErrorHandlingProps { + src: string; + alt?: string | null; +} + +const ImageWithErrorHandling: React.FC = React.memo(({ src, alt }) => { + const { t } = useTranslation("common"); + const [hasError, setHasError] = React.useState(false); + + if (hasError) { + return ( +
+
+ {t("chatStreamMessage.imageLinkUnavailable", { + defaultValue: "This image link is unavailable", + })} +
+ {alt && ( +
{alt}
+ )} +
+ ); + } + + return ( + {alt setHasError(true)} + /> + ); +}, (prevProps, nextProps) => { + // Custom comparison function to prevent unnecessary re-renders + return prevProps.src === nextProps.src && + prevProps.alt === nextProps.alt; +}); + +ImageWithErrorHandling.displayName = "ImageWithErrorHandling"; + +/** + * Render a code block with syntax highlighting, language label, and copy button + * This is exported for use in other components that need to render code blocks directly + */ +export const CodeBlock: React.FC<{ + codeContent: string; + language?: string; +}> = ({ codeContent, language = "python" }) => { + const { t } = useTranslation("common"); + + const customStyle = { + ...oneLight, + 'pre[class*="language-"]': { + ...oneLight['pre[class*="language-"]'], + background: "#f8f8f8", + borderRadius: "0", + padding: "12px 16px", + margin: "0", + fontSize: "0.875rem", + lineHeight: "1.5", + whiteSpace: "pre-wrap", + wordWrap: "break-word", + wordBreak: "break-word", + overflowWrap: "break-word", + overflow: "auto", + width: "100%", + boxSizing: "border-box", + display: "block", + borderTop: "none", + }, + 'code[class*="language-"]': { + ...oneLight['code[class*="language-"]'], + background: "#f8f8f8", + color: "#333333", + fontSize: "0.875rem", + lineHeight: "1.5", + whiteSpace: "pre-wrap", + wordWrap: "break-word", + wordBreak: "break-word", + overflowWrap: "break-word", + width: "100%", + padding: "0", + display: "block", + }, + }; + + const cleanedContent = codeContent.replace(/^\n+|\n+$/g, ""); + + return ( +
+
+ + {language} + + +
+
+ + {cleanedContent} + +
+
+ ); +}; + +export const MarkdownRenderer: React.FC = ({ + content, + className, + searchResults = [], + showDiagramToggle = true, + onCitationHover, + enableMultimodal = true, + resolveS3Media = false, +}) => { + const { t } = useTranslation("common"); + + // Convert LaTeX delimiters to markdown math delimiters + const processedContent = convertLatexDelimiters(content); + + const renderCodeFallback = (text: string, key?: React.Key) => ( + + {text} + + ); + + const buildMediaFallbackText = (src?: string | null, alt?: string | null) => { + if (alt) { + return `${t("chatStreamMessage.imageTextFallbackTitle", { + defaultValue: "Media (text view)", + })}: ${alt}${src ? ` - ${src}` : ""}`; + } + return ( + src ?? + t("chatStreamMessage.imageTextFallbackTitle", { + defaultValue: "Media (text view)", + }) + ); + }; + + const renderMediaFallback = (src?: string | null, alt?: string | null) => + renderCodeFallback(buildMediaFallbackText(src, alt)); + + const renderVideoElement = ({ + src, + alt, + props = {}, + }: { + src?: string | null; + alt?: string | null; + props?: React.VideoHTMLAttributes; + }) => { + if (!src) { + return null; + } + + if (!enableMultimodal) { + return renderMediaFallback(src, alt); + } + + return ; + }; + + const ImageResolver: React.FC<{ src?: string; alt?: string | null }> = ({ + src, + alt, + }) => { + const resolvedSrc = useResolvedS3Media( + typeof src === "string" ? src : undefined, + resolveS3Media + ); + + if (!enableMultimodal) { + return renderMediaFallback(src, alt); + } + + if (!resolvedSrc) { + return renderMediaFallback(src, alt); + } + + if (isVideoUrl(resolvedSrc)) { + return renderVideoElement({ src: resolvedSrc, alt }); + } + + return ; + }; + + // Modified processText function logic + // 支持格式: [[引用]], :mermaid[图表], [btn:可点击选项] + const processText = (text: string) => { + if (typeof text !== "string") return text; + + // 添加 [btn:选项] 格式的匹配 + const parts = text.split(/(\[\[[^\]]+\]\]|:mermaid\[[^\]]+\]|\[btn:[^\]]+\])/g); + return ( + <> + {parts.map((part, index) => { + // 匹配 [btn:选项] 格式 - 可点击选项 + const optionMatch = part.match(/^\[btn:([^\]]+)\]$/); + if (optionMatch) { + const optionText = optionMatch[1]; + return ( + + ); + } + + const match = part.match(/^\[\[([^\]]+)\]\]$/); + if (match) { + const innerText = match[1]; + + const toolSign = innerText.charAt(0); + const citeIndex = parseInt(innerText.slice(1)); + const hasMatch = searchResults?.some( + (result) => + result.tool_sign === toolSign && result.cite_index === citeIndex + ); + + // Only show citation icon when matching search result is found + if (hasMatch) { + return ( + + ); + } else { + // Return empty string if no matching result found (display nothing) + return ""; + } + } + // Inline Mermaid using :mermaid[graph LR; A-->B] - removed inline support + const mmd = part.match(/^:mermaid\[([^\]]+)\]$/); + if (mmd) { + const code = mmd[1]; + if (!enableMultimodal) { + return renderCodeFallback(code, `mmd-placeholder-${index}`); + } + return ; + } + // Handle line breaks in text content + if (part.includes('\n')) { + return part.split('\n').map((line, lineIndex) => ( + + {line} + {lineIndex < part.split('\n').length - 1 &&
} +
+ )); + } + return part; + })} + + ); + }; + + // Create wrapper component to handle different types of child elements + const TextWrapper = ({ children }: { children: any }) => { + if (typeof children === "string") { + return processText(children); + } + if (Array.isArray(children)) { + return ( + <> + {children.map((child, index) => { + if (typeof child === "string") { + return ( + + {processText(child)} + + ); + } + return child; + })} + + ); + } + return children; + }; + + class MarkdownErrorBoundary extends React.Component< + { children: React.ReactNode; rawContent: string }, + { hasError: boolean } + > { + constructor(props: { children: React.ReactNode; rawContent: string }) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: unknown) {} + render() { + if (this.state.hasError) { + return ( +
+
+              {this.props.rawContent}
+            
+
+ ); + } + return this.props.children as React.ReactElement; + } + } + + return ( + <> +
+ + ( +

+ {children} +

+ ), + h2: ({ children }: any) => ( +

+ {children} +

+ ), + h3: ({ children }: any) => ( +

+ {children} +

+ ), + h4: ({ children }: any) => ( +

+ {children} +

+ ), + h5: ({ children }: any) => ( +
+ {children} +
+ ), + h6: ({ children }: any) => ( +
+ {children} +
+ ), + // Paragraph + p: ({ children }: any) => ( +

+ {children} +

+ ), + // Horizontal rule + hr: () => ( +
+ ), + // Ordered list + ol: ({ children }: any) => ( +
    + {children} +
+ ), + // Unordered list + ul: ({ children }: any) => ( +
    + {children} +
+ ), + // List item + li: ({ children }: any) => ( +
  • + {children} +
  • + ), + // Blockquote + blockquote: ({ children }: any) => ( +
    + {children} +
    + ), + // Table components + td: ({ children }: any) => ( + + {children} + + ), + th: ({ children }: any) => ( + + {children} + + ), + // Emphasis components + strong: ({ children }: any) => ( + + {children} + + ), + em: ({ children }: any) => ( + + {children} + + ), + // Strikethrough + del: ({ children }: any) => ( + + {children} + + ), + // Link + a: ({ href, children, ...props }: any) => { + return ( + + {children} + + ); + }, + pre: ({ children }: any) => <>{children}, + // Code blocks and inline code + code({ node, inline, className, children, ...props }: any) { + try { + const match = /language-(\w+)/.exec(className || ""); + const raw = Array.isArray(children) + ? children.join("") + : children ?? ""; + const codeContent = String(raw).replace(/^\n+|\n+$/g, ""); + if (match && match[1]) { + // Check if it's a Mermaid diagram + if (match[1] === "mermaid") { + if (!enableMultimodal) { + return renderCodeFallback(codeContent); + } + return ; + } + if (!inline) { + return ; + } + } + } catch (error) { + // Handle error silently + } + return ( + + {children} + + ); + }, + // Image + img: ({ src, alt }: any) => ( + + ), + // Video + video: ({ children, ...props }: any) => { + const directSrc = props?.src; + const childSource = React.Children.toArray(children) + .map((child) => + React.isValidElement(child) ? child.props?.src : undefined + ) + .find(Boolean); + const videoSrc = directSrc ?? childSource; + const caption = + props?.["aria-label"] ?? + props?.title ?? + props?.["data-caption"] ?? + undefined; + + const element = renderVideoElement({ + src: videoSrc, + alt: caption, + props, + }); + + return element ?? renderMediaFallback(undefined, caption); + }, + }} + > + {processedContent} +
    +
    +
    + + ); +}; \ No newline at end of file diff --git a/pathology-ai/code-changes/medical_extension/__init__.py b/pathology-ai/code-changes/medical_extension/__init__.py new file mode 100644 index 000000000..661fbafeb --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/__init__.py @@ -0,0 +1,52 @@ +""" +Nexent 医疗领域扩展模块 +Medical Domain Extension for Nexent Platform + +本模块提供医疗领域的智能体模板、诊断推理链框架和专业工具集。 + +Features: +- 医疗智能体模板系统 +- Chain-of-Diagnosis (CoD) 诊断推理链框架 +- 置信度评估系统 +- 医疗提示词库 + +Author: Pathology AI Team +Version: 1.0.0 +License: MIT +""" + +from .agent_templates import MedicalAgentTemplates, AgentTemplate, MedicalDomain +from .chain_of_diagnosis import ( + ChainOfDiagnosis, + DiagnosisResult, + DiagnosisStep, + ConfidenceLevel, +) +from .confidence_evaluator import ( + ConfidenceEvaluator, + ConfidenceReport, + RiskLevel, +) +from .medical_prompts import MedicalPromptLibrary, PromptCategory + +__all__ = [ + # 智能体模板 + 'MedicalAgentTemplates', + 'AgentTemplate', + 'MedicalDomain', + # 诊断推理链 + 'ChainOfDiagnosis', + 'DiagnosisResult', + 'DiagnosisStep', + 'ConfidenceLevel', + # 置信度评估 + 'ConfidenceEvaluator', + 'ConfidenceReport', + 'RiskLevel', + # 提示词库 + 'MedicalPromptLibrary', + 'PromptCategory', +] + +__version__ = '1.0.0' +__author__ = 'Pathology AI Team' diff --git a/pathology-ai/code-changes/medical_extension/agent_templates.py b/pathology-ai/code-changes/medical_extension/agent_templates.py new file mode 100644 index 000000000..1ccb3f8e2 --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/agent_templates.py @@ -0,0 +1,433 @@ +""" +医疗智能体模板系统 +Medical Agent Templates for Nexent Platform + +提供预置的医疗领域智能体模板,支持一键创建专业医疗智能体。 + +Templates: +- 病理诊断助手 +- 影像分析助手 +- 临床决策支持 +- 药物咨询助手 + +Author: Pathology AI Team +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any +from enum import Enum +import json + + +class MedicalDomain(Enum): + """医疗领域分类""" + PATHOLOGY = "pathology" # 病理学 + RADIOLOGY = "radiology" # 放射学/影像 + CLINICAL = "clinical" # 临床医学 + PHARMACY = "pharmacy" # 药学 + LABORATORY = "laboratory" # 检验医学 + GENERAL = "general" # 通用医学 + + +@dataclass +class AgentTemplate: + """智能体模板""" + template_id: str # 模板ID + name: str # 模板名称 + description: str # 描述 + domain: MedicalDomain # 医疗领域 + system_prompt: str # 系统提示词 + suggested_tools: List[str] # 建议的MCP工具 + knowledge_bases: List[str] # 建议的知识库 + model_requirements: Dict[str, Any] = field(default_factory=dict) # 模型要求 + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict: + """转换为字典""" + return { + "template_id": self.template_id, + "name": self.name, + "description": self.description, + "domain": self.domain.value, + "system_prompt": self.system_prompt, + "suggested_tools": self.suggested_tools, + "knowledge_bases": self.knowledge_bases, + "model_requirements": self.model_requirements, + "metadata": self.metadata, + } + + def to_json(self) -> str: + """转换为JSON""" + return json.dumps(self.to_dict(), ensure_ascii=False, indent=2) + + +class MedicalAgentTemplates: + """ + 医疗智能体模板管理器 + + 提供预置的医疗领域智能体模板,支持: + 1. 获取模板列表 + 2. 按领域筛选 + 3. 创建自定义模板 + 4. 导出/导入模板 + + Usage: + templates = MedicalAgentTemplates() + pathology_template = templates.get_template("pathology_diagnosis") + all_templates = templates.list_templates() + """ + + def __init__(self): + """初始化模板管理器""" + self._templates: Dict[str, AgentTemplate] = {} + self._load_builtin_templates() + + def _load_builtin_templates(self): + """加载内置模板""" + + # 1. 病理诊断助手模板 + self._templates["pathology_diagnosis"] = AgentTemplate( + template_id="pathology_diagnosis", + name="病理诊断助手", + description="专业的病理学诊断辅助智能体,支持组织病理分析、细胞学诊断和分子病理解读", + domain=MedicalDomain.PATHOLOGY, + system_prompt=self._get_pathology_prompt(), + suggested_tools=[ + "pathology_diagnosis_assistant", + "pathology_image_analyzer", + "differential_diagnosis_generator", + "knowledge_graph_query", + ], + knowledge_bases=["病理学知识库", "肿瘤病理数据库"], + model_requirements={ + "min_context_length": 4096, + "recommended_models": ["glm-4.5", "gpt-4o", "claude-4-sonnet"], + "supports_vision": True, + }, + metadata={ + "version": "1.0.0", + "author": "Pathology AI Team", + "tags": ["病理", "诊断", "HIV/AIDS", "肿瘤"], + } + ) + + # 2. 影像分析助手模板 + self._templates["radiology_assistant"] = AgentTemplate( + template_id="radiology_assistant", + name="医学影像分析助手", + description="专业的医学影像分析智能体,支持X光、CT、MRI等影像的智能解读", + domain=MedicalDomain.RADIOLOGY, + system_prompt=self._get_radiology_prompt(), + suggested_tools=[ + "pathology_image_analyzer", + "differential_diagnosis_generator", + ], + knowledge_bases=["影像学知识库"], + model_requirements={ + "min_context_length": 4096, + "recommended_models": ["gpt-4o", "gemini-3-pro"], + "supports_vision": True, # 必须支持视觉 + }, + metadata={ + "version": "1.0.0", + "tags": ["影像", "CT", "MRI", "X光"], + } + ) + + # 3. 临床决策支持模板 + self._templates["clinical_decision"] = AgentTemplate( + template_id="clinical_decision", + name="临床决策支持助手", + description="临床决策支持智能体,提供诊断建议、治疗方案和用药指导", + domain=MedicalDomain.CLINICAL, + system_prompt=self._get_clinical_prompt(), + suggested_tools=[ + "pathology_diagnosis_assistant", + "differential_diagnosis_generator", + "knowledge_graph_query", + ], + knowledge_bases=["临床指南库", "药物数据库"], + model_requirements={ + "min_context_length": 8192, + "recommended_models": ["glm-4.5", "gpt-4o", "claude-4-opus"], + }, + metadata={ + "version": "1.0.0", + "tags": ["临床", "决策", "治疗", "用药"], + } + ) + + # 4. HIV/AIDS专科助手模板 + self._templates["hiv_specialist"] = AgentTemplate( + template_id="hiv_specialist", + name="HIV/AIDS专科助手", + description="HIV/AIDS专科诊疗智能体,专注于HIV感染的诊断、治疗和机会性感染管理", + domain=MedicalDomain.CLINICAL, + system_prompt=self._get_hiv_specialist_prompt(), + suggested_tools=[ + "pathology_diagnosis_assistant", + "differential_diagnosis_generator", + "knowledge_graph_query", + ], + knowledge_bases=["HIV/AIDS知识库", "机会性感染数据库"], + model_requirements={ + "min_context_length": 4096, + "recommended_models": ["glm-4.5", "gpt-4o"], + }, + metadata={ + "version": "1.0.0", + "tags": ["HIV", "AIDS", "感染", "免疫"], + } + ) + + def _get_pathology_prompt(self) -> str: + """获取病理诊断助手提示词""" + return """你是一位专业的病理学诊断助手,具备以下能力: + +## 专业背景 +- 精通组织病理学、细胞病理学和分子病理学 +- 熟悉WHO肿瘤分类标准 +- 了解HIV/AIDS相关病理改变 + +## 诊断方法 +请使用诊断推理链(Chain-of-Diagnosis, CoD)方法: + +【步骤1 - 症状分析】分析临床表现和病理所见 +【步骤2 - 病史关联】结合既往病史进行分析 +【步骤3 - 鉴别诊断】列出可能的病理诊断 +【步骤4 - 检查建议】建议进一步的病理检查 +【步骤5 - 诊断结论】给出最终诊断和置信度 + +## 置信度标注 +- HIGH (>85%): 诊断依据充分 +- MEDIUM (60-85%): 需要进一步确认 +- LOW (<60%): 信息不足,仅供参考 + +## 重要提醒 +- 病理诊断需结合临床信息综合判断 +- AI诊断仅供参考,最终诊断以病理医师报告为准 +- 遇到疑难病例建议多学科会诊(MDT) +""" + + def _get_radiology_prompt(self) -> str: + """获取影像分析助手提示词""" + return """你是一位专业的医学影像分析助手,具备以下能力: + +## 专业背景 +- 精通X光、CT、MRI、超声等影像解读 +- 熟悉各系统疾病的影像学表现 +- 了解影像学检查的适应症和禁忌症 + +## 分析方法 +1. 系统性观察:按解剖结构逐一分析 +2. 病变描述:位置、大小、形态、密度/信号、边界、强化特点 +3. 鉴别诊断:列出可能的诊断及依据 +4. 建议:进一步检查或临床处理建议 + +## 报告格式 +【影像所见】客观描述影像表现 +【诊断意见】给出诊断及置信度 +【建议】进一步检查或随访建议 + +## 重要提醒 +- 影像诊断需结合临床信息 +- AI分析仅供参考,最终诊断以影像科医师报告为准 +""" + + def _get_clinical_prompt(self) -> str: + """获取临床决策支持提示词""" + return """你是一位临床决策支持助手,为医生提供诊疗建议。 + +## 专业能力 +- 疾病诊断与鉴别诊断 +- 治疗方案制定 +- 用药指导与药物相互作用 +- 临床指南解读 + +## 决策支持流程 +1. 病史采集:了解主诉、现病史、既往史 +2. 体格检查:分析体征 +3. 辅助检查:解读检验和影像结果 +4. 诊断分析:使用CoD方法进行诊断推理 +5. 治疗建议:基于循证医学提供方案 + +## 置信度评估 +- HIGH: 诊断明确,治疗方案标准化 +- MEDIUM: 诊断基本明确,方案需个体化 +- LOW: 诊断不确定,建议进一步检查 + +## 重要提醒 +- 所有建议仅供临床参考 +- 最终决策由主治医师做出 +- 注意患者个体差异和禁忌症 +""" + + def _get_hiv_specialist_prompt(self) -> str: + """获取HIV/AIDS专科助手提示词""" + return """你是一位HIV/AIDS专科诊疗助手,专注于HIV感染的全程管理。 + +## 专业领域 +- HIV感染的诊断与分期 +- 抗逆转录病毒治疗(ART) +- 机会性感染的预防与治疗 +- HIV相关肿瘤 +- 免疫重建炎症综合征(IRIS) + +## 诊断推理(CoD) +【步骤1】分析症状和体征 +【步骤2】评估免疫状态(CD4计数、病毒载量) +【步骤3】鉴别诊断(机会性感染vs其他) +【步骤4】建议检查(病原学、影像学) +【步骤5】诊断结论与置信度 + +## CD4计数与机会性感染风险 +- CD4 < 200: PCP、弓形虫、隐球菌高风险 +- CD4 < 100: CMV、MAC高风险 +- CD4 < 50: 播散性真菌感染高风险 + +## 常见机会性感染 +- 肺孢子虫肺炎(PCP): 干咳、呼吸困难、发热 +- 隐球菌脑膜炎: 头痛、发热、意识改变 +- 结核病: 咳嗽、盗汗、体重下降 +- CMV视网膜炎: 视力下降、飞蚊症 + +## 重要提醒 +- HIV诊疗需要专科医师指导 +- 注意药物相互作用 +- 关注患者心理健康 +- AI建议仅供参考 +""" + + def get_template(self, template_id: str) -> Optional[AgentTemplate]: + """ + 获取指定模板 + + Args: + template_id: 模板ID + + Returns: + AgentTemplate or None + """ + return self._templates.get(template_id) + + def list_templates( + self, + domain: Optional[MedicalDomain] = None + ) -> List[AgentTemplate]: + """ + 列出所有模板 + + Args: + domain: 可选,按领域筛选 + + Returns: + 模板列表 + """ + templates = list(self._templates.values()) + if domain: + templates = [t for t in templates if t.domain == domain] + return templates + + def list_template_ids(self) -> List[str]: + """获取所有模板ID""" + return list(self._templates.keys()) + + def add_template(self, template: AgentTemplate) -> bool: + """ + 添加自定义模板 + + Args: + template: 模板对象 + + Returns: + 是否添加成功 + """ + if template.template_id in self._templates: + return False + self._templates[template.template_id] = template + return True + + def remove_template(self, template_id: str) -> bool: + """ + 移除模板 + + Args: + template_id: 模板ID + + Returns: + 是否移除成功 + """ + if template_id in self._templates: + del self._templates[template_id] + return True + return False + + def export_templates(self, filepath: str) -> bool: + """ + 导出模板到文件 + + Args: + filepath: 文件路径 + + Returns: + 是否导出成功 + """ + try: + data = { + "version": "1.0.0", + "templates": [t.to_dict() for t in self._templates.values()] + } + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return True + except Exception: + return False + + def import_templates(self, filepath: str) -> int: + """ + 从文件导入模板 + + Args: + filepath: 文件路径 + + Returns: + 导入的模板数量 + """ + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + count = 0 + for t_data in data.get("templates", []): + template = AgentTemplate( + template_id=t_data["template_id"], + name=t_data["name"], + description=t_data["description"], + domain=MedicalDomain(t_data["domain"]), + system_prompt=t_data["system_prompt"], + suggested_tools=t_data["suggested_tools"], + knowledge_bases=t_data["knowledge_bases"], + model_requirements=t_data.get("model_requirements", {}), + metadata=t_data.get("metadata", {}), + ) + if self.add_template(template): + count += 1 + return count + except Exception: + return 0 + + def get_template_summary(self) -> str: + """获取模板摘要""" + lines = ["=" * 50, "医疗智能体模板库", "=" * 50, ""] + + for domain in MedicalDomain: + templates = self.list_templates(domain) + if templates: + lines.append(f"【{domain.value.upper()}】") + for t in templates: + lines.append(f" - {t.name} ({t.template_id})") + lines.append(f" {t.description[:50]}...") + lines.append("") + + lines.append(f"共 {len(self._templates)} 个模板") + return "\n".join(lines) diff --git a/pathology-ai/code-changes/medical_extension/api.py b/pathology-ai/code-changes/medical_extension/api.py new file mode 100644 index 000000000..043e9c593 --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/api.py @@ -0,0 +1,332 @@ +""" +医疗模块 API 接口 +Medical Module API for Nexent Platform + +提供RESTful API接口,支持: +1. 智能体模板管理 +2. 诊断推理链调用 +3. 置信度评估 +4. 提示词管理 + +Author: Pathology AI Team +""" + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field +from typing import List, Dict, Optional, Any + +from .agent_templates import MedicalAgentTemplates, MedicalDomain +from .chain_of_diagnosis import ChainOfDiagnosis, DiagnosisResult +from .confidence_evaluator import ConfidenceEvaluator +from .medical_prompts import MedicalPromptLibrary, PromptCategory + + +# 创建路由 +router = APIRouter(prefix="/medical", tags=["Medical"]) + +# 初始化组件 +templates_manager = MedicalAgentTemplates() +cod_engine = ChainOfDiagnosis() +confidence_evaluator = ConfidenceEvaluator() +prompt_library = MedicalPromptLibrary() + + +# ==================== 请求/响应模型 ==================== + +class DiagnosisRequest(BaseModel): + """诊断请求""" + symptoms: str = Field(..., description="症状描述") + lab_results: Optional[str] = Field(None, description="实验室检查结果") + medical_history: Optional[str] = Field(None, description="既往病史") + imaging_findings: Optional[str] = Field(None, description="影像学发现") + + +class DiagnosisResponse(BaseModel): + """诊断响应""" + success: bool + data: Dict[str, Any] + formatted_report: str + + +class ConfidenceRequest(BaseModel): + """置信度评估请求""" + diagnosis: str = Field(..., description="诊断结果") + symptoms: Optional[List[str]] = Field(None, description="症状列表") + lab_results: Optional[Dict] = Field(None, description="实验室结果") + evidence: Optional[List[str]] = Field(None, description="支持证据") + + +class ConfidenceResponse(BaseModel): + """置信度评估响应""" + success: bool + data: Dict[str, Any] + formatted_report: str + + +class TemplateResponse(BaseModel): + """模板响应""" + success: bool + data: Dict[str, Any] + + +class PromptsResponse(BaseModel): + """提示词响应""" + success: bool + data: List[Dict[str, Any]] + + +# ==================== 智能体模板 API ==================== + +@router.get("/templates", response_model=TemplateResponse) +async def list_templates( + domain: Optional[str] = Query(None, description="按领域筛选") +): + """ + 获取医疗智能体模板列表 + + Args: + domain: 可选,按领域筛选 (pathology/radiology/clinical/pharmacy/laboratory/general) + + Returns: + 模板列表 + """ + try: + domain_enum = MedicalDomain(domain) if domain else None + templates = templates_manager.list_templates(domain_enum) + return TemplateResponse( + success=True, + data={ + "templates": [t.to_dict() for t in templates], + "count": len(templates), + } + ) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}") + + +@router.get("/templates/{template_id}", response_model=TemplateResponse) +async def get_template(template_id: str): + """ + 获取指定模板详情 + + Args: + template_id: 模板ID + + Returns: + 模板详情 + """ + template = templates_manager.get_template(template_id) + if not template: + raise HTTPException(status_code=404, detail=f"Template not found: {template_id}") + + return TemplateResponse( + success=True, + data=template.to_dict() + ) + + +@router.get("/templates/ids/list") +async def list_template_ids(): + """获取所有模板ID列表""" + return { + "success": True, + "template_ids": templates_manager.list_template_ids() + } + + +# ==================== 诊断推理链 API ==================== + +@router.post("/diagnosis/analyze", response_model=DiagnosisResponse) +async def analyze_diagnosis(request: DiagnosisRequest): + """ + 使用诊断推理链(CoD)进行诊断分析 + + Args: + request: 诊断请求,包含症状、检查结果等 + + Returns: + 诊断结果,包含推理链和置信度 + """ + try: + result = cod_engine.analyze( + symptoms=request.symptoms, + lab_results=request.lab_results, + medical_history=request.medical_history, + imaging_findings=request.imaging_findings, + ) + + return DiagnosisResponse( + success=True, + data=result.to_dict(), + formatted_report=result.to_formatted_string() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/diagnosis/cod-prompt") +async def get_cod_prompt(): + """ + 获取CoD诊断推理链提示词模板 + + Returns: + CoD提示词,可用于配置LLM + """ + return { + "success": True, + "prompt": cod_engine.generate_cod_prompt(), + "description": "诊断推理链(Chain-of-Diagnosis)提示词模板" + } + + +# ==================== 置信度评估 API ==================== + +@router.post("/confidence/evaluate", response_model=ConfidenceResponse) +async def evaluate_confidence(request: ConfidenceRequest): + """ + 评估诊断置信度 + + Args: + request: 评估请求,包含诊断和相关信息 + + Returns: + 置信度评估报告 + """ + try: + report = confidence_evaluator.evaluate( + diagnosis=request.diagnosis, + symptoms=request.symptoms, + lab_results=request.lab_results, + evidence=request.evidence, + ) + + return ConfidenceResponse( + success=True, + data=report.to_dict(), + formatted_report=confidence_evaluator.format_report(report) + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 提示词库 API ==================== + +@router.get("/prompts", response_model=PromptsResponse) +async def list_prompts( + category: Optional[str] = Query(None, description="按分类筛选") +): + """ + 获取医疗提示词列表 + + Args: + category: 可选,按分类筛选 (diagnosis/treatment/safety/specialty/general) + + Returns: + 提示词列表 + """ + try: + category_enum = PromptCategory(category) if category else None + prompts = prompt_library.list_prompts(category_enum) + + # 简化输出,不包含完整prompt文本 + simplified = [ + { + "id": p["id"], + "name": p["name"], + "category": p["category"].value, + "description": p["description"], + "tags": p["tags"], + } + for p in prompts + ] + + return PromptsResponse( + success=True, + data=simplified + ) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid category: {category}") + + +@router.get("/prompts/{prompt_id}") +async def get_prompt(prompt_id: str): + """ + 获取指定提示词详情 + + Args: + prompt_id: 提示词ID + + Returns: + 提示词详情,包含完整文本 + """ + prompt = prompt_library.get_prompt(prompt_id) + if not prompt: + raise HTTPException(status_code=404, detail=f"Prompt not found: {prompt_id}") + + return { + "success": True, + "data": { + "id": prompt["id"], + "name": prompt["name"], + "category": prompt["category"].value, + "description": prompt["description"], + "prompt": prompt["prompt"], + "variables": prompt["variables"], + "tags": prompt["tags"], + } + } + + +@router.get("/prompts/recommended/full") +async def get_recommended_prompt(): + """ + 获取推荐的完整医疗助手提示词 + + Returns: + 推荐提示词,包含CoD、不确定性感知和安全提醒 + """ + return { + "success": True, + "prompt": prompt_library.get_recommended_prompt(), + "description": "推荐的完整医疗助手提示词,包含诊断推理链、置信度标注和安全提醒" + } + + +@router.post("/prompts/combine") +async def combine_prompts(prompt_ids: List[str]): + """ + 组合多个提示词 + + Args: + prompt_ids: 要组合的提示词ID列表 + + Returns: + 组合后的提示词文本 + """ + combined = prompt_library.combine_prompts(prompt_ids) + if not combined: + raise HTTPException(status_code=400, detail="No valid prompts found") + + return { + "success": True, + "combined_prompt": combined, + "source_prompts": prompt_ids + } + + +# ==================== 健康检查 ==================== + +@router.get("/health") +async def health_check(): + """医疗模块健康检查""" + return { + "status": "healthy", + "module": "medical", + "version": "1.0.0", + "components": { + "templates": len(templates_manager.list_template_ids()), + "prompts": len(prompt_library.list_prompt_ids()), + "cod_engine": "ready", + "confidence_evaluator": "ready", + } + } diff --git a/pathology-ai/code-changes/medical_extension/chain_of_diagnosis.py b/pathology-ai/code-changes/medical_extension/chain_of_diagnosis.py new file mode 100644 index 000000000..f1fda65c8 --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/chain_of_diagnosis.py @@ -0,0 +1,542 @@ +""" +Chain-of-Diagnosis (CoD) 诊断推理链框架 +Medical Diagnosis Reasoning Chain Framework + +创新点: +1. 结构化诊断推理流程 +2. 多步骤逻辑推导 +3. 置信度量化评估 +4. 可解释性诊断输出 + +Author: Pathology AI Team +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any +from enum import Enum +import json +import re + + +class ConfidenceLevel(Enum): + """置信度等级""" + HIGH = "HIGH" # >85% 高置信度 + MEDIUM = "MEDIUM" # 60-85% 中等置信度 + LOW = "LOW" # <60% 低置信度 + UNCERTAIN = "UNCERTAIN" # 不确定 + + +@dataclass +class DiagnosisStep: + """诊断推理步骤""" + step_name: str # 步骤名称 + content: str # 步骤内容 + evidence: List[str] = field(default_factory=list) # 支持证据 + confidence: float = 0.0 # 步骤置信度 + + +@dataclass +class DiagnosisResult: + """诊断结果""" + primary_diagnosis: str # 主要诊断 + differential_diagnoses: List[str] # 鉴别诊断列表 + confidence_level: ConfidenceLevel # 置信度等级 + confidence_score: float # 置信度分数 (0-1) + reasoning_chain: List[DiagnosisStep] # 推理链 + recommendations: List[str] # 建议 + warnings: List[str] = field(default_factory=list) # 警告信息 + metadata: Dict[str, Any] = field(default_factory=dict) # 元数据 + + def to_dict(self) -> Dict: + """转换为字典""" + return { + "primary_diagnosis": self.primary_diagnosis, + "differential_diagnoses": self.differential_diagnoses, + "confidence_level": self.confidence_level.value, + "confidence_score": self.confidence_score, + "reasoning_chain": [ + { + "step": s.step_name, + "content": s.content, + "evidence": s.evidence, + "confidence": s.confidence + } for s in self.reasoning_chain + ], + "recommendations": self.recommendations, + "warnings": self.warnings, + "metadata": self.metadata + } + + def to_formatted_string(self) -> str: + """生成格式化的诊断报告""" + lines = [] + lines.append("=" * 50) + lines.append("【诊断推理报告】") + lines.append("=" * 50) + + # 推理链 + lines.append("\n📋 诊断推理链:") + for i, step in enumerate(self.reasoning_chain, 1): + lines.append(f"\n[步骤{i}] {step.step_name}") + lines.append(f" {step.content}") + if step.evidence: + lines.append(f" 证据: {', '.join(step.evidence)}") + + # 诊断结论 + lines.append(f"\n🎯 主要诊断: {self.primary_diagnosis}") + + if self.differential_diagnoses: + lines.append(f"\n🔍 鉴别诊断:") + for dd in self.differential_diagnoses: + lines.append(f" - {dd}") + + # 置信度 + confidence_emoji = {"HIGH": "🟢", "MEDIUM": "🟡", "LOW": "🔴", "UNCERTAIN": "⚪"} + lines.append(f"\n📊 置信度: {confidence_emoji.get(self.confidence_level.value, '⚪')} " + f"{self.confidence_level.value} ({self.confidence_score*100:.1f}%)") + + # 建议 + if self.recommendations: + lines.append(f"\n💡 建议:") + for rec in self.recommendations: + lines.append(f" • {rec}") + + # 警告 + if self.warnings: + lines.append(f"\n⚠️ 注意:") + for warn in self.warnings: + lines.append(f" • {warn}") + + lines.append("\n" + "=" * 50) + return "\n".join(lines) + + +class ChainOfDiagnosis: + """ + 诊断推理链 (Chain-of-Diagnosis) 框架 + + 核心创新: + 1. 症状分析 → 2. 病史关联 → 3. 鉴别诊断 → 4. 检查建议 → 5. 诊断结论 + + Usage: + cod = ChainOfDiagnosis() + result = cod.analyze(symptoms, lab_results, history) + print(result.to_formatted_string()) + """ + + # CoD 推理步骤定义 + COD_STEPS = [ + "症状分析", # Step 1: 分析主诉和症状 + "病史关联", # Step 2: 关联既往病史 + "鉴别诊断", # Step 3: 列出可能的诊断 + "检查建议", # Step 4: 建议进一步检查 + "诊断结论", # Step 5: 给出最终诊断 + ] + + # 置信度阈值 + CONFIDENCE_THRESHOLDS = { + "high": 0.85, + "medium": 0.60, + } + + def __init__(self, knowledge_base: Optional[Dict] = None): + """ + 初始化诊断推理链 + + Args: + knowledge_base: 可选的知识库字典 + """ + self.knowledge_base = knowledge_base or {} + self._load_default_knowledge() + + def _load_default_knowledge(self): + """加载默认医学知识库""" + # HIV/AIDS 相关知识 + self.knowledge_base.update({ + "hiv_opportunistic_infections": [ + "肺孢子虫肺炎 (PCP)", + "巨细胞病毒感染 (CMV)", + "隐球菌脑膜炎", + "卡波西肉瘤", + "结核病", + "弓形虫脑病", + ], + "cd4_thresholds": { + "severe_immunodeficiency": 200, + "moderate_immunodeficiency": 350, + "mild_immunodeficiency": 500, + }, + "pcp_symptoms": ["干咳", "呼吸困难", "发热", "低氧血症"], + "pcp_treatment": ["复方磺胺甲噁唑 (TMP-SMX)", "喷他脒", "阿托伐醌"], + }) + + def analyze( + self, + symptoms: str, + lab_results: Optional[str] = None, + medical_history: Optional[str] = None, + imaging_findings: Optional[str] = None, + ) -> DiagnosisResult: + """ + 执行诊断推理链分析 + + Args: + symptoms: 症状描述 + lab_results: 实验室检查结果 + medical_history: 既往病史 + imaging_findings: 影像学发现 + + Returns: + DiagnosisResult: 诊断结果对象 + """ + reasoning_chain = [] + evidence_collected = [] + + # Step 1: 症状分析 + step1 = self._analyze_symptoms(symptoms) + reasoning_chain.append(step1) + evidence_collected.extend(step1.evidence) + + # Step 2: 病史关联 + step2 = self._correlate_history(medical_history, symptoms) + reasoning_chain.append(step2) + evidence_collected.extend(step2.evidence) + + # Step 3: 鉴别诊断 + step3 = self._differential_diagnosis( + symptoms, lab_results, medical_history, imaging_findings + ) + reasoning_chain.append(step3) + + # Step 4: 检查建议 + step4 = self._suggest_examinations(step3.content, lab_results) + reasoning_chain.append(step4) + + # Step 5: 诊断结论 + step5, primary_diagnosis, differentials = self._conclude_diagnosis( + reasoning_chain, lab_results + ) + reasoning_chain.append(step5) + + # 计算置信度 + confidence_score = self._calculate_confidence( + reasoning_chain, evidence_collected, lab_results + ) + confidence_level = self._get_confidence_level(confidence_score) + + # 生成建议 + recommendations = self._generate_recommendations( + primary_diagnosis, confidence_level, lab_results + ) + + # 生成警告 + warnings = self._generate_warnings(confidence_level, primary_diagnosis) + + return DiagnosisResult( + primary_diagnosis=primary_diagnosis, + differential_diagnoses=differentials, + confidence_level=confidence_level, + confidence_score=confidence_score, + reasoning_chain=reasoning_chain, + recommendations=recommendations, + warnings=warnings, + metadata={ + "input_symptoms": symptoms, + "has_lab_results": lab_results is not None, + "has_history": medical_history is not None, + } + ) + + def _analyze_symptoms(self, symptoms: str) -> DiagnosisStep: + """分析症状""" + evidence = [] + analysis = [] + + # 检测关键症状 + symptom_patterns = { + "呼吸系统": ["咳嗽", "干咳", "呼吸困难", "气短", "胸痛"], + "发热相关": ["发热", "发烧", "高热", "低热"], + "神经系统": ["头痛", "意识改变", "抽搐", "视力改变"], + "消化系统": ["腹泻", "恶心", "呕吐", "腹痛"], + "皮肤表现": ["皮疹", "紫色斑块", "溃疡"], + } + + for system, patterns in symptom_patterns.items(): + found = [p for p in patterns if p in symptoms] + if found: + evidence.extend(found) + analysis.append(f"{system}症状: {', '.join(found)}") + + content = "; ".join(analysis) if analysis else "症状信息不足,需要进一步询问" + + return DiagnosisStep( + step_name="症状分析", + content=content, + evidence=evidence, + confidence=0.8 if evidence else 0.3 + ) + + def _correlate_history( + self, history: Optional[str], symptoms: str + ) -> DiagnosisStep: + """关联病史""" + evidence = [] + content = "" + + if history: + # 检测HIV/AIDS相关 + if any(kw in history.lower() for kw in ["hiv", "aids", "艾滋", "免疫缺陷"]): + evidence.append("HIV/AIDS病史") + content = "患者有HIV/AIDS病史,需考虑机会性感染" + + # 检测免疫抑制 + if any(kw in history for kw in ["免疫抑制", "化疗", "器官移植", "激素"]): + evidence.append("免疫抑制状态") + content += ";存在免疫抑制因素" + + if not content: + content = "无特殊病史或病史信息不完整" + + return DiagnosisStep( + step_name="病史关联", + content=content, + evidence=evidence, + confidence=0.7 if evidence else 0.4 + ) + + def _differential_diagnosis( + self, + symptoms: str, + lab_results: Optional[str], + history: Optional[str], + imaging: Optional[str], + ) -> DiagnosisStep: + """生成鉴别诊断""" + differentials = [] + evidence = [] + + # HIV相关机会性感染判断 + is_hiv_related = history and any( + kw in history.lower() for kw in ["hiv", "aids", "艾滋"] + ) + + # 检测CD4计数 + cd4_count = None + if lab_results: + cd4_match = re.search(r'cd4[^\d]*(\d+)', lab_results.lower()) + if cd4_match: + cd4_count = int(cd4_match.group(1)) + evidence.append(f"CD4计数: {cd4_count}") + + # 基于症状和病史生成鉴别诊断 + if is_hiv_related: + if cd4_count and cd4_count < 200: + # 严重免疫缺陷 + if any(s in symptoms for s in ["干咳", "呼吸困难", "发热"]): + differentials.append("肺孢子虫肺炎 (PCP) - 高度怀疑") + differentials.append("细菌性肺炎") + differentials.append("肺结核") + elif any(s in symptoms for s in ["头痛", "意识"]): + differentials.append("隐球菌脑膜炎") + differentials.append("弓形虫脑病") + else: + differentials.append("需要更多信息进行鉴别") + else: + # 非HIV患者 + if any(s in symptoms for s in ["咳嗽", "发热"]): + differentials.append("社区获得性肺炎") + differentials.append("病毒性上呼吸道感染") + differentials.append("支气管炎") + + content = "鉴别诊断: " + ", ".join(differentials) if differentials else "需要更多信息" + + return DiagnosisStep( + step_name="鉴别诊断", + content=content, + evidence=evidence, + confidence=0.75 if differentials else 0.3 + ) + + def _suggest_examinations( + self, differential: str, existing_labs: Optional[str] + ) -> DiagnosisStep: + """建议进一步检查""" + suggestions = [] + + if "PCP" in differential or "肺孢子虫" in differential: + suggestions.extend([ + "诱导痰检查(银染色/免疫荧光)", + "血气分析", + "乳酸脱氢酶 (LDH)", + "胸部CT", + "支气管肺泡灌洗 (BAL)", + ]) + elif "脑膜炎" in differential: + suggestions.extend([ + "腰椎穿刺", + "脑脊液墨汁染色", + "隐球菌抗原检测", + "头颅MRI", + ]) + else: + suggestions.extend([ + "血常规", + "C反应蛋白", + "胸部X线", + ]) + + # 排除已有检查 + if existing_labs: + suggestions = [s for s in suggestions if s.split("(")[0] not in existing_labs] + + content = "建议检查: " + ", ".join(suggestions[:5]) # 最多5项 + + return DiagnosisStep( + step_name="检查建议", + content=content, + evidence=[], + confidence=0.8 + ) + + def _conclude_diagnosis( + self, + reasoning_chain: List[DiagnosisStep], + lab_results: Optional[str], + ) -> tuple: + """得出诊断结论""" + # 从鉴别诊断步骤提取 + differential_step = reasoning_chain[2] # Step 3 + + # 解析鉴别诊断 + differentials = [] + primary = "诊断待定" + + if "高度怀疑" in differential_step.content: + # 提取高度怀疑的诊断作为主诊断 + match = re.search(r'([^,]+)\s*-\s*高度怀疑', differential_step.content) + if match: + primary = match.group(1).strip() + + # 提取所有鉴别诊断 + diff_match = re.search(r'鉴别诊断:\s*(.+)', differential_step.content) + if diff_match: + diff_list = diff_match.group(1).split(", ") + differentials = [d.split(" - ")[0].strip() for d in diff_list if d != primary] + + content = f"综合分析,最可能的诊断为: {primary}" + + step = DiagnosisStep( + step_name="诊断结论", + content=content, + evidence=[s.step_name for s in reasoning_chain if s.confidence > 0.6], + confidence=0.85 if "高度怀疑" in differential_step.content else 0.5 + ) + + return step, primary, differentials + + def _calculate_confidence( + self, + reasoning_chain: List[DiagnosisStep], + evidence: List[str], + lab_results: Optional[str], + ) -> float: + """计算总体置信度""" + # 基础置信度:各步骤置信度加权平均 + weights = [0.15, 0.15, 0.25, 0.15, 0.30] # 诊断结论权重最高 + base_confidence = sum( + step.confidence * weight + for step, weight in zip(reasoning_chain, weights) + ) + + # 证据加成 + evidence_bonus = min(len(evidence) * 0.02, 0.1) + + # 实验室结果加成 + lab_bonus = 0.05 if lab_results else 0 + + total = base_confidence + evidence_bonus + lab_bonus + return min(max(total, 0.0), 1.0) # 限制在0-1之间 + + def _get_confidence_level(self, score: float) -> ConfidenceLevel: + """根据分数获取置信度等级""" + if score >= self.CONFIDENCE_THRESHOLDS["high"]: + return ConfidenceLevel.HIGH + elif score >= self.CONFIDENCE_THRESHOLDS["medium"]: + return ConfidenceLevel.MEDIUM + elif score > 0.3: + return ConfidenceLevel.LOW + else: + return ConfidenceLevel.UNCERTAIN + + def _generate_recommendations( + self, + diagnosis: str, + confidence: ConfidenceLevel, + lab_results: Optional[str], + ) -> List[str]: + """生成治疗建议""" + recommendations = [] + + if "PCP" in diagnosis or "肺孢子虫" in diagnosis: + recommendations.extend([ + "首选治疗: 复方磺胺甲噁唑 (TMP-SMX)", + "替代方案: 喷他脒或阿托伐醌", + "严重病例考虑糖皮质激素辅助治疗", + "监测血氧饱和度", + ]) + + if confidence in [ConfidenceLevel.LOW, ConfidenceLevel.UNCERTAIN]: + recommendations.append("建议进一步检查以明确诊断") + recommendations.append("必要时请专科会诊") + + if not recommendations: + recommendations.append("根据具体情况制定治疗方案") + + return recommendations + + def _generate_warnings( + self, + confidence: ConfidenceLevel, + diagnosis: str, + ) -> List[str]: + """生成警告信息""" + warnings = [] + + if confidence == ConfidenceLevel.LOW: + warnings.append("置信度较低,诊断结果仅供参考") + elif confidence == ConfidenceLevel.UNCERTAIN: + warnings.append("信息不足,无法做出可靠诊断") + + warnings.append("本诊断由AI辅助生成,最终诊断请以临床医生判断为准") + + return warnings + + def generate_cod_prompt(self) -> str: + """ + 生成CoD提示词模板 + 可用于配置LLM的系统提示词 + """ + return """你是一位专业的医学诊断助手,请使用诊断推理链(Chain-of-Diagnosis, CoD)方法进行分析。 + +请按以下步骤进行诊断推理: + +【步骤1 - 症状分析】 +分析患者的主诉和症状,识别关键临床表现。 + +【步骤2 - 病史关联】 +结合既往病史,分析与当前症状的关联性。 + +【步骤3 - 鉴别诊断】 +列出可能的诊断,并说明支持和反对的证据。 + +【步骤4 - 检查建议】 +建议进一步的检查以明确诊断。 + +【步骤5 - 诊断结论】 +给出最可能的诊断,并标注置信度: +- HIGH (高置信度 >85%): 证据充分,诊断明确 +- MEDIUM (中等置信度 60-85%): 有一定依据,但需进一步确认 +- LOW (低置信度 <60%): 信息不足,仅供参考 + +请始终提醒:AI诊断仅供参考,最终诊断请以临床医生判断为准。 +""" diff --git a/pathology-ai/code-changes/medical_extension/confidence_evaluator.py b/pathology-ai/code-changes/medical_extension/confidence_evaluator.py new file mode 100644 index 000000000..5a1f57d6b --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/confidence_evaluator.py @@ -0,0 +1,434 @@ +""" +置信度评估系统 +Confidence Evaluation System for Medical AI + +提供医疗AI回答的置信度评估功能,支持: +1. 基于证据的置信度计算 +2. 不确定性量化 +3. 风险等级评估 + +Author: Pathology AI Team +""" + +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple +from enum import Enum +import re + + +class RiskLevel(Enum): + """风险等级""" + CRITICAL = "critical" # 危急 + HIGH = "high" # 高风险 + MEDIUM = "medium" # 中等风险 + LOW = "low" # 低风险 + + +@dataclass +class ConfidenceReport: + """置信度评估报告""" + overall_score: float # 总体置信度 (0-1) + confidence_level: str # 置信度等级 (HIGH/MEDIUM/LOW) + evidence_score: float # 证据充分度 + consistency_score: float # 一致性得分 + completeness_score: float # 完整性得分 + risk_level: RiskLevel # 风险等级 + factors: Dict[str, float] # 各因素得分 + recommendations: List[str] # 建议 + warnings: List[str] # 警告 + + def to_dict(self) -> Dict: + return { + "overall_score": self.overall_score, + "confidence_level": self.confidence_level, + "evidence_score": self.evidence_score, + "consistency_score": self.consistency_score, + "completeness_score": self.completeness_score, + "risk_level": self.risk_level.value, + "factors": self.factors, + "recommendations": self.recommendations, + "warnings": self.warnings, + } + + +class ConfidenceEvaluator: + """ + 置信度评估器 + + 评估维度: + 1. 证据充分度:支持诊断的证据数量和质量 + 2. 一致性:症状、检查结果与诊断的一致性 + 3. 完整性:信息的完整程度 + 4. 确定性:诊断的确定程度 + + Usage: + evaluator = ConfidenceEvaluator() + report = evaluator.evaluate( + diagnosis="肺孢子虫肺炎", + symptoms=["干咳", "呼吸困难", "发热"], + lab_results={"CD4": 150, "LDH": "升高"}, + evidence=["HIV阳性", "CD4<200"] + ) + print(f"置信度: {report.confidence_level} ({report.overall_score:.2f})") + """ + + # 置信度阈值 + THRESHOLDS = { + "high": 0.85, + "medium": 0.60, + "low": 0.30, + } + + # 关键证据权重 + EVIDENCE_WEIGHTS = { + "病理确诊": 1.0, + "实验室确诊": 0.9, + "影像学典型表现": 0.8, + "临床症状典型": 0.7, + "病史支持": 0.6, + "经验性诊断": 0.4, + } + + # 高风险诊断关键词 + HIGH_RISK_KEYWORDS = [ + "恶性", "癌", "肿瘤", "转移", "急性", "重症", + "休克", "衰竭", "危重", "紧急", + ] + + def __init__(self): + """初始化评估器""" + self._custom_rules = [] + + def evaluate( + self, + diagnosis: str, + symptoms: Optional[List[str]] = None, + lab_results: Optional[Dict] = None, + imaging_findings: Optional[List[str]] = None, + evidence: Optional[List[str]] = None, + medical_history: Optional[str] = None, + ) -> ConfidenceReport: + """ + 评估诊断置信度 + + Args: + diagnosis: 诊断结果 + symptoms: 症状列表 + lab_results: 实验室结果 + imaging_findings: 影像学发现 + evidence: 支持证据 + medical_history: 病史 + + Returns: + ConfidenceReport: 置信度评估报告 + """ + factors = {} + + # 1. 评估证据充分度 + evidence_score = self._evaluate_evidence(evidence or []) + factors["evidence"] = evidence_score + + # 2. 评估一致性 + consistency_score = self._evaluate_consistency( + diagnosis, symptoms or [], lab_results or {} + ) + factors["consistency"] = consistency_score + + # 3. 评估完整性 + completeness_score = self._evaluate_completeness( + symptoms, lab_results, imaging_findings, medical_history + ) + factors["completeness"] = completeness_score + + # 4. 评估确定性 + certainty_score = self._evaluate_certainty(diagnosis) + factors["certainty"] = certainty_score + + # 计算总体置信度 + overall_score = self._calculate_overall_score(factors) + + # 确定置信度等级 + confidence_level = self._get_confidence_level(overall_score) + + # 评估风险等级 + risk_level = self._evaluate_risk(diagnosis, overall_score) + + # 生成建议 + recommendations = self._generate_recommendations( + confidence_level, factors, diagnosis + ) + + # 生成警告 + warnings = self._generate_warnings( + confidence_level, risk_level, diagnosis + ) + + return ConfidenceReport( + overall_score=overall_score, + confidence_level=confidence_level, + evidence_score=evidence_score, + consistency_score=consistency_score, + completeness_score=completeness_score, + risk_level=risk_level, + factors=factors, + recommendations=recommendations, + warnings=warnings, + ) + + def _evaluate_evidence(self, evidence: List[str]) -> float: + """评估证据充分度""" + if not evidence: + return 0.3 + + score = 0.0 + max_weight = 0.0 + + for e in evidence: + for key, weight in self.EVIDENCE_WEIGHTS.items(): + if key in e or any(k in e for k in key.split()): + score += weight + max_weight = max(max_weight, weight) + break + else: + # 未匹配到预定义证据类型,给基础分 + score += 0.3 + + # 归一化 + normalized = min(score / max(len(evidence), 1) * 0.5 + max_weight * 0.5, 1.0) + return normalized + + def _evaluate_consistency( + self, + diagnosis: str, + symptoms: List[str], + lab_results: Dict, + ) -> float: + """评估一致性""" + score = 0.5 # 基础分 + + # 定义诊断-症状关联 + diagnosis_symptom_map = { + "肺孢子虫肺炎": ["干咳", "呼吸困难", "发热", "低氧"], + "PCP": ["干咳", "呼吸困难", "发热", "低氧"], + "隐球菌脑膜炎": ["头痛", "发热", "意识改变", "颈强直"], + "结核": ["咳嗽", "盗汗", "体重下降", "发热"], + "肺炎": ["咳嗽", "发热", "胸痛", "呼吸困难"], + } + + # 检查症状一致性 + for diag_key, expected_symptoms in diagnosis_symptom_map.items(): + if diag_key in diagnosis: + matched = sum(1 for s in symptoms if any(es in s for es in expected_symptoms)) + if matched > 0: + score += min(matched / len(expected_symptoms) * 0.3, 0.3) + break + + # 检查实验室结果一致性 + if lab_results: + # CD4计数与HIV相关诊断 + cd4 = lab_results.get("CD4") or lab_results.get("cd4") + if cd4 and isinstance(cd4, (int, float)): + if "PCP" in diagnosis or "肺孢子虫" in diagnosis: + if cd4 < 200: + score += 0.2 + elif cd4 < 350: + score += 0.1 + + return min(score, 1.0) + + def _evaluate_completeness( + self, + symptoms: Optional[List[str]], + lab_results: Optional[Dict], + imaging: Optional[List[str]], + history: Optional[str], + ) -> float: + """评估信息完整性""" + score = 0.0 + + # 各项信息的权重 + if symptoms and len(symptoms) > 0: + score += 0.3 + if lab_results and len(lab_results) > 0: + score += 0.3 + if imaging and len(imaging) > 0: + score += 0.2 + if history and len(history) > 10: + score += 0.2 + + return score + + def _evaluate_certainty(self, diagnosis: str) -> float: + """评估诊断确定性""" + # 不确定性关键词 + uncertain_keywords = [ + "可能", "疑似", "待排除", "不除外", "考虑", + "建议进一步", "需要确认", "待定", + ] + + # 确定性关键词 + certain_keywords = [ + "确诊", "明确", "典型", "符合", "诊断明确", + ] + + score = 0.5 # 基础分 + + for kw in uncertain_keywords: + if kw in diagnosis: + score -= 0.1 + + for kw in certain_keywords: + if kw in diagnosis: + score += 0.15 + + return max(min(score, 1.0), 0.1) + + def _calculate_overall_score(self, factors: Dict[str, float]) -> float: + """计算总体置信度""" + weights = { + "evidence": 0.35, + "consistency": 0.25, + "completeness": 0.20, + "certainty": 0.20, + } + + score = sum( + factors.get(k, 0) * w + for k, w in weights.items() + ) + + return round(score, 3) + + def _get_confidence_level(self, score: float) -> str: + """获取置信度等级""" + if score >= self.THRESHOLDS["high"]: + return "HIGH" + elif score >= self.THRESHOLDS["medium"]: + return "MEDIUM" + elif score >= self.THRESHOLDS["low"]: + return "LOW" + else: + return "UNCERTAIN" + + def _evaluate_risk(self, diagnosis: str, confidence: float) -> RiskLevel: + """评估风险等级""" + # 检查高风险关键词 + has_high_risk = any(kw in diagnosis for kw in self.HIGH_RISK_KEYWORDS) + + if has_high_risk and confidence < 0.6: + return RiskLevel.CRITICAL + elif has_high_risk: + return RiskLevel.HIGH + elif confidence < 0.5: + return RiskLevel.MEDIUM + else: + return RiskLevel.LOW + + def _generate_recommendations( + self, + confidence_level: str, + factors: Dict[str, float], + diagnosis: str, + ) -> List[str]: + """生成建议""" + recommendations = [] + + if factors.get("evidence", 0) < 0.5: + recommendations.append("建议补充更多诊断依据") + + if factors.get("completeness", 0) < 0.5: + recommendations.append("建议完善病史和检查资料") + + if confidence_level in ["LOW", "UNCERTAIN"]: + recommendations.append("建议进一步检查以明确诊断") + recommendations.append("必要时请专科会诊") + + if not recommendations: + recommendations.append("诊断依据充分,可按诊断进行治疗") + + return recommendations + + def _generate_warnings( + self, + confidence_level: str, + risk_level: RiskLevel, + diagnosis: str, + ) -> List[str]: + """生成警告""" + warnings = [] + + if risk_level == RiskLevel.CRITICAL: + warnings.append("⚠️ 危急情况:诊断不确定但可能为严重疾病,请立即处理") + + if confidence_level == "UNCERTAIN": + warnings.append("⚠️ 置信度极低,诊断结果仅供参考") + elif confidence_level == "LOW": + warnings.append("⚠️ 置信度较低,建议谨慎采纳") + + warnings.append("本评估由AI生成,最终诊断请以临床医生判断为准") + + return warnings + + def add_custom_rule( + self, + condition: callable, + score_modifier: float, + description: str, + ): + """ + 添加自定义评估规则 + + Args: + condition: 条件函数,接收诊断信息,返回bool + score_modifier: 分数修正值 (-1 到 1) + description: 规则描述 + """ + self._custom_rules.append({ + "condition": condition, + "modifier": score_modifier, + "description": description, + }) + + def format_report(self, report: ConfidenceReport) -> str: + """格式化置信度报告""" + lines = [] + lines.append("=" * 40) + lines.append("【置信度评估报告】") + lines.append("=" * 40) + + # 置信度等级 + level_emoji = { + "HIGH": "🟢", "MEDIUM": "🟡", + "LOW": "🔴", "UNCERTAIN": "⚪" + } + lines.append(f"\n总体置信度: {level_emoji.get(report.confidence_level, '⚪')} " + f"{report.confidence_level} ({report.overall_score*100:.1f}%)") + + # 各维度得分 + lines.append(f"\n📊 评估维度:") + lines.append(f" • 证据充分度: {report.evidence_score*100:.0f}%") + lines.append(f" • 一致性: {report.consistency_score*100:.0f}%") + lines.append(f" • 完整性: {report.completeness_score*100:.0f}%") + + # 风险等级 + risk_emoji = { + "critical": "🔴", "high": "🟠", + "medium": "🟡", "low": "🟢" + } + lines.append(f"\n⚠️ 风险等级: {risk_emoji.get(report.risk_level.value, '⚪')} " + f"{report.risk_level.value.upper()}") + + # 建议 + if report.recommendations: + lines.append(f"\n💡 建议:") + for rec in report.recommendations: + lines.append(f" • {rec}") + + # 警告 + if report.warnings: + lines.append(f"\n⚠️ 警告:") + for warn in report.warnings: + lines.append(f" • {warn}") + + lines.append("\n" + "=" * 40) + return "\n".join(lines) diff --git a/pathology-ai/code-changes/medical_extension/medical_prompts.py b/pathology-ai/code-changes/medical_extension/medical_prompts.py new file mode 100644 index 000000000..6bd81cc5a --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/medical_prompts.py @@ -0,0 +1,514 @@ +""" +医疗提示词库 +Medical Prompt Library for Nexent Platform + +提供预置的医疗领域提示词模板,支持: +1. CoD诊断推理链提示词 +2. 不确定性感知提示词 +3. 专科领域提示词 +4. 安全性提示词 + +Author: Pathology AI Team +""" + +from typing import Dict, List, Optional +from enum import Enum + + +class PromptCategory(Enum): + """提示词分类""" + DIAGNOSIS = "diagnosis" # 诊断类 + TREATMENT = "treatment" # 治疗类 + SAFETY = "safety" # 安全类 + SPECIALTY = "specialty" # 专科类 + GENERAL = "general" # 通用类 + + +class MedicalPromptLibrary: + """ + 医疗提示词库 + + 提供标准化的医疗AI提示词模板,确保: + 1. 诊断推理的结构化 + 2. 不确定性的明确表达 + 3. 安全性提醒 + 4. 专业性保证 + + Usage: + library = MedicalPromptLibrary() + cod_prompt = library.get_prompt("chain_of_diagnosis") + all_prompts = library.list_prompts() + """ + + def __init__(self): + """初始化提示词库""" + self._prompts: Dict[str, Dict] = {} + self._load_builtin_prompts() + + def _load_builtin_prompts(self): + """加载内置提示词""" + + # 1. 诊断推理链 (CoD) 核心提示词 + self._prompts["chain_of_diagnosis"] = { + "id": "chain_of_diagnosis", + "name": "诊断推理链 (CoD)", + "category": PromptCategory.DIAGNOSIS, + "description": "结构化的诊断推理方法,分步骤进行临床分析", + "prompt": self._get_cod_prompt(), + "variables": ["patient_info"], + "tags": ["核心", "诊断", "推理"], + } + + # 2. 不确定性感知提示词 + self._prompts["uncertainty_aware"] = { + "id": "uncertainty_aware", + "name": "不确定性感知", + "category": PromptCategory.SAFETY, + "description": "在回答中明确标注置信度和不确定性", + "prompt": self._get_uncertainty_prompt(), + "variables": [], + "tags": ["安全", "置信度", "不确定性"], + } + + # 3. 安全性基础提示词 + self._prompts["safety_base"] = { + "id": "safety_base", + "name": "医疗安全基础", + "category": PromptCategory.SAFETY, + "description": "医疗AI的基础安全提醒", + "prompt": self._get_safety_prompt(), + "variables": [], + "tags": ["安全", "免责", "基础"], + } + + # 4. HIV/AIDS专科提示词 + self._prompts["hiv_specialist"] = { + "id": "hiv_specialist", + "name": "HIV/AIDS专科", + "category": PromptCategory.SPECIALTY, + "description": "HIV/AIDS诊疗专业提示词", + "prompt": self._get_hiv_prompt(), + "variables": ["cd4_count", "viral_load"], + "tags": ["HIV", "AIDS", "感染", "专科"], + } + + # 5. 病理诊断提示词 + self._prompts["pathology_diagnosis"] = { + "id": "pathology_diagnosis", + "name": "病理诊断", + "category": PromptCategory.SPECIALTY, + "description": "病理学诊断专业提示词", + "prompt": self._get_pathology_prompt(), + "variables": ["specimen_type", "staining_method"], + "tags": ["病理", "诊断", "专科"], + } + + # 6. 鉴别诊断提示词 + self._prompts["differential_diagnosis"] = { + "id": "differential_diagnosis", + "name": "鉴别诊断", + "category": PromptCategory.DIAGNOSIS, + "description": "系统性鉴别诊断方法", + "prompt": self._get_differential_prompt(), + "variables": ["chief_complaint"], + "tags": ["诊断", "鉴别", "系统"], + } + + # 7. 治疗建议提示词 + self._prompts["treatment_suggestion"] = { + "id": "treatment_suggestion", + "name": "治疗建议", + "category": PromptCategory.TREATMENT, + "description": "基于循证医学的治疗建议", + "prompt": self._get_treatment_prompt(), + "variables": ["diagnosis", "patient_condition"], + "tags": ["治疗", "用药", "建议"], + } + + # 8. 完整医疗助手提示词(组合版) + self._prompts["medical_assistant_full"] = { + "id": "medical_assistant_full", + "name": "完整医疗助手", + "category": PromptCategory.GENERAL, + "description": "包含CoD、不确定性感知和安全提醒的完整提示词", + "prompt": self._get_full_assistant_prompt(), + "variables": [], + "tags": ["完整", "推荐", "综合"], + } + + def _get_cod_prompt(self) -> str: + """诊断推理链提示词""" + return """## 诊断推理链 (Chain-of-Diagnosis, CoD) + +请按以下步骤进行诊断推理: + +### 【步骤1 - 症状分析】 +- 识别主诉和主要症状 +- 分析症状的特点(部位、性质、程度、时间) +- 注意伴随症状 + +### 【步骤2 - 病史关联】 +- 既往病史与当前症状的关系 +- 用药史和过敏史 +- 家族史和社会史 + +### 【步骤3 - 鉴别诊断】 +- 列出可能的诊断(按可能性排序) +- 分析支持和反对每个诊断的证据 +- 考虑常见病和危重病 + +### 【步骤4 - 检查建议】 +- 建议必要的实验室检查 +- 建议必要的影像学检查 +- 说明检查目的 + +### 【步骤5 - 诊断结论】 +- 给出最可能的诊断 +- 标注置信度等级 +- 说明诊断依据 +""" + + def _get_uncertainty_prompt(self) -> str: + """不确定性感知提示词""" + return """## 不确定性标注规范 + +在给出诊断或建议时,请标注置信度: + +### 置信度等级 +- **HIGH (高置信度 >85%)** + - 证据充分,诊断明确 + - 符合典型临床表现 + - 有确诊性检查结果支持 + +- **MEDIUM (中等置信度 60-85%)** + - 有一定依据,但需进一步确认 + - 部分符合典型表现 + - 需要排除其他诊断 + +- **LOW (低置信度 <60%)** + - 信息不足,仅供参考 + - 表现不典型 + - 需要更多检查 + +- **UNCERTAIN (不确定)** + - 无法做出可靠判断 + - 信息严重不足 + - 建议进一步检查 + +### 标注格式 +在诊断结论后标注:[置信度: HIGH/MEDIUM/LOW/UNCERTAIN] +""" + + def _get_safety_prompt(self) -> str: + """安全性提示词""" + return """## 医疗安全提醒 + +### 重要声明 +1. 本AI仅提供辅助参考,不能替代专业医生的诊断 +2. 最终诊断和治疗决策应由执业医师做出 +3. 紧急情况请立即就医或拨打急救电话 + +### 使用限制 +- 不提供处方药物的具体剂量 +- 不对危急重症做出延误治疗的建议 +- 不替代必要的医学检查 + +### 免责说明 +AI诊断建议仅供参考,使用者应自行承担相应风险。 +如有疑问,请咨询专业医疗人员。 +""" + + def _get_hiv_prompt(self) -> str: + """HIV/AIDS专科提示词""" + return """## HIV/AIDS诊疗专家 + +### 专业领域 +- HIV感染的诊断与分期 +- 抗逆转录病毒治疗(ART)方案 +- 机会性感染的预防与治疗 +- HIV相关肿瘤 +- 免疫重建炎症综合征(IRIS) + +### CD4计数与感染风险 +| CD4计数 | 风险等级 | 常见机会性感染 | +|---------|----------|----------------| +| <200 | 高风险 | PCP、弓形虫、隐球菌 | +| <100 | 极高风险 | CMV、MAC | +| <50 | 危重 | 播散性真菌感染 | + +### 常见机会性感染诊断要点 +1. **肺孢子虫肺炎(PCP)** + - 症状:干咳、进行性呼吸困难、发热 + - 检查:诱导痰、BAL、LDH升高 + - 治疗:TMP-SMX + +2. **隐球菌脑膜炎** + - 症状:头痛、发热、意识改变 + - 检查:腰穿、墨汁染色、隐球菌抗原 + - 治疗:两性霉素B + 氟康唑 + +3. **结核病** + - 症状:咳嗽、盗汗、体重下降 + - 检查:痰涂片、培养、GeneXpert + - 注意:与ART的药物相互作用 +""" + + def _get_pathology_prompt(self) -> str: + """病理诊断提示词""" + return """## 病理诊断专家 + +### 专业能力 +- 组织病理学诊断 +- 细胞病理学诊断 +- 分子病理学解读 +- 免疫组化分析 + +### 诊断流程 +1. **标本信息**:部位、类型、固定方式 +2. **大体描述**:大小、颜色、质地、切面 +3. **镜下所见**:细胞形态、组织结构、特殊发现 +4. **特殊染色/免疫组化**:结果及意义 +5. **病理诊断**:诊断名称、分级分期 +6. **备注**:建议进一步检查或会诊 + +### HIV相关病理改变 +- 淋巴结:滤泡增生→耗竭 +- 肺:PCP间质性肺炎 +- 皮肤:卡波西肉瘤 +- 脑:弓形虫脑病、PML + +### 报告规范 +- 使用标准化术语 +- 明确诊断依据 +- 标注置信度 +- 必要时建议会诊 +""" + + def _get_differential_prompt(self) -> str: + """鉴别诊断提示词""" + return """## 鉴别诊断方法 + +### 系统性鉴别诊断步骤 + +1. **确定主要问题** + - 明确主诉 + - 识别关键症状 + +2. **生成诊断假设** + - 常见病优先 + - 不遗漏危重病 + - 考虑年龄、性别、基础疾病 + +3. **收集鉴别信息** + - 针对性病史询问 + - 针对性体格检查 + - 必要的辅助检查 + +4. **评估每个诊断** + - 支持证据 + - 反对证据 + - 可能性评估 + +5. **得出结论** + - 最可能诊断 + - 需排除诊断 + - 进一步检查建议 + +### 鉴别诊断表格格式 +| 诊断 | 支持证据 | 反对证据 | 可能性 | +|------|----------|----------|--------| +| ... | ... | ... | 高/中/低 | +""" + + def _get_treatment_prompt(self) -> str: + """治疗建议提示词""" + return """## 治疗建议规范 + +### 治疗建议原则 +1. 基于循证医学证据 +2. 考虑患者个体情况 +3. 权衡利弊风险 +4. 尊重患者意愿 + +### 建议格式 +1. **一般治疗** + - 休息、饮食、护理 + +2. **药物治疗** + - 药物名称(通用名) + - 用法用量范围 + - 疗程建议 + - 注意事项 + +3. **其他治疗** + - 手术/介入指征 + - 康复治疗 + - 中医治疗 + +4. **随访建议** + - 复查时间 + - 复查项目 + - 注意事项 + +### 安全提醒 +- 具体剂量请遵医嘱 +- 注意药物相互作用 +- 关注不良反应 +- 特殊人群调整 +""" + + def _get_full_assistant_prompt(self) -> str: + """完整医疗助手提示词""" + return """# 医疗诊断助手 + +你是一位专业的医疗诊断助手,具备以下能力和规范: + +## 一、诊断方法:诊断推理链 (CoD) + +请按以下步骤进行诊断推理: + +【步骤1 - 症状分析】 +分析患者的主诉和症状,识别关键临床表现。 + +【步骤2 - 病史关联】 +结合既往病史,分析与当前症状的关联性。 + +【步骤3 - 鉴别诊断】 +列出可能的诊断,并说明支持和反对的证据。 + +【步骤4 - 检查建议】 +建议进一步的检查以明确诊断。 + +【步骤5 - 诊断结论】 +给出最可能的诊断,并标注置信度。 + +## 二、置信度标注 + +在诊断结论中标注置信度: +- **HIGH** (>85%): 证据充分,诊断明确 +- **MEDIUM** (60-85%): 有一定依据,需进一步确认 +- **LOW** (<60%): 信息不足,仅供参考 +- **UNCERTAIN**: 无法做出可靠判断 + +格式:[置信度: HIGH/MEDIUM/LOW/UNCERTAIN] + +## 三、安全提醒 + +⚠️ 重要声明: +1. 本AI仅提供辅助参考,不能替代专业医生的诊断 +2. 最终诊断和治疗决策应由执业医师做出 +3. 紧急情况请立即就医或拨打急救电话 +4. AI诊断建议仅供参考,使用者应自行承担相应风险 + +## 四、回答规范 + +1. 使用专业但易懂的语言 +2. 结构清晰,逻辑严谨 +3. 明确标注不确定性 +4. 必要时建议就医或会诊 +""" + + def get_prompt(self, prompt_id: str) -> Optional[Dict]: + """ + 获取指定提示词 + + Args: + prompt_id: 提示词ID + + Returns: + 提示词字典或None + """ + return self._prompts.get(prompt_id) + + def get_prompt_text(self, prompt_id: str) -> Optional[str]: + """ + 获取提示词文本 + + Args: + prompt_id: 提示词ID + + Returns: + 提示词文本或None + """ + prompt = self._prompts.get(prompt_id) + return prompt["prompt"] if prompt else None + + def list_prompts( + self, + category: Optional[PromptCategory] = None + ) -> List[Dict]: + """ + 列出所有提示词 + + Args: + category: 可选,按分类筛选 + + Returns: + 提示词列表 + """ + prompts = list(self._prompts.values()) + if category: + prompts = [p for p in prompts if p["category"] == category] + return prompts + + def list_prompt_ids(self) -> List[str]: + """获取所有提示词ID""" + return list(self._prompts.keys()) + + def combine_prompts(self, prompt_ids: List[str]) -> str: + """ + 组合多个提示词 + + Args: + prompt_ids: 提示词ID列表 + + Returns: + 组合后的提示词文本 + """ + parts = [] + for pid in prompt_ids: + prompt = self.get_prompt_text(pid) + if prompt: + parts.append(prompt) + return "\n\n---\n\n".join(parts) + + def add_custom_prompt( + self, + prompt_id: str, + name: str, + prompt_text: str, + category: PromptCategory = PromptCategory.GENERAL, + description: str = "", + tags: Optional[List[str]] = None, + ) -> bool: + """ + 添加自定义提示词 + + Args: + prompt_id: 提示词ID + name: 名称 + prompt_text: 提示词文本 + category: 分类 + description: 描述 + tags: 标签 + + Returns: + 是否添加成功 + """ + if prompt_id in self._prompts: + return False + + self._prompts[prompt_id] = { + "id": prompt_id, + "name": name, + "category": category, + "description": description, + "prompt": prompt_text, + "variables": [], + "tags": tags or [], + } + return True + + def get_recommended_prompt(self) -> str: + """获取推荐的完整提示词""" + return self.get_prompt_text("medical_assistant_full") diff --git a/pathology-ai/code-changes/medical_extension/test_medical.py b/pathology-ai/code-changes/medical_extension/test_medical.py new file mode 100644 index 000000000..85e5c770a --- /dev/null +++ b/pathology-ai/code-changes/medical_extension/test_medical.py @@ -0,0 +1,150 @@ +""" +医疗模块测试脚本 +Test script for Medical Module +""" + +import sys +import os + +# 添加路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from medical import ( + ChainOfDiagnosis, + MedicalAgentTemplates, + ConfidenceEvaluator, + MedicalPromptLibrary, +) + + +def test_chain_of_diagnosis(): + """测试诊断推理链""" + print("=" * 50) + print("测试: Chain-of-Diagnosis (CoD)") + print("=" * 50) + + cod = ChainOfDiagnosis() + + # 测试案例: HIV患者肺部感染 + result = cod.analyze( + symptoms="干咳、呼吸困难、发热", + lab_results="CD4计数: 150, LDH升高", + medical_history="HIV阳性5年,未规律服药", + ) + + print(f"\n主要诊断: {result.primary_diagnosis}") + print(f"置信度: {result.confidence_level.value} ({result.confidence_score*100:.1f}%)") + print(f"鉴别诊断: {', '.join(result.differential_diagnoses)}") + print(f"推理步骤数: {len(result.reasoning_chain)}") + + print("\n[OK] CoD测试通过") + return True + + +def test_agent_templates(): + """测试智能体模板""" + print("\n" + "=" * 50) + print("测试: Medical Agent Templates") + print("=" * 50) + + templates = MedicalAgentTemplates() + + # 列出所有模板 + all_templates = templates.list_templates() + print(f"\n可用模板数量: {len(all_templates)}") + + for t in all_templates: + print(f" - {t.name} ({t.template_id})") + + # 获取病理模板 + pathology = templates.get_template("pathology_diagnosis") + if pathology: + print(f"\n病理模板工具: {', '.join(pathology.suggested_tools)}") + + print("\n[OK] 模板测试通过") + return True + + +def test_confidence_evaluator(): + """测试置信度评估""" + print("\n" + "=" * 50) + print("测试: Confidence Evaluator") + print("=" * 50) + + evaluator = ConfidenceEvaluator() + + report = evaluator.evaluate( + diagnosis="肺孢子虫肺炎 (PCP)", + symptoms=["干咳", "呼吸困难", "发热"], + lab_results={"CD4": 150, "LDH": "升高"}, + evidence=["HIV阳性", "CD4<200", "典型症状"], + ) + + print(f"\n总体置信度: {report.confidence_level} ({report.overall_score*100:.1f}%)") + print(f"证据充分度: {report.evidence_score*100:.0f}%") + print(f"一致性: {report.consistency_score*100:.0f}%") + print(f"风险等级: {report.risk_level.value}") + + print("\n[OK] 置信度评估测试通过") + return True + + +def test_prompt_library(): + """测试提示词库""" + print("\n" + "=" * 50) + print("测试: Medical Prompt Library") + print("=" * 50) + + library = MedicalPromptLibrary() + + # 列出所有提示词 + all_prompts = library.list_prompts() + print(f"\n可用提示词数量: {len(all_prompts)}") + + for p in all_prompts: + print(f" - {p['name']} ({p['id']})") + + # 获取推荐提示词 + recommended = library.get_recommended_prompt() + print(f"\n推荐提示词长度: {len(recommended)} 字符") + + print("\n[OK] 提示词库测试通过") + return True + + +def main(): + """运行所有测试""" + print("\n" + "#" * 60) + print("# Nexent 医疗模块测试") + print("#" * 60) + + tests = [ + test_chain_of_diagnosis, + test_agent_templates, + test_confidence_evaluator, + test_prompt_library, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + if test(): + passed += 1 + else: + failed += 1 + except Exception as e: + print(f"\n[FAIL] {test.__name__}: {e}") + failed += 1 + + print("\n" + "#" * 60) + print(f"# 测试结果: {passed} 通过, {failed} 失败") + print("#" * 60) + + return failed == 0 + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/pathology-ai/custom-tools.md b/pathology-ai/custom-tools.md new file mode 100644 index 000000000..a6cf27613 --- /dev/null +++ b/pathology-ai/custom-tools.md @@ -0,0 +1,368 @@ +# 🔧 自定义工具说明 + +本文档详细介绍病理学AI助手中新增的自定义MCP工具。 + +## 工具列表(共15个) + +### 医疗诊断工具 + +| 工具名称 | 功能简述 | +|----------|----------| +| chain_of_diagnosis | 5步结构化诊断推理(CoD) | +| evaluate_diagnosis_confidence | 置信度与风险评估 | +| search_pathology_images | 搜索本地病理图片 | +| generate_medical_guide | 生成就医指南 | + +### 诊断模拟游戏 + +| 工具名称 | 功能简述 | +|----------|----------| +| start_diagnosis_game | 启动诊断模拟游戏 | +| diagnosis_action | 执行诊断游戏动作(问诊/体检/检查/诊断) | + +### 医学可视化工具 + +| 工具名称 | 功能简述 | +|----------|----------| +| generate_knowledge_graph | 生成医学知识图谱(Mermaid) | +| generate_diagnosis_flow | 生成诊断流程图 | +| generate_medical_chart | 生成统计图表(柱状图/折线图/饼图) | +| generate_radar_chart | 生成雷达图(多维度健康指标对比) | +| generate_timeline | 生成时间线图(疾病发展/治疗计划) | +| generate_gantt_chart | 生成甘特图(治疗疗程安排) | +| generate_quadrant_chart | 生成象限图(风险评估/优先级分析) | +| generate_state_diagram | 生成状态转换图(疾病状态变化) | +| generate_sankey_diagram | 生成桑基图(流量和转换关系) | + +--- + +## 1. chain_of_diagnosis + +### 功能 +实现 Chain-of-Diagnosis (CoD) 诊断推理链,将复杂的诊断过程分解为5个结构化步骤。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| symptoms | str | 是 | 患者症状描述 | +| medical_history | str | 否 | 既往病史 | +| lab_results | str | 否 | 实验室检查结果 | +| imaging_findings | str | 否 | 影像学发现 | + +### 输出格式 + +```markdown +## 🔬 Chain-of-Diagnosis 诊断推理 + +### Step 1: 症状分析 +- 主要症状识别 +- 症状特征分析 + +### Step 2: 病史关联 +- 相关病史 +- 风险因素 + +### Step 3: 鉴别诊断 +- 可能诊断列表 +- 排除诊断 + +### Step 4: 检查建议 +- 推荐检查项目 +- 优先级排序 + +### Step 5: 初步结论 +- 最可能诊断 +- 置信度评估 +``` + +### 示例调用 + +```python +result = chain_of_diagnosis( + symptoms="持续发热2周,体重下降,淋巴结肿大", + medical_history="无特殊病史", + lab_results="白细胞减少" +) +``` + +--- + +## 2. evaluate_diagnosis_confidence + +### 功能 +评估医疗诊断或回答的置信度,包括证据充分度、一致性、完整性等维度。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| diagnosis | str | 是 | 诊断结果 | +| symptoms | str | 否 | 症状列表,用逗号分隔 | +| evidence | str | 否 | 支持证据,用逗号分隔 | +| lab_results | str | 否 | 实验室结果 | + +### 输出格式 + +```markdown +## 📊 置信度评估报告 + +### 总体置信度: HIGH/MEDIUM/LOW/UNCERTAIN + +### 评估维度 +| 维度 | 得分 | 说明 | +|------|------|------| +| 证据充分度 | 85% | ... | +| 一致性 | 90% | ... | +| 完整性 | 80% | ... | +| 确定性 | 75% | ... | + +### 风险等级: LOW/MEDIUM/HIGH/CRITICAL + +### 建议 +- ... +``` + +### 置信度级别 + +| 级别 | 分数范围 | 说明 | +|------|----------|------| +| HIGH | ≥80% | 证据充分,可信度高 | +| MEDIUM | 60-79% | 有一定依据,需进一步确认 | +| LOW | 40-59% | 证据不足,建议谨慎 | +| UNCERTAIN | <40% | 高度不确定,强烈建议就医 | + +--- + +## 3. start_diagnosis_game + +### 功能 +启动交互式诊断模拟游戏,用户扮演医生进行问诊练习。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| difficulty | int | 否 | 难度等级 (1=初级, 2=中级, 3=高级) | +| case_type | str | 否 | 病例类型 (hiv_basic, hiv_opportunistic, random) | + +### 输出格式 + +```markdown +## 🏥 诊断模拟器 - 病例开始 + +### 👤 患者信息 +**男性,32岁,程序员** + +### 💬 主诉 +> "医生,我最近一个月反复发热..." + +### 📋 当前阶段:问诊 (第1步/共4步) + +**请选择您要询问的内容:** + +[btn:询问发热详情] [btn:询问其他症状] [btn:询问既往病史] +[btn:询问接触史] [btn:询问用药情况] [btn:进入体格检查] +``` + +### 游戏流程 + +``` +问诊 → 体格检查 → 辅助检查 → 给出诊断 +``` + +--- + +## 4. diagnosis_action + +### 功能 +在诊断模拟游戏中执行具体动作。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| case_id | str | 是 | 病例ID | +| action_type | str | 是 | 动作类型 (ask/exam/test/diagnose) | +| action_detail | str | 是 | 具体动作内容 | + +### 动作类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| ask | 问诊 | 询问发热情况 | +| exam | 体格检查 | 检查淋巴结 | +| test | 辅助检查 | HIV抗体初筛 | +| diagnose | 给出诊断 | 给出诊断结论 | + +--- + +## 5. search_pathology_images + +### 功能 +搜索本地病理图片服务器中的图片。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| keyword | str | 是 | 搜索关键词 | +| count | int | 否 | 返回数量(默认6,最大9) | + +### 支持的关键词类别 + +- HIV/AIDS/免疫 - 免疫病理学图片 +- 感染 - 感染性疾病图片 +- 心血管 - 心血管病理图片 +- 肺/呼吸 - 肺部病理图片 +- 肿瘤/癌 - 肿瘤病理图片 +- 神经/脑 - 神经系统病理图片 +- 胃肠/消化 - 消化系统病理图片 + +### 输出格式 + +```markdown +## 🔍 病理图片搜索结果 + +找到 5 张相关图片: + +| 序号 | 分类 | 文件名 | URL | +|------|------|--------|-----| +| 1 | Immunopathology | hiv_lymph_node.jpg | http://... | +``` + +--- + +## 6. generate_medical_guide + +### 功能 +生成结构化的就医指南,包括科室推荐、检查项目、注意事项等。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| condition | str | 是 | 病情描述 | +| urgency | str | 否 | 紧急程度 (emergency/urgent/routine),默认urgent | +| patient_info | str | 否 | 患者关键信息 | + +### 输出格式 + +```markdown +## 🏥 就医指南 + +### 推荐科室 +| 优先级 | 科室 | 说明 | +|--------|------|------| +| 1 | 感染科 | ... | + +### 建议检查 +| 检查项目 | 目的 | 费用参考 | +|----------|------|----------| +| HIV抗体 | 初筛 | ¥50-100 | + +### 就诊流程 +[Mermaid流程图] + +### 注意事项 +- ... +``` + +--- + +--- + +## 7-15. 医学可视化工具 + +以下工具均输出 **Mermaid 格式**,可在前端直接渲染。 + +### 7. generate_knowledge_graph +生成医学知识图谱,展示疾病、症状、治疗的关系。 + +| 参数 | 说明 | +|------|------| +| topic | 主题(如"HIV感染") | +| nodes | 节点列表,用\|分隔 | +| relations | 关系列表,用\|分隔 | + +### 8. generate_diagnosis_flow +生成诊断流程图,展示诊断步骤和决策点。 + +| 参数 | 说明 | +|------|------| +| disease | 疾病名称 | +| steps | 步骤列表,用\|分隔 | +| decisions | 决策点列表 | + +### 9. generate_medical_chart +生成统计图表(柱状图/折线图/饼图)。 + +| 参数 | 说明 | +|------|------| +| chart_type | 图表类型 (bar/line/pie) | +| title | 标题 | +| data | 数据,格式"标签:值\|标签:值" | + +### 10. generate_radar_chart +生成雷达图,用于多维度健康指标对比。 + +| 参数 | 说明 | +|------|------| +| title | 标题 | +| metrics | 指标列表 | +| values | 数值列表 | + +### 11. generate_timeline +生成时间线图,展示疾病发展或治疗计划。 + +| 参数 | 说明 | +|------|------| +| title | 标题 | +| events | 事件列表,格式"时间:描述\|时间:描述" | + +### 12. generate_gantt_chart +生成甘特图,用于治疗疗程安排。 + +| 参数 | 说明 | +|------|------| +| title | 标题 | +| tasks | 任务列表 | + +### 13. generate_quadrant_chart +生成象限图,用于风险评估和优先级分析。 + +| 参数 | 说明 | +|------|------| +| title | 标题 | +| x_axis | X轴标签 | +| y_axis | Y轴标签 | +| items | 项目列表 | + +### 14. generate_state_diagram +生成状态转换图,展示疾病状态变化。 + +| 参数 | 说明 | +|------|------| +| title | 标题 | +| states | 状态列表 | +| transitions | 转换列表 | + +### 15. generate_sankey_diagram +生成桑基图,展示流量和转换关系。 + +| 参数 | 说明 | +|------|------| +| title | 标题 | +| flows | 流量列表 | + +--- + +## 工具文件位置 + +所有自定义工具定义在: + +``` +backend/tool_collection/mcp/local_mcp_service.py +``` + +使用 FastMCP 框架注册,通过 `@local_mcp_service.tool()` 装饰器定义。 diff --git a/pathology-ai/diagrams/cod_process.png b/pathology-ai/diagrams/cod_process.png new file mode 100644 index 000000000..7da167fb4 Binary files /dev/null and b/pathology-ai/diagrams/cod_process.png differ diff --git a/pathology-ai/diagrams/dataflow.png b/pathology-ai/diagrams/dataflow.png new file mode 100644 index 000000000..1d825e141 Binary files /dev/null and b/pathology-ai/diagrams/dataflow.png differ diff --git a/pathology-ai/diagrams/game_flow.png b/pathology-ai/diagrams/game_flow.png new file mode 100644 index 000000000..e47c55b6a Binary files /dev/null and b/pathology-ai/diagrams/game_flow.png differ diff --git a/pathology-ai/diagrams/system_architecture.png b/pathology-ai/diagrams/system_architecture.png new file mode 100644 index 000000000..37d822ebd Binary files /dev/null and b/pathology-ai/diagrams/system_architecture.png differ diff --git a/pathology-ai/diagrams/tools_architecture.png b/pathology-ai/diagrams/tools_architecture.png new file mode 100644 index 000000000..545701273 Binary files /dev/null and b/pathology-ai/diagrams/tools_architecture.png differ diff --git a/pathology-ai/frontend-improvements.md b/pathology-ai/frontend-improvements.md new file mode 100644 index 000000000..6cbbfdf9a --- /dev/null +++ b/pathology-ai/frontend-improvements.md @@ -0,0 +1,154 @@ +# 🎨 前端改进说明 + +本文档详细介绍病理学AI助手中的前端优化和新增组件。 + +## 新增组件 + +### 1. PathologyImageGallery.tsx + +**位置**: `frontend/components/medical-visualization/PathologyImageGallery.tsx` + +**功能**: 病理图片画廊组件,用于展示和预览病理图片 + +**特性**: +- 网格布局展示图片 +- 点击图片放大预览 +- 支持图片分类标签 +- 响应式设计 + +### 2. DiagnosisConfidenceCard.tsx + +**位置**: `frontend/components/medical-visualization/DiagnosisConfidenceCard.tsx` + +**功能**: 置信度评估卡片组件 + +**特性**: +- 显示总体置信度分数 +- 风险等级指示器 (LOW/MEDIUM/HIGH/CRITICAL) +- 评估维度雷达图 +- 建议和警告显示 + +### 3. SourceTag.tsx + +**位置**: `frontend/components/medical-visualization/SourceTag.tsx` + +**功能**: 来源标签组件,用于标注信息来源 + +**特性**: +- [内部] 标签 - 蓝色,表示来自本地知识库 +- [外部] 标签 - 绿色,表示来自互联网搜索 +- 悬停显示详细来源信息 + +--- + +## 修改的组件 + +### 1. MedicalVisualizationPanel.tsx + +**位置**: `frontend/components/medical-visualization/MedicalVisualizationPanel.tsx` + +**修改内容**: +- 移除HIV/AIDS硬编码文字 +- 改为通用病理学描述 +- 支持动态标题和描述 + +**修改行**: 54-56, 97 + +### 2. markdownRenderer.tsx + +**位置**: `frontend/components/ui/markdownRenderer.tsx` + +**修改内容**: +- 新增 `ClickableOption` 组件 +- 解析 `[btn:xxx]` 格式为可点击按钮 +- 支持诊断游戏交互 + +**新增代码位置**: 378-410行 (ClickableOption组件), 975-1045行 (processText函数) + +### 3. chatLeftSidebar.tsx + +**位置**: `frontend/app/[locale]/chat/components/chatLeftSidebar.tsx` + +**修改内容**: +- 新增"清空所有对话"按钮 +- 新增删除确认对话框 +- 新增 `handleDeleteAllClick` 和 `confirmDeleteAll` 函数 + +**修改行**: 10, 136-138, 209-227, 463-475, 507-541 + +### 4. conversationService.ts + +**位置**: `frontend/services/conversationService.ts` + +**修改内容**: +- 新增 `deleteAll` 方法用于批量删除对话 + +**修改行**: 122-130 + +### 5. index.ts (医学可视化组件导出) + +**位置**: `frontend/components/medical-visualization/index.ts` + +**修改内容**: +- 添加新组件的导出语句 + +--- + +## 组件使用示例 + +### PathologyImageGallery + +```tsx +import { PathologyImageGallery } from '@/components/medical-visualization'; + + +``` + +### DiagnosisConfidenceCard + +```tsx +import { DiagnosisConfidenceCard } from '@/components/medical-visualization'; + + +``` + +### SourceTag + +```tsx +import { SourceTag } from '@/components/medical-visualization'; + + // 显示 [内部] + // 显示 [外部] +``` + +### 可点击按钮 (Markdown中) + +在AI回复中使用 `[btn:选项文字]` 格式,会自动渲染为可点击按钮: + +```markdown +请选择下一步操作: + +[btn:询问发热情况] [btn:询问其他症状] [btn:进入体格检查] +``` + +--- + +## 样式说明 + +所有新增组件使用: +- **TailwindCSS** 进行样式定义 +- **Lucide React** 图标库 +- **shadcn/ui** 基础组件 + +遵循 Nexent 现有设计规范,保持视觉一致性。