feat: apply knowledge type retrieval priority

This commit is contained in:
2026-07-17 15:12:34 +08:00
parent 9173267e09
commit d647a3d44b
7 changed files with 202 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@@ -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": "大本营答疑助手"}

View File

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