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="亲子课程")