fix(rag): recall complete course homework lists

This commit is contained in:
2026-07-16 15:48:40 +08:00
parent cbbdb41b80
commit 245b6b3eca
2 changed files with 72 additions and 8 deletions

View File

@@ -32,7 +32,8 @@ from app.services.rag_service import PromptService, RagResult, RetrievedChunk
SAFETY_RULE_VERSION = "minimum-safety-v1" SAFETY_RULE_VERSION = "minimum-safety-v1"
MAX_LEXICAL_CANDIDATES = 12 MAX_LEXICAL_CANDIDATES = 12
MAX_SELECTED_SECTIONS = 4 MAX_SELECTED_SECTIONS = 4
MAX_OVERVIEW_SELECTED_SECTIONS = 8 MAX_OVERVIEW_LEXICAL_CANDIDATES = 48
MAX_OVERVIEW_SELECTED_SECTIONS = 24
BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"} BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"}
@@ -105,10 +106,17 @@ class KnowledgeAgentService:
try: try:
if need_knowledge and selected_ids: if need_knowledge and selected_ids:
terms = cls._query_terms(retrieval_question) terms = cls._query_terms(retrieval_question)
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
trace.append(cls._trace("search_knowledge", len(trace) + 1, {"queryTerms": terms, "knowledgeIds": selected_ids}, {"candidateCount": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
await cls._rerank(db, retrieval_question, candidates, trace, started)
practice_overview = cls._is_practice_overview(retrieval_question) practice_overview = cls._is_practice_overview(retrieval_question)
candidate_limit = cls._candidate_limit(retrieval_question)
candidates = cls.search_knowledge(
db,
terms,
selected_ids,
catalog,
candidate_limit=candidate_limit,
)
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)
selected = cls._select_sections( selected = cls._select_sections(
candidates, candidates,
limit=cls._selection_limit(retrieval_question), limit=cls._selection_limit(retrieval_question),
@@ -323,7 +331,15 @@ class KnowledgeAgentService:
return {"knowledgeId": item.knowledge.id, "knowledgeName": item.knowledge.name, "versionId": item.version.id, "chunkId": item.chunk.id, "sectionId": item.section.id, "title": item.chunk.title, "lexicalScore": item.lexical_score, "rerankScore": item.rerank_score, "selected": item.selected, "discardReason": item.discard_reason} return {"knowledgeId": item.knowledge.id, "knowledgeName": item.knowledge.name, "versionId": item.version.id, "chunkId": item.chunk.id, "sectionId": item.section.id, "title": item.chunk.title, "lexicalScore": item.lexical_score, "rerankScore": item.rerank_score, "selected": item.selected, "discardReason": item.discard_reason}
@classmethod @classmethod
def search_knowledge(cls, db: Session, terms: list[str], selected_ids: list[int], catalog: list[dict]) -> list[Candidate]: def search_knowledge(
cls,
db: Session,
terms: list[str],
selected_ids: list[int],
catalog: list[dict],
*,
candidate_limit: int = MAX_LEXICAL_CANDIDATES,
) -> list[Candidate]:
versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog} 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] version_ids = [versions_by_kb[item] for item in selected_ids if item in versions_by_kb]
if not version_ids: if not version_ids:
@@ -349,7 +365,7 @@ class KnowledgeAgentService:
if score > 0: if score > 0:
candidates.append(Candidate(chunk, section, knowledge, version, score)) 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: (item.lexical_score, item.version.published_at or item.version.created_at), reverse=True)
return candidates[:MAX_LEXICAL_CANDIDATES] return candidates[:candidate_limit]
@classmethod @classmethod
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict], started: float) -> None: async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict], started: float) -> None:
@@ -385,6 +401,15 @@ class KnowledgeAgentService:
prefer_numbered_practice: bool = False, prefer_numbered_practice: bool = False,
) -> list[Candidate]: ) -> list[Candidate]:
if prefer_numbered_practice: if prefer_numbered_practice:
numbered_candidates = [
item for item in candidates
if KnowledgeAgentService._is_numbered_practice_title(item.chunk.title)
]
if numbered_candidates:
for item in candidates:
if not KnowledgeAgentService._is_numbered_practice_title(item.chunk.title):
item.discard_reason = "作业清单优先采用编号练习章节"
candidates = numbered_candidates
candidates = sorted( candidates = sorted(
candidates, candidates,
key=lambda item: ( key=lambda item: (
@@ -415,6 +440,12 @@ class KnowledgeAgentService:
return MAX_OVERVIEW_SELECTED_SECTIONS return MAX_OVERVIEW_SELECTED_SECTIONS
return MAX_SELECTED_SECTIONS return MAX_SELECTED_SECTIONS
@staticmethod
def _candidate_limit(question: str) -> int:
if KnowledgeAgentService._is_practice_overview(question):
return MAX_OVERVIEW_LEXICAL_CANDIDATES
return MAX_LEXICAL_CANDIDATES
@staticmethod @staticmethod
def _is_practice_overview(question: str) -> bool: def _is_practice_overview(question: str) -> bool:
overview_markers = ("有哪些", "是什么", "包括什么", "都有什么", "列出", "汇总", "总结") overview_markers = ("有哪些", "是什么", "包括什么", "都有什么", "列出", "汇总", "总结")

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from datetime import datetime from datetime import datetime
from types import SimpleNamespace
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,7 +20,7 @@ from app.models.knowledge import (
KnowledgeVersion, KnowledgeVersion,
) )
from app.models.chat import ChatMessage from app.models.chat import ChatMessage
from app.services.knowledge_agent_service import KnowledgeAgentService from app.services.knowledge_agent_service import Candidate, KnowledgeAgentService
def _database() -> Session: def _database() -> Session:
@@ -187,13 +188,45 @@ def test_homework_overview_expands_practice_terms_and_section_limit():
assert "功课" in terms assert "功课" in terms
assert "练习" in terms assert "练习" in terms
assert KnowledgeAgentService._selection_limit("合一的作业是什么?") == 8 assert KnowledgeAgentService._candidate_limit("合一的作业是什么?") == 48
assert KnowledgeAgentService._selection_limit("合一的作业是什么?") == 24
assert KnowledgeAgentService._candidate_limit("这个练习怎么做?") == 12
assert KnowledgeAgentService._selection_limit("这个练习怎么做?") == 4 assert KnowledgeAgentService._selection_limit("这个练习怎么做?") == 4
assert KnowledgeAgentService._title_intent_boost("十七、练习一:风铃式静心", terms) == 20.0 assert KnowledgeAgentService._title_intent_boost("十七、练习一:风铃式静心", terms) == 20.0
assert KnowledgeAgentService._title_intent_boost("完整练习的方向", terms) == 3.0 assert KnowledgeAgentService._title_intent_boost("完整练习的方向", terms) == 3.0
assert KnowledgeAgentService._title_intent_boost("课程定位", terms) == 0.0 assert KnowledgeAgentService._title_intent_boost("课程定位", terms) == 0.0
def test_homework_overview_keeps_all_numbered_practices_before_selection():
candidates = [
Candidate(
chunk=SimpleNamespace(title=f"练习{index}:课程作业", id=index),
section=SimpleNamespace(id=index),
knowledge=SimpleNamespace(id=1),
version=SimpleNamespace(id=1),
lexical_score=float(30 - index),
)
for index in range(1, 15)
]
unrelated = Candidate(
chunk=SimpleNamespace(title="课程介绍", id=99),
section=SimpleNamespace(id=99),
knowledge=SimpleNamespace(id=1),
version=SimpleNamespace(id=1),
lexical_score=100.0,
)
selected = KnowledgeAgentService._select_sections(
[unrelated, *candidates],
limit=KnowledgeAgentService._selection_limit("原生里的作业内容都有什么"),
prefer_numbered_practice=True,
)
assert len(selected) == 14
assert all(item.chunk.title.startswith("练习") for item in selected)
def test_complete_section_includes_child_headings_but_stops_at_next_peer(): def test_complete_section_includes_child_headings_but_stops_at_next_peer():
with _database() as db: with _database() as db:
knowledge = _add_published_knowledge(db, knowledge_id=1, name="合一课程") knowledge = _add_published_knowledge(db, knowledge_id=1, name="合一课程")