From 92906c82bcbbf858da249c5cabf66eb5b03fa2a4 Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sun, 27 Sep 2026 16:41:03 +0800 Subject: [PATCH] fix: label citation links by their actual target --- HANDOFF.md | 4 ++++ backend/tests/test_chat_agent.py | 7 ++++++- backend/wisegrade/agent_tools.py | 2 +- docs/CHAT_AGENT.md | 2 +- frontend/src/Chat.tsx | 17 +++++++++++++++-- frontend/tests/chat.spec.ts | 31 +++++++++++++++++++++++-------- 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index d0fc3e6..5f697a6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -104,3 +104,7 @@ uv run pytest -q 7 号会话的四次 `get_scores` 实际分别读取评分项偏移量 0、6、12、18,旧工具记录只保存了批次与项目 ID,造成界面上看似相同参数。新记录保存经校验的默认值、查询词、筛选及分页参数;旧评分记录从结果恢复实际偏移量。7 号会话旧检索记录的查询词没有持久化,无法准确还原,界面会显示“历史调用未保存查询词”。同一轮内同名工具的相同有效参数只执行一次,后续调用标为“已跳过”,不同分页或查询正常执行。复核评语与原因继续省略,未知字段及密钥不保存。 隔离 PostgreSQL 测试容器执行 `pytest -q -m 'not integration'`:37 通过、2 个集成测试未运行;末次后端微调另运行针对性测试,1 通过。前端 `npm run build` 通过,Playwright Chromium `npm run test:e2e`:3 通过。测试覆盖注册工具参数字段、查询词与敏感文本边界、相同参数跳过、不同分页和查询正常执行,以及浏览器中旧记录偏移量恢复与参数展示。API 与 Web 镜像已构建并更新,两个容器均健康;正式页面通过全新 Chromium 会话打开 7 号会话,四次评分调用显示偏移量 0、6、12、18,旧检索调用显示未保存查询词提示。 + +## 2026-09-27 引用链接标签 + +检索引用的 `url` 指向页面文字 JSON 接口,`image_url` 指向页面图像;评分引用现在分别保存评分页 `url` 和证据 `image_url`。界面按目标显示“查看页面文字”“查看评分”“查看图像”;旧评分引用中把图像放在 `url` 的记录也能识别,两个字段指向同一图像时只显示一个链接。无需迁移历史数据。后端评分引用针对性测试 1 通过,前端构建通过,Playwright 3 通过。API 与 Web 容器已更新并健康;正式页面检查 7 号会话 74 个图像链接,均显示“查看图像”。 diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index 5e26e1b..79a4210 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -83,7 +83,8 @@ 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", "image_url": None}]} + "quote": "证据" * 200, "document": "报告.pdf", + "image_url": f"/api/assets/{item.id}/1" if index == 0 else None}]} for index in range(14)]} monkeypatch.setattr(agent_tools, "project_result", result) @@ -92,6 +93,10 @@ 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"][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 later = execute(db, conversation, "get_scores", {"batch_id": created["id"], "project_offset": 1, "criterion_offset": 12}, "") diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index 58ae2c8..dade03a 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -285,7 +285,7 @@ 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": evidence["image_url"] or url}) + "url": url, "image_url": evidence["image_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), diff --git a/docs/CHAT_AGENT.md b/docs/CHAT_AGENT.md index bef5423..ad1065e 100644 --- a/docs/CHAT_AGENT.md +++ b/docs/CHAT_AGENT.md @@ -6,7 +6,7 @@ 打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。 -可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和可打开的页面链接。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 +可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和来源链接。引用按实际目标标注“查看页面文字”“查看图像”或“查看评分”;历史评分引用中只保存了图像地址的记录也标为“查看图像”。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。 扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。 diff --git a/frontend/src/Chat.tsx b/frontend/src/Chat.tsx index 86365ab..a8d07da 100644 --- a/frontend/src/Chat.tsx +++ b/frontend/src/Chat.tsx @@ -84,6 +84,17 @@ const safeUrl = (raw: unknown) => { try { const url = new URL(raw, window.location.origin); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined; } catch { return undefined; } }; +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); +}; +const sourceLabel = (href: string) => { + const url = new URL(href); + if (isImageUrl(href)) return '查看图像'; + if (/^\/api\/knowledge\/documents\/\d+\/pages\/\d+\/?$/.test(url.pathname)) return '查看页面文字'; + if (url.pathname === '/' && url.searchParams.get('page') === 'scores') return '查看评分'; + return '打开来源'; +}; function Answer({ text }: { text: string }) { return
safeUrl(url) || ''} components={{ a: ({ href, children }) => safeUrl(href) ? {children} : {children}, @@ -94,10 +105,12 @@ function CitationCards({ items }: { items: any[] }) { return
{items.map((item: any, index) => { const name = String(item.title || item.document || '未命名资料'); 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); return
{name}{place && {place}}
{quote && (quote.length > 320 ?
{quote.slice(0, 240)}… 展开原文

{quote}

:

{quote}

)} - {safeUrl(item.url) && 打开页面} - {safeUrl(item.image_url) && 查看图像}
; + {sourceHref && {sourceLabel(sourceHref)}} + {imageHref && imageHref !== sourceHref && 查看图像}
; })}
; } function Citations({ items }: { items: any[] }) { diff --git a/frontend/tests/chat.spec.ts b/frontend/tests/chat.spec.ts index 199bb87..f966b88 100644 --- a/frontend/tests/chat.spec.ts +++ b/frontend/tests/chat.spec.ts @@ -6,8 +6,11 @@ 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)) \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/pages/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }, - { chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/pages/48' }], + 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' }, + { 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页原文片段' }] } }, { id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, omitted_text_fields: 1 }, @@ -93,8 +96,8 @@ 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('引用 · 2')).toBeVisible(); - await expect(page.getByText('资料.pdf')).not.toBeVisible(); + await expect(page.getByText('引用 · 4')).toBeVisible(); + await expect(page.getByText('资料.pdf', { exact: true })).not.toBeVisible(); await expect(page.getByText('后台进度')).toHaveCount(0); await expect(page.locator('.chat-tools')).toBeVisible(); await expect(page.locator('.chat-tool').first()).not.toBeVisible(); @@ -116,8 +119,20 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa await expect(page.locator('.chat-tool').nth(3).locator('summary')).toContainText('已跳过'); await page.locator('.chat-citation-group > summary').click(); await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', ''); - await expect(page.getByText('资料.pdf')).toBeVisible(); + await expect(page.getByText('资料.pdf', { exact: true })).toBeVisible(); 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\/assets\/5\/48$/); + await expect(legacyImage.getByRole('link')).toHaveCount(1); + const scoreCitation = page.locator('.citation-card').filter({ hasText: '评分报告.pdf' }); + 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' }); + await expect(textOnly.getByRole('link', { name: '查看页面文字' })).toBeVisible(); + await expect(textOnly.getByRole('link')).toHaveCount(1); const positions = await page.evaluate(() => { const card = document.querySelector('.chat-assistant .ant-card-body'); return ['.chat-tools', '.chat-markdown', '.chat-citations'].map(selector => @@ -137,7 +152,7 @@ test('switching sessions leaves runs independent and updates each status', async await page.goto('/?page=chat'); await page.getByPlaceholder(/例如/).fill('甲'); await page.getByRole('button', { name: /发\s*送/ }).click(); - await expect(page.getByText('甲', { exact: true })).toBeVisible(); + await expect(page.locator('.chat-user .chat-content')).toHaveText('甲'); await expect(page.getByText('生成中').first()).toBeVisible(); await page.getByRole('button', { name: /^删\s*除$/ }).last().click(); await page.getByRole('button', { name: /删\s*除\s*会\s*话/ }).click(); @@ -148,12 +163,12 @@ test('switching sessions leaves runs independent and updates each status', async await page.getByRole('button', { name: /新\s*建/ }).click(); await page.getByPlaceholder(/例如/).fill('乙'); await page.getByRole('button', { name: /发\s*送/ }).click(); - await expect(page.getByText('乙', { exact: true })).toBeVisible(); + await expect(page.locator('.chat-user .chat-content')).toHaveText('乙'); await expect(page.getByText('生成中').first()).toBeVisible(); expect(state.history[8].runs[0].status).toBe('running'); state.history[8].runs[0].status = 'completed'; state.history[8].messages.push({ id: 82, role: 'assistant', content: '回答甲', citations: [], tool_calls: [] }); - await page.getByText('甲', { exact: true }).first().click(); + await page.locator('.chat-session').filter({ has: page.getByText('甲', { exact: true }) }).click(); await expect(page.getByText('回答甲')).toBeVisible(); expect(state.history[9].runs[0].status).toBe('running'); expect(state.calls.filter(c => c === 'POST /conversations/first-message')).toHaveLength(2);