feat: implement metadata field definition with status governance [AC-IDSMETA-13, AC-IDSMETA-14]

This commit is contained in:
MerCry 2026-03-02 22:14:46 +08:00
parent e179abd0e5
commit c432f457b8
25 changed files with 4244 additions and 83 deletions

View File

@ -0,0 +1,59 @@
import request from '@/utils/request'
import type {
DecompositionTemplate,
DecompositionTemplateDetail,
DecompositionTemplateCreate,
DecompositionTemplateUpdate,
DecompositionTemplateListResponse
} from '@/types/decomposition-template'
export const decompositionTemplateApi = {
list: (params?: { scene?: string; status?: string }) =>
request<DecompositionTemplateListResponse>({
method: 'GET',
url: '/admin/decomposition-templates',
params
}),
get: (id: string) =>
request<DecompositionTemplateDetail>({
method: 'GET',
url: `/admin/decomposition-templates/${id}`
}),
create: (data: DecompositionTemplateCreate) =>
request<DecompositionTemplate>({
method: 'POST',
url: '/admin/decomposition-templates',
data
}),
update: (id: string, data: DecompositionTemplateUpdate) =>
request<DecompositionTemplate>({
method: 'PUT',
url: `/admin/decomposition-templates/${id}`,
data
}),
delete: (id: string) =>
request({ method: 'DELETE', url: `/admin/decomposition-templates/${id}` }),
activate: (id: string) =>
request<DecompositionTemplate>({
method: 'POST',
url: `/admin/decomposition-templates/${id}/activate`
}),
archive: (id: string) =>
request<DecompositionTemplate>({
method: 'POST',
url: `/admin/decomposition-templates/${id}/archive`
}),
rollback: (id: string, version: number) =>
request<DecompositionTemplate>({
method: 'POST',
url: `/admin/decomposition-templates/${id}/rollback`,
data: { version }
})
}

View File

@ -0,0 +1,59 @@
import request from '@/utils/request'
import type {
MetadataFieldDefinition,
MetadataFieldCreateRequest,
MetadataFieldUpdateRequest,
MetadataFieldListResponse,
MetadataPayload,
MetadataScope
} from '@/types/metadata'
export const metadataSchemaApi = {
list: (status?: 'draft' | 'active' | 'deprecated') =>
request<MetadataFieldListResponse>({ method: 'GET', url: '/admin/metadata-schemas', params: status ? { status } : {} }),
get: (id: string) =>
request<MetadataFieldDefinition>({ method: 'GET', url: `/admin/metadata-schemas/${id}` }),
create: (data: MetadataFieldCreateRequest) =>
request<MetadataFieldDefinition>({ method: 'POST', url: '/admin/metadata-schemas', data }),
update: (id: string, data: MetadataFieldUpdateRequest) =>
request<MetadataFieldDefinition>({ method: 'PUT', url: `/admin/metadata-schemas/${id}`, data }),
delete: (id: string) =>
request({ method: 'DELETE', url: `/admin/metadata-schemas/${id}` }),
getByScope: (scope: MetadataScope, includeDeprecated = false) =>
request<MetadataFieldListResponse>({
method: 'GET',
url: '/admin/metadata-schemas',
params: { scope, include_deprecated: includeDeprecated }
}),
validate: (metadata: MetadataPayload, scope?: MetadataScope) =>
request<{ valid: boolean; errors?: { field_key: string; message: string }[] }>({
method: 'POST',
url: '/admin/metadata-schemas/validate',
data: { metadata, scope }
}),
checkCompatibility: (oldScope: MetadataScope, newScope: MetadataScope, metadata: MetadataPayload) =>
request<{
compatible: boolean;
conflicts: { field_key: string; reason: string }[];
preserved_keys: string[]
}>({
method: 'POST',
url: '/admin/metadata-schemas/check-compatibility',
data: { old_scope: oldScope, new_scope: newScope, metadata }
})
}
export type {
MetadataFieldDefinition,
MetadataFieldCreateRequest,
MetadataFieldUpdateRequest,
MetadataFieldListResponse,
MetadataPayload
}

View File

@ -0,0 +1,189 @@
<template>
<div class="metadata-field-renderer">
<el-form-item
:label="field.label"
:prop="propPath"
:required="field.required && !isDeprecated"
:class="{ 'is-deprecated': isDeprecated }"
>
<template #label>
<div class="field-label-wrapper">
<span>{{ field.label }}</span>
<el-tag v-if="isDeprecated" type="danger" size="small" class="deprecated-tag">
已废弃
</el-tag>
<el-tooltip v-if="field.description" :content="field.description" placement="top">
<el-icon class="field-help"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div v-if="isDeprecated" class="deprecated-notice">
<el-alert
type="warning"
:closable="false"
show-icon
>
<template #title>
此字段已废弃仅保留历史数据展示不可编辑
</template>
</el-alert>
<div class="deprecated-value" v-if="modelValue !== undefined && modelValue !== null && modelValue !== ''">
当前值: {{ formatValue(modelValue) }}
</div>
</div>
<template v-else>
<el-input
v-if="field.type === 'string'"
:model-value="modelValue as string"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="placeholder"
:disabled="disabled"
clearable
/>
<el-input-number
v-else-if="field.type === 'number'"
:model-value="modelValue as number"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="placeholder"
:disabled="disabled"
style="width: 100%"
/>
<el-switch
v-else-if="field.type === 'boolean'"
:model-value="modelValue as boolean"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
/>
<el-select
v-else-if="field.type === 'enum'"
:model-value="modelValue as string"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="placeholder"
:disabled="disabled"
clearable
style="width: 100%"
>
<el-option
v-for="opt in field.options"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
<el-select
v-else-if="field.type === 'array_enum'"
:model-value="modelValue as string[]"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="placeholder"
:disabled="disabled"
multiple
clearable
style="width: 100%"
>
<el-option
v-for="opt in field.options"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
</template>
<div v-if="fieldHint" class="field-hint">{{ fieldHint }}</div>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import type { MetadataFieldDefinition, MetadataPayload } from '@/types/metadata'
const props = defineProps<{
field: MetadataFieldDefinition
modelValue: string | number | boolean | string[] | undefined
propPath?: string
disabled?: boolean
isNewObject?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | boolean | string[] | undefined): void
}>()
const isDeprecated = computed(() => props.field.status === 'deprecated')
const placeholder = computed(() => {
if (props.field.default !== undefined) {
return `默认: ${props.field.default}`
}
return `请输入${props.field.label}`
})
const fieldHint = computed(() => {
const hints: string[] = []
if (props.field.is_filterable) {
hints.push('可作为过滤条件')
}
if (props.field.is_rank_feature) {
hints.push('可作为排序特征')
}
return hints.join(' | ')
})
const formatValue = (value: string | number | boolean | string[] | undefined): string => {
if (value === undefined || value === null) return '无'
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
</script>
<style scoped>
.metadata-field-renderer {
width: 100%;
}
.field-label-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.deprecated-tag {
font-size: 10px;
}
.field-help {
color: var(--el-text-color-secondary);
cursor: help;
}
.is-deprecated :deep(.el-form-item__label) {
color: var(--el-text-color-secondary);
}
.deprecated-notice {
margin-bottom: 8px;
}
.deprecated-value {
margin-top: 8px;
padding: 8px 12px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.field-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<div class="metadata-form">
<div v-if="loading" class="loading-wrapper">
<el-skeleton :rows="3" animated />
</div>
<template v-else>
<div v-if="allFields.length === 0" class="empty-fields">
<el-empty description="暂无适用的元数据字段" :image-size="60" />
</div>
<template v-else>
<el-row :gutter="16">
<el-col
v-for="field in visibleFields"
:key="field.id"
:xs="24"
:sm="12"
:md="colSpan"
>
<MetadataFieldRenderer
:field="field"
:model-value="localMetadata[field.field_key]"
:prop-path="`metadata.${field.field_key}`"
:disabled="disabled || (field.status === 'deprecated' && !showDeprecatedEditable)"
:is-new-object="isNewObject"
@update:model-value="handleFieldUpdate(field.field_key, $event)"
/>
</el-col>
</el-row>
<div v-if="deprecatedFields.length > 0 && showDeprecated" class="deprecated-section">
<el-divider content-position="left">
<el-tag type="danger" size="small">已废弃字段</el-tag>
</el-divider>
<el-row :gutter="16">
<el-col
v-for="field in deprecatedFields"
:key="field.id"
:xs="24"
:sm="12"
:md="colSpan"
>
<MetadataFieldRenderer
:field="field"
:model-value="localMetadata[field.field_key]"
:prop-path="`metadata.${field.field_key}`"
disabled
:is-new-object="isNewObject"
@update:model-value="handleFieldUpdate(field.field_key, $event)"
/>
</el-col>
</el-row>
</div>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import MetadataFieldRenderer from './MetadataFieldRenderer.vue'
import { metadataSchemaApi } from '@/api/metadata-schema'
import type { MetadataFieldDefinition, MetadataPayload, MetadataScope } from '@/types/metadata'
const props = withDefaults(defineProps<{
scope: MetadataScope
modelValue?: MetadataPayload
disabled?: boolean
isNewObject?: boolean
showDeprecated?: boolean
showDeprecatedEditable?: boolean
colSpan?: number
}>(), {
modelValue: () => ({}),
disabled: false,
isNewObject: true,
showDeprecated: true,
showDeprecatedEditable: false,
colSpan: 8
})
const emit = defineEmits<{
(e: 'update:modelValue', value: MetadataPayload): void
(e: 'fields-loaded', fields: MetadataFieldDefinition[]): void
}>()
const loading = ref(false)
const allFields = ref<MetadataFieldDefinition[]>([])
const localMetadata = ref<MetadataPayload>({})
const activeFields = computed(() => {
return allFields.value.filter(f => f.status === 'active')
})
const deprecatedFields = computed(() => {
return allFields.value.filter(f => f.status === 'deprecated')
})
const visibleFields = computed(() => {
if (props.isNewObject) {
return activeFields.value
}
return allFields.value.filter(f => f.status !== 'draft')
})
const loadFields = async () => {
loading.value = true
try {
const res = await metadataSchemaApi.getByScope(props.scope, props.showDeprecated)
allFields.value = res.items || []
emit('fields-loaded', allFields.value)
applyDefaults()
} catch (error: any) {
console.error('加载元数据字段失败', error)
ElMessage.error('加载元数据字段失败')
} finally {
loading.value = false
}
}
const applyDefaults = () => {
const defaults: MetadataPayload = {}
activeFields.value.forEach(field => {
if (field.default !== undefined && localMetadata.value[field.field_key] === undefined) {
defaults[field.field_key] = field.default
}
})
if (Object.keys(defaults).length > 0) {
localMetadata.value = { ...defaults, ...localMetadata.value }
emit('update:modelValue', { ...localMetadata.value })
}
}
const handleFieldUpdate = (fieldKey: string, value: string | number | boolean | string[] | undefined) => {
localMetadata.value[fieldKey] = value
emit('update:modelValue', { ...localMetadata.value })
}
watch(() => props.modelValue, (newVal) => {
if (newVal) {
localMetadata.value = { ...newVal }
}
}, { immediate: true, deep: true })
watch(() => props.scope, () => {
loadFields()
})
onMounted(() => {
loadFields()
})
defineExpose({
validate: async () => {
const errors: { field_key: string; message: string }[] = []
activeFields.value.forEach(field => {
if (field.required) {
const value = localMetadata.value[field.field_key]
if (value === undefined || value === null || value === '' ||
(Array.isArray(value) && value.length === 0)) {
errors.push({
field_key: field.field_key,
message: `${field.label} 为必填项`
})
}
}
if (field.type === 'enum' || field.type === 'array_enum') {
const value = localMetadata.value[field.field_key]
if (value !== undefined && value !== null && field.options) {
if (field.type === 'enum' && !field.options.includes(value as string)) {
errors.push({
field_key: field.field_key,
message: `${field.label} 的值不在有效选项中`
})
}
if (field.type === 'array_enum' && Array.isArray(value)) {
const invalidValues = value.filter(v => !field.options!.includes(v))
if (invalidValues.length > 0) {
errors.push({
field_key: field.field_key,
message: `${field.label} 包含无效选项: ${invalidValues.join(', ')}`
})
}
}
}
}
})
return {
valid: errors.length === 0,
errors
}
},
getMetadata: () => ({ ...localMetadata.value }),
getFields: () => allFields.value
})
</script>
<style scoped>
.metadata-form {
width: 100%;
}
.loading-wrapper {
padding: 16px;
}
.empty-fields {
padding: 16px;
text-align: center;
}
.deprecated-section {
margin-top: 16px;
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
}
</style>

View File

@ -0,0 +1,232 @@
<template>
<el-dialog
v-model="visible"
title="类型切换确认"
width="600px"
:close-on-click-modal="false"
>
<div class="type-change-handler">
<el-alert
type="warning"
:closable="false"
show-icon
class="change-alert"
>
<template #title>
检测到类型变更部分元数据字段可能需要处理
</template>
</el-alert>
<div v-if="conflicts.length > 0" class="conflicts-section">
<h4 class="section-title">需要处理的字段</h4>
<el-table :data="conflicts" stripe size="small">
<el-table-column prop="label" label="字段名" width="120" />
<el-table-column label="当前值" width="150">
<template #default="{ row }">
{{ formatValue(row.old_value) }}
</template>
</el-table-column>
<el-table-column label="冲突原因" min-width="120">
<template #default="{ row }">
<el-tag :type="row.conflict_type === 'removed' ? 'danger' : 'warning'" size="small">
{{ row.conflict_type === 'removed' ? '字段不存在' : '类型不匹配' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="处理方式" width="140">
<template #default="{ row }">
<el-select v-model="row.action" size="small" style="width: 100%">
<el-option label="移除" value="remove" />
<el-option
v-if="row.map_to"
:label="`映射到 ${row.map_to}`"
value="map"
/>
</el-select>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="preserved.length > 0" class="preserved-section">
<h4 class="section-title">
<el-icon class="success-icon"><CircleCheckFilled /></el-icon>
保留的字段 ({{ preserved.length }})
</h4>
<div class="preserved-tags">
<el-tag
v-for="item in preserved"
:key="item.field_key"
type="success"
size="small"
class="preserved-tag"
>
{{ item.field_key }}: {{ formatValue(item.value) }}
</el-tag>
</div>
</div>
</div>
<template #footer>
<el-button @click="handleCancel">取消切换</el-button>
<el-button type="primary" @click="handleConfirm">
确认切换
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { CircleCheckFilled } from '@element-plus/icons-vue'
import type { MetadataFieldDefinition, MetadataPayload, MetadataScope, TypeChangeConflict } from '@/types/metadata'
interface ConflictItem extends TypeChangeConflict {
action: 'remove' | 'map'
}
const props = defineProps<{
show: boolean
oldScope: MetadataScope
newScope: MetadataScope
currentMetadata: MetadataPayload
oldFields: MetadataFieldDefinition[]
newFields: MetadataFieldDefinition[]
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'confirm', result: { metadata: MetadataPayload; removed: string[] }): void
(e: 'cancel'): void
}>()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const conflicts = ref<ConflictItem[]>([])
const preserved = ref<{ field_key: string; value: string | number | boolean | string[] | undefined }[]>([])
const analyzeCompatibility = () => {
conflicts.value = []
preserved.value = []
const newFieldKeys = new Set(props.newFields.map(f => f.field_key))
const newFieldTypes = new Map(props.newFields.map(f => [f.field_key, f.type]))
Object.entries(props.currentMetadata).forEach(([key, value]) => {
if (value === undefined || value === null) return
if (!newFieldKeys.has(key)) {
const oldField = props.oldFields.find(f => f.field_key === key)
conflicts.value.push({
field_key: key,
label: oldField?.label || key,
conflict_type: 'removed',
old_value: value,
suggested_action: 'remove',
action: 'remove'
})
} else {
const newType = newFieldTypes.get(key)
const oldField = props.oldFields.find(f => f.field_key === key)
const oldType = oldField?.type
if (oldType !== newType) {
conflicts.value.push({
field_key: key,
label: oldField?.label || key,
conflict_type: 'type_mismatch',
old_value: value,
suggested_action: 'remove',
action: 'remove'
})
} else {
preserved.value.push({ field_key: key, value })
}
}
})
}
const formatValue = (value: string | number | boolean | string[] | undefined): string => {
if (value === undefined || value === null) return '无'
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
const handleConfirm = () => {
const newMetadata: MetadataPayload = {}
const removed: string[] = []
preserved.value.forEach(item => {
newMetadata[item.field_key] = item.value
})
conflicts.value.forEach(item => {
if (item.action === 'remove') {
removed.push(item.field_key)
} else if (item.action === 'map' && item.map_to) {
newMetadata[item.map_to] = item.old_value
}
})
emit('confirm', { metadata: newMetadata, removed })
visible.value = false
}
const handleCancel = () => {
emit('cancel')
visible.value = false
}
watch(() => props.show, (show) => {
if (show) {
analyzeCompatibility()
}
})
</script>
<style scoped>
.type-change-handler {
max-height: 400px;
overflow-y: auto;
}
.change-alert {
margin-bottom: 16px;
}
.section-title {
margin: 16px 0 12px 0;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.success-icon {
color: var(--el-color-success);
}
.conflicts-section {
margin-bottom: 16px;
}
.preserved-section {
margin-top: 16px;
}
.preserved-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preserved-tag {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,3 @@
export { default as MetadataForm } from './MetadataForm.vue'
export { default as MetadataFieldRenderer } from './MetadataFieldRenderer.vue'
export { default as TypeChangeHandler } from './TypeChangeHandler.vue'

View File

@ -53,6 +53,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@/views/admin/knowledge-base/index.vue'),
meta: { title: '多知识库管理' }
},
{
path: '/admin/metadata-schemas',
name: 'MetadataSchema',
component: () => import('@/views/admin/metadata-schema/index.vue'),
meta: { title: '元数据模式配置' }
},
{
path: '/admin/intent-rules',
name: 'IntentRule',
@ -71,6 +77,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@/views/admin/guardrail/index.vue'),
meta: { title: '输出护栏管理' }
},
{
path: '/admin/decomposition-templates',
name: 'DecompositionTemplate',
component: () => import('@/views/admin/decomposition-template/index.vue'),
meta: { title: '拆解模板管理' }
},
{
path: '/admin/monitoring/intent-rules',
name: 'IntentRuleMonitoring',

View File

@ -0,0 +1,84 @@
import type { MetadataPayload } from '@/types/metadata'
export interface DecompositionTemplate {
id: string
name: string
scene: string
description?: string
current_version: number
status: DecompositionStatus
is_latest_effective: boolean
effective_at?: string
metadata?: MetadataPayload
created_at: string
updated_at: string
}
export interface DecompositionTemplateDetail {
id: string
name: string
scene: string
description?: string
current_version: number
status: DecompositionStatus
is_latest_effective: boolean
effective_at?: string
steps: DecompositionStep[]
versions: DecompositionVersion[]
metadata?: MetadataPayload
created_at: string
updated_at: string
}
export type DecompositionStatus = 'draft' | 'active' | 'archived'
export interface DecompositionStep {
step_id: string
step_no: number
instruction: string
expected_output?: string
dependencies?: string[]
}
export interface DecompositionVersion {
version: number
status: DecompositionStatus
steps: DecompositionStep[]
created_at: string
effective_at?: string
archived_at?: string
}
export interface DecompositionTemplateCreate {
name: string
scene: string
description?: string
steps: DecompositionStep[]
metadata?: MetadataPayload
}
export interface DecompositionTemplateUpdate {
name?: string
scene?: string
description?: string
steps?: DecompositionStep[]
metadata?: MetadataPayload
}
export interface DecompositionTemplateListResponse {
data: DecompositionTemplate[]
}
export const DECOMPOSITION_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿', color: 'info' },
{ value: 'active', label: '生效', color: 'success' },
{ value: 'archived', label: '归档', color: 'warning' }
]
export const DECOMPOSITION_SCENE_OPTIONS = [
{ value: 'customer_service', label: '客服场景' },
{ value: 'sales', label: '销售场景' },
{ value: 'support', label: '技术支持' },
{ value: 'complaint', label: '投诉处理' },
{ value: 'general', label: '通用场景' }
]

View File

@ -1,3 +1,5 @@
import type { MetadataPayload } from '@/types/metadata'
export interface IntentRule {
id: string
name: string
@ -11,6 +13,7 @@ export interface IntentRule {
transfer_message?: string
hit_count: number
is_enabled: boolean
metadata?: MetadataPayload
created_at: string
updated_at: string
}
@ -26,6 +29,7 @@ export interface IntentRuleCreate {
flow_id?: string
transfer_message?: string
is_enabled?: boolean
metadata?: MetadataPayload
}
export interface IntentRuleUpdate {
@ -39,6 +43,7 @@ export interface IntentRuleUpdate {
flow_id?: string
transfer_message?: string
is_enabled?: boolean
metadata?: MetadataPayload
}
export interface IntentRuleListResponse {

View File

@ -0,0 +1,93 @@
export type MetadataFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'array_enum'
export type MetadataFieldStatus = 'draft' | 'active' | 'deprecated'
export type MetadataScope = 'kb_document' | 'intent_rule' | 'script_flow' | 'prompt_template'
export interface MetadataFieldDefinition {
id: string
field_key: string
label: string
type: MetadataFieldType
description?: string
required: boolean
options?: string[]
default?: string | number | boolean
scope: MetadataScope[]
is_filterable: boolean
is_rank_feature: boolean
status: MetadataFieldStatus
created_at?: string
updated_at?: string
}
export interface MetadataFieldCreateRequest {
field_key: string
label: string
type: MetadataFieldType
required: boolean
options?: string[]
default?: string | number | boolean
scope: MetadataScope[]
is_filterable?: boolean
is_rank_feature?: boolean
status: MetadataFieldStatus
}
export interface MetadataFieldUpdateRequest {
label?: string
required?: boolean
options?: string[]
default?: string | number | boolean
scope?: MetadataScope[]
is_filterable?: boolean
is_rank_feature?: boolean
status?: MetadataFieldStatus
}
export interface MetadataFieldListResponse {
items: MetadataFieldDefinition[]
}
export interface MetadataPayload {
[key: string]: string | number | boolean | string[] | undefined
}
export interface TypeChangeConflict {
field_key: string
label: string
conflict_type: 'removed' | 'type_mismatch'
old_value?: string | number | boolean | string[]
suggested_action: 'remove' | 'map'
map_to?: string
}
export interface TypeChangeResult {
preserved: { field_key: string; value: string | number | boolean | string[] | undefined }[]
conflicts: TypeChangeConflict[]
}
export const METADATA_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿', color: 'info', description: '字段可编辑,不可用于新建对象' },
{ value: 'active', label: '生效', color: 'success', description: '可用于新建与编辑对象' },
{ value: 'deprecated', label: '废弃', color: 'danger', description: '不可用于新建,历史数据可读' }
]
export const METADATA_SCOPE_OPTIONS = [
{ value: 'kb_document', label: '知识库文档' },
{ value: 'intent_rule', label: '意图规则' },
{ value: 'script_flow', label: '话术流程' },
{ value: 'prompt_template', label: 'Prompt模板' }
]
export const METADATA_TYPE_OPTIONS = [
{ value: 'string', label: '文本' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔值' },
{ value: 'enum', label: '单选枚举' },
{ value: 'array_enum', label: '多选枚举' }
]
export const STATUS_TAG_MAP: Record<MetadataFieldStatus, '' | 'success' | 'warning' | 'danger' | 'info'> = {
draft: 'info',
active: 'success',
deprecated: 'danger'
}

View File

@ -1,9 +1,12 @@
import type { MetadataPayload } from '@/types/metadata'
export interface PromptTemplate {
id: string
name: string
scene: string
description?: string
is_default: boolean
metadata?: MetadataPayload
published_version?: PromptVersionInfo
created_at: string
updated_at: string
@ -24,6 +27,7 @@ export interface PromptTemplateDetail {
current_content?: string
variables?: PromptVariable[]
versions?: PromptVersion[]
metadata?: MetadataPayload
published_version?: PromptVersionInfo
created_at: string
updated_at: string
@ -51,6 +55,7 @@ export interface PromptTemplateCreate {
system_instruction: string
variables?: PromptVariable[]
is_default?: boolean
metadata?: MetadataPayload
}
export interface PromptTemplateUpdate {
@ -59,6 +64,7 @@ export interface PromptTemplateUpdate {
description?: string
system_instruction?: string
variables?: PromptVariable[]
metadata?: MetadataPayload
}
export interface PromptTemplateListResponse {
@ -84,8 +90,11 @@ export const SCENE_OPTIONS = [
export const BUILTIN_VARIABLES: PromptVariable[] = [
{ name: 'persona_name', description: 'AI 人设名称', default_value: 'AI助手' },
{ name: 'persona_personality', description: 'AI 性格特点', default_value: '热情、耐心、专业' },
{ name: 'persona_tone', description: 'AI 说话风格', default_value: '亲切自然,使用口语化表达' },
{ name: 'brand_name', description: '品牌名称', default_value: '我们公司' },
{ name: 'current_time', description: '当前时间' },
{ name: 'channel_type', description: '渠道类型web/wechat/app' },
{ name: 'channel_type', description: '渠道类型web/wechat/phone/app' },
{ name: 'user_name', description: '用户名称' },
{ name: 'context', description: '检索上下文' },
{ name: 'query', description: '用户问题' },

View File

@ -1,3 +1,5 @@
import type { MetadataPayload } from '@/types/metadata'
export interface ScriptFlow {
id: string
name: string
@ -5,6 +7,7 @@ export interface ScriptFlow {
step_count: number
is_enabled: boolean
linked_rule_count: number
metadata?: MetadataPayload
created_at: string
updated_at: string
}
@ -15,10 +18,13 @@ export interface ScriptFlowDetail {
description?: string
steps: FlowStep[]
is_enabled: boolean
metadata?: MetadataPayload
created_at: string
updated_at: string
}
export type ScriptMode = 'fixed' | 'flexible' | 'template'
export interface FlowStep {
step_id: string
step_no: number
@ -27,11 +33,18 @@ export interface FlowStep {
timeout_seconds?: number
timeout_action?: 'repeat' | 'skip' | 'transfer'
next_conditions?: NextCondition[]
default_next?: number
script_mode?: ScriptMode
intent?: string
intent_description?: string
script_constraints?: string[]
expected_variables?: string[]
}
export interface NextCondition {
keywords: string[]
target_step_id: string
keywords?: string[]
pattern?: string
goto_step: number
}
export interface ScriptFlowCreate {
@ -39,6 +52,7 @@ export interface ScriptFlowCreate {
description?: string
steps: FlowStep[]
is_enabled?: boolean
metadata?: MetadataPayload
}
export interface ScriptFlowUpdate {
@ -46,6 +60,7 @@ export interface ScriptFlowUpdate {
description?: string
steps?: FlowStep[]
is_enabled?: boolean
metadata?: MetadataPayload
}
export interface ScriptFlowListResponse {
@ -57,3 +72,17 @@ export const TIMEOUT_ACTION_OPTIONS = [
{ value: 'skip', label: '跳过进入下一步' },
{ value: 'transfer', label: '转人工' }
]
export const SCRIPT_MODE_OPTIONS = [
{ value: 'fixed' as const, label: '固定话术', description: '话术内容固定不变' },
{ value: 'flexible' as const, label: '灵活话术', description: 'AI根据意图和上下文生成' },
{ value: 'template' as const, label: '模板话术', description: 'AI填充模板中的变量' }
]
export const PRESET_CONSTRAINTS = [
'必须礼貌',
'语气自然',
'简洁明了',
'不要生硬',
'不要重复'
]

View File

@ -0,0 +1,676 @@
<template>
<div class="decomposition-template-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">拆解模板管理</h1>
<p class="page-desc">管理复杂问题的拆解模板支持版本管理与生效标记[AC-IDSMETA-22]</p>
</div>
<div class="header-actions">
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 130px;">
<el-option
v-for="opt in DECOMPOSITION_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 140px;">
<el-option
v-for="opt in DECOMPOSITION_SCENE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建模板
</el-button>
</div>
</div>
</div>
<el-card shadow="hover" class="template-card" v-loading="loading">
<el-table :data="templates" stripe style="width: 100%">
<el-table-column prop="name" label="模板名称" min-width="180">
<template #default="{ row }">
<div class="template-name">
<span class="name-text">{{ row.name }}</span>
<el-tag v-if="row.is_latest_effective" type="success" size="small" class="effective-tag">
最近生效
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="scene" label="场景" width="120">
<template #default="{ row }">
<el-tag size="small">{{ getSceneLabel(row.scene) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="current_version" label="版本" width="100">
<template #default="{ row }">
<span class="version-badge">v{{ row.current_version }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" size="small">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="effective_at" label="生效时间" width="180">
<template #default="{ row }">
<span v-if="row.effective_at">{{ formatDate(row.effective_at) }}</span>
<span v-else class="no-date">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
v-if="row.status === 'draft'"
type="success"
link
size="small"
@click="handleActivate(row)"
>
<el-icon><Check /></el-icon>
生效
</el-button>
<el-button
v-if="row.status === 'active'"
type="warning"
link
size="small"
@click="handleArchive(row)"
>
<el-icon><FolderOpened /></el-icon>
归档
</el-button>
<el-button type="info" link size="small" @click="handleViewDetail(row)">
<el-icon><View /></el-icon>
详情
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑模板' : '新建模板'"
width="850px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="模板名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入模板名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="场景" prop="scene">
<el-select v-model="formData.scene" placeholder="请选择场景" style="width: 100%;">
<el-option
v-for="opt in DECOMPOSITION_SCENE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述">
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
</el-form-item>
<el-divider content-position="left">拆解步骤</el-divider>
<div class="steps-editor">
<div
v-for="(step, index) in formData.steps"
:key="step.step_id"
class="step-item"
>
<div class="step-header">
<span class="step-order">步骤 {{ index + 1 }}</span>
<el-button type="danger" link size="small" @click="removeStep(index)">
删除
</el-button>
</div>
<el-form-item label="指令" required>
<el-input
v-model="step.instruction"
type="textarea"
:rows="2"
placeholder="请输入该步骤的处理指令"
/>
</el-form-item>
<el-form-item label="期望输出">
<el-input
v-model="step.expected_output"
placeholder="可选:描述该步骤期望的输出格式"
/>
</el-form-item>
</div>
<el-button type="primary" plain @click="addStep" style="width: 100%; margin-top: 12px;">
<el-icon><Plus /></el-icon>
添加步骤
</el-button>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
<el-drawer v-model="detailDrawer" title="模板详情" size="600px" destroy-on-close>
<div v-if="currentTemplate" class="detail-content">
<div class="detail-header">
<h3>{{ currentTemplate.name }}</h3>
<div class="detail-tags">
<el-tag :type="getStatusTagType(currentTemplate.status)" size="small">
{{ getStatusLabel(currentTemplate.status) }}
</el-tag>
<el-tag v-if="currentTemplate.is_latest_effective" type="success" size="small">
最近生效
</el-tag>
</div>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="场景">{{ getSceneLabel(currentTemplate.scene) }}</el-descriptions-item>
<el-descriptions-item label="当前版本">v{{ currentTemplate.current_version }}</el-descriptions-item>
<el-descriptions-item label="生效时间">
{{ currentTemplate.effective_at ? formatDate(currentTemplate.effective_at) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentTemplate.created_at) }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ currentTemplate.description || '-' }}
</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">版本历史</el-divider>
<el-timeline>
<el-timeline-item
v-for="version in currentTemplate.versions"
:key="version.version"
:type="version.status === 'active' ? 'success' : 'info'"
:timestamp="formatDate(version.created_at)"
placement="top"
>
<div class="version-item">
<div class="version-header">
<span class="version-number">v{{ version.version }}</span>
<el-tag :type="getStatusTagType(version.status)" size="small">
{{ getStatusLabel(version.status) }}
</el-tag>
</div>
<div class="version-steps">
<div v-for="(step, idx) in version.steps" :key="step.step_id" class="version-step">
<span class="step-no">{{ idx + 1 }}.</span>
<span class="step-instruction">{{ step.instruction }}</span>
</div>
</div>
<el-button
v-if="version.status === 'archived'"
type="primary"
link
size="small"
@click="handleRollback(version.version)"
>
回滚到此版本
</el-button>
</div>
</el-timeline-item>
</el-timeline>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Check, FolderOpened, View } from '@element-plus/icons-vue'
import { decompositionTemplateApi } from '@/api/decomposition-template'
import { DECOMPOSITION_STATUS_OPTIONS, DECOMPOSITION_SCENE_OPTIONS } from '@/types/decomposition-template'
import type {
DecompositionTemplate,
DecompositionTemplateDetail,
DecompositionTemplateCreate,
DecompositionTemplateUpdate,
DecompositionStatus
} from '@/types/decomposition-template'
const loading = ref(false)
const templates = ref<DecompositionTemplate[]>([])
const filterStatus = ref('')
const filterScene = ref('')
const dialogVisible = ref(false)
const detailDrawer = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref()
const currentTemplate = ref<DecompositionTemplateDetail | null>(null)
const currentEditId = ref('')
const generateStepId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const formData = ref<DecompositionTemplateCreate>({
name: '',
scene: '',
description: '',
steps: [],
metadata: {}
})
const formRules = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
scene: [{ required: true, message: '请选择场景', trigger: 'change' }]
}
const getSceneLabel = (scene: string) => {
const opt = DECOMPOSITION_SCENE_OPTIONS.find(o => o.value === scene)
return opt?.label || scene
}
const getStatusLabel = (status: DecompositionStatus) => {
const opt = DECOMPOSITION_STATUS_OPTIONS.find(o => o.value === status)
return opt?.label || status
}
const getStatusTagType = (status: DecompositionStatus): '' | 'success' | 'warning' | 'danger' | 'info' => {
const typeMap: Record<DecompositionStatus, '' | 'success' | 'warning' | 'danger' | 'info'> = {
draft: 'info',
active: 'success',
archived: 'warning'
}
return typeMap[status] || 'info'
}
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const loadTemplates = async () => {
loading.value = true
try {
const res = await decompositionTemplateApi.list({
status: filterStatus.value || undefined,
scene: filterScene.value || undefined
})
templates.value = res.data || []
} catch (error) {
ElMessage.error('加载模板列表失败')
} finally {
loading.value = false
}
}
const handleCreate = () => {
isEdit.value = false
currentEditId.value = ''
formData.value = {
name: '',
scene: '',
description: '',
steps: [],
metadata: {}
}
dialogVisible.value = true
}
const handleEdit = async (row: DecompositionTemplate) => {
isEdit.value = true
currentEditId.value = row.id
try {
const detail = await decompositionTemplateApi.get(row.id)
formData.value = {
name: detail.name,
scene: detail.scene,
description: detail.description || '',
steps: detail.steps || [],
metadata: detail.metadata || {}
}
dialogVisible.value = true
} catch (error) {
ElMessage.error('加载模板详情失败')
}
}
const handleDelete = async (row: DecompositionTemplate) => {
try {
await ElMessageBox.confirm('确定要删除该模板吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await decompositionTemplateApi.delete(row.id)
ElMessage.success('删除成功')
loadTemplates()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleActivate = async (row: DecompositionTemplate) => {
try {
await ElMessageBox.confirm('确定要将该模板设为生效状态吗?', '确认生效', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
await decompositionTemplateApi.activate(row.id)
ElMessage.success('已生效')
loadTemplates()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
}
}
const handleArchive = async (row: DecompositionTemplate) => {
try {
await ElMessageBox.confirm('确定要归档该模板吗?', '确认归档', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await decompositionTemplateApi.archive(row.id)
ElMessage.success('已归档')
loadTemplates()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
}
}
const handleViewDetail = async (row: DecompositionTemplate) => {
try {
currentTemplate.value = await decompositionTemplateApi.get(row.id)
detailDrawer.value = true
} catch (error) {
ElMessage.error('加载模板详情失败')
}
}
const handleRollback = async (version: number) => {
if (!currentTemplate.value) return
try {
await ElMessageBox.confirm(`确定要回滚到版本 v${version} 吗?`, '确认回滚', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await decompositionTemplateApi.rollback(currentTemplate.value.id, version)
ElMessage.success('回滚成功')
currentTemplate.value = await decompositionTemplateApi.get(currentTemplate.value.id)
loadTemplates()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('回滚失败')
}
}
}
const addStep = () => {
formData.value.steps.push({
step_id: generateStepId(),
step_no: formData.value.steps.length + 1,
instruction: '',
expected_output: '',
dependencies: []
})
}
const removeStep = (index: number) => {
formData.value.steps.splice(index, 1)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
} catch {
return
}
if (formData.value.steps.length === 0) {
ElMessage.warning('请至少添加一个步骤')
return
}
for (let i = 0; i < formData.value.steps.length; i++) {
if (!formData.value.steps[i].instruction?.trim()) {
ElMessage.warning(`步骤 ${i + 1} 的指令不能为空`)
return
}
}
submitting.value = true
try {
const submitData = {
...formData.value,
steps: formData.value.steps.map((step, index) => ({
...step,
step_no: index + 1
}))
}
if (isEdit.value) {
const updateData: DecompositionTemplateUpdate = submitData
await decompositionTemplateApi.update(currentEditId.value, updateData)
ElMessage.success('保存成功')
} else {
await decompositionTemplateApi.create(submitData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadTemplates()
} catch (error) {
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
} finally {
submitting.value = false
}
}
watch([filterStatus, filterScene], () => {
loadTemplates()
})
onMounted(() => {
loadTemplates()
})
</script>
<style scoped>
.decomposition-template-page {
padding: 24px;
}
.page-header {
margin-bottom: 24px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.title-section {
flex: 1;
min-width: 300px;
}
.page-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.page-desc {
margin: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.template-card {
border-radius: 8px;
}
.template-name {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
font-weight: 500;
}
.effective-tag {
font-size: 10px;
}
.version-badge {
display: inline-block;
padding: 2px 8px;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.no-date {
color: var(--el-text-color-placeholder);
}
.steps-editor {
max-height: 350px;
overflow-y: auto;
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
}
.step-item {
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.step-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.step-order {
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-content {
padding: 0 16px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.detail-header h3 {
margin: 0;
font-size: 18px;
}
.detail-tags {
display: flex;
gap: 8px;
}
.version-item {
padding: 8px 0;
}
.version-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.version-number {
font-weight: 600;
color: var(--el-text-color-primary);
}
.version-steps {
margin-bottom: 8px;
padding-left: 12px;
}
.version-step {
font-size: 13px;
color: var(--el-text-color-regular);
margin-bottom: 4px;
}
.step-no {
color: var(--el-text-color-secondary);
margin-right: 4px;
}
.step-instruction {
word-break: break-all;
}
</style>

View File

@ -4,7 +4,7 @@
<div class="header-content">
<div class="title-section">
<h1 class="page-title">意图规则管理</h1>
<p class="page-desc">配置意图识别规则让特定问题走固定回复或话术流程</p>
<p class="page-desc">配置意图识别规则让特定问题走固定回复或话术流程[AC-IDSMETA-16]</p>
</div>
<div class="header-actions">
<el-select v-model="filterResponseType" placeholder="按响应类型筛选" clearable style="width: 150px;">
@ -48,6 +48,14 @@
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80" sortable />
<el-table-column prop="hit_count" label="命中次数" width="100" sortable />
<el-table-column label="元数据" width="120">
<template #default="{ row }">
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
{{ Object.keys(row.metadata).length }} 个字段
</el-tag>
<span v-else class="no-metadata">-</span>
</template>
</el-table-column>
<el-table-column prop="is_enabled" label="状态" width="80">
<template #default="{ row }">
<el-switch
@ -79,7 +87,7 @@
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑规则' : '新建规则'"
width="700px"
width="800px"
:close-on-click-modal="false"
destroy-on-close
>
@ -168,6 +176,16 @@
</el-form-item>
</div>
</transition>
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
<MetadataForm
ref="metadataFormRef"
scope="intent_rule"
v-model="formData.metadata"
:is-new-object="!isEdit"
:col-span="12"
/>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@ -198,10 +216,12 @@ import {
} from '@/api/intent-rule'
import { listKnowledgeBases } from '@/api/knowledge-base'
import { listScriptFlows } from '@/api/script-flow'
import { MetadataForm } from '@/components/metadata'
import { RESPONSE_TYPE_OPTIONS, RESPONSE_TYPE_MAP } from '@/types/intent-rule'
import type { IntentRule, IntentRuleCreate, IntentRuleUpdate } from '@/types/intent-rule'
import type { KnowledgeBase } from '@/types/knowledge-base'
import type { ScriptFlow } from '@/types/script-flow'
import type { MetadataPayload } from '@/types/metadata'
import KeywordInput from './components/KeywordInput.vue'
import PatternInput from './components/PatternInput.vue'
import TestDialog from './components/TestDialog.vue'
@ -215,6 +235,7 @@ const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref()
const metadataFormRef = ref()
const currentEditId = ref('')
const testDialogVisible = ref(false)
const testRuleId = ref('')
@ -231,7 +252,8 @@ const defaultFormData = (): IntentRuleCreate => ({
target_kb_ids: [],
flow_id: '',
transfer_message: '',
is_enabled: true
is_enabled: true,
metadata: {}
})
const formData = ref<IntentRuleCreate>(defaultFormData())
@ -308,7 +330,8 @@ const handleEdit = (row: IntentRule) => {
target_kb_ids: row.target_kb_ids || [],
flow_id: row.flow_id || '',
transfer_message: row.transfer_message || '',
is_enabled: row.is_enabled
is_enabled: row.is_enabled,
metadata: row.metadata || {}
}
dialogVisible.value = true
}
@ -361,6 +384,14 @@ const handleSubmit = async () => {
return
}
if (metadataFormRef.value) {
const validation = await metadataFormRef.value.validate()
if (!validation.valid) {
ElMessage.warning('请完善必填的元数据字段')
return
}
}
submitting.value = true
try {
if (isEdit.value) {
@ -370,7 +401,8 @@ const handleSubmit = async () => {
patterns: formData.value.patterns,
response_type: formData.value.response_type,
priority: formData.value.priority,
is_enabled: formData.value.is_enabled
is_enabled: formData.value.is_enabled,
metadata: formData.value.metadata
}
if (formData.value.response_type === 'fixed') {
updateData.fixed_reply = formData.value.fixed_reply
@ -464,6 +496,10 @@ onMounted(() => {
margin-left: 4px;
}
.no-metadata {
color: var(--el-text-color-placeholder);
}
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}

View File

@ -1,22 +1,17 @@
<template>
<div class="document-list">
<div class="list-header">
<el-upload
ref="uploadRef"
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ kb_id: kbId }"
:show-file-list="false"
:before-upload="beforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
<el-button type="primary" @click="handleUploadClick">
<el-icon><Upload /></el-icon>
上传文档
</el-button>
<input
ref="fileInputRef"
type="file"
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button type="primary">
<el-icon><Upload /></el-icon>
上传文档
</el-button>
</el-upload>
style="display: none"
@change="handleFileSelect"
/>
</div>
<el-table :data="documents" v-loading="loading" stripe>
@ -28,13 +23,36 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="元数据" min-width="200">
<template #default="{ row }">
<div class="metadata-preview" v-if="row.metadata && Object.keys(row.metadata).length > 0">
<el-tag
v-for="(value, key) in getPreviewMetadata(row.metadata)"
:key="key"
size="small"
type="info"
class="metadata-tag"
>
{{ key }}: {{ formatMetadataValue(value) }}
</el-tag>
<el-tag v-if="Object.keys(row.metadata).length > 3" size="small" type="info">
+{{ Object.keys(row.metadata).length - 3 }}
</el-tag>
</div>
<span v-else class="no-metadata">-</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="上传时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEditMetadata(row)">
<el-icon><Edit /></el-icon>
编辑元数据
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
@ -51,17 +69,82 @@
@current-change="loadDocuments"
/>
</div>
<el-dialog
v-model="uploadDialogVisible"
title="上传文档"
width="700px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form :model="uploadForm" :rules="uploadRules" ref="uploadFormRef" label-width="80px">
<el-form-item label="文件">
<div class="file-info" v-if="selectedFile">
<el-icon><Document /></el-icon>
<span>{{ selectedFile.name }}</span>
<el-tag size="small" type="info">{{ formatFileSize(selectedFile.size) }}</el-tag>
</div>
<el-button v-else @click="handleUploadClick">选择文件</el-button>
</el-form-item>
<el-divider content-position="left">元数据配置 [AC-IDSMETA-15]</el-divider>
<MetadataForm
ref="metadataFormRef"
scope="kb_document"
v-model="uploadForm.metadata"
:is-new-object="true"
:col-span="12"
/>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="handleUpload">
上传
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="editDialogVisible"
title="编辑元数据"
width="600px"
:close-on-click-modal="false"
destroy-on-close
>
<MetadataForm
ref="editMetadataFormRef"
scope="kb_document"
v-model="editForm.metadata"
:is-new-object="false"
:show-deprecated="true"
:col-span="12"
/>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSaveMetadata">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import { Upload, Edit, Document } from '@element-plus/icons-vue'
import { listDocuments, deleteDocument, getIndexJob } from '@/api/knowledge-base'
import type { Document, IndexJob } from '@/types/knowledge-base'
import { metadataSchemaApi } from '@/api/metadata-schema'
import { MetadataForm } from '@/components/metadata'
import type { Document as DocType, IndexJob } from '@/types/knowledge-base'
import type { MetadataPayload } from '@/types/metadata'
import { useTenantStore } from '@/stores/tenant'
interface DocumentWithMetadata extends DocType {
metadata?: MetadataPayload
}
const props = defineProps<{
kbId: string
}>()
@ -72,7 +155,7 @@ const emit = defineEmits<{
const tenantStore = useTenantStore()
const loading = ref(false)
const documents = ref<Document[]>([])
const documents = ref<DocumentWithMetadata[]>([])
const currentPage = ref(1)
const pagination = ref({
page: 1,
@ -81,14 +164,28 @@ const pagination = ref({
totalPages: 0
})
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}/admin/kb/documents`
const fileInputRef = ref<HTMLInputElement>()
const uploadDialogVisible = ref(false)
const editDialogVisible = ref(false)
const uploading = ref(false)
const saving = ref(false)
const selectedFile = ref<File | null>(null)
const uploadFormRef = ref()
const metadataFormRef = ref()
const editMetadataFormRef = ref()
const currentEditDoc = ref<DocumentWithMetadata | null>(null)
const uploadForm = ref({
metadata: {} as MetadataPayload
})
const uploadHeaders = computed(() => ({
'X-Tenant-Id': tenantStore.currentTenantId
}))
const editForm = ref({
metadata: {} as MetadataPayload
})
const uploadRules = {
file: [{ required: true, message: '请选择文件', trigger: 'change' }]
}
const getStatusType = (status: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
@ -122,6 +219,27 @@ const formatDate = (dateStr: string) => {
})
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const getPreviewMetadata = (metadata: MetadataPayload) => {
const keys = Object.keys(metadata).slice(0, 3)
const result: MetadataPayload = {}
keys.forEach(key => {
result[key] = metadata[key]
})
return result
}
const formatMetadataValue = (value: string | number | boolean | string[] | undefined): string => {
if (value === undefined || value === null) return ''
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
const loadDocuments = async () => {
loading.value = true
try {
@ -139,46 +257,128 @@ const loadDocuments = async () => {
}
}
const beforeUpload = (file: File) => {
const allowedTypes = [
'text/plain',
'text/markdown',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]
const handleUploadClick = () => {
fileInputRef.value?.click()
}
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const allowedExtensions = ['.txt', '.md', '.pdf', '.doc', '.docx', '.xls', '.xlsx']
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!allowedExtensions.includes(ext)) {
ElMessage.error('不支持的文件格式')
return false
return
}
const maxSize = 50 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 50MB')
return false
return
}
return true
}
selectedFile.value = file
uploadForm.value.metadata = {}
uploadDialogVisible.value = true
const handleUploadSuccess = (response: any) => {
if (response.jobId) {
ElMessage.success('文档上传成功,正在处理中...')
emit('upload-success')
loadDocuments()
pollJobStatus(response.jobId)
} else {
ElMessage.error('上传失败')
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleUploadError = () => {
ElMessage.error('上传失败,请重试')
const handleUpload = async () => {
if (!selectedFile.value) {
ElMessage.warning('请选择文件')
return
}
if (metadataFormRef.value) {
const validation = await metadataFormRef.value.validate()
if (!validation.valid) {
ElMessage.warning('请完善必填的元数据字段')
return
}
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('kb_id', props.kbId)
formData.append('metadata', JSON.stringify(uploadForm.value.metadata))
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
const response = await fetch(`${baseUrl}/admin/kb/documents`, {
method: 'POST',
headers: {
'X-Tenant-Id': tenantStore.currentTenantId
},
body: formData
})
const result = await response.json()
if (result.jobId) {
ElMessage.success('文档上传成功,正在处理中...')
emit('upload-success')
loadDocuments()
pollJobStatus(result.jobId)
} else {
ElMessage.error(result.message || '上传失败')
}
} catch (error) {
ElMessage.error('上传失败,请重试')
} finally {
uploading.value = false
uploadDialogVisible.value = false
selectedFile.value = null
}
}
const handleEditMetadata = (doc: DocumentWithMetadata) => {
currentEditDoc.value = doc
editForm.value.metadata = doc.metadata || {}
editDialogVisible.value = true
}
const handleSaveMetadata = async () => {
if (!currentEditDoc.value) return
if (editMetadataFormRef.value) {
const validation = await editMetadataFormRef.value.validate()
if (!validation.valid) {
ElMessage.warning('请完善必填的元数据字段')
return
}
}
saving.value = true
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
const response = await fetch(`${baseUrl}/admin/kb/documents/${currentEditDoc.value.docId}/metadata`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Id': tenantStore.currentTenantId
},
body: JSON.stringify({ metadata: editForm.value.metadata })
})
if (response.ok) {
ElMessage.success('元数据更新成功')
loadDocuments()
editDialogVisible.value = false
} else {
const result = await response.json()
ElMessage.error(result.message || '更新失败')
}
} catch (error) {
ElMessage.error('更新失败,请重试')
} finally {
saving.value = false
}
}
const pollJobStatus = async (jobId: string) => {
@ -208,7 +408,7 @@ const pollJobStatus = async (jobId: string) => {
setTimeout(poll, 2000)
}
const handleDelete = async (row: Document) => {
const handleDelete = async (row: DocumentWithMetadata) => {
try {
await ElMessageBox.confirm('确定要删除该文档吗?', '确认删除', {
confirmButtonText: '确定',
@ -245,4 +445,27 @@ onMounted(() => {
display: flex;
justify-content: flex-end;
}
.metadata-preview {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.metadata-tag {
margin: 0;
}
.no-metadata {
color: var(--el-text-color-placeholder);
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,587 @@
<template>
<div class="metadata-schema-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">元数据字段配置</h1>
<p class="page-desc">配置知识库意图规则话术流程Prompt模板的动态元数据字段[AC-IDSMETA-13]</p>
</div>
<div class="header-actions">
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 140px;">
<el-option
v-for="opt in METADATA_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建字段
</el-button>
</div>
</div>
</div>
<el-card shadow="hover" class="schema-card" v-loading="loading">
<el-table :data="fields" stripe style="width: 100%">
<el-table-column prop="field_key" label="字段标识" min-width="120">
<template #default="{ row }">
<code class="field-key">{{ row.field_key }}</code>
</template>
</el-table-column>
<el-table-column prop="label" label="显示名称" width="120" />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" type="info">{{ getTypeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="required" label="必填" width="80">
<template #default="{ row }">
<el-tag :type="row.required ? 'danger' : 'info'" size="small">
{{ row.required ? '必填' : '可选' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="scope" label="适用范围" min-width="180">
<template #default="{ row }">
<div class="scope-tags">
<el-tag
v-for="s in row.scope"
:key="s"
size="small"
class="scope-tag"
>
{{ getScopeLabel(s) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_TAG_MAP[row.status as MetadataFieldStatus]" size="small">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-dropdown trigger="click" @command="(cmd: MetadataFieldStatus) => handleStatusChange(row, cmd)">
<el-button type="warning" link size="small">
<el-icon><Switch /></el-icon>
状态
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="opt in METADATA_STATUS_OPTIONS"
:key="opt.value"
:command="opt.value"
:disabled="opt.value === row.status"
>
<el-tag :type="STATUS_TAG_MAP[opt.value as MetadataFieldStatus]" size="small">{{ opt.label }}</el-tag>
<span class="status-desc">{{ opt.description }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && fields.length === 0" description="暂无元数据字段" />
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑元数据字段' : '新建元数据字段'"
width="700px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="字段标识" prop="field_key">
<el-input
v-model="formData.field_key"
placeholder="如grade, subject"
:disabled="isEdit"
/>
<div class="field-hint">仅允许小写字母数字下划线</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="显示名称" prop="label">
<el-input v-model="formData.label" placeholder="如:年级" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="字段类型" prop="type">
<el-select v-model="formData.type" style="width: 100%;" @change="onTypeChange">
<el-option
v-for="opt in METADATA_TYPE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" style="width: 100%;">
<el-option
v-for="opt in METADATA_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
>
<div class="status-option">
<el-tag :type="STATUS_TAG_MAP[opt.value as MetadataFieldStatus]" size="small">{{ opt.label }}</el-tag>
<span class="status-option-desc">{{ opt.description }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="适用范围" prop="scope">
<el-checkbox-group v-model="formData.scope">
<el-checkbox
v-for="opt in METADATA_SCOPE_OPTIONS"
:key="opt.value"
:label="opt.value"
>
{{ opt.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="是否必填">
<el-switch v-model="formData.required" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="可过滤">
<el-switch v-model="formData.is_filterable" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="排序特征">
<el-switch v-model="formData.is_rank_feature" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="默认值">
<el-input v-model="formData.default_value" placeholder="可选默认值" />
</el-form-item>
<el-form-item
v-if="formData.type === 'enum' || formData.type === 'array_enum'"
label="选项列表"
prop="options"
>
<div class="options-container">
<el-tag
v-for="(opt, idx) in formData.options"
:key="idx"
closable
@close="removeOption(idx)"
class="option-tag"
>
{{ opt }}
</el-tag>
<el-input
v-model="newOption"
placeholder="输入后回车添加"
size="small"
class="option-input"
@keyup.enter="addOption"
/>
</div>
</el-form-item>
<el-divider content-position="left" v-if="isEdit && formData.status === 'deprecated'">
<el-tag type="danger">废弃影响范围</el-tag>
</el-divider>
<div v-if="isEdit && formData.status === 'deprecated'" class="deprecated-impact">
<el-alert type="warning" :closable="false" show-icon>
<template #title>
将此字段设为废弃后以下影响将生效 [AC-IDSMETA-14]
</template>
</el-alert>
<ul class="impact-list">
<li>新建对象时此字段将不再显示</li>
<li>已有对象的历史数据仍可查看但不可编辑</li>
<li>作为过滤条件时仅对历史数据生效</li>
</ul>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Switch, ArrowDown } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { metadataSchemaApi } from '@/api/metadata-schema'
import {
METADATA_STATUS_OPTIONS,
METADATA_SCOPE_OPTIONS,
METADATA_TYPE_OPTIONS,
STATUS_TAG_MAP,
type MetadataFieldDefinition,
type MetadataFieldCreateRequest,
type MetadataFieldUpdateRequest,
type MetadataFieldStatus,
type MetadataScope
} from '@/types/metadata'
const loading = ref(false)
const fields = ref<MetadataFieldDefinition[]>([])
const filterStatus = ref<MetadataFieldStatus | ''>('')
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
const newOption = ref('')
const formData = reactive({
id: '',
field_key: '',
label: '',
type: 'string' as 'string' | 'number' | 'boolean' | 'enum' | 'array_enum',
required: false,
options: [] as string[],
default_value: '' as string | number | boolean,
scope: [] as MetadataScope[],
is_filterable: true,
is_rank_feature: false,
status: 'draft' as MetadataFieldStatus
})
const formRules: FormRules = {
field_key: [
{ required: true, message: '请输入字段标识', trigger: 'blur' },
{ pattern: /^[a-z0-9_]+$/, message: '仅允许小写字母、数字、下划线', trigger: 'blur' }
],
label: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
scope: [{ type: 'array', min: 1, message: '请至少选择一个适用范围', trigger: 'change' }],
options: [
{
validator: (_rule, value, callback) => {
if ((formData.type === 'enum' || formData.type === 'array_enum') && (!value || value.length === 0)) {
callback(new Error('枚举类型必须至少有一个选项'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
const getTypeLabel = (type: string) => {
return METADATA_TYPE_OPTIONS.find(o => o.value === type)?.label || type
}
const getStatusLabel = (status: MetadataFieldStatus) => {
return METADATA_STATUS_OPTIONS.find(o => o.value === status)?.label || status
}
const getScopeLabel = (scope: MetadataScope) => {
return METADATA_SCOPE_OPTIONS.find(o => o.value === scope)?.label || scope
}
const fetchFields = async () => {
loading.value = true
try {
const res = await metadataSchemaApi.list(filterStatus.value || undefined)
fields.value = res.items || []
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取元数据字段失败')
} finally {
loading.value = false
}
}
const handleCreate = () => {
isEdit.value = false
Object.assign(formData, {
id: '',
field_key: '',
label: '',
type: 'string',
required: false,
options: [],
default_value: '',
scope: [],
is_filterable: true,
is_rank_feature: false,
status: 'draft'
})
dialogVisible.value = true
}
const handleEdit = (field: MetadataFieldDefinition) => {
isEdit.value = true
Object.assign(formData, {
id: field.id,
field_key: field.field_key,
label: field.label,
type: field.type,
required: field.required,
options: field.options || [],
default_value: field.default ?? '',
scope: [...field.scope],
is_filterable: field.is_filterable,
is_rank_feature: field.is_rank_feature,
status: field.status
})
dialogVisible.value = true
}
const handleDelete = async (field: MetadataFieldDefinition) => {
try {
await ElMessageBox.confirm(
`确定要删除字段「${field.label}(${field.field_key})」吗?`,
'删除确认',
{ type: 'warning' }
)
await metadataSchemaApi.delete(field.id)
ElMessage.success('删除成功')
fetchFields()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}
}
const handleStatusChange = async (field: MetadataFieldDefinition, newStatus: MetadataFieldStatus) => {
if (newStatus === field.status) return
const statusDesc = METADATA_STATUS_OPTIONS.find(o => o.value === newStatus)?.description
try {
await ElMessageBox.confirm(
`确定要将字段「${field.label}」状态改为「${getStatusLabel(newStatus)}」吗?\n\n${statusDesc}`,
'状态变更确认',
{ type: 'warning' }
)
await metadataSchemaApi.update(field.id, { status: newStatus })
ElMessage.success('状态更新成功')
fetchFields()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.message || '状态更新失败')
}
}
}
const onTypeChange = () => {
if (formData.type !== 'enum' && formData.type !== 'array_enum') {
formData.options = []
}
}
const addOption = () => {
const value = newOption.value.trim()
if (value && !formData.options.includes(value)) {
formData.options.push(value)
newOption.value = ''
}
}
const removeOption = (index: number) => {
formData.options.splice(index, 1)
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const data: MetadataFieldCreateRequest | MetadataFieldUpdateRequest = {
field_key: formData.field_key,
label: formData.label,
type: formData.type,
required: formData.required,
scope: formData.scope,
is_filterable: formData.is_filterable,
is_rank_feature: formData.is_rank_feature,
status: formData.status
}
if (formData.type === 'enum' || formData.type === 'array_enum') {
data.options = formData.options
}
if (formData.default_value !== '') {
data.default = formData.default_value
}
if (isEdit.value) {
await metadataSchemaApi.update(formData.id, data as MetadataFieldUpdateRequest)
ElMessage.success('更新成功')
} else {
await metadataSchemaApi.create(data as MetadataFieldCreateRequest)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchFields()
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '操作失败')
} finally {
submitting.value = false
}
})
}
watch(filterStatus, () => {
fetchFields()
})
onMounted(() => {
fetchFields()
})
</script>
<style scoped lang="scss">
.metadata-schema-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-section {
.page-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
}
.page-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.schema-card {
border-radius: 8px;
}
.field-key {
padding: 2px 6px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
.scope-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.scope-tag {
margin: 0;
}
.field-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.status-desc {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.status-option {
display: flex;
align-items: center;
}
.status-option-desc {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.options-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
.option-tag {
margin: 0;
}
.option-input {
width: 150px;
}
}
.deprecated-impact {
margin-top: 16px;
padding: 12px;
background-color: var(--el-color-warning-light-9);
border-radius: 6px;
}
.impact-list {
margin: 12px 0 0 0;
padding-left: 20px;
color: var(--el-text-color-regular);
font-size: 13px;
li {
margin-bottom: 4px;
}
}
</style>

View File

@ -248,7 +248,6 @@ import {
type ConversationDetail,
type ExportTaskResponse
} from '@/api/monitoring'
import request from '@/utils/request'
const loading = ref(false)
const tableData = ref<ConversationItem[]>([])
@ -433,7 +432,8 @@ const downloadExport = () => {
if (exportTask.value) {
const url = getExportDownloadUrl(exportTask.value.taskId)
const link = document.createElement('a')
link.href = request.defaults.baseURL + url
const baseURL = import.meta.env.VITE_APP_BASE_API || '/api'
link.href = baseURL + url
link.download = exportTask.value.fileName || 'export.json'
document.body.appendChild(link)
link.click()

View File

@ -4,7 +4,7 @@
<div class="header-content">
<div class="title-section">
<h1 class="page-title">Prompt 模板管理</h1>
<p class="page-desc">管理不同场景的 Prompt 模板支持版本管理和一键回滚</p>
<p class="page-desc">管理不同场景的 Prompt 模板支持版本管理和一键回滚[AC-IDSMETA-16]</p>
</div>
<div class="header-actions">
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 150px;">
@ -43,6 +43,14 @@
<span v-else class="no-version">未发布</span>
</template>
</el-table-column>
<el-table-column label="元数据" width="120">
<template #default="{ row }">
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
{{ Object.keys(row.metadata).length }} 个字段
</el-tag>
<span v-else class="no-metadata">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
@ -78,7 +86,7 @@
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑模板' : '新建模板'"
width="900px"
width="950px"
:close-on-click-modal="false"
destroy-on-close
>
@ -100,6 +108,19 @@
<el-form-item label="描述">
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
</el-form-item>
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
<MetadataForm
ref="metadataFormRef"
scope="prompt_template"
v-model="formData.metadata"
:is-new-object="!isEdit"
:col-span="8"
/>
<el-divider content-position="left">系统指令</el-divider>
<el-form-item label="系统指令" prop="system_instruction">
<div class="content-editor">
<div class="editor-main">
@ -183,6 +204,7 @@ import {
publishPromptTemplate,
rollbackPromptTemplate
} from '@/api/prompt-template'
import { MetadataForm } from '@/components/metadata'
import { SCENE_OPTIONS, BUILTIN_VARIABLES } from '@/types/prompt-template'
import type { PromptTemplate, PromptTemplateDetail, PromptTemplateCreate, PromptTemplateUpdate, PromptVariable } from '@/types/prompt-template'
import TemplateDetail from './components/TemplateDetail.vue'
@ -197,6 +219,7 @@ const detailDrawer = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref()
const metadataFormRef = ref()
const currentTemplate = ref<PromptTemplateDetail | null>(null)
const currentEditId = ref('')
const previewDialogVisible = ref(false)
@ -208,7 +231,8 @@ const formData = ref<PromptTemplateCreate>({
scene: '',
description: '',
system_instruction: '',
variables: []
variables: [],
metadata: {}
})
const formRules = {
@ -270,7 +294,8 @@ const handleCreate = () => {
scene: '',
description: '',
system_instruction: '',
variables: []
variables: [],
metadata: {}
}
dialogVisible.value = true
}
@ -285,7 +310,8 @@ const handleEdit = async (row: PromptTemplate) => {
scene: detail.scene,
description: detail.description || '',
system_instruction: detail.current_content || '',
variables: detail.variables || []
variables: detail.variables || [],
metadata: detail.metadata || {}
}
dialogVisible.value = true
} catch (error) {
@ -358,6 +384,14 @@ const handleSubmit = async () => {
return
}
if (metadataFormRef.value) {
const validation = await metadataFormRef.value.validate()
if (!validation.valid) {
ElMessage.warning('请完善必填的元数据字段')
return
}
}
submitting.value = true
try {
if (isEdit.value) {
@ -366,7 +400,8 @@ const handleSubmit = async () => {
scene: formData.value.scene,
description: formData.value.description,
system_instruction: formData.value.system_instruction,
variables: formData.value.variables
variables: formData.value.variables,
metadata: formData.value.metadata
}
await updatePromptTemplate(currentEditId.value, updateData)
ElMessage.success('保存成功')
@ -509,6 +544,10 @@ onMounted(() => {
font-size: 12px;
}
.no-metadata {
color: var(--el-text-color-placeholder);
}
.content-editor {
display: flex;
gap: 16px;

View File

@ -0,0 +1,142 @@
<template>
<div class="constraint-manager">
<div class="constraint-tags" v-if="modelValue && modelValue.length > 0">
<el-tag
v-for="(constraint, index) in modelValue"
:key="index"
closable
type="info"
@close="removeConstraint(index)"
class="constraint-tag"
:title="constraint"
>
<span class="constraint-text">{{ constraint }}</span>
</el-tag>
</div>
<el-input
v-model="newConstraint"
placeholder="输入约束条件后按回车添加"
@keyup.enter="addConstraint"
class="constraint-input"
size="small"
>
<template #append>
<el-button @click="addConstraint" :disabled="!newConstraint.trim()">
添加
</el-button>
</template>
</el-input>
<div class="constraint-presets">
<span class="preset-label">常用约束</span>
<el-button
v-for="preset in PRESET_CONSTRAINTS"
:key="preset"
size="small"
round
@click="addPreset(preset)"
:disabled="modelValue?.includes(preset)"
>
{{ preset }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { PRESET_CONSTRAINTS } from '@/types/script-flow'
const props = defineProps<{
modelValue?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const newConstraint = ref('')
const addConstraint = () => {
const value = newConstraint.value.trim()
if (!value) return
const currentConstraints = props.modelValue || []
if (currentConstraints.includes(value)) {
newConstraint.value = ''
return
}
emit('update:modelValue', [...currentConstraints, value])
newConstraint.value = ''
}
const removeConstraint = (index: number) => {
const currentConstraints = props.modelValue || []
const newConstraints = [...currentConstraints]
newConstraints.splice(index, 1)
emit('update:modelValue', newConstraints)
}
const addPreset = (preset: string) => {
const currentConstraints = props.modelValue || []
if (currentConstraints.includes(preset)) return
emit('update:modelValue', [...currentConstraints, preset])
}
</script>
<style scoped>
.constraint-manager {
display: flex;
flex-direction: column;
gap: 12px;
}
.constraint-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.constraint-tag {
max-width: 100%;
display: inline-flex;
align-items: center;
}
.constraint-tag :deep(.el-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.constraint-tag :deep(.el-tag__close) {
flex-shrink: 0;
margin-left: 4px;
}
.constraint-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.constraint-input {
max-width: 400px;
}
.constraint-presets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.preset-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@ -4,7 +4,7 @@
<div class="header-content">
<div class="title-section">
<h1 class="page-title">话术流程管理</h1>
<p class="page-desc">编排多步骤的话术流程引导用户按固定步骤完成信息收集</p>
<p class="page-desc">编排多步骤的话术流程引导用户按固定步骤完成信息收集[AC-IDSMETA-16]</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleCreate">
@ -25,6 +25,14 @@
</el-table-column>
<el-table-column prop="step_count" label="步骤数" width="100" />
<el-table-column prop="linked_rule_count" label="关联规则" width="100" />
<el-table-column label="元数据" width="120">
<template #default="{ row }">
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
{{ Object.keys(row.metadata).length }} 个字段
</el-tag>
<span v-else class="no-metadata">-</span>
</template>
</el-table-column>
<el-table-column prop="is_enabled" label="状态" width="80">
<template #default="{ row }">
<el-switch
@ -65,7 +73,7 @@
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑流程' : '新建流程'"
width="900px"
width="950px"
:close-on-click-modal="false"
destroy-on-close
>
@ -86,6 +94,16 @@
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
</el-form-item>
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
<MetadataForm
ref="metadataFormRef"
scope="script_flow"
v-model="formData.metadata"
:is-new-object="!isEdit"
:col-span="8"
/>
<el-divider content-position="left">步骤配置</el-divider>
<div class="steps-editor">
@ -107,14 +125,107 @@
</el-button>
</div>
<div class="step-content">
<el-form-item label="话术内容">
<el-input
v-model="element.content"
type="textarea"
:rows="3"
placeholder="请输入话术内容,支持 {{variable}} 占位符"
/>
<el-form-item label="话术模式">
<el-radio-group v-model="element.script_mode" size="small">
<el-radio-button
v-for="opt in SCRIPT_MODE_OPTIONS"
:key="opt.value"
:value="opt.value"
>
<el-tooltip :content="opt.description" placement="top">
<span>{{ opt.label }} <el-icon class="mode-help-icon"><QuestionFilled /></el-icon></span>
</el-tooltip>
</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="element.script_mode === 'fixed'">
<el-form-item label="话术内容" required>
<el-input
v-model="element.content"
type="textarea"
:rows="3"
placeholder="请输入固定话术内容"
/>
</el-form-item>
</template>
<template v-if="element.script_mode === 'flexible'">
<el-form-item label="步骤意图" required>
<el-input
v-model="element.intent"
placeholder="例如:获取用户姓名"
/>
</el-form-item>
<el-form-item label="意图说明">
<el-input
v-model="element.intent_description"
type="textarea"
:rows="2"
placeholder="详细描述这一步的目的和期望效果"
/>
</el-form-item>
<el-form-item label="话术约束">
<ConstraintManager v-model="element.script_constraints" />
</el-form-item>
<el-form-item label="Fallback话术" required>
<el-input
v-model="element.content"
type="textarea"
:rows="2"
placeholder="AI生成失败时使用的备用话术"
/>
</el-form-item>
<el-form-item label="期望变量">
<el-select
v-model="element.expected_variables"
multiple
filterable
allow-create
default-first-option
placeholder="输入变量名后回车添加"
style="width: 100%"
/>
</el-form-item>
</template>
<template v-if="element.script_mode === 'template'">
<el-form-item label="话术模板" required>
<el-input
v-model="element.content"
type="textarea"
:rows="3"
placeholder="使用 {变量名} 标记可变部分,例如:您好{user_name},请问您{inquiry_style}"
/>
<div class="template-hint">
提示使用 {变量名} 标记需要AI填充的部分
</div>
</el-form-item>
<el-form-item label="步骤意图">
<el-input
v-model="element.intent"
placeholder="可选:描述模板的使用场景"
/>
</el-form-item>
<el-form-item label="期望变量">
<el-select
v-model="element.expected_variables"
multiple
filterable
allow-create
default-first-option
placeholder="输入变量名后回车添加"
style="width: 100%"
/>
</el-form-item>
</template>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="等待输入">
@ -139,6 +250,78 @@
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left" v-if="element.wait_input">分支跳转</el-divider>
<div v-if="element.wait_input" class="branch-editor">
<div
v-for="(cond, ci) in (element.next_conditions || [])"
:key="ci"
class="branch-item"
>
<el-row :gutter="8" align="middle">
<el-col :span="10">
<el-form-item label="关键词" label-width="60px" style="margin-bottom: 0;">
<el-select
v-model="cond.keywords"
multiple
filterable
allow-create
default-first-option
placeholder="输入关键词回车"
style="width: 100%"
size="small"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="正则" label-width="40px" style="margin-bottom: 0;">
<el-input
v-model="cond.pattern"
placeholder="可选"
size="small"
/>
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="跳转" label-width="40px" style="margin-bottom: 0;">
<el-select v-model="cond.goto_step" placeholder="步骤" size="small" style="width: 100%">
<el-option
v-for="(s, si) in formData.steps"
:key="si"
:label="'步骤 ' + (si + 1)"
:value="si + 1"
:disabled="si === index"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="3">
<el-button type="danger" link size="small" @click="removeBranch(element, ci)">删除</el-button>
</el-col>
</el-row>
</div>
<el-row :gutter="8" align="middle" style="margin-top: 8px;">
<el-col :span="16">
<el-button type="primary" link size="small" @click="addBranch(element)">
<el-icon><Plus /></el-icon>
</el-button>
</el-col>
<el-col :span="8">
<el-form-item label="默认跳转" label-width="70px" style="margin-bottom: 0;">
<el-select v-model="element.default_next" placeholder="顺序" size="small" clearable style="width: 100%">
<el-option
v-for="(s, si) in formData.steps"
:key="si"
:label="'步骤 ' + (si + 1)"
:value="si + 1"
:disabled="si === index"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
@ -172,7 +355,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, View, Rank, VideoPlay } from '@element-plus/icons-vue'
import { Plus, Edit, Delete, View, Rank, VideoPlay, QuestionFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import {
listScriptFlows,
@ -181,10 +364,12 @@ import {
deleteScriptFlow,
getScriptFlow
} from '@/api/script-flow'
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
import { MetadataForm } from '@/components/metadata'
import { TIMEOUT_ACTION_OPTIONS, SCRIPT_MODE_OPTIONS } from '@/types/script-flow'
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep, ScriptMode } from '@/types/script-flow'
import FlowPreview from './components/FlowPreview.vue'
import SimulateDialog from './components/SimulateDialog.vue'
import ConstraintManager from './components/ConstraintManager.vue'
const loading = ref(false)
const flows = ref<ScriptFlow[]>([])
@ -196,6 +381,7 @@ const currentSimulateFlowName = ref('')
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref()
const metadataFormRef = ref()
const currentFlow = ref<ScriptFlowDetail | null>(null)
const currentEditId = ref('')
@ -205,7 +391,8 @@ const defaultFormData = (): ScriptFlowCreate => ({
name: '',
description: '',
steps: [],
is_enabled: true
is_enabled: true,
metadata: {}
})
const formData = ref<ScriptFlowCreate>(defaultFormData())
@ -253,8 +440,14 @@ const handleEdit = async (row: ScriptFlow) => {
formData.value = {
name: detail.name,
description: detail.description || '',
steps: detail.steps || [],
is_enabled: detail.is_enabled
steps: (detail.steps || []).map(step => ({
...step,
script_mode: step.script_mode || 'fixed',
script_constraints: step.script_constraints || [],
expected_variables: step.expected_variables || []
})),
is_enabled: detail.is_enabled,
metadata: detail.metadata || {}
}
dialogVisible.value = true
} catch (error) {
@ -312,17 +505,47 @@ const addStep = () => {
wait_input: true,
timeout_seconds: 30,
timeout_action: 'repeat',
next_conditions: []
next_conditions: [],
script_mode: 'fixed',
script_constraints: [],
expected_variables: []
})
}
const removeStep = (index: number) => {
const removedStepNo = index + 1
formData.value.steps.splice(index, 1)
formData.value.steps.forEach((step, i) => {
step.step_no = i + 1
if (step.next_conditions) {
step.next_conditions = step.next_conditions
.filter(c => c.goto_step !== removedStepNo)
.map(c => ({
...c,
goto_step: c.goto_step > removedStepNo ? c.goto_step - 1 : c.goto_step
}))
}
if (step.default_next !== undefined && step.default_next !== null) {
if (step.default_next === removedStepNo) {
step.default_next = undefined
} else if (step.default_next > removedStepNo) {
step.default_next = step.default_next - 1
}
}
})
}
const addBranch = (step: FlowStep) => {
if (!step.next_conditions) {
step.next_conditions = []
}
step.next_conditions.push({ keywords: [], goto_step: 0 })
}
const removeBranch = (step: FlowStep, index: number) => {
step.next_conditions?.splice(index, 1)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
@ -330,11 +553,45 @@ const handleSubmit = async () => {
return
}
if (metadataFormRef.value) {
const validation = await metadataFormRef.value.validate()
if (!validation.valid) {
ElMessage.warning('请完善必填的元数据字段')
return
}
}
if (formData.value.steps.length === 0) {
ElMessage.warning('请至少添加一个步骤')
return
}
for (let i = 0; i < formData.value.steps.length; i++) {
const step = formData.value.steps[i]
const stepLabel = `步骤 ${i + 1}`
if (step.script_mode === 'fixed' && !step.content?.trim()) {
ElMessage.warning(`${stepLabel}:固定模式需要填写话术内容`)
return
}
if (step.script_mode === 'flexible') {
if (!step.intent?.trim()) {
ElMessage.warning(`${stepLabel}:灵活模式需要填写步骤意图`)
return
}
if (!step.content?.trim()) {
ElMessage.warning(`${stepLabel}灵活模式需要填写Fallback话术`)
return
}
}
if (step.script_mode === 'template' && !step.content?.trim()) {
ElMessage.warning(`${stepLabel}:模板模式需要填写话术模板`)
return
}
}
submitting.value = true
try {
const submitData = {
@ -412,6 +669,10 @@ onMounted(() => {
border-radius: 8px;
}
.no-metadata {
color: var(--el-text-color-placeholder);
}
.steps-editor {
max-height: 400px;
overflow-y: auto;
@ -450,4 +711,32 @@ onMounted(() => {
.step-content {
padding: 16px;
}
.template-hint {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.mode-help-icon {
margin-left: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
vertical-align: middle;
}
.branch-editor {
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
margin-top: 8px;
}
.branch-item {
padding: 8px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
margin-bottom: 8px;
}
</style>

View File

@ -5,12 +5,15 @@ Admin API routes for AI Service management.
from app.api.admin.api_key import router as api_key_router
from app.api.admin.dashboard import router as dashboard_router
from app.api.admin.decomposition_template import router as decomposition_template_router
from app.api.admin.embedding import router as embedding_router
from app.api.admin.flow_test import router as flow_test_router
from app.api.admin.guardrails import router as guardrails_router
from app.api.admin.intent_rules import router as intent_rules_router
from app.api.admin.kb import router as kb_router
from app.api.admin.llm import router as llm_router
from app.api.admin.metadata_field_definition import router as metadata_field_definition_router
from app.api.admin.metadata_schema import router as metadata_schema_router
from app.api.admin.monitoring import router as monitoring_router
from app.api.admin.prompt_templates import router as prompt_templates_router
from app.api.admin.rag import router as rag_router
@ -21,12 +24,15 @@ from app.api.admin.tenants import router as tenants_router
__all__ = [
"api_key_router",
"dashboard_router",
"decomposition_template_router",
"embedding_router",
"flow_test_router",
"guardrails_router",
"intent_rules_router",
"kb_router",
"llm_router",
"metadata_field_definition_router",
"metadata_schema_router",
"monitoring_router",
"prompt_templates_router",
"rag_router",

View File

@ -0,0 +1,360 @@
"""
Metadata Field Definition API.
[AC-IDSMETA-13, AC-IDSMETA-14] 元数据字段定义管理接口支持字段级状态治理
"""
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.core.exceptions import MissingTenantIdException
from app.core.tenant import get_tenant_id
from app.models.entities import (
MetadataFieldDefinition,
MetadataFieldDefinitionCreate,
MetadataFieldDefinitionUpdate,
MetadataFieldStatus,
)
from app.services.metadata_field_definition_service import MetadataFieldDefinitionService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/metadata-schemas", tags=["MetadataSchemas"])
def get_current_tenant_id() -> str:
"""Get current tenant ID from context."""
tenant_id = get_tenant_id()
if not tenant_id:
raise MissingTenantIdException()
return tenant_id
@router.get(
"",
operation_id="listMetadataSchemas",
summary="List metadata schemas",
description="[AC-IDSMETA-13] 获取元数据字段定义列表,支持按状态过滤",
)
async def list_schemas(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
status: Annotated[str | None, Query(
description="按状态过滤: draft/active/deprecated"
)] = None,
) -> JSONResponse:
"""
[AC-IDSMETA-13] 列出元数据字段定义
"""
logger.info(
f"[AC-IDSMETA-13] Listing metadata field definitions: "
f"tenant={tenant_id}, status={status}"
)
if status and status not in [s.value for s in MetadataFieldStatus]:
return JSONResponse(
status_code=400,
content={
"code": "INVALID_STATUS",
"message": f"Invalid status: {status}",
"details": {
"valid_values": [s.value for s in MetadataFieldStatus]
}
}
)
service = MetadataFieldDefinitionService(session)
fields = await service.list_field_definitions(tenant_id, status)
return JSONResponse(
content={
"items": [
{
"id": str(f.id),
"field_key": f.field_key,
"label": f.label,
"type": f.type,
"required": f.required,
"options": f.options,
"default": f.default_value,
"scope": f.scope,
"is_filterable": f.is_filterable,
"is_rank_feature": f.is_rank_feature,
"status": f.status,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in fields
]
}
)
@router.post(
"",
operation_id="createMetadataSchema",
summary="Create metadata schema",
description="[AC-IDSMETA-13] 创建新的元数据字段定义",
status_code=201,
)
async def create_schema(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
field_create: MetadataFieldDefinitionCreate,
) -> JSONResponse:
"""
[AC-IDSMETA-13] 创建元数据字段定义
"""
logger.info(
f"[AC-IDSMETA-13] Creating metadata field definition: "
f"tenant={tenant_id}, field_key={field_create.field_key}"
)
service = MetadataFieldDefinitionService(session)
try:
field = await service.create_field_definition(tenant_id, field_create)
await session.commit()
except ValueError as e:
return JSONResponse(
status_code=400,
content={
"code": "VALIDATION_ERROR",
"message": str(e),
}
)
return JSONResponse(
status_code=201,
content={
"id": str(field.id),
"field_key": field.field_key,
"label": field.label,
"type": field.type,
"required": field.required,
"options": field.options,
"default": field.default_value,
"scope": field.scope,
"is_filterable": field.is_filterable,
"is_rank_feature": field.is_rank_feature,
"status": field.status,
"created_at": field.created_at.isoformat() if field.created_at else None,
"updated_at": field.updated_at.isoformat() if field.updated_at else None,
}
)
@router.put(
"/{id}",
operation_id="updateMetadataSchema",
summary="Update metadata schema",
description="[AC-IDSMETA-14] 更新元数据字段定义,支持状态切换",
)
async def update_schema(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
id: str,
field_update: MetadataFieldDefinitionUpdate,
) -> JSONResponse:
"""
[AC-IDSMETA-14] 更新元数据字段定义
"""
logger.info(
f"[AC-IDSMETA-14] Updating metadata field definition: "
f"tenant={tenant_id}, id={id}"
)
if field_update.status and field_update.status not in [s.value for s in MetadataFieldStatus]:
return JSONResponse(
status_code=400,
content={
"code": "INVALID_STATUS",
"message": f"Invalid status: {field_update.status}",
"details": {
"valid_values": [s.value for s in MetadataFieldStatus]
}
}
)
service = MetadataFieldDefinitionService(session)
try:
field = await service.update_field_definition(tenant_id, id, field_update)
except ValueError as e:
return JSONResponse(
status_code=400,
content={
"code": "VALIDATION_ERROR",
"message": str(e),
}
)
if not field:
return JSONResponse(
status_code=404,
content={
"code": "NOT_FOUND",
"message": f"Field definition {id} not found",
}
)
await session.commit()
return JSONResponse(
content={
"id": str(field.id),
"field_key": field.field_key,
"label": field.label,
"type": field.type,
"required": field.required,
"options": field.options,
"default": field.default_value,
"scope": field.scope,
"is_filterable": field.is_filterable,
"is_rank_feature": field.is_rank_feature,
"status": field.status,
"created_at": field.created_at.isoformat() if field.created_at else None,
"updated_at": field.updated_at.isoformat() if field.updated_at else None,
}
)
@router.get(
"/active",
operation_id="getActiveMetadataSchemas",
summary="Get active metadata schemas",
description="[AC-IDSMETA-14] 获取活跃状态的字段定义,用于新建对象时选择",
)
async def get_active_schemas(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
scope: Annotated[str | None, Query(
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
)] = None,
) -> JSONResponse:
"""
[AC-IDSMETA-14] 获取活跃状态的字段定义
"""
logger.info(
f"[AC-IDSMETA-14] Getting active metadata field definitions: "
f"tenant={tenant_id}, scope={scope}"
)
service = MetadataFieldDefinitionService(session)
fields = await service.get_active_field_definitions(tenant_id, scope)
return JSONResponse(
content={
"items": [
{
"id": str(f.id),
"field_key": f.field_key,
"label": f.label,
"type": f.type,
"required": f.required,
"options": f.options,
"default": f.default_value,
"scope": f.scope,
"is_filterable": f.is_filterable,
"is_rank_feature": f.is_rank_feature,
"status": f.status,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in fields
]
}
)
@router.get(
"/readable",
operation_id="getReadableMetadataSchemas",
summary="Get readable metadata schemas",
description="[AC-IDSMETA-14] 获取可读取的字段定义active + deprecated用于历史数据展示",
)
async def get_readable_schemas(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
scope: Annotated[str | None, Query(
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
)] = None,
) -> JSONResponse:
"""
[AC-IDSMETA-14] 获取可读取的字段定义active + deprecated
"""
logger.info(
f"[AC-IDSMETA-14] Getting readable metadata field definitions: "
f"tenant={tenant_id}, scope={scope}"
)
service = MetadataFieldDefinitionService(session)
fields = await service.get_field_definitions_for_read(tenant_id, scope)
return JSONResponse(
content={
"items": [
{
"id": str(f.id),
"field_key": f.field_key,
"label": f.label,
"type": f.type,
"required": f.required,
"options": f.options,
"default": f.default_value,
"scope": f.scope,
"is_filterable": f.is_filterable,
"is_rank_feature": f.is_rank_feature,
"status": f.status,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in fields
]
}
)
@router.post(
"/validate",
operation_id="validateMetadataForCreate",
summary="Validate metadata for create",
description="[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象",
)
async def validate_metadata_for_create(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
body: dict[str, Any],
) -> JSONResponse:
"""
[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象
Request body:
{
"metadata": {"grade": "初一", "subject": "语文"},
"scope": "kb_document"
}
"""
metadata = body.get("metadata", {})
scope = body.get("scope", "kb_document")
logger.info(
f"[AC-IDSMETA-14] Validating metadata for create: "
f"tenant={tenant_id}, scope={scope}"
)
service = MetadataFieldDefinitionService(session)
is_valid, errors = await service.validate_metadata_for_create(
tenant_id, metadata, scope
)
return JSONResponse(
content={
"isValid": is_valid,
"errors": errors,
}
)

View File

@ -15,12 +15,15 @@ from app.api import chat_router, health_router
from app.api.admin import (
api_key_router,
dashboard_router,
decomposition_template_router,
embedding_router,
flow_test_router,
guardrails_router,
intent_rules_router,
kb_router,
llm_router,
metadata_field_definition_router,
metadata_schema_router,
monitoring_router,
prompt_templates_router,
rag_router,
@ -147,6 +150,7 @@ app.include_router(chat_router)
app.include_router(api_key_router)
app.include_router(dashboard_router)
app.include_router(decomposition_template_router)
app.include_router(embedding_router)
app.include_router(flow_test_router)
app.include_router(guardrails_router)
@ -154,6 +158,8 @@ app.include_router(intent_rules_router)
app.include_router(kb_router)
app.include_router(kb_optimized_router)
app.include_router(llm_router)
app.include_router(metadata_field_definition_router)
app.include_router(metadata_schema_router)
app.include_router(monitoring_router)
app.include_router(prompt_templates_router)
app.include_router(rag_router)

View File

@ -289,6 +289,7 @@ class PromptTemplate(SQLModel, table=True):
"""
[AC-AISVC-51, AC-AISVC-52] Prompt template entity with tenant isolation.
Main table for storing template metadata.
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
"""
__tablename__ = "prompt_templates"
@ -302,6 +303,11 @@ class PromptTemplate(SQLModel, table=True):
scene: str = Field(..., description="Scene tag: chat/rag_qa/greeting/farewell")
description: str | None = Field(default=None, description="Template description")
is_default: bool = Field(default=False, description="Whether this is the default template for the scene")
metadata_: dict[str, Any] | None = Field(
default=None,
sa_column=Column("metadata", JSON, nullable=True),
description="[AC-IDSMETA-16] Structured metadata for the prompt template"
)
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
@ -350,6 +356,7 @@ class PromptTemplateCreate(SQLModel):
system_instruction: str
variables: list[dict[str, Any]] | None = None
is_default: bool = False
metadata_: dict[str, Any] | None = None
class PromptTemplateUpdate(SQLModel):
@ -361,6 +368,7 @@ class PromptTemplateUpdate(SQLModel):
system_instruction: str | None = None
variables: list[dict[str, Any]] | None = None
is_default: bool | None = None
metadata_: dict[str, Any] | None = None
class ResponseType(str, Enum):
@ -375,6 +383,7 @@ class IntentRule(SQLModel, table=True):
"""
[AC-AISVC-65] Intent rule entity with tenant isolation.
Supports keyword and regex matching for intent recognition.
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
"""
__tablename__ = "intent_rules"
@ -407,6 +416,11 @@ class IntentRule(SQLModel, table=True):
transfer_message: str | None = Field(default=None, description="Transfer message for transfer type")
is_enabled: bool = Field(default=True, description="Whether the rule is enabled")
hit_count: int = Field(default=0, description="Hit count for statistics")
metadata_: dict[str, Any] | None = Field(
default=None,
sa_column=Column("metadata", JSON, nullable=True),
description="[AC-IDSMETA-16] Structured metadata for the intent rule"
)
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
@ -423,6 +437,7 @@ class IntentRuleCreate(SQLModel):
flow_id: str | None = None
fixed_reply: str | None = None
transfer_message: str | None = None
metadata_: dict[str, Any] | None = None
class IntentRuleUpdate(SQLModel):
@ -438,6 +453,7 @@ class IntentRuleUpdate(SQLModel):
fixed_reply: str | None = None
transfer_message: str | None = None
is_enabled: bool | None = None
metadata_: dict[str, Any] | None = None
class IntentMatchResult:
@ -643,6 +659,7 @@ class ScriptFlow(SQLModel, table=True):
"""
[AC-AISVC-71] Script flow entity with tenant isolation.
Stores flow definition with steps in JSONB format.
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
"""
__tablename__ = "script_flows"
@ -660,6 +677,11 @@ class ScriptFlow(SQLModel, table=True):
description="Flow steps list with step_no, content, wait_input, timeout_seconds"
)
is_enabled: bool = Field(default=True, description="Whether the flow is enabled")
metadata_: dict[str, Any] | None = Field(
default=None,
sa_column=Column("metadata", JSON, nullable=True),
description="[AC-IDSMETA-16] Structured metadata for the script flow"
)
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
@ -700,8 +722,18 @@ class FlowInstance(SQLModel, table=True):
completed_at: datetime | None = Field(default=None, description="Completion time (nullable)")
class ScriptMode(str, Enum):
"""[AC-IDS-01] Script generation mode for flow steps."""
FIXED = "fixed"
FLEXIBLE = "flexible"
TEMPLATE = "template"
class FlowStep(SQLModel):
"""[AC-AISVC-71] Schema for a single flow step."""
"""
[AC-AISVC-71] Schema for a single flow step.
[AC-IDS-01] Extended with intent-driven script generation fields.
"""
step_no: int = Field(..., ge=1, description="Step number (1-indexed)")
content: str = Field(..., description="Script content for this step")
@ -717,6 +749,31 @@ class FlowStep(SQLModel):
)
default_next: int | None = Field(default=None, description="Default next step if no condition matches")
script_mode: str = Field(
default=ScriptMode.FIXED.value,
description="[AC-IDS-01] Script mode: fixed/flexible/template"
)
intent: str | None = Field(
default=None,
description="[AC-IDS-01] Step intent for flexible mode (e.g., '获取用户姓名')"
)
intent_description: str | None = Field(
default=None,
description="[AC-IDS-01] Detailed intent description for better AI understanding"
)
script_constraints: list[str] | None = Field(
default=None,
description="[AC-IDS-01] Script constraints for flexible mode (e.g., ['必须礼貌', '语气自然'])"
)
expected_variables: list[str] | None = Field(
default=None,
description="[AC-IDS-01] Expected variables to extract from user input"
)
rag_config: dict[str, Any] | None = Field(
default=None,
description="RAG configuration for this step: {'enabled': true, 'tag_filter': {'grade': '${context.grade}', 'type': '痛点'}}"
)
class ScriptFlowCreate(SQLModel):
"""[AC-AISVC-71] Schema for creating a new script flow."""
@ -725,6 +782,7 @@ class ScriptFlowCreate(SQLModel):
description: str | None = None
steps: list[dict[str, Any]]
is_enabled: bool = True
metadata_: dict[str, Any] | None = None
class ScriptFlowUpdate(SQLModel):
@ -734,6 +792,7 @@ class ScriptFlowUpdate(SQLModel):
description: str | None = None
steps: list[dict[str, Any]] | None = None
is_enabled: bool | None = None
metadata_: dict[str, Any] | None = None
class FlowAdvanceResult:
@ -886,3 +945,271 @@ class ConversationDetail(SQLModel):
should_transfer: bool = False
execution_steps: list[dict[str, Any]] | None = None
created_at: datetime
class MetadataFieldType(str, Enum):
"""元数据字段类型"""
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
ENUM = "enum"
ARRAY_ENUM = "array_enum"
class MetadataFieldStatus(str, Enum):
"""[AC-IDSMETA-13] 元数据字段状态"""
DRAFT = "draft"
ACTIVE = "active"
DEPRECATED = "deprecated"
class MetadataScope(str, Enum):
"""[AC-IDSMETA-15] 元数据字段适用范围"""
KB_DOCUMENT = "kb_document"
INTENT_RULE = "intent_rule"
SCRIPT_FLOW = "script_flow"
PROMPT_TEMPLATE = "prompt_template"
class MetadataField(SQLModel):
"""元数据字段定义(非持久化,用于嵌套结构)"""
name: str = Field(..., description="字段名称,如 grade, subject, industry")
label: str = Field(..., description="字段显示名称,如 年级, 学科, 行业")
field_type: str = Field(
default=MetadataFieldType.STRING.value,
description="字段类型: string/number/boolean/enum/array_enum"
)
options: list[str] | None = Field(
default=None,
description="选项列表,用于 enum/array_enum 类型,如 ['初一', '初二', '初三']"
)
required: bool = Field(default=False, description="是否必填")
default_value: str | None = Field(default=None, description="默认值")
description: str | None = Field(default=None, description="字段描述")
sort_order: int = Field(default=0, description="排序顺序")
class MetadataFieldDefinition(SQLModel, table=True):
"""
[AC-IDSMETA-13] 元数据字段定义表
每个字段独立存储支持字段级状态管理draft/active/deprecated
"""
__tablename__ = "metadata_field_definitions"
__table_args__ = (
Index("ix_metadata_field_definitions_tenant", "tenant_id"),
Index("ix_metadata_field_definitions_tenant_status", "tenant_id", "status"),
Index("ix_metadata_field_definitions_tenant_field_key", "tenant_id", "field_key", unique=True),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
field_key: str = Field(
...,
description="字段键名,仅允许小写字母数字下划线,如 grade, subject, industry",
min_length=1,
max_length=64,
)
label: str = Field(..., description="字段显示名称", min_length=1, max_length=64)
type: str = Field(
default=MetadataFieldType.STRING.value,
description="字段类型: string/number/boolean/enum/array_enum"
)
required: bool = Field(default=False, description="是否必填")
options: list[str] | None = Field(
default=None,
sa_column=Column("options", JSON, nullable=True),
description="选项列表,用于 enum/array_enum 类型"
)
default_value: str | None = Field(default=None, description="默认值", sa_column=Column("default_value", JSON, nullable=True))
scope: list[str] = Field(
default_factory=lambda: [MetadataScope.KB_DOCUMENT.value],
sa_column=Column("scope", JSON, nullable=False),
description="适用范围: kb_document/intent_rule/script_flow/prompt_template"
)
is_filterable: bool = Field(default=True, description="是否可用于过滤")
is_rank_feature: bool = Field(default=False, description="是否用于排序特征")
status: str = Field(
default=MetadataFieldStatus.DRAFT.value,
description="字段状态: draft/active/deprecated"
)
version: int = Field(default=1, description="版本号")
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
class MetadataFieldDefinitionCreate(SQLModel):
"""[AC-IDSMETA-13] 创建元数据字段定义"""
field_key: str = Field(..., min_length=1, max_length=64)
label: str = Field(..., min_length=1, max_length=64)
type: str = Field(default=MetadataFieldType.STRING.value)
required: bool = Field(default=False)
options: list[str] | None = None
default_value: str | int | float | bool | None = None
scope: list[str] = Field(default_factory=lambda: [MetadataScope.KB_DOCUMENT.value])
is_filterable: bool = Field(default=True)
is_rank_feature: bool = Field(default=False)
status: str = Field(default=MetadataFieldStatus.DRAFT.value)
class MetadataFieldDefinitionUpdate(SQLModel):
"""[AC-IDSMETA-14] 更新元数据字段定义"""
label: str | None = Field(default=None, min_length=1, max_length=64)
required: bool | None = None
options: list[str] | None = None
default_value: str | int | float | bool | None = None
scope: list[str] | None = None
is_filterable: bool | None = None
is_rank_feature: bool | None = None
status: str | None = None
class MetadataSchema(SQLModel, table=True):
"""
元数据模式定义保留兼容性
每个租户可以定义自己的元数据字段配置
"""
__tablename__ = "metadata_schemas"
__table_args__ = (
Index("ix_metadata_schemas_tenant", "tenant_id"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
name: str = Field(..., description="模式名称,如 教育行业元数据")
description: str | None = Field(default=None, description="模式描述")
fields: list[dict[str, Any]] = Field(
default=[],
sa_column=Column("fields", JSON, nullable=False),
description="字段定义列表"
)
is_default: bool = Field(default=False, description="是否为租户默认模式")
is_enabled: bool = Field(default=True, description="是否启用")
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
class MetadataSchemaCreate(SQLModel):
"""创建元数据模式"""
name: str
description: str | None = None
fields: list[dict[str, Any]]
is_default: bool = False
class MetadataSchemaUpdate(SQLModel):
"""更新元数据模式"""
name: str | None = None
description: str | None = None
fields: list[dict[str, Any]] | None = None
is_default: bool | None = None
is_enabled: bool | None = None
class MetadataFieldCreate(SQLModel):
"""创建元数据字段"""
name: str
label: str
field_type: str = "string"
options: list[str] | None = None
required: bool = False
default_value: str | None = None
description: str | None = None
sort_order: int = 0
class DecompositionTemplateStatus(str, Enum):
"""[AC-IDSMETA-22] 拆解模板状态"""
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class DecompositionTemplate(SQLModel, table=True):
"""
[AC-IDSMETA-22] 拆解模板表
用于将待录入文本按固定模板拆解为结构化数据
"""
__tablename__ = "decomposition_templates"
__table_args__ = (
Index("ix_decomposition_templates_tenant", "tenant_id"),
Index("ix_decomposition_templates_tenant_status", "tenant_id", "status"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
name: str = Field(..., description="模板名称")
description: str | None = Field(default=None, description="模板描述")
version: int = Field(default=1, description="版本号")
status: str = Field(
default=DecompositionTemplateStatus.DRAFT.value,
description="模板状态: draft/published/archived"
)
template_schema: dict[str, Any] = Field(
default_factory=dict,
sa_column=Column("template_schema", JSON, nullable=False),
description="输出模板结构定义,包含字段名、类型、描述等"
)
extraction_hints: dict[str, Any] | None = Field(
default=None,
sa_column=Column("extraction_hints", JSON, nullable=True),
description="提取提示,用于指导 LLM 提取特定字段"
)
example_input: str | None = Field(default=None, description="示例输入文本")
example_output: dict[str, Any] | None = Field(
default=None,
sa_column=Column("example_output", JSON, nullable=True),
description="示例输出 JSON"
)
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
class DecompositionTemplateCreate(SQLModel):
"""[AC-IDSMETA-22] 创建拆解模板"""
name: str
description: str | None = None
template_schema: dict[str, Any]
extraction_hints: dict[str, Any] | None = None
example_input: str | None = None
example_output: dict[str, Any] | None = None
class DecompositionTemplateUpdate(SQLModel):
"""[AC-IDSMETA-22] 更新拆解模板"""
name: str | None = None
description: str | None = None
template_schema: dict[str, Any] | None = None
extraction_hints: dict[str, Any] | None = None
example_input: str | None = None
example_output: dict[str, Any] | None = None
status: str | None = None
class DecompositionRequest(SQLModel):
"""[AC-IDSMETA-21] 拆解请求"""
text: str = Field(..., description="待拆解的文本")
template_id: str | None = Field(default=None, description="指定模板 ID可选")
hints: dict[str, Any] | None = Field(default=None, description="额外提取提示")
class DecompositionResult(SQLModel):
"""[AC-IDSMETA-21] 拆解结果"""
success: bool = Field(..., description="是否成功")
data: dict[str, Any] | None = Field(default=None, description="拆解后的结构化数据")
template_id: str | None = Field(default=None, description="使用的模板 ID")
template_version: int | None = Field(default=None, description="使用的模板版本")
confidence: float | None = Field(default=None, description="拆解置信度")
error: str | None = Field(default=None, description="错误信息")
latency_ms: int | None = Field(default=None, description="处理耗时(毫秒)")

View File

@ -0,0 +1,472 @@
"""
Metadata Field Definition Service.
[AC-IDSMETA-13, AC-IDSMETA-14] 元数据字段定义管理服务支持字段级状态治理
"""
import logging
import re
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col
from app.models.entities import (
MetadataFieldDefinition,
MetadataFieldDefinitionCreate,
MetadataFieldDefinitionUpdate,
MetadataFieldStatus,
MetadataFieldType,
MetadataScope,
)
logger = logging.getLogger(__name__)
class MetadataFieldDefinitionService:
"""
[AC-IDSMETA-13] 元数据字段定义服务
管理租户的动态元数据字段配置支持字段级状态治理
"""
FIELD_KEY_PATTERN = re.compile(r"^[a-z0-9_]+$")
def __init__(self, session: AsyncSession):
self._session = session
async def list_field_definitions(
self,
tenant_id: str,
status: str | None = None,
scope: str | None = None,
) -> list[MetadataFieldDefinition]:
"""
[AC-IDSMETA-13] 列出租户所有元数据字段定义
Args:
tenant_id: 租户 ID
status: 按状态过滤draft/active/deprecated
scope: 按适用范围过滤
Returns:
MetadataFieldDefinition 列表
"""
stmt = select(MetadataFieldDefinition).where(
MetadataFieldDefinition.tenant_id == tenant_id,
)
if status:
stmt = stmt.where(MetadataFieldDefinition.status == status)
if scope:
stmt = stmt.where(MetadataFieldDefinition.scope.contains([scope]))
stmt = stmt.order_by(col(MetadataFieldDefinition.created_at).desc())
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def get_field_definition(
self,
tenant_id: str,
field_id: str,
) -> MetadataFieldDefinition | None:
"""
获取单个字段定义
Args:
tenant_id: 租户 ID
field_id: 字段定义 ID
Returns:
MetadataFieldDefinition None
"""
stmt = select(MetadataFieldDefinition).where(
MetadataFieldDefinition.tenant_id == tenant_id,
MetadataFieldDefinition.id == uuid.UUID(field_id),
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def get_field_definition_by_key(
self,
tenant_id: str,
field_key: str,
) -> MetadataFieldDefinition | None:
"""
通过 field_key 获取字段定义
Args:
tenant_id: 租户 ID
field_key: 字段键名
Returns:
MetadataFieldDefinition None
"""
stmt = select(MetadataFieldDefinition).where(
MetadataFieldDefinition.tenant_id == tenant_id,
MetadataFieldDefinition.field_key == field_key,
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def create_field_definition(
self,
tenant_id: str,
field_create: MetadataFieldDefinitionCreate,
) -> MetadataFieldDefinition:
"""
[AC-IDSMETA-13] 创建元数据字段定义
Args:
tenant_id: 租户 ID
field_create: 创建数据
Returns:
创建的 MetadataFieldDefinition
Raises:
ValueError: 如果 field_key 已存在或格式不正确
"""
if not self.FIELD_KEY_PATTERN.match(field_create.field_key):
raise ValueError(
f"field_key '{field_create.field_key}' 格式不正确,"
"仅允许小写字母、数字和下划线"
)
existing = await self.get_field_definition_by_key(tenant_id, field_create.field_key)
if existing:
raise ValueError(f"field_key '{field_create.field_key}' 已存在")
self._validate_field_type_and_options(
field_create.type,
field_create.options,
field_create.field_key,
)
field = MetadataFieldDefinition(
tenant_id=tenant_id,
field_key=field_create.field_key,
label=field_create.label,
type=field_create.type,
required=field_create.required,
options=field_create.options,
default_value=field_create.default_value,
scope=field_create.scope,
is_filterable=field_create.is_filterable,
is_rank_feature=field_create.is_rank_feature,
status=field_create.status,
version=1,
)
self._session.add(field)
await self._session.flush()
logger.info(
f"[AC-IDSMETA-13] Created field definition: tenant={tenant_id}, "
f"field_key={field.field_key}, status={field.status}"
)
return field
async def update_field_definition(
self,
tenant_id: str,
field_id: str,
field_update: MetadataFieldDefinitionUpdate,
) -> MetadataFieldDefinition | None:
"""
[AC-IDSMETA-14] 更新元数据字段定义
Args:
tenant_id: 租户 ID
field_id: 字段定义 ID
field_update: 更新数据
Returns:
更新后的 MetadataFieldDefinition None
"""
field = await self.get_field_definition(tenant_id, field_id)
if not field:
return None
if field_update.label is not None:
field.label = field_update.label
if field_update.required is not None:
field.required = field_update.required
if field_update.options is not None:
self._validate_field_type_and_options(field.type, field_update.options, field.field_key)
field.options = field_update.options
if field_update.default_value is not None:
field.default_value = field_update.default_value
if field_update.scope is not None:
field.scope = field_update.scope
if field_update.is_filterable is not None:
field.is_filterable = field_update.is_filterable
if field_update.is_rank_feature is not None:
field.is_rank_feature = field_update.is_rank_feature
if field_update.status is not None:
old_status = field.status
field.status = field_update.status
logger.info(
f"[AC-IDSMETA-14] Field status changed: tenant={tenant_id}, "
f"field_key={field.field_key}, {old_status} -> {field.status}"
)
field.version += 1
field.updated_at = datetime.utcnow()
await self._session.flush()
logger.info(
f"[AC-IDSMETA-14] Updated field definition: tenant={tenant_id}, "
f"field_id={field_id}, version={field.version}"
)
return field
async def get_active_field_definitions(
self,
tenant_id: str,
scope: str | None = None,
) -> list[MetadataFieldDefinition]:
"""
[AC-IDSMETA-14] 获取活跃状态的字段定义用于新建对象时选择
Args:
tenant_id: 租户 ID
scope: 按适用范围过滤
Returns:
状态为 active MetadataFieldDefinition 列表
"""
return await self.list_field_definitions(
tenant_id,
status=MetadataFieldStatus.ACTIVE.value,
scope=scope,
)
async def get_field_definitions_for_read(
self,
tenant_id: str,
scope: str | None = None,
) -> list[MetadataFieldDefinition]:
"""
[AC-IDSMETA-14] 获取可用于读取的字段定义active + deprecated不含 draft
用于历史数据读取和展示
Args:
tenant_id: 租户 ID
scope: 按适用范围过滤
Returns:
状态为 active deprecated MetadataFieldDefinition 列表
"""
stmt = select(MetadataFieldDefinition).where(
MetadataFieldDefinition.tenant_id == tenant_id,
MetadataFieldDefinition.status.in_([
MetadataFieldStatus.ACTIVE.value,
MetadataFieldStatus.DEPRECATED.value,
]),
)
if scope:
stmt = stmt.where(MetadataFieldDefinition.scope.contains([scope]))
stmt = stmt.order_by(col(MetadataFieldDefinition.created_at).desc())
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def validate_metadata_for_create(
self,
tenant_id: str,
metadata: dict[str, Any],
scope: str,
) -> tuple[bool, list[str]]:
"""
[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象
检查:
1. 所有 required 字段是否已填写
2. 字段是否为 active 状态deprecated 字段禁止用于新建
3. 值类型是否正确
4. enum/array_enum 类型的值是否在 options
Args:
tenant_id: 租户 ID
metadata: 元数据字典
scope: 对象类型范围
Returns:
(是否有效, 错误消息列表)
"""
active_fields = await self.get_active_field_definitions(tenant_id, scope)
active_field_map = {f.field_key: f for f in active_fields}
errors = []
for field_key, field_def in active_field_map.items():
if field_def.required and field_key not in metadata:
errors.append(f"必填字段 '{field_def.label}' ({field_key}) 未填写")
continue
for field_key, value in metadata.items():
if field_key not in active_field_map:
deprecated_field = await self.get_field_definition_by_key(tenant_id, field_key)
if deprecated_field and deprecated_field.status == MetadataFieldStatus.DEPRECATED.value:
errors.append(
f"字段 '{deprecated_field.label}' ({field_key}) 已废弃,"
"不能用于新建对象"
)
continue
field_def = active_field_map[field_key]
type_errors = self._validate_field_value(field_def, value)
errors.extend(type_errors)
return len(errors) == 0, errors
async def validate_metadata_for_update(
self,
tenant_id: str,
metadata: dict[str, Any],
scope: str,
) -> tuple[bool, list[str]]:
"""
[AC-IDSMETA-14] 验证元数据是否可用于更新对象
与新建不同更新时允许保留 deprecated 字段的值但不能新增
Args:
tenant_id: 租户 ID
metadata: 元数据字典
scope: 对象类型范围
Returns:
(是否有效, 错误消息列表)
"""
readable_fields = await self.get_field_definitions_for_read(tenant_id, scope)
readable_field_map = {f.field_key: f for f in readable_fields}
active_fields = await self.get_active_field_definitions(tenant_id, scope)
active_field_map = {f.field_key: f for f in active_fields}
errors = []
for field_key, field_def in active_field_map.items():
if field_def.required and field_key not in metadata:
errors.append(f"必填字段 '{field_def.label}' ({field_key}) 未填写")
for field_key, value in metadata.items():
if field_key not in readable_field_map:
errors.append(f"未知字段 '{field_key}'")
continue
field_def = readable_field_map[field_key]
type_errors = self._validate_field_value(field_def, value)
errors.extend(type_errors)
return len(errors) == 0, errors
def _validate_field_value(
self,
field_def: MetadataFieldDefinition,
value: Any,
) -> list[str]:
"""验证字段值类型和选项"""
errors = []
field_key = field_def.field_key
field_type = field_def.type
if value is None:
return errors
if field_type == MetadataFieldType.NUMBER.value:
if not isinstance(value, (int, float)):
try:
float(value)
except (ValueError, TypeError):
errors.append(f"字段 '{field_def.label}' ({field_key}) 应为数字类型")
elif field_type == MetadataFieldType.BOOLEAN.value:
if not isinstance(value, bool):
if value not in ["true", "false", "1", "0", 1, 0]:
errors.append(f"字段 '{field_def.label}' ({field_key}) 应为布尔类型")
elif field_type == MetadataFieldType.ENUM.value:
if field_def.options and value not in field_def.options:
errors.append(
f"字段 '{field_def.label}' ({field_key}) 的值 '{value}' "
f"不在允许选项中: {field_def.options}"
)
elif field_type == MetadataFieldType.ARRAY_ENUM.value:
if not isinstance(value, list):
errors.append(f"字段 '{field_def.label}' ({field_key}) 应为数组类型")
elif field_def.options:
for v in value:
if v not in field_def.options:
errors.append(
f"字段 '{field_def.label}' ({field_key}) 的值 '{v}' "
f"不在允许选项中: {field_def.options}"
)
return errors
def _validate_field_type_and_options(
self,
field_type: str,
options: list[str] | None,
field_key: str,
) -> None:
"""验证字段类型和选项的一致性"""
if field_type in [MetadataFieldType.ENUM.value, MetadataFieldType.ARRAY_ENUM.value]:
if not options or len(options) == 0:
raise ValueError(
f"字段 '{field_key}' 类型为 {field_type},必须提供 options"
)
if len(options) != len(set(options)):
raise ValueError(
f"字段 '{field_key}' 的 options 存在重复值"
)
async def get_field_definitions_map(
self,
tenant_id: str,
scope: str | None = None,
include_deprecated: bool = False,
) -> dict[str, dict[str, Any]]:
"""
获取字段定义映射用于前端动态渲染表单
Args:
tenant_id: 租户 ID
scope: 按适用范围过滤
include_deprecated: 是否包含 deprecated 字段
Returns:
字段键名到字段定义的映射
"""
if include_deprecated:
fields = await self.get_field_definitions_for_read(tenant_id, scope)
else:
fields = await self.get_active_field_definitions(tenant_id, scope)
return {
f.field_key: {
"id": str(f.id),
"field_key": f.field_key,
"label": f.label,
"type": f.type,
"required": f.required,
"options": f.options,
"default": f.default_value,
"scope": f.scope,
"is_filterable": f.is_filterable,
"is_rank_feature": f.is_rank_feature,
"status": f.status,
}
for f in fields
}