diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue
index 31cd95e..c22cab4 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue
+++ b/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue
@@ -48,6 +48,12 @@ const form = reactive({
});
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
const batchForm = reactive({ knowledgeType: "" });
+const knowledgeTypeOptions = [
+ { label: "课程知识库", value: "course" },
+ { label: "答疑知识库", value: "qa" },
+ { label: "固定信息类知识库", value: "fixed" },
+ { label: "通用知识库", value: "general" },
+];
onMounted(load);
@@ -321,7 +327,7 @@ function statusType(status: string) {
}
function typeLabel(value: string) {
- return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
+ return knowledgeTypeOptions.find((item) => item.value === value)?.label || value;
}
function errorMessage(error: unknown, fallback: string) {
@@ -367,7 +373,7 @@ async function confirmAction(
-
+
保存知识库
@@ -376,7 +382,7 @@ async function confirmAction(
-
+
@@ -424,7 +430,7 @@ async function confirmAction(
-
+
取消保存
@@ -433,7 +439,7 @@ async function confirmAction(
-
+
取消下一步
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
index e1d7ae6..673b209 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
@@ -92,7 +92,7 @@ export interface KnowledgeItem {
status: number;
remark?: string | null;
sourceTitle?: string | null;
- knowledgeType: "course" | "qa" | "general";
+ knowledgeType: "course" | "qa" | "general" | "fixed";
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
sourceError?: string | null;
diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
index 7894d58..4811154 100644
--- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
+++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
@@ -70,7 +70,7 @@ class KnowledgeSaveRequest(BaseModel):
feishuNodeId: str = Field(min_length=1, max_length=100)
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)$")
+ knowledgeType: str = Field(default="general", pattern="^(course|qa|general|fixed)$")
class PromptSaveRequest(BaseModel):
diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py b/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
index 8c86d92..1636e6b 100644
--- a/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
+++ b/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
@@ -13,12 +13,12 @@ class KnowledgeLifecycleRequest(BaseModel):
class KnowledgeMetadataUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
- knowledgeType: str = Field(pattern="^(course|qa|general)$")
+ knowledgeType: str = Field(pattern="^(course|qa|general|fixed)$")
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
- knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
+ knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general|fixed)$")
class KnowledgeBatchSyncRequest(BaseModel):
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py
index 71ee9f0..516ed66 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py
@@ -44,6 +44,12 @@ BUSINESS_MARKERS = {
"课程", "大本营", "训练营", "老师", "卢慧", "功课", "作业", "学员",
"课堂", "练习", "静心", "觉察", "内在",
}
+FIXED_INFORMATION_TYPE = "fixed"
+FIXED_INFORMATION_MARKERS = {
+ "上课时间", "上课安排", "开课时间", "带练", "回放", "音频", "课程作业", "作业",
+ "会议", "会议链接", "直播链接", "链接", "服务权益", "权益", "课程助理", "助理",
+ "联系方式", "联系谁", "当前安排", "最新安排", "什么时候上课", "几点上课",
+}
_ROUTING_STOP_TERMS = {
"课程", "练习", "功课", "作业", "静心", "具体", "详细", "内容", "怎么", "操作", "什么", "方法",
}
@@ -111,9 +117,11 @@ class KnowledgeAgentService:
title_routes = cls._route_knowledge_by_titles(db, terms, catalog)
if title_routes:
need_knowledge = True
- selected_ids = list(dict.fromkeys(
- [item["knowledgeId"] for item in title_routes] + selected_ids
- ))[:4]
+ selected_ids = cls._prioritize_knowledge_ids(
+ [item["knowledgeId"] for item in title_routes] + selected_ids,
+ catalog,
+ prefer_fixed_information=cls._is_fixed_information_query(retrieval_question),
+ )[:4]
reason = "章节标题直接命中知识库"
trace.append(cls._trace(
"agent_decision",
@@ -140,6 +148,7 @@ class KnowledgeAgentService:
selected_ids,
catalog,
candidate_limit=candidate_limit,
+ prefer_fixed_information=cls._is_fixed_information_query(retrieval_question),
)
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)
@@ -147,9 +156,15 @@ class KnowledgeAgentService:
candidates,
limit=cls._selection_limit(retrieval_question),
prefer_numbered_practice=practice_overview,
+ prefer_fixed_information=cls._is_fixed_information_query(retrieval_question),
)
if practice_overview:
- selected.sort(key=lambda item: (item.knowledge.name, item.section.sort_order))
+ selected.sort(key=lambda item: (
+ item.knowledge.knowledge_type != FIXED_INFORMATION_TYPE,
+ -cls._version_timestamp(item.version),
+ item.knowledge.name,
+ item.section.sort_order,
+ ))
selected_contents = {
item.section.id: cls._protect_content(
cls._read_complete_section(db, item.section),
@@ -167,6 +182,8 @@ class KnowledgeAgentService:
section_id=item.section.id,
chunk_id=item.chunk.id,
score=item.rerank_score if item.rerank_score is not None else item.lexical_score,
+ knowledge_type=item.knowledge.knowledge_type,
+ published_at=(item.version.published_at or item.version.created_at).isoformat(),
)
for item in selected
]
@@ -370,6 +387,7 @@ class KnowledgeAgentService:
catalog: list[dict],
*,
candidate_limit: int = MAX_LEXICAL_CANDIDATES,
+ prefer_fixed_information: bool = False,
) -> 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]
@@ -393,9 +411,18 @@ class KnowledgeAgentService:
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 and prefer_fixed_information and knowledge.knowledge_type == FIXED_INFORMATION_TYPE:
+ score += 12.0
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)
+ candidates.sort(
+ key=lambda item: (
+ prefer_fixed_information and item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE,
+ item.lexical_score,
+ item.version.published_at or item.version.created_at,
+ ),
+ reverse=True,
+ )
return candidates[:candidate_limit]
@classmethod
@@ -430,15 +457,18 @@ class KnowledgeAgentService:
*,
limit: int = MAX_SELECTED_SECTIONS,
prefer_numbered_practice: bool = False,
+ prefer_fixed_information: bool = False,
) -> list[Candidate]:
if prefer_numbered_practice:
numbered_candidates = [
item for item in candidates
if KnowledgeAgentService._is_numbered_practice_title(item.chunk.title)
+ or (prefer_fixed_information and item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE)
]
if numbered_candidates:
for item in candidates:
- if not KnowledgeAgentService._is_numbered_practice_title(item.chunk.title):
+ keep_fixed = prefer_fixed_information and item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE
+ if not KnowledgeAgentService._is_numbered_practice_title(item.chunk.title) and not keep_fixed:
item.discard_reason = "作业清单优先采用编号练习章节"
candidates = numbered_candidates
candidates = sorted(
@@ -449,6 +479,16 @@ class KnowledgeAgentService:
),
reverse=True,
)
+ if prefer_fixed_information:
+ candidates = sorted(
+ candidates,
+ key=lambda item: (
+ item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE,
+ item.rerank_score if item.rerank_score is not None else item.lexical_score,
+ item.version.published_at or item.version.created_at,
+ ),
+ reverse=True,
+ )
selected: list[Candidate] = []
seen: set[int] = set()
for item in candidates:
@@ -461,6 +501,7 @@ class KnowledgeAgentService:
item.discard_reason = "超过本轮章节数量限制"
else:
item.selected = True
+ item.discard_reason = None
selected.append(item)
seen.add(item.section.id)
return selected
@@ -558,25 +599,58 @@ class KnowledgeAgentService:
@staticmethod
def _decide(question: str, catalog: list[dict]) -> tuple[bool, list[int], str]:
business = any(marker in question for marker in BUSINESS_MARKERS)
+ fixed_information = KnowledgeAgentService._is_fixed_information_query(question)
if not catalog:
- return business, [], "当前没有可用正式知识库"
+ return business or fixed_information, [], "当前没有可用正式知识库"
terms = set(extract_terms(question))
- ranked: list[tuple[float, int]] = []
+ ranked: list[tuple[bool, float, str, int]] = []
for item in catalog:
manifest_terms = set(extract_terms(" ".join(str(item.get(key, "")) for key in ("name", "purpose", "applicableQuestions", "coreTopics"))))
overlap = len(terms & manifest_terms) / max(1, min(len(terms), 18))
- if overlap > 0 or business:
- ranked.append((overlap, int(item["knowledgeId"])))
+ is_fixed = item.get("type") == FIXED_INFORMATION_TYPE
+ if overlap > 0 or business or (fixed_information and is_fixed):
+ ranked.append((fixed_information and is_fixed, overlap, str(item.get("publishedAt") or ""), int(item["knowledgeId"])))
ranked.sort(reverse=True)
- selected = [item_id for score, item_id in ranked if score >= 0.04][:4]
+ selected = [item_id for preferred, score, _, item_id in ranked if preferred or score >= 0.04][:4]
if business and not selected:
- selected = [item_id for _, item_id in ranked[:3]]
+ selected = [item_id for _, _, _, item_id in ranked[:3]]
+ if fixed_information and selected:
+ return True, selected, "涉及当前安排,固定信息类知识库优先"
if business:
return True, selected, "涉及课程/老师/业务知识"
if selected:
return True, selected, "问题与知识目录主题相关"
return False, [], "问题与当前知识目录无关,无需调用知识库"
+ @staticmethod
+ def _is_fixed_information_query(question: str) -> bool:
+ return any(marker in question for marker in FIXED_INFORMATION_MARKERS)
+
+ @staticmethod
+ def _prioritize_knowledge_ids(
+ knowledge_ids: list[int],
+ catalog: list[dict],
+ *,
+ prefer_fixed_information: bool,
+ ) -> list[int]:
+ unique_ids = list(dict.fromkeys(knowledge_ids))
+ if not prefer_fixed_information:
+ return unique_ids
+ catalog_by_id = {int(item["knowledgeId"]): item for item in catalog}
+ return sorted(
+ unique_ids,
+ key=lambda knowledge_id: (
+ catalog_by_id.get(knowledge_id, {}).get("type") == FIXED_INFORMATION_TYPE,
+ str(catalog_by_id.get(knowledge_id, {}).get("publishedAt") or ""),
+ ),
+ reverse=True,
+ )
+
+ @staticmethod
+ def _version_timestamp(version: KnowledgeVersion) -> float:
+ value = version.published_at or version.created_at
+ return value.timestamp() if value is not None else 0.0
+
@staticmethod
def _route_knowledge_by_titles(db: Session, terms: list[str], catalog: list[dict]) -> list[dict]:
"""Map a named exercise/chapter back to its knowledge base before retrieval."""
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py
index f516f84..3e12d68 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py
@@ -26,6 +26,8 @@ class RetrievedChunk:
section_id: int | None = None
chunk_id: int | None = None
score: float | None = None
+ knowledge_type: str = "general"
+ published_at: str | None = None
@dataclass(frozen=True)
@@ -120,10 +122,7 @@ class PromptService:
prompt = cls._load_active_prompt(db)
# 知识库上下文
- context = "\n\n".join(
- f"[已回读完整章节 {index}] {chunk.title}\n来源知识库:{chunk.knowledge_name}\n{chunk.content}"
- for index, chunk in enumerate(chunks, start=1)
- )
+ context = "\n\n".join(cls._format_chunk(index, chunk) for index, chunk in enumerate(chunks, start=1))
if not context:
context = "本轮没有可靠的正式知识章节。对于一般常识可以谨慎回答;涉及课程、老师观点或公司业务时必须说明依据不足,不得编造。"
@@ -134,7 +133,8 @@ class PromptService:
+ "\n\n"
+ "[不可关闭的最低安全规则 v1]\n"
+ "现实危险、自伤伤人风险应优先建议立即寻求线下专业帮助;医疗、法律、财务问题不得给出替代专业意见的结论;"
- + "不得伪造老师观点或课程内容;不得输出整篇课程文章、大段连续原文,也不得通过多轮拼接还原完整资料。",
+ + "不得伪造老师观点或课程内容;不得输出整篇课程文章、大段连续原文,也不得通过多轮拼接还原完整资料。"
+ + cls._fixed_information_rule(chunks),
}
]
@@ -159,6 +159,30 @@ class PromptService:
messages.append({"role": "user", "content": question.strip()})
return messages
+ @staticmethod
+ def _format_chunk(index: int, chunk: RetrievedChunk) -> str:
+ if chunk.knowledge_type == "fixed":
+ priority = "固定信息类·最高优先级"
+ published = f"\n生效版本时间:{chunk.published_at}" if chunk.published_at else ""
+ else:
+ priority = "普通知识"
+ published = ""
+ return (
+ f"[已回读完整章节 {index}|{priority}] {chunk.title}\n"
+ f"来源知识库:{chunk.knowledge_name}{published}\n{chunk.content}"
+ )
+
+ @staticmethod
+ def _fixed_information_rule(chunks: list[RetrievedChunk]) -> str:
+ if not any(chunk.knowledge_type == "fixed" for chunk in chunks):
+ return ""
+ return (
+ "\n[固定信息优先规则]\n"
+ "本轮召回了固定信息类知识库。上课时间、带练安排、回放和音频、课程作业、会议链接、服务权益、"
+ "课程助理联系方式及其他当前安排,必须以固定信息类知识库中的最新生效内容为准。"
+ "如与课程、答疑或通用知识库冲突,忽略其他库的冲突内容;多个固定信息来源冲突时,以生效版本时间较新的内容为准。"
+ )
+
@staticmethod
def render_messages(messages: list[dict[str, str]]) -> str:
role_labels = {"system": "系统", "user": "用户", "assistant": "大本营答疑助手"}
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_agent.py b/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_agent.py
index 9808ea0..359e6f9 100644
--- a/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_agent.py
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_agent.py
@@ -5,6 +5,7 @@ import json
from datetime import datetime
from types import SimpleNamespace
+import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
@@ -24,13 +25,34 @@ from app.services.knowledge_agent_service import Candidate, KnowledgeAgentServic
from app.services.knowledge_pipeline_service import parse_sections
+@pytest.fixture(autouse=True)
+def _disable_shared_redis_cache(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Keep unit-test catalogs isolated from the running development database cache."""
+ monkeypatch.setattr(
+ "app.services.knowledge_catalog_cache_service.get_sync_redis_client",
+ lambda: None,
+ )
+
+
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 _add_published_knowledge(db: Session, *, knowledge_id: int, name: str, open_status: int = 1) -> Knowledge:
+def _add_published_knowledge(
+ db: Session,
+ *,
+ knowledge_id: int,
+ name: str,
+ open_status: int = 1,
+ knowledge_type: str = "course",
+ purpose: str = "解答家长与孩子沟通、学习动力相关课程问题",
+ applicable_questions: str = "孩子学习动力、亲子沟通",
+ core_topics: str = "家长情绪、倾听孩子、学习动力",
+ section_title: str = "家长沟通的第一步",
+ section_content: str = "家长和学习动力不足的孩子沟通时,第一步是先稳定自己的焦虑,再倾听孩子遇到的具体困难。",
+) -> Knowledge:
knowledge = Knowledge(
id=knowledge_id,
name=name,
@@ -39,7 +61,7 @@ def _add_published_knowledge(db: Session, *, knowledge_id: int, name: str, open_
status=open_status,
source_status="normal",
manifest_confirmed=1,
- knowledge_type="course",
+ knowledge_type=knowledge_type,
review_mode="manual",
)
db.add(knowledge)
@@ -68,10 +90,10 @@ def _add_published_knowledge(db: Session, *, knowledge_id: int, name: str, open_
KnowledgeManifest(
knowledge_id=knowledge.id,
version_id=version.id,
- purpose="解答家长与孩子沟通、学习动力相关课程问题",
- applicable_questions="孩子学习动力、亲子沟通",
+ purpose=purpose,
+ applicable_questions=applicable_questions,
inapplicable_questions="天气和交通",
- core_topics="家长情绪、倾听孩子、学习动力",
+ core_topics=core_topics,
boundaries="不输出完整课程资料",
content_hash=f"manifest-{knowledge_id}",
confirmed=1,
@@ -81,8 +103,8 @@ def _add_published_knowledge(db: Session, *, knowledge_id: int, name: str, open_
knowledge_id=knowledge.id,
version_id=version.id,
section_key="S0001",
- title="家长沟通的第一步",
- content="家长和学习动力不足的孩子沟通时,第一步是先稳定自己的焦虑,再倾听孩子遇到的具体困难。",
+ title=section_title,
+ content=section_content,
source_start=0,
source_end=44,
sort_order=1,
@@ -97,7 +119,7 @@ def _add_published_knowledge(db: Session, *, knowledge_id: int, name: str, open_
section_id=section.id,
title=section.title,
content=section.content,
- normalized_text="家长 沟通 学习 动力 孩子 焦虑 倾听",
+ normalized_text=section_content,
keywords='["家长","沟通","学习动力"]',
synonyms='["父母","交流"]',
source_start=0,
@@ -135,6 +157,48 @@ def test_course_question_without_catalog_does_not_fall_back_to_general_knowledge
assert result.chunks == []
+def test_fixed_information_question_without_catalog_does_not_fall_back_to_general_knowledge():
+ with _database() as db:
+ result = asyncio.run(KnowledgeAgentService.build_result(db, question="这周的会议链接在哪里?"))
+ assert result.allow_general_knowledge is False
+ assert result.chunks == []
+
+
+def test_fixed_information_is_retrieved_first_and_overrides_other_types():
+ with _database() as db:
+ _add_published_knowledge(
+ db,
+ knowledge_id=1,
+ name="课程历史安排",
+ section_title="练习一:课程会议安排",
+ section_content="课程作业沿用旧安排,会议链接为旧链接,周二晚上上课。",
+ purpose="课程上课时间和会议安排",
+ applicable_questions="上课时间、会议链接",
+ core_topics="课程安排",
+ )
+ _add_published_knowledge(
+ db,
+ knowledge_id=2,
+ name="当前固定信息",
+ knowledge_type="fixed",
+ section_title="本周上课安排",
+ section_content="本周上课时间为周三晚上,会议链接为最新链接。",
+ purpose="保存当前上课时间、会议链接和服务安排",
+ applicable_questions="上课时间、会议链接、带练安排",
+ core_topics="当前安排",
+ )
+
+ result = asyncio.run(KnowledgeAgentService.build_result(db, question="课程作业和会议链接都有什么?"))
+
+ assert result.allow_general_knowledge is False
+ assert result.chunks[0].knowledge_type == "fixed"
+ assert result.chunks[0].knowledge_name == "当前固定信息"
+ assert "固定信息类·最高优先级" in result.prompt
+ assert "如与课程、答疑或通用知识库冲突,忽略其他库的冲突内容" in result.prompt
+ decision = next(item for item in result.tool_trace if item["tool"] == "agent_decision")
+ assert decision["selectedKnowledgeIds"][0] == 2
+
+
def test_course_question_searches_chunk_and_reads_parent_section():
with _database() as db:
_add_published_knowledge(db, knowledge_id=1, name="亲子课程")