Compare commits

..

9 Commits

25 changed files with 1157 additions and 508 deletions

View File

@@ -103,7 +103,7 @@ const modelForm = reactive({
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: null as number | null,
maxToken: 8192 as number | null,
contextWindow: null as number | null,
streamEnabled: 1,
responseFormat: "",
@@ -471,18 +471,6 @@ function buildModelPayload() {
payload[field] = null;
}
});
Object.assign(payload, {
temperature: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: null,
contextWindow: null,
streamEnabled: 1,
responseFormat: null,
extraParams: null,
});
return payload;
}
@@ -513,7 +501,7 @@ async function quickAddModel() {
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: null,
maxToken: 8192,
contextWindow: null,
streamEnabled: 1,
responseFormat: null,
@@ -594,13 +582,13 @@ function editModel(row: ModelItem) {
apiKey: "",
authType: row.authType,
apiVersion: row.apiVersion ?? "",
temperature: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: null,
contextWindow: null,
temperature: row.temperature ?? null,
topP: row.topP ?? null,
topK: row.topK ?? null,
presencePenalty: row.presencePenalty ?? null,
frequencyPenalty: row.frequencyPenalty ?? null,
maxToken: row.maxToken ?? 8192,
contextWindow: row.contextWindow ?? null,
streamEnabled: row.streamEnabled,
responseFormat: row.responseFormat ?? "",
extraParams: row.extraParams ?? "",
@@ -626,7 +614,7 @@ function resetModelForm() {
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: null,
maxToken: 8192,
contextWindow: null,
streamEnabled: 1,
responseFormat: "",

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
defineProps<{
temperature: number | null;
topP: number | null;
topK: number | null;
presencePenalty: number | null;
frequencyPenalty: number | null;
maxToken: number | null;
disabled?: boolean;
}>();
defineEmits<{
"update:temperature": [value: number | null];
"update:topP": [value: number | null];
"update:topK": [value: number | null];
"update:presencePenalty": [value: number | null];
"update:frequencyPenalty": [value: number | null];
"update:maxToken": [value: number | null];
}>();
</script>
<template>
<div class="agent-config-grid">
<el-form-item>
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 02。数值越小回答越稳定、一致数值越大表达越多样但也更容易发散。答疑场景建议使用较低数值。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="temperature" :disabled="disabled" :min="0" :max="2" :step="0.1" @update:model-value="$emit('update:temperature', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 01。模型只从累计概率达到该数值的候选词中选择。数值越小越专注越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="topP" :disabled="disabled" :min="0" :max="1" :step="0.05" @update:model-value="$emit('update:topP', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 11000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="topK" :disabled="disabled" :min="1" :max="1000" @update:model-value="$emit('update:topK', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数。建议正式答疑使用 40968192数值越大允许回答越长也会增加耗时和费用最终仍受模型自身上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="maxToken" :disabled="disabled" :min="256" :max="100000" :step="256" @update:model-value="$emit('update:maxToken', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="presencePenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:presencePenalty', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="frequencyPenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:frequencyPenalty', $event)" />
</el-form-item>
</div>
</template>

View File

@@ -3,7 +3,8 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { AgentDebugResult, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
import type { AgentDebugResult, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
import AgentGenerationParameters from "./AgentGenerationParameters.vue";
import AdminPagination from "./AdminPagination.vue";
const props = defineProps<{ previewKnowledgeId?: number | null }>();
@@ -11,6 +12,7 @@ const emit = defineEmits<{ consumedPreview: [] }>();
const loading = ref(false);
const promptSaving = ref(false);
const runtimeSaving = ref(false);
const historyLoading = ref(false);
const agentDebugging = ref(false);
const activeConfigTab = ref("prompt");
@@ -37,9 +39,18 @@ const agentForm = reactive({
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 1024 as number | null,
maxToken: 8192 as number | null,
question: "",
});
const runtimeConfig = ref<AgentRuntimeConfig | null>(null);
const runtimeForm = reactive({
temperature: null as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 8192 as number | null,
});
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
const selectedModelName = computed(() => {
@@ -63,13 +74,15 @@ watch(() => props.previewKnowledgeId, (knowledgeId) => {
async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(),
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(), api.agentRuntimeConfig(),
]);
applyPrompt(promptResult);
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.modelId = modelRows.find((item) => item.enabled === 1)?.id ?? modelRows[0]?.id;
applyRuntimeConfig(formalConfig);
applyDebugModelDefaults(agentForm.modelId);
agentForm.knowledgeIds = knowledgeRows
.filter((item) => item.status === 1 && item.lifecycleStatus === "active")
.map((item) => item.id);
@@ -82,6 +95,56 @@ async function load() {
}
}
function applyRuntimeConfig(value: AgentRuntimeConfig) {
runtimeConfig.value = value;
Object.assign(runtimeForm, {
temperature: value.temperature,
topP: value.topP,
topK: value.topK,
presencePenalty: value.presencePenalty,
frequencyPenalty: value.frequencyPenalty,
maxToken: value.maxToken,
});
}
function applyDebugModelDefaults(modelId?: number) {
const model = models.value.find((item) => item.id === modelId);
if (!model) return;
Object.assign(agentForm, {
temperature: model.temperature ?? null,
topP: model.topP ?? null,
topK: model.topK ?? null,
presencePenalty: model.presencePenalty ?? null,
frequencyPenalty: model.frequencyPenalty ?? null,
maxToken: model.maxToken ?? (model.enabled === 1 ? runtimeConfig.value?.maxToken : null) ?? 8192,
});
}
async function saveRuntimeConfig() {
if (!runtimeConfig.value?.modelId) return ElMessage.error("请先在模型管理中启用一个正式模型");
if (!runtimeForm.maxToken) return ElMessage.warning("最大输出长度不能为空");
runtimeSaving.value = true;
try {
const saved = await api.saveAgentRuntimeConfig({
temperature: runtimeForm.temperature,
topP: runtimeForm.topP,
topK: runtimeForm.topK,
presencePenalty: runtimeForm.presencePenalty,
frequencyPenalty: runtimeForm.frequencyPenalty,
maxToken: runtimeForm.maxToken,
});
applyRuntimeConfig(saved);
const model = models.value.find((item) => item.id === saved.modelId);
if (model) Object.assign(model, runtimeForm);
if (agentForm.modelId === saved.modelId) applyDebugModelDefaults(saved.modelId ?? undefined);
ElMessage.success("正式运行参数已保存,将从用户端下一条消息开始生效");
} catch (error) {
ElMessage.error(errorMessage(error, "正式运行参数保存失败"));
} finally {
runtimeSaving.value = false;
}
}
function applyPreviewKnowledge(knowledgeId: number) {
if (knowledge.value.some((item) => item.id === knowledgeId)) {
agentForm.knowledgeIds = [knowledgeId];
@@ -262,7 +325,7 @@ function errorMessage(error: unknown, fallback: string) {
<div class="page-head agent-page-head">
<div>
<h2>Agent 管理</h2>
<p>维护主提示词及历史版本配置本次调试参数并验证真实检索与回答效果</p>
<p>维护主提示词用户端正式生成参数和历史版本并验证真实检索与回答效果</p>
</div>
<div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
@@ -286,11 +349,34 @@ function errorMessage(error: unknown, fallback: string) {
</div>
</el-tab-pane>
<el-tab-pane label="正式运行配置" name="runtime">
<div class="agent-section-intro">
<div><h3>用户端正式运行参数</h3><p>保存后从用户端下一条消息开始生效当前正式模型{{ runtimeConfig?.modelName || '未启用模型' }}</p></div>
<el-button type="primary" :loading="runtimeSaving" :disabled="!runtimeConfig?.modelId" @click="saveRuntimeConfig">保存正式参数</el-button>
</div>
<el-alert v-if="!runtimeConfig?.modelId" title="当前没有已启用模型,请先到模型管理中启用一个正式模型。" type="warning" :closable="false" show-icon />
<el-alert v-else title="最大输出长度默认提升为 8192 Token设置过低会导致长回答在完成前被模型截断。实际可用上限仍受模型供应商限制。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="agent-runtime-form">
<section class="agent-advanced-settings">
<div class="agent-subsection-title"><h4>正式生成参数</h4><span>用户端正式对话使用</span></div>
<AgentGenerationParameters
v-model:temperature="runtimeForm.temperature"
v-model:top-p="runtimeForm.topP"
v-model:top-k="runtimeForm.topK"
v-model:presence-penalty="runtimeForm.presencePenalty"
v-model:frequency-penalty="runtimeForm.frequencyPenalty"
v-model:max-token="runtimeForm.maxToken"
:disabled="!runtimeConfig?.modelId"
/>
</section>
</el-form>
</el-tab-pane>
<el-tab-pane label="调试配置" name="debug">
<div class="agent-section-intro"><div><h3>本次调试配置</h3><p>配置只作用于后台预览不会修改模型和知识库的正式状态</p></div></div>
<el-form label-position="top">
<el-form-item label="调试模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型" @change="applyDebugModelDefaults">
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
</el-select>
</el-form-item>
@@ -301,32 +387,14 @@ function errorMessage(error: unknown, fallback: string) {
</el-form-item>
<section class="agent-advanced-settings">
<div class="agent-subsection-title"><h4>高级生成参数</h4><span>仅影响本次后台调试</span></div>
<div class="agent-config-grid">
<el-form-item>
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 02。数值越小回答越稳定、一致数值越大表达越多样但也更容易发散。答疑场景建议使用较低数值。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 01。模型只从累计概率达到该数值的候选词中选择。数值越小越专注越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.topP" :min="0" :max="1" :step="0.05" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 11000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.topK" :min="1" :max="1000" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数,也不代表一定会生成到该长度。数值越大允许回答越长,同时可能增加耗时和费用,最终仍受模型上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.maxToken" :min="1" :max="100000" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.presencePenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
</div>
<AgentGenerationParameters
v-model:temperature="agentForm.temperature"
v-model:top-p="agentForm.topP"
v-model:top-k="agentForm.topK"
v-model:presence-penalty="agentForm.presencePenalty"
v-model:frequency-penalty="agentForm.frequencyPenalty"
v-model:max-token="agentForm.maxToken"
/>
</section>
</el-form>
</el-tab-pane>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../services/api";
import type { KnowledgeVersionContent } from "../types/api";
const props = defineProps<{
versionId: number | null;
}>();
const loading = ref(false);
const error = ref("");
const content = ref<KnowledgeVersionContent | null>(null);
const activeSectionId = ref<number | null>(null);
let requestSequence = 0;
const activeSection = computed(() => (
content.value?.sections.find((section) => section.id === activeSectionId.value)
?? content.value?.sections[0]
?? null
));
const characterCount = computed(() => (
content.value?.sections.reduce((total, section) => total + section.content.length, 0) ?? 0
));
watch(() => props.versionId, loadVersion, { immediate: true });
async function loadVersion(versionId: number | null) {
const sequence = ++requestSequence;
content.value = null;
activeSectionId.value = null;
error.value = "";
if (!versionId) return;
loading.value = true;
try {
const result = await api.knowledgeVersionContent(versionId);
if (sequence !== requestSequence) return;
content.value = result;
activeSectionId.value = result.sections[0]?.id ?? null;
} catch (loadError) {
if (sequence !== requestSequence) return;
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
</script>
<template>
<section class="content-preview" v-loading="loading">
<header class="content-preview-toolbar">
<div>
<h4>知识库内容预览</h4>
<p>查看当前最新版实际进入检索链路的章节内容</p>
</div>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
<template #default><el-button link type="primary" @click="loadVersion(versionId)">重新加载</el-button></template>
</el-alert>
<el-empty v-else-if="!loading && !versionId" description="该知识库暂无可预览版本" />
<el-empty v-else-if="!loading && content && content.sections.length === 0" description="当前版本没有可预览的章节内容" />
<template v-else-if="content && activeSection">
<div class="content-preview-summary">
<span>V{{ content.version.versionNo }}</span>
<span>{{ content.sections.length }} 个章节</span>
<span>{{ content.cards.length }} 张知识卡</span>
<span> {{ characterCount.toLocaleString() }} </span>
</div>
<div class="content-preview-layout">
<aside class="content-outline" aria-label="章节目录">
<strong>章节目录</strong>
<button
v-for="section in content.sections"
:key="section.id"
type="button"
:class="{ active: section.id === activeSection.id }"
:title="section.title"
@click="activeSectionId = section.id"
>
<span>{{ section.sectionKey }}</span>
<em>{{ section.title }}</em>
</button>
</aside>
<article class="content-article">
<div class="content-article-head">
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
<span>源内容位置 {{ activeSection.sourceStart }}{{ activeSection.sourceEnd }}</span>
</div>
<pre>{{ activeSection.content }}</pre>
</article>
</div>
<el-collapse class="content-card-panel">
<el-collapse-item :title="`查看知识卡(${content.cards.length}`" name="cards">
<el-empty v-if="content.cards.length === 0" description="当前版本尚未生成知识卡" :image-size="72" />
<el-table v-else :data="content.cards" max-height="360">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
<el-table-column prop="riskLevel" label="风险" width="100" />
</el-table>
</el-collapse-item>
</el-collapse>
</template>
</section>
</template>
<style scoped>
.content-preview { min-height: 360px; }
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
.content-outline { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
.content-outline > strong { display: block; padding: 0 10px 10px; color: #455c53; font-size: 13px; }
.content-outline button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; width: 100%; gap: 8px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: #53645e; text-align: left; cursor: pointer; }
.content-outline button:hover { background: #eaf3ef; }
.content-outline button.active { background: #dcefe7; color: #176746; }
.content-outline button span { font-size: 11px; color: #7c8d87; }
.content-outline button em { overflow: hidden; font-style: normal; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.content-article { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 24px 28px; }
.content-article-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #edf1ef; }
.content-article-head small { color: #26815f; }
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
.content-card-panel { margin-top: 16px; }
@media (max-width: 900px) {
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
.content-outline > strong { display: none; }
.content-outline button { min-width: 180px; width: 180px; }
.content-article { height: auto; max-height: 58vh; padding: 20px; }
.content-article-head { flex-direction: column; gap: 8px; }
}
</style>

View File

@@ -3,14 +3,13 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
import type {
KnowledgeDetail,
KnowledgeItem,
KnowledgeSyncJob,
KnowledgeVersion,
KnowledgeVersionContent,
MigrationSummary,
} from "../types/api";
@@ -22,9 +21,8 @@ const loading = ref(false);
const operatingId = ref<number | null>(null);
const detailOpen = ref(false);
const detail = ref<KnowledgeDetail | null>(null);
const versions = ref<KnowledgeVersion[]>([]);
const jobs = ref<KnowledgeSyncJob[]>([]);
const versionContent = ref<KnowledgeVersionContent | null>(null);
const previewVersionId = ref<number | null>(null);
const activeTab = ref("overview");
const selectedRows = ref<KnowledgeItem[]>([]);
const editOpen = ref(false);
@@ -34,19 +32,18 @@ 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: "" });
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const form = reactive({
name: "",
feishuSpaceId: "",
feishuNodeId: "",
knowledgeType: "general",
reviewMode: "manual",
status: 0,
remark: "",
});
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
const batchForm = reactive({ knowledgeType: "" });
onMounted(load);
@@ -55,7 +52,7 @@ async function load(page = pager.page, pageSize = pager.pageSize) {
try {
const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
sourceStatus: filter.source,
page, pageSize,
});
rows.value = result.items;
@@ -74,7 +71,6 @@ function openEdit() {
id: detail.value.knowledge.id,
name: detail.value.knowledge.name,
knowledgeType: detail.value.knowledge.knowledgeType,
reviewMode: detail.value.knowledge.reviewMode,
});
editOpen.value = true;
}
@@ -87,7 +83,7 @@ async function saveMetadata() {
editSaving.value = true;
try {
await api.updateKnowledgeMetadata(editForm.id, {
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
});
ElMessage.success("知识库基本信息已更新");
editOpen.value = false;
@@ -104,13 +100,13 @@ function openBatchEdit() {
ElMessage.warning("请先勾选需要修改的知识库");
return;
}
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
Object.assign(batchForm, { knowledgeType: "" });
batchOpen.value = true;
}
async function saveBatchMetadata() {
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
ElMessage.warning("请至少选择一个修改项");
if (!batchForm.knowledgeType) {
ElMessage.warning("请选择知识库类型");
return;
}
if (!await confirmAction(
@@ -123,7 +119,6 @@ async function saveBatchMetadata() {
const result = await api.batchUpdateKnowledgeMetadata({
knowledgeIds: selectedRows.value.map((item) => item.id),
knowledgeType: batchForm.knowledgeType || undefined,
reviewMode: batchForm.reviewMode || undefined,
});
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`);
batchOpen.value = false;
@@ -139,11 +134,6 @@ async function handleMore(command: string, item: KnowledgeItem) {
try {
if (command === "detail") return await openDetail(item);
if (command === "preview") return emit("preview", item.id);
if (command === "versions") {
await openDetail(item);
if (detailOpen.value) activeTab.value = "versions";
return;
}
if (command === "archive") return await archive(item);
if (command === "restore") return await restore(item);
if (command === "delete") return await remove(item);
@@ -168,9 +158,9 @@ async function createKnowledge() {
}
try {
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", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步");
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败"));
@@ -194,10 +184,12 @@ async function resolveNode() {
async function openDetail(item: KnowledgeItem) {
detailOpen.value = true;
detail.value = null;
activeTab.value = "overview";
versionContent.value = null;
previewVersionId.value = null;
try {
await refreshDetail(item.id);
const refreshedDetail = await refreshDetail(item.id);
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
} catch (error) {
detailOpen.value = false;
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
@@ -205,20 +197,23 @@ async function openDetail(item: KnowledgeItem) {
}
async function refreshDetail(id: number) {
[detail.value, versions.value, jobs.value] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
const [nextDetail, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
]);
detail.value = nextDetail;
jobs.value = nextJobs;
return nextDetail;
}
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
async function sync(item: KnowledgeItem) {
operatingId.value = item.id;
try {
const result = await api.syncKnowledge(item.id, mode);
const result = await api.syncKnowledge(item.id);
const message = result.status === "unchanged"
? "飞书内容未变化,未生成新版本"
: result.status === "published"
? "同步、质检和自动发布已完成"
: "同步处理完成,已生成待审核版本";
? "同步完成,最新版本已自动生效"
: "同步完成";
ElMessage.success(message);
await load();
if (detailOpen.value) await refreshDetail(item.id);
@@ -231,7 +226,7 @@ async function sync(item: KnowledgeItem, mode: "review" | "replace") {
async function runMigration() {
if (!await confirmAction(
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
"将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
"开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return;
@@ -247,75 +242,6 @@ async function runMigration() {
}
}
async function confirmManifest() {
if (!detail.value?.pendingVersion) return;
try {
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
ElMessage.success("知识库说明卡已确认");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "说明卡确认失败"));
}
}
async function review(action: "approve" | "reject") {
if (!detail.value?.pendingVersion) return;
let reason = "";
if (action === "reject") {
try {
const result = await ElMessageBox.prompt(
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
"拒绝版本",
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
);
reason = result.value;
} catch {
return;
}
}
try {
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本审核失败"));
}
}
async function publish(keepOpen: boolean) {
if (!detail.value?.pendingVersion) return;
if (!await confirmAction(
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}`,
"发布知识版本",
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
ElMessage.success("正式版本已发布");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本发布失败"));
}
}
async function rollback(version: KnowledgeVersion) {
if (!detail.value) return;
if (!await confirmAction(
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
"回滚知识版本",
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
ElMessage.success("知识版本已回滚");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本回滚失败"));
}
}
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
const next = item.status !== 1;
if (!await confirmAction(
@@ -338,7 +264,7 @@ async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
}
async function archive(item: KnowledgeItem) {
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录保留。", "归档知识库", {
if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "archived");
@@ -357,11 +283,6 @@ async function remove(item: KnowledgeItem) {
await load();
}
async function showVersion(version: KnowledgeVersion) {
versionContent.value = await api.knowledgeVersionContent(version.id);
activeTab.value = "content";
}
function statusType(status: string) {
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
@@ -393,14 +314,13 @@ async function confirmAction(
<template>
<div class="page-head inline">
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步审核和发布相互独立</p></div>
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步成功后自动生效并且只保留最新版</p></div>
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
</div>
<section class="migration-overview">
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
<div><span>待审核</span><strong>{{ migration?.counts.pending_review ?? 0 }}</strong></div>
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
</section>
@@ -419,11 +339,8 @@ async function confirmAction(
<el-select v-model="form.knowledgeType">
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
</el-select>
<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>
<el-button type="primary" @click="createKnowledge">保存知识库</el-button>
</div>
</section>
@@ -432,47 +349,41 @@ async function confirmAction(
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
<el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
</div>
<div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span>
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" />
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</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="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></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 }"><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-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)">同步最新版</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 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>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
<template v-if="detail">
<div class="knowledge-detail-head">
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
<div><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · 同步后自动生效</p></div>
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
</div>
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="overview">
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>审核方式</dt><dd>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
<div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>版本更新</dt><dd>同步成功后自动替换</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>当前版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd></dl>
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途</strong>{{ detail.manifest.purpose }}</p><p><strong>适用</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界</strong>{{ detail.manifest.boundaries }}</p></section>
</el-tab-pane>
<el-tab-pane label="内容结构" name="content">
<el-empty v-if="!versionContent" description="请从版本记录中选择一个版本查看" />
<template v-else><el-collapse><el-collapse-item v-for="section in versionContent.sections" :key="section.id" :title="`${section.sectionKey} ${section.title}`"><pre class="source-content">{{ section.content }}</pre></el-collapse-item></el-collapse><h4>知识卡</h4><el-table :data="versionContent.cards"><el-table-column prop="title" label="标题" /><el-table-column prop="summary" label="摘要" min-width="300" /><el-table-column prop="riskLevel" label="风险" width="100" /><el-table-column prop="reviewStatus" label="审核" width="110" /></el-table></template>
<el-tab-pane label="内容预览" name="content" lazy>
<KnowledgeContentPreview :version-id="previewVersionId" />
</el-tab-pane>
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="mode" label="模式" width="110" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
</el-tabs>
</template>
</el-drawer>
@@ -481,7 +392,6 @@ async function confirmAction(
<el-form label-position="top">
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
</el-form>
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
@@ -491,7 +401,6 @@ async function confirmAction(
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
<el-form label-position="top" class="batch-edit-form">
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
</el-dialog>
@@ -522,6 +431,12 @@ async function confirmAction(
gap: 10px;
}
.knowledge-detail-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.batch-edit-form {
margin-top: 20px;
}

View File

@@ -2,6 +2,8 @@ import type {
AdminProfile,
AdminUser,
AgentDebugResult,
AgentGenerationConfig,
AgentRuntimeConfig,
AiLogRecord,
ApiResponse,
ChatDetail,
@@ -11,7 +13,6 @@ import type {
KnowledgeItem,
KnowledgeDetail,
KnowledgeSyncJob,
KnowledgeVersion,
KnowledgeVersionContent,
MigrationSummary,
KnowledgeBatchUpdateResult,
@@ -137,9 +138,9 @@ export const api = {
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) =>
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string }) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) =>
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string }) =>
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
importKnowledgeFromSpace: (body: { node_token: string }) =>
@@ -148,19 +149,10 @@ export const api = {
{ method: "POST", body: JSON.stringify(body) },
),
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
syncKnowledge: (id: number, mode: "review" | "replace") =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }),
confirmKnowledgeManifest: (id: number, versionId: number) =>
request<null>(`/admin/knowledge/${id}/manifest/${versionId}/confirm`, { method: "POST", body: "{}" }),
reviewKnowledgeVersion: (id: number, versionId: number, action: "approve" | "reject", reason?: string) =>
request<null>(`/admin/knowledge/${id}/version/${versionId}/review`, { method: "POST", body: JSON.stringify({ action, reason }) }),
publishKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/publish`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
syncKnowledge: (id: number) =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
setKnowledgeOpen: (id: number, open: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
@@ -178,6 +170,9 @@ export const api = {
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
debugAgent: (payload: Record<string, unknown>) =>
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),

View File

@@ -93,13 +93,10 @@ export interface KnowledgeItem {
remark?: string | null;
sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general";
reviewMode: "manual" | "automatic";
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
sourceError?: string | null;
manifestConfirmed: boolean;
currentVersionId?: number | null;
pendingVersionId?: number | null;
lastSyncedAt?: string | null;
lastPublishedAt?: string | null;
migrationStatus: string;
@@ -133,6 +130,20 @@ export interface ModelItem {
enabled: number;
}
export interface AgentGenerationConfig {
temperature: number | null;
topP: number | null;
topK: number | null;
presencePenalty: number | null;
frequencyPenalty: number | null;
maxToken: number;
}
export interface AgentRuntimeConfig extends AgentGenerationConfig {
modelId: number | null;
modelName: string | null;
}
export interface AgentDebugResult {
ok: boolean;
message: string;
@@ -176,7 +187,6 @@ export interface KnowledgeManifest {
export interface KnowledgeDetail {
knowledge: KnowledgeItem;
currentVersion?: KnowledgeVersion | null;
pendingVersion?: KnowledgeVersion | null;
manifest?: KnowledgeManifest | null;
}

View File

@@ -0,0 +1,37 @@
"""make knowledge synchronization automatically publish
Revision ID: 0011_knowledge_auto_publish
Revises: 0010_prompt_history_management
"""
from alembic import op
import sqlalchemy as sa
revision = "0011_knowledge_auto_publish"
down_revision = "0010_prompt_history_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Keep the legacy column for backward compatibility while removing manual
# review from the active workflow and API.
op.execute("UPDATE sys_knowledge SET review_mode = 'automatic'")
op.alter_column(
"sys_knowledge",
"review_mode",
existing_type=sa.String(length=30),
existing_nullable=False,
server_default="automatic",
)
def downgrade() -> None:
op.alter_column(
"sys_knowledge",
"review_mode",
existing_type=sa.String(length=30),
existing_nullable=False,
server_default="manual",
)

View File

@@ -0,0 +1,55 @@
"""keep only the latest knowledge version
Revision ID: 0012_knowledge_latest_only
Revises: 0011_knowledge_auto_publish
"""
from alembic import op
revision = "0012_knowledge_latest_only"
down_revision = "0011_knowledge_auto_publish"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("UPDATE sys_knowledge SET pending_version_id = NULL")
for table in (
"sys_knowledge_review_record",
"sys_knowledge_card",
"sys_knowledge_chunk",
"sys_knowledge_section",
"sys_knowledge_manifest",
):
op.execute(
f"""
DELETE detail
FROM {table} AS detail
JOIN sys_knowledge AS knowledge ON knowledge.id = detail.knowledge_id
WHERE knowledge.current_version_id IS NULL
OR detail.version_id <> knowledge.current_version_id
"""
)
op.execute(
"""
DELETE version
FROM sys_knowledge_version AS version
JOIN sys_knowledge AS knowledge ON knowledge.id = version.knowledge_id
WHERE knowledge.current_version_id IS NULL
OR version.id <> knowledge.current_version_id
"""
)
op.execute(
"""
DELETE snapshot
FROM sys_knowledge_source_snapshot AS snapshot
LEFT JOIN sys_knowledge_version AS version ON version.snapshot_id = snapshot.id
WHERE version.id IS NULL
"""
)
def downgrade() -> None:
# Removed historical content cannot be reconstructed from the latest version.
pass

View File

@@ -36,7 +36,6 @@ def list_knowledge(
knowledgeType: str = Query(default=""),
openStatus: int | None = Query(default=None),
sourceStatus: str = Query(default=""),
pending: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
@@ -51,8 +50,6 @@ def list_knowledge(
query = query.where(Knowledge.status == openStatus)
if sourceStatus:
query = query.where(Knowledge.source_status == sourceStatus)
if pending is not None:
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
@@ -119,7 +116,7 @@ def import_knowledge_from_space(
feishu_node_id=node_token,
status=0,
knowledge_type="general",
review_mode="manual",
review_mode="automatic",
remark=f"obj_type={info.get('obj_type', '')}",
)
db.add(knowledge)
@@ -153,7 +150,7 @@ def create_knowledge(
status=0,
remark=payload.remark,
knowledge_type=payload.knowledgeType,
review_mode=payload.reviewMode,
review_mode="automatic",
)
db.add(knowledge)
db.flush()
@@ -179,7 +176,7 @@ def update_knowledge(
knowledge.name = payload.name
knowledge.remark = payload.remark
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
knowledge.review_mode = "automatic"
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
db.commit()
@@ -243,13 +240,10 @@ def _knowledge_dict(knowledge: Knowledge) -> dict:
"remark": knowledge.remark,
"sourceTitle": knowledge.source_title,
"knowledgeType": knowledge.knowledge_type,
"reviewMode": knowledge.review_mode,
"lifecycleStatus": knowledge.lifecycle_status,
"sourceStatus": knowledge.source_status,
"sourceError": knowledge.source_error,
"manifestConfirmed": knowledge.manifest_confirmed == 1,
"currentVersionId": knowledge.current_version_id,
"pendingVersionId": knowledge.pending_version_id,
"lastSyncedAt": knowledge.last_synced_at,
"lastPublishedAt": knowledge.last_published_at,
"migrationStatus": knowledge.migration_status,

View File

@@ -23,10 +23,7 @@ from app.schemas.knowledge import (
KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest,
KnowledgeMetadataUpdateRequest,
KnowledgePublishRequest,
KnowledgeReviewRequest,
KnowledgeStatusRequest,
KnowledgeSyncRequest,
)
from app.services.admin_service import OperationLogService
from app.services.knowledge_pipeline_service import KnowledgePipelineService
@@ -41,8 +38,8 @@ def batch_update_metadata(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
if payload.knowledgeType is None and payload.reviewMode is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少选择一个待修改字段")
if payload.knowledgeType is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="选择待修改的知识库类型")
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
rows = {
item.id: item
@@ -56,8 +53,6 @@ def batch_update_metadata(
continue
if payload.knowledgeType is not None:
item.knowledge_type = payload.knowledgeType
if payload.reviewMode is not None:
item.review_mode = payload.reviewMode
db.add(item)
OperationLogService.write(
db,
@@ -85,7 +80,7 @@ def update_metadata(
knowledge = _knowledge(db, knowledge_id)
knowledge.name = payload.name.strip()
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
knowledge.review_mode = "automatic"
db.add(knowledge)
OperationLogService.write(
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
@@ -104,37 +99,19 @@ def detail(
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
pending = db.get(KnowledgeVersion, knowledge.pending_version_id) if knowledge.pending_version_id else None
manifest = None
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id
manifest_version_id = knowledge.current_version_id
if manifest_version_id:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
return api_success(
{
"knowledge": _knowledge_detail(knowledge),
"currentVersion": _version_dict(current),
"pendingVersion": _version_dict(pending),
"manifest": _manifest_dict(manifest),
}
)
@router.get("/knowledge/{knowledge_id}/versions")
def versions(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
_knowledge(db, knowledge_id)
rows = db.scalars(
select(KnowledgeVersion)
.where(KnowledgeVersion.knowledge_id == knowledge_id)
.order_by(KnowledgeVersion.version_no.desc())
).all()
return api_success([_version_dict(item) for item in rows])
@router.get("/knowledge/version/{version_id}/content")
def version_content(
version_id: int,
@@ -206,7 +183,6 @@ def sync_jobs(
@router.post("/knowledge/{knowledge_id}/sync")
async def synchronize(
knowledge_id: int,
payload: KnowledgeSyncRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -216,7 +192,7 @@ async def synchronize(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode=payload.mode
db, knowledge, admin_id=current_admin.id
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
@@ -225,90 +201,6 @@ async def synchronize(
return api_success(_job_dict(job))
@router.post("/knowledge/{knowledge_id}/manifest/{version_id}/confirm")
def confirm_manifest(
knowledge_id: int,
version_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡")
try:
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="confirm_manifest", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/review")
def review(
knowledge_id: int,
version_id: int,
payload: KnowledgeReviewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id or version.status != "pending_review":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本")
if version.quality_status == "blocked" and payload.action == "approve":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=f"review_{payload.action}", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/publish")
def publish(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本")
try:
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
def rollback(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.current_version_id == version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
if payload.keepOpen and knowledge.lifecycle_status != "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
try:
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.put("/knowledge/{knowledge_id}/open-status")
def open_status(
knowledge_id: int,
@@ -321,8 +213,6 @@ def open_status(
if payload.open:
if not knowledge.current_version_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
if knowledge.manifest_confirmed != 1:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="首次知识库说明卡尚未确认")
if knowledge.source_status != "normal":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
if knowledge.lifecycle_status != "active":
@@ -381,7 +271,7 @@ async def run_migration(
for knowledge in rows:
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode="review"
db, knowledge, admin_id=current_admin.id
)
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
except Exception as exc:
@@ -410,14 +300,6 @@ def _version(db: Session, version_id: int) -> KnowledgeVersion:
return item
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
knowledge = _knowledge(db, knowledge_id)
version = _version(db, version_id)
if version.knowledge_id != knowledge.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
return knowledge, version
def _knowledge_detail(item: Knowledge) -> dict:
return {
"id": item.id,
@@ -428,14 +310,11 @@ def _knowledge_detail(item: Knowledge) -> dict:
"feishuSpaceId": item.feishu_space_id,
"feishuNodeId": item.feishu_node_id,
"knowledgeType": item.knowledge_type,
"reviewMode": item.review_mode,
"open": item.status == 1,
"lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status,
"sourceError": item.source_error,
"manifestConfirmed": item.manifest_confirmed == 1,
"currentVersionId": item.current_version_id,
"pendingVersionId": item.pending_version_id,
"lastSyncedAt": item.last_synced_at,
"lastPublishedAt": item.last_published_at,
"migrationStatus": item.migration_status,

View File

@@ -13,6 +13,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.knowledge import Knowledge
from app.schemas.admin import (
AgentDebugRequest,
AgentRuntimeConfigSaveRequest,
EnableModelRequest,
ModelSaveRequest,
PromptSaveRequest,
@@ -184,6 +185,46 @@ async def debug_agent(
return api_success(result)
@router.get("/agent/runtime-config")
def get_agent_runtime_config(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = _enabled_model(db)
return api_success(_agent_runtime_config_dict(model))
@router.put("/agent/runtime-config")
def save_agent_runtime_config(
payload: AgentRuntimeConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = _enabled_model(db)
if model is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="当前没有已启用模型,请先在模型管理中启用一个模型",
)
model.temperature = payload.temperature
model.top_p = payload.topP
model.top_k = payload.topK
model.presence_penalty = payload.presencePenalty
model.frequency_penalty = payload.frequencyPenalty
model.max_token = payload.maxToken
db.add(model)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="agent",
action="save_runtime_config",
target_id=model.id,
)
db.commit()
db.refresh(model)
return api_success(_agent_runtime_config_dict(model))
@router.get("/model/list")
def list_models(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
models = db.scalars(select(ModelConfig).order_by(ModelConfig.id.desc())).all()
@@ -376,6 +417,28 @@ def _model_dict(model: ModelConfig) -> dict:
}
def _enabled_model(db: Session) -> ModelConfig | None:
return db.scalar(
select(ModelConfig)
.where(ModelConfig.enabled == 1)
.order_by(ModelConfig.id.desc())
.limit(1)
)
def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
return {
"modelId": model.id if model is not None else None,
"modelName": (model.display_name or model.model_name) if model is not None else None,
"temperature": float(model.temperature) if model is not None and model.temperature is not None else None,
"topP": float(model.top_p) if model is not None and model.top_p is not None else None,
"topK": model.top_k if model is not None else None,
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
}
def _default_prompt_detail() -> dict:
return {
"id": None,

View File

@@ -22,7 +22,7 @@ class Knowledge(Base, TimestampMixin):
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False)
review_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
review_mode: Mapped[str] = mapped_column(String(30), default="automatic", nullable=False)
lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False)
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -71,7 +71,6 @@ class KnowledgeSaveRequest(BaseModel):
status: int = Field(default=1, ge=0, le=1)
remark: str | None = Field(default=None, max_length=255)
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
class PromptSaveRequest(BaseModel):
@@ -89,7 +88,16 @@ class AgentDebugRequest(BaseModel):
topK: int | None = Field(default=None, ge=1, le=1000)
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int | None = Field(default=1024, ge=1, le=100000)
maxToken: int | None = Field(default=8192, ge=1, le=100000)
class AgentRuntimeConfigSaveRequest(BaseModel):
temperature: float | None = Field(default=None, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1)
topK: int | None = Field(default=None, ge=1, le=1000)
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int = Field(default=8192, ge=256, le=100000)
class ModelSaveRequest(BaseModel):

View File

@@ -3,19 +3,6 @@ from __future__ import annotations
from pydantic import BaseModel, Field
class KnowledgeSyncRequest(BaseModel):
mode: str = Field(default="review", pattern="^(review|replace)$")
class KnowledgeReviewRequest(BaseModel):
action: str = Field(pattern="^(approve|reject)$")
reason: str | None = Field(default=None, max_length=1000)
class KnowledgePublishRequest(BaseModel):
keepOpen: bool = False
class KnowledgeStatusRequest(BaseModel):
open: bool
@@ -27,13 +14,11 @@ class KnowledgeLifecycleRequest(BaseModel):
class KnowledgeMetadataUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
knowledgeType: str = Field(pattern="^(course|qa|general)$")
reviewMode: str = Field(pattern="^(manual|automatic)$")
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$")
class AttentionUpdateRequest(BaseModel):

View File

@@ -365,8 +365,19 @@ async def _feishu_download_async(path: str, config: FeishuRetrievalConfig) -> by
async def _get_docx_content_async(doc_token: str, config: FeishuRetrievalConfig) -> str:
data = await _feishu_get_async(f"/open-apis/docx/v1/documents/{doc_token}/blocks", config=config)
items = data.get("data", {}).get("items", [])
path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
items: list[dict] = []
page_token = ""
while True:
params: dict[str, Any] = {"page_size": 500}
if page_token:
params["page_token"] = page_token
data = await _feishu_get_async(path, params=params, config=config)
page = data.get("data", {})
items.extend(page.get("items", []))
page_token = str(page.get("page_token") or "")
if not page.get("has_more") or not page_token:
break
return _parse_docx_content(items)

View File

@@ -234,8 +234,19 @@ def _parse_docx_content(blocks: list[dict]) -> str:
def _get_docx_content(doc_token: str, config: FeishuRetrievalConfig) -> str:
"""读取单个飞书云文档内容"""
data = _feishu_get(f"/open-apis/docx/v1/documents/{doc_token}/blocks", config=config)
items = data.get("data", {}).get("items", [])
path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
items: list[dict] = []
page_token = ""
while True:
params: dict[str, Any] = {"page_size": 500}
if page_token:
params["page_token"] = page_token
data = _feishu_get(path, params=params, config=config)
page = data.get("data", {})
items.extend(page.get("items", []))
page_token = str(page.get("page_token") or "")
if not page.get("has_more") or not page_token:
break
return _parse_docx_content(items)

View File

@@ -32,6 +32,8 @@ from app.services.rag_service import PromptService, RagResult, RetrievedChunk
SAFETY_RULE_VERSION = "minimum-safety-v1"
MAX_LEXICAL_CANDIDATES = 12
MAX_SELECTED_SECTIONS = 4
MAX_OVERVIEW_LEXICAL_CANDIDATES = 48
MAX_OVERVIEW_SELECTED_SECTIONS = 24
BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"}
@@ -104,16 +106,34 @@ class KnowledgeAgentService:
try:
if need_knowledge and selected_ids:
terms = cls._query_terms(retrieval_question)
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
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))
practice_overview = cls._is_practice_overview(retrieval_question)
candidate_limit = cls._candidate_limit(retrieval_question)
candidates = cls.search_knowledge(
db,
terms,
selected_ids,
catalog,
candidate_limit=candidate_limit,
)
trace.append(cls._trace("search_knowledge", len(trace) + 1, {"queryTerms": terms, "knowledgeIds": selected_ids, "candidateLimit": candidate_limit}, {"candidateCount": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
await cls._rerank(db, retrieval_question, candidates, trace, started)
selected = cls._select_sections(candidates)
selected = cls._select_sections(
candidates,
limit=cls._selection_limit(retrieval_question),
prefer_numbered_practice=practice_overview,
)
if practice_overview:
selected.sort(key=lambda item: (item.knowledge.name, item.section.sort_order))
selected_contents = {
item.section.id: cls._protect_content(cls._read_complete_section(db, item.section))
for item in selected
}
chunks = [
RetrievedChunk(
knowledge_id=item.knowledge.id,
knowledge_name=item.knowledge.name,
title=item.section.title,
content=cls._protect_content(item.section.content),
content=selected_contents[item.section.id],
version_id=item.version.id,
section_id=item.section.id,
chunk_id=item.chunk.id,
@@ -121,7 +141,7 @@ class KnowledgeAgentService:
)
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))
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": selected_contents[x.section.id], "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)
@@ -313,7 +333,15 @@ class KnowledgeAgentService:
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]:
def search_knowledge(
cls,
db: Session,
terms: list[str],
selected_ids: list[int],
catalog: list[dict],
*,
candidate_limit: int = MAX_LEXICAL_CANDIDATES,
) -> list[Candidate]:
versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
version_ids = [versions_by_kb[item] for item in selected_ids if item in versions_by_kb]
if not version_ids:
@@ -328,17 +356,18 @@ class KnowledgeAgentService:
section, knowledge, version = sections.get(chunk.section_id), knowledge_rows.get(chunk.knowledge_id), versions.get(chunk.version_id)
if not section or not knowledge or not version:
continue
haystack = f"{chunk.title} {chunk.normalized_text} {chunk.content}".lower()
haystack = f"{knowledge.name} {chunk.title} {chunk.normalized_text} {chunk.content}".lower()
score = 0.0
for term in terms:
count = haystack.count(term)
if count:
idf = math.log((len(chunks) + 1) / (frequencies.get(term, 0) + 1)) + 1
score += (count / (count + 1.2)) * idf * (2.4 if term in chunk.title.lower() else 1.0)
score += cls._title_intent_boost(chunk.title, terms)
if score > 0:
candidates.append(Candidate(chunk, section, knowledge, version, score))
candidates.sort(key=lambda item: (item.lexical_score, item.version.published_at or item.version.created_at), reverse=True)
return candidates[:MAX_LEXICAL_CANDIDATES]
return candidates[:candidate_limit]
@classmethod
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict], started: float) -> None:
@@ -367,7 +396,30 @@ class KnowledgeAgentService:
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]:
def _select_sections(
candidates: list[Candidate],
*,
limit: int = MAX_SELECTED_SECTIONS,
prefer_numbered_practice: bool = False,
) -> list[Candidate]:
if prefer_numbered_practice:
numbered_candidates = [
item for item in candidates
if KnowledgeAgentService._is_numbered_practice_title(item.chunk.title)
]
if numbered_candidates:
for item in candidates:
if not KnowledgeAgentService._is_numbered_practice_title(item.chunk.title):
item.discard_reason = "作业清单优先采用编号练习章节"
candidates = numbered_candidates
candidates = sorted(
candidates,
key=lambda item: (
KnowledgeAgentService._is_numbered_practice_title(item.chunk.title),
item.rerank_score if item.rerank_score is not None else item.lexical_score,
),
reverse=True,
)
selected: list[Candidate] = []
seen: set[int] = set()
for item in candidates:
@@ -376,7 +428,7 @@ class KnowledgeAgentService:
item.discard_reason = "相关性不足"
elif item.section.id in seen:
item.discard_reason = "同一父章节已有更高分候选"
elif len(selected) >= MAX_SELECTED_SECTIONS:
elif len(selected) >= limit:
item.discard_reason = "超过本轮章节数量限制"
else:
item.selected = True
@@ -384,6 +436,60 @@ class KnowledgeAgentService:
seen.add(item.section.id)
return selected
@staticmethod
def _selection_limit(question: str) -> int:
if KnowledgeAgentService._is_practice_overview(question):
return MAX_OVERVIEW_SELECTED_SECTIONS
return MAX_SELECTED_SECTIONS
@staticmethod
def _candidate_limit(question: str) -> int:
if KnowledgeAgentService._is_practice_overview(question):
return MAX_OVERVIEW_LEXICAL_CANDIDATES
return MAX_LEXICAL_CANDIDATES
@staticmethod
def _is_practice_overview(question: str) -> bool:
overview_markers = ("有哪些", "是什么", "包括什么", "都有什么", "列出", "汇总", "总结")
practice_markers = ("作业", "功课", "练习")
return any(marker in question for marker in practice_markers) and any(marker in question for marker in overview_markers)
@staticmethod
def _is_numbered_practice_title(title: str) -> bool:
return bool(re.search(r"(?:练习|作业|功课)\s*[一二三四五六七八九十百\d]+", title))
@staticmethod
def _title_intent_boost(title: str, terms: list[str]) -> float:
if not {"作业", "功课", "练习"}.intersection(terms):
return 0.0
if KnowledgeAgentService._is_numbered_practice_title(title):
return 20.0
if any(marker in title for marker in ("练习", "作业", "功课")):
return 3.0
return 0.0
@staticmethod
def _read_complete_section(db: Session, section: KnowledgeSection) -> str:
"""Read a heading together with its lower-level child sections."""
level = _heading_level(section.content)
if level is None:
return section.content
rows = db.scalars(
select(KnowledgeSection)
.where(
KnowledgeSection.version_id == section.version_id,
KnowledgeSection.sort_order >= section.sort_order,
)
.order_by(KnowledgeSection.sort_order)
).all()
parts: list[str] = []
for row in rows:
row_level = _heading_level(row.content)
if row.id != section.id and row_level is not None and row_level <= level:
break
parts.append(row.content)
return "\n\n".join(parts)
@staticmethod
def _persist_candidates(db: Session, log_id: int, candidates: list[Candidate]) -> None:
for item in candidates:
@@ -443,6 +549,11 @@ def _json_trace_value(value):
return str(value)
def _heading_level(content: str) -> int | None:
match = re.match(r"^(#{1,6})\s+", content.lstrip())
return len(match.group(1)) if match else None
def _extract_json(value: str) -> str:
match = re.search(r"\{[\s\S]*\}", value)
if not match:

View File

@@ -6,7 +6,7 @@ import re
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.models.knowledge import (
@@ -25,7 +25,7 @@ from app.services.external_errors import ExternalServiceError
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
from app.services.feishu_service import _feishu_retrieval_config
PROCESSING_RULE_VERSION = "knowledge-pipeline-v1"
PROCESSING_RULE_VERSION = "knowledge-pipeline-v3-latest-only"
MAX_SECTION_CHARS = 2800
MAX_CHUNK_CHARS = 650
@@ -46,13 +46,10 @@ class KnowledgePipelineService:
knowledge: Knowledge,
*,
admin_id: int,
mode: str,
) -> KnowledgeSyncJob:
if mode not in {"review", "replace"}:
raise ValueError("同步模式必须为 review 或 replace")
job = KnowledgeSyncJob(
knowledge_id=knowledge.id,
mode=mode,
mode="replace",
status="running",
stage="fetching",
progress=5,
@@ -88,10 +85,40 @@ class KnowledgePipelineService:
knowledge.source_error = None
knowledge.source_title = source_title
knowledge.last_synced_at = _now()
job.status = "unchanged"
latest_version = db.scalar(
select(KnowledgeVersion)
.where(KnowledgeVersion.snapshot_id == latest_snapshot.id)
.order_by(KnowledgeVersion.id.desc())
.limit(1)
)
if latest_version is not None and latest_version.id != knowledge.current_version_id:
if latest_version.quality_status == "blocked":
raise ValueError("最新同步版本质检未通过,已保留当前正式版本")
cls._activate_version(
db,
knowledge,
latest_version,
admin_id,
keep_open=knowledge.status == 1,
action="sync_publish",
)
job.status = "published"
job.result_summary = json.dumps(
{
"changed": False,
"activatedExistingVersion": True,
"versionId": latest_version.id,
"versionNo": latest_version.version_no,
},
ensure_ascii=False,
)
else:
job.status = "unchanged"
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
if latest_version is not None:
cls._prune_old_versions(db, knowledge, latest_version)
job.stage = "completed"
job.progress = 100
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
job.finished_at = _now()
db.add_all([knowledge, job])
db.commit()
@@ -188,7 +215,7 @@ class KnowledgePipelineService:
content_hash=section.content_hash,
generation_model="deterministic-v1",
generation_rule_version=PROCESSING_RULE_VERSION,
review_status="pending",
review_status="approved",
)
)
@@ -210,7 +237,9 @@ class KnowledgePipelineService:
boundaries=manifest_data["boundaries"],
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
change_level=change_level,
confirmed=1 if previous_manifest and previous_manifest.confirmed == 1 and change_level == "minor" else 0,
confirmed=1,
confirmed_by=admin_id,
confirmed_at=_now(),
)
db.add(manifest)
@@ -222,40 +251,32 @@ class KnowledgePipelineService:
{"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
ensure_ascii=False,
)
version.status = "pending_review"
if quality["blocking"]:
issue_text = "".join(
item["message"] for item in quality["issues"] if item["level"] == "blocking"
)
raise ValueError(f"同步内容质检未通过:{issue_text or '存在阻断项'}")
knowledge.source_title = source_title
knowledge.source_status = "normal"
knowledge.source_error = None
knowledge.pending_version_id = version.id
knowledge.last_synced_at = _now()
knowledge.migration_status = "pending_review"
job.status = "pending_review"
cls._activate_version(
db,
knowledge,
version,
admin_id,
keep_open=knowledge.status == 1,
action="sync_publish",
)
job.status = "published"
job.stage = "completed"
job.progress = 100
job.result_summary = json.dumps(
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
ensure_ascii=False,
)
job.finished_at = _now()
if (
knowledge.review_mode == "automatic"
and version.quality_status == "passed"
and not quality["highRisk"]
and manifest.confirmed == 1
):
cls.review_version(
db,
knowledge,
version,
admin_id,
"approve",
"自动审核方式:质检通过且说明卡可继承,自动发布。",
)
cls.publish_version(db, knowledge, version, admin_id, keep_open=knowledge.status == 1)
job.status = "published"
job.result_summary = json.dumps(
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
ensure_ascii=False,
)
db.add_all([knowledge, version, job])
db.commit()
return job
@@ -278,57 +299,23 @@ class KnowledgePipelineService:
raise
@staticmethod
def confirm_manifest(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int) -> None:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
if manifest is None:
raise ValueError("该版本没有知识库说明卡")
manifest.confirmed = 1
manifest.confirmed_by = admin_id
manifest.confirmed_at = _now()
if knowledge.pending_version_id == version.id:
knowledge.manifest_confirmed = 1
db.add_all([manifest, knowledge])
@staticmethod
def review_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, action: str, reason: str | None) -> None:
if action not in {"approve", "reject"}:
raise ValueError("审核动作不正确")
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == version.id)).all()
review_status = "approved" if action == "approve" else "rejected"
for card in cards:
card.review_status = review_status
db.add(card)
version.status = "approved" if action == "approve" else "rejected"
db.add_all(
[
version,
KnowledgeReviewRecord(
knowledge_id=knowledge.id,
version_id=version.id,
action=action,
reason=reason,
reviewed_by=admin_id,
),
]
)
@staticmethod
def publish_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
if version.status != "approved":
raise ValueError("只有审核通过的版本可以发布")
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
if manifest is None or manifest.confirmed != 1:
raise ValueError("知识库说明卡尚未确认")
def _activate_version(
db: Session,
knowledge: Knowledge,
version: KnowledgeVersion,
admin_id: int,
*,
keep_open: bool,
action: str,
) -> None:
"""Atomically replace the active version with validated latest content."""
if version.quality_status == "blocked":
raise ValueError("质检未通过的版本不能生效")
if keep_open and knowledge.lifecycle_status != "active":
raise ValueError("归档知识库不能在发布后开放")
raise ValueError("归档知识库不能在版本替换后开放")
if keep_open and knowledge.source_status != "normal":
raise ValueError("飞书源文章异常,不能在发布后开放")
raise ValueError("飞书源文章异常,不能替换正式版本")
previous_version_id = knowledge.current_version_id
if previous_version_id:
previous = db.get(KnowledgeVersion, previous_version_id)
if previous:
previous.status = "superseded"
db.add(previous)
version.status = "published"
version.published_by = admin_id
version.published_at = _now()
@@ -346,40 +333,42 @@ class KnowledgePipelineService:
knowledge_id=knowledge.id,
from_version_id=previous_version_id,
to_version_id=version.id,
action="publish",
action=action,
keep_open=knowledge.status,
operated_by=admin_id,
),
]
)
db.flush()
KnowledgePipelineService._prune_old_versions(db, knowledge, version)
@staticmethod
def rollback(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
if version.status not in {"published", "superseded"}:
raise ValueError("只能回滚到历史正式版本")
previous_id = knowledge.current_version_id
if previous_id:
previous = db.get(KnowledgeVersion, previous_id)
if previous:
previous.status = "superseded"
db.add(previous)
version.status = "published"
knowledge.current_version_id = version.id
knowledge.status = 1 if keep_open else 0
knowledge.last_published_at = _now()
db.add_all(
[
knowledge,
version,
KnowledgePublishLog(
knowledge_id=knowledge.id,
from_version_id=previous_id,
to_version_id=version.id,
action="rollback",
keep_open=knowledge.status,
operated_by=admin_id,
),
]
def _prune_old_versions(db: Session, knowledge: Knowledge, keep: KnowledgeVersion) -> None:
"""Keep only the active version and its derived retrieval data."""
old_version_ids = list(db.scalars(
select(KnowledgeVersion.id).where(
KnowledgeVersion.knowledge_id == knowledge.id,
KnowledgeVersion.id != keep.id,
)
).all())
if old_version_ids:
for model in (
KnowledgeReviewRecord,
KnowledgeCard,
KnowledgeChunk,
KnowledgeSection,
KnowledgeManifest,
):
db.execute(delete(model).where(model.version_id.in_(old_version_ids)))
db.execute(delete(KnowledgeVersion).where(KnowledgeVersion.id.in_(old_version_ids)))
# Snapshots are source copies for a version. Once that version is gone,
# retaining the old snapshot would still be retaining knowledge history.
db.execute(
delete(KnowledgeSourceSnapshot).where(
KnowledgeSourceSnapshot.knowledge_id == knowledge.id,
KnowledgeSourceSnapshot.id != keep.snapshot_id,
)
)
@staticmethod
@@ -455,6 +444,9 @@ _SYNONYMS = {
"焦虑": ["担心", "紧张", "害怕"],
"沟通": ["交流", "对话", "聊天"],
"课程": ["训练营", "大本营", "课堂"],
"作业": ["功课", "练习"],
"功课": ["作业", "练习"],
"练习": ["作业", "功课"],
}
@@ -507,7 +499,7 @@ def quality_check(content: str, sections: list[ParsedSection], chunk_count: int)
issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"})
high_risk = detect_risk_level(content) == "high"
if high_risk:
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,需要重点审核"})
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,请重点关注内容准确性"})
return {
"blocking": any(item["level"] == "blocking" for item in issues),
"issues": issues,

View File

@@ -116,7 +116,7 @@ class ModelClientService:
return {
"ok": True,
"message": "Agent 调试完成",
"answer": answer[:4000],
"answer": answer,
"prompt": rag_result.prompt,
"modelName": model.model_name,
"retrieveCount": len(rag_result.chunks),
@@ -494,7 +494,7 @@ def _agent_system_prompt() -> str:
def _max_output_tokens(model: ModelConfig) -> int:
configured = max(1, model.max_token or 1024)
configured = max(1, model.max_token or 8192)
if not model.context_window:
return configured
return min(configured, max(1, model.context_window // 3))

View File

@@ -121,7 +121,7 @@ class PromptService:
# 知识库上下文
context = "\n\n".join(
f"[已回读完整章节 {index}] {chunk.title}\n{chunk.content}"
f"[已回读完整章节 {index}] {chunk.title}\n来源知识库:{chunk.knowledge_name}\n{chunk.content}"
for index, chunk in enumerate(chunks, start=1)
)
if not context:
@@ -152,7 +152,10 @@ class PromptService:
if content and message.role in {"user", "assistant"}:
messages.append({"role": message.role, "content": content})
messages.append({"role": "system", "content": f"[本轮可靠知识上下文]\n{context}"})
messages.append({
"role": "system",
"content": f"[本轮可靠知识上下文]\n{context}",
})
messages.append({"role": "user", "content": question.strip()})
return messages

View File

@@ -0,0 +1,116 @@
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin_settings import get_agent_runtime_config, save_agent_runtime_config
from app.models import Base
from app.models.admin import Admin
from app.models.ai_config import ModelConfig
from app.schemas.admin import AgentRuntimeConfigSaveRequest
from app.services.model_stream_service import _openai_stream_payload
from app.services.model_service import ModelClientService, _max_output_tokens
from app.services.rag_service import RagResult
def _database() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
return Session(engine)
def _admin() -> Admin:
return Admin(id=1, username="admin", password="hash", name="系统管理员", status=1)
def _model() -> ModelConfig:
return ModelConfig(
id=1,
provider="openai-compatible",
display_name="正式模型",
api_type="openai_compatible",
model_name="production-model",
base_url="https://example.com/v1",
api_url="",
api_key="encrypted",
auth_type="bearer",
max_token=None,
stream_enabled=1,
timeout_second=30,
enabled=1,
)
def test_runtime_config_defaults_to_long_answer_safe_max_tokens():
with _database() as db:
admin = _admin()
model = _model()
db.add_all([admin, model])
db.commit()
result = get_agent_runtime_config(db=db, current_admin=admin)["data"]
assert result["modelId"] == model.id
assert result["modelName"] == "正式模型"
assert result["maxToken"] == 8192
assert _max_output_tokens(model) == 8192
def test_saved_runtime_config_is_persisted_on_enabled_model():
with _database() as db:
admin = _admin()
model = _model()
db.add_all([admin, model])
db.commit()
result = save_agent_runtime_config(
AgentRuntimeConfigSaveRequest(
temperature=0.3,
topP=0.9,
topK=40,
presencePenalty=0.2,
frequencyPenalty=0.4,
maxToken=12000,
),
db=db,
current_admin=admin,
)["data"]
db.refresh(model)
assert result["maxToken"] == 12000
assert model.max_token == 12000
assert model.temperature == Decimal("0.30")
assert model.top_p == Decimal("0.90")
assert model.top_k == 40
assert model.presence_penalty == Decimal("0.20")
assert model.frequency_penalty == Decimal("0.40")
payload = _openai_stream_payload(
model,
RagResult(question="测试", knowledge_scopes=[], chunks=[], prompt="测试", allow_general_knowledge=True),
)
assert payload["max_tokens"] == 12000
assert payload["temperature"] == 0.3
assert payload["top_p"] == 0.9
assert payload["presence_penalty"] == 0.2
assert payload["frequency_penalty"] == 0.4
def test_agent_debug_does_not_truncate_long_answer(monkeypatch):
monkeypatch.setattr(
"app.services.model_service._call_configured_model",
lambda *_args, **_kwargs: "" * 6000,
)
result = ModelClientService.debug_model(
_model(),
RagResult(question="测试", knowledge_scopes=[], chunks=[], prompt="测试", allow_general_knowledge=True),
{"max_token": 8192},
)
assert len(result["answer"]) == 6000

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
from app.services.feishu_async_service import _get_docx_content_async
from app.services.feishu_service import FeishuRetrievalConfig, _get_docx_content
def _config() -> FeishuRetrievalConfig:
return FeishuRetrievalConfig(
search_url="",
app_id="app-id",
app_secret="secret",
timeout_seconds=10,
retry_count=1,
)
def _heading(text: str) -> dict:
return {
"block_type": 3,
"heading1": {"elements": [{"text_run": {"content": text}}]},
}
def test_docx_content_reads_all_block_pages():
pages = [
{"data": {"items": [_heading("第一页")], "has_more": True, "page_token": "next-page"}},
{"data": {"items": [_heading("第二页作业")], "has_more": False, "page_token": ""}},
]
with patch("app.services.feishu_service._feishu_get", side_effect=pages) as feishu_get:
content = _get_docx_content("doc-token", _config())
assert "第一页" in content
assert "第二页作业" in content
assert feishu_get.call_count == 2
assert feishu_get.call_args_list[0].kwargs["params"] == {"page_size": 500}
assert feishu_get.call_args_list[1].kwargs["params"] == {"page_size": 500, "page_token": "next-page"}
def test_async_docx_content_reads_all_block_pages():
pages = [
{"data": {"items": [_heading("第一页")], "has_more": True, "page_token": "next-page"}},
{"data": {"items": [_heading("第二页作业")], "has_more": False, "page_token": ""}},
]
feishu_get = AsyncMock(side_effect=pages)
with patch("app.services.feishu_async_service._feishu_get_async", feishu_get):
content = asyncio.run(_get_docx_content_async("doc-token", _config()))
assert "第一页" in content
assert "第二页作业" in content
assert feishu_get.await_count == 2
assert feishu_get.await_args_list[1].kwargs["params"] == {"page_size": 500, "page_token": "next-page"}

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, select
@@ -9,7 +12,14 @@ from sqlalchemy.pool import StaticPool
from app.api.admin_knowledge_lifecycle import batch_update_metadata, update_metadata
from app.models import Base
from app.models.admin import Admin, Role
from app.models.knowledge import Knowledge, KnowledgeManifest, KnowledgeSourceSnapshot, KnowledgeVersion
from app.models.knowledge import (
Knowledge,
KnowledgeCard,
KnowledgeManifest,
KnowledgePublishLog,
KnowledgeSourceSnapshot,
KnowledgeVersion,
)
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.knowledge_pipeline_service import KnowledgePipelineService
@@ -82,7 +92,7 @@ def test_metadata_update_preserves_source_version_and_open_state():
original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status)
update_metadata(
item.id,
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"),
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course"),
db,
_admin(),
)
@@ -98,7 +108,7 @@ def test_metadata_update_requires_super_admin():
with pytest.raises(HTTPException) as exc:
update_metadata(
item.id,
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"),
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa"),
db,
_admin("operator"),
)
@@ -118,23 +128,81 @@ def test_batch_metadata_update_reports_missing_items():
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
def test_rollback_switches_published_version_statuses():
def test_sync_automatically_replaces_current_version_and_removes_history():
source_content = (
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
)
with _database() as db:
item = _knowledge(db)
old_version = db.get(KnowledgeVersion, item.current_version_id)
snapshot = db.scalar(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id))
target = KnowledgeVersion(
knowledge_id=item.id,
snapshot_id=snapshot.id,
version_no=0,
status="superseded",
quality_status="passed",
processing_rule_version="test-v1",
previous_version_id = item.current_version_id
loader = AsyncMock(return_value=[("新源标题", source_content, None)])
with patch(
"app.services.knowledge_pipeline_service.AsyncFeishuKnowledgeService._load_documents",
loader,
):
job = asyncio.run(
KnowledgePipelineService.synchronize(
db,
item,
admin_id=1,
)
)
db.refresh(item)
current = db.get(KnowledgeVersion, item.current_version_id)
versions = db.scalars(
select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == item.id)
).all()
snapshots = db.scalars(
select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id)
).all()
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id))
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
publish_log = db.scalar(
select(KnowledgePublishLog).where(KnowledgePublishLog.to_version_id == current.id)
)
db.add(target)
db.flush()
KnowledgePipelineService.rollback(db, item, target, admin_id=1, keep_open=True)
db.commit()
assert item.current_version_id == target.id
assert target.status == "published"
assert old_version.status == "superseded"
assert job.status == "published"
assert job.mode == "replace"
assert current.status == "published"
assert current.quality_status == "passed"
assert db.get(KnowledgeVersion, previous_version_id) is None
assert [version.id for version in versions] == [current.id]
assert [snapshot.id for snapshot in snapshots] == [current.snapshot_id]
assert item.pending_version_id is None
assert item.status == 1
assert item.migration_status == "completed"
assert manifest.confirmed == 1
assert cards and all(card.review_status == "approved" for card in cards)
assert publish_log.action == "sync_publish"
def test_sync_validation_failure_keeps_current_version_active():
with _database() as db:
item = _knowledge(db)
previous_version_id = item.current_version_id
loader = AsyncMock(return_value=[("无效源内容", "内容过短", None)])
with patch(
"app.services.knowledge_pipeline_service.AsyncFeishuKnowledgeService._load_documents",
loader,
):
with pytest.raises(ValueError, match="质检未通过"):
asyncio.run(
KnowledgePipelineService.synchronize(
db,
item,
admin_id=1,
)
)
db.refresh(item)
current = db.get(KnowledgeVersion, previous_version_id)
versions = db.scalars(
select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == item.id)
).all()
assert item.current_version_id == previous_version_id
assert current.status == "published"
assert len(versions) == 1

View File

@@ -3,8 +3,9 @@ from __future__ import annotations
import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
@@ -19,7 +20,7 @@ from app.models.knowledge import (
KnowledgeVersion,
)
from app.models.chat import ChatMessage
from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.knowledge_agent_service import Candidate, KnowledgeAgentService
def _database() -> Session:
@@ -141,6 +142,10 @@ def test_course_question_searches_chunk_and_reads_parent_section():
assert len(result.chunks) == 1
assert "先稳定自己的焦虑" in result.chunks[0].content
assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace)
knowledge_context = next(
message["content"] for message in result.messages if message["content"].startswith("[本轮可靠知识上下文]")
)
assert "来源知识库:亲子课程" in knowledge_context
def test_tool_trace_serializes_published_datetime():
@@ -173,3 +178,89 @@ def test_contextual_follow_up_is_rewritten_before_agent_decision():
assert rewrite["rewrittenQuestion"] == "关于“课程退款条件有哪些?”的追问:那第二种情况呢?"
assert decision["request"]["question"] == rewrite["rewrittenQuestion"]
def test_homework_overview_expands_practice_terms_and_section_limit():
terms = KnowledgeAgentService._query_terms("合一的作业是什么?")
assert "功课" in terms
assert "练习" in terms
assert KnowledgeAgentService._candidate_limit("合一的作业是什么?") == 48
assert KnowledgeAgentService._selection_limit("合一的作业是什么?") == 24
assert KnowledgeAgentService._candidate_limit("这个练习怎么做?") == 12
assert KnowledgeAgentService._selection_limit("这个练习怎么做?") == 4
assert KnowledgeAgentService._title_intent_boost("十七、练习一:风铃式静心", terms) == 20.0
assert KnowledgeAgentService._title_intent_boost("完整练习的方向", terms) == 3.0
assert KnowledgeAgentService._title_intent_boost("课程定位", terms) == 0.0
def test_homework_overview_keeps_all_numbered_practices_before_selection():
candidates = [
Candidate(
chunk=SimpleNamespace(title=f"练习{index}:课程作业", id=index),
section=SimpleNamespace(id=index),
knowledge=SimpleNamespace(id=1),
version=SimpleNamespace(id=1),
lexical_score=float(30 - index),
)
for index in range(1, 15)
]
unrelated = Candidate(
chunk=SimpleNamespace(title="课程介绍", id=99),
section=SimpleNamespace(id=99),
knowledge=SimpleNamespace(id=1),
version=SimpleNamespace(id=1),
lexical_score=100.0,
)
selected = KnowledgeAgentService._select_sections(
[unrelated, *candidates],
limit=KnowledgeAgentService._selection_limit("原生里的作业内容都有什么"),
prefer_numbered_practice=True,
)
assert len(selected) == 14
assert all(item.chunk.title.startswith("练习") for item in selected)
def test_complete_section_includes_child_headings_but_stops_at_next_peer():
with _database() as db:
knowledge = _add_published_knowledge(db, knowledge_id=1, name="合一课程")
version_id = knowledge.current_version_id
parent = db.scalar(select(KnowledgeSection).where(KnowledgeSection.version_id == version_id))
assert parent is not None
parent.title = "练习一:风铃式静心"
parent.content = "# 练习一:风铃式静心"
parent.sort_order = 1
db.add_all([
parent,
KnowledgeSection(
knowledge_id=knowledge.id,
version_id=version_id,
section_key="S0002",
title="基本操作",
content="## 基本操作\n鼻吸鼻呼,呼气时发出连续蜂鸣声。",
source_start=10,
source_end=40,
sort_order=2,
content_hash="child",
),
KnowledgeSection(
knowledge_id=knowledge.id,
version_id=version_id,
section_key="S0003",
title="练习二:内感知建模",
content="# 练习二:内感知建模\n这是下一项作业。",
source_start=41,
source_end=70,
sort_order=3,
content_hash="next",
),
])
db.commit()
content = KnowledgeAgentService._read_complete_section(db, parent)
assert "鼻吸鼻呼" in content
assert "下一项作业" not in content