feat: unify scoring and chat evidence pages

This commit is contained in:
2026-09-27 17:47:32 +08:00
parent 039e93d0de
commit 83aa0e84a2
21 changed files with 511 additions and 132 deletions
+9 -3
View File
@@ -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 大块提示。
+2
View File
@@ -29,3 +29,5 @@ WiseGrade 是面向《自动化专业综合实践》课程的教师端自动评
3. 运行 `docker compose up`,访问 <http://127.0.0.1:18080>。
打开系统默认进入空白“会话”草稿;点击“新建”也只切换草稿,发送第一条消息才保存会话。历史会话可从列表继续或删除;回答上方可逐层展开工具调用,末尾可展开引用原文与页面链接。查询可直接执行,导入、入库、建批、启动、复核和导出先展示确认卡片,由教师点击后执行。学生材料只从只读挂载的 `/data/inbox` 读取。没有本地嵌入模型或 API 密钥时,系统仍可启动和完成导入;索引、会话或评分会显示明确错误。详细部署和使用步骤见 [部署与使用指南](docs/DEPLOYMENT.md)。
评分证据与会话检索引用共用文档 ID 和页码。页面文字来自 PostgreSQL 的完整资料页记录,检索片段与向量也在 PostgreSQL;页面 PNG 位于 `data/processed/`,可从引用的“查看图像”打开。数据库直接生成的课程目标和规则没有实体页图像,因此只显示“查看页面文字”。历史引用由迁移 `0005` 一次性规范化。
@@ -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")
+42 -1
View File
@@ -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:
+45 -6
View File
@@ -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)
+84 -24
View File
@@ -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"
+1 -1
View File
@@ -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"
+3 -1
View File
@@ -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):
+2 -1
View File
@@ -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":
+6 -7
View File
@@ -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),
+16 -6
View File
@@ -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}")
+4 -49
View File
@@ -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)
+8 -4
View File
@@ -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}
+53
View File
@@ -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)
+8
View File
@@ -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)
+6 -1
View File
@@ -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))
+10 -3
View File
@@ -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
+2 -2
View File
@@ -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 在预览和确认时复查。
+1 -1
View File
@@ -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 <><Title level={2}>逐项评分与教师复核</Title><Space wrap><Select style={{ width: 180 }} placeholder="评分批次" value={batch} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatch} /><Select style={{ width: 330 }} placeholder="学生项目" value={project} options={options(projects, p => `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} /></Space>{result && <><Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{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) => <Tag color={r.status === 'scored' || r.status === 'reviewed' ? 'green' : 'orange'}>{r.status}</Tag> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue({ score: r.final_score ?? 0, comment: r.teacher_comment || r.model_comment || '', reason: '' }); }}>查看证据/复核</Button> }]} /></>}
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => 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 && <><Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '—'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Paragraph>{ev.quote}</Paragraph>{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true }]}><InputNumber min={0} max={review.criterion.max_score} /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal>
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => 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 && <><Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '—'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Paragraph>{ev.quote}</Paragraph><Space wrap>{ev.url && <a href={ev.url} target="_blank" rel="noreferrer">查看页面文字</a>}{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Space></Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true }]}><InputNumber min={0} max={review.criterion.max_score} /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal>
</>;
}
+11 -7
View File
@@ -67,7 +67,9 @@ function ActionCard({ action, conversationId, reload }: { action: any; conversat
<ImportEditor action={action} conversationId={conversationId} reload={reload} /></>}
{action.kind === 'review' && <><Paragraph>批次 #{preview.batch_id} · {preview.student_no} {preview.student_name} · {preview.criterion_name}({preview.criterion_id})</Paragraph>
<Paragraph>原状态:{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)}`}</Paragraph>
{preview.evidence?.map((item: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{item.document} · 第 {item.page} 页</Text><Paragraph>{item.quote}</Paragraph>{safeUrl(item.url) && <a href={safeUrl(item.url)} target="_blank" rel="noopener noreferrer">查看页面</a>}</Card>)}</>}
{preview.evidence?.map((item: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{item.document} · 第 {item.page} 页</Text><Paragraph>{item.quote}</Paragraph>
<Space wrap>{safeUrl(item.url) && <a href={safeUrl(item.url)} target="_blank" rel="noopener noreferrer">查看页面文字</a>}
{safeUrl(item.image_url) && <a href={safeUrl(item.image_url)} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></Card>)}</>}
{action.kind === 'create_batch' && <Paragraph>固定项目清单:{preview.project_ids?.join('、') || '无项目'}({preview.project_ids?.length || 0} 个)</Paragraph>}
{action.kind === 'course_ingest' && <><Paragraph>入库资料:{preview.files?.map((item: any) => item[0]).join('、') || '无'}。</Paragraph>{preview.missing_files?.length > 0 && <Alert type="warning" message={`资料文件缺失:${preview.missing_files.join('、')}`} />}</>}
{action.kind === 'start_batch' && <Paragraph>项目范围:{preview.project_ids?.join('、') || '旧批次历史清单未知'};当前状态:{preview.status}</Paragraph>}
@@ -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 <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div>
{quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
<Space wrap size="small">{textHref && textHref !== sourceHref && <a href={textHref} target="_blank" rel="noopener noreferrer">查看页面文字</a>}
{sourceHref && sourceHref !== imageHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
<Space wrap size="small">{sourceHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
{scoreHref && scoreHref !== sourceHref && <a href={scoreHref} target="_blank" rel="noopener noreferrer">查看评分</a>}
{imageHref && <a href={imageHref} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>;
})}</div>;
}
@@ -119,7 +123,7 @@ function Citations({ items }: { items: any[] }) {
const seen = new Set<string>();
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 <section className="chat-citations" aria-label="引用资料"><details className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
+15 -15
View File
@@ -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)) <img src=x onerror=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);