Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
pipelines,
projects,
rag,
rewrite,
search,
settings_api,
subscription,
Expand Down Expand Up @@ -41,4 +42,5 @@
api_router.include_router(settings_api.router)
api_router.include_router(conversations.router)
api_router.include_router(chat.router)
api_router.include_router(rewrite.router)
api_router.include_router(pipelines.router)
25 changes: 23 additions & 2 deletions backend/app/api/v1/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import json
import logging
import uuid
Expand All @@ -15,6 +16,7 @@
from app.api.deps import get_db
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.paper import Paper
from app.schemas.conversation import ChatStreamRequest
from app.services.llm.client import LLMClient, get_llm_client
from app.services.rag_service import RAGService
Expand Down Expand Up @@ -79,13 +81,21 @@ async def _stream_chat(
citations = []

if request.knowledge_base_ids:
for kb_id in request.knowledge_base_ids:
result = await rag.query(
rag_tasks = [
rag.query(
project_id=kb_id,
question=request.message,
top_k=5,
include_sources=True,
)
for kb_id in request.knowledge_base_ids
]
results = await asyncio.gather(*rag_tasks, return_exceptions=True)

for result in results:
if isinstance(result, Exception):
logger.warning("RAG query failed for a KB: %s", result)
continue
if result.get("sources"):
all_sources.extend(result["sources"])
for src in result["sources"]:
Expand All @@ -94,14 +104,25 @@ async def _stream_chat(
f"p.{src.get('page_number', '?')}]\n{src.get('excerpt', '')}"
)

paper_ids = list({pid for pid in (src.get("paper_id") for src in all_sources) if pid is not None})
papers_by_id: dict[int, Paper] = {}
if paper_ids:
result = await db.execute(select(Paper).where(Paper.id.in_(paper_ids)))
papers_by_id = {p.id: p for p in result.scalars().all()}

for i, src in enumerate(all_sources, 1):
paper = papers_by_id.get(src.get("paper_id")) if src.get("paper_id") else None
citation = {
"index": i,
"paper_id": src.get("paper_id"),
"paper_title": src.get("paper_title", ""),
"page_number": src.get("page_number"),
"excerpt": src.get("excerpt", ""),
"relevance_score": src.get("relevance_score", 0),
"chunk_type": src.get("chunk_type", "text"),
"authors": paper.authors if paper else None,
"year": paper.year if paper else None,
"doi": paper.doi if paper else None,
}
citations.append(citation)
yield _sse("citation", citation)
Expand Down
128 changes: 128 additions & 0 deletions backend/app/api/v1/rewrite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Rewrite API — SSE endpoint for streaming excerpt rewriting."""

from __future__ import annotations

import asyncio
import json
import logging
from typing import Literal

from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, field_validator, model_validator
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_db
from app.services.llm.client import LLMClient, get_llm_client
from app.services.user_settings_service import UserSettingsService

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/chat", tags=["rewrite"])

_rewrite_semaphore = asyncio.Semaphore(3)

REWRITE_PROMPTS: dict[str, str] = {
"simplify": (
"Rewrite the following academic text in plain, accessible language. "
"Keep the core meaning and key concepts intact, but make it understandable "
"to a general audience. Output only the rewritten text, no explanations."
),
"academic": (
"Rewrite the following text in formal academic style. "
"Use precise terminology, passive voice where appropriate, and proper "
"academic conventions. Maintain the original meaning. Output only the rewritten text."
),
"translate_en": (
"Translate the following text into English. "
"Preserve academic terminology and the original meaning. "
"Output only the translation, no explanations."
),
"translate_zh": ("将以下文本翻译为中文。保留学术术语和原意。仅输出翻译结果,不要添加解释。"),
}

REWRITE_TIMEOUT = 30.0


class RewriteRequest(BaseModel):
excerpt: str
style: Literal["simplify", "academic", "translate_en", "translate_zh", "custom"]
custom_prompt: str | None = None
source_language: str = "auto"

@field_validator("excerpt")
@classmethod
def excerpt_max_length(cls, v: str) -> str:
if len(v) > 2000:
raise ValueError("excerpt must not exceed 2000 characters")
return v

@model_validator(mode="after")
def custom_requires_prompt(self) -> RewriteRequest:
if self.style == "custom" and not self.custom_prompt:
raise ValueError("custom_prompt required when style is 'custom'")
return self


def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"


async def _stream_rewrite(request: RewriteRequest, db: AsyncSession):
"""Generator that yields SSE events for the rewrite stream."""
try:
async with _rewrite_semaphore:
svc = UserSettingsService(db)
config = await svc.get_merged_llm_config()
llm = get_llm_client(config=config)

system_prompt = request.custom_prompt or "" if request.style == "custom" else REWRITE_PROMPTS[request.style]

messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": request.excerpt},
]

full_text = ""
try:
async for token in asyncio.wait_for(
_collect_stream(llm, messages),
timeout=REWRITE_TIMEOUT,
):
full_text += token
yield _sse("rewrite_delta", {"delta": token})
except TimeoutError:
yield _sse("error", {"code": "timeout", "message": "Rewrite timed out after 30s"})
return

yield _sse("rewrite_end", {"full_text": full_text})

except asyncio.CancelledError:
logger.info("Rewrite stream cancelled by client")
return
except Exception as e:
logger.exception("Rewrite stream error")
yield _sse("error", {"code": "rewrite_error", "message": str(e)})


async def _collect_stream(llm: LLMClient, messages: list[dict[str, str]]):
"""Wrap the async iterator so asyncio.wait_for can timeout the whole stream."""
async for token in llm.chat_stream(messages, temperature=0.3, task_type="rewrite"):
yield token


@router.post("/rewrite")
async def rewrite_stream(
request: RewriteRequest,
db: AsyncSession = Depends(get_db),
):
"""SSE streaming rewrite endpoint — rewrites an excerpt in the chosen style."""
return StreamingResponse(
_stream_rewrite(request, db),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "omelette-backend"
version = "0.1.0"
version = "0.2.0"
description = "Scientific Literature Lifecycle Management System / 科研文献全生命周期管理系统"
readme = "README.md"
license = {text = "MIT"}
Expand Down
177 changes: 177 additions & 0 deletions docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
date: 2026-03-12
topic: rich-citation-rewrite
---

# 知识库检索增强:富媒体引用展示、内容重写与体验优化

## 我们要构建什么

在现有 Chat Playground 中全面升级知识库检索体验,涵盖四个维度:

1. **丰富引用卡片** — 将简陋的引用列表升级为可展开的富媒体引用卡片,展示原文片段、来源信息、相关度分数
2. **片段重写与对比** — 引用卡片上可对原文片段进行多风格重写(简化/学术/翻译/自定义),重写结果以对比形式展示
3. **内联标注与联动** — AI 回答中的引用标记 `[1][2]` 变为可交互元素,hover 预览原文,颜色区分来源
4. **富媒体渲染与体验优化** — 引入 A2UI 协议实现 LLM 驱动的富媒体 UI、美化聊天界面加载动画、并行 LLM 调用提速

## 为什么选择渐进增强方案

考虑过三种方案:

- **渐进增强现有消息(选定)**:改动最小,复用现有架构,用户无需学习新界面,每步迭代都能独立上线
- **独立工作面板**:空间充裕但开发量大,偏离聊天范式
- **新工具模式**:架构一致但需定义新 SSE 事件,第一步过重

渐进增强方案符合 YAGNI 原则,从改善引用卡片这个"最小有用增量"开始,后续逐步引入富媒体和性能优化。

## 关键决策

- **入口位置**:在现有 Playground 聊天界面中增强,不新建页面
- **第一步优先级**:丰富引用卡片展示(展示 excerpt、来源信息、相关度)
- **重写风格**:灵活选择,支持简化、学术改写、翻译、自定义 prompt
- **可视化方式**:综合使用卡片展开、左右对比、内联高亮,按步骤逐步引入
- **数据基础**:后端已返回 `excerpt` 和 `relevance_score`,前端目前未使用,第一步零后端改动
- **富媒体协议**:采用 Google A2UI 协议实现 LLM 驱动的声明式 UI 渲染
- **性能优化**:并行 LLM 调用,优化流式传输协议

## 分步实施细节

### 第一步:丰富引用卡片 + A2UI 基础设施 + 聊天 UI 美化

**现状问题:**
- 后端 citation 返回 `excerpt`(原文片段),前端类型定义为 `snippet` 但未展示
- 引用列表只显示 `[index] paper_title (p.page_number)`,信息量不足
- 引用不可交互,无法查看原文内容
- 聊天加载动画简陋(仅打字机光标),缺少视觉反馈

**目标效果:**
- 引用卡片默认折叠,展示:序号、论文标题、页码、相关度 badge
- 点击展开后展示:原文片段(excerpt)高亮显示、论文元数据(作者/年份/DOI)
- 相关度用颜色梯度表示(高→绿,中→黄,低→灰)
- 卡片底部预留"重写"按钮位置(第二步激活)
- 新加载动画:思考中骨架屏 + 引用卡片渐入动画 + 流式文字渲染优化
- A2UI 基础设施就绪,引用卡片优先使用 A2UI 渲染

**技术要点:**
- 安装 `@a2ui/react` + `@a2ui/web-lib`,搭建 A2UI 渲染管线
- 定义 `CitationCard` 为 A2UI 组件目录中的首个自定义组件
- 统一前后端字段:`snippet` → `excerpt`
- 前端 `Citation` 类型增加 `excerpt`、`relevance_score`、`authors`、`year`、`doi` 字段
- `MessageBubble` 增加 A2UI 分支:收到 A2UI 消息用渲染器,否则降级 Markdown
- 加载动画使用 CSS `@keyframes` + `transition`,实现骨架屏 → 内容渐入过渡

### 第二步:片段重写与对比

**目标效果:**
- 引用卡片上增加"重写"按钮,点击弹出重写风格选择器
- 重写风格:简化、学术改写、中英互译、自定义 prompt
- 重写结果以 diff 对比视图展示:左侧原文、右侧重写,差异高亮
- 重写结果可复制、可收藏

**技术要点:**
- 新增后端 API:`POST /api/v1/chat/rewrite`,接收 excerpt + style,返回重写结果
- 前端新建 `RewritePanel` 组件,使用 diff 库做文本对比
- 重写调用使用 SSE 流式输出

### 第三步:内联标注与联动

**目标效果:**
- AI 回答正文中的 `[1]`、`[2]` 解析为可交互标签
- Hover 标签弹出 popover 预览原文片段
- 不同来源用颜色区分(最多 5-6 种颜色循环)
- 点击标签滚动到对应引用卡片

**技术要点:**
- 自定义 ReactMarkdown 插件,解析 `[数字]` 模式
- 使用 Radix Tooltip/Popover 做预览
- 颜色映射跟随 citation index

### 第四步:A2UI 富媒体渲染 + 并行 LLM 优化

#### 4A. A2UI 协议集成

**背景:** Google A2UI(v0.8 稳定版)是一个声明式 UI 协议——AI Agent 生成 JSON 描述组件结构,客户端使用原生组件安全渲染,无需执行任意代码。

**为什么适合 Omelette:**
- 当前聊天只能输出 Markdown 文本,无法渲染图表、交互式表格、知识图谱等
- A2UI 让 LLM 能"画 UI":比如检索结果可以渲染为交互式论文关系图、对比表格、时间线视图
- 声明式 JSON 格式对 LLM 友好,支持流式渐进渲染,与现有 SSE 架构兼容
- React 渲染器已稳定(`@a2ui/react`),可直接集成

**目标效果:**
- LLM 可根据查询类型动态选择输出格式:纯文本、引用卡片、对比表格、关系图
- 用户看到的不仅是文字回答,还有交互式的可视化组件
- 组件可点击、展开、排序,与底层数据绑定

**技术要点:**
- 安装 `@a2ui/react` + `@a2ui/web-lib`
- 定义 Omelette 组件目录(Component Catalog):CitationCard、ComparisonTable、PaperTimeline、KnowledgeGraph 等
- 扩展 SSE 事件:新增 `a2ui_surface` 事件类型,传输 A2UI JSON 消息
- 后端 LLM prompt 中注入 A2UI 组件 schema,让模型能生成结构化 UI 描述
- `MessageBubble` 增加分支:收到 A2UI 消息时用 A2UI 渲染器,否则降级为 Markdown

#### 4B. 并行 LLM 调用优化

**背景:** 当前某些场景(如多知识库检索、重写+摘要同时进行)需要多次串行 LLM 调用,用户等待时间长。

**优化方向:**

| 策略 | 适用场景 | 实现方式 |
|------|---------|---------|
| **asyncio 并发** | 多知识库同时检索 | `asyncio.gather()` 并发调用 RAG |
| **多流合并** | 检索+生成同步进行 | 后端并行发起 RAG 检索和 LLM 生成,通过 SSE 多事件类型分流 |
| **gRPC 双向流** | 高吞吐、低延迟场景 | 替代 HTTP SSE,支持双向流和多路复用 |
| **HTTP/2 Server Push** | 中间方案 | 复用现有 HTTP 基础设施,多路复用降延迟 |

**选定路径:直接引入 gRPC 双向流**

一步到位实现多路复用和双向通信,不走中间过渡方案。

**技术要点:**
- 后端引入 `grpcio` + `grpcio-tools`,定义 `ChatService` proto(含双向流 RPC)
- 前端通过 `grpc-web`(或 `@connectrpc/connect-web`)连接
- 支持多路复用:检索流、生成流、重写流可同时进行,共享一个连接
- 后端并行化:`asyncio.gather()` 并发调用 RAG 检索和 LLM 生成
- 保留 SSE 作为降级方案,gRPC 不可用时自动回退
- proto 定义:`ChatStreamRequest`、`ChatStreamResponse`(含 oneof 消息类型)

## UI 与 LLM 生成内容的联动机制

A2UI 的核心联动方式:

```
用户输入 → 后端 LLM 分析意图 → 选择输出格式 → 生成 A2UI JSON → SSE 流式传输 → 前端 A2UI 渲染器 → 交互式 UI
用户交互事件 → 回传后端 → LLM 继续处理
```

**意图到 UI 的映射举例:**
- "比较这三篇论文的方法" → `ComparisonTable` 组件(交互式对比表格)
- "展示这个领域的研究时间线" → `PaperTimeline` 组件(可缩放时间线)
- "这段话的来源是什么" → `CitationCard` 组件(展开式引用卡片)
- 普通问答 → 降级为 Markdown 文本 + 引用列表

## 聊天加载动画美化

**现状:** 仅有打字机光标闪烁(`●`),缺少分阶段视觉反馈。

**目标:** 加载过程分阶段展示,让用户感知系统正在做什么:

| 阶段 | 动画效果 |
|------|---------|
| 检索中 | 脉冲式搜索图标 + "正在检索 X 篇文献..." |
| 分析中 | 骨架屏渐入,模拟内容结构 |
| 生成中 | 文字逐步渲染 + 引用卡片淡入 |
| 完成 | 内容固定 + 交互元素激活 |

使用 CSS `@keyframes` + `transition` 实现,保持轻量,不引入重型动画库。

## 已解决的问题

1. **A2UI 引入时机** → 第一步就预埋基础设施,安装 `@a2ui/react`,用 A2UI 渲染引用卡片,为后续打基础
2. **并行 LLM 策略** → 直接引入 gRPC 双向流,一步到位实现多路复用,不走 asyncio 中间方案
3. **组件目录范围** → 丰富集:CitationCard、ComparisonTable、PaperTimeline、RewriteDiff、KnowledgeGraph、StatsDashboard、ExportPanel

## 下一步

→ `/ce:plan` 开始第一步(丰富引用卡片 + A2UI 基础设施 + 聊天 UI 美化)的实施规划
Loading
Loading