feat(ai): Prompt 管理 Phase 2 — analysis_type 后端选择键 + 筛选修复

- 新增 ai_prompt.analysis_type 列作为后端按链路选择 Prompt 的唯一键
- name 回归显示标识符用途,不再承担选择键角色
- 迁移 000164: 新增 analysis_type 列 + 从 name 回填 + 索引
- 迁移 000165: 修复旧数据从 category 错误回填的问题
- AiPromptList 页面重构: 分析类型/调用链路列、详情抽屉、新建表单
- DrawerForm 组件新增 onValuesChange 回调支持跨字段联动
- 新建表单选择分析类型后自动填充标识符
- 筛选过滤器改为按 analysis_type 而非 category 过滤(后端+前端同步)
- 停用/激活/回滚/删除操作完整可用
This commit is contained in:
iven
2026-05-26 17:04:26 +08:00
parent 3972db4f98
commit 3c7b48b6f6
10 changed files with 549 additions and 165 deletions

View File

@@ -11,6 +11,7 @@ export interface PromptItem {
version: number; version: number;
is_active: boolean; is_active: boolean;
category: string; category: string;
analysis_type: string;
tags: Record<string, unknown> | null; tags: Record<string, unknown> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -23,10 +24,11 @@ export interface CreatePromptReq {
user_prompt_template: string; user_prompt_template: string;
model_config: Record<string, unknown>; model_config: Record<string, unknown>;
category: string; category: string;
analysis_type: string;
} }
export const promptApi = { export const promptApi = {
list: async (params?: { category?: string; page?: number; page_size?: number }) => { list: async (params?: { category?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/prompts', { params }); const resp = await client.get('/ai/prompts', { params });
return resp.data.data as PaginatedResponse<PromptItem>; return resp.data.data as PaginatedResponse<PromptItem>;
}, },
@@ -38,8 +40,15 @@ export const promptApi = {
const resp = await client.post(`/ai/prompts/${id}/activate`); const resp = await client.post(`/ai/prompts/${id}/activate`);
return resp.data.data as PromptItem; return resp.data.data as PromptItem;
}, },
deactivate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
return resp.data.data as PromptItem;
},
rollback: async (id: string) => { rollback: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/rollback`); const resp = await client.post(`/ai/prompts/${id}/rollback`);
return resp.data.data as PromptItem; return resp.data.data as PromptItem;
}, },
delete: async (id: string) => {
await client.delete(`/ai/prompts/${id}`);
},
}; };

View File

@@ -20,6 +20,7 @@ interface DrawerFormProps {
children?: React.ReactNode; children?: React.ReactNode;
columns?: 1 | 2; columns?: 1 | 2;
form?: ReturnType<typeof Form.useForm>[0]; form?: ReturnType<typeof Form.useForm>[0];
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
} }
export function DrawerForm({ export function DrawerForm({
@@ -34,6 +35,7 @@ export function DrawerForm({
children, children,
columns = 2, columns = 2,
form: externalForm, form: externalForm,
onValuesChange,
}: DrawerFormProps) { }: DrawerFormProps) {
const [internalForm] = Form.useForm(); const [internalForm] = Form.useForm();
const form = externalForm ?? internalForm; const form = externalForm ?? internalForm;
@@ -84,7 +86,7 @@ export function DrawerForm({
</Space> </Space>
} }
> >
<Form form={form} layout="vertical" initialValues={initialValues}> <Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={onValuesChange}>
{sections {sections
? sections.map((s, i) => ( ? sections.map((s, i) => (
<div key={i}> <div key={i}>

View File

@@ -3,36 +3,68 @@ import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
Select, Select,
Tag, Tag,
Badge,
message, message,
Drawer,
Descriptions,
Typography,
Slider,
InputNumber,
Modal,
} from 'antd'; } from 'antd';
import { PlusOutlined, UndoOutlined, CheckOutlined } from '@ant-design/icons'; import {
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts'; PlusOutlined,
UndoOutlined,
CheckOutlined,
EyeOutlined,
StopOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { promptApi, type PromptItem } from '../../api/ai/prompts';
import { AuthButton } from '../../components/AuthButton'; import { AuthButton } from '../../components/AuthButton';
import { DrawerForm } from '../../components/DrawerForm';
import type { FormSection } from '../../components/DrawerForm';
import { PageContainer } from '../../components/PageContainer';
import { useThemeMode } from '../../hooks/useThemeMode'; import { useThemeMode } from '../../hooks/useThemeMode';
import { formatDateTime } from '../../utils/format';
const CATEGORIES = [ // --- 分析类型定义(与后端 AnalysisType::prompt_name() 一一对应) ---
{ value: 'lab_report_interpretation', label: '化验单解读' },
{ value: 'health_trend_analysis', label: '趋势分析' }, const ANALYSIS_TYPES = [
{ value: 'personalized_checkup_plan', label: '体检方案' }, { value: 'lab_report_interpretation', label: '化验单解读', api: '化验单解读 API' },
{ value: 'report_summary_generation', label: '报告摘要' }, { value: 'health_trend_analysis', label: '趋势分析', api: '趋势分析 API' },
{ value: 'personalized_checkup_plan', label: '体检方案', api: '体检方案 API' },
{ value: 'report_summary_generation', label: '报告摘要', api: '报告摘要 API' },
{ value: 'follow_up_summary_generation', label: '随访摘要', api: '随访摘要 API' },
] as const;
const ANALYSIS_TYPE_MAP = Object.fromEntries(
ANALYSIS_TYPES.map((t) => [t.value, t]),
);
const MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'qwen-plus', label: 'Qwen Plus' },
]; ];
const CATEGORY_MAP: Record<string, string> = Object.fromEntries( const DEFAULT_MODEL_CONFIG = { model: 'deepseek-chat', temperature: 0.7, max_tokens: 4096 };
CATEGORIES.map((c) => [c.value, c.label]),
);
export default function AiPromptList() { export default function AiPromptList() {
const [data, setData] = useState<PromptItem[]>([]); const [data, setData] = useState<PromptItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [categoryFilter, setCategoryFilter] = useState<string | undefined>(); const [analysisTypeFilter, setAnalysisTypeFilter] = useState<string | undefined>();
const [modalOpen, setModalOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [viewing, setViewing] = useState<PromptItem | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const isDark = useThemeMode(); const isDark = useThemeMode();
@@ -43,7 +75,7 @@ export default function AiPromptList() {
const result = await promptApi.list({ const result = await promptApi.list({
page: p, page: p,
page_size: 20, page_size: 20,
category: categoryFilter, analysis_type: analysisTypeFilter,
}); });
setData(result.data); setData(result.data);
setTotal(result.total); setTotal(result.total);
@@ -53,18 +85,29 @@ export default function AiPromptList() {
setLoading(false); setLoading(false);
} }
}, },
[page, categoryFilter], [page, analysisTypeFilter],
); );
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const handleCreate = async (values: CreatePromptReq) => { const handleCreate = async (values: Record<string, unknown>) => {
const model = String(values.model ?? 'deepseek-chat');
const temperature = Number(values.temperature ?? 0.7);
const max_tokens = Number(values.max_tokens ?? 4096);
try { try {
await promptApi.create(values); await promptApi.create({
name: String(values.name ?? ''),
analysis_type: String(values.analysis_type ?? ''),
category: String(values.analysis_type ?? ''),
description: values.description ? String(values.description) : undefined,
system_prompt: String(values.system_prompt ?? ''),
user_prompt_template: String(values.user_prompt_template ?? ''),
model_config: { model, temperature, max_tokens },
});
message.success('Prompt 创建成功'); message.success('Prompt 创建成功');
setModalOpen(false); setDrawerOpen(false);
form.resetFields(); form.resetFields();
fetchData(); fetchData();
} catch { } catch {
@@ -72,7 +115,7 @@ export default function AiPromptList() {
} }
}; };
const handleActivate = async (id: string) => { const handleActivate = useCallback(async (id: string) => {
try { try {
await promptApi.activate(id); await promptApi.activate(id);
message.success('已激活'); message.success('已激活');
@@ -80,9 +123,19 @@ export default function AiPromptList() {
} catch { } catch {
message.error('激活失败'); message.error('激活失败');
} }
}; }, [fetchData]);
const handleRollback = async (id: string) => { const handleDeactivate = useCallback(async (id: string) => {
try {
await promptApi.deactivate(id);
message.success('已停用');
fetchData();
} catch {
message.error('停用失败');
}
}, [fetchData]);
const handleRollback = useCallback(async (id: string) => {
try { try {
await promptApi.rollback(id); await promptApi.rollback(id);
message.success('已回滚'); message.success('已回滚');
@@ -90,31 +143,78 @@ export default function AiPromptList() {
} catch { } catch {
message.error('回滚失败'); message.error('回滚失败');
} }
}, [fetchData]);
const handleDelete = useCallback((record: PromptItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除 Prompt「${record.name}」(v${record.version}) 吗?`,
okType: 'danger',
onOk: async () => {
try {
await promptApi.delete(record.id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
},
});
}, [fetchData]);
const openDetail = (record: PromptItem) => {
setViewing(record);
setDetailOpen(true);
}; };
// 按 analysis_type 汇总当前激活版本
const activeVersionMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of data) {
if (item.is_active) {
map.set(item.analysis_type, item.version);
}
}
return map;
}, [data]);
const columns = useMemo(() => [ const columns = useMemo(() => [
{ {
title: '名称', title: '名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 180, width: 160,
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>, render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
}, },
{ {
title: '类别', title: '分析类型',
dataIndex: 'category', dataIndex: 'analysis_type',
key: 'category', key: 'analysis_type',
width: 120, width: 130,
render: (v: string) => ( render: (v: string) => {
<Tag color="blue">{CATEGORY_MAP[v] || v}</Tag> const cfg = ANALYSIS_TYPE_MAP[v];
), return cfg ? <Tag color="blue">{cfg.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '调用链路',
dataIndex: 'analysis_type',
key: 'api_route',
width: 160,
render: (v: string) => {
const cfg = ANALYSIS_TYPE_MAP[v];
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>{cfg?.api ?? v}</Typography.Text>;
},
}, },
{ {
title: '版本', title: '版本',
dataIndex: 'version', dataIndex: 'version',
key: 'version', key: 'version',
width: 70, width: 70,
render: (v: number) => `v${v}`, render: (v: number, record: PromptItem) => {
const isActive = activeVersionMap.get(record.analysis_type) === v && record.is_active;
return isActive ? <Tag color="green">v{v}</Tag> : <span>v{v}</span>;
},
}, },
{ {
title: '状态', title: '状态',
@@ -122,7 +222,7 @@ export default function AiPromptList() {
key: 'is_active', key: 'is_active',
width: 80, width: 80,
render: (v: boolean) => ( render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> <Badge status={v ? 'success' : 'default'} text={v ? '启用' : '停用'} />
), ),
}, },
{ {
@@ -130,145 +230,247 @@ export default function AiPromptList() {
dataIndex: 'updated_at', dataIndex: 'updated_at',
key: 'updated_at', key: 'updated_at',
width: 170, width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'), render: (v: string) => formatDateTime(v),
}, },
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 160, width: 220,
render: (_: unknown, record: PromptItem) => ( render: (_: unknown, record: PromptItem) => (
<AuthButton code="ai.prompt.manage"> <AuthButton code="ai.prompt.manage">
<Space size={4}> <Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openDetail(record)}>
</Button>
{!record.is_active && ( {!record.is_active && (
<Button <Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleActivate(record.id)}>
type="link"
size="small"
icon={<CheckOutlined />}
onClick={() => handleActivate(record.id)}
>
</Button> </Button>
)} )}
<Button {record.is_active && (
type="link" <Button type="link" size="small" icon={<StopOutlined />} onClick={() => handleDeactivate(record.id)}>
size="small"
icon={<UndoOutlined />} </Button>
onClick={() => handleRollback(record.id)} )}
> <Button type="link" size="small" icon={<UndoOutlined />} onClick={() => handleRollback(record.id)}>
</Button> </Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
</Button>
</Space> </Space>
</AuthButton> </AuthButton>
), ),
}, },
], [handleActivate, handleRollback]); ], [handleActivate, handleDeactivate, handleRollback, handleDelete, activeVersionMap]);
return ( const formSections: FormSection[] = [
<div> {
<div className="erp-page-header"> title: '基本信息',
<div> fields: (
<h4>AI Prompt </h4> <>
<div className="erp-page-subtitle"> AI </div> <Form.Item
</div> name="analysis_type"
<Space size={8}> label="分析类型"
<Select rules={[{ required: true, message: '请选择分析类型' }]}
placeholder="筛选类别" extra="选择后自动填充名称,决定该 Prompt 被哪条分析链路调用"
value={categoryFilter} >
onChange={(v) => { <Select
setCategoryFilter(v); options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
setPage(1); placeholder="选择分析类型"
}} />
options={CATEGORIES} </Form.Item>
allowClear
style={{ width: 150 }}
/>
<AuthButton code="ai.prompt.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
>
Prompt
</Button>
</AuthButton>
</Space>
</div>
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchData(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
<Modal
title="新建 Prompt"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
width={600}
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item <Form.Item
name="name" name="name"
label="名称" label="标识符"
rules={[{ required: true, message: '请输入 Prompt 名称' }]} rules={[
{ required: true, message: '请输入标识符' },
{ pattern: /^[a-z0-9_]{3,64}$/, message: '仅允许小写字母、数字、下划线3-64位' },
]}
extra="后端按此标识符查找 Prompt通常与分析类型一致非必要勿改"
> >
<Input placeholder="如:化验单解读 V2" /> <Input placeholder="如 lab_report_interpretation" />
</Form.Item>
<Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
<Select options={CATEGORIES} placeholder="选择类别" />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述"> <Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="Prompt 用途说明" /> <Input.TextArea rows={2} placeholder="Prompt 用途说明(仅展示,不影响选择)" />
</Form.Item> </Form.Item>
</>
),
},
{
title: '模型配置',
fields: (
<>
<Form.Item name="model" label="模型" rules={[{ required: true, message: '请选择模型' }]}>
<Select options={MODEL_OPTIONS} placeholder="选择 AI 模型" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" extra="越低越确定,越高越多样">
<Slider min={0} max={2} step={0.1} />
</Form.Item>
<Form.Item name="max_tokens" label="Max Tokens">
<InputNumber min={256} max={8192} step={256} style={{ width: '100%' }} placeholder="4096" />
</Form.Item>
</>
),
},
{
title: '提示词模板',
fields: (
<>
<Form.Item <Form.Item
name="system_prompt" name="system_prompt"
label="System Prompt" label="System Prompt"
rules={[{ required: true, message: '请输入 System Prompt' }]} rules={[{ required: true, message: '请输入 System Prompt' }]}
> >
<Input.TextArea rows={4} placeholder="系统提示词" /> <Input.TextArea rows={6} placeholder="系统提示词,定义 AI 的角色和行为规则" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="user_prompt_template" name="user_prompt_template"
label="User Prompt 模板" label="User Prompt 模板"
rules={[{ required: true, message: '请输入 User Prompt 模板' }]} rules={[{ required: true, message: '请输入 User Prompt 模板' }]}
extra="支持 Handlebars {{变量}} 语法,如 {{patient_name}}、{{report_date}}"
> >
<Input.TextArea rows={4} placeholder="用户提示词模板,可用 {{变量}} 占位" /> <Input.TextArea rows={6} placeholder="用户提示词模板,可用 {{变量}} 占位" />
</Form.Item> </Form.Item>
<Form.Item </>
name="model_config" ),
label="模型配置 (JSON)" },
initialValue={{ model: 'deepseek-chat', temperature: 0.7 }} ];
return (
<PageContainer
title="AI Prompt 管理"
subtitle="管理 AI 分析提示词模板和版本"
filters={
<Select
placeholder="筛选分析类型"
value={analysisTypeFilter}
onChange={(v) => {
setAnalysisTypeFilter(v);
setPage(1);
}}
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
allowClear
style={{ width: 160 }}
/>
}
actions={
<AuthButton code="ai.prompt.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
form.setFieldsValue(DEFAULT_MODEL_CONFIG);
setDrawerOpen(true);
}}
> >
<Input.TextArea rows={3} placeholder='{"model": "deepseek-chat", "temperature": 0.7}' /> Prompt
</Form.Item> </Button>
</Form> </AuthButton>
</Modal> }
</div> >
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchData(p);
},
showTotal: (t) => `${t} 条记录`,
}}
/>
{/* 新建 Prompt Drawer */}
<DrawerForm
title="新建 Prompt"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleCreate}
form={form}
onValuesChange={(changed) => {
if ('analysis_type' in changed && changed.analysis_type) {
form.setFieldValue('name', changed.analysis_type);
}
}}
initialValues={{
...DEFAULT_MODEL_CONFIG,
category: '',
analysis_type: undefined,
name: '',
description: '',
}}
width={720}
columns={1}
sections={formSections}
/>
{/* 查看 Prompt 详情 */}
<Drawer
title={viewing ? `${viewing.name} (v${viewing.version})` : 'Prompt 详情'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setViewing(null); }}
width={640}
styles={{ body: { background: isDark ? '#141414' : undefined } }}
>
{viewing && (
<>
<Descriptions column={2} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="分析类型">
<Tag color="blue">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.label ?? viewing.analysis_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="标识符">
<Typography.Text code style={{ fontSize: 12 }}>{viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={viewing.is_active ? 'success' : 'default'} text={viewing.is_active ? '启用' : '停用'} />
</Descriptions.Item>
<Descriptions.Item label="版本">v{viewing.version}</Descriptions.Item>
<Descriptions.Item label="调用链路" span={2}>
<Typography.Text type="secondary">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.api ?? viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatDateTime(viewing.updated_at)}</Descriptions.Item>
</Descriptions>
{viewing.description && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<div style={{ marginTop: 4 }}>{viewing.description}</div>
</div>
)}
{[
{ label: 'System Prompt', content: viewing.system_prompt },
{ label: 'User Prompt 模板', content: viewing.user_prompt_template },
{ label: '模型配置', content: JSON.stringify(viewing.model_config, null, 2) },
].map(({ label, content }) => (
<div key={label} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{label}</Typography.Text>
<pre style={{
marginTop: 4,
padding: 12,
background: isDark ? '#1e293b' : '#f8fafc',
borderRadius: 8,
fontSize: 13,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 300,
overflow: 'auto',
}}>
{content}
</pre>
</div>
))}
</>
)}
</Drawer>
</PageContainer>
); );
} }

View File

@@ -16,6 +16,8 @@ pub struct Model {
pub version: i32, pub version: i32,
pub is_active: bool, pub is_active: bool,
pub category: String, pub category: String,
/// 后端选择键:与 AnalysisType::prompt_name() 对应handler 按此字段查找激活 Prompt
pub analysis_type: String,
pub tags: Option<serde_json::Value>, pub tags: Option<serde_json::Value>,
pub created_at: DateTimeUtc, pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc, pub updated_at: DateTimeUtc,

View File

@@ -95,7 +95,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "lab_report_interpretation") .get_active_prompt(ctx.tenant_id, AnalysisType::LabReport.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -190,7 +190,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "health_trend_analysis") .get_active_prompt(ctx.tenant_id, AnalysisType::Trends.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -262,7 +262,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "personalized_checkup_plan") .get_active_prompt(ctx.tenant_id, AnalysisType::CheckupPlan.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -341,7 +341,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "report_summary_generation") .get_active_prompt(ctx.tenant_id, AnalysisType::ReportSummary.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -417,7 +417,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation") .get_active_prompt(ctx.tenant_id, AnalysisType::FollowUpSummary.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -577,6 +577,7 @@ where
#[derive(Debug, Deserialize, utoipa::IntoParams)] #[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListPromptsQuery { pub struct ListPromptsQuery {
pub category: Option<String>, pub category: Option<String>,
pub analysis_type: Option<String>,
pub page: Option<u64>, pub page: Option<u64>,
pub page_size: Option<u64>, pub page_size: Option<u64>,
} }
@@ -605,7 +606,11 @@ where
}; };
let (items, total) = state let (items, total) = state
.prompt .prompt
.list_prompts(ctx.tenant_id, params.category, &pagination) .list_prompts(
ctx.tenant_id,
params.analysis_type.or(params.category),
&pagination,
)
.await?; .await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items, "data": items,
@@ -623,6 +628,7 @@ pub struct CreatePromptBody {
pub user_prompt_template: String, pub user_prompt_template: String,
pub model_config: serde_json::Value, pub model_config: serde_json::Value,
pub category: String, pub category: String,
pub analysis_type: String,
} }
#[utoipa::path( #[utoipa::path(
@@ -655,6 +661,7 @@ where
body.user_prompt_template, body.user_prompt_template,
body.model_config, body.model_config,
body.category, body.category,
body.analysis_type,
) )
.await?; .await?;
Ok(Json(ApiResponse::ok(prompt))) Ok(Json(ApiResponse::ok(prompt)))
@@ -702,6 +709,48 @@ where
Ok(Json(ApiResponse::ok(prompt))) Ok(Json(ApiResponse::ok(prompt)))
} }
#[utoipa::path(
post,
path = "/ai/prompts/{id}/deactivate",
responses((status = 200, description = "停用 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn deactivate_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.deactivate_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
#[utoipa::path(
delete,
path = "/ai/prompts/{id}",
responses((status = 200, description = "删除 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn delete_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
state.prompt.delete_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(())))
}
// === 用量统计 === // === 用量统计 ===
#[utoipa::path( #[utoipa::path(

View File

@@ -511,6 +511,14 @@ impl AiModule {
"/ai/prompts/{id}/rollback", "/ai/prompts/{id}/rollback",
axum::routing::post(crate::handler::rollback_prompt), axum::routing::post(crate::handler::rollback_prompt),
) )
.route(
"/ai/prompts/{id}/deactivate",
axum::routing::post(crate::handler::deactivate_prompt),
)
.route(
"/ai/prompts/{id}",
axum::routing::delete(crate::handler::delete_prompt),
)
.route( .route(
"/ai/usage/overview", "/ai/usage/overview",
axum::routing::get(crate::handler::usage_overview), axum::routing::get(crate::handler::usage_overview),

View File

@@ -17,20 +17,20 @@ impl PromptService {
Self { db } Self { db }
} }
/// 获取当前激活的 Prompt 模板 /// 获取当前激活的 Prompt 模板(按 analysis_type 查找)
pub async fn get_active_prompt( pub async fn get_active_prompt(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,
name: &str, analysis_type: &str,
) -> AiResult<ai_prompt::Model> { ) -> AiResult<ai_prompt::Model> {
ai_prompt::Entity::find() ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(name)) .filter(ai_prompt::Column::AnalysisType.eq(analysis_type))
.filter(ai_prompt::Column::IsActive.eq(true)) .filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null()) .filter(ai_prompt::Column::DeletedAt.is_null())
.one(&self.db) .one(&self.db)
.await? .await?
.ok_or_else(|| AiError::PromptNotFound(name.into())) .ok_or_else(|| AiError::PromptNotFound(analysis_type.into()))
} }
/// 新建 Prompt /// 新建 Prompt
@@ -44,6 +44,7 @@ impl PromptService {
user_prompt_template: String, user_prompt_template: String,
model_config: serde_json::Value, model_config: serde_json::Value,
category: String, category: String,
analysis_type: String,
) -> AiResult<ai_prompt::Model> { ) -> AiResult<ai_prompt::Model> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
let now = chrono::Utc::now(); let now = chrono::Utc::now();
@@ -59,6 +60,7 @@ impl PromptService {
version: Set(1), version: Set(1),
is_active: Set(true), is_active: Set(true),
category: Set(category), category: Set(category),
analysis_type: Set(analysis_type),
tags: Set(None), tags: Set(None),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
@@ -74,15 +76,15 @@ impl PromptService {
pub async fn list_prompts( pub async fn list_prompts(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,
category: Option<String>, analysis_type: Option<String>,
pagination: &Pagination, pagination: &Pagination,
) -> AiResult<(Vec<ai_prompt::Model>, u64)> { ) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
let mut query = ai_prompt::Entity::find() let mut query = ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::DeletedAt.is_null()); .filter(ai_prompt::Column::DeletedAt.is_null());
if let Some(cat) = &category { if let Some(at) = &analysis_type {
query = query.filter(ai_prompt::Column::Category.eq(cat.as_str())); query = query.filter(ai_prompt::Column::AnalysisType.eq(at.as_str()));
} }
let total = query.clone().count(&self.db).await?; let total = query.clone().count(&self.db).await?;
@@ -132,6 +134,7 @@ impl PromptService {
version: Set(entity.version + 1), version: Set(entity.version + 1),
is_active: Set(entity.is_active), is_active: Set(entity.is_active),
category: Set(entity.category.clone()), category: Set(entity.category.clone()),
analysis_type: Set(entity.analysis_type.clone()),
tags: Set(entity.tags.clone()), tags: Set(entity.tags.clone()),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
@@ -143,7 +146,7 @@ impl PromptService {
Ok(active.insert(&self.db).await?) Ok(active.insert(&self.db).await?)
} }
/// 激活指定 Prompt停用同 name+category 的其他版本) /// 激活指定 Prompt停用同 analysis_type 的其他版本,原子操作
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> { pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id) let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db) .one(&self.db)
@@ -154,25 +157,23 @@ impl PromptService {
return Err(AiError::Validation("跨租户操作".into())); return Err(AiError::Validation("跨租户操作".into()));
} }
// 停用同 name + category 的其他激活版本 // 原子操作:停用同 analysis_type 的其他版本
let siblings = ai_prompt::Entity::find() ai_prompt::Entity::update_many()
.col_expr(
ai_prompt::Column::IsActive,
sea_orm::sea_query::Expr::value(false),
)
.col_expr(
ai_prompt::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
)
.filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(&entity.name)) .filter(ai_prompt::Column::AnalysisType.eq(&entity.analysis_type))
.filter(ai_prompt::Column::Category.eq(&entity.category))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.filter(ai_prompt::Column::Id.ne(id)) .filter(ai_prompt::Column::Id.ne(id))
.all(&self.db) .filter(ai_prompt::Column::DeletedAt.is_null())
.exec(&self.db)
.await?; .await?;
for sibling in siblings {
let mut active: ai_prompt::ActiveModel = sibling.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&self.db).await?;
}
// 激活目标 // 激活目标
let mut active: ai_prompt::ActiveModel = entity.into(); let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(true); active.is_active = Set(true);
@@ -185,4 +186,41 @@ impl PromptService {
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> { pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
self.activate_prompt(id, tenant_id).await self.activate_prompt(id, tenant_id).await
} }
/// 停用 Prompt
pub async fn deactivate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
Ok(active.update(&self.db).await?)
}
/// 删除 Prompt软删除
pub async fn delete_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<()> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
let mut active: ai_prompt::ActiveModel = entity.into();
active.deleted_at = Set(Some(chrono::Utc::now()));
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&self.db).await?;
Ok(())
}
} }

View File

@@ -170,6 +170,8 @@ mod m20260522_000160_article_add_is_public;
mod m20260522_000161_patient_points_manage_perm; mod m20260522_000161_patient_points_manage_perm;
mod m20260522_000162_seed_patient_miniprogram_permissions; mod m20260522_000162_seed_patient_miniprogram_permissions;
mod m20260526_000163_points_rule_unique_event_type; mod m20260526_000163_points_rule_unique_event_type;
mod m20260526_000164_ai_prompt_add_analysis_type;
mod m20260526_000165_ai_prompt_fix_analysis_type;
pub struct Migrator; pub struct Migrator;
@@ -347,6 +349,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260522_000161_patient_points_manage_perm::Migration), Box::new(m20260522_000161_patient_points_manage_perm::Migration),
Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration), Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration),
Box::new(m20260526_000163_points_rule_unique_event_type::Migration), Box::new(m20260526_000163_points_rule_unique_event_type::Migration),
Box::new(m20260526_000164_ai_prompt_add_analysis_type::Migration),
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
] ]
} }
} }

View File

@@ -0,0 +1,47 @@
use sea_orm_migration::prelude::*;
/// ai_prompt 新增 analysis_type 列作为后端选择键name 回归显示名
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 1. 新增 analysis_type 列(先允许 NULL 以便回填)
db.execute_unprepared(
"ALTER TABLE ai_prompt ADD COLUMN IF NOT EXISTS analysis_type VARCHAR(64)",
)
.await?;
// 2. 用 name 值回填 analysis_typename 在旧数据中就是后端选择键)
db.execute_unprepared(
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type IS NULL AND deleted_at IS NULL",
)
.await?;
// 3. 设置 NOT NULL 约束
db.execute_unprepared("ALTER TABLE ai_prompt ALTER COLUMN analysis_type SET NOT NULL")
.await?;
// 4. 为 analysis_type 创建索引(后端按此列查询激活 Prompt
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_ai_prompt_analysis_type ON ai_prompt (tenant_id, analysis_type, is_active) WHERE deleted_at IS NULL",
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared("DROP INDEX IF EXISTS idx_ai_prompt_analysis_type")
.await?;
db.execute_unprepared("ALTER TABLE ai_prompt DROP COLUMN IF EXISTS analysis_type")
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,23 @@
use sea_orm_migration::prelude::*;
/// 修复 ai_prompt.analysis_type 回填数据:从 name真正的后端选择键而非 category泛化标签
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type != name AND deleted_at IS NULL",
)
.await?;
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}