fix(rag): retrieve complete course assignments

This commit is contained in:
2026-07-15 22:09:52 +08:00
parent ba1840233c
commit 56c9f2f359
7 changed files with 245 additions and 13 deletions

View File

@@ -365,8 +365,19 @@ async def _feishu_download_async(path: str, config: FeishuRetrievalConfig) -> by
async def _get_docx_content_async(doc_token: str, config: FeishuRetrievalConfig) -> str: async def _get_docx_content_async(doc_token: str, config: FeishuRetrievalConfig) -> str:
data = await _feishu_get_async(f"/open-apis/docx/v1/documents/{doc_token}/blocks", config=config) path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
items = data.get("data", {}).get("items", []) items: list[dict] = []
page_token = ""
while True:
params: dict[str, Any] = {"page_size": 500}
if page_token:
params["page_token"] = page_token
data = await _feishu_get_async(path, params=params, config=config)
page = data.get("data", {})
items.extend(page.get("items", []))
page_token = str(page.get("page_token") or "")
if not page.get("has_more") or not page_token:
break
return _parse_docx_content(items) return _parse_docx_content(items)

View File

@@ -234,8 +234,19 @@ def _parse_docx_content(blocks: list[dict]) -> str:
def _get_docx_content(doc_token: str, config: FeishuRetrievalConfig) -> str: def _get_docx_content(doc_token: str, config: FeishuRetrievalConfig) -> str:
"""读取单个飞书云文档内容""" """读取单个飞书云文档内容"""
data = _feishu_get(f"/open-apis/docx/v1/documents/{doc_token}/blocks", config=config) path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
items = data.get("data", {}).get("items", []) items: list[dict] = []
page_token = ""
while True:
params: dict[str, Any] = {"page_size": 500}
if page_token:
params["page_token"] = page_token
data = _feishu_get(path, params=params, config=config)
page = data.get("data", {})
items.extend(page.get("items", []))
page_token = str(page.get("page_token") or "")
if not page.get("has_more") or not page_token:
break
return _parse_docx_content(items) return _parse_docx_content(items)

View File

@@ -32,6 +32,7 @@ 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
BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"} BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"}
@@ -107,13 +108,22 @@ class KnowledgeAgentService:
candidates = cls.search_knowledge(db, terms, selected_ids, catalog) 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)) 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) await cls._rerank(db, retrieval_question, candidates, trace, started)
selected = cls._select_sections(candidates) practice_overview = cls._is_practice_overview(retrieval_question)
selected = cls._select_sections(
candidates,
limit=cls._selection_limit(retrieval_question),
prefer_numbered_practice=practice_overview,
)
selected_contents = {
item.section.id: cls._protect_content(cls._read_complete_section(db, item.section))
for item in selected
}
chunks = [ chunks = [
RetrievedChunk( RetrievedChunk(
knowledge_id=item.knowledge.id, knowledge_id=item.knowledge.id,
knowledge_name=item.knowledge.name, knowledge_name=item.knowledge.name,
title=item.section.title, title=item.section.title,
content=cls._protect_content(item.section.content), content=selected_contents[item.section.id],
version_id=item.version.id, version_id=item.version.id,
section_id=item.section.id, section_id=item.section.id,
chunk_id=item.chunk.id, chunk_id=item.chunk.id,
@@ -121,7 +131,7 @@ class KnowledgeAgentService:
) )
for item in selected for item in selected
] ]
trace.append(cls._trace("read_knowledge_sections", len(trace) + 1, {"sectionIds": [item.section.id for item in selected]}, {"count": len(selected), "sections": [{"knowledgeId": x.knowledge.id, "knowledgeName": x.knowledge.name, "sectionId": x.section.id, "title": x.section.title, "content": cls._protect_content(x.section.content), "adopted": True} for x in selected]}, started)) trace.append(cls._trace("read_knowledge_sections", len(trace) + 1, {"sectionIds": [item.section.id for item in selected]}, {"count": len(selected), "sections": [{"knowledgeId": x.knowledge.id, "knowledgeName": x.knowledge.name, "sectionId": x.section.id, "title": x.section.title, "content": selected_contents[x.section.id], "adopted": True} for x in selected]}, started))
cls._persist_candidates(db, log.id, candidates) cls._persist_candidates(db, log.id, candidates)
log.knowledge_called = 1 log.knowledge_called = 1
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids) log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
@@ -328,13 +338,14 @@ class KnowledgeAgentService:
section, knowledge, version = sections.get(chunk.section_id), knowledge_rows.get(chunk.knowledge_id), versions.get(chunk.version_id) section, knowledge, version = sections.get(chunk.section_id), knowledge_rows.get(chunk.knowledge_id), versions.get(chunk.version_id)
if not section or not knowledge or not version: if not section or not knowledge or not version:
continue continue
haystack = f"{chunk.title} {chunk.normalized_text} {chunk.content}".lower() haystack = f"{knowledge.name} {chunk.title} {chunk.normalized_text} {chunk.content}".lower()
score = 0.0 score = 0.0
for term in terms: for term in terms:
count = haystack.count(term) count = haystack.count(term)
if count: if count:
idf = math.log((len(chunks) + 1) / (frequencies.get(term, 0) + 1)) + 1 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 += (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: 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)
@@ -367,7 +378,21 @@ class KnowledgeAgentService:
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback"}, started, status="fallback", error=str(exc)[:300])) trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback"}, started, status="fallback", error=str(exc)[:300]))
@staticmethod @staticmethod
def _select_sections(candidates: list[Candidate]) -> list[Candidate]: def _select_sections(
candidates: list[Candidate],
*,
limit: int = MAX_SELECTED_SECTIONS,
prefer_numbered_practice: bool = False,
) -> list[Candidate]:
if prefer_numbered_practice:
candidates = sorted(
candidates,
key=lambda item: (
KnowledgeAgentService._is_numbered_practice_title(item.chunk.title),
item.rerank_score if item.rerank_score is not None else item.lexical_score,
),
reverse=True,
)
selected: list[Candidate] = [] selected: list[Candidate] = []
seen: set[int] = set() seen: set[int] = set()
for item in candidates: for item in candidates:
@@ -376,7 +401,7 @@ class KnowledgeAgentService:
item.discard_reason = "相关性不足" item.discard_reason = "相关性不足"
elif item.section.id in seen: elif item.section.id in seen:
item.discard_reason = "同一父章节已有更高分候选" item.discard_reason = "同一父章节已有更高分候选"
elif len(selected) >= MAX_SELECTED_SECTIONS: elif len(selected) >= limit:
item.discard_reason = "超过本轮章节数量限制" item.discard_reason = "超过本轮章节数量限制"
else: else:
item.selected = True item.selected = True
@@ -384,6 +409,54 @@ class KnowledgeAgentService:
seen.add(item.section.id) seen.add(item.section.id)
return selected return selected
@staticmethod
def _selection_limit(question: str) -> int:
if KnowledgeAgentService._is_practice_overview(question):
return MAX_OVERVIEW_SELECTED_SECTIONS
return MAX_SELECTED_SECTIONS
@staticmethod
def _is_practice_overview(question: str) -> bool:
overview_markers = ("有哪些", "是什么", "包括什么", "都有什么", "列出", "汇总", "总结")
practice_markers = ("作业", "功课", "练习")
return any(marker in question for marker in practice_markers) and any(marker in question for marker in overview_markers)
@staticmethod
def _is_numbered_practice_title(title: str) -> bool:
return bool(re.search(r"(?:练习|作业|功课)\s*[一二三四五六七八九十百\d]+", title))
@staticmethod
def _title_intent_boost(title: str, terms: list[str]) -> float:
if not {"作业", "功课", "练习"}.intersection(terms):
return 0.0
if KnowledgeAgentService._is_numbered_practice_title(title):
return 20.0
if any(marker in title for marker in ("练习", "作业", "功课")):
return 3.0
return 0.0
@staticmethod
def _read_complete_section(db: Session, section: KnowledgeSection) -> str:
"""Read a heading together with its lower-level child sections."""
level = _heading_level(section.content)
if level is None:
return section.content
rows = db.scalars(
select(KnowledgeSection)
.where(
KnowledgeSection.version_id == section.version_id,
KnowledgeSection.sort_order >= section.sort_order,
)
.order_by(KnowledgeSection.sort_order)
).all()
parts: list[str] = []
for row in rows:
row_level = _heading_level(row.content)
if row.id != section.id and row_level is not None and row_level <= level:
break
parts.append(row.content)
return "\n\n".join(parts)
@staticmethod @staticmethod
def _persist_candidates(db: Session, log_id: int, candidates: list[Candidate]) -> None: def _persist_candidates(db: Session, log_id: int, candidates: list[Candidate]) -> None:
for item in candidates: for item in candidates:
@@ -443,6 +516,11 @@ def _json_trace_value(value):
return str(value) return str(value)
def _heading_level(content: str) -> int | None:
match = re.match(r"^(#{1,6})\s+", content.lstrip())
return len(match.group(1)) if match else None
def _extract_json(value: str) -> str: def _extract_json(value: str) -> str:
match = re.search(r"\{[\s\S]*\}", value) match = re.search(r"\{[\s\S]*\}", value)
if not match: if not match:

View File

@@ -455,6 +455,9 @@ _SYNONYMS = {
"焦虑": ["担心", "紧张", "害怕"], "焦虑": ["担心", "紧张", "害怕"],
"沟通": ["交流", "对话", "聊天"], "沟通": ["交流", "对话", "聊天"],
"课程": ["训练营", "大本营", "课堂"], "课程": ["训练营", "大本营", "课堂"],
"作业": ["功课", "练习"],
"功课": ["作业", "练习"],
"练习": ["作业", "功课"],
} }

View File

@@ -121,7 +121,7 @@ class PromptService:
# 知识库上下文 # 知识库上下文
context = "\n\n".join( context = "\n\n".join(
f"[已回读完整章节 {index}] {chunk.title}\n{chunk.content}" f"[已回读完整章节 {index}] {chunk.title}\n来源知识库:{chunk.knowledge_name}\n{chunk.content}"
for index, chunk in enumerate(chunks, start=1) for index, chunk in enumerate(chunks, start=1)
) )
if not context: if not context:
@@ -152,7 +152,21 @@ class PromptService:
if content and message.role in {"user", "assistant"}: if content and message.role in {"user", "assistant"}:
messages.append({"role": message.role, "content": content}) messages.append({"role": message.role, "content": content})
messages.append({"role": "system", "content": f"[本轮可靠知识上下文]\n{context}"}) messages.append({
"role": "system",
"content": (
"[本轮可靠知识上下文]\n"
"以下章节均来自当前正式开放、已发布的知识库,可以作为本轮回答的可靠依据。"
"如果其中已经明确包含用户询问的课程、作业、功课或练习,不得再以‘没有资料’、"
"‘无法确认’或要求用户补充课程名称来回避回答;应直接根据章节标题和正文归纳。"
"用户问‘某课程的作业是什么’时,含义是询问该课程包含哪些作业或练习,"
"不要求知识库中必须存在一个与‘某课程作业’完全同名的章节。"
"回答作业清单类问题时,应直接给出作业名称,并概括每项作业的用途或核心方向;"
"正文已经提供基本步骤时可以简要说明,不要把本轮已经取到的内容留到下一轮再问。"
"章节标题可以作为作业或练习名称,标题下的子章节是对应的正式说明。\n\n"
f"{context}"
),
})
messages.append({"role": "user", "content": question.strip()}) messages.append({"role": "user", "content": question.strip()})
return messages return messages

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
from app.services.feishu_async_service import _get_docx_content_async
from app.services.feishu_service import FeishuRetrievalConfig, _get_docx_content
def _config() -> FeishuRetrievalConfig:
return FeishuRetrievalConfig(
search_url="",
app_id="app-id",
app_secret="secret",
timeout_seconds=10,
retry_count=1,
)
def _heading(text: str) -> dict:
return {
"block_type": 3,
"heading1": {"elements": [{"text_run": {"content": text}}]},
}
def test_docx_content_reads_all_block_pages():
pages = [
{"data": {"items": [_heading("第一页")], "has_more": True, "page_token": "next-page"}},
{"data": {"items": [_heading("第二页作业")], "has_more": False, "page_token": ""}},
]
with patch("app.services.feishu_service._feishu_get", side_effect=pages) as feishu_get:
content = _get_docx_content("doc-token", _config())
assert "第一页" in content
assert "第二页作业" in content
assert feishu_get.call_count == 2
assert feishu_get.call_args_list[0].kwargs["params"] == {"page_size": 500}
assert feishu_get.call_args_list[1].kwargs["params"] == {"page_size": 500, "page_token": "next-page"}
def test_async_docx_content_reads_all_block_pages():
pages = [
{"data": {"items": [_heading("第一页")], "has_more": True, "page_token": "next-page"}},
{"data": {"items": [_heading("第二页作业")], "has_more": False, "page_token": ""}},
]
feishu_get = AsyncMock(side_effect=pages)
with patch("app.services.feishu_async_service._feishu_get_async", feishu_get):
content = asyncio.run(_get_docx_content_async("doc-token", _config()))
assert "第一页" in content
assert "第二页作业" in content
assert feishu_get.await_count == 2
assert feishu_get.await_args_list[1].kwargs["params"] == {"page_size": 500, "page_token": "next-page"}

View File

@@ -4,7 +4,7 @@ import asyncio
import json import json
from datetime import datetime from datetime import datetime
from sqlalchemy import create_engine from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -141,6 +141,13 @@ def test_course_question_searches_chunk_and_reads_parent_section():
assert len(result.chunks) == 1 assert len(result.chunks) == 1
assert "先稳定自己的焦虑" in result.chunks[0].content assert "先稳定自己的焦虑" in result.chunks[0].content
assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace) assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace)
knowledge_context = next(
message["content"] for message in result.messages if message["content"].startswith("[本轮可靠知识上下文]")
)
assert "不得再以‘没有资料’" in knowledge_context
assert "不要求知识库中必须存在一个" in knowledge_context
assert "不要把本轮已经取到的内容留到下一轮再问" in knowledge_context
assert "来源知识库:亲子课程" in knowledge_context
def test_tool_trace_serializes_published_datetime(): def test_tool_trace_serializes_published_datetime():
@@ -173,3 +180,57 @@ def test_contextual_follow_up_is_rewritten_before_agent_decision():
assert rewrite["rewrittenQuestion"] == "关于“课程退款条件有哪些?”的追问:那第二种情况呢?" assert rewrite["rewrittenQuestion"] == "关于“课程退款条件有哪些?”的追问:那第二种情况呢?"
assert decision["request"]["question"] == rewrite["rewrittenQuestion"] assert decision["request"]["question"] == rewrite["rewrittenQuestion"]
def test_homework_overview_expands_practice_terms_and_section_limit():
terms = KnowledgeAgentService._query_terms("合一的作业是什么?")
assert "功课" in terms
assert "练习" in terms
assert KnowledgeAgentService._selection_limit("合一的作业是什么?") == 8
assert KnowledgeAgentService._selection_limit("这个练习怎么做?") == 4
assert KnowledgeAgentService._title_intent_boost("十七、练习一:风铃式静心", terms) == 20.0
assert KnowledgeAgentService._title_intent_boost("完整练习的方向", terms) == 3.0
assert KnowledgeAgentService._title_intent_boost("课程定位", terms) == 0.0
def test_complete_section_includes_child_headings_but_stops_at_next_peer():
with _database() as db:
knowledge = _add_published_knowledge(db, knowledge_id=1, name="合一课程")
version_id = knowledge.current_version_id
parent = db.scalar(select(KnowledgeSection).where(KnowledgeSection.version_id == version_id))
assert parent is not None
parent.title = "练习一:风铃式静心"
parent.content = "# 练习一:风铃式静心"
parent.sort_order = 1
db.add_all([
parent,
KnowledgeSection(
knowledge_id=knowledge.id,
version_id=version_id,
section_key="S0002",
title="基本操作",
content="## 基本操作\n鼻吸鼻呼,呼气时发出连续蜂鸣声。",
source_start=10,
source_end=40,
sort_order=2,
content_hash="child",
),
KnowledgeSection(
knowledge_id=knowledge.id,
version_id=version_id,
section_key="S0003",
title="练习二:内感知建模",
content="# 练习二:内感知建模\n这是下一项作业。",
source_start=41,
source_end=70,
sort_order=3,
content_hash="next",
),
])
db.commit()
content = KnowledgeAgentService._read_complete_section(db, parent)
assert "鼻吸鼻呼" in content
assert "下一项作业" not in content