| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- <script setup>
- import { computed, onMounted, reactive, ref } from 'vue'
- import { useMessage } from 'naive-ui'
- import {
- addRagQueryExampleCandidates, deleteRagQueryExample, getKnowledgeBaseBindings, listRagQueryExamples, refreshRagCapability,
- suggestRagGovernance, upsertKnowledgeBaseBindingBySource, verifyRagQueryExample
- } from '../../api/rag'
- const message = useMessage()
- const form = reactive({ sourceType: 'GRAPH', sourceId: '1', description: '' })
- const profile = ref(null)
- const suggestion = ref(null)
- const examples = ref([])
- const bindings = ref([])
- const loading = reactive({ profile: false, suggest: false, apply: false })
- const selectedLabels = ref([])
- const selectedRelationships = ref([])
- const selectedTables = ref([])
- const selectedProperties = reactive({})
- const rulesText = ref('')
- const isGraph = computed(() => form.sourceType === 'GRAPH')
- const graph = computed(() => profile.value?.graphSchema || { nodes: [], relationships: [] })
- const sql = computed(() => profile.value?.sqlSchema || { tables: [] })
- const relationshipGroups = computed(() => {
- const groups = new Map()
- for (const relation of graph.value.relationships) {
- const current = groups.get(relation.type) || { type: relation.type, startLabels: [], endLabels: [], properties: {} }
- current.startLabels = [...new Set([...current.startLabels, ...(relation.startLabels || [])])]
- current.endLabels = [...new Set([...current.endLabels, ...(relation.endLabels || [])])]
- Object.assign(current.properties, relation.properties || {})
- groups.set(relation.type, current)
- }
- return [...groups.values()]
- })
- const binding = computed(() => bindings.value.find(item =>
- item.sourceType === form.sourceType && String(item.sourceId) === String(form.sourceId)))
- function clearSelection() {
- selectedLabels.value = []
- selectedRelationships.value = []
- selectedTables.value = []
- Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
- }
- function currentBindingConfig() {
- const raw = binding.value?.config || binding.value?.configJson || {}
- try { return typeof raw === 'string' ? JSON.parse(raw || '{}') : raw }
- catch { return {} }
- }
- function ownerProperties(owner) {
- const node = graph.value.nodes.find(item => item.label === owner)
- if (node) return Object.keys(node.properties || {})
- const relation = relationshipGroups.value.find(item => item.type === owner)
- return Object.keys(relation?.properties || {})
- }
- function updateLabels(values) {
- selectedLabels.value = values
- values.forEach(owner => { if (!Array.isArray(selectedProperties[owner])) selectedProperties[owner] = ownerProperties(owner) })
- }
- function updateRelationships(values) {
- selectedRelationships.value = values
- values.forEach(owner => { if (!Array.isArray(selectedProperties[owner])) selectedProperties[owner] = ownerProperties(owner) })
- }
- function updateOwnerProperties(owner, values, ownerType) {
- selectedProperties[owner] = values
- if (!values.length) return
- if (ownerType === 'label' && !selectedLabels.value.includes(owner))
- selectedLabels.value = [...selectedLabels.value, owner]
- if (ownerType === 'relationship' && !selectedRelationships.value.includes(owner))
- selectedRelationships.value = [...selectedRelationships.value, owner]
- }
- function initializeFromBinding() {
- clearSelection()
- rulesText.value = ''
- if (!binding.value) return
- const config = currentBindingConfig()
- if (isGraph.value) {
- const actualLabels = graph.value.nodes.map(node => node.label)
- const actualRelationships = relationshipGroups.value.map(rel => rel.type)
- selectedLabels.value = Array.isArray(config.allowedLabels) && config.allowedLabels.length
- ? config.allowedLabels.filter(name => actualLabels.includes(name)) : actualLabels
- selectedRelationships.value = Array.isArray(config.allowedRelationships) && config.allowedRelationships.length
- ? config.allowedRelationships.filter(name => actualRelationships.includes(name)) : actualRelationships
- const configuredProperties = config.allowedProperties || {}
- ;[...selectedLabels.value, ...selectedRelationships.value].forEach(owner => {
- selectedProperties[owner] = Array.isArray(configuredProperties[owner])
- ? configuredProperties[owner].filter(name => ownerProperties(owner).includes(name))
- : ownerProperties(owner)
- })
- } else {
- const actualTables = sql.value.tables.map(table => table.name)
- selectedTables.value = Array.isArray(config.allowedTables) && config.allowedTables.length
- ? config.allowedTables.filter(name => actualTables.includes(name)) : actualTables
- }
- rulesText.value = (config.generationRules || config.rules || []).join('\n')
- }
- async function loadProfile() {
- loading.profile = true
- try {
- profile.value = await refreshRagCapability(form.sourceType, form.sourceId)
- examples.value = await listRagQueryExamples(form.sourceType, form.sourceId)
- bindings.value = await getKnowledgeBaseBindings(1)
- initializeFromBinding()
- } catch (error) { message.error(error.message || 'Schema 刷新失败') }
- finally { loading.profile = false }
- }
- async function generateSuggestion() {
- loading.suggest = true
- try {
- suggestion.value = await suggestRagGovernance(form.sourceType, form.sourceId, form.description)
- const config = suggestion.value?.suggestedConfig || suggestion.value?.config || suggestion.value
- if (isGraph.value) {
- selectedLabels.value = config.allowedLabels || selectedLabels.value
- selectedRelationships.value = config.allowedRelationships || selectedRelationships.value
- Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
- Object.assign(selectedProperties, config.allowedProperties || {})
- } else selectedTables.value = config.allowedTables || selectedTables.value
- rulesText.value = (config.generationRules || config.rules || []).join('\n')
- message.success('已生成建议,请审核后再应用')
- } catch (error) { message.error(error.message || '建议生成失败') }
- finally { loading.suggest = false }
- }
- function buildConfig() {
- const original = binding.value?.config || binding.value?.configJson || {}
- const config = typeof original === 'string' ? JSON.parse(original || '{}') : { ...original }
- Object.assign(config, {
- retrievalMode: 'AUTO_GENERATE', topK: config.topK || 5,
- generationRules: rulesText.value.split('\n').map(v => v.trim()).filter(Boolean)
- })
- if (isGraph.value) Object.assign(config, {
- allowTextToCypher: true, allowedLabels: selectedLabels.value,
- allowedRelationships: selectedRelationships.value,
- allowedProperties: Object.fromEntries(Object.entries(selectedProperties).filter(([owner]) =>
- selectedLabels.value.includes(owner) || selectedRelationships.value.includes(owner)))
- })
- else Object.assign(config, { allowTextToSql: true, allowedTables: selectedTables.value })
- return config
- }
- async function applyConfig() {
- if (isGraph.value && !selectedLabels.value.length) return message.error('至少授权一个 Label,空白名单会造成权限语义不明确')
- if (!isGraph.value && !selectedTables.value.length) return message.error('至少授权一个 Table,空白名单会造成权限语义不明确')
- if (isGraph.value) {
- const orphan = Object.entries(selectedProperties).find(([owner, values]) => values?.length
- && !selectedLabels.value.includes(owner) && !selectedRelationships.value.includes(owner))
- if (orphan) return message.error(`${orphan[0]} 已选择属性但未授权所属 Label/Relationship`)
- }
- loading.apply = true
- try {
- // 按 (sourceType, sourceId) 查找或创建绑定:解决用户进入治理页时 kbId=1 下尚未建立绑定记录的问题
- await upsertKnowledgeBaseBindingBySource(1, form.sourceType, form.sourceId, buildConfig())
- bindings.value = await getKnowledgeBaseBindings(1)
- message.success('授权配置已应用')
- } catch (error) { message.error(error.message || '配置应用失败') }
- finally { loading.apply = false }
- }
- async function setVerified(item, value) {
- try { Object.assign(item, await verifyRagQueryExample(item.id, value)); message.success(value ? '案例已审核通过' : '案例已取消授权') }
- catch (error) { message.error(error.message || '案例审核失败') }
- }
- async function removeExample(item) {
- try {
- await deleteRagQueryExample(item.id)
- examples.value = examples.value.filter(example => example.id !== item.id)
- message.success('案例已删除')
- } catch (error) { message.error(error.message || '案例删除失败') }
- }
- async function importCandidates() {
- const candidates = suggestion.value?.examples || []
- if (!candidates.length) return message.warning('当前建议中没有可导入案例')
- try {
- const result = await addRagQueryExampleCandidates(form.sourceType, form.sourceId, candidates)
- examples.value = result.examples || []
- const rejected = result.rejected?.length || 0
- message[rejected ? 'warning' : 'success'](`已通过预检并导入 ${result.accepted || 0} 条,拒绝 ${rejected} 条`)
- } catch (error) { message.error(error.message || '案例导入失败') }
- }
- onMounted(loadProfile)
- </script>
- <template>
- <div class="page">
- <header><div><h1>RAG 治理中心</h1><p>根据真实 Schema 建议业务子图,审核授权范围与动态 Few-shot。</p></div></header>
- <n-card class="toolbar">
- <n-space align="end">
- <n-form-item label="数据源类型"><n-select v-model:value="form.sourceType" style="width:180px" :options="[{label:'图数据库',value:'GRAPH'},{label:'结构化数据库',value:'STRUCTURED_DATA'}]" @update:value="loadProfile" /></n-form-item>
- <n-form-item label="数据源 ID"><n-input v-model:value="form.sourceId" style="width:110px" /></n-form-item>
- <n-form-item label="业务描述"><n-input v-model:value="form.description" placeholder="例如:东海救援任务、力量与阶段" style="width:360px" /></n-form-item>
- <n-button :loading="loading.profile" @click="loadProfile">刷新画像</n-button>
- <n-button type="primary" :loading="loading.suggest" @click="generateSuggestion">生成业务子图与案例</n-button>
- </n-space>
- </n-card>
- <div class="metrics" v-if="profile">
- <n-card size="small"><b>画像版本</b><span>{{ profile.version }}</span></n-card>
- <n-card size="small"><b>Schema 规模</b><span>{{ isGraph ? `${graph.nodes.length} Labels / ${graph.relationships.length} Relationships` : `${sql.tables.length} Tables` }}</span></n-card>
- <n-card size="small"><b>语义目录</b><span>{{ profile.semanticCatalog?.length || 0 }} 项已向量化</span></n-card>
- <n-card size="small"><b>绑定状态</b><span>{{ binding ? '可应用' : '未绑定' }}</span></n-card>
- </div>
- <div class="columns">
- <n-card title="授权审核" class="panel">
- <template v-if="isGraph">
- <n-collapse :default-expanded-names="['labels']">
- <n-collapse-item name="labels" :title="`Labels(${selectedLabels.length}/${graph.nodes.length})`">
- <n-checkbox-group :value="selectedLabels" @update:value="updateLabels"><n-collapse accordion>
- <n-collapse-item v-for="node in graph.nodes" :key="node.label" :name="`label:${node.label}`">
- <template #header><n-checkbox :value="node.label" :label="node.label" @click.stop /></template>
- <div class="schema-detail"><span>Properties</span><n-checkbox-group :value="selectedProperties[node.label]" @update:value="values => updateOwnerProperties(node.label, values, 'label')"><n-space><n-checkbox v-for="(_, name) in node.properties" :key="name" :value="name" :label="name" /></n-space></n-checkbox-group><n-empty v-if="!Object.keys(node.properties || {}).length" size="small" description="无属性" /></div>
- </n-collapse-item>
- </n-collapse></n-checkbox-group>
- </n-collapse-item>
- <n-collapse-item name="relationships" :title="`Relationships(${selectedRelationships.length}/${relationshipGroups.length})`">
- <n-checkbox-group :value="selectedRelationships" @update:value="updateRelationships"><n-collapse accordion>
- <n-collapse-item v-for="rel in relationshipGroups" :key="rel.type" :name="`rel:${rel.type}`">
- <template #header><n-checkbox :value="rel.type" :label="rel.type" @click.stop /></template>
- <div class="schema-detail"><p>{{ rel.startLabels?.join(' | ') }} → {{ rel.endLabels?.join(' | ') }}</p><span>Properties</span><n-checkbox-group :value="selectedProperties[rel.type]" @update:value="values => updateOwnerProperties(rel.type, values, 'relationship')"><n-space><n-checkbox v-for="(_, name) in rel.properties" :key="name" :value="name" :label="name" /></n-space></n-checkbox-group><n-empty v-if="!Object.keys(rel.properties || {}).length" size="small" description="无属性" /></div>
- </n-collapse-item>
- </n-collapse></n-checkbox-group>
- </n-collapse-item>
- </n-collapse>
- </template>
- <template v-else><n-checkbox-group v-model:value="selectedTables"><n-collapse :default-expanded-names="['tables']"><n-collapse-item name="tables" :title="`Tables(${selectedTables.length}/${sql.tables.length})`"><n-collapse accordion><n-collapse-item v-for="table in sql.tables" :key="table.name" :name="`table:${table.name}`"><template #header><n-checkbox :value="table.name" :label="table.name" @click.stop /></template><div class="schema-detail"><p>{{ table.description || '无表说明' }}</p><n-space><n-tag v-for="column in table.columns" :key="column.name" size="small">{{ column.name }} · {{ column.type }}</n-tag></n-space></div></n-collapse-item></n-collapse></n-collapse-item></n-collapse></n-checkbox-group></template>
- <h3>生成规则(每行一条)</h3><n-input v-model:value="rulesText" type="textarea" :rows="5" placeholder="只生成只读查询 优先使用明确关系而非笛卡尔积" />
- <n-button type="primary" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
- </n-card>
- <n-card title="建议与画像" class="panel"><n-alert type="info" :show-icon="false">JSON 是建议草稿:suggestedConfig 会同步到左侧表单,点击“应用”后才生效;examples 需点击导入并逐条审核。</n-alert><n-tag v-if="suggestion" type="warning" class="apply">草稿 · 尚未应用</n-tag><n-button v-if="suggestion?.examples?.length" block class="apply" @click="importCandidates">导入为待审核 Few-shot</n-button><pre>{{ JSON.stringify(suggestion || { message: '点击“生成业务子图与案例”获取建议' }, null, 2) }}</pre></n-card>
- <n-card title="动态 Few-shot 审核" class="panel"><n-empty v-if="!examples.length" description="暂无学习案例" /><div v-for="item in examples" :key="item.id" class="example"><div><b>{{ item.question }}</b><code>{{ item.queryText || item.generatedQuery }}</code></div><n-space vertical align="end"><n-switch :value="item.verified" @update:value="value => setVerified(item, value)" /><n-popconfirm positive-text="删除" negative-text="取消" @positive-click="removeExample(item)"><template #trigger><n-button size="tiny" type="error" ghost>删除</n-button></template>删除后无法恢复,确定删除这条 Few-shot 吗?</n-popconfirm></n-space></div></n-card>
- </div>
- </div>
- </template>
- <style scoped>
- .page{padding:24px;min-height:100vh;background:var(--bg-primary);color:var(--text-primary)}header{display:flex;justify-content:space-between;margin-bottom:18px}h1{margin:0 0 6px;font-size:25px}p{margin:0;color:var(--text-secondary)}.toolbar{margin-bottom:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.metrics :deep(.n-card__content){display:flex;flex-direction:column;gap:8px}.metrics span{color:#38bdf8}.columns{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:14px;align-items:start}.panel{max-height:calc(100vh - 280px);overflow:auto}.schema-detail{padding:4px 10px 12px 34px}.schema-detail>span{display:block;color:var(--text-secondary);font-size:12px;margin-bottom:8px}.schema-detail p{font-size:12px;color:var(--text-secondary);margin:0 0 10px}.apply{margin-top:18px}pre{white-space:pre-wrap;word-break:break-word;padding:12px;background:#061426;border-radius:8px;color:#93c5fd;font-size:12px}.example{display:flex;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border-color)}.example code{display:block;white-space:pre-wrap;margin-top:8px;color:var(--text-secondary)}@media(max-width:1200px){.columns{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
- </style>
|