From 83aa0e84a29933c49b14c6f08f3c3667fca88fd8 Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sun, 27 Sep 2026 17:47:32 +0800 Subject: [PATCH] feat: unify scoring and chat evidence pages --- HANDOFF.md | 12 +- README.md | 2 + .../versions/0005_knowledge_pages.py | 183 ++++++++++++++++++ backend/tests/test_api.py | 43 +++- backend/tests/test_chat_agent.py | 51 ++++- backend/tests/test_chat_sessions.py | 108 ++++++++--- backend/tests/test_foundation.py | 2 +- backend/tests/test_llm_and_rag.py | 4 +- backend/wisegrade/actions.py | 3 +- backend/wisegrade/agent_tools.py | 13 +- backend/wisegrade/api.py | 22 ++- backend/wisegrade/chat_api.py | 53 +---- backend/wisegrade/exports.py | 12 +- backend/wisegrade/knowledge_pages.py | 53 +++++ backend/wisegrade/models.py | 8 + backend/wisegrade/rag.py | 7 +- backend/wisegrade/scoring.py | 13 +- docs/CHAT_AGENT.md | 4 +- frontend/src/App.tsx | 2 +- frontend/src/Chat.tsx | 18 +- frontend/tests/chat.spec.ts | 30 +-- 21 files changed, 511 insertions(+), 132 deletions(-) create mode 100644 backend/migrations/versions/0005_knowledge_pages.py create mode 100644 backend/wisegrade/knowledge_pages.py diff --git a/HANDOFF.md b/HANDOFF.md index 477c383..6977495 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -29,7 +29,7 @@ | `frontend/src/Chat.tsx` | 会话恢复、引用、进度和确认卡片 | | `backend/tests/` | 路径、规则、版本、解析、模型输出、复核、导出及示例材料流程测试 | -教师选择 `/data/inbox` 下的相对目录后,后端扫描 `学号_姓名_选题题目` 子目录;教师确认材料类型后保存路径、大小、时间和 SHA-256。解析结果与页面图像进入 `data/processed/`,知识片段进入 PostgreSQL。评分时复制课程、规则和模型配置快照到独立批次,由 RQ worker 处理。模型原始评分、证据和教师修改记录分别保留;导出文件写入 `data/exports/`。原件不经过浏览器上传。 +教师选择 `/data/inbox` 下的相对目录后,后端扫描 `学号_姓名_选题题目` 子目录;教师确认材料类型后保存路径、大小、时间和 SHA-256。解析页面图像进入 `data/processed/`,完整页文字、知识片段及其向量进入 PostgreSQL。评分证据和 Agent 检索引用共用文档 ID、页码和资料页链接。评分时复制课程、规则和模型配置快照到独立批次,由 RQ worker 处理。模型原始评分、证据和教师修改记录分别保留;导出文件写入 `data/exports/`。原件不经过浏览器上传。 ## 启动与检查 @@ -107,8 +107,14 @@ uv run pytest -q ## 2026-09-27 引用链接标签 -检索引用的 `url` 指向页面文字 JSON 接口,`image_url` 指向页面图像;评分引用现在分别保存评分页 `url` 和证据 `image_url`。界面按目标显示“查看页面文字”“查看评分”“查看图像”;旧评分引用中把图像放在 `url` 的记录也能识别,两个字段指向同一图像时只显示一个链接。无需迁移历史数据。后端评分引用针对性测试 1 通过,前端构建通过,Playwright 3 通过。API 与 Web 容器已更新并健康;正式页面检查 7 号会话 74 个图像链接,均显示“查看图像”。 +检索引用的 `url` 指向页面文字 JSON 接口,`image_url` 指向页面图像;评分引用现在分别保存评分页 `url` 和证据 `image_url`。界面按目标显示“查看页面文字”“查看评分”“查看图像”。本阶段尚未迁移历史数据,后续由 `0005` 一次性迁移,见下文。后端评分引用针对性测试 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 个不同的文字页链接均可打开,且每条引用的摘录都存在于目标页。 +评分证据 `ScoreEvidence` 原已保存 `document_id` 和 `page`,前一版遗漏了这条来源链。该阶段 `project_result` 将文档 ID 交给 `get_scores`,新回答引用保存 `text_url`;历史会话曾在读取时匹配评分证据,但不改写数据库。隔离测试容器 `pytest -q -m 'not integration'` 为 38 通过、2 个集成测试未运行;前端构建和 Playwright 3 项测试通过。API 与 Web 容器已更新并健康。正式会话 7 的 74 条引用在浏览器中均显示文字和图像链接;其中 41 条旧评分引用经后端恢复。随后 `0005` 已将这一读取时匹配改为一次性数据迁移。 + +## 2026-09-27 统一资料页与评分证据 + +迁移 `0005` 新增 `knowledge_pages`,以文档 ID 和页码保存完整提取文字、处理目录内的相对 PNG 路径。`knowledge_chunks` 继续保存检索片段和 pgvector 向量;原始学生文件仍只读挂载在 `/data/inbox`,PNG 实体位于 `data/processed/`。检索、评分模型的页面图像、评分证据、评分复核和 Agent 引用现在都从同一资料页取得文字与图像地址。`/api/knowledge/documents/{id}/pages/{page}` 返回完整页文字,末尾 `/image` 返回 PNG。数据库生成的课程目标与评分规则没有原始实体图像,因而只提供文字链接。迁移直接改写已保存的会话引用和工具结果;会话 API 不再做旧图像直链的读取时补全。 + +迁移前备份为本机 `data/backups/wisegrade-pre-0005-20260927-174222.dump`(权限 `600`,不提交)。先将备份还原到隔离库演练迁移:157 个资料页、155 个图像页、0 个旧图像直链留在会话或工具记录、161 条评分证据均有资料页;随后正式数据库升级到 `0005`,155 个 PNG 路径均能找到实体文件,161 条证据原文均存在于对应完整页,199 条历史会话引用都有统一的文字和图像链接。Web 入口健康与页面接口均返回 200,抽查图像为 `image/png`;真实 Chromium 打开 7 号会话检查 74 张引用卡片,均有文字和图像链接,390px 页面无横向溢出。独立 PostgreSQL 测试容器 `pytest -q -m 'not integration'`:40 通过、2 个真实集成测试未运行;前端 `npm run build` 与 Playwright 3 项通过。API、worker、Web 镜像从当前源码重新构建并更新。前端构建仍有 Vite 大块提示。 diff --git a/README.md b/README.md index 52fc274..15b265b 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,5 @@ WiseGrade 是面向《自动化专业综合实践》课程的教师端自动评 3. 运行 `docker compose up`,访问 。 打开系统默认进入空白“会话”草稿;点击“新建”也只切换草稿,发送第一条消息才保存会话。历史会话可从列表继续或删除;回答上方可逐层展开工具调用,末尾可展开引用原文与页面链接。查询可直接执行,导入、入库、建批、启动、复核和导出先展示确认卡片,由教师点击后执行。学生材料只从只读挂载的 `/data/inbox` 读取。没有本地嵌入模型或 API 密钥时,系统仍可启动和完成导入;索引、会话或评分会显示明确错误。详细部署和使用步骤见 [部署与使用指南](docs/DEPLOYMENT.md)。 + +评分证据与会话检索引用共用文档 ID 和页码。页面文字来自 PostgreSQL 的完整资料页记录,检索片段与向量也在 PostgreSQL;页面 PNG 位于 `data/processed/`,可从引用的“查看图像”打开。数据库直接生成的课程目标和规则没有实体页图像,因此只显示“查看页面文字”。历史引用由迁移 `0005` 一次性规范化。 diff --git a/backend/migrations/versions/0005_knowledge_pages.py b/backend/migrations/versions/0005_knowledge_pages.py new file mode 100644 index 0000000..d12d11f --- /dev/null +++ b/backend/migrations/versions/0005_knowledge_pages.py @@ -0,0 +1,183 @@ +"""Persist canonical knowledge page text and image locations. + +Revision ID: 0005 +Revises: 0004 +""" +import hashlib +import json +import re +from collections import defaultdict +from pathlib import Path + +from alembic import op +import sqlalchemy as sa + +from wisegrade.config import get_settings + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def _json(value): + return json.loads(value) if isinstance(value, str) else value or {} + + +def _text(source): + blocks = [str(source.get("text") or "").strip()] + blocks.extend("表格: " + str(table.get("rows", table)) for table in source.get("tables", [])) + return "\n".join(block for block in blocks if block) + + +def _relative_image(root, value): + if not value: + return None + path = Path(value).resolve() + return str(path.relative_to(root)) if path.is_relative_to(root) and path.is_file() else None + + +def _restore_chunk_text(parts): + """Undo the 100-character overlap used by index version 2.""" + if not parts: + return "" + blocks = [] + current = previous = parts[0] + for part in parts[1:]: + if len(previous) == 650 and previous[-100:] == part[:100]: + current += part[100:] + else: + blocks.append(current) + current = part + previous = part + blocks.append(current) + return "\n".join(blocks) + + +_PAGE_URL = re.compile(r"/api/knowledge/documents/(\d+)/pages/(\d+)/?") +_ASSET_URL = re.compile(r"/api/assets/(\d+)/(\d+)/?") + + +def _migrate_references(db): + """Rewrite saved citations once; API reads the stored canonical links as-is.""" + pages = {(row.document_id, row.page): row for row in db.execute(sa.text( + "SELECT document_id, page, text, image_path FROM knowledge_pages")).mappings()} + evidence = defaultdict(list) + for row in db.execute(sa.text( + "SELECT submission_file_id, page, quote, document_id FROM score_evidences " + "WHERE submission_file_id IS NOT NULL AND document_id IS NOT NULL")).mappings(): + evidence[row.submission_file_id, row.page].append((row.quote, row.document_id)) + + def normalize(item): + if not isinstance(item, dict): + return item + old_url = item.get("url") + raw_page = item.get("page") + identity = None + if isinstance(item.get("document_id"), int) and isinstance(raw_page, int): + identity = (item["document_id"], raw_page) + for raw in (item.get("text_url"), old_url): + match = _PAGE_URL.fullmatch(str(raw or "")) + if match: + from_url = (int(match[1]), int(match[2])) + if identity and identity != from_url: + raise RuntimeError("旧引用的文档 ID 与页面链接冲突") + identity = from_url + asset = next((match for raw in (old_url, item.get("image_url")) + if (match := _ASSET_URL.fullmatch(str(raw or "")))), None) + if not identity and asset and isinstance(item.get("quote"), str) and item["quote"]: + file_id, page = int(asset[1]), int(asset[2]) + quote = item["quote"] + matches = {document_id for saved, document_id in evidence[file_id, page] + if saved.startswith(quote) and (record := pages.get((document_id, page))) + and quote in record.text} + if len(matches) != 1: + raise RuntimeError("旧评分引用无法唯一对应资料页,请先核对评分证据") + identity = (matches.pop(), page) + if not identity: + return item + if raw_page is not None and raw_page != identity[1]: + raise RuntimeError("旧引用的页码与来源链接冲突") + record = pages.get(identity) + if not record: + raise RuntimeError("旧引用对应的资料页不存在") + base = f"/api/knowledge/documents/{identity[0]}/pages/{identity[1]}" + value = {**item, "document_id": identity[0], "page": identity[1], "url": base, + "image_url": base + "/image" if record.image_path else None} + if isinstance(old_url, str) and old_url.startswith("/?page=scores"): + value["score_url"] = old_url + value.pop("text_url", None) + return value + + def walk(value): + if isinstance(value, list): + return [walk(item) for item in value] + if isinstance(value, dict): + return normalize({key: walk(item) for key, item in value.items()}) + return value + + for table, columns in (("chat_messages", ("citations", "tool_calls")), + ("agent_runs", ("tool_calls", "events")), + ("pending_actions", ("preview", "result"))): + for row in db.execute(sa.text(f"SELECT id, {', '.join(columns)} FROM {table}")).mappings(): + changed = {} + for column in columns: + old = row[column] + new = walk(old) + if new != old: + changed[column] = new + if changed: + assignments = ", ".join(f"{column} = :{column}" for column in changed) + statement = sa.text(f"UPDATE {table} SET {assignments} WHERE id = :id") + for column in changed: + statement = statement.bindparams(sa.bindparam(column, type_=sa.JSON())) + db.execute(statement, {"id": row.id, **changed}) + + +def upgrade(): + if "knowledge_pages" not in sa.inspect(op.get_bind()).get_table_names(): + op.create_table( + "knowledge_pages", + sa.Column("document_id", sa.Integer(), sa.ForeignKey("knowledge_documents.id", ondelete="CASCADE"), primary_key=True), + sa.Column("page", sa.Integer(), primary_key=True), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("image_path", sa.String(1200), nullable=True), + ) + db = op.get_bind() + root = get_settings().processed_path.resolve() + documents = db.execute(sa.text( + "SELECT id, course_version_id, submission_file_id, title FROM knowledge_documents")).mappings().all() + files = {row.id: _json(row.parsed) for row in db.execute(sa.text( + "SELECT id, parsed FROM submission_files")).mappings()} + templates = {row.id: _json(row.templates) for row in db.execute(sa.text( + "SELECT id, templates FROM course_versions")).mappings()} + chunks = defaultdict(list) + for row in db.execute(sa.text( + "SELECT document_id, page, text FROM knowledge_chunks ORDER BY document_id, page, id")).mappings(): + chunks[row.document_id, row.page].append(row.text) + insert = sa.text("INSERT INTO knowledge_pages (document_id, page, text, image_path) " + "VALUES (:document_id, :page, :text, :image_path) ON CONFLICT DO NOTHING") + pages_by_document = defaultdict(list) + for (document_id, page), parts in chunks.items(): + pages_by_document[document_id].append((page, parts)) + for doc in documents: + parsed_pages = {item.get("page_number"): item for item in files.get(doc.submission_file_id, {}).get("pages", [])} + for page in parsed_pages: + pages_by_document[doc.id].append((page, chunks.get((doc.id, page), []))) + seen = set() + for page, parts in pages_by_document[doc.id]: + if page in seen: + continue + seen.add(page) + source = parsed_pages.get(page) + text = _text(source) if source else _restore_chunk_text(parts) + image = _relative_image(root, source.get("rendered_image_path")) if source else None + if not image and doc.submission_file_id is None and doc.title in templates.get(doc.course_version_id, []): + folder = hashlib.sha256(doc.title.encode()).hexdigest()[:16] + image = _relative_image(root, root / f"course-{doc.course_version_id}" / folder / f"page-{page}.png") + db.execute(insert, {"document_id": doc.id, "page": page, "text": text, "image_path": image}) + _migrate_references(db) + + +def downgrade(): + op.drop_table("knowledge_pages") diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ac86618..99b84e4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,9 +1,12 @@ from fastapi.testclient import TestClient from sqlalchemy import select +from wisegrade import agent_tools from wisegrade.db import get_db from wisegrade.main import app -from wisegrade.models import CourseVersion, CriterionScore, Project, RubricVersion, Student +from wisegrade.models import (Conversation, CourseVersion, CriterionScore, KnowledgeChunk, + KnowledgePage, Project, RubricVersion, Student) +from wisegrade.rag import ingest from wisegrade.scoring import run_batch @@ -15,6 +18,44 @@ def client_for(session_factory): return TestClient(app) +def test_file_page_has_text_and_image_virtual_page_has_text_only(session_factory, environment, monkeypatch): + image = environment / "processed" / "course-1" / "report" / "page-49.png" + image.parent.mkdir(parents=True) + image.write_bytes(b"\x89PNG\r\n\x1a\n") + vectors = lambda texts: [[0.1] * 1024 for _ in texts] + with session_factory() as db: + document = ingest(db, course_version_id=1, project_id=None, submission_file_id=None, + title="课程报告.pdf", category="course", source_hash="file", + pages=[{"page_number": 49, "text": "第四十九页完整文字", "tables": [{"rows": [["指标", "结果"]]}], + "rendered_image_path": str(image)}], embedder=vectors) + virtual = ingest(db, course_version_id=1, project_id=None, submission_file_id=None, + title="系统生成规则", category="course", source_hash="virtual", + pages=[{"page_number": 1, "text": "规则文字"}], embedder=vectors) + assert db.get(KnowledgePage, (document.id, 49)).image_path == "course-1/report/page-49.png" + chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id)) + monkeypatch.setattr(agent_tools, "retrieve", lambda *_, **__: [{"chunk_id": chunk.id, + "document_id": document.id, "project_id": None, "page": 49, "text": chunk.text}]) + citation = agent_tools.execute(db, Conversation(course_version_id=1), "search_course_material", + {"course_version_id": 1, "query": "完整文字"}, "")["citations"][0] + assert citation["url"] == f"/api/knowledge/documents/{document.id}/pages/49" + assert citation["image_url"] == citation["url"] + "/image" + document_id, virtual_id = document.id, virtual.id + client = client_for(session_factory) + try: + resource = client.get(f"/api/knowledge/documents/{document_id}/pages/49") + assert resource.status_code == 200 + assert resource.json()["text"] == "第四十九页完整文字\n表格: [['指标', '结果']]" + image_url = resource.json()["image_url"] + assert image_url == f"/api/knowledge/documents/{document_id}/pages/49/image" + assert client.get(image_url).content.startswith(b"\x89PNG") + virtual_resource = client.get(f"/api/knowledge/documents/{virtual_id}/pages/1") + assert virtual_resource.json()["text"] == "规则文字" + assert virtual_resource.json()["image_url"] is None + assert client.get(f"/api/knowledge/documents/{virtual_id}/pages/1/image").status_code == 404 + finally: + app.dependency_overrides.clear() + + def test_course_and_rubric_versions_are_immutable(session_factory): client = client_for(session_factory) try: diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index a00f388..e9829c4 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -14,12 +14,13 @@ from wisegrade.actions import confirm, prepare from wisegrade.agent_tools import execute from wisegrade.db import get_db from wisegrade.indexing import run_index_task -from wisegrade.llm import DeepSeekClient, ModelError +from wisegrade.llm import DeepSeekClient, ModelError, ScoreResult from wisegrade.main import app from wisegrade.models import (AgentRun, ChatMessage, Conversation, CourseVersion, CriterionScore, IndexTask, PendingAction, - Project, RubricVersion, ScoringBatch, Student, TeacherRevision, now) + Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile, + TeacherRevision, now) from wisegrade.rag import ingest -from wisegrade.scoring import run_batch +from wisegrade.scoring import _score_criterion, run_batch from wisegrade.storage import scan @@ -93,9 +94,9 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch): assert first_page["results"][0]["project_id"] == first.id assert len(first_page["results"][0]["rows"]) == 6 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"][0]["url"] is None + assert first_page["citations"][0]["image_url"] is None + assert first_page["citations"][0]["score_url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}" 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 @@ -106,6 +107,44 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch): assert later["next_project_offset"] is None and later["next_criterion_offset"] is None +def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment): + project = add_project(db, "123450090") + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + batch = db.get(ScoringBatch, created["id"]) + criterion = batch.rubric_snapshot["criteria"][0] + file = SubmissionFile(project_id=project.id, category=criterion["category"], + relative_path="test/score/source.pdf", mime_type="application/pdf", size=1, + mtime=1, sha256="b" * 64, recognition_status="recognized", parse_status="ready") + db.add(file) + db.flush() + image = environment / "processed" / f"file-{file.id}" / "page-2.png" + image.parent.mkdir(parents=True) + image.write_bytes(b"\x89PNG\r\n\x1a\n") + source = {"page_number": 2, "text": "评分所用原文证据", "rendered_image_path": str(image)} + file.parsed = {"pages": [source]} + document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id, + title="报告.pdf", category=criterion["category"], source_hash=file.sha256, + pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts]) + from wisegrade.models import KnowledgeChunk + chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id)) + evidence = {"chunk_id": chunk.id, "document_id": document.id, "project_id": project.id, + "page": 2, "text": chunk.text} + sent_images = [] + + class FakeClient: + def json_call(self, _prompt, _payload, _schema, *, images=None): + sent_images.extend(images or []) + return ScoreResult(criterion_id=criterion["criterion_id"], score=1, + max_score=criterion["max_score"], reason="测试评分", + evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], confidence=.9) + + scored = _score_criterion(db, batch, project, criterion, FakeClient(), + retriever=lambda *_, **kwargs: [] if kwargs.get("public_only") else [evidence]) + saved = db.scalar(select(ScoreEvidence).where(ScoreEvidence.score_id == scored.id)) + assert sent_images == [str(image)] + assert saved.document_id == document.id and saved.page == 2 and saved.image_path == str(image) + + def test_import_confirmation_hash_change_index_and_idempotency(environment, db, monkeypatch, session_factory): folder = environment / "inbox" / "batch" / "123450003_张三_机器人" folder.mkdir(parents=True) diff --git a/backend/tests/test_chat_sessions.py b/backend/tests/test_chat_sessions.py index fde337f..2024854 100644 --- a/backend/tests/test_chat_sessions.py +++ b/backend/tests/test_chat_sessions.py @@ -12,8 +12,9 @@ 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, CriterionScore, - KnowledgeChunk, KnowledgeDocument, PendingAction, Project, ScoreEvidence, + KnowledgeChunk, KnowledgeDocument, KnowledgePage, PendingAction, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile, now) +from wisegrade.rag import ingest, split_pages def client_for(session_factory): @@ -179,7 +180,7 @@ 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): +def test_score_citations_link_the_canonical_text_and_image_page(session_factory, environment): with session_factory() as db: student = Student(student_no="987654320", name="测试学生") db.add(student) @@ -192,13 +193,15 @@ def test_score_citations_link_text_and_restore_legacy_image_only(session_factory 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="这里有原文证据和分析")) + image_path = environment / "processed" / f"file-{file.id}" / "page-2.png" + image_path.parent.mkdir(parents=True) + image_path.write_bytes(b"\x89PNG\r\n\x1a\n") + source = {"page_number": 2, "section": "测试", "text": "这里有原文证据和分析", + "rendered_image_path": str(image_path)} + file.parsed = {"pages": [source]} + document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id, + title="报告.pdf", category="final_report", source_hash="a" * 64, + pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts]) 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, @@ -207,32 +210,33 @@ def test_score_citations_link_text_and_restore_legacy_image_only(session_factory 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) + page=2, quote="原文证据", image_path=str(image_path))) + 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" + db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[ + {"title": "报告.pdf", "document_id": document.id, "page": 2, "quote": "原文证据", + "url": expected, "image_url": expected + "/image"}])) + db.commit() 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 + assert generated["citations"][0]["url"] == expected + assert generated["citations"][0]["image_url"] == expected + "/image" + assert generated["citations"][0]["score_url"].startswith("/?page=scores") + assert chat_api._answer_citations(generated["citations"])[0]["url"] == expected 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 citations[0]["url"] == expected + assert citations[0]["image_url"] == expected + "/image" assert "原文证据" in client.get(expected).json()["text"] + assert client.get(expected + "/image").content.startswith(b"\x89PNG") with session_factory() as db: stored = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id)) - assert "text_url" not in stored.citations[0] + assert stored.citations[0]["url"] == expected finally: app.dependency_overrides.clear() @@ -307,7 +311,7 @@ def test_large_score_result_keeps_structured_rows_for_model_and_history(session_ app.dependency_overrides.clear() -def test_upgrade_from_legacy_0003_preserves_history(session_factory): +def test_upgrade_from_legacy_0003_migrates_pages_and_citations(session_factory, environment): from pathlib import Path from alembic import command from alembic.config import Config @@ -319,21 +323,77 @@ def test_upgrade_from_legacy_0003_preserves_history(session_factory): db.flush() db.add(ChatMessage(conversation_id=row.id, role="user", content="旧问题")) db.add(AgentRun(conversation_id=row.id, status="completed")) + document = KnowledgeDocument(course_version_id=1, title="旧课程资料", document_type="course", + source_hash="legacy", index_version=2, status="ready") + db.add(document) + db.flush() + legacy_text = "旧资料第三页的完整文字。" * 80 + for part in split_pages([{"page_number": 3, "text": legacy_text}]): + db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=None, + document_type="course", page=3, text=part["text"])) + student = Student(student_no="987654322", name="迁移测试") + db.add(student) + db.flush() + project = Project(course_version_id=1, student_id=student.id, topic="迁移", source_dir="test/migration") + db.add(project) + db.flush() + file = SubmissionFile(project_id=project.id, category="final_report", relative_path="test/migration/report.pdf", + mime_type="application/pdf", size=1, mtime=1, sha256="b" * 64, + recognition_status="recognized", parse_status="ready") + db.add(file) + db.flush() + image = environment / "processed" / "migration" / "page-2.png" + image.parent.mkdir(parents=True) + image.write_bytes(b"\x89PNG\r\n\x1a\n") + file.parsed = {"pages": [{"page_number": 2, "text": "迁移评分证据原文", "rendered_image_path": str(image)}]} + student_doc = KnowledgeDocument(course_version_id=1, project_id=project.id, + submission_file_id=file.id, title="迁移报告.pdf", document_type="final_report", + source_hash="student", index_version=2, status="ready") + db.add(student_doc) + db.flush() + db.add(KnowledgeChunk(document_id=student_doc.id, course_version_id=1, project_id=project.id, + document_type="final_report", page=2, text="迁移评分证据原文")) + batch_id = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)["id"] + batch = db.get(ScoringBatch, batch_id) + score = CriterionScore(batch_id=batch_id, project_id=project.id, + criterion_id=batch.rubric_snapshot["criteria"][0]["criterion_id"], status="scored") + db.add(score) + db.flush() + db.add(ScoreEvidence(score_id=score.id, document_id=student_doc.id, submission_file_id=file.id, + page=2, quote="迁移评分证据", image_path=str(image))) + assistant = ChatMessage(conversation_id=row.id, role="assistant", content="旧回答", citations=[ + {"title": "旧课程资料", "page": 3, "quote": legacy_text[:30], + "url": f"/api/knowledge/documents/{document.id}/pages/3"}, + {"title": "迁移报告.pdf", "page": 2, "quote": "迁移评分证据", + "url": f"/api/assets/{file.id}/2"}]) + db.add(assistant) db.commit() conversation_id = row.id + document_id = document.id + student_doc_id = student_doc.id engine = session_factory.kw['bind'] with engine.begin() as connection: connection.exec_driver_sql('ALTER TABLE conversations DROP COLUMN first_request_id') connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN tool_calls') connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN events') + connection.exec_driver_sql('DROP TABLE knowledge_pages') connection.exec_driver_sql("UPDATE alembic_version SET version_num = '0003'") root = Path(__file__).resolve().parents[1] config = Config(str(root / 'alembic.ini')) config.set_main_option('script_location', str(root / 'migrations')) command.upgrade(config, 'head') with session_factory() as db: - assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0004' + assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0005' assert db.get(Conversation, conversation_id).title == '旧记录' assert db.scalar(select(ChatMessage.content).where(ChatMessage.conversation_id == conversation_id)) == '旧问题' run = db.scalar(select(AgentRun).where(AgentRun.conversation_id == conversation_id)) assert run.tool_calls == [] and run.events == [] + assert db.get(KnowledgePage, (document_id, 3)).text == legacy_text + saved = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id, + ChatMessage.role == "assistant")) + assert saved.citations[0]["document_id"] == document_id + assert saved.citations[0]["url"] == f"/api/knowledge/documents/{document_id}/pages/3" + score_citation = saved.citations[1] + assert score_citation["document_id"] == student_doc_id + assert score_citation["url"] == f"/api/knowledge/documents/{student_doc_id}/pages/2" + assert score_citation["image_url"] == score_citation["url"] + "/image" diff --git a/backend/tests/test_foundation.py b/backend/tests/test_foundation.py index efc23ca..94da1b3 100644 --- a/backend/tests/test_foundation.py +++ b/backend/tests/test_foundation.py @@ -10,7 +10,7 @@ from wisegrade.storage import classify, extract_identity, parse_student_folder, def test_postgresql_migrations_and_vector_extension(db): assert db.bind.dialect.name == "postgresql" - assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0004" + assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0005" assert db.scalar(text("SELECT extname FROM pg_extension WHERE extname = 'vector'")) == "vector" diff --git a/backend/tests/test_llm_and_rag.py b/backend/tests/test_llm_and_rag.py index ba77c95..5af2e03 100644 --- a/backend/tests/test_llm_and_rag.py +++ b/backend/tests/test_llm_and_rag.py @@ -3,7 +3,7 @@ from PIL import Image, ImageDraw from sqlalchemy import select from wisegrade.llm import DeepSeekClient, FactResult, ModelError, ScoreResult, validate_score -from wisegrade.models import AppSetting, KnowledgeChunk, Project, Student +from wisegrade.models import AppSetting, KnowledgeChunk, KnowledgePage, Project, Student from wisegrade.rag import ingest, local_encoder, retrieve, split_pages from wisegrade.settings_store import model_config, save_model_config @@ -82,12 +82,14 @@ def test_stale_knowledge_index_reuses_document_id(db): title="报告", category="stage3", source_hash="same-source", pages=pages) old_chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id)) old_chunk.text = "过时片段" + db.get(KnowledgePage, (document.id, 1)).text = "过时整页" document.index_version = 1 db.commit() reindexed = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None, title="报告", category="stage3", source_hash="same-source", pages=pages) assert reindexed.id == document.id and reindexed.index_version == 2 assert db.scalar(select(KnowledgeChunk.text).where(KnowledgeChunk.document_id == document.id)) == pages[0]["text"] + assert db.get(KnowledgePage, (document.id, 1)).text == pages[0]["text"] def test_model_key_is_encrypted_and_hidden(db): diff --git a/backend/wisegrade/actions.py b/backend/wisegrade/actions.py index 308c405..15438b7 100644 --- a/backend/wisegrade/actions.py +++ b/backend/wisegrade/actions.py @@ -10,6 +10,7 @@ from sqlalchemy import select from . import services from .exports import batch_projects, export_excel, export_project_pdf, project_result +from .knowledge_pages import evidence_links from .models import (AuditLog, Conversation, CourseVersion, CriterionScore, IndexTask, PendingAction, Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile, now) from .storage import CATEGORIES, scan, secure_path, sha256 @@ -194,7 +195,7 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher "old_status": score.status, "evidence_warning": score.error or score.missing_evidence, "evidence": [{"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料", "page": item.page, "quote": item.quote, - "url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} + **evidence_links(db, item)} for item in evidence], "new_score": payload["score"], "comment": payload["comment"], "reason": payload["reason"]} elif kind == "export": diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index ebceba0..ef24cc5 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -9,6 +9,7 @@ from sqlalchemy import select from .actions import prepare, require_scope from .exports import batch_projects, project_result +from .knowledge_pages import page_links from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeDocument, Project, RubricVersion, ScoringBatch, Student, SubmissionFile) from .rag import retrieve @@ -206,12 +207,11 @@ def _search(db, version_id, project_id, query, *, public_only=False): citations = [] for chunk in chunks: doc = db.get(KnowledgeDocument, chunk["document_id"]) - file_id = doc.submission_file_id if doc else None + links = page_links(db, chunk["document_id"], chunk["page"]) citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"], "title": doc.title if doc else "资料", "page": chunk["page"], "quote": chunk["text"][:500], "project_id": chunk["project_id"], - "url": f"/api/knowledge/documents/{chunk['document_id']}/pages/{chunk['page']}", - "image_url": f"/api/assets/{file_id}/{chunk['page']}" if file_id else None}) + **links}) return {"citations": citations, "found": bool(citations)} @@ -283,11 +283,10 @@ def scores(db, convo, args, _user): for ev in row["evidence"][:2]]} for row in rows]}) for row in rows: for evidence in row["evidence"][:2]: + links = page_links(db, evidence.get("document_id"), evidence["page"]) citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"], - "quote": evidence["quote"][:320], - "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}) + "quote": evidence["quote"][:320], "document_id": evidence.get("document_id"), + **links, "score_url": url}) 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/api.py b/backend/wisegrade/api.py index ca22fde..ec28b26 100644 --- a/backend/wisegrade/api.py +++ b/backend/wisegrade/api.py @@ -11,8 +11,9 @@ from . import services from .config import get_settings from .db import get_db from .exports import batch_projects, export_excel, export_project_pdf, project_result +from .knowledge_pages import page_image_path, page_links from .models import (AuditLog, Course, CourseVersion, CriterionScore, ExportTask, - IndexTask, KnowledgeChunk, KnowledgeDocument, Project, RubricVersion, ScoringBatch, + IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch, Student, SubmissionFile, TeacherRevision) from .materialized import sync_course_entities, sync_rubric_entities from .rules import RubricData @@ -330,13 +331,22 @@ def knowledge(course_version_id: int, project_id: int | None = None, db: Session @router.get("/knowledge/documents/{document_id}/pages/{page}") def knowledge_page(document_id: int, page: int, db: Session = Depends(get_db)): doc = found(db, KnowledgeDocument, document_id) - chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document_id, - KnowledgeChunk.page == page).order_by(KnowledgeChunk.id)).all() - if not chunks: + record = db.get(KnowledgePage, (document_id, page)) + if not record: raise HTTPException(404, "资料页面不存在") return {"document_id": doc.id, "title": doc.title, "course_version_id": doc.course_version_id, - "project_id": doc.project_id, "page": page, "text": "\n".join(chunk.text for chunk in chunks), - "image_url": f"/api/assets/{doc.submission_file_id}/{page}" if doc.submission_file_id else None} + "project_id": doc.project_id, "page": page, + "text": record.text, + "image_url": page_links(db, document_id, page)["image_url"]} + + +@router.get("/knowledge/documents/{document_id}/pages/{page}/image") +def knowledge_page_image(document_id: int, page: int, db: Session = Depends(get_db)): + doc = found(db, KnowledgeDocument, document_id) + path = page_image_path(db, doc, page) + if not path: + raise HTTPException(404, "页面图像不存在") + return FileResponse(path, media_type="image/png") @router.post("/knowledge/ingest-course/{course_version_id}") diff --git a/backend/wisegrade/chat_api.py b/backend/wisegrade/chat_api.py index b5178d7..16c6575 100644 --- a/backend/wisegrade/chat_api.py +++ b/backend/wisegrade/chat_api.py @@ -1,8 +1,6 @@ """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 @@ -20,7 +18,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, KnowledgeChunk, PendingAction, Project, ScoreEvidence, ScoringBatch, now +from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now from .settings_store import client_for_chat router = APIRouter(prefix="/api/conversations") @@ -90,48 +88,6 @@ 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() @@ -143,11 +99,10 @@ 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", "tool_calls", "created_at")}, - "citations": restored_citations.get(msg.id, msg.citations)} for msg in messages], + "citations": 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)} @@ -265,7 +220,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), ("text_url", 500)) + (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_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)} @@ -280,7 +235,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("text_url")) + safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("score_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 295d453..4bb8181 100644 --- a/backend/wisegrade/exports.py +++ b/backend/wisegrade/exports.py @@ -7,6 +7,7 @@ from sqlalchemy import select from weasyprint import HTML from .config import get_settings +from .knowledge_pages import evidence_links from .models import (CriterionScore, ExportTask, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile) from .rules import totals @@ -18,6 +19,12 @@ def project_result(db, batch: ScoringBatch, project: Project): criteria = batch.rubric_snapshot["criteria"] totals_ = totals({key: score.final_score for key, score in by_id.items()}, criteria, batch.rubric_snapshot["weights"]) rows = [] + def evidence_dict(item): + links = evidence_links(db, item) + return {"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 "课程资料", + **links} for criterion in criteria: score = by_id.get(criterion["criterion_id"]) evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all() if score else [] @@ -28,10 +35,7 @@ def project_result(db, batch: ScoringBatch, project: Project): "model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None, "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]}) + "evidence": [evidence_dict(item) for item in evidence]}) student = db.get(Student, project.student_id) return {"project_id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic, "totals": totals_, "rows": rows, "facts": project.facts} diff --git a/backend/wisegrade/knowledge_pages.py b/backend/wisegrade/knowledge_pages.py new file mode 100644 index 0000000..c4edc11 --- /dev/null +++ b/backend/wisegrade/knowledge_pages.py @@ -0,0 +1,53 @@ +"""Canonical page content and links shared by retrieval, scoring, and citations.""" +from pathlib import Path + +from .config import get_settings +from .models import KnowledgeDocument, KnowledgePage + + +def text_url(document_id: int, page: int) -> str: + return f"/api/knowledge/documents/{document_id}/pages/{page}" + + +def image_url(document_id: int, page: int) -> str: + return f"{text_url(document_id, page)}/image" + + +def page_text(source: dict) -> str: + blocks = [str(source.get("text") or "").strip()] + blocks.extend("表格: " + str(table.get("rows", table)) for table in source.get("tables", [])) + return "\n".join(block for block in blocks if block) + + +def relative_image_path(value: str | None) -> str | None: + if not value: + return None + root = get_settings().processed_path.resolve() + path = Path(value).resolve() + if not path.is_relative_to(root): + raise ValueError("页面图像路径不在处理目录内") + return str(path.relative_to(root)) + + +def page_image_path(db, doc: KnowledgeDocument, page: int) -> Path | None: + root = get_settings().processed_path.resolve() + record = db.get(KnowledgePage, (doc.id, page)) + path = (root / record.image_path).resolve() if record and record.image_path else None + return path if path and path.is_relative_to(root) and path.is_file() else None + + +def page_links(db, document_id: int | None, page: int | None) -> dict[str, str | None]: + if not document_id or not page: + return {"url": None, "image_url": None} + doc = db.get(KnowledgeDocument, document_id) + if not doc: + return {"url": None, "image_url": None} + record = db.get(KnowledgePage, (document_id, page)) + if not record: + return {"url": None, "image_url": None} + return {"url": text_url(document_id, page), + "image_url": image_url(document_id, page) if page_image_path(db, doc, page) else None} + + +def evidence_links(db, evidence) -> dict[str, str | None]: + return page_links(db, evidence.document_id, evidence.page) diff --git a/backend/wisegrade/models.py b/backend/wisegrade/models.py index 377d16f..3f081dd 100644 --- a/backend/wisegrade/models.py +++ b/backend/wisegrade/models.py @@ -170,6 +170,14 @@ class KnowledgeDocument(Base): __table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),) +class KnowledgePage(Base): + __tablename__ = "knowledge_pages" + document_id = Column(Integer, ForeignKey("knowledge_documents.id", ondelete="CASCADE"), primary_key=True) + page = Column(Integer, primary_key=True) + text = Column(Text, nullable=False, default="") + image_path = Column(String(1200), nullable=True) # Relative to processed_path. + + class KnowledgeChunk(Base): __tablename__ = "knowledge_chunks" id = Column(Integer, primary_key=True) diff --git a/backend/wisegrade/rag.py b/backend/wisegrade/rag.py index 757e719..1ac834c 100644 --- a/backend/wisegrade/rag.py +++ b/backend/wisegrade/rag.py @@ -4,7 +4,8 @@ from pathlib import Path from sqlalchemy import cast, delete, func, or_, select from .config import get_settings -from .models import KnowledgeChunk, KnowledgeDocument +from .knowledge_pages import page_text, relative_image_path +from .models import KnowledgeChunk, KnowledgeDocument, KnowledgePage INDEX_VERSION = 2 @@ -60,6 +61,7 @@ def ingest(db, *, course_version_id: int, project_id: int | None, submission_fil if len(vectors) != len(chunks): raise RuntimeError("嵌入向量数量与知识片段数量不一致") if existing: + db.execute(delete(KnowledgePage).where(KnowledgePage.document_id == existing.id)) db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id)) doc = existing doc.document_type, doc.source_hash = category, source_hash @@ -69,6 +71,9 @@ def ingest(db, *, course_version_id: int, project_id: int | None, submission_fil db.add(doc) doc.index_version, doc.status = INDEX_VERSION, "indexing" db.flush() + for source in pages: + db.add(KnowledgePage(document_id=doc.id, page=source["page_number"], text=page_text(source), + image_path=relative_image_path(source.get("rendered_image_path")))) for chunk, vector in zip(chunks, vectors): db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id, document_type=category, page=chunk["page"], section=chunk["section"], text=chunk["text"], embedding=vector)) diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py index e2abc0b..1b3d386 100644 --- a/backend/wisegrade/scoring.py +++ b/backend/wisegrade/scoring.py @@ -6,6 +6,7 @@ from sqlalchemy import select, update from .config import get_settings from .db import SessionLocal +from .knowledge_pages import page_image_path from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validate_score from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch, SubmissionFile, now) @@ -126,8 +127,13 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever): return score if client is None: raise ModelError("未配置或无法连接评分模型") - page_paths = {page["page_number"]: page.get("rendered_image_path") for page in ready[0].parsed["pages"]} - images = [page_paths[page] for page in dict.fromkeys(chunk["page"] for chunk in chunks[:2]) if page_paths.get(page)] + image_sources = dict.fromkeys((chunk["document_id"], chunk["page"]) for chunk in chunks[:2]) + images = [] + for document_id, page in image_sources: + doc = db.get(KnowledgeDocument, document_id) + path = page_image_path(db, doc, page) if doc else None + if path: + images.append(str(path)) public_context = retriever(db, course_version_id=batch.course_version_id, project_id=project.id, category=None, query=query, limit=6, public_only=True) payload = {"criterion": criterion, "project_facts": project.facts, "course_context": public_context, @@ -153,8 +159,9 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever): for evidence in result.evidence: chunk = by_id[evidence.chunk_id] doc = db.get(KnowledgeDocument, chunk["document_id"]) + path = page_image_path(db, doc, evidence.page) db.add(ScoreEvidence(score_id=score.id, document_id=doc.id, submission_file_id=doc.submission_file_id, - page=evidence.page, quote=evidence.quote, image_path=page_paths.get(evidence.page))) + page=evidence.page, quote=evidence.quote, image_path=str(path) if path else None)) db.commit() return score diff --git a/docs/CHAT_AGENT.md b/docs/CHAT_AGENT.md index d576f5f..e9def94 100644 --- a/docs/CHAT_AGENT.md +++ b/docs/CHAT_AGENT.md @@ -6,7 +6,7 @@ 打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。 -可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。检索引用提供页面文字链接,存在图像时也提供图像链接;评分引用由已保存的证据文档 ID 和页码提供页面文字链接,并保留评分页与图像链接。历史评分引用若只保存了图像地址,读取会话时会用文件 ID、页码和原文摘录匹配评分证据;仅在唯一匹配到现存文字页时补充链接,不改写历史消息。链接按目标标注“查看页面文字”“查看图像”或“查看评分”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 +可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。评分证据与 Agent 检索引用都使用 `KnowledgeDocument` 的文档 ID、页码,对应同一 `KnowledgePage`:`/api/knowledge/documents/{id}/pages/{page}` 返回该页完整提取文字,`/image` 返回存在的页面 PNG。评分引用另有“查看评分”链接。历史引用由迁移 `0005` 一次性改写为这些统一地址;会话读取不再猜测旧图像链接。实体 PDF、DOCX、PPTX 页面经解析后通常同时有文字和图像;数据库直接生成的课程目标、评分规则等虚拟资料没有实体页面图像,只提供文字。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。 @@ -14,7 +14,7 @@ ## 数据与任务 -迁移 `0003` 添加会话、消息、运行、待确认操作、索引任务和批次固定项目清单;`0004` 添加首次请求幂等键与有界工具进度记录。新批次在创建时保存项目 ID 列表;评分、结果查询和导出都使用该列表。旧批次该字段为 `NULL`,界面显示“历史项目清单未知”,只能按课程版本当前项目兼容读取,不能声称还原了创建时的范围。批次状态为 `created`、`queued`、`running`、`partial`、`failed`、`completed`;重复启动已入队或运行中的批次返回冲突。重试保留已评分项和教师复核。 +迁移 `0003` 添加会话、消息、运行、待确认操作、索引任务和批次固定项目清单;`0004` 添加首次请求幂等键与有界工具进度记录;`0005` 回填完整资料页文字与相对图像路径,并将历史会话中的引用一次性转换为统一页面链接。检索片段的文字和 1024 维向量保存在 PostgreSQL 的 `knowledge_chunks`;供教师核对的完整页文字单独保存在 `knowledge_pages`,避免以有重叠的检索片段拼接页面。PNG 文件位于共享 `data/processed/`,数据库只存相对于处理目录的路径;学生原件仍只读挂载在 `/data/inbox`。新批次在创建时保存项目 ID 列表;评分、结果查询和导出都使用该列表。旧批次该字段为 `NULL`,界面显示“历史项目清单未知”,只能按课程版本当前项目兼容读取,不能声称还原了创建时的范围。批次状态为 `created`、`queued`、`running`、`partial`、`failed`、`completed`;重复启动已入队或运行中的批次返回冲突。重试保留已评分项和教师复核。 导入后的每个待解析文件对应持久 `IndexTask`。worker 复用评分的解析和 RAG 入库代码;同一文件用共享处理目录中的文件锁串行化,避免自动索引与评分竞争写入。任务有 queued/running/completed/failed 状态、错误和重试入口。课程资料入库在会话中也作为索引任务执行。若 Redis 不可用,导入数据仍保存,索引任务标记失败并可重试。原始学生文件只从只读 `/data/inbox` 读取,文件 SHA-256 在预览和确认时复查。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd90fe6..f945937 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -117,7 +117,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } useEffect(() => { if (batch && project) load(`/projects/${project}/scores?batch_id=${batch}`, setResult); }, [batch, project]); const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult); return <>逐项评分与教师复核 `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} />{result && <>{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />} row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{r.criterion.name} }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态', render: (_, r) => {r.status} }, { title: '操作', render: (_, r) => }]} />} - setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{ev.quote}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}
{review.score_id && }{revisions.length > 0 &&
{JSON.stringify(revisions, null, 2)}
}}
+ setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{ev.quote}{ev.url && 查看页面文字}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}
{review.score_id && }{revisions.length > 0 &&
{JSON.stringify(revisions, null, 2)}
}}
; } diff --git a/frontend/src/Chat.tsx b/frontend/src/Chat.tsx index e8b2b48..cdbb3be 100644 --- a/frontend/src/Chat.tsx +++ b/frontend/src/Chat.tsx @@ -67,7 +67,9 @@ function ActionCard({ action, conversationId, reload }: { action: any; conversat } {action.kind === 'review' && <>批次 #{preview.batch_id} · {preview.student_no} {preview.student_name} · {preview.criterion_name}({preview.criterion_id}) 原状态:{preview.old_status};模型原始分:{preview.old_model_score ?? '无'};教师原分:{preview.old_teacher_score ?? '无'};新分:{preview.new_score};新评语:{preview.comment};修改原因:{preview.reason}。{preview.evidence_warning && ` 证据/异常:${JSON.stringify(preview.evidence_warning)}`} - {preview.evidence?.map((item: any, index: number) => {item.document} · 第 {item.page} 页{item.quote}{safeUrl(item.url) && 查看页面})}} + {preview.evidence?.map((item: any, index: number) => {item.document} · 第 {item.page} 页{item.quote} + {safeUrl(item.url) && 查看页面文字} + {safeUrl(item.image_url) && 查看图像})}} {action.kind === 'create_batch' && 固定项目清单:{preview.project_ids?.join('、') || '无项目'}({preview.project_ids?.length || 0} 个)} {action.kind === 'course_ingest' && <>入库资料:{preview.files?.map((item: any) => item[0]).join('、') || '无'}。{preview.missing_files?.length > 0 && }} {action.kind === 'start_batch' && 项目范围:{preview.project_ids?.join('、') || '旧批次历史清单未知'};当前状态:{preview.status}} @@ -86,7 +88,9 @@ const safeUrl = (raw: unknown) => { }; const isImageUrl = (href: string) => { const path = new URL(href).pathname; - return /^\/api\/assets\/\d+\/\d+\/?$/.test(path) || /\.(?:png|jpe?g|gif|webp|avif|svg)$/i.test(path); + return /^\/api\/assets\/\d+\/\d+\/?$/.test(path) || + /^\/api\/knowledge\/documents\/\d+\/pages\/\d+\/image\/?$/.test(path) || + /\.(?:png|jpe?g|gif|webp|avif|svg)$/i.test(path); }; const sourceLabel = (href: string) => { const url = new URL(href); @@ -106,12 +110,12 @@ 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 textHref = safeUrl(item.text_url); - const imageHref = safeUrl(item.image_url) || (sourceHref && isImageUrl(sourceHref) ? sourceHref : undefined); + const scoreHref = safeUrl(item.score_url); + const imageHref = safeUrl(item.image_url); return
{name}{place && {place}}
{quote && (quote.length > 320 ?
{quote.slice(0, 240)}… 展开原文

{quote}

:

{quote}

)} - {textHref && textHref !== sourceHref && 查看页面文字} - {sourceHref && sourceHref !== imageHref && {sourceLabel(sourceHref)}} + {sourceHref && {sourceLabel(sourceHref)}} + {scoreHref && scoreHref !== sourceHref && 查看评分} {imageHref && 查看图像}
; })}; } @@ -119,7 +123,7 @@ 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.document_id, item.title || item.document, item.page, item.quote, - item.url, item.text_url, item.image_url]); + item.url, item.score_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 3f74948..f0e9e9b 100644 --- a/frontend/tests/chat.spec.ts +++ b/frontend/tests/chat.spec.ts @@ -6,12 +6,12 @@ async function mockApi(page: Page) { 7: { id: 7, title: '历史会话', course_version_id: 1, batch_id: null, project_id: null, scope_locked: true, messages: [{ id: 1, role: 'user', content: '旧问题' }, { id: 2, role: 'assistant', 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', 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' }, + citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/knowledge/documents/10/pages/2/image' }, + { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/knowledge/documents/10/pages/2/image' }, + { chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/knowledge/documents/12/pages/48', image_url: '/api/knowledge/documents/12/pages/48/image' }, + { title: '已迁移评分证据.pdf', page: 50, quote: '评分证据', url: '/api/knowledge/documents/12/pages/50', image_url: '/api/knowledge/documents/12/pages/50/image' }, + { title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/api/knowledge/documents/12/pages/49', + image_url: '/api/knowledge/documents/12/pages/49/image', score_url: '/?page=scores&batch_id=3&project_id=5' }, { 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页原文片段' }] } }, @@ -125,18 +125,18 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa await expect(page.getByText('第 48 页')).toBeVisible(); const searchCitation = page.locator('.citation-card').filter({ has: page.getByText('资料.pdf', { exact: true }) }); 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(2); - const imageOnly = page.locator('.citation-card').filter({ hasText: '仅图像.pdf' }); - await expect(imageOnly.getByRole('link', { name: '查看图像' })).toBeVisible(); - await expect(imageOnly.getByRole('link')).toHaveCount(1); + await expect(searchCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2\/image$/); + const migrated = page.locator('.citation-card').filter({ hasText: '综合报告.pdf' }); + await expect(migrated.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/48$/); + await expect(migrated.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/48\/image$/); + await expect(migrated.getByRole('link')).toHaveCount(2); + const scoreEvidence = page.locator('.citation-card').filter({ hasText: '已迁移评分证据.pdf' }); + await expect(scoreEvidence.getByRole('link', { name: '查看页面文字' })).toBeVisible(); + await expect(scoreEvidence.getByRole('link', { name: '查看图像' })).toBeVisible(); 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$/); + await expect(scoreCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/12\/pages\/49\/image$/); const textOnly = page.locator('.citation-card').filter({ hasText: '课程资料.pdf' }); await expect(textOnly.getByRole('link', { name: '查看页面文字' })).toBeVisible(); await expect(textOnly.getByRole('link')).toHaveCount(1);