356 lines
13 KiB
Python
356 lines
13 KiB
Python
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 "",
|
|||
|
|
)
|