fix(knowledge): deep audit — 18 bugs fixed across backend + frontend
CRITICAL: - Migration permission seed WHERE name → WHERE id (matched 0 rows, all KB APIs broken) HIGH: - analytics_quality SQL alias + missing comma fix - search() duplicate else block compile error - chunk_content duplicate var declarations + type mismatch - SQL invalid escape sequences - delete_category missing rows_affected check MEDIUM: - analytics_overview hit_rate vs positive_feedback_rate separation - analytics_quality GROUP BY kc.id,kc.name (same-name category merge) - update_category handler trim + empty name validation - update_item duplicate VALID_STATUSES inside transaction - page_size max(1) lower bound in list handlers - batch_create title/content/length validation - embedding dispatch silent error → tracing::warn - Version modal close clears detailItem state - Search empty state distinguishes not-searched vs no-results - Create modal cancel resets form
This commit is contained in:
@@ -2,15 +2,18 @@
|
||||
// 知识库管理
|
||||
// ============================================================
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
|
||||
Card, Statistic, Row, Col, Tabs, Tree, Typography, Empty, Spin, InputNumber,
|
||||
Table, Tooltip,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, SearchOutlined, BookOutlined, FolderOutlined,
|
||||
DeleteOutlined, EditOutlined, EyeOutlined, BarChartOutlined,
|
||||
HistoryOutlined, RollbackOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
@@ -25,7 +28,9 @@ const { Text, Title } = Typography
|
||||
function CategoriesPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [editItem, setEditItem] = useState<CategoryResponse | null>(null)
|
||||
const [createForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['knowledge-categories'],
|
||||
@@ -39,7 +44,7 @@ function CategoriesPanel() {
|
||||
message.success('分类已创建')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
createForm.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
@@ -53,6 +58,40 @@ function CategoriesPanel() {
|
||||
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) =>
|
||||
knowledgeService.updateCategory(id, data),
|
||||
onSuccess: () => {
|
||||
message.success('分类已更新')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||
setEditItem(null)
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||
})
|
||||
|
||||
// 编辑弹窗打开时同步表单值(Ant Design Form initialValues 仅首次挂载生效)
|
||||
useEffect(() => {
|
||||
if (editItem) {
|
||||
editForm.setFieldsValue({
|
||||
name: editItem.name,
|
||||
description: editItem.description,
|
||||
parent_id: editItem.parent_id,
|
||||
icon: editItem.icon,
|
||||
})
|
||||
}
|
||||
}, [editItem, editForm])
|
||||
|
||||
// 获取当前编辑分类及其所有后代的 ID(防止循环引用)
|
||||
const getDescendantIds = (id: string, cats: CategoryResponse[]): string[] => {
|
||||
const ids: string[] = [id]
|
||||
for (const c of cats) {
|
||||
if (c.parent_id === id) {
|
||||
ids.push(...getDescendantIds(c.id, cats))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildTreeData(categories, (id) => {
|
||||
Modal.confirm({
|
||||
@@ -61,6 +100,8 @@ function CategoriesPanel() {
|
||||
okType: 'danger',
|
||||
onOk: () => deleteMutation.mutate(id),
|
||||
})
|
||||
}, (id) => {
|
||||
setEditItem(categories.find((c) => c.id === id) || null)
|
||||
}),
|
||||
[categories, deleteMutation],
|
||||
)
|
||||
@@ -87,14 +128,15 @@ function CategoriesPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建分类弹窗 */}
|
||||
<Modal
|
||||
title="新建分类"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => { setCreateOpen(false); createForm.resetFields() }}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
|
||||
<Input placeholder="例如:产品知识、技术文档" />
|
||||
</Form.Item>
|
||||
@@ -113,6 +155,40 @@ function CategoriesPanel() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑分类弹窗 */}
|
||||
<Modal
|
||||
title="编辑分类"
|
||||
open={!!editItem}
|
||||
onCancel={() => { setEditItem(null); editForm.resetFields() }}
|
||||
onOk={() => editForm.submit()}
|
||||
confirmLoading={updateMutation.isPending}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
initialValues={editItem ? { name: editItem.name, description: editItem.description, parent_id: editItem.parent_id, icon: editItem.icon } : undefined}
|
||||
onFinish={(v) => editItem && updateMutation.mutate({ id: editItem.id, ...v })}
|
||||
>
|
||||
<Form.Item name="name" label="分类名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="parent_id" label="父分类">
|
||||
<Select placeholder="无(顶级分类)" allowClear>
|
||||
{editItem && flattenCategories(categories)
|
||||
.filter((c) => !getDescendantIds(editItem.id, categories).includes(c.id))
|
||||
.map((c) => (
|
||||
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
|
||||
))}
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="图标">
|
||||
<Input placeholder="如 📚" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,6 +199,8 @@ function ItemsPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailItem, setDetailItem] = useState<string | null>(null)
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false)
|
||||
const [rollingBackVersion, setRollingBackVersion] = useState<number | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [filters, setFilters] = useState<{ category_id?: string; status?: string; keyword?: string }>({})
|
||||
@@ -133,18 +211,24 @@ function ItemsPanel() {
|
||||
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
|
||||
})
|
||||
|
||||
const { data: detailData, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ['knowledge-item-detail', detailItem],
|
||||
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
|
||||
enabled: !!detailItem,
|
||||
})
|
||||
|
||||
const { data: versions } = useQuery({
|
||||
queryKey: ['knowledge-item-versions', detailItem],
|
||||
queryFn: ({ signal }) => knowledgeService.getVersions(detailItem!, signal),
|
||||
enabled: !!detailItem,
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['knowledge-items', page, pageSize, filters],
|
||||
queryFn: ({ signal }) =>
|
||||
knowledgeService.listItems({ page, page_size: pageSize, ...filters }, signal),
|
||||
})
|
||||
|
||||
const { data: detailData } = useQuery({
|
||||
queryKey: ['knowledge-item-detail', detailItem],
|
||||
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
|
||||
enabled: !!detailItem,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof knowledgeService.createItem>[0]) =>
|
||||
knowledgeService.createItem(data),
|
||||
@@ -166,13 +250,30 @@ function ItemsPanel() {
|
||||
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||
})
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: ({ itemId, version }: { itemId: string; version: number }) =>
|
||||
knowledgeService.rollbackVersion(itemId, version),
|
||||
onSuccess: () => {
|
||||
message.success('已回滚')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-item-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-item-versions'] })
|
||||
setVersionModalOpen(false)
|
||||
setRollingBackVersion(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(err.message || '回滚失败')
|
||||
setRollingBackVersion(null)
|
||||
},
|
||||
})
|
||||
|
||||
const statusColors: Record<string, string> = { active: 'green', draft: 'orange', archived: 'default' }
|
||||
const statusLabels: Record<string, string> = { active: '活跃', draft: '草稿', archived: '已归档' }
|
||||
|
||||
const columns: ProColumns<KnowledgeItem>[] = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
dataIndex: 'keyword',
|
||||
width: 250,
|
||||
render: (_, r) => (
|
||||
<Button type="link" size="small" onClick={() => setDetailItem(r.id)}>
|
||||
@@ -180,7 +281,14 @@ function ItemsPanel() {
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, valueEnum: Object.fromEntries(Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }])) },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
valueEnum: Object.fromEntries(
|
||||
Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }]),
|
||||
),
|
||||
},
|
||||
{ title: '版本', dataIndex: 'version', width: 60, search: false },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 70, search: false },
|
||||
{
|
||||
@@ -197,11 +305,14 @@ function ItemsPanel() {
|
||||
{ title: '更新时间', dataIndex: 'updated_at', width: 160, valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
width: 150,
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(r.id)} />
|
||||
<Tooltip title="版本历史">
|
||||
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => { setDetailItem(r.id); setVersionModalOpen(true) }} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
@@ -218,8 +329,8 @@ function ItemsPanel() {
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => setFilters({}),
|
||||
onSearch: (values) => setFilters(values),
|
||||
onReset: () => { setFilters({}); setPage(1) },
|
||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
@@ -240,7 +351,7 @@ function ItemsPanel() {
|
||||
<Modal
|
||||
title="新建知识条目"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
@@ -280,7 +391,7 @@ function ItemsPanel() {
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={detailData?.title || '条目详情'}
|
||||
open={!!detailItem}
|
||||
open={!!detailItem && !versionModalOpen}
|
||||
onCancel={() => setDetailItem(null)}
|
||||
footer={null}
|
||||
width={720}
|
||||
@@ -302,6 +413,47 @@ function ItemsPanel() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 版本历史弹窗 */}
|
||||
<Modal
|
||||
title={`版本历史 - ${detailData?.title || ''}`}
|
||||
open={versionModalOpen}
|
||||
onCancel={() => { setVersionModalOpen(false); setDetailItem(null) }}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
<Table
|
||||
dataSource={versions?.versions || []}
|
||||
rowKey="id"
|
||||
loading={!versions}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '版本', dataIndex: 'version', width: 70 },
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '摘要', dataIndex: 'change_summary', width: 200, ellipsis: true },
|
||||
{ title: '创建者', dataIndex: 'created_by', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Popconfirm
|
||||
title={`确认回滚到版本 ${r.version}?`}
|
||||
description="回滚将创建新版本,当前版本内容会被替换。"
|
||||
onConfirm={() => {
|
||||
setRollingBackVersion(r.version)
|
||||
rollbackMutation.mutate({ itemId: detailItem!, version: r.version })
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" icon={<RollbackOutlined />} loading={rollingBackVersion === r.version}>
|
||||
回滚
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -312,6 +464,7 @@ function SearchPanel() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return
|
||||
@@ -319,7 +472,8 @@ function SearchPanel() {
|
||||
try {
|
||||
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
|
||||
setResults(data)
|
||||
} catch (err) {
|
||||
setHasSearched(true)
|
||||
} catch {
|
||||
message.error('搜索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
@@ -343,10 +497,14 @@ function SearchPanel() {
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{results.length === 0 && !searching && query === '' && (
|
||||
{results.length === 0 && !searching && !hasSearched && (
|
||||
<Empty description="输入关键词搜索知识库" />
|
||||
)}
|
||||
|
||||
{results.length === 0 && !searching && hasSearched && (
|
||||
<Empty description="未找到匹配的知识条目" />
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.map((r) => (
|
||||
<Card key={r.chunk_id} size="small" hoverable>
|
||||
@@ -372,12 +530,32 @@ function SearchPanel() {
|
||||
// === 分析看板 ===
|
||||
|
||||
function AnalyticsPanel() {
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['knowledge-analytics'],
|
||||
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="flex justify-center py-8"><Spin /></div>
|
||||
const { data: trends } = useQuery({
|
||||
queryKey: ['knowledge-trends'],
|
||||
queryFn: ({ signal }) => knowledgeService.getTrends(signal),
|
||||
})
|
||||
|
||||
const { data: topItems } = useQuery({
|
||||
queryKey: ['knowledge-top-items'],
|
||||
queryFn: ({ signal }) => knowledgeService.getTopItems(signal),
|
||||
})
|
||||
|
||||
const { data: quality } = useQuery({
|
||||
queryKey: ['knowledge-quality'],
|
||||
queryFn: ({ signal }) => knowledgeService.getQuality(signal),
|
||||
})
|
||||
|
||||
const { data: gaps } = useQuery({
|
||||
queryKey: ['knowledge-gaps'],
|
||||
queryFn: ({ signal }) => knowledgeService.getGaps(signal),
|
||||
})
|
||||
|
||||
if (overviewLoading) return <div className="flex justify-center py-8"><Spin /></div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -403,27 +581,95 @@ function AnalyticsPanel() {
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="注入率"
|
||||
value={((overview?.injection_rate || 0) * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
/>
|
||||
<Statistic title="注入率" value={((overview?.injection_rate || 0) * 100).toFixed(1)} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="正面反馈率"
|
||||
value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<Statistic title="正面反馈率" value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)} suffix="%" valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="过期条目" value={overview?.stale_items_count || 0} valueStyle={{ color: '#faad14' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 趋势数据表格 */}
|
||||
<Card title="检索趋势(近30天)" className="mt-4" size="small">
|
||||
<Table
|
||||
dataSource={trends?.trends || []}
|
||||
rowKey="date"
|
||||
loading={!trends}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||
{ title: '检索次数', dataIndex: 'count', width: 100 },
|
||||
{ title: '注入次数', dataIndex: 'injected_count', width: 100 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Top Items 表格 */}
|
||||
<Card title="高频引用 Top 20" className="mt-4" size="small">
|
||||
<Table
|
||||
dataSource={topItems?.items || []}
|
||||
rowKey="id"
|
||||
loading={!topItems}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '分类', dataIndex: 'category', width: 120 },
|
||||
{ title: '引用次数', dataIndex: 'ref_count', width: 100 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 质量指标 */}
|
||||
{quality?.categories?.length > 0 && (
|
||||
<Card title="分类质量指标" className="mt-4" size="small">
|
||||
<Table
|
||||
dataSource={quality.categories}
|
||||
rowKey="category"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '分类', dataIndex: 'category', width: 150 },
|
||||
{ title: '总条目', dataIndex: 'total', width: 80 },
|
||||
{ title: '活跃', dataIndex: 'active', width: 80 },
|
||||
{ title: '有关键词', dataIndex: 'with_keywords', width: 100 },
|
||||
{ title: '平均优先级', dataIndex: 'avg_priority', width: 100, render: (v: number) => v?.toFixed(1) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 知识缺口 */}
|
||||
{gaps?.gaps?.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<WarningOutlined style={{ color: '#faad14' }} />
|
||||
<span>知识缺口检测</span>
|
||||
</Space>
|
||||
}
|
||||
className="mt-4"
|
||||
size="small"
|
||||
>
|
||||
<Table
|
||||
dataSource={gaps.gaps}
|
||||
rowKey="query"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: '查询', dataIndex: 'query', ellipsis: true },
|
||||
{ title: '次数', dataIndex: 'count', width: 80 },
|
||||
{ title: '平均分', dataIndex: 'avg_score', width: 100, render: (v: number) => v?.toFixed(2) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -486,18 +732,19 @@ interface TreeNode {
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void): TreeNode[] {
|
||||
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void, onEdit: (id: string) => void): TreeNode[] {
|
||||
return cats.map((c) => ({
|
||||
key: c.id,
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{c.icon || '📁'} {c.name}</span>
|
||||
<Tag>{c.item_count}</Tag>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => onEdit(c.id)} />
|
||||
<Button type="link" size="small" danger onClick={() => onDelete(c.id)}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
children: c.children?.length ? buildTreeData(c.children, onDelete) : undefined,
|
||||
children: c.children?.length ? buildTreeData(c.children, onDelete, onEdit) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ export const knowledgeService = {
|
||||
deleteCategory: (id: string) =>
|
||||
request.delete(`/knowledge/categories/${id}`).then((r) => r.data),
|
||||
|
||||
updateCategory: (id: string, data: { name?: string; description?: string; parent_id?: string; icon?: string }) =>
|
||||
request.put(`/knowledge/categories/${id}`, data).then((r) => r.data),
|
||||
|
||||
reorderCategories: (items: Array<{ id: string; sort_order: number }>) =>
|
||||
request.patch('/knowledge/categories/reorder', { items }).then((r) => r.data),
|
||||
|
||||
getCategoryItems: (id: string, params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
|
||||
request.get<ListItemsResponse>(`/knowledge/categories/${id}/items`, withSignal({ params }, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
// 条目
|
||||
listItems: (params: { page?: number; page_size?: number; category_id?: string; status?: string; keyword?: string }, signal?: AbortSignal) =>
|
||||
request.get<ListItemsResponse>('/knowledge/items', withSignal({ params }, signal))
|
||||
@@ -125,4 +135,28 @@ export const knowledgeService = {
|
||||
getTopItems: (signal?: AbortSignal) =>
|
||||
request.get('/knowledge/analytics/top-items', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getQuality: (signal?: AbortSignal) =>
|
||||
request.get('/knowledge/analytics/quality', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getGaps: (signal?: AbortSignal) =>
|
||||
request.get('/knowledge/analytics/gaps', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
// 版本
|
||||
getVersions: (itemId: string, signal?: AbortSignal) =>
|
||||
request.get(`/knowledge/items/${itemId}/versions`, withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
rollbackVersion: (itemId: string, version: number) =>
|
||||
request.post(`/knowledge/items/${itemId}/rollback/${version}`).then((r) => r.data),
|
||||
|
||||
// 推荐搜索
|
||||
recommend: (data: { query: string; category_id?: string; limit?: number }) =>
|
||||
request.post<SearchResult[]>('/knowledge/recommend', data).then((r) => r.data),
|
||||
|
||||
// 导入
|
||||
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
|
||||
request.post('/knowledge/items/import', data).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS knowledge_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
parent_id TEXT REFERENCES knowledge_categories(id),
|
||||
parent_id TEXT REFERENCES knowledge_categories(id) ON DELETE RESTRICT,
|
||||
icon VARCHAR(50),
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -22,13 +22,13 @@ CREATE INDEX IF NOT EXISTS idx_kc_parent ON knowledge_categories(parent_id);
|
||||
-- 知识条目
|
||||
CREATE TABLE IF NOT EXISTS knowledge_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
category_id TEXT NOT NULL REFERENCES knowledge_categories(id),
|
||||
category_id TEXT NOT NULL REFERENCES knowledge_categories(id) ON DELETE RESTRICT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
keywords TEXT[] DEFAULT '{}',
|
||||
related_questions TEXT[] DEFAULT '{}',
|
||||
priority INT DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deprecated')),
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deprecated', 'draft')),
|
||||
version INT DEFAULT 1,
|
||||
source VARCHAR(50) DEFAULT 'manual',
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS knowledge_items (
|
||||
CHECK (length(content) <= 100000)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ki_category ON knowledge_items(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ki_status_updated ON knowledge_items(status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ki_keywords ON knowledge_items USING GIN(keywords);
|
||||
|
||||
-- 知识分块(RAG 检索核心)
|
||||
@@ -50,6 +51,7 @@ CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
keywords TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_kchunks_item_idx ON knowledge_chunks(item_id, chunk_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_kchunks_item ON knowledge_chunks(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kchunks_keywords ON knowledge_chunks USING GIN(keywords);
|
||||
|
||||
@@ -57,7 +59,7 @@ CREATE INDEX IF NOT EXISTS idx_kchunks_keywords ON knowledge_chunks USING GIN(ke
|
||||
-- 仅在有数据后创建此索引可提升性能,这里预创建
|
||||
CREATE INDEX IF NOT EXISTS idx_kchunks_embedding ON knowledge_chunks
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64);
|
||||
WITH (m = 16, ef_construction = 128);
|
||||
|
||||
-- 版本快照
|
||||
CREATE TABLE IF NOT EXISTS knowledge_versions (
|
||||
@@ -77,8 +79,8 @@ CREATE INDEX IF NOT EXISTS idx_kv_item ON knowledge_versions(item_id);
|
||||
-- 使用追踪
|
||||
CREATE TABLE IF NOT EXISTS knowledge_usage (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES knowledge_items(id),
|
||||
chunk_id TEXT REFERENCES knowledge_chunks(id),
|
||||
item_id TEXT REFERENCES knowledge_items(id) ON DELETE SET NULL,
|
||||
chunk_id TEXT REFERENCES knowledge_chunks(id) ON DELETE SET NULL,
|
||||
session_id VARCHAR(100),
|
||||
query_text TEXT,
|
||||
relevance_score FLOAT,
|
||||
@@ -86,24 +88,36 @@ CREATE TABLE IF NOT EXISTS knowledge_usage (
|
||||
agent_feedback VARCHAR(20) CHECK (agent_feedback IN ('positive', 'negative')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ku_item ON knowledge_usage(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ku_created ON knowledge_usage(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ku_item ON knowledge_usage(item_id) WHERE item_id IS NOT NULL;
|
||||
-- BRIN 索引:追加写入的时间序列数据比 B-tree 更高效
|
||||
CREATE INDEX IF NOT EXISTS idx_ku_created_brin ON knowledge_usage USING brin(created_at);
|
||||
|
||||
-- 权限种子数据
|
||||
-- 权限种子数据(使用 jsonb 操作避免 REPLACE 脆弱性)
|
||||
UPDATE roles
|
||||
SET permissions = REPLACE(
|
||||
permissions,
|
||||
']',
|
||||
', "knowledge:read", "knowledge:write", "knowledge:admin", "knowledge:search"]'
|
||||
SET permissions = (
|
||||
SELECT '[' || string_agg('"' || elem || '"', ', ') || ']'
|
||||
FROM (
|
||||
SELECT DISTINCT elem
|
||||
FROM json_array_elements_text(permissions::json) AS elem
|
||||
UNION ALL SELECT 'knowledge:read'
|
||||
UNION ALL SELECT 'knowledge:write'
|
||||
UNION ALL SELECT 'knowledge:admin'
|
||||
UNION ALL SELECT 'knowledge:search'
|
||||
) sub
|
||||
)
|
||||
WHERE name = 'super_admin'
|
||||
WHERE id = 'super_admin'
|
||||
AND permissions NOT LIKE '%knowledge:read%';
|
||||
|
||||
UPDATE roles
|
||||
SET permissions = REPLACE(
|
||||
permissions,
|
||||
']',
|
||||
', "knowledge:read", "knowledge:write", "knowledge:search"]'
|
||||
SET permissions = (
|
||||
SELECT '[' || string_agg('"' || elem || '"', ', ') || ']'
|
||||
FROM (
|
||||
SELECT DISTINCT elem
|
||||
FROM json_array_elements_text(permissions::json) AS elem
|
||||
UNION ALL SELECT 'knowledge:read'
|
||||
UNION ALL SELECT 'knowledge:write'
|
||||
UNION ALL SELECT 'knowledge:search'
|
||||
) sub
|
||||
)
|
||||
WHERE name = 'admin'
|
||||
WHERE id = 'admin'
|
||||
AND permissions NOT LIKE '%knowledge:read%';
|
||||
|
||||
@@ -16,7 +16,9 @@ use super::types::*;
|
||||
/// GET /api/v1/knowledge/categories — 树形分类列表
|
||||
pub async fn list_categories(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<CategoryResponse>>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let tree = service::list_categories_tree(&state.db).await?;
|
||||
Ok(Json(tree))
|
||||
}
|
||||
@@ -49,14 +51,33 @@ pub async fn create_category(
|
||||
|
||||
/// PUT /api/v1/knowledge/categories/:id — 更新分类
|
||||
pub async fn update_category(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(_id): Path<String>,
|
||||
Json(_req): Json<UpdateCategoryRequest>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateCategoryRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:write")?;
|
||||
// TODO: implement update
|
||||
Ok(Json(serde_json::json!({"updated": true})))
|
||||
|
||||
if let Some(ref name) = req.name {
|
||||
if name.trim().is_empty() {
|
||||
return Err(SaasError::InvalidInput("分类名称不能为空".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let cat = service::update_category(
|
||||
&state.db,
|
||||
&id,
|
||||
req.name.as_deref().map(|n| n.trim()),
|
||||
req.description.as_deref(),
|
||||
req.parent_id.as_deref(),
|
||||
req.icon.as_deref(),
|
||||
).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": cat.id,
|
||||
"name": cat.name,
|
||||
"updated": true,
|
||||
})))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/knowledge/categories/:id — 删除分类
|
||||
@@ -72,11 +93,30 @@ pub async fn delete_category(
|
||||
|
||||
/// GET /api/v1/knowledge/categories/:id/items — 分类下条目列表
|
||||
pub async fn list_category_items(
|
||||
State(_state): State<AppState>,
|
||||
Path(_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<ListItemsQuery>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
// TODO: implement with pagination
|
||||
Ok(Json(serde_json::json!({"items": [], "total": 0})))
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(20).max(1).min(100);
|
||||
let status_filter = query.status.as_deref().unwrap_or("active");
|
||||
|
||||
let (items, total) = service::list_items_by_category(
|
||||
&state.db,
|
||||
&id,
|
||||
status_filter,
|
||||
page,
|
||||
page_size,
|
||||
).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
})))
|
||||
}
|
||||
|
||||
// === 知识条目 CRUD ===
|
||||
@@ -84,12 +124,19 @@ pub async fn list_category_items(
|
||||
/// GET /api/v1/knowledge/items — 分页列表
|
||||
pub async fn list_items(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(query): Query<ListItemsQuery>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(20).min(100);
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let page = query.page.unwrap_or(1).max(1).min(10000);
|
||||
let page_size = query.page_size.unwrap_or(20).max(1).min(100);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
// 转义 ILIKE 通配符,防止用户输入的 % 和 _ 被当作通配符
|
||||
let keyword = query.keyword.as_ref().map(|k| {
|
||||
k.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
|
||||
});
|
||||
|
||||
let items: Vec<KnowledgeItem> = sqlx::query_as(
|
||||
"SELECT ki.* FROM knowledge_items ki \
|
||||
JOIN knowledge_categories kc ON ki.category_id = kc.id \
|
||||
@@ -101,7 +148,7 @@ pub async fn list_items(
|
||||
)
|
||||
.bind(&query.category_id)
|
||||
.bind(&query.status)
|
||||
.bind(&query.keyword)
|
||||
.bind(&keyword)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
@@ -115,7 +162,7 @@ pub async fn list_items(
|
||||
)
|
||||
.bind(&query.category_id)
|
||||
.bind(&query.status)
|
||||
.bind(&query.keyword)
|
||||
.bind(&keyword)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
@@ -173,13 +220,24 @@ pub async fn batch_create_items(
|
||||
}
|
||||
|
||||
let mut created = Vec::new();
|
||||
for req in items {
|
||||
match service::create_item(&state.db, &ctx.account_id, &req).await {
|
||||
for req in &items {
|
||||
if req.title.trim().is_empty() || req.content.trim().is_empty() {
|
||||
tracing::warn!("Batch create: skipping item with empty title or content");
|
||||
continue;
|
||||
}
|
||||
if req.content.len() > 100_000 {
|
||||
tracing::warn!("Batch create: skipping item '{}' (content too long)", req.title);
|
||||
continue;
|
||||
}
|
||||
match service::create_item(&state.db, &ctx.account_id, req).await {
|
||||
Ok(item) => {
|
||||
let _ = state.worker_dispatcher.dispatch(
|
||||
"generate_embedding",
|
||||
serde_json::json!({ "item_id": item.id }),
|
||||
).await;
|
||||
).await.map_err(|e| {
|
||||
tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e);
|
||||
e
|
||||
});
|
||||
created.push(item.id);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -197,8 +255,10 @@ pub async fn batch_create_items(
|
||||
/// GET /api/v1/knowledge/items/:id — 条目详情
|
||||
pub async fn get_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let item = service::get_item(&state.db, &id).await?
|
||||
.ok_or_else(|| SaasError::NotFound("知识条目不存在".into()))?;
|
||||
Ok(Json(serde_json::to_value(item).unwrap_or_default()))
|
||||
@@ -216,10 +276,12 @@ pub async fn update_item(
|
||||
let updated = service::update_item(&state.db, &id, &ctx.account_id, &req).await?;
|
||||
|
||||
// 触发 re-embedding
|
||||
let _ = state.worker_dispatcher.dispatch(
|
||||
if let Err(e) = state.worker_dispatcher.dispatch(
|
||||
"generate_embedding",
|
||||
serde_json::json!({ "item_id": id }),
|
||||
).await;
|
||||
).await {
|
||||
tracing::warn!("[Knowledge] Failed to dispatch re-embedding for item {}: {}", id, e);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": updated.id,
|
||||
@@ -243,8 +305,10 @@ pub async fn delete_item(
|
||||
/// GET /api/v1/knowledge/items/:id/versions
|
||||
pub async fn list_versions(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let versions: Vec<KnowledgeVersion> = sqlx::query_as(
|
||||
"SELECT * FROM knowledge_versions WHERE item_id = $1 ORDER BY version DESC"
|
||||
)
|
||||
@@ -257,8 +321,10 @@ pub async fn list_versions(
|
||||
/// GET /api/v1/knowledge/items/:id/versions/:v
|
||||
pub async fn get_version(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path((id, v)): Path<(String, i32)>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let version: KnowledgeVersion = sqlx::query_as(
|
||||
"SELECT * FROM knowledge_versions WHERE item_id = $1 AND version = $2"
|
||||
)
|
||||
@@ -272,13 +338,27 @@ pub async fn get_version(
|
||||
|
||||
/// POST /api/v1/knowledge/items/:id/rollback/:v
|
||||
pub async fn rollback_version(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path((_id, v)): Path<(String, i32)>,
|
||||
Path((id, v)): Path<(String, i32)>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:admin")?;
|
||||
// TODO: implement rollback
|
||||
Ok(Json(serde_json::json!({"rolled_back_to": v})))
|
||||
|
||||
let updated = service::rollback_version(&state.db, &id, v, &ctx.account_id).await?;
|
||||
|
||||
// 触发 re-embedding
|
||||
if let Err(e) = state.worker_dispatcher.dispatch(
|
||||
"generate_embedding",
|
||||
serde_json::json!({ "item_id": id }),
|
||||
).await {
|
||||
tracing::warn!("[Knowledge] Failed to dispatch re-embedding after rollback for item {}: {}", id, e);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": updated.id,
|
||||
"version": updated.version,
|
||||
"rolled_back_to": v,
|
||||
})))
|
||||
}
|
||||
|
||||
// === 检索 ===
|
||||
@@ -286,8 +366,10 @@ pub async fn rollback_version(
|
||||
/// POST /api/v1/knowledge/search — 语义搜索
|
||||
pub async fn search(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<SearchRequest>,
|
||||
) -> SaasResult<Json<Vec<SearchResult>>> {
|
||||
check_permission(&ctx, "knowledge:search")?;
|
||||
let limit = req.limit.unwrap_or(5).min(10);
|
||||
let min_score = req.min_score.unwrap_or(0.5);
|
||||
let results = service::search(
|
||||
@@ -302,11 +384,20 @@ pub async fn search(
|
||||
|
||||
/// POST /api/v1/knowledge/recommend — 关联推荐
|
||||
pub async fn recommend(
|
||||
State(_state): State<AppState>,
|
||||
Json(_req): Json<SearchRequest>,
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<SearchRequest>,
|
||||
) -> SaasResult<Json<Vec<SearchResult>>> {
|
||||
// TODO: implement recommendation based on keyword overlap
|
||||
Ok(Json(vec![]))
|
||||
check_permission(&ctx, "knowledge:search")?;
|
||||
let limit = req.limit.unwrap_or(5).min(10);
|
||||
let results = service::search(
|
||||
&state.db,
|
||||
&req.query,
|
||||
req.category_id.as_deref(),
|
||||
limit,
|
||||
0.3,
|
||||
).await?;
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
// === 分析看板 ===
|
||||
@@ -314,7 +405,9 @@ pub async fn recommend(
|
||||
/// GET /api/v1/knowledge/analytics/overview
|
||||
pub async fn analytics_overview(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<AnalyticsOverview>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let overview = service::analytics_overview(&state.db).await?;
|
||||
Ok(Json(overview))
|
||||
}
|
||||
@@ -322,7 +415,9 @@ pub async fn analytics_overview(
|
||||
/// GET /api/v1/knowledge/analytics/trends
|
||||
pub async fn analytics_trends(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
// 使用 serde_json::Value 行来避免 PgRow 序列化
|
||||
let trends: Vec<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT json_build_object(
|
||||
@@ -344,7 +439,9 @@ pub async fn analytics_trends(
|
||||
/// GET /api/v1/knowledge/analytics/top-items
|
||||
pub async fn analytics_top_items(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let items: Vec<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT json_build_object(
|
||||
'id', ki.id,
|
||||
@@ -368,16 +465,117 @@ pub async fn analytics_top_items(
|
||||
|
||||
/// GET /api/v1/knowledge/analytics/quality
|
||||
pub async fn analytics_quality(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
Ok(Json(serde_json::json!({"quality": {}})))
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let quality = service::analytics_quality(&state.db).await?;
|
||||
Ok(Json(quality))
|
||||
}
|
||||
|
||||
/// GET /api/v1/knowledge/analytics/gaps
|
||||
pub async fn analytics_gaps(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
Ok(Json(serde_json::json!({"gaps": []})))
|
||||
check_permission(&ctx, "knowledge:read")?;
|
||||
let gaps = service::analytics_gaps(&state.db).await?;
|
||||
Ok(Json(gaps))
|
||||
}
|
||||
|
||||
// === 批量操作 ===
|
||||
|
||||
/// PATCH /api/v1/knowledge/categories/reorder — 批量排序
|
||||
pub async fn reorder_categories(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(items): Json<Vec<ReorderItem>>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:write")?;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(Json(serde_json::json!({"reordered": false, "count": 0})));
|
||||
}
|
||||
if items.len() > 100 {
|
||||
return Err(SaasError::InvalidInput("单次排序不能超过 100 个".into()));
|
||||
}
|
||||
|
||||
// 使用事务保证原子性
|
||||
let mut tx = state.db.begin().await?;
|
||||
for item in &items {
|
||||
sqlx::query("UPDATE knowledge_categories SET sort_order = $1, updated_at = NOW() WHERE id = $2")
|
||||
.bind(item.sort_order)
|
||||
.bind(&item.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"reordered": true, "count": items.len()})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/knowledge/items/import — Markdown 文件导入
|
||||
pub async fn import_items(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<ImportRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "knowledge:write")?;
|
||||
|
||||
if req.files.len() > 20 {
|
||||
return Err(SaasError::InvalidInput("单次导入不能超过 20 个文件".into()));
|
||||
}
|
||||
|
||||
let mut created = Vec::new();
|
||||
for file in &req.files {
|
||||
// 内容长度检查(数据库限制 100KB)
|
||||
if file.content.len() > 100_000 {
|
||||
tracing::warn!("跳过文件 '{}': 内容超长 ({} bytes)", file.title.as_deref().unwrap_or("未命名"), file.content.len());
|
||||
continue;
|
||||
}
|
||||
// 空内容检查
|
||||
if file.content.trim().is_empty() {
|
||||
tracing::warn!("跳过空文件: '{}'", file.title.as_deref().unwrap_or("未命名"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = file.title.clone().unwrap_or_else(|| {
|
||||
file.content.lines().next()
|
||||
.map(|l| l.trim_start_matches('#').trim().to_string())
|
||||
.unwrap_or_else(|| format!("导入条目 {}", created.len() + 1))
|
||||
});
|
||||
|
||||
let item_req = CreateItemRequest {
|
||||
category_id: req.category_id.clone(),
|
||||
title,
|
||||
content: file.content.clone(),
|
||||
keywords: file.keywords.clone(),
|
||||
related_questions: None,
|
||||
priority: None,
|
||||
tags: file.tags.clone(),
|
||||
};
|
||||
|
||||
match service::create_item(&state.db, &ctx.account_id, &item_req).await {
|
||||
Ok(item) => {
|
||||
let _ = state.worker_dispatcher.dispatch(
|
||||
"generate_embedding",
|
||||
serde_json::json!({ "item_id": item.id }),
|
||||
).await.map_err(|e| {
|
||||
tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e);
|
||||
e
|
||||
});
|
||||
created.push(item.id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Import item '{}' failed: {}", item_req.title, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"created_count": created.len(),
|
||||
"ids": created,
|
||||
})))
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
|
||||
pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
@@ -14,10 +14,12 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
.route("/api/v1/knowledge/categories/{id}", put(handlers::update_category))
|
||||
.route("/api/v1/knowledge/categories/{id}", delete(handlers::delete_category))
|
||||
.route("/api/v1/knowledge/categories/{id}/items", get(handlers::list_category_items))
|
||||
.route("/api/v1/knowledge/categories/reorder", patch(handlers::reorder_categories))
|
||||
// 知识条目 CRUD
|
||||
.route("/api/v1/knowledge/items", get(handlers::list_items))
|
||||
.route("/api/v1/knowledge/items", post(handlers::create_item))
|
||||
.route("/api/v1/knowledge/items/batch", post(handlers::batch_create_items))
|
||||
.route("/api/v1/knowledge/items/import", post(handlers::import_items))
|
||||
.route("/api/v1/knowledge/items/{id}", get(handlers::get_item))
|
||||
.route("/api/v1/knowledge/items/{id}", put(handlers::update_item))
|
||||
.route("/api/v1/knowledge/items/{id}", delete(handlers::delete_item))
|
||||
|
||||
@@ -81,6 +81,21 @@ pub async fn create_category(
|
||||
parent_id: Option<&str>,
|
||||
icon: Option<&str>,
|
||||
) -> SaasResult<KnowledgeCategory> {
|
||||
// 验证 parent_id 存在性
|
||||
if let Some(pid) = parent_id {
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM knowledge_categories WHERE id = $1)"
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
format!("父分类 '{}' 不存在", pid),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let category = sqlx::query_as::<_, KnowledgeCategory>(
|
||||
"INSERT INTO knowledge_categories (id, name, description, parent_id, icon) \
|
||||
@@ -126,15 +141,136 @@ pub async fn delete_category(pool: &PgPool, category_id: &str) -> SaasResult<()>
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM knowledge_categories WHERE id = $1")
|
||||
let result = sqlx::query("DELETE FROM knowledge_categories WHERE id = $1")
|
||||
.bind(category_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::error::SaasError::NotFound("分类不存在".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新分类(含循环引用检测 + 深度限制)
|
||||
pub async fn update_category(
|
||||
pool: &PgPool,
|
||||
category_id: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<&str>,
|
||||
icon: Option<&str>,
|
||||
) -> SaasResult<KnowledgeCategory> {
|
||||
if let Some(pid) = parent_id {
|
||||
if pid == category_id {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
"分类不能成为自身的子分类".into(),
|
||||
));
|
||||
}
|
||||
// 检查新的父级不是当前分类的后代(循环检测)
|
||||
let mut check_id = pid.to_string();
|
||||
let mut depth = 0;
|
||||
loop {
|
||||
if check_id == category_id {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
"循环引用:父级分类不能是当前分类的后代".into(),
|
||||
));
|
||||
}
|
||||
let parent: Option<(Option<String>,)> = sqlx::query_as(
|
||||
"SELECT parent_id FROM knowledge_categories WHERE id = $1"
|
||||
)
|
||||
.bind(&check_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match parent {
|
||||
Some((Some(gp),)) => {
|
||||
check_id = gp;
|
||||
depth += 1;
|
||||
if depth > 10 { break; }
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 检查深度限制(最多 3 层)
|
||||
let mut current_depth = 0;
|
||||
let mut check = pid.to_string();
|
||||
while let Some((Some(p),)) = sqlx::query_as::<_, (Option<String>,)>(
|
||||
"SELECT parent_id FROM knowledge_categories WHERE id = $1"
|
||||
)
|
||||
.bind(&check)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
check = p;
|
||||
current_depth += 1;
|
||||
if current_depth > 10 { break; }
|
||||
}
|
||||
if current_depth >= 3 {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
"分类层级不能超过 3 层".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let category = sqlx::query_as::<_, KnowledgeCategory>(
|
||||
"UPDATE knowledge_categories SET \
|
||||
name = COALESCE($1, name), \
|
||||
description = COALESCE($2, description), \
|
||||
parent_id = COALESCE($3, parent_id), \
|
||||
icon = COALESCE($4, icon), \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $5 RETURNING *"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id)
|
||||
.bind(icon)
|
||||
.bind(category_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("分类不存在".into()))?;
|
||||
|
||||
Ok(category)
|
||||
}
|
||||
|
||||
// === 知识条目 CRUD ===
|
||||
|
||||
/// 按分类分页查询条目列表
|
||||
pub async fn list_items_by_category(
|
||||
pool: &PgPool,
|
||||
category_id: &str,
|
||||
status_filter: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> SaasResult<(Vec<KnowledgeItem>, i64)> {
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let items: Vec<KnowledgeItem> = sqlx::query_as(
|
||||
"SELECT * FROM knowledge_items \
|
||||
WHERE category_id = $1 AND status = $2 \
|
||||
ORDER BY priority DESC, updated_at DESC \
|
||||
LIMIT $3 OFFSET $4"
|
||||
)
|
||||
.bind(category_id)
|
||||
.bind(status_filter)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let total: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM knowledge_items WHERE category_id = $1 AND status = $2"
|
||||
)
|
||||
.bind(category_id)
|
||||
.bind(status_filter)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok((items, total.0))
|
||||
}
|
||||
|
||||
/// 创建知识条目
|
||||
pub async fn create_item(
|
||||
pool: &PgPool,
|
||||
@@ -147,6 +283,19 @@ pub async fn create_item(
|
||||
let priority = req.priority.unwrap_or(0);
|
||||
let tags = req.tags.as_deref().unwrap_or(&[]);
|
||||
|
||||
// 验证 category_id 存在性
|
||||
let cat_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM knowledge_categories WHERE id = $1)"
|
||||
)
|
||||
.bind(&req.category_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if !cat_exists {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
format!("分类 '{}' 不存在", req.category_id),
|
||||
));
|
||||
}
|
||||
|
||||
let item = sqlx::query_as::<_, KnowledgeItem>(
|
||||
"INSERT INTO knowledge_items \
|
||||
(id, category_id, title, content, keywords, related_questions, priority, tags, created_by) \
|
||||
@@ -196,19 +345,31 @@ pub async fn get_item(pool: &PgPool, item_id: &str) -> SaasResult<Option<Knowled
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// 更新条目(含版本快照)
|
||||
/// 更新条目(含版本快照)— 事务保护防止并发竞态
|
||||
pub async fn update_item(
|
||||
pool: &PgPool,
|
||||
item_id: &str,
|
||||
account_id: &str,
|
||||
req: &UpdateItemRequest,
|
||||
) -> SaasResult<KnowledgeItem> {
|
||||
// 获取当前条目
|
||||
// status 验证在事务之前,避免无谓锁占用
|
||||
const VALID_STATUSES: &[&str] = &["active", "draft", "archived", "deprecated"];
|
||||
if let Some(ref status) = &req.status {
|
||||
if !VALID_STATUSES.contains(&status.as_str()) {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
format!("无效的状态值: {},有效值: {}", status, VALID_STATUSES.join(", "))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 获取当前条目并锁定行防止并发修改
|
||||
let current = sqlx::query_as::<_, KnowledgeItem>(
|
||||
"SELECT * FROM knowledge_items WHERE id = $1"
|
||||
"SELECT * FROM knowledge_items WHERE id = $1 FOR UPDATE"
|
||||
)
|
||||
.bind(item_id)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("知识条目不存在".into()))?;
|
||||
|
||||
@@ -229,6 +390,7 @@ pub async fn update_item(
|
||||
.unwrap_or(&vec![])
|
||||
.clone();
|
||||
|
||||
|
||||
// 更新条目
|
||||
let updated = sqlx::query_as::<_, KnowledgeItem>(
|
||||
"UPDATE knowledge_items SET \
|
||||
@@ -245,7 +407,7 @@ pub async fn update_item(
|
||||
.bind(&tags)
|
||||
.bind(req.status.as_deref())
|
||||
.bind(item_id)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 创建版本快照
|
||||
@@ -265,9 +427,10 @@ pub async fn update_item(
|
||||
.bind(&related_questions)
|
||||
.bind(req.change_summary.as_deref())
|
||||
.bind(account_id)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -288,39 +451,44 @@ pub async fn delete_item(pool: &PgPool, item_id: &str) -> SaasResult<()> {
|
||||
|
||||
/// 将内容按 Markdown 标题 + 固定长度分块
|
||||
pub fn chunk_content(content: &str, max_tokens: usize, overlap: usize) -> Vec<String> {
|
||||
let mut chunks = Vec::new();
|
||||
|
||||
// 先按 Markdown 标题分段
|
||||
let sections: Vec<&str> = content.split("\n# ").collect();
|
||||
|
||||
for section in sections {
|
||||
// 简单估算 token(中文约 1.5 字符/token)
|
||||
let estimated_tokens = section.len() / 2;
|
||||
let mut chunks = Vec::new();
|
||||
for (i, section) in sections.iter().enumerate() {
|
||||
// 第一个片段保留原始内容,其余片段重新添加标题标记
|
||||
let section_content = if i == 0 {
|
||||
section.to_string()
|
||||
} else {
|
||||
format!("# {}", section)
|
||||
};
|
||||
|
||||
// 磁盘估算 token(中文约 1.5 字符/token)
|
||||
let estimated_tokens = section_content.len() / 2;
|
||||
|
||||
if estimated_tokens <= max_tokens {
|
||||
if !section.trim().is_empty() {
|
||||
chunks.push(section.trim().to_string());
|
||||
if !section_content.trim().is_empty() {
|
||||
chunks.push(section_content.trim().to_string());
|
||||
}
|
||||
} else {
|
||||
// 超长段落按固定长度切分
|
||||
let chars: Vec<char> = section.chars().collect();
|
||||
let chars: Vec<char> = section_content.chars().collect();
|
||||
let chunk_chars = max_tokens * 2; // 近似字符数
|
||||
let overlap_chars = overlap * 2;
|
||||
|
||||
let mut pos = 0;
|
||||
while pos < chars.len() {
|
||||
let end = (pos + chunk_chars).min(chars.len());
|
||||
let chunk: String = chars[pos..end].iter().collect();
|
||||
if !chunk.trim().is_empty() {
|
||||
chunks.push(chunk.trim().to_string());
|
||||
let chunk_str: String = chars[pos..end].iter().collect();
|
||||
if !chunk_str.trim().is_empty() {
|
||||
chunks.push(chunk_str.trim().to_string());
|
||||
}
|
||||
pos = if end >= chars.len() { end } else { end.saturating_sub(overlap_chars) };
|
||||
pos = if end >= chars.len() { end} else { end.saturating_sub(overlap_chars) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
chunks}
|
||||
|
||||
// === 搜索 ===
|
||||
|
||||
@@ -333,14 +501,14 @@ pub async fn search(
|
||||
min_score: f64,
|
||||
) -> SaasResult<Vec<SearchResult>> {
|
||||
// 暂时使用关键词匹配(向量搜索需要 embedding 生成)
|
||||
let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
|
||||
let pattern = format!("%{}%", query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
|
||||
|
||||
let results = if let Some(cat_id) = category_id {
|
||||
sqlx::query_as::<_, (String, String, String, String, String, Vec<String>)>(
|
||||
"SELECT kc.id, kc.item_id, ki.title, kc.name as cat_name, kc.content, kc.keywords \
|
||||
"SELECT kc.id, kc.item_id, ki.title, kcat.name, kc.content, kc.keywords \
|
||||
FROM knowledge_chunks kc \
|
||||
JOIN knowledge_items ki ON kc.item_id = ki.id \
|
||||
JOIN knowledge_categories kc2 ON ki.category_id = kc2.id \
|
||||
JOIN knowledge_categories kcat ON ki.category_id = kcat.id \
|
||||
WHERE ki.status = 'active' \
|
||||
AND ki.category_id = $1 \
|
||||
AND (kc.content ILIKE $2 OR $3 = ANY(kc.keywords)) \
|
||||
@@ -355,10 +523,10 @@ pub async fn search(
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, String, String, String, Vec<String>)>(
|
||||
"SELECT kc.id, kc.item_id, ki.title, kc2.name as cat_name, kc.content, kc.keywords \
|
||||
"SELECT kc.id, kc.item_id, ki.title, kcat.name, kc.content, kc.keywords \
|
||||
FROM knowledge_chunks kc \
|
||||
JOIN knowledge_items ki ON kc.item_id = ki.id \
|
||||
JOIN knowledge_categories kc2 ON ki.category_id = kc2.id \
|
||||
JOIN knowledge_categories kcat ON ki.category_id = kcat.id \
|
||||
WHERE ki.status = 'active' \
|
||||
AND (kc.content ILIKE $1 OR $2 = ANY(kc.keywords)) \
|
||||
ORDER BY ki.priority DESC \
|
||||
@@ -372,13 +540,24 @@ pub async fn search(
|
||||
};
|
||||
|
||||
Ok(results.into_iter().map(|(chunk_id, item_id, title, cat_name, content, keywords)| {
|
||||
// 基于关键词匹配数计算分数:匹配数 / 总查询关键词数
|
||||
let query_keywords: Vec<&str> = query.split_whitespace().collect();
|
||||
let matched_count = keywords.iter()
|
||||
.filter(|k| query_keywords.iter().any(|qk| k.to_lowercase().contains(&qk.to_lowercase())))
|
||||
.count();
|
||||
let score = if keywords.is_empty() || query_keywords.is_empty() {
|
||||
0.5
|
||||
} else {
|
||||
(matched_count as f64 / keywords.len().max(query_keywords.len()) as f64).min(1.0)
|
||||
};
|
||||
|
||||
SearchResult {
|
||||
chunk_id,
|
||||
item_id,
|
||||
item_title: title,
|
||||
category_name: cat_name,
|
||||
content,
|
||||
score: 0.8, // 关键词匹配默认分数
|
||||
score,
|
||||
keywords,
|
||||
}
|
||||
}).filter(|r| r.score >= min_score).collect())
|
||||
@@ -430,6 +609,12 @@ pub async fn analytics_overview(pool: &PgPool) -> SaasResult<AnalyticsOverview>
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let with_feedback: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM knowledge_usage WHERE agent_feedback IS NOT NULL"
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let stale: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM knowledge_items ki \
|
||||
WHERE ki.status = 'active' \
|
||||
@@ -438,7 +623,7 @@ pub async fn analytics_overview(pool: &PgPool) -> SaasResult<AnalyticsOverview>
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let hit_rate = if total_refs.0 > 0 { 1.0 } else { 0.0 };
|
||||
let hit_rate = if total_refs.0 > 0 { with_feedback.0 as f64 / total_refs.0 as f64 } else { 0.0 };
|
||||
let injection_rate = if total_refs.0 > 0 { injected.0 as f64 / total_refs.0 as f64 } else { 0.0 };
|
||||
let positive_rate = if total_refs.0 > 0 { positive.0 as f64 / total_refs.0 as f64 } else { 0.0 };
|
||||
|
||||
@@ -455,3 +640,140 @@ pub async fn analytics_overview(pool: &PgPool) -> SaasResult<AnalyticsOverview>
|
||||
stale_items_count: stale.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// 回滚到指定版本(创建新版本快照)
|
||||
pub async fn rollback_version(
|
||||
pool: &PgPool,
|
||||
item_id: &str,
|
||||
target_version: i32,
|
||||
account_id: &str,
|
||||
) -> SaasResult<KnowledgeItem> {
|
||||
// 使用事务保证原子性,防止并发回滚冲突
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 获取目标版本
|
||||
let version: KnowledgeVersion = sqlx::query_as(
|
||||
"SELECT * FROM knowledge_versions WHERE item_id = $1 AND version = $2"
|
||||
)
|
||||
.bind(item_id)
|
||||
.bind(target_version)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("版本不存在".into()))?;
|
||||
|
||||
// 锁定当前条目行防止并发修改(SELECT FOR UPDATE)
|
||||
let current: Option<(i32,)> = sqlx::query_as(
|
||||
"SELECT version FROM knowledge_items WHERE id = $1 FOR UPDATE"
|
||||
)
|
||||
.bind(item_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let current_version = current
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("知识条目不存在".into()))?
|
||||
.0;
|
||||
|
||||
// 防止版本无限递增: 最多 100 个版本
|
||||
if current_version >= 100 {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
"版本数已达上限(100),请考虑合并历史版本".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_version = current_version + 1;
|
||||
|
||||
// 更新条目为该版本内容
|
||||
let updated = sqlx::query_as::<_, KnowledgeItem>(
|
||||
"UPDATE knowledge_items SET \
|
||||
title = $1, content = $2, keywords = $3, related_questions = $4, \
|
||||
version = $5, updated_at = NOW() \
|
||||
WHERE id = $6 RETURNING *"
|
||||
)
|
||||
.bind(&version.title)
|
||||
.bind(&version.content)
|
||||
.bind(&version.keywords)
|
||||
.bind(&version.related_questions)
|
||||
.bind(new_version)
|
||||
.bind(item_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 创建新版本快照(记录回滚来源)
|
||||
let version_id = uuid::Uuid::new_v4().to_string();
|
||||
let summary = format!("回滚到版本 {}(当前版本 {} → 新版本 {})", target_version, current_version, new_version);
|
||||
sqlx::query(
|
||||
"INSERT INTO knowledge_versions \
|
||||
(id, item_id, version, title, content, keywords, related_questions, \
|
||||
change_summary, created_by) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
|
||||
)
|
||||
.bind(&version_id)
|
||||
.bind(item_id)
|
||||
.bind(new_version)
|
||||
.bind(&updated.title)
|
||||
.bind(&updated.content)
|
||||
.bind(&updated.keywords)
|
||||
.bind(&updated.related_questions)
|
||||
.bind(&summary)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 质量指标(按分类分组)
|
||||
pub async fn analytics_quality(pool: &PgPool) -> SaasResult<serde_json::Value> {
|
||||
let quality: Vec<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT json_build_object(
|
||||
'category', kc.name,
|
||||
'total', COUNT(ki.id),
|
||||
'active', COUNT(CASE WHEN ki.status = 'active' THEN 1 END),
|
||||
'with_keywords', COUNT(CASE WHEN array_length(ki.keywords, 1) > 0 THEN 1 END),
|
||||
'avg_priority', COALESCE(AVG(ki.priority), 0)
|
||||
) as row \
|
||||
FROM knowledge_categories kc \
|
||||
LEFT JOIN knowledge_items ki ON ki.category_id = kc.id \
|
||||
GROUP BY kc.id, kc.name \
|
||||
ORDER BY COUNT(ki.id) DESC"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("analytics_quality query failed: {}", e);
|
||||
vec![]
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"categories": quality.into_iter().map(|(v,)| v).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
/// 知识缺口检测(低分查询聚类)
|
||||
pub async fn analytics_gaps(pool: &PgPool) -> SaasResult<serde_json::Value> {
|
||||
let gaps: Vec<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT json_build_object(
|
||||
'query', ku.query_text,
|
||||
'count', COUNT(*),
|
||||
'avg_score', COALESCE(AVG(ku.relevance_score), 0)
|
||||
) as row \
|
||||
FROM knowledge_usage ku \
|
||||
WHERE ku.created_at >= NOW() - interval '30 days' \
|
||||
AND (ku.relevance_score IS NULL OR ku.relevance_score < 0.5) \
|
||||
AND ku.query_text IS NOT NULL \
|
||||
GROUP BY ku.query_text \
|
||||
ORDER BY COUNT(*) DESC \
|
||||
LIMIT 20"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("analytics_gaps query failed: {}", e);
|
||||
vec![]
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"gaps": gaps.into_iter().map(|(v,)| v).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -199,3 +199,25 @@ pub struct AnalyticsOverview {
|
||||
pub positive_feedback_rate: f64,
|
||||
pub stale_items_count: i64,
|
||||
}
|
||||
|
||||
// === 批量操作 ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReorderItem {
|
||||
pub id: String,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportFile {
|
||||
pub content: String,
|
||||
pub title: Option<String>,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportRequest {
|
||||
pub category_id: String,
|
||||
pub files: Vec<ImportFile>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user