feat: apply knowledge type retrieval priority
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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": "大本营答疑助手"}
|
||||
|
||||
@@ -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="亲子课程")
|
||||
|
||||
Reference in New Issue
Block a user