fix: link score citations to their source text pages

This commit is contained in:
2026-09-27 17:15:55 +08:00
parent 92906c82bc
commit 039e93d0de
9 changed files with 143 additions and 18 deletions
+4
View File
@@ -108,3 +108,7 @@ uv run pytest -q
## 2026-09-27 引用链接标签 ## 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`。界面按目标显示“查看页面文字”“查看评分”“查看图像”;旧评分引用中把图像放在 `url` 的记录也能识别,两个字段指向同一图像时只显示一个链接。无需迁移历史数据。后端评分引用针对性测试 1 通过,前端构建通过,Playwright 3 通过。API 与 Web 容器已更新并健康;正式页面检查 7 号会话 74 个图像链接,均显示“查看图像”。
## 2026-09-27 评分引用文字页补全
评分证据 `ScoreEvidence` 原已保存 `document_id` 和 `page`,前一版遗漏了这条来源链。现在 `project_result` 将文档 ID 交给 `get_scores`,新回答引用保存 `text_url`;评分页链接和图像链接仍各自保留。历史会话详情按文件 ID、页码、原文摘录与评分证据匹配,并核对当前知识片段包含该摘录;唯一匹配时只在响应中补充文字页链接,不改写数据库,无法确认来源时保持原状。隔离测试容器 `pytest -q -m 'not integration'` 为 38 通过、2 个集成测试未运行;前端构建和 Playwright 3 项测试通过。API 与 Web 容器已更新并健康。正式会话 7 的 74 条引用在浏览器中均显示文字和图像链接;其中 41 条旧评分引用经后端恢复,26 个不同的文字页链接均可打开,且每条引用的摘录都存在于目标页。
+2 -1
View File
@@ -83,7 +83,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
"rows": [{"criterion": {"criterion_id": f"C{index}", "name": f"评分项{index}", "max_score": 10}, "rows": [{"criterion": {"criterion_id": f"C{index}", "name": f"评分项{index}", "max_score": 10},
"score_id": index, "model_score": 8, "teacher_score": None, "final_score": 8, "score_id": index, "model_score": 8, "teacher_score": None, "final_score": 8,
"status": "scored", "error": "", "evidence": [{"page": index + 1, "status": "scored", "error": "", "evidence": [{"page": index + 1,
"quote": "证据" * 200, "document": "报告.pdf", "quote": "证据" * 200, "document": "报告.pdf", "document_id": index + 100,
"image_url": f"/api/assets/{item.id}/1" if index == 0 else None}]} "image_url": f"/api/assets/{item.id}/1" if index == 0 else None}]}
for index in range(14)]} for index in range(14)]}
@@ -95,6 +95,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1 assert first_page["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]["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]["image_url"] == f"/api/assets/{first.id}/1"
assert first_page["citations"][0]["text_url"] == "/api/knowledge/documents/100/pages/1"
assert first_page["citations"][1]["url"] == first_page["citations"][0]["url"] assert first_page["citations"][1]["url"] == first_page["citations"][0]["url"]
assert first_page["citations"][1]["image_url"] is None assert first_page["citations"][1]["image_url"] is None
assert len(json.dumps(first_page, ensure_ascii=False)) < 12000 assert len(json.dumps(first_page, ensure_ascii=False)) < 12000
+63 -2
View File
@@ -2,15 +2,18 @@
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from threading import Event from threading import Event
from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import func, select from sqlalchemy import func, select
from wisegrade import chat_api from wisegrade import agent_tools, chat_api, services
from wisegrade.db import get_db from wisegrade.db import get_db
from wisegrade.main import app from wisegrade.main import app
from wisegrade.models import AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, PendingAction, Project, ScoringBatch, Student, now from wisegrade.models import (AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, CriterionScore,
KnowledgeChunk, KnowledgeDocument, PendingAction, Project, ScoreEvidence,
ScoringBatch, Student, SubmissionFile, now)
def client_for(session_factory): def client_for(session_factory):
@@ -176,6 +179,64 @@ def test_tool_record_keeps_bounded_query_but_redacts_review_text_and_secrets():
assert 'secret' not in str(search) assert 'secret' not in str(search)
def test_score_citations_link_text_and_restore_legacy_image_only(session_factory):
with session_factory() as db:
student = Student(student_no="987654320", name="测试学生")
db.add(student)
db.flush()
project = Project(course_version_id=1, student_id=student.id, topic="测试项目", source_dir="test/citation")
db.add(project)
db.flush()
file = SubmissionFile(project_id=project.id, category="final_report", relative_path="test/citation/report.pdf",
mime_type="application/pdf", size=1, mtime=1, sha256="a" * 64,
recognition_status="recognized", parse_status="ready")
db.add(file)
db.flush()
document = KnowledgeDocument(course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", document_type="final_report", source_hash="a" * 64,
index_version=2, status="ready")
db.add(document)
db.flush()
db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=project.id,
document_type="final_report", page=2, section="测试", text="这里有原文证据和分析"))
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
batch = db.get(ScoringBatch, created["id"])
score = CriterionScore(batch_id=batch.id, project_id=project.id,
criterion_id=batch.rubric_snapshot["criteria"][0]["criterion_id"],
model_score=8, status="scored")
db.add(score)
db.flush()
db.add(ScoreEvidence(score_id=score.id, document_id=document.id, submission_file_id=file.id,
page=2, quote="原文证据", image_path="page-2.png"))
conversation = Conversation(title="旧评分引用", course_version_id=1, batch_id=batch.id, project_id=project.id)
db.add(conversation)
db.flush()
image_url = f"/api/assets/{file.id}/2"
db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[
{"title": "报告.pdf", "page": 2, "quote": "原文证据", "url": image_url},
{"title": "报告.pdf", "page": 2, "quote": "不匹配的引文", "url": image_url}]))
db.commit()
expected = f"/api/knowledge/documents/{document.id}/pages/2"
generated = agent_tools.execute(db, conversation, "get_scores",
{"batch_id": batch.id, "project_id": project.id}, "")
assert generated["citations"][0]["text_url"] == expected
assert chat_api._answer_citations(generated["citations"])[0]["text_url"] == expected
assert generated["citations"][0]["image_url"] == image_url
conversation_id = conversation.id
client = client_for(session_factory)
try:
detail = client.get(f"/api/conversations/{conversation_id}").json()
citations = detail["messages"][0]["citations"]
assert citations[0]["text_url"] == expected
assert "text_url" not in citations[1]
assert "原文证据" in client.get(expected).json()["text"]
with session_factory() as db:
stored = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id))
assert "text_url" not in stored.citations[0]
finally:
app.dependency_overrides.clear()
def test_late_citations_and_search_snippets_survive_restoration(session_factory, monkeypatch): def test_late_citations_and_search_snippets_survive_restoration(session_factory, monkeypatch):
class FakeClient: class FakeClient:
def tool_call(self, messages, _tools): def tool_call(self, messages, _tools):
+3 -1
View File
@@ -285,7 +285,9 @@ def scores(db, convo, args, _user):
for evidence in row["evidence"][:2]: for evidence in row["evidence"][:2]:
citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"], citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"],
"quote": evidence["quote"][:320], "quote": evidence["quote"][:320],
"url": url, "image_url": evidence["image_url"]}) "url": url, "image_url": evidence["image_url"],
"text_url": f"/api/knowledge/documents/{evidence['document_id']}/pages/{evidence['page']}"
if evidence.get("document_id") else None})
next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None next_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 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), return {"batch_id": batch.id, "status_filter": args.status, "count": len(results),
+51 -5
View File
@@ -1,6 +1,8 @@
"""Persistent teacher conversations and SSE agent orchestration.""" """Persistent teacher conversations and SSE agent orchestration."""
import json import json
import re
import time import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta from datetime import timedelta
from threading import Lock from threading import Lock
@@ -18,7 +20,7 @@ from .agent_tools import TOOLS, execute, tool_specs
from .config import get_settings from .config import get_settings
from .db import get_db from .db import get_db
from .llm import ModelError from .llm import ModelError
from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now from .models import AgentRun, ChatMessage, Conversation, KnowledgeChunk, PendingAction, Project, ScoreEvidence, ScoringBatch, now
from .settings_store import client_for_chat from .settings_store import client_for_chat
router = APIRouter(prefix="/api/conversations") router = APIRouter(prefix="/api/conversations")
@@ -88,6 +90,48 @@ def _validated_scope(db, data):
return scope 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): def _detail(db, conversation):
stale = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id, stale = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id,
AgentRun.status == "running", AgentRun.created_at < now() - timedelta(minutes=15))).all() AgentRun.status == "running", AgentRun.created_at < now() - timedelta(minutes=15))).all()
@@ -99,9 +143,11 @@ def _detail(db, conversation):
db.commit() db.commit()
runs = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id).order_by(AgentRun.id.desc()).limit(20)).all() 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() 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() 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 return {**_summary(conversation), "messages": [{**{key: getattr(msg, key) for key in
("id", "role", "content", "citations", "tool_calls", "created_at")} for msg in messages], ("id", "role", "content", "tool_calls", "created_at")},
"citations": restored_citations.get(msg.id, msg.citations)} for msg in messages],
"actions": [action_dict(action) for action in actions], "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], "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)} "scope_locked": any(msg.role == "user" for msg in messages)}
@@ -219,7 +265,7 @@ def _safe_citation(item):
if not isinstance(item, dict): if not isinstance(item, dict):
return None return None
return {key: value[:limit] if isinstance(value, str) else value for key, limit in return {key: value[:limit] if isinstance(value, str) else value for key, limit in
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500)) (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("text_url", 500))
if isinstance((value := item.get(key)), str)} | { if isinstance((value := item.get(key)), str)} | {
key: item[key] for key in ("chunk_id", "document_id", "page", "project_id") key: item[key] for key in ("chunk_id", "document_id", "page", "project_id")
if isinstance(item.get(key), int)} if isinstance(item.get(key), int)}
@@ -234,7 +280,7 @@ def _answer_citations(items):
if not safe: if not safe:
continue continue
key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"), key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"),
safe.get("page"), safe.get("quote"), safe.get("url")) safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("text_url"))
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
citations.append(safe) citations.append(safe)
+1
View File
@@ -29,6 +29,7 @@ def project_result(db, batch: ScoringBatch, project: Project):
"missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "", "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 "", "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, "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 "课程资料", "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]}) "image_url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} for item in evidence]})
student = db.get(Student, project.student_id) student = db.get(Student, project.student_id)
+1 -1
View File
@@ -6,7 +6,7 @@
打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。 打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。
可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。引用按实际目标标注“查看页面文字”“查看图像”或“查看评分”;历史评分引用中只保存了图像地址的记录也标为“查看图像”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。检索引用提供页面文字链接,存在图像时也提供图像链接;评分引用由已保存的证据文档 ID 和页码提供页面文字链接,并保留评分页与图像链接。历史评分引用若只保存了图像地址,读取会话时会用文件 ID、页码和原文摘录匹配评分证据;仅在唯一匹配到现存文字页时补充链接,不改写历史消息。链接按目标标注“查看页面文字”“查看图像”或“查看评分”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。
扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。 扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。
+7 -4
View File
@@ -106,17 +106,20 @@ function CitationCards({ items }: { items: any[] }) {
const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`; const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`;
const quote = typeof item.quote === 'string' ? item.quote.trim() : ''; const quote = typeof item.quote === 'string' ? item.quote.trim() : '';
const sourceHref = safeUrl(item.url); const sourceHref = safeUrl(item.url);
const imageHref = safeUrl(item.image_url); const textHref = safeUrl(item.text_url);
const imageHref = safeUrl(item.image_url) || (sourceHref && isImageUrl(sourceHref) ? sourceHref : undefined);
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div> 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>)} {quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
<Space wrap size="small">{sourceHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>} <Space wrap size="small">{textHref && textHref !== sourceHref && <a href={textHref} target="_blank" rel="noopener noreferrer">查看页面文字</a>}
{imageHref && imageHref !== sourceHref && <a href={imageHref} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>; {sourceHref && sourceHref !== imageHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
{imageHref && <a href={imageHref} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>;
})}</div>; })}</div>;
} }
function Citations({ items }: { items: any[] }) { function Citations({ items }: { items: any[] }) {
const seen = new Set<string>(); const seen = new Set<string>();
const unique = (items || []).filter(item => { if (!item || typeof item !== 'object') return false; const unique = (items || []).filter(item => { if (!item || typeof item !== 'object') return false;
const key = JSON.stringify([item.title || item.document, item.page, item.quote, item.url, item.image_url]); const key = JSON.stringify([item.document_id, item.title || item.document, item.page, item.quote,
item.url, item.text_url, item.image_url]);
if (seen.has(key)) return false; seen.add(key); return true; }); if (seen.has(key)) return false; seen.add(key); return true; });
if (!unique.length) return null; 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>; return <section className="chat-citations" aria-label="引用资料"><details className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
+11 -4
View File
@@ -8,8 +8,10 @@ async function mockApi(page: Page) {
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```', 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' }, 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' }, { 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' }, { 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: 49, quote: '评分原文片段', url: '/?page=scores&batch_id=3&project_id=5', image_url: '/api/assets/5/49' }, { title: '仅图像.pdf', page: 50, quote: '未匹配片段', url: '/api/assets/5/50' },
{ title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/?page=scores&batch_id=3&project_id=5',
image_url: '/api/assets/5/49', text_url: '/api/knowledge/documents/12/pages/49' },
{ title: '课程资料.pdf', page: 5, quote: '课程说明', url: '/api/knowledge/documents/11/pages/5' }], { 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 }, 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页原文片段' }] } }, summary: '找到 1 条引用', result: { found: true, citations: [{ page: 48, quote: '第48页原文片段' }] } },
@@ -96,7 +98,7 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
await expect(page.locator('.chat-markdown pre')).toBeVisible(); await expect(page.locator('.chat-markdown pre')).toBeVisible();
await expect(page.locator('.chat-markdown img')).toHaveCount(0); await expect(page.locator('.chat-markdown img')).toHaveCount(0);
await expect(page.getByText('危险链接')).not.toHaveAttribute('href'); await expect(page.getByText('危险链接')).not.toHaveAttribute('href');
await expect(page.getByText('引用 · 4')).toBeVisible(); await expect(page.getByText('引用 · 5')).toBeVisible();
await expect(page.getByText('资料.pdf', { exact: true })).not.toBeVisible(); await expect(page.getByText('资料.pdf', { exact: true })).not.toBeVisible();
await expect(page.getByText('后台进度')).toHaveCount(0); await expect(page.getByText('后台进度')).toHaveCount(0);
await expect(page.locator('.chat-tools')).toBeVisible(); await expect(page.locator('.chat-tools')).toBeVisible();
@@ -125,9 +127,14 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
await expect(searchCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2$/); await expect(searchCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2$/);
await expect(searchCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/2$/); await expect(searchCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/2$/);
const legacyImage = page.locator('.citation-card').filter({ hasText: '综合报告.pdf' }); 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', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/48$/);
await expect(legacyImage.getByRole('link')).toHaveCount(1); await expect(legacyImage.getByRole('link')).toHaveCount(2);
const imageOnly = page.locator('.citation-card').filter({ hasText: '仅图像.pdf' });
await expect(imageOnly.getByRole('link', { name: '查看图像' })).toBeVisible();
await expect(imageOnly.getByRole('link')).toHaveCount(1);
const scoreCitation = page.locator('.citation-card').filter({ hasText: '评分报告.pdf' }); 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', /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\/assets\/5\/49$/);
const textOnly = page.locator('.citation-card').filter({ hasText: '课程资料.pdf' }); const textOnly = page.locator('.citation-card').filter({ hasText: '课程资料.pdf' });