feat: unify scoring and chat evidence pages
This commit is contained in:
+9
-3
@@ -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 大块提示。
|
||||
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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 在预览和确认时复查。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user