From 039e93d0de0e4816db6efe514c390ca09f94b923 Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sun, 27 Sep 2026 17:15:55 +0800 Subject: [PATCH] fix: link score citations to their source text pages --- HANDOFF.md | 4 ++ backend/tests/test_chat_agent.py | 3 +- backend/tests/test_chat_sessions.py | 65 ++++++++++++++++++++++++++++- backend/wisegrade/agent_tools.py | 4 +- backend/wisegrade/chat_api.py | 56 ++++++++++++++++++++++--- backend/wisegrade/exports.py | 1 + docs/CHAT_AGENT.md | 2 +- frontend/src/Chat.tsx | 11 +++-- frontend/tests/chat.spec.ts | 15 +++++-- 9 files changed, 143 insertions(+), 18 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 5f697a6..477c383 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -108,3 +108,7 @@ uv run pytest -q ## 2026-09-27 引用链接标签 检索引用的 `url` 指向页面文字 JSON 接口,`image_url` 指向页面图像;评分引用现在分别保存评分页 `url` 和证据 `image_url`。界面按目标显示“查看页面文字”“查看评分”“查看图像”;旧评分引用中把图像放在 `url` 的记录也能识别,两个字段指向同一图像时只显示一个链接。无需迁移历史数据。后端评分引用针对性测试 1 通过,前端构建通过,Playwright 3 通过。API 与 Web 容器已更新并健康;正式页面检查 7 号会话 74 个图像链接,均显示“查看图像”。 + +## 2026-09-27 评分引用文字页补全 + +评分证据 `ScoreEvidence` 原已保存 `document_id` 和 `page`,前一版遗漏了这条来源链。现在 `project_result` 将文档 ID 交给 `get_scores`,新回答引用保存 `text_url`;评分页链接和图像链接仍各自保留。历史会话详情按文件 ID、页码、原文摘录与评分证据匹配,并核对当前知识片段包含该摘录;唯一匹配时只在响应中补充文字页链接,不改写数据库,无法确认来源时保持原状。隔离测试容器 `pytest -q -m 'not integration'` 为 38 通过、2 个集成测试未运行;前端构建和 Playwright 3 项测试通过。API 与 Web 容器已更新并健康。正式会话 7 的 74 条引用在浏览器中均显示文字和图像链接;其中 41 条旧评分引用经后端恢复,26 个不同的文字页链接均可打开,且每条引用的摘录都存在于目标页。 diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index 79a4210..a00f388 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -83,7 +83,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch): "rows": [{"criterion": {"criterion_id": f"C{index}", "name": f"评分项{index}", "max_score": 10}, "score_id": index, "model_score": 8, "teacher_score": None, "final_score": 8, "status": "scored", "error": "", "evidence": [{"page": index + 1, - "quote": "证据" * 200, "document": "报告.pdf", + "quote": "证据" * 200, "document": "报告.pdf", "document_id": index + 100, "image_url": f"/api/assets/{item.id}/1" if index == 0 else None}]} for index in range(14)]} @@ -95,6 +95,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch): assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1 assert first_page["citations"][0]["url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}" assert first_page["citations"][0]["image_url"] == f"/api/assets/{first.id}/1" + assert first_page["citations"][0]["text_url"] == "/api/knowledge/documents/100/pages/1" assert first_page["citations"][1]["url"] == first_page["citations"][0]["url"] assert first_page["citations"][1]["image_url"] is None assert len(json.dumps(first_page, ensure_ascii=False)) < 12000 diff --git a/backend/tests/test_chat_sessions.py b/backend/tests/test_chat_sessions.py index 7482e3d..fde337f 100644 --- a/backend/tests/test_chat_sessions.py +++ b/backend/tests/test_chat_sessions.py @@ -2,15 +2,18 @@ import time from concurrent.futures import ThreadPoolExecutor from threading import Event +from types import SimpleNamespace from uuid import uuid4 from fastapi.testclient import TestClient from sqlalchemy import func, select -from wisegrade import chat_api +from wisegrade import agent_tools, chat_api, services from wisegrade.db import get_db from wisegrade.main import app -from wisegrade.models import AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, PendingAction, Project, ScoringBatch, Student, now +from wisegrade.models import (AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, CriterionScore, + KnowledgeChunk, KnowledgeDocument, PendingAction, Project, ScoreEvidence, + ScoringBatch, Student, SubmissionFile, now) def client_for(session_factory): @@ -176,6 +179,64 @@ def test_tool_record_keeps_bounded_query_but_redacts_review_text_and_secrets(): assert 'secret' not in str(search) +def test_score_citations_link_text_and_restore_legacy_image_only(session_factory): + with session_factory() as db: + student = Student(student_no="987654320", name="测试学生") + db.add(student) + db.flush() + project = Project(course_version_id=1, student_id=student.id, topic="测试项目", source_dir="test/citation") + db.add(project) + db.flush() + file = SubmissionFile(project_id=project.id, category="final_report", relative_path="test/citation/report.pdf", + mime_type="application/pdf", size=1, mtime=1, sha256="a" * 64, + recognition_status="recognized", parse_status="ready") + db.add(file) + db.flush() + document = KnowledgeDocument(course_version_id=1, project_id=project.id, submission_file_id=file.id, + title="报告.pdf", document_type="final_report", source_hash="a" * 64, + index_version=2, status="ready") + db.add(document) + db.flush() + db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=project.id, + document_type="final_report", page=2, section="测试", text="这里有原文证据和分析")) + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + batch = db.get(ScoringBatch, created["id"]) + score = CriterionScore(batch_id=batch.id, project_id=project.id, + criterion_id=batch.rubric_snapshot["criteria"][0]["criterion_id"], + model_score=8, status="scored") + db.add(score) + db.flush() + db.add(ScoreEvidence(score_id=score.id, document_id=document.id, submission_file_id=file.id, + page=2, quote="原文证据", image_path="page-2.png")) + conversation = Conversation(title="旧评分引用", course_version_id=1, batch_id=batch.id, project_id=project.id) + db.add(conversation) + db.flush() + image_url = f"/api/assets/{file.id}/2" + db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[ + {"title": "报告.pdf", "page": 2, "quote": "原文证据", "url": image_url}, + {"title": "报告.pdf", "page": 2, "quote": "不匹配的引文", "url": image_url}])) + db.commit() + expected = f"/api/knowledge/documents/{document.id}/pages/2" + generated = agent_tools.execute(db, conversation, "get_scores", + {"batch_id": batch.id, "project_id": project.id}, "") + assert generated["citations"][0]["text_url"] == expected + assert chat_api._answer_citations(generated["citations"])[0]["text_url"] == expected + assert generated["citations"][0]["image_url"] == image_url + conversation_id = conversation.id + client = client_for(session_factory) + try: + detail = client.get(f"/api/conversations/{conversation_id}").json() + citations = detail["messages"][0]["citations"] + assert citations[0]["text_url"] == expected + assert "text_url" not in citations[1] + assert "原文证据" in client.get(expected).json()["text"] + with session_factory() as db: + stored = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id)) + assert "text_url" not in stored.citations[0] + finally: + app.dependency_overrides.clear() + + def test_late_citations_and_search_snippets_survive_restoration(session_factory, monkeypatch): class FakeClient: def tool_call(self, messages, _tools): diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index dade03a..ebceba0 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -285,7 +285,9 @@ def scores(db, convo, args, _user): for evidence in row["evidence"][:2]: citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"], "quote": evidence["quote"][:320], - "url": url, "image_url": evidence["image_url"]}) + "url": url, "image_url": evidence["image_url"], + "text_url": f"/api/knowledge/documents/{evidence['document_id']}/pages/{evidence['page']}" + if evidence.get("document_id") else None}) next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None next_project_offset = args.project_offset + 1 if args.project_offset + 1 < len(results) else None return {"batch_id": batch.id, "status_filter": args.status, "count": len(results), diff --git a/backend/wisegrade/chat_api.py b/backend/wisegrade/chat_api.py index 611329d..b5178d7 100644 --- a/backend/wisegrade/chat_api.py +++ b/backend/wisegrade/chat_api.py @@ -1,6 +1,8 @@ """Persistent teacher conversations and SSE agent orchestration.""" import json +import re import time +from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from datetime import timedelta from threading import Lock @@ -18,7 +20,7 @@ from .agent_tools import TOOLS, execute, tool_specs from .config import get_settings from .db import get_db from .llm import ModelError -from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now +from .models import AgentRun, ChatMessage, Conversation, KnowledgeChunk, PendingAction, Project, ScoreEvidence, ScoringBatch, now from .settings_store import client_for_chat router = APIRouter(prefix="/api/conversations") @@ -88,6 +90,48 @@ def _validated_scope(db, data): return scope +def _restored_citations(db, messages): + """Resolve old score image links only when saved evidence identifies one live text page.""" + refs = [] + originals = {msg.id: msg.citations or [] for msg in messages} + for msg in messages: + for index, item in enumerate(originals[msg.id]): + if not isinstance(item, dict) or item.get("text_url") or re.fullmatch( + r"/api/knowledge/documents/\d+/pages/\d+/?", str(item.get("url") or "")): + continue + match = re.fullmatch(r"/api/assets/(\d+)/(\d+)/?", str(item.get("image_url") or item.get("url") or "")) + if match and isinstance(item.get("quote"), str) and item["quote"]: + refs.append((msg.id, index, item, int(match[1]), int(match[2]))) + if not refs: + return {} + file_ids = {file_id for _, _, _, file_id, _ in refs} + pages = {page for _, _, _, _, page in refs} + evidence = defaultdict(list) + for file_id, page, quote, document_id in db.execute(select( + ScoreEvidence.submission_file_id, ScoreEvidence.page, ScoreEvidence.quote, ScoreEvidence.document_id).where( + ScoreEvidence.submission_file_id.in_(file_ids), ScoreEvidence.page.in_(pages), + ScoreEvidence.document_id.is_not(None))): + evidence[file_id, page].append((quote, document_id)) + document_ids = {document_id for rows in evidence.values() for _, document_id in rows} + if not document_ids: + return {} + chunks = defaultdict(list) + for document_id, page, content in db.execute(select( + KnowledgeChunk.document_id, KnowledgeChunk.page, KnowledgeChunk.text).where( + KnowledgeChunk.document_id.in_(document_ids), KnowledgeChunk.page.in_(pages))): + chunks[document_id, page].append(content) + restored = {} + for message_id, index, item, file_id, page in refs: + quote = item["quote"] + matches = {document_id for saved_quote, document_id in evidence[file_id, page] + if saved_quote.startswith(quote) and any(quote in content for content in chunks[document_id, page])} + if len(matches) == 1: + citations = restored.setdefault(message_id, [dict(value) if isinstance(value, dict) else value + for value in originals[message_id]]) + citations[index]["text_url"] = f"/api/knowledge/documents/{matches.pop()}/pages/{page}" + return restored + + def _detail(db, conversation): stale = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id, AgentRun.status == "running", AgentRun.created_at < now() - timedelta(minutes=15))).all() @@ -99,9 +143,11 @@ def _detail(db, conversation): db.commit() runs = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id).order_by(AgentRun.id.desc()).limit(20)).all() messages = db.scalars(select(ChatMessage).where(ChatMessage.conversation_id == conversation.id).order_by(ChatMessage.id)).all() + restored_citations = _restored_citations(db, messages) actions = db.scalars(select(PendingAction).where(PendingAction.conversation_id == conversation.id).order_by(PendingAction.id)).all() - return {**_summary(conversation), "messages": [{key: getattr(msg, key) for key in - ("id", "role", "content", "citations", "tool_calls", "created_at")} for msg in messages], + return {**_summary(conversation), "messages": [{**{key: getattr(msg, key) for key in + ("id", "role", "content", "tool_calls", "created_at")}, + "citations": restored_citations.get(msg.id, msg.citations)} for msg in messages], "actions": [action_dict(action) for action in actions], "runs": [{key: getattr(run, key) for key in ("id", "status", "error", "tool_calls", "created_at", "finished_at")} for run in runs], "scope_locked": any(msg.role == "user" for msg in messages)} @@ -219,7 +265,7 @@ def _safe_citation(item): if not isinstance(item, dict): return None return {key: value[:limit] if isinstance(value, str) else value for key, limit in - (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500)) + (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("text_url", 500)) if isinstance((value := item.get(key)), str)} | { key: item[key] for key in ("chunk_id", "document_id", "page", "project_id") if isinstance(item.get(key), int)} @@ -234,7 +280,7 @@ def _answer_citations(items): if not safe: continue key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"), - safe.get("page"), safe.get("quote"), safe.get("url")) + safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("text_url")) if key not in seen: seen.add(key) citations.append(safe) diff --git a/backend/wisegrade/exports.py b/backend/wisegrade/exports.py index ce9a281..295d453 100644 --- a/backend/wisegrade/exports.py +++ b/backend/wisegrade/exports.py @@ -29,6 +29,7 @@ def project_result(db, batch: ScoringBatch, project: Project): "missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "", "status": score.status if score else "pending", "error": score.error if score else "", "evidence": [{"page": item.page, "quote": item.quote, "file_id": item.submission_file_id, + "document_id": item.document_id, "document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料", "image_url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} for item in evidence]}) student = db.get(Student, project.student_id) diff --git a/docs/CHAT_AGENT.md b/docs/CHAT_AGENT.md index ad1065e..d576f5f 100644 --- a/docs/CHAT_AGENT.md +++ b/docs/CHAT_AGENT.md @@ -6,7 +6,7 @@ 打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。 -可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。引用按实际目标标注“查看页面文字”“查看图像”或“查看评分”;历史评分引用中只保存了图像地址的记录也标为“查看图像”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 +可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。检索引用提供页面文字链接,存在图像时也提供图像链接;评分引用由已保存的证据文档 ID 和页码提供页面文字链接,并保留评分页与图像链接。历史评分引用若只保存了图像地址,读取会话时会用文件 ID、页码和原文摘录匹配评分证据;仅在唯一匹配到现存文字页时补充链接,不改写历史消息。链接按目标标注“查看页面文字”“查看图像”或“查看评分”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。 diff --git a/frontend/src/Chat.tsx b/frontend/src/Chat.tsx index a8d07da..e8b2b48 100644 --- a/frontend/src/Chat.tsx +++ b/frontend/src/Chat.tsx @@ -106,17 +106,20 @@ function CitationCards({ items }: { items: any[] }) { const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`; const quote = typeof item.quote === 'string' ? item.quote.trim() : ''; const sourceHref = safeUrl(item.url); - const imageHref = safeUrl(item.image_url); + const textHref = safeUrl(item.text_url); + const imageHref = safeUrl(item.image_url) || (sourceHref && isImageUrl(sourceHref) ? sourceHref : undefined); return
{name}{place && {place}}
{quote && (quote.length > 320 ?
{quote.slice(0, 240)}… 展开原文

{quote}

:

{quote}

)} - {sourceHref && {sourceLabel(sourceHref)}} - {imageHref && imageHref !== sourceHref && 查看图像}
; + {textHref && textHref !== sourceHref && 查看页面文字} + {sourceHref && sourceHref !== imageHref && {sourceLabel(sourceHref)}} + {imageHref && 查看图像}; })}; } function Citations({ items }: { items: any[] }) { const seen = new Set(); const unique = (items || []).filter(item => { if (!item || typeof item !== 'object') return false; - const key = JSON.stringify([item.title || item.document, item.page, item.quote, item.url, item.image_url]); + const key = JSON.stringify([item.document_id, item.title || item.document, item.page, item.quote, + item.url, item.text_url, item.image_url]); if (seen.has(key)) return false; seen.add(key); return true; }); if (!unique.length) return null; return
引用 · {unique.length}
; diff --git a/frontend/tests/chat.spec.ts b/frontend/tests/chat.spec.ts index f966b88..3f74948 100644 --- a/frontend/tests/chat.spec.ts +++ b/frontend/tests/chat.spec.ts @@ -8,8 +8,10 @@ async function mockApi(page: Page) { content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript:alert(1)) \n\n> 引用\n\n- 列表\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst x = 1;\n```', citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/assets/5/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/assets/5/2' }, - { chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/assets/5/48' }, - { title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/?page=scores&batch_id=3&project_id=5', image_url: '/api/assets/5/49' }, + { chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/assets/5/48', text_url: '/api/knowledge/documents/12/pages/48' }, + { title: '仅图像.pdf', page: 50, quote: '未匹配片段', url: '/api/assets/5/50' }, + { title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/?page=scores&batch_id=3&project_id=5', + image_url: '/api/assets/5/49', text_url: '/api/knowledge/documents/12/pages/49' }, { title: '课程资料.pdf', page: 5, quote: '课程说明', url: '/api/knowledge/documents/11/pages/5' }], tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, omitted_text_fields: 1 }, summary: '找到 1 条引用', result: { found: true, citations: [{ page: 48, quote: '第48页原文片段' }] } }, @@ -96,7 +98,7 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa await expect(page.locator('.chat-markdown pre')).toBeVisible(); await expect(page.locator('.chat-markdown img')).toHaveCount(0); await expect(page.getByText('危险链接')).not.toHaveAttribute('href'); - await expect(page.getByText('引用 · 4')).toBeVisible(); + await expect(page.getByText('引用 · 5')).toBeVisible(); await expect(page.getByText('资料.pdf', { exact: true })).not.toBeVisible(); await expect(page.getByText('后台进度')).toHaveCount(0); await expect(page.locator('.chat-tools')).toBeVisible(); @@ -125,9 +127,14 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa await expect(searchCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2$/); await expect(searchCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/2$/); const legacyImage = page.locator('.citation-card').filter({ hasText: '综合报告.pdf' }); + await expect(legacyImage.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/48$/); await expect(legacyImage.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/48$/); - await expect(legacyImage.getByRole('link')).toHaveCount(1); + await expect(legacyImage.getByRole('link')).toHaveCount(2); + const imageOnly = page.locator('.citation-card').filter({ hasText: '仅图像.pdf' }); + await expect(imageOnly.getByRole('link', { name: '查看图像' })).toBeVisible(); + await expect(imageOnly.getByRole('link')).toHaveCount(1); const scoreCitation = page.locator('.citation-card').filter({ hasText: '评分报告.pdf' }); + await expect(scoreCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/49$/); await expect(scoreCitation.getByRole('link', { name: '查看评分' })).toHaveAttribute('href', /page=scores&batch_id=3&project_id=5$/); await expect(scoreCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/49$/); const textOnly = page.locator('.citation-card').filter({ hasText: '课程资料.pdf' });