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