fix: complete admin knowledge audit improvements

This commit is contained in:
2026-07-13 11:40:34 +08:00
parent 82e0c02bac
commit 120fe185f9
16 changed files with 502 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue"; import AdminLoginView from "./components/AdminLoginView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue"; import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings"; import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
import { api, clearToken, getToken, saveToken } from "./services/api"; import { api, clearToken, getToken, saveToken } from "./services/api";
import type { import type {
@@ -27,6 +28,11 @@ const activeMenu = ref("dashboard");
const loading = ref(false); const loading = ref(false);
const stats = ref<DashboardStats | null>(null); 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({ const dashboardFilters = reactive({
start: "", start: "",
end: "", end: "",
@@ -249,8 +255,15 @@ async function previewKnowledge(knowledgeId: number) {
async function loadDashboard() { async function loadDashboard() {
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end); 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() { async function loadCurrentMenu() {
loading.value = true; loading.value = true;
try { try {
@@ -262,6 +275,7 @@ async function loadCurrentMenu() {
promptContent.value = prompt.promptContent; promptContent.value = prompt.promptContent;
models.value = modelRows; models.value = modelRows;
knowledge.value = knowledgeRows; knowledge.value = knowledgeRows;
agentForm.knowledgeIds = knowledgeRows.filter((item) => item.status === 1 && item.lifecycleStatus === "active").map((item) => item.id);
if (!agentForm.modelId && modelRows.length) { if (!agentForm.modelId && modelRows.length) {
agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id; agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id;
} }
@@ -276,7 +290,7 @@ async function loadCurrentMenu() {
aiLogs.value = await api.aiLogs(); aiLogs.value = await api.aiLogs();
operationLogs.value = await api.operationLogs(); 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(); if (activeMenu.value === "attention") attentionRecords.value = await api.attentionList();
} finally { } finally {
loading.value = false; loading.value = false;
@@ -937,6 +951,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div> </div>
</div> </div>
</section> </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 :data="users" stripe>
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" /> <el-table-column prop="phone" label="手机号" width="150" />
@@ -986,8 +1001,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="调试知识库"> <el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" multiple collapse-tags placeholder="默认使用全部启用知识库"> <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" :value="item.id" /> <el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="Temperature"> <el-form-item label="Temperature">
@@ -1067,10 +1082,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div> </div>
<div class="agent-trace-list"> <div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" /> <el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<article v-for="(step, index) in agentDebugTrace" :key="index"> <details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<strong>{{ index + 1 }}. {{ step.tool }}</strong> <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>
<pre>{{ JSON.stringify(step, null, 2) }}</pre> <h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
</article> <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> </div>
</aside> </aside>
</section> </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="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" /> <el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" /> <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 }"> <template #default="{ row }">
<el-button size="small" @click="editModel(row)">编辑</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>
<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>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -1232,7 +1246,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
<template v-if="activeMenu === 'retrievals'"> <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 :data="retrievalLogs" stripe>
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="sessionId" label="会话" width="90" /> <el-table-column prop="sessionId" label="会话" width="90" />

View File

@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import TableRowActions from "./TableRowActions.vue";
import type { import type {
KnowledgeDetail, KnowledgeDetail,
KnowledgeItem, KnowledgeItem,
@@ -29,6 +30,9 @@ const editOpen = ref(false);
const editSaving = ref(false); const editSaving = ref(false);
const batchOpen = ref(false); const batchOpen = ref(false);
const batchSaving = 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 filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
const form = reactive({ const form = reactive({
name: "", name: "",
@@ -159,13 +163,14 @@ async function restore(item: KnowledgeItem) {
} }
async function createKnowledge() { async function createKnowledge() {
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) { if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请填写名称、SpaceID 和 NodeID"); ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
return; return;
} }
try { try {
await api.createKnowledge({ ...form }); await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" }); Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步"); ElMessage.success("知识库已新增,请进行首次同步");
await load(); await load();
} catch (error) { } 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) { async function openDetail(item: KnowledgeItem) {
detailOpen.value = true; detailOpen.value = true;
activeTab.value = "overview"; activeTab.value = "overview";
@@ -387,10 +407,15 @@ async function confirmAction(
</section> </section>
<section class="knowledge-work-panel"> <section class="knowledge-work-panel">
<h3>新增知识库</h3> <h3>通过 Node ID 快速新增</h3>
<div class="knowledge-create-grid"> <div class="node-resolve-row">
<el-input v-model="form.feishuSpaceId" placeholder="SpaceID" /> <el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
<el-input v-model="form.feishuNodeId" placeholder="NodeID" /> <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-input v-model="form.name" placeholder="后台显示名称" />
<el-select v-model="form.knowledgeType"> <el-select v-model="form.knowledgeType">
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /> <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-select v-model="form.reviewMode">
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" /> <el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
</el-select> </el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button> <el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
</div> </div>
</section> </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="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 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 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-table>
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close> <el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>

View File

@@ -0,0 +1,3 @@
<template>
<div class="table-row-actions"><slot /></div>
</template>

View File

@@ -116,6 +116,7 @@ export const api = {
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }), deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"), 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>) => createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }), request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) => updateKnowledge: (id: number, payload: Record<string, unknown>) =>
@@ -182,6 +183,11 @@ export const api = {
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`), aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"), operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
retrievalLogs: () => request<RetrievalLogItem[]>("/admin/retrieval-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}`), retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: () => request<AttentionItem[]>("/admin/attention/list"), attentionList: () => request<AttentionItem[]>("/admin/attention/list"),
updateAttention: (id: number, status: string, note?: string) => updateAttention: (id: number, status: string, note?: string) =>

View File

@@ -275,6 +275,16 @@ textarea {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px; 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 { .stat {
padding: 18px; padding: 18px;
@@ -393,6 +403,9 @@ textarea {
gap: 8px; 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 { .knowledge-filters {
display: grid; display: grid;
grid-template-columns: minmax(240px, 1.6fr) repeat(4, minmax(130px, 0.8fr)); grid-template-columns: minmax(240px, 1.6fr) repeat(4, minmax(130px, 0.8fr));
@@ -699,6 +712,19 @@ textarea {
width: 100%; 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 { .agent-preview-head {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -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")

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -18,6 +19,8 @@ from app.models.knowledge import (
) )
from app.schemas.knowledge import AttentionUpdateRequest from app.schemas.knowledge import AttentionUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.models.logs import LogRetentionPolicy
from app.services.maintenance_service import MaintenanceService
router = APIRouter() router = APIRouter()
@@ -29,10 +32,12 @@ def retrieval_logs(
knowledgeCalled: bool | None = Query(default=None), knowledgeCalled: bool | None = Query(default=None),
statusValue: str = Query(default="", alias="status"), statusValue: str = Query(default="", alias="status"),
attentionCreated: bool | None = Query(default=None), 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), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc()).limit(300) query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
if sessionId is not None: if sessionId is not None:
query = query.where(KnowledgeRetrievalLog.session_id == sessionId) query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
if knowledgeId is not None: if knowledgeId is not None:
@@ -43,9 +48,55 @@ def retrieval_logs(
query = query.where(KnowledgeRetrievalLog.status == statusValue) query = query.where(KnowledgeRetrievalLog.status == statusValue)
if attentionCreated is not None: if attentionCreated is not None:
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0)) 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()]) 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}") @router.get("/retrieval-log/{log_id}")
def retrieval_detail( def retrieval_detail(
log_id: int, log_id: int,

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
import json
from pathlib import Path
from sqlalchemy import select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db 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.models.admin import Admin
from app.schemas.admin import DashboardStats from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService 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() router = APIRouter()
@@ -30,3 +35,53 @@ def dashboard(
end_dt = None end_dt = None
stats = AdminDashboardService.stats(db, start_dt, end_dt) stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump()) 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

View File

@@ -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]) 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") @router.post("/knowledge/import")
def import_knowledge_from_space( def import_knowledge_from_space(
payload: dict, payload: dict,
@@ -91,12 +116,7 @@ def create_knowledge(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
existing = db.scalar( existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
select(Knowledge).where(
Knowledge.feishu_space_id == payload.feishuSpaceId,
Knowledge.feishu_node_id == payload.feishuNodeId,
)
)
if existing is not None: if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
knowledge = Knowledge( knowledge = Knowledge(

View File

@@ -11,6 +11,8 @@ from app.core.database import create_tables
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.core.observability import RequestObservabilityMiddleware, configure_logging from app.core.observability import RequestObservabilityMiddleware, configure_logging
from app.services.secret_service import SecretService from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService
import asyncio
@asynccontextmanager @asynccontextmanager
@@ -19,7 +21,15 @@ async def lifespan(app: FastAPI):
SecretService.validate_production_key() SecretService.validate_production_key()
if settings.auto_create_tables: if settings.auto_create_tables:
create_tables() create_tables()
yield 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() settings = get_settings()

View File

@@ -19,7 +19,7 @@ from app.models.knowledge import (
KnowledgeVersion, KnowledgeVersion,
UserKnowledgePermission, UserKnowledgePermission,
) )
from app.models.logs import AiRequestLog, OperationLog from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
from app.models.user import User from app.models.user import User
__all__ = [ __all__ = [
@@ -44,6 +44,8 @@ __all__ = [
"HumanAttentionRecord", "HumanAttentionRecord",
"ModelConfig", "ModelConfig",
"OperationLog", "OperationLog",
"LogRetentionPolicy",
"StorageSnapshot",
"Prompt", "Prompt",
"Role", "Role",
"SystemConfig", "SystemConfig",

View File

@@ -44,3 +44,25 @@ class OperationLog(Base):
ip: Mapped[str | None] = mapped_column(String(50), nullable=True) ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
result: Mapped[str] = mapped_column(String(20), nullable=False) result: Mapped[str] = mapped_column(String(20), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), 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)

View File

@@ -23,6 +23,7 @@ from app.models.knowledge import (
KnowledgeVersion, KnowledgeVersion,
) )
from app.services.knowledge_pipeline_service import expand_synonyms, extract_terms 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.knowledge_service import KnowledgeScope
from app.services.model_service import _call_configured_model, _system_config_bool from app.services.model_service import _call_configured_model, _system_config_bool
from app.services.rag_service import PromptService, RagResult, RetrievedChunk from app.services.rag_service import PromptService, RagResult, RetrievedChunk
@@ -76,16 +77,16 @@ class KnowledgeAgentService:
db.add(log) db.add(log)
db.flush() db.flush()
scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog] 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) 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] = [] chunks: list[RetrievedChunk] = []
try: try:
if need_knowledge and selected_ids: if need_knowledge and selected_ids:
terms = cls._query_terms(question) terms = cls._query_terms(question)
candidates = cls.search_knowledge(db, terms, selected_ids, catalog) candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
trace.append({"tool": "search_knowledge", "queryTerms": terms, "candidateCount": len(candidates)}) 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) await cls._rerank(db, question, candidates, trace, started)
selected = cls._select_sections(candidates) selected = cls._select_sections(candidates)
chunks = [ chunks = [
RetrievedChunk( RetrievedChunk(
@@ -100,7 +101,7 @@ class KnowledgeAgentService:
) )
for item in selected 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) cls._persist_candidates(db, log.id, candidates)
log.knowledge_called = 1 log.knowledge_called = 1
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids) 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, version_overrides: dict[int, int] | None = None,
preview_knowledge_ids: list[int] | None = None, preview_knowledge_ids: list[int] | None = None,
) -> list[dict]: ) -> 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") query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
if preview_knowledge_ids is None: if preview_knowledge_ids is None:
query = query.where( query = query.where(
@@ -171,6 +179,15 @@ class KnowledgeAgentService:
) )
return catalog 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 @classmethod
def search_knowledge(cls, db: Session, terms: list[str], selected_ids: list[int], catalog: list[dict]) -> list[Candidate]: 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} versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
@@ -200,12 +217,12 @@ class KnowledgeAgentService:
return candidates[:MAX_LEXICAL_CANDIDATES] return candidates[:MAX_LEXICAL_CANDIDATES]
@classmethod @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: if not candidates:
return return
model = db.scalar(select(ModelConfig).where(ModelConfig.enabled == 1).order_by(ModelConfig.id.desc()).limit(1)) 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): 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 return
prompt = ( prompt = (
"你是知识检索重排器。根据用户问题给候选打0到100相关分只返回JSON" "你是知识检索重排器。根据用户问题给候选打0到100相关分只返回JSON"
@@ -221,9 +238,9 @@ class KnowledgeAgentService:
for index, item in enumerate(candidates, 1): for index, item in enumerate(candidates, 1):
item.rerank_score = score_map.get(index) 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) 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: 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 @staticmethod
def _select_sections(candidates: list[Candidate]) -> list[Candidate]: def _select_sections(candidates: list[Candidate]) -> list[Candidate]:

View File

@@ -1,12 +1,46 @@
from __future__ import annotations from __future__ import annotations
import json
import time
from typing import Callable
from app.services.redis_client import get_sync_redis_client from app.services.redis_client import get_sync_redis_client
class KnowledgeCatalogCacheService: class KnowledgeCatalogCacheService:
"""Central invalidation point for current and future KnowledgeList caches.""" """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 @classmethod
def invalidate(cls) -> None: def invalidate(cls) -> None:

View File

@@ -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()

View File

@@ -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。