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 引用链接标签
|
## 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 个不同的文字页链接均可打开,且每条引用的摘录都存在于目标页。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。
|
打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。
|
||||||
|
|
||||||
可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。引用按实际目标标注“查看页面文字”“查看图像”或“查看评分”;历史评分引用中只保存了图像地址的记录也标为“查看图像”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。
|
可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。检索引用提供页面文字链接,存在图像时也提供图像链接;评分引用由已保存的证据文档 ID 和页码提供页面文字链接,并保留评分页与图像链接。历史评分引用若只保存了图像地址,读取会话时会用文件 ID、页码和原文摘录匹配评分证据;仅在唯一匹配到现存文字页时补充链接,不改写历史消息。链接按目标标注“查看页面文字”“查看图像”或“查看评分”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。
|
||||||
|
|
||||||
扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。
|
扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 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 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>;
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
Reference in New Issue
Block a user