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