Compare commits
9 Commits
ba1840233c
...
codex/h5-q
| Author | SHA1 | Date | |
|---|---|---|---|
| e3634eb26c | |||
| 9ba8fb6e0c | |||
| 921b56cfb9 | |||
| 1b5fcc4d52 | |||
| 6c02662d3b | |||
| 245b6b3eca | |||
| cbbdb41b80 | |||
| c3f24d0ccd | |||
| 56c9f2f359 |
@@ -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: "",
|
||||
|
||||
@@ -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="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。答疑场景建议使用较低数值。"><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="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><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="取值 1–1000。每次生成只从概率最高的前 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 数,不等于字数。建议正式答疑使用 4096–8192;数值越大允许回答越长,也会增加耗时和费用,最终仍受模型自身上限约束。"><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="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><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="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><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>
|
||||
@@ -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="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。答疑场景建议使用较低数值。"><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="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><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="取值 1–1000。每次生成只从概率最高的前 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="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><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="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user