RagGovernance.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. <script setup>
  2. import { computed, onMounted, reactive, ref } from 'vue'
  3. import { useMessage } from 'naive-ui'
  4. import {
  5. addRagQueryExampleCandidates, deleteRagQueryExample, getKnowledgeBaseBindings, listRagQueryExamples, refreshRagCapability,
  6. suggestRagGovernance, upsertKnowledgeBaseBindingBySource, verifyRagQueryExample
  7. } from '../../api/rag'
  8. const message = useMessage()
  9. const form = reactive({ sourceType: 'GRAPH', sourceId: '1', description: '' })
  10. const profile = ref(null)
  11. const suggestion = ref(null)
  12. const examples = ref([])
  13. const bindings = ref([])
  14. const loading = reactive({ profile: false, suggest: false, apply: false })
  15. const selectedLabels = ref([])
  16. const selectedRelationships = ref([])
  17. const selectedTables = ref([])
  18. const selectedProperties = reactive({})
  19. const rulesText = ref('')
  20. const isGraph = computed(() => form.sourceType === 'GRAPH')
  21. const graph = computed(() => profile.value?.graphSchema || { nodes: [], relationships: [] })
  22. const sql = computed(() => profile.value?.sqlSchema || { tables: [] })
  23. const relationshipGroups = computed(() => {
  24. const groups = new Map()
  25. for (const relation of graph.value.relationships) {
  26. const current = groups.get(relation.type) || { type: relation.type, startLabels: [], endLabels: [], properties: {} }
  27. current.startLabels = [...new Set([...current.startLabels, ...(relation.startLabels || [])])]
  28. current.endLabels = [...new Set([...current.endLabels, ...(relation.endLabels || [])])]
  29. Object.assign(current.properties, relation.properties || {})
  30. groups.set(relation.type, current)
  31. }
  32. return [...groups.values()]
  33. })
  34. const binding = computed(() => bindings.value.find(item =>
  35. item.sourceType === form.sourceType && String(item.sourceId) === String(form.sourceId)))
  36. function clearSelection() {
  37. selectedLabels.value = []
  38. selectedRelationships.value = []
  39. selectedTables.value = []
  40. Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
  41. }
  42. function currentBindingConfig() {
  43. const raw = binding.value?.config || binding.value?.configJson || {}
  44. try { return typeof raw === 'string' ? JSON.parse(raw || '{}') : raw }
  45. catch { return {} }
  46. }
  47. function ownerProperties(owner) {
  48. const node = graph.value.nodes.find(item => item.label === owner)
  49. if (node) return Object.keys(node.properties || {})
  50. const relation = relationshipGroups.value.find(item => item.type === owner)
  51. return Object.keys(relation?.properties || {})
  52. }
  53. function updateLabels(values) {
  54. selectedLabels.value = values
  55. values.forEach(owner => { if (!Array.isArray(selectedProperties[owner])) selectedProperties[owner] = ownerProperties(owner) })
  56. }
  57. function updateRelationships(values) {
  58. selectedRelationships.value = values
  59. values.forEach(owner => { if (!Array.isArray(selectedProperties[owner])) selectedProperties[owner] = ownerProperties(owner) })
  60. }
  61. function updateOwnerProperties(owner, values, ownerType) {
  62. selectedProperties[owner] = values
  63. if (!values.length) return
  64. if (ownerType === 'label' && !selectedLabels.value.includes(owner))
  65. selectedLabels.value = [...selectedLabels.value, owner]
  66. if (ownerType === 'relationship' && !selectedRelationships.value.includes(owner))
  67. selectedRelationships.value = [...selectedRelationships.value, owner]
  68. }
  69. function initializeFromBinding() {
  70. clearSelection()
  71. rulesText.value = ''
  72. if (!binding.value) return
  73. const config = currentBindingConfig()
  74. if (isGraph.value) {
  75. const actualLabels = graph.value.nodes.map(node => node.label)
  76. const actualRelationships = relationshipGroups.value.map(rel => rel.type)
  77. selectedLabels.value = Array.isArray(config.allowedLabels) && config.allowedLabels.length
  78. ? config.allowedLabels.filter(name => actualLabels.includes(name)) : actualLabels
  79. selectedRelationships.value = Array.isArray(config.allowedRelationships) && config.allowedRelationships.length
  80. ? config.allowedRelationships.filter(name => actualRelationships.includes(name)) : actualRelationships
  81. const configuredProperties = config.allowedProperties || {}
  82. ;[...selectedLabels.value, ...selectedRelationships.value].forEach(owner => {
  83. selectedProperties[owner] = Array.isArray(configuredProperties[owner])
  84. ? configuredProperties[owner].filter(name => ownerProperties(owner).includes(name))
  85. : ownerProperties(owner)
  86. })
  87. } else {
  88. const actualTables = sql.value.tables.map(table => table.name)
  89. selectedTables.value = Array.isArray(config.allowedTables) && config.allowedTables.length
  90. ? config.allowedTables.filter(name => actualTables.includes(name)) : actualTables
  91. }
  92. rulesText.value = (config.generationRules || config.rules || []).join('\n')
  93. }
  94. async function loadProfile() {
  95. loading.profile = true
  96. try {
  97. profile.value = await refreshRagCapability(form.sourceType, form.sourceId)
  98. examples.value = await listRagQueryExamples(form.sourceType, form.sourceId)
  99. bindings.value = await getKnowledgeBaseBindings(1)
  100. initializeFromBinding()
  101. } catch (error) { message.error(error.message || 'Schema 刷新失败') }
  102. finally { loading.profile = false }
  103. }
  104. async function generateSuggestion() {
  105. loading.suggest = true
  106. try {
  107. suggestion.value = await suggestRagGovernance(form.sourceType, form.sourceId, form.description)
  108. const config = suggestion.value?.suggestedConfig || suggestion.value?.config || suggestion.value
  109. if (isGraph.value) {
  110. selectedLabels.value = config.allowedLabels || selectedLabels.value
  111. selectedRelationships.value = config.allowedRelationships || selectedRelationships.value
  112. Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
  113. Object.assign(selectedProperties, config.allowedProperties || {})
  114. } else selectedTables.value = config.allowedTables || selectedTables.value
  115. rulesText.value = (config.generationRules || config.rules || []).join('\n')
  116. message.success('已生成建议,请审核后再应用')
  117. } catch (error) { message.error(error.message || '建议生成失败') }
  118. finally { loading.suggest = false }
  119. }
  120. function buildConfig() {
  121. const original = binding.value?.config || binding.value?.configJson || {}
  122. const config = typeof original === 'string' ? JSON.parse(original || '{}') : { ...original }
  123. Object.assign(config, {
  124. retrievalMode: 'AUTO_GENERATE', topK: config.topK || 5,
  125. generationRules: rulesText.value.split('\n').map(v => v.trim()).filter(Boolean)
  126. })
  127. if (isGraph.value) Object.assign(config, {
  128. allowTextToCypher: true, allowedLabels: selectedLabels.value,
  129. allowedRelationships: selectedRelationships.value,
  130. allowedProperties: Object.fromEntries(Object.entries(selectedProperties).filter(([owner]) =>
  131. selectedLabels.value.includes(owner) || selectedRelationships.value.includes(owner)))
  132. })
  133. else Object.assign(config, { allowTextToSql: true, allowedTables: selectedTables.value })
  134. return config
  135. }
  136. async function applyConfig() {
  137. if (isGraph.value && !selectedLabels.value.length) return message.error('至少授权一个 Label,空白名单会造成权限语义不明确')
  138. if (!isGraph.value && !selectedTables.value.length) return message.error('至少授权一个 Table,空白名单会造成权限语义不明确')
  139. if (isGraph.value) {
  140. const orphan = Object.entries(selectedProperties).find(([owner, values]) => values?.length
  141. && !selectedLabels.value.includes(owner) && !selectedRelationships.value.includes(owner))
  142. if (orphan) return message.error(`${orphan[0]} 已选择属性但未授权所属 Label/Relationship`)
  143. }
  144. loading.apply = true
  145. try {
  146. // 按 (sourceType, sourceId) 查找或创建绑定:解决用户进入治理页时 kbId=1 下尚未建立绑定记录的问题
  147. await upsertKnowledgeBaseBindingBySource(1, form.sourceType, form.sourceId, buildConfig())
  148. bindings.value = await getKnowledgeBaseBindings(1)
  149. message.success('授权配置已应用')
  150. } catch (error) { message.error(error.message || '配置应用失败') }
  151. finally { loading.apply = false }
  152. }
  153. async function setVerified(item, value) {
  154. try { Object.assign(item, await verifyRagQueryExample(item.id, value)); message.success(value ? '案例已审核通过' : '案例已取消授权') }
  155. catch (error) { message.error(error.message || '案例审核失败') }
  156. }
  157. async function removeExample(item) {
  158. try {
  159. await deleteRagQueryExample(item.id)
  160. examples.value = examples.value.filter(example => example.id !== item.id)
  161. message.success('案例已删除')
  162. } catch (error) { message.error(error.message || '案例删除失败') }
  163. }
  164. async function importCandidates() {
  165. const candidates = suggestion.value?.examples || []
  166. if (!candidates.length) return message.warning('当前建议中没有可导入案例')
  167. try {
  168. const result = await addRagQueryExampleCandidates(form.sourceType, form.sourceId, candidates)
  169. examples.value = result.examples || []
  170. const rejected = result.rejected?.length || 0
  171. message[rejected ? 'warning' : 'success'](`已通过预检并导入 ${result.accepted || 0} 条,拒绝 ${rejected} 条`)
  172. } catch (error) { message.error(error.message || '案例导入失败') }
  173. }
  174. onMounted(loadProfile)
  175. </script>
  176. <template>
  177. <div class="page">
  178. <header><div><h1>RAG 治理中心</h1><p>根据真实 Schema 建议业务子图,审核授权范围与动态 Few-shot。</p></div></header>
  179. <n-card class="toolbar">
  180. <n-space align="end">
  181. <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>
  182. <n-form-item label="数据源 ID"><n-input v-model:value="form.sourceId" style="width:110px" /></n-form-item>
  183. <n-form-item label="业务描述"><n-input v-model:value="form.description" placeholder="例如:东海救援任务、力量与阶段" style="width:360px" /></n-form-item>
  184. <n-button :loading="loading.profile" @click="loadProfile">刷新画像</n-button>
  185. <n-button type="primary" :loading="loading.suggest" @click="generateSuggestion">生成业务子图与案例</n-button>
  186. </n-space>
  187. </n-card>
  188. <div class="metrics" v-if="profile">
  189. <n-card size="small"><b>画像版本</b><span>{{ profile.version }}</span></n-card>
  190. <n-card size="small"><b>Schema 规模</b><span>{{ isGraph ? `${graph.nodes.length} Labels / ${graph.relationships.length} Relationships` : `${sql.tables.length} Tables` }}</span></n-card>
  191. <n-card size="small"><b>语义目录</b><span>{{ profile.semanticCatalog?.length || 0 }} 项已向量化</span></n-card>
  192. <n-card size="small"><b>绑定状态</b><span>{{ binding ? '可应用' : '未绑定' }}</span></n-card>
  193. </div>
  194. <div class="columns">
  195. <n-card title="授权审核" class="panel">
  196. <template v-if="isGraph">
  197. <n-collapse :default-expanded-names="['labels']">
  198. <n-collapse-item name="labels" :title="`Labels(${selectedLabels.length}/${graph.nodes.length})`">
  199. <n-checkbox-group :value="selectedLabels" @update:value="updateLabels"><n-collapse accordion>
  200. <n-collapse-item v-for="node in graph.nodes" :key="node.label" :name="`label:${node.label}`">
  201. <template #header><n-checkbox :value="node.label" :label="node.label" @click.stop /></template>
  202. <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>
  203. </n-collapse-item>
  204. </n-collapse></n-checkbox-group>
  205. </n-collapse-item>
  206. <n-collapse-item name="relationships" :title="`Relationships(${selectedRelationships.length}/${relationshipGroups.length})`">
  207. <n-checkbox-group :value="selectedRelationships" @update:value="updateRelationships"><n-collapse accordion>
  208. <n-collapse-item v-for="rel in relationshipGroups" :key="rel.type" :name="`rel:${rel.type}`">
  209. <template #header><n-checkbox :value="rel.type" :label="rel.type" @click.stop /></template>
  210. <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>
  211. </n-collapse-item>
  212. </n-collapse></n-checkbox-group>
  213. </n-collapse-item>
  214. </n-collapse>
  215. </template>
  216. <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>
  217. <h3>生成规则(每行一条)</h3><n-input v-model:value="rulesText" type="textarea" :rows="5" placeholder="只生成只读查询&#10;优先使用明确关系而非笛卡尔积" />
  218. <n-button type="primary" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
  219. </n-card>
  220. <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>
  221. <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>
  222. </div>
  223. </div>
  224. </template>
  225. <style scoped>
  226. .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)}}
  227. </style>