From 93ff9c8c8d5268151fffa7947941752c6d01a9db Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Sat, 17 Oct 2026 20:15:54 +0800 Subject: [PATCH] fix: include matching body sections despite nonempty semantic recall --- backend/tests/test_optimization.py | 33 +++++++++++++++++++++++++++++- backend/wisegrade/rag.py | 9 ++++++-- backend/wisegrade/scoring.py | 8 +++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index 95a58d8..b20d8b9 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -26,7 +26,7 @@ from wisegrade.pages import classify_document from wisegrade.parsing import text_quality from wisegrade.rag import INDEX_VERSION, build_chunks, ingest, keyword_overlap, query_terms, retrieve, retrieve_targets from wisegrade.rules import Criterion, EvidenceTarget -from wisegrade.scoring import _attach_page_types, _score_criterion +from wisegrade.scoring import _attach_page_types, _body_navigator, _score_criterion from wisegrade.seed import seed_initial from wisegrade.storage import extract_identity, sha256 from wisegrade.submissions import file_index_lock @@ -149,6 +149,37 @@ def test_shared_chunk_keeps_all_target_ids(): assert selection["evidence"][0]["target_ids"] == ["t0", "t1"] +def test_plan_navigation_finds_future_work_body_despite_nonempty_semantic_hits(sqlite_db): + db = sqlite_db + item = project(db) + doc = document(db, item, pages=[ + {"page_number": 3, "text": "目录\n4.2 后续改进方向.......... 11\n4.3 参考文献.......... 12"}, + {"page_number": 6, "text": "2.3 系统控制\n控制器通过闭环反馈调整电机转速,实现稳定的运动控制。"}, + {"page_number": 14, "text": "4.2 后续改进方向\n后续将探索深度学习检测算法,提高复杂光照场景下的鲁棒性;" + "引入模型预测控制优化动态跟踪,并完善状态监控和数据回放功能。"}, + ]) + noise = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == doc.id, + KnowledgeChunk.page == 6)) + navigator = _body_navigator(db, course_version_id=1, project_id=item.id, category="stage3") + targets = normalize_targets(["计划", "章节"]) + selected = retrieve_targets(db, course_version_id=1, project_id=item.id, category="stage3", + targets=targets, navigator=navigator, limit=2, + retrieve_fn=lambda *args, **kwargs: [{ + "chunk_id": noise.id, "document_id": doc.id, "page": 6, + "page_type": "body", "text": noise.text, "rank": .03}]) + assert any(hit["page"] == 14 and "t0" in hit["target_ids"] for hit in selected["evidence"]) + assert all(hit["page_type"] == "body" for hit in selected["evidence"]) + + +def test_heading_navigation_supplements_existing_hits_for_custom_labels(): + target = normalize_targets(["实验数据"]) + raw = {"chunk_id": 1, "document_id": 1, "page": 2, "text": "仅描述装置。", "page_type": "body", "rank": .03} + body = {"chunk_id": 2, "document_id": 1, "page": 5, "text": "实测响应时间80毫秒。", "page_type": "body", "rank": .5} + result = retrieve_targets(None, course_version_id=1, project_id=1, targets=target, limit=1, + retrieve_fn=lambda *args, **kwargs: [raw], navigator=lambda _: [body]) + assert result["evidence"][0]["chunk_id"] == 2 + + def test_document_filter_and_index_readiness_precede_limit(sqlite_db): db = sqlite_db item = project(db) diff --git a/backend/wisegrade/rag.py b/backend/wisegrade/rag.py index 7de5191..88e5397 100644 --- a/backend/wisegrade/rag.py +++ b/backend/wisegrade/rag.py @@ -391,11 +391,16 @@ def retrieve_targets(db, *, course_version_id: int, project_id: int, targets: li substantive_only=target.get("use") != "structure", semantic_pool=SEMANTIC_POOL, keyword_pool=KEYWORD_POOL) hits = [normalize_hit(hit) for hit in (raw_hits or [])] - if target.get("use") == "content" and not hits and navigator is not None: + if target.get("use") == "content" and navigator is not None: # The contents list may only be used to navigate: its headings are matched # against headings that really occur in body text, and those sections and # their neighbouring pages are searched. The quotation still lands on body. - hits = [dict(hit, via="toc_navigation") for hit in (navigator(target) or [])] + # Semantic recall always returns neighbours, even for a short vague + # query. Supplement it with matching body sections rather than waiting + # for zero hits, and keep those sections ahead of incidental matches. + navigated = [normalize_hit(hit) for hit in (navigator(target) or [])] + seen_ids = {hit["chunk_id"] for hit in navigated} + hits = navigated + [hit for hit in hits if hit["chunk_id"] not in seen_ids] per_target[target["target_id"]] = hits for hit in hits: if hit["chunk_id"] not in seen: diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py index c675f0a..a7af4a6 100644 --- a/backend/wisegrade/scoring.py +++ b/backend/wisegrade/scoring.py @@ -100,11 +100,17 @@ def _body_navigator(db, *, course_version_id, project_id, category): label = normalize_heading(target.get("label") or "") if not label: return [] + # A rubric often says simply "计划", while the actual section is future + # work. Expand only this concept; headings still have to exist in body text. + labels = [label] + plan_terms = ("计划", "后续改进", "后续工作", "未来工作", "下一阶段", "下一步") + if any(term in label for term in plan_terms): + labels.extend(plan_terms) candidates = [] for heading, places in headings.items(): if not heading: continue - if heading.startswith(label) or label.startswith(heading) or label in heading or heading in label: + if any(term in heading or heading in term for term in labels): candidates.extend(places) if not candidates: return []