Files
my_bookmate/backend/app/api/chat.py
T

356 lines
13 KiB
Python
Raw Normal View History

import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from app.core.database import get_db
from app.models.book import Book, Chapter, Segment
from app.models.chat import Conversation, Message
from app.llm.hub import resolve_provider
from app.rag.engine import (
search_segments, build_chat_prompt, parse_citations,
estimate_tokens, match_chapters, _trim_messages,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chat", tags=["chat"])
class CreateConversationRequest(BaseModel):
book_id: str
title: str | None = None
model: str = "claude"
class SendMessageRequest(BaseModel):
content: str
model: str | None = None
# 被重试的用户消息 id:提供时不新建 user 消息,新 AI 回答作为该问题的新版本
# (parent_message_id 指向它),历史上下文只取该问题之前的对话
reply_to: str | None = None
class ConversationOut(BaseModel):
id: str
book_id: str
title: str | None
model: str
created_at: str
class Config:
from_attributes = True
class MessageOut(BaseModel):
id: str
conversation_id: str
parent_message_id: str | None
role: str
content: str
citations: list | None
model: str | None
created_at: str
class Config:
from_attributes = True
@router.get("/conversations", response_model=list[ConversationOut])
async def list_conversations(book_id: str | None = None, db: AsyncSession = Depends(get_db)):
q = select(Conversation).order_by(Conversation.updated_at.desc())
if book_id:
q = q.where(Conversation.book_id == book_id)
result = await db.execute(q)
convs = result.scalars().all()
return [ConversationOut(
id=c.id, book_id=c.book_id, title=c.title, model=c.model,
created_at=c.created_at.isoformat() if c.created_at else "",
) for c in convs]
@router.post("/conversations", response_model=ConversationOut)
async def create_conversation(req: CreateConversationRequest, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, req.book_id)
if not book:
raise HTTPException(404, "书不存在")
conv = Conversation(book_id=req.book_id, title=req.title or f"与《{book.title}》的对话", model=req.model)
db.add(conv)
await db.commit()
await db.refresh(conv)
return ConversationOut(
id=conv.id, book_id=conv.book_id, title=conv.title, model=conv.model,
created_at=conv.created_at.isoformat() if conv.created_at else "",
)
@router.get("/conversations/{conv_id}/messages", response_model=list[MessageOut])
async def get_messages(conv_id: str, db: AsyncSession = Depends(get_db)):
conv = await db.get(Conversation, conv_id)
if not conv:
raise HTTPException(404, "会话不存在")
result = await db.execute(
select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at)
)
msgs = result.scalars().all()
return [_msg_to_out(m) for m in msgs]
@router.post("/conversations/{conv_id}/messages")
async def send_message(conv_id: str, req: SendMessageRequest, db: AsyncSession = Depends(get_db)):
conv = await db.get(Conversation, conv_id)
if not conv:
raise HTTPException(404, "会话不存在")
book = await db.get(Book, conv.book_id)
if not book:
raise HTTPException(404, "书不存在")
model_name = req.model or conv.model
# 重试:复用被重试的用户消息(不新建),新回答作为它的新版本
reply_user: Message | None = None
if req.reply_to:
reply_user = await db.get(Message, req.reply_to)
if not reply_user or reply_user.role != "user" or reply_user.conversation_id != conv_id:
raise HTTPException(400, "reply_to 必须是本会话内的一条用户消息")
user_msg = reply_user
else:
user_msg = Message(conversation_id=conv_id, role="user", content=req.content, model=model_name)
db.add(user_msg)
await db.commit()
chapters = (await db.execute(
select(Chapter).where(Chapter.book_id == book.id).order_by(Chapter.order_idx)
)).scalars().all()
# 章节标题匹配:命中章节时把整章原文注入 prompt(解决「第X章讲了什么」这类问题)
matched = match_chapters(list(chapters), req.content)
chapter_segments = []
if matched:
result = await db.execute(
select(Segment)
.where(Segment.chapter_id.in_([c.id for c in matched]))
.order_by(Segment.order_idx)
)
chapter_segments = list(result.scalars().all())
segments = await search_segments(
db, book.id, req.content, top_k=15, chapters=list(chapters)
)
history_rows = (await db.execute(
select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at)
)).scalars().all()
# 历史上下文:重试时只取被重试问题之前的对话(旧版本回答不进 prompt);
# 正常发送时排除当前这轮 user 消息即可。
if reply_user is not None:
row_ids = {m.id for m in history_rows}
if reply_user.id in row_ids:
idx = next(i for i, m in enumerate(history_rows) if m.id == reply_user.id)
history = [{"role": m.role, "content": m.content} for m in history_rows[:idx]]
else:
# 数据异常(如 SRU),退回为全量历史
history = [{"role": m.role, "content": m.content} for m in history_rows]
else:
history = [{"role": m.role, "content": m.content} for m in history_rows[:-1]]
messages = build_chat_prompt(
book, list(chapters), segments, history, req.content,
chapter_segments=chapter_segments,
)
total_tokens = estimate_tokens("".join(m["content"] for m in messages))
provider = await resolve_provider(db, model_name)
if provider is None:
raise HTTPException(400, f"模型 {model_name} 未配置,请先在页面「管理模型」中添加或选择可用模型")
if total_tokens > provider.max_context * 0.8:
# 内容感知裁剪:保留 system(含检索原文 + 整章注入),从最旧历史开始丢
messages = _trim_messages(messages, int(provider.max_context * 0.8))
async def stream_response():
full_content = ""
try:
async for token in provider.chat_stream(messages, temperature=0.7):
full_content += token
yield f"data: {json.dumps({'type': 'token', 'content': token}, ensure_ascii=False)}\n\n"
except Exception as e:
logger.exception("流式生成失败(conversation=%s)", conv_id)
yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n"
return
parsed = parse_citations(full_content, segments)
assistant_msg = Message(
conversation_id=conv_id,
parent_message_id=user_msg.id,
role="assistant",
content=parsed["text"],
citations=json.dumps(parsed["citations"], ensure_ascii=False),
model=model_name,
)
db.add(assistant_msg)
await db.commit()
await db.refresh(assistant_msg)
yield f"data: {json.dumps({'type': 'done', 'message_id': assistant_msg.id, 'citations': parsed['citations']}, ensure_ascii=False)}\n\n"
return StreamingResponse(stream_response(), media_type="text/event-stream")
@router.post("/messages/{msg_id}/retry")
async def retry_message(msg_id: str, db: AsyncSession = Depends(get_db)):
msg = await db.get(Message, msg_id)
if not msg or msg.role != "user":
raise HTTPException(400, "只能对用户消息重试")
# 走 reply_to 语义:不新建 user 消息,而是给该问题追加一个新版本回答
return await send_message(
msg.conversation_id,
SendMessageRequest(content=msg.content, model=msg.model, reply_to=msg.id),
db,
)
@router.get("/segments/{segment_id}/context")
async def segment_context(
segment_id: str,
before: int = Query(2, ge=0, le=6),
after: int = Query(2, ge=0, le=6),
db: AsyncSession = Depends(get_db),
):
"""引用段落的阅读上下文:章节标题 + 前后段落,供前端「阅读原文」弹层。"""
seg = await db.get(Segment, segment_id)
if not seg:
raise HTTPException(404, "段落不存在")
ch = await db.get(Chapter, seg.chapter_id)
prev_rows = await db.execute(
select(Segment.id, Segment.text, Segment.order_idx, Segment.cfi)
.where(Segment.chapter_id == seg.chapter_id, Segment.order_idx < seg.order_idx)
.order_by(Segment.order_idx.desc())
.limit(before)
)
next_rows = await db.execute(
select(Segment.id, Segment.text, Segment.order_idx, Segment.cfi)
.where(Segment.chapter_id == seg.chapter_id, Segment.order_idx > seg.order_idx)
.order_by(Segment.order_idx)
.limit(after)
)
prev_list = [{"id": r[0], "text": r[1], "order_idx": r[2], "cfi": r[3]} for r in prev_rows.all()]
next_list = [{"id": r[0], "text": r[1], "order_idx": r[2], "cfi": r[3]} for r in next_rows.all()]
prev_list.reverse() # 保持阅读顺序
return {
"segment": {"id": seg.id, "text": seg.text, "order_idx": seg.order_idx, "cfi": seg.cfi},
"chapter": {"id": ch.id, "title": ch.title, "level": ch.level} if ch else None,
"before": prev_list,
"after": next_list,
}
@router.get("/search")
async def search_book(book_id: str, q: str = Query(min_length=1), db: AsyncSession = Depends(get_db)):
segments = await search_segments(db, book_id, q, top_k=10)
return [
{"id": s.id, "text": s.text[:300], "cfi": s.cfi, "char_count": s.char_count}
for s in segments
]
@router.delete("/messages/{msg_id}")
async def delete_message(msg_id: str, db: AsyncSession = Depends(get_db)):
msg = await db.get(Message, msg_id)
if not msg:
raise HTTPException(404, "消息不存在")
await db.delete(msg)
await db.commit()
return {"deleted": msg_id}
@router.delete("/conversations/{conv_id}")
async def delete_conversation(conv_id: str, db: AsyncSession = Depends(get_db)):
conv = await db.get(Conversation, conv_id)
if not conv:
raise HTTPException(404, "会话不存在")
# ORM cascade="all, delete-orphan" 会一并删除该会话下的所有消息
await db.delete(conv)
await db.commit()
return {"deleted": conv_id}
@router.post("/conversations/{conv_id}/branch")
async def branch_conversation(conv_id: str, from_message_id: str = Query(...), db: AsyncSession = Depends(get_db)):
conv = await db.get(Conversation, conv_id)
if not conv:
raise HTTPException(404, "会话不存在")
branch = Conversation(book_id=conv.book_id, title=f"{conv.title or '对话'} (分支)", model=conv.model)
db.add(branch)
await db.flush()
result = await db.execute(
select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at)
)
msgs = result.scalars().all()
for m in msgs:
new_msg = Message(
conversation_id=branch.id,
role=m.role,
content=m.content,
citations=m.citations,
model=m.model,
)
db.add(new_msg)
if m.id == from_message_id:
break
await db.commit()
await db.refresh(branch)
return {
"id": branch.id,
"book_id": branch.book_id,
"title": branch.title,
"model": branch.model,
"created_at": branch.created_at.isoformat() if branch.created_at else "",
}
@router.get("/conversations/{conv_id}/export")
async def export_conversation(conv_id: str, fmt: str = "markdown", db: AsyncSession = Depends(get_db)):
conv = await db.get(Conversation, conv_id)
if not conv:
raise HTTPException(404, "会话不存在")
result = await db.execute(
select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at)
)
msgs = result.scalars().all()
book = await db.get(Book, conv.book_id)
lines = [f"# {conv.title or '对话'}", f"书籍: {book.title if book else '未知'}\n"]
for m in msgs:
role_label = "🧑 用户" if m.role == "user" else "🤖 AI"
lines.append(f"## {role_label}\n\n{m.content}\n")
if m.citations:
try:
citations = json.loads(m.citations)
for i, c in enumerate(citations):
lines.append(f"> 引用 {i+1}: {c.get('excerpt', '')}\n")
except (json.JSONDecodeError, TypeError):
pass
return {"content": "\n".join(lines), "format": fmt, "title": conv.title}
def _msg_to_out(m: Message) -> MessageOut:
citations = None
if m.citations:
try:
citations = json.loads(m.citations)
except (json.JSONDecodeError, TypeError):
citations = None
return MessageOut(
id=m.id, conversation_id=m.conversation_id, parent_message_id=m.parent_message_id,
role=m.role, content=m.content, citations=citations, model=m.model,
created_at=m.created_at.isoformat() if m.created_at else "",
)