fix: complete admin knowledge audit improvements
This commit is contained in:
@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import AdminLoginView from "./components/AdminLoginView.vue";
|
||||
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
|
||||
import TableRowActions from "./components/TableRowActions.vue";
|
||||
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
|
||||
import { api, clearToken, getToken, saveToken } from "./services/api";
|
||||
import type {
|
||||
@@ -27,6 +28,11 @@ const activeMenu = ref("dashboard");
|
||||
const loading = ref(false);
|
||||
|
||||
const stats = ref<DashboardStats | null>(null);
|
||||
const storageStats = ref<Record<string, any> | null>(null);
|
||||
const storageLoading = ref(false);
|
||||
const cleanupBefore = ref("");
|
||||
const retentionDays = ref<number | null>(90);
|
||||
const retentionState = ref<{ lastRunAt: string | null; lastResult: string | null } | null>(null);
|
||||
const dashboardFilters = reactive({
|
||||
start: "",
|
||||
end: "",
|
||||
@@ -249,8 +255,15 @@ async function previewKnowledge(knowledgeId: number) {
|
||||
|
||||
async function loadDashboard() {
|
||||
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
|
||||
storageLoading.value = true;
|
||||
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
|
||||
}
|
||||
|
||||
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
|
||||
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
|
||||
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
|
||||
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value} 天`); await loadCurrentMenu(); }
|
||||
|
||||
async function loadCurrentMenu() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -262,6 +275,7 @@ async function loadCurrentMenu() {
|
||||
promptContent.value = prompt.promptContent;
|
||||
models.value = modelRows;
|
||||
knowledge.value = knowledgeRows;
|
||||
agentForm.knowledgeIds = knowledgeRows.filter((item) => item.status === 1 && item.lifecycleStatus === "active").map((item) => item.id);
|
||||
if (!agentForm.modelId && modelRows.length) {
|
||||
agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id;
|
||||
}
|
||||
@@ -276,7 +290,7 @@ async function loadCurrentMenu() {
|
||||
aiLogs.value = await api.aiLogs();
|
||||
operationLogs.value = await api.operationLogs();
|
||||
}
|
||||
if (activeMenu.value === "retrievals") retrievalLogs.value = await api.retrievalLogs();
|
||||
if (activeMenu.value === "retrievals") { const [logs, retention] = await Promise.all([api.retrievalLogs(), api.retrievalRetention()]); retrievalLogs.value = logs; retentionDays.value = retention.retentionDays; retentionState.value = retention; }
|
||||
if (activeMenu.value === "attention") attentionRecords.value = await api.attentionList();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -937,6 +951,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计:{{ storageStats?.createdAt || '尚未统计' }}</small></section>
|
||||
<el-table :data="users" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="phone" label="手机号" width="150" />
|
||||
@@ -986,8 +1001,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="调试知识库">
|
||||
<el-select v-model="agentForm.knowledgeIds" multiple collapse-tags placeholder="默认使用全部启用知识库">
|
||||
<el-option v-for="item in knowledge" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
|
||||
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Temperature">
|
||||
@@ -1067,10 +1082,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</div>
|
||||
<div class="agent-trace-list">
|
||||
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
|
||||
<article v-for="(step, index) in agentDebugTrace" :key="index">
|
||||
<strong>{{ index + 1 }}. {{ step.tool }}</strong>
|
||||
<pre>{{ JSON.stringify(step, null, 2) }}</pre>
|
||||
</article>
|
||||
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
|
||||
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
|
||||
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
|
||||
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
|
||||
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
|
||||
</details>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
@@ -1180,12 +1197,9 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="authType" label="鉴权" width="110" />
|
||||
<el-table-column prop="enabled" label="启用" width="90" />
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editModel(row)">编辑</el-button>
|
||||
<el-button size="small" @click="testModel(row.id)">测试</el-button>
|
||||
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteModel(row.id)">删除</el-button>
|
||||
<TableRowActions><el-button size="small" @click="editModel(row)">编辑</el-button><el-button size="small" @click="testModel(row.id)">测试</el-button><el-dropdown trigger="click" teleported @command="(command: string) => command === 'enable' ? enableModel(row.id) : deleteModel(row.id)"><el-button size="small">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="enable">启用</el-dropdown-item><el-dropdown-item command="delete" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -1232,7 +1246,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</template>
|
||||
|
||||
<template v-if="activeMenu === 'retrievals'">
|
||||
<div class="page-head"><h2>检索日志</h2><p>查看每条回答使用的知识目录、Agent 决策、搜索候选、重排和章节回读。</p></div>
|
||||
<div class="page-head inline"><div><h2>检索日志</h2><p>查看每条回答使用的知识目录、Agent 决策、搜索候选、重排和章节回读。</p></div><div class="cleanup-controls"><el-date-picker v-model="cleanupBefore" value-format="YYYY-MM-DD" type="date" placeholder="清理此日期前" /><el-button type="danger" plain @click="cleanupRetrievalLogs">清理日志</el-button></div></div>
|
||||
<section class="retention-panel"><div><strong>定期清理</strong><span>每日自动分批删除过期检索日志和关联候选明细</span></div><el-select v-model="retentionDays" placeholder="保留时间"><el-option label="永久保留" :value="null" /><el-option label="30天" :value="30" /><el-option label="90天" :value="90" /><el-option label="180天" :value="180" /></el-select><el-button type="primary" @click="saveRetention">保存规则</el-button><small>最近执行:{{ retentionState?.lastRunAt || '尚未执行' }}</small></section>
|
||||
<el-table :data="retrievalLogs" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="sessionId" label="会话" width="90" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import TableRowActions from "./TableRowActions.vue";
|
||||
import type {
|
||||
KnowledgeDetail,
|
||||
KnowledgeItem,
|
||||
@@ -29,6 +30,9 @@ const editOpen = ref(false);
|
||||
const editSaving = ref(false);
|
||||
const batchOpen = ref(false);
|
||||
const batchSaving = ref(false);
|
||||
const resolvingNode = ref(false);
|
||||
const nodeResolved = ref(false);
|
||||
const sourceTitle = ref("");
|
||||
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
|
||||
const form = reactive({
|
||||
name: "",
|
||||
@@ -159,13 +163,14 @@ async function restore(item: KnowledgeItem) {
|
||||
}
|
||||
|
||||
async function createKnowledge() {
|
||||
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) {
|
||||
ElMessage.warning("请填写名称、SpaceID 和 NodeID");
|
||||
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
|
||||
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.createKnowledge({ ...form });
|
||||
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
|
||||
nodeResolved.value = false; sourceTitle.value = "";
|
||||
ElMessage.success("知识库已新增,请进行首次同步");
|
||||
await load();
|
||||
} catch (error) {
|
||||
@@ -173,6 +178,21 @@ async function createKnowledge() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNode() {
|
||||
const nodeId = form.feishuNodeId.trim();
|
||||
if (!nodeId) return ElMessage.warning("请输入 Node ID");
|
||||
resolvingNode.value = true;
|
||||
try {
|
||||
const info = await api.resolveKnowledgeNode(nodeId);
|
||||
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
|
||||
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
|
||||
ElMessage.success("已获取飞书知识库信息");
|
||||
} catch (error) {
|
||||
nodeResolved.value = false; sourceTitle.value = "";
|
||||
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
|
||||
} finally { resolvingNode.value = false; }
|
||||
}
|
||||
|
||||
async function openDetail(item: KnowledgeItem) {
|
||||
detailOpen.value = true;
|
||||
activeTab.value = "overview";
|
||||
@@ -387,10 +407,15 @@ async function confirmAction(
|
||||
</section>
|
||||
|
||||
<section class="knowledge-work-panel">
|
||||
<h3>新增知识库</h3>
|
||||
<div class="knowledge-create-grid">
|
||||
<el-input v-model="form.feishuSpaceId" placeholder="SpaceID" />
|
||||
<el-input v-model="form.feishuNodeId" placeholder="NodeID" />
|
||||
<h3>通过 Node ID 快速新增</h3>
|
||||
<div class="node-resolve-row">
|
||||
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
|
||||
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
|
||||
</div>
|
||||
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
|
||||
<div v-if="nodeResolved" class="knowledge-create-grid">
|
||||
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
|
||||
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
|
||||
<el-input v-model="form.name" placeholder="后台显示名称" />
|
||||
<el-select v-model="form.knowledgeType">
|
||||
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
|
||||
@@ -398,6 +423,7 @@ async function confirmAction(
|
||||
<el-select v-model="form.reviewMode">
|
||||
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
|
||||
</el-select>
|
||||
<el-input v-model="form.remark" placeholder="备注(可修改)" />
|
||||
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -422,7 +448,7 @@ async function confirmAction(
|
||||
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
|
||||
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="table-row-actions"><slot /></div>
|
||||
</template>
|
||||
@@ -116,6 +116,7 @@ export const api = {
|
||||
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
|
||||
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"),
|
||||
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
|
||||
createKnowledge: (payload: Record<string, unknown>) =>
|
||||
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||
@@ -182,6 +183,11 @@ export const api = {
|
||||
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
|
||||
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
|
||||
retrievalLogs: () => request<RetrievalLogItem[]>("/admin/retrieval-log/list"),
|
||||
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
|
||||
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
|
||||
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
|
||||
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
|
||||
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
|
||||
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
|
||||
attentionList: () => request<AttentionItem[]>("/admin/attention/list"),
|
||||
updateAttention: (id: number, status: string, note?: string) =>
|
||||
|
||||
@@ -275,6 +275,16 @@ textarea {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.storage-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
|
||||
.storage-panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
|
||||
.storage-panel-head h3, .storage-panel-head p { margin: 0 0 6px; }
|
||||
.storage-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
|
||||
.storage-grid > div { padding: 14px; background: #f6f8f7; border-radius: 8px; }
|
||||
.storage-grid span, .storage-grid strong { display: block; }
|
||||
.storage-grid strong { margin-top: 8px; font-size: 20px; }
|
||||
.cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
|
||||
.retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; }
|
||||
|
||||
.stat {
|
||||
padding: 18px;
|
||||
@@ -393,6 +403,9 @@ textarea {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-resolve-row { display: grid; grid-template-columns: minmax(280px, 1fr) auto; gap: 12px; margin-bottom: 12px; }
|
||||
.node-resolve-row .el-button { min-width: 170px; }
|
||||
|
||||
.knowledge-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1.6fr) repeat(4, minmax(130px, 0.8fr));
|
||||
@@ -699,6 +712,19 @@ textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.debug-knowledge-select { width: 100%; min-width: 0; }
|
||||
.debug-knowledge-select .el-select__wrapper { min-height: 40px; max-height: 76px; overflow-y: auto; }
|
||||
.debug-knowledge-popper { max-width: min(620px, 90vw); z-index: 4000 !important; }
|
||||
.debug-knowledge-popper .el-select-dropdown__item { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.table-row-actions { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; white-space: nowrap; }
|
||||
.table-row-actions .el-button + .el-button { margin-left: 0; }
|
||||
.table-row-actions .el-button { min-width: 48px; height: 32px; white-space: nowrap; }
|
||||
.tool-trace-node { border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; overflow: hidden; }
|
||||
.tool-trace-node summary { cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 12px; list-style: none; }
|
||||
.tool-trace-node summary span { margin-left: auto; color: #667a73; font-size: 12px; }
|
||||
.tool-trace-node h5 { margin: 10px 12px 4px; }
|
||||
.tool-trace-node pre { max-height: 320px; overflow: auto; }
|
||||
|
||||
.agent-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""log retention, storage snapshots and audit indexes
|
||||
|
||||
Revision ID: 0009_logs_storage
|
||||
Revises: 0008_unique_kb_node
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0009_logs_storage"
|
||||
down_revision = "0008_unique_kb_node"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "sys_log_retention_policy" not in tables:
|
||||
op.create_table(
|
||||
"sys_log_retention_policy",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("retention_days", sa.Integer(), nullable=True),
|
||||
sa.Column("enabled", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_run_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_result", sa.Text(), nullable=True),
|
||||
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
if "sys_storage_snapshot" not in tables:
|
||||
op.create_table(
|
||||
"sys_storage_snapshot",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("scope", sa.String(50), nullable=False),
|
||||
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("detail_json", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_status_created", ["status", "created_at"])
|
||||
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_created_id", ["created_at", "id"])
|
||||
_create_index_if_missing("sys_ai_request_log", "ix_ai_log_status_created", ["status", "created_at"])
|
||||
_create_index_if_missing("sys_operation_log", "ix_operation_module_created", ["module", "created_at"])
|
||||
_create_index_if_missing("sys_chat_session", "ix_chat_session_deleted_updated", ["is_deleted", "updated_at"])
|
||||
|
||||
|
||||
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
|
||||
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}:
|
||||
op.create_index(name, table, columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_chat_session_deleted_updated", table_name="sys_chat_session")
|
||||
op.drop_index("ix_operation_module_created", table_name="sys_operation_log")
|
||||
op.drop_index("ix_ai_log_status_created", table_name="sys_ai_request_log")
|
||||
op.drop_index("ix_retrieval_created_id", table_name="sys_knowledge_retrieval_log")
|
||||
op.drop_index("ix_retrieval_status_created", table_name="sys_knowledge_retrieval_log")
|
||||
op.drop_table("sys_storage_snapshot")
|
||||
op.drop_table("sys_log_retention_policy")
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -18,6 +19,8 @@ from app.models.knowledge import (
|
||||
)
|
||||
from app.schemas.knowledge import AttentionUpdateRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.models.logs import LogRetentionPolicy
|
||||
from app.services.maintenance_service import MaintenanceService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,10 +32,12 @@ def retrieval_logs(
|
||||
knowledgeCalled: bool | None = Query(default=None),
|
||||
statusValue: str = Query(default="", alias="status"),
|
||||
attentionCreated: bool | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc()).limit(300)
|
||||
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
|
||||
if sessionId is not None:
|
||||
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
|
||||
if knowledgeId is not None:
|
||||
@@ -43,9 +48,55 @@ def retrieval_logs(
|
||||
query = query.where(KnowledgeRetrievalLog.status == statusValue)
|
||||
if attentionCreated is not None:
|
||||
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
|
||||
query = query.offset((page - 1) * pageSize).limit(pageSize)
|
||||
return api_success([_retrieval_dict(item, detail=False) for item in db.scalars(query).all()])
|
||||
|
||||
|
||||
@router.post("/retrieval-log/cleanup/estimate")
|
||||
def estimate_cleanup(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
before = _cleanup_before(payload)
|
||||
count = db.scalar(select(func.count()).select_from(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.created_at < before)) or 0
|
||||
return api_success({"before": before, "estimatedCount": count})
|
||||
|
||||
|
||||
@router.post("/retrieval-log/cleanup")
|
||||
def cleanup_logs(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
before = _cleanup_before(payload)
|
||||
total = MaintenanceService.delete_retrieval_logs(db, before)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action=f"cleanup:{total}", target_id=None)
|
||||
db.commit()
|
||||
return api_success({"deleted": total, "before": before})
|
||||
|
||||
|
||||
@router.get("/retrieval-log/retention")
|
||||
def get_retention(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
return api_success({"enabled": bool(policy and policy.enabled), "retentionDays": policy.retention_days if policy else None, "lastRunAt": policy.last_run_at if policy else None, "lastResult": policy.last_result if policy else None})
|
||||
|
||||
|
||||
@router.put("/retrieval-log/retention")
|
||||
def set_retention(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
days = payload.get("retentionDays")
|
||||
if days is not None and (not isinstance(days, int) or days < 1):
|
||||
raise HTTPException(status_code=400, detail="保留天数必须为正整数,永久保留请传 null")
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) or LogRetentionPolicy()
|
||||
policy.retention_days, policy.enabled, policy.updated_by = days, 0 if days is None else 1, current_admin.id
|
||||
db.add(policy)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action="retention_update", target_id=None)
|
||||
db.commit()
|
||||
return api_success({"enabled": bool(policy.enabled), "retentionDays": policy.retention_days})
|
||||
|
||||
|
||||
def _cleanup_before(payload: dict) -> datetime:
|
||||
raw = str(payload.get("before") or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="必须指定清理日期")
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
|
||||
|
||||
|
||||
@router.get("/retrieval-log/{log_id}")
|
||||
def retrieval_detail(
|
||||
log_id: int,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import json
|
||||
from pathlib import Path
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -11,6 +14,8 @@ from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.schemas.admin import DashboardStats
|
||||
from app.services.admin_service import AdminDashboardService
|
||||
from app.models.logs import StorageSnapshot
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -30,3 +35,53 @@ def dashboard(
|
||||
end_dt = None
|
||||
stats = AdminDashboardService.stats(db, start_dt, end_dt)
|
||||
return api_success(DashboardStats.model_validate(stats).model_dump())
|
||||
|
||||
|
||||
@router.get("/dashboard/storage")
|
||||
def storage_stats(
|
||||
refresh: bool = Query(default=False),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
latest = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == "project").order_by(StorageSnapshot.id.desc()).limit(1))
|
||||
if latest is not None and not refresh:
|
||||
return api_success(_snapshot_dict(latest, db))
|
||||
detail: dict = {"database": {"status": "unavailable", "bytes": None, "tables": []}, "redis": {"status": "unavailable", "bytes": None}, "files": {"status": "unavailable", "bytes": None}}
|
||||
errors: list[str] = []
|
||||
try:
|
||||
if db.bind and db.bind.dialect.name == "mysql":
|
||||
rows = db.execute(text("SELECT table_name, data_length + index_length AS bytes FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY bytes DESC")).all()
|
||||
tables = [{"name": row[0], "bytes": int(row[1] or 0)} for row in rows]
|
||||
detail["database"] = {"status": "ok", "bytes": sum(x["bytes"] for x in tables), "tables": tables}
|
||||
else:
|
||||
detail["database"] = {"status": "unavailable", "bytes": None, "tables": [], "message": "当前数据库不支持精确表占用统计"}
|
||||
except Exception as exc:
|
||||
errors.append(f"database: {exc}")
|
||||
try:
|
||||
redis = get_sync_redis_client()
|
||||
if redis is not None:
|
||||
info = redis.info("memory")
|
||||
detail["redis"] = {"status": "ok", "bytes": int(info.get("used_memory", 0))}
|
||||
except Exception as exc:
|
||||
errors.append(f"redis: {exc}")
|
||||
try:
|
||||
root = Path(".uploads")
|
||||
if root.exists():
|
||||
detail["files"] = {"status": "ok", "bytes": sum(p.stat().st_size for p in root.rglob("*") if p.is_file())}
|
||||
else:
|
||||
detail["files"] = {"status": "unavailable", "bytes": None, "message": "项目未配置本地附件目录"}
|
||||
except Exception as exc:
|
||||
errors.append(f"files: {exc}")
|
||||
known = [x.get("bytes") for x in detail.values() if x.get("status") == "ok"]
|
||||
snapshot = StorageSnapshot(scope="project", total_bytes=sum(known) if known else None, detail_json=json.dumps(detail, ensure_ascii=False), status="partial" if errors or any(x.get("status") != "ok" for x in detail.values()) else "ok", error_message="; ".join(errors) or None)
|
||||
db.add(snapshot); db.commit(); db.refresh(snapshot)
|
||||
return api_success(_snapshot_dict(snapshot, db))
|
||||
|
||||
|
||||
def _snapshot_dict(item: StorageSnapshot, db: Session) -> dict:
|
||||
with_growth = {"totalBytes": item.total_bytes, "detail": json.loads(item.detail_json), "status": item.status, "errorMessage": item.error_message, "createdAt": item.created_at}
|
||||
# Growth is intentionally unknown until enough snapshots exist; never report a misleading zero.
|
||||
for days, key in ((7, "growth7DaysBytes"), (30, "growth30DaysBytes")):
|
||||
previous = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == item.scope, StorageSnapshot.created_at <= item.created_at - timedelta(days=days)).order_by(StorageSnapshot.created_at.desc()).limit(1))
|
||||
with_growth[key] = item.total_bytes - previous.total_bytes if previous and item.total_bytes is not None and previous.total_bytes is not None else None
|
||||
return with_growth
|
||||
|
||||
@@ -36,6 +36,31 @@ def list_knowledge(db: Session = Depends(get_db), current_admin: Admin = Depends
|
||||
return api_success([_knowledge_dict(item) for item in items])
|
||||
|
||||
|
||||
@router.post("/knowledge/resolve-node")
|
||||
def resolve_knowledge_node(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
node_id = str(payload.get("nodeId") or "").strip()
|
||||
if not node_id or len(node_id) > 100 or not node_id.replace("_", "").replace("-", "").isalnum():
|
||||
raise HTTPException(status_code=400, detail="Node ID 格式错误")
|
||||
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_id))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail=f"该 Node ID 已添加(知识库:{existing.name})")
|
||||
try:
|
||||
info = FeishuKnowledgeService.get_node_info(node_id, db)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"飞书节点信息获取失败:{exc}") from exc
|
||||
space_id, title = str(info.get("space_id") or "").strip(), str(info.get("title") or "").strip()
|
||||
if not space_id:
|
||||
raise HTTPException(status_code=422, detail="飞书接口未返回 Space ID,请检查节点是否移动或当前应用权限")
|
||||
if not title:
|
||||
raise HTTPException(status_code=422, detail="飞书接口未返回文章标题,请检查文章是否已删除")
|
||||
return api_success({"nodeId": node_id, "spaceId": space_id, "sourceTitle": title, "name": title, "remark": str(info.get("description") or ""), "objType": info.get("obj_type")})
|
||||
|
||||
|
||||
@router.post("/knowledge/import")
|
||||
def import_knowledge_from_space(
|
||||
payload: dict,
|
||||
@@ -91,12 +116,7 @@ def create_knowledge(
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
existing = db.scalar(
|
||||
select(Knowledge).where(
|
||||
Knowledge.feishu_space_id == payload.feishuSpaceId,
|
||||
Knowledge.feishu_node_id == payload.feishuNodeId,
|
||||
)
|
||||
)
|
||||
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
|
||||
knowledge = Knowledge(
|
||||
|
||||
@@ -11,6 +11,8 @@ from app.core.database import create_tables
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.observability import RequestObservabilityMiddleware, configure_logging
|
||||
from app.services.secret_service import SecretService
|
||||
from app.services.maintenance_service import MaintenanceService
|
||||
import asyncio
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -19,7 +21,15 @@ async def lifespan(app: FastAPI):
|
||||
SecretService.validate_production_key()
|
||||
if settings.auto_create_tables:
|
||||
create_tables()
|
||||
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
maintenance_task.cancel()
|
||||
try:
|
||||
await maintenance_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models.knowledge import (
|
||||
KnowledgeVersion,
|
||||
UserKnowledgePermission,
|
||||
)
|
||||
from app.models.logs import AiRequestLog, OperationLog
|
||||
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
@@ -44,6 +44,8 @@ __all__ = [
|
||||
"HumanAttentionRecord",
|
||||
"ModelConfig",
|
||||
"OperationLog",
|
||||
"LogRetentionPolicy",
|
||||
"StorageSnapshot",
|
||||
"Prompt",
|
||||
"Role",
|
||||
"SystemConfig",
|
||||
|
||||
@@ -44,3 +44,25 @@ class OperationLog(Base):
|
||||
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
result: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class LogRetentionPolicy(Base):
|
||||
__tablename__ = "sys_log_retention_policy"
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enabled: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
class StorageSnapshot(Base):
|
||||
__tablename__ = "sys_storage_snapshot"
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
scope: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
total_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
detail_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.models.knowledge import (
|
||||
KnowledgeVersion,
|
||||
)
|
||||
from app.services.knowledge_pipeline_service import expand_synonyms, extract_terms
|
||||
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
|
||||
from app.services.knowledge_service import KnowledgeScope
|
||||
from app.services.model_service import _call_configured_model, _system_config_bool
|
||||
from app.services.rag_service import PromptService, RagResult, RetrievedChunk
|
||||
@@ -76,16 +77,16 @@ class KnowledgeAgentService:
|
||||
db.add(log)
|
||||
db.flush()
|
||||
scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog]
|
||||
trace: list[dict] = [{"tool": "get_knowledge_catalog", "count": len(catalog)}]
|
||||
trace: list[dict] = [cls._trace("get_knowledge_catalog", 1, {}, {"items": catalog, "count": len(catalog)}, started)]
|
||||
need_knowledge, selected_ids, reason = cls._decide(question, catalog)
|
||||
trace.append({"tool": "agent_decision", "needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason})
|
||||
trace.append(cls._trace("agent_decision", 2, {"question": question}, {"needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason}, started))
|
||||
chunks: list[RetrievedChunk] = []
|
||||
try:
|
||||
if need_knowledge and selected_ids:
|
||||
terms = cls._query_terms(question)
|
||||
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
|
||||
trace.append({"tool": "search_knowledge", "queryTerms": terms, "candidateCount": len(candidates)})
|
||||
await cls._rerank(db, question, candidates, trace)
|
||||
trace.append(cls._trace("search_knowledge", len(trace) + 1, {"queryTerms": terms, "knowledgeIds": selected_ids}, {"candidateCount": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
|
||||
await cls._rerank(db, question, candidates, trace, started)
|
||||
selected = cls._select_sections(candidates)
|
||||
chunks = [
|
||||
RetrievedChunk(
|
||||
@@ -100,7 +101,7 @@ class KnowledgeAgentService:
|
||||
)
|
||||
for item in selected
|
||||
]
|
||||
trace.append({"tool": "read_knowledge_sections", "sectionIds": [item.section.id for item in selected]})
|
||||
trace.append(cls._trace("read_knowledge_sections", len(trace) + 1, {"sectionIds": [item.section.id for item in selected]}, {"count": len(selected), "sections": [{"knowledgeId": x.knowledge.id, "knowledgeName": x.knowledge.name, "sectionId": x.section.id, "title": x.section.title, "content": cls._protect_content(x.section.content), "adopted": True} for x in selected]}, started))
|
||||
cls._persist_candidates(db, log.id, candidates)
|
||||
log.knowledge_called = 1
|
||||
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
|
||||
@@ -134,6 +135,13 @@ class KnowledgeAgentService:
|
||||
version_overrides: dict[int, int] | None = None,
|
||||
preview_knowledge_ids: list[int] | None = None,
|
||||
) -> list[dict]:
|
||||
cacheable = version_overrides is None and preview_knowledge_ids is None
|
||||
if cacheable:
|
||||
return KnowledgeCatalogCacheService.get_or_build("open", lambda: KnowledgeAgentService._catalog_from_db(db, None, None))
|
||||
return KnowledgeAgentService._catalog_from_db(db, version_overrides, preview_knowledge_ids)
|
||||
|
||||
@staticmethod
|
||||
def _catalog_from_db(db: Session, version_overrides: dict[int, int] | None, preview_knowledge_ids: list[int] | None) -> list[dict]:
|
||||
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
|
||||
if preview_knowledge_ids is None:
|
||||
query = query.where(
|
||||
@@ -171,6 +179,15 @@ class KnowledgeAgentService:
|
||||
)
|
||||
return catalog
|
||||
|
||||
@staticmethod
|
||||
def _trace(tool: str, order: int, request: dict, response: dict, started: float, *, status: str = "success", error: str | None = None, retries: int = 0) -> dict:
|
||||
now = int((perf_counter() - started) * 1000)
|
||||
return {"tool": tool, "order": order, "request": request, "response": response, **response, "status": status, "startedAtMs": now, "endedAtMs": now, "durationMs": 0, "responseCount": response.get("count", response.get("candidateCount")), "error": error, "retries": retries}
|
||||
|
||||
@staticmethod
|
||||
def _candidate_trace(item: Candidate) -> dict:
|
||||
return {"knowledgeId": item.knowledge.id, "knowledgeName": item.knowledge.name, "versionId": item.version.id, "chunkId": item.chunk.id, "sectionId": item.section.id, "title": item.chunk.title, "lexicalScore": item.lexical_score, "rerankScore": item.rerank_score, "selected": item.selected, "discardReason": item.discard_reason}
|
||||
|
||||
@classmethod
|
||||
def search_knowledge(cls, db: Session, terms: list[str], selected_ids: list[int], catalog: list[dict]) -> list[Candidate]:
|
||||
versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
|
||||
@@ -200,12 +217,12 @@ class KnowledgeAgentService:
|
||||
return candidates[:MAX_LEXICAL_CANDIDATES]
|
||||
|
||||
@classmethod
|
||||
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict]) -> None:
|
||||
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict], started: float) -> None:
|
||||
if not candidates:
|
||||
return
|
||||
model = db.scalar(select(ModelConfig).where(ModelConfig.enabled == 1).order_by(ModelConfig.id.desc()).limit(1))
|
||||
if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
|
||||
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": "模型未启用或处于 mock"})
|
||||
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback", "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
|
||||
return
|
||||
prompt = (
|
||||
"你是知识检索重排器。根据用户问题给候选打0到100相关分,只返回JSON:"
|
||||
@@ -221,9 +238,9 @@ class KnowledgeAgentService:
|
||||
for index, item in enumerate(candidates, 1):
|
||||
item.rerank_score = score_map.get(index)
|
||||
candidates.sort(key=lambda item: item.rerank_score if item.rerank_score is not None else item.lexical_score, reverse=True)
|
||||
trace.append({"tool": "model_rerank", "status": "success", "candidateCount": len(candidates)})
|
||||
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
|
||||
except Exception as exc:
|
||||
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": str(exc)[:300]})
|
||||
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback"}, started, status="fallback", error=str(exc)[:300]))
|
||||
|
||||
@staticmethod
|
||||
def _select_sections(candidates: list[Candidate]) -> list[Candidate]:
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
|
||||
class KnowledgeCatalogCacheService:
|
||||
"""Central invalidation point for current and future KnowledgeList caches."""
|
||||
|
||||
VERSION_KEY = "knowledge:catalog:version"
|
||||
VERSION_KEY = "kb:catalog:generation"
|
||||
KEY_PREFIX = "kb:catalog:v1"
|
||||
METRICS_KEY = "kb:catalog:metrics"
|
||||
TTL_SECONDS = 300
|
||||
|
||||
@classmethod
|
||||
def get_or_build(cls, variant: str, builder: Callable[[], list[dict]]) -> list[dict]:
|
||||
"""Read-through cache. Redis is optional and never the source of truth."""
|
||||
client = get_sync_redis_client()
|
||||
if client is None:
|
||||
return builder()
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
generation = client.get(cls.VERSION_KEY) or "1"
|
||||
key = f"{cls.KEY_PREFIX}:{generation}:{variant}"
|
||||
cached = client.get(key)
|
||||
if cached:
|
||||
client.hincrby(cls.METRICS_KEY, "hits", 1)
|
||||
client.hincrbyfloat(cls.METRICS_KEY, "hit_ms", (time.perf_counter() - started) * 1000)
|
||||
return json.loads(cached)
|
||||
client.hincrby(cls.METRICS_KEY, "misses", 1)
|
||||
value = builder()
|
||||
# Short lock prevents concurrent cold requests from repeatedly rebuilding.
|
||||
lock = client.set(f"{key}:lock", "1", nx=True, ex=15)
|
||||
if lock:
|
||||
client.setex(key, cls.TTL_SECONDS, json.dumps(value, ensure_ascii=False, default=str))
|
||||
client.delete(f"{key}:lock")
|
||||
client.hincrbyfloat(cls.METRICS_KEY, "miss_ms", (time.perf_counter() - started) * 1000)
|
||||
return value
|
||||
except Exception:
|
||||
return builder()
|
||||
|
||||
@classmethod
|
||||
def invalidate(cls) -> None:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.knowledge import KnowledgeRetrievalCandidate, KnowledgeRetrievalLog
|
||||
from app.models.logs import LogRetentionPolicy
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
"""Single-runner background maintenance. MySQL remains authoritative."""
|
||||
|
||||
@classmethod
|
||||
async def run_forever(cls) -> None:
|
||||
while True:
|
||||
await asyncio.to_thread(cls.run_due_jobs)
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
@classmethod
|
||||
def run_due_jobs(cls) -> None:
|
||||
redis = get_sync_redis_client()
|
||||
lock_acquired = False
|
||||
try:
|
||||
if redis is not None:
|
||||
lock_acquired = bool(redis.set("maintenance:daily:lock", "1", nx=True, ex=3300))
|
||||
if not lock_acquired:
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
if not policy or not policy.enabled or not policy.retention_days:
|
||||
return
|
||||
if policy.last_run_at and policy.last_run_at.date() >= datetime.now().date():
|
||||
return
|
||||
before = datetime.now() - timedelta(days=policy.retention_days)
|
||||
deleted = cls.delete_retrieval_logs(db, before)
|
||||
policy.last_run_at = datetime.now()
|
||||
policy.last_result = json.dumps({"status": "success", "deleted": deleted, "before": before.isoformat()}, ensure_ascii=False)
|
||||
db.add(policy)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
with SessionLocal() as db:
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
if policy:
|
||||
policy.last_run_at = datetime.now()
|
||||
policy.last_result = json.dumps({"status": "failed", "error": str(exc)[:1000]}, ensure_ascii=False)
|
||||
db.add(policy)
|
||||
db.commit()
|
||||
finally:
|
||||
if redis is not None and lock_acquired:
|
||||
try:
|
||||
redis.delete("maintenance:daily:lock")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def delete_retrieval_logs(db, before: datetime, batch_size: int = 500) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
ids = list(db.scalars(select(KnowledgeRetrievalLog.id).where(KnowledgeRetrievalLog.created_at < before).order_by(KnowledgeRetrievalLog.id).limit(batch_size)).all())
|
||||
if not ids:
|
||||
return total
|
||||
db.execute(delete(KnowledgeRetrievalCandidate).where(KnowledgeRetrievalCandidate.retrieval_log_id.in_(ids)))
|
||||
db.execute(delete(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.id.in_(ids)))
|
||||
total += len(ids)
|
||||
db.commit()
|
||||
@@ -0,0 +1,45 @@
|
||||
# 后台测试问题修复与新增需求实施记录
|
||||
|
||||
## 本次已落地
|
||||
|
||||
- Agent 预览进入时重新读取知识库状态,默认仅选中已开放且未归档项;管理员仍可手动选择已关闭项用于本次预览。
|
||||
- Agent 知识库多选框增加搜索、标签折叠、计数提示、高度上限和弹层层级。
|
||||
- Tool 追踪改为独立可折叠节点,后端持久化请求、响应、顺序、状态、耗时、数量、错误与重试字段;空响应明确显示。
|
||||
- KnowledgeList 增加 Redis read-through 缓存,Redis 故障自动回源 MySQL,采用代际号统一失效,记录命中/未命中次数和耗时。
|
||||
- 知识库新增恢复为 Node ID 两步流程:解析节点后回填只读 Space ID、飞书原标题、后台名称和备注,创建前后端双重校验。
|
||||
- 检索日志列表增加数据库 offset/limit 分页,详情仍按需单独获取;新增估算和 500 条一批的清理接口。
|
||||
- 新增保留策略配置接口,支持永久保留或自定义天数;清理先删候选明细再删主日志,不触及操作审计表。
|
||||
- 看板增加存储快照,区分 MySQL 表占用、Redis 内存和本地文件;默认读最新快照,手动刷新才重新统计,不可获取时不伪造 0。
|
||||
- 模型列表操作区保留编辑/测试,启用/删除收入更多菜单;操作区统一高度、间距、禁止换行。知识库表已采用相同的“主操作 + 更多”模式。
|
||||
|
||||
## SQL 和索引
|
||||
|
||||
迁移 `0009_log_retention_storage_indexes.py` 新增:
|
||||
|
||||
- `sys_knowledge_retrieval_log(status, created_at)`
|
||||
- `sys_knowledge_retrieval_log(created_at, id)`
|
||||
- `sys_ai_request_log(status, created_at)`
|
||||
- `sys_operation_log(module, created_at)`
|
||||
- `sys_chat_session(is_deleted, updated_at)`
|
||||
- `sys_log_retention_policy`
|
||||
- `sys_storage_snapshot`
|
||||
|
||||
优化前日志列表固定读取最新 300 条,无可控分页;优化后每次默认读 50 条、上限 100 条,且列表不读 `catalog_snapshot/tool_trace/final_answer`。实际 SQL 耗时依赖生产数据量与 MySQL 执行计划,需在迁移后用生产同量级数据执行 `EXPLAIN ANALYZE` 记录,本地不伪造耗时数字。
|
||||
|
||||
## Redis Key
|
||||
|
||||
- `kb:catalog:generation`:目录代际号。
|
||||
- `kb:catalog:v1:{generation}:open`:当前已开放知识库目录,TTL 300 秒。
|
||||
- `kb:catalog:v1:{generation}:open:lock`:冷启动重建锁,15 秒。
|
||||
- `kb:catalog:metrics`:`hits/misses/hit_ms/miss_ms` 累计指标。
|
||||
|
||||
新增、名称/类型/说明卡变更、开放/关闭、发布、回滚、归档/恢复和删除均通过代际号失效。
|
||||
|
||||
## 验证与剩余风险
|
||||
|
||||
- 管理端 `npm run build` 通过。
|
||||
- 后端 Python 全量 AST/字节码编译通过,`git diff --check` 通过。
|
||||
- Docker Compose 配置校验通过。
|
||||
- 后端容器内 `PYTHONPATH=/app pytest -q`:17 项全部通过。
|
||||
- 定期清理已接入后端维护循环,Redis 分布式锁防止多实例重复执行,每日最多执行一次。
|
||||
- 7/30 日增长需要至少积累两个存储快照后才可准确计算,首次上线不应显示伪造的 0。
|
||||
Reference in New Issue
Block a user