feat: add BookMate AI reading workspace and full EPUB reader

This commit is contained in:
2026-10-24 03:12:20 +08:00
parent eaeb45fc85
commit c7b0cfb92b
60 changed files with 8996 additions and 191 deletions
View File
View File
+146
View File
@@ -0,0 +1,146 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db, async_session
from app.core.config import settings
from app.models.book import Book, Chapter, Segment
from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut
from app.epub.parser import parse_epub
from app.rag.vector import ensure_embeddings
router = APIRouter(prefix="/books", tags=["books"])
async def _embed_book_in_background(book_id: str):
"""上传后后台批量生成向量;失败时状态记 failed,首次对话会懒补。"""
async with async_session() as session:
try:
await ensure_embeddings(session, book_id)
except Exception:
pass
@router.post("", response_model=BookOut)
async def upload_book(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
background_tasks: BackgroundTasks = BackgroundTasks(),
):
if not file.filename or not file.filename.lower().endswith(".epub"):
raise HTTPException(400, "仅支持 .epub 文件")
upload_dir = settings.upload_path
ext = ".epub"
filename = f"{uuid.uuid4().hex}{ext}"
file_path = upload_dir / filename
async with aiofiles.open(file_path, "wb") as f:
content = await file.read()
await f.write(content)
try:
book = await parse_epub(str(file_path), db)
await db.commit()
await db.refresh(book)
background_tasks.add_task(_embed_book_in_background, book.id)
return book
except ValueError as e:
os.remove(file_path)
raise HTTPException(409, str(e))
except Exception as e:
os.remove(file_path)
raise HTTPException(500, f"解析失败: {str(e)}")
@router.get("", response_model=list[BookOut])
async def list_books(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Book).order_by(Book.created_at.desc()))
return result.scalars().all()
@router.get("/{book_id}", response_model=BookDetailOut)
async def get_book(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
ch_count = await db.scalar(
select(func.count()).select_from(Chapter).where(Chapter.book_id == book_id)
)
seg_count = await db.scalar(
select(func.count()).select_from(Segment).where(Segment.book_id == book_id)
)
return BookDetailOut(
id=book.id,
filename=book.filename,
title=book.title,
author=book.author,
language=book.language,
char_count=book.char_count,
status=book.status,
created_at=book.created_at,
chapter_count=ch_count or 0,
segment_count=seg_count or 0,
)
@router.get("/{book_id}/chapters", response_model=list[ChapterOut])
async def get_chapters(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.order_idx)
)
return result.scalars().all()
@router.get("/{book_id}/file")
async def read_book_file(book_id: str, db: AsyncSession = Depends(get_db)):
"""供浏览器阅读原始 EPUB,保留正文、图片和出版排版。"""
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
file_path = Path(book.file_path).resolve()
upload_root = Path(settings.upload_dir).resolve()
if not file_path.is_relative_to(upload_root) or file_path.suffix.lower() != ".epub":
raise HTTPException(404, "书籍文件不可用")
if not file_path.is_file():
raise HTTPException(404, "原始 EPUB 文件已丢失,请重新导入")
return FileResponse(
file_path,
media_type="application/epub+zip",
headers={"Cache-Control": "private, no-cache", "X-Content-Type-Options": "nosniff"},
)
@router.get("/{book_id}/segments", response_model=list[SegmentOut])
async def get_segments(book_id: str, chapter_id: str | None = None, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
q = select(Segment).where(Segment.book_id == book_id)
if chapter_id:
q = q.where(Segment.chapter_id == chapter_id)
q = q.order_by(Segment.order_idx)
result = await db.execute(q)
return result.scalars().all()
@router.delete("/{book_id}")
async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
file_path = book.file_path
await db.delete(book)
await db.commit()
if file_path and os.path.exists(file_path):
os.remove(file_path)
return {"deleted": book_id}
+356
View File
@@ -0,0 +1,356 @@
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 "",
)
+183
View File
@@ -0,0 +1,183 @@
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.ai_model import AiModel
from app.llm.hub import builtin_models, resolve_provider, _mask_key
router = APIRouter(prefix="/models", tags=["models"])
class AddModelRequest(BaseModel):
label: str
kind: str = "openai_compatible" # openai_compatible | anthropic
base_url: str = ""
api_key: str = ""
model_id: str
class UpdateModelRequest(BaseModel):
label: str | None = None
kind: str | None = None
base_url: str | None = None
api_key: str | None = None
model_id: str | None = None
class ImportModelsRequest(BaseModel):
api_key: str
base_url: str = "https://openrouter.ai/api/v1"
class ImportOpenRouterRequest(BaseModel):
api_key: str
base_url: str = "https://openrouter.ai/api/v1"
OPENROUTER_BLOCKLIST = {
# 一些非文本/量级过大的模型 id 特征,导入时快速排除
"image", "tts", "whisper", "embed", "rerank", "moderation", "dall-e", "flux", "playground",
}
def _to_out(m: AiModel) -> dict:
return {
"name": m.name,
"label": m.label,
"kind": m.kind,
"base_url": m.base_url,
"api_key_masked": _mask_key(m.api_key),
"model_id": m.model_id,
"builtin": False,
}
@router.get("")
async def list_models(db: AsyncSession = Depends(get_db)):
"""内置(.env)+ 用户自定义(DB)模型列表。api_key 只回显掩码。"""
result = await db.execute(select(AiModel).order_by(AiModel.sort_order, AiModel.created_at))
custom = [_to_out(m) for m in result.scalars().all()]
return builtin_models() + custom
@router.post("")
async def add_model(req: AddModelRequest, db: AsyncSession = Depends(get_db)):
label = (req.label or "").strip() or req.model_id
# name = 请求模型 id;冲突时追加序号(-2, -3 ...)
base_name = req.model_id.strip() or "custom-model"
name = base_name
suffix = 1
while True:
exists = (await db.execute(select(AiModel.id).where(AiModel.name == name))).scalar()
if exists is None:
break
suffix += 1
name = f"{base_name}-{suffix}"
row = AiModel(
name=name, label=label, kind=req.kind,
base_url=(req.base_url or "").strip(), api_key=(req.api_key or "").strip(),
model_id=req.model_id.strip(), sort_order=100,
)
db.add(row)
await db.commit()
await db.refresh(row)
return _to_out(row)
@router.put("/{name}")
async def update_model(name: str, req: UpdateModelRequest, db: AsyncSession = Depends(get_db)):
for m in builtin_models():
if m["name"] == name:
raise HTTPException(400, "内置模型不可修改,请在页面添加同名模型")
row = (await db.execute(select(AiModel).where(AiModel.name == name))).scalar()
if not row:
raise HTTPException(404, "模型不存在")
if req.label is not None:
row.label = req.label.strip() or row.label
if req.kind is not None:
row.kind = req.kind
if req.base_url is not None:
row.base_url = req.base_url.strip()
if req.api_key is not None:
row.api_key = req.api_key.strip()
if req.model_id is not None:
row.model_id = req.model_id.strip()
await db.commit()
await db.refresh(row)
return _to_out(row)
@router.delete("/{name}")
async def delete_model(name: str, db: AsyncSession = Depends(get_db)):
for m in builtin_models():
if m["name"] == name:
raise HTTPException(400, "内置模型不可删除")
row = (await db.execute(select(AiModel).where(AiModel.name == name))).scalar()
if not row:
raise HTTPException(404, "模型不存在")
await db.delete(row)
await db.commit()
return {"deleted": name}
@router.post("/{name}/test")
async def test_model(name: str, db: AsyncSession = Depends(get_db)):
"""用该模型的配置发一条极短请求,验证 base_url/api_key/model_id 是否可用。"""
provider = await resolve_provider(db, name)
if provider is None:
raise HTTPException(404, "模型不存在")
try:
resp = await provider.chat(
[{"role": "user", "content": "hi"}],
temperature=0,
)
return {"ok": True, "model": resp.model, "reply": resp.content[:80]}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
@router.post("/import")
async def import_models(req: ImportModelsRequest):
"""通用 OpenAI 兼容 /models 拉取(OpenRouter / 硅基流动 / SenseNova / 任取 base_url),
供前端挑选后批量添加(不落库)。"""
key = (req.api_key or "").strip()
if not key:
raise HTTPException(400, "需要 API Key")
base = (req.base_url or "").strip().rstrip("/")
if not base:
raise HTTPException(400, "需要 Base URL")
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(
f"{base}/models",
headers={"Authorization": f"Bearer {key}"},
)
if resp.status_code >= 400:
raise HTTPException(resp.status_code, f"OpenRouter 返回 {resp.status_code}: {resp.text[:200]}")
data = resp.json()
except httpx.HTTPError as e:
raise HTTPException(502, f"请求模型列表失败: {type(e).__name__}: {e}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"解析模型列表失败: {e}")
models = []
for m in data.get("data", []):
mid = (m.get("id") or "").strip()
if not mid:
continue
low = mid.lower()
if any(b in low for b in OPENROUTER_BLOCKLIST):
continue
models.append({"id": mid, "name": (m.get("name") or mid)})
return {"models": models, "count": len(models)}
@router.post("/import-openrouter")
async def import_openrouter_models(req: ImportOpenRouterRequest, db: AsyncSession = Depends(get_db)):
"""兼容旧调用:等价于通用 /import(默认 base_url = OpenRouter)。"""
return await import_models(ImportModelsRequest(api_key=req.api_key, base_url=req.base_url))
View File
+43
View File
@@ -0,0 +1,43 @@
from pydantic_settings import BaseSettings
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
database_url: str = f"sqlite+aiosqlite:///{BASE_DIR / 'data' / 'bookmate.db'}"
upload_dir: str = str(BASE_DIR / "uploads")
data_dir: str = str(BASE_DIR / "data")
api_host: str = "0.0.0.0"
api_port: int = 8000
# ===== 向量检索 & 重排(OpenAI 兼容 API,如硅基流动 SiliconFlow)=====
# embedding_base_url: 支持 OpenAI 兼容 /embeddings 的端点前缀
embedding_base_url: str = "https://api.siliconflow.cn/v1"
embedding_api_key: str = "" # 留空则自动降级为纯关键词检索
embedding_model: str = "BAAI/bge-m3" # 1024 维
# rerank: 硅基流动使用 /v1/rerank,OpenAI 不提供;留空则跳过重排
rerank_base_url: str = "https://api.siliconflow.cn/v1"
rerank_api_key: str = ""
rerank_model: str = "BAAI/bge-reranker-v2-m3"
embedding_batch_size: int = 64
@property
def upload_path(self) -> Path:
p = Path(self.upload_dir)
p.mkdir(parents=True, exist_ok=True)
return p
@property
def data_path(self) -> Path:
p = Path(self.data_dir)
p.mkdir(parents=True, exist_ok=True)
return p
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
settings = Settings()
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
settings.data_path.mkdir(parents=True, exist_ok=True)
settings.upload_path.mkdir(parents=True, exist_ok=True)
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _migrate()
async def _migrate():
"""轻量迁移:SQLite 下给已有 segments 表补 embedding 列(create_all 不会改老表)。"""
async with engine.begin() as conn:
rows = (await conn.exec_driver_sql("PRAGMA table_info(segments)")).all()
cols = {row[1] for row in rows}
if rows and "embedding" not in cols:
await conn.exec_driver_sql("ALTER TABLE segments ADD COLUMN embedding TEXT")
async def get_db():
async with async_session() as session:
yield session
View File
+37
View File
@@ -0,0 +1,37 @@
import hashlib
def make_cfi(spine_index: int, file_href: str, element_id: str = "") -> str:
"""生成 CFI(Canonical Fragment Identifier)
格式: epubi://title/spine_index/file_href!element_id
"""
clean_href = file_href.split("#")[0]
cfi = f"epubi://book/{spine_index}/{clean_href}"
if element_id:
cfi += f"!{element_id}"
return cfi
def make_element_id(text: str, index: int) -> str:
"""基于文本内容和索引用哈希生成稳定 ID"""
short_hash = hashlib.md5(f"{text[:100]}{index}".encode()).hexdigest()[:8]
return f"seg-{index:04d}-{short_hash}"
def parse_cfi(cfi: str) -> dict | None:
"""解析 CFI 字符串,返回各组成部分"""
if not cfi.startswith("epubi://"):
return None
try:
rest = cfi[len("epubi://book/"):]
parts = rest.split("/", 2)
spine_index = int(parts[0])
file_href = parts[1] if len(parts) > 1 else ""
fragment = parts[2] if len(parts) > 2 else ""
return {
"spine_index": spine_index,
"file_href": file_href,
"fragment": fragment.lstrip("!"),
}
except (ValueError, IndexError):
return None
+97
View File
@@ -0,0 +1,97 @@
import re
def chunk_paragraphs(
paragraphs: list[dict],
heading_path: str,
cfi_base: str,
target_chars: int = 1500,
max_chars: int = 4000,
min_chars: int = 200,
) -> list[dict]:
"""将段落列表按语义边界聚类,不按固定字符数切。
策略:
- 章节总字数 < target_chars: 整章一块
- otherwise: 按段落聚类,目标 target_chars/块,允许±50%浮动
"""
total_chars = sum(len(p["text"]) for p in paragraphs)
if total_chars <= target_chars:
merged_text = "\n\n".join(p["text"] for p in paragraphs)
prefix = f"《heading_path》" if heading_path else ""
return [{
"text": f"{prefix}\n{merged_text}" if prefix else merged_text,
"char_count": len(merged_text),
"cfi": cfi_base,
"heading_path": heading_path,
}]
chunks = []
buf = []
buf_chars = 0
def flush():
nonlocal buf_chars
if not buf:
return
merged = "\n\n".join(p["text"] for p in buf)
prefix = f"《heading_path》" if heading_path else ""
text = f"{prefix}\n{merged}" if prefix else merged
chunks.append({
"text": text,
"char_count": len(merged),
"cfi": cfi_base,
"heading_path": heading_path,
})
buf.clear()
buf_chars = 0
for i, p in enumerate(paragraphs):
p_chars = len(p["text"])
if p["tag"] in ("h1", "h2", "h3", "h4"):
flush()
prefix = f"《heading_path》" if heading_path else ""
text = f"{prefix}\n{p['text']}" if prefix else p["text"]
chunks.append({
"text": text,
"char_count": p_chars,
"cfi": cfi_base,
"heading_path": heading_path,
})
continue
if buf_chars + p_chars > target_chars * 1.5:
flush()
buf.append(p)
buf_chars += p_chars
flush()
for i in range(len(chunks)):
chunks[i]["cfi"] = f"{cfi_base}!chunk-{i:04d}"
return chunks
def chapter_to_chunks(
paragraphs: list[dict],
heading_path: str,
cfi_start: str,
book_char_total: int,
) -> list[dict]:
"""根据书的总规模决定切块策略"""
if book_char_total < 50_000:
target = 2000
elif book_char_total < 500_000:
target = 1500
else:
target = 1000
return chunk_paragraphs(
paragraphs,
heading_path=heading_path,
cfi_base=cfi_start,
target_chars=target,
)
+187
View File
@@ -0,0 +1,187 @@
import re
from bs4 import BeautifulSoup, Tag
BLACKLIST_PATTERNS = [
(re.compile(r"^\s*目\s*录\s*$"), True),
(re.compile(r"^\s*Contents\b", re.I), True),
(re.compile(r"^\s*Table of Contents", re.I), True),
(re.compile(r"版权所有"), True),
(re.compile(r"Copyright\s*\d{4}", re.I), True),
(re.compile(r"ISBN[\s:\-]*\d"), True),
(re.compile(r"版\s*权\s*页"), True),
(re.compile(r"^\s*[·\-\—\*_=]{3,}\s*$"), True),
(re.compile(r"^\s*序\s*言\s*$"), True),
(re.compile(r"^\s*前\s*言\s*$"), True),
(re.compile(r"^\s*致\s*谢\s*$"), True),
(re.compile(r"^\s*后\s*记\s*$"), True),
(re.compile(r"^\s*译者序\s*$"), True),
]
SKIP_TAGS = {"script", "style", "nav", "head", "meta", "link", "br"}
SENTENCE_ENDERS = re.compile(r"[。!?;…)」』】>)\)\"\"]$")
ENGLISH_SENTENCE_ENDERS = re.compile(r"[.!?;\"]$")
def clean_html(html_bytes: bytes, fix_wraps: bool = True) -> str:
soup = BeautifulSoup(html_bytes, "lxml")
for tag_name in SKIP_TAGS:
for tag in soup.find_all(tag_name):
tag.decompose()
body = soup.find("body") or soup
text = body.get_text("\n", strip=False)
lines = [line.strip() for line in text.split("\n") if line.strip()]
lines = _filter_boilerplate(lines)
if fix_wraps:
lines = _fix_chinese_wraps(lines)
return "\n\n".join(lines)
def extract_paragraphs(html_bytes: bytes, fix_wraps: bool = True) -> list[dict]:
"""从 HTML 中提取段落列表,返回 [{text, tag, has_children}]"""
soup = BeautifulSoup(html_bytes, "lxml")
for tag_name in SKIP_TAGS:
for tag in soup.find_all(tag_name):
tag.decompose()
body = soup.find("body") or soup
paragraphs = []
for el in body.descendants:
if not isinstance(el, Tag):
continue
if el.name in SKIP_TAGS:
continue
if not el.name or el.name in ("html", "head", "body", "div", "span", "section", "article", "main"):
continue
text = el.get_text(" ", strip=True)
if not text or len(text) < 2:
continue
if el.name in ("table",):
text = _table_to_markdown(el)
elif el.name in ("blockquote",):
text = "> " + text.replace("\n", "\n> ")
paragraphs.append({
"text": text,
"tag": el.name,
"has_children": bool(el.find_all()),
})
if fix_wraps:
paragraphs = _fix_paragraph_wraps(paragraphs)
return paragraphs
def _filter_boilerplate(lines: list[str]) -> list[str]:
result = []
for line in lines:
stripped = re.sub(r"\s+", "", line)
if len(stripped) < 15:
continue
if any(pattern.search(stripped) for pattern, _ in BLACKLIST_PATTERNS):
continue
result.append(line)
return result
def _fix_chinese_wraps(lines: list[str]) -> list[str]:
"""中文断行修复:末字非句末标点时,与下一行合并"""
if not lines:
return lines
result = []
buf = ""
for line in lines:
line = line.strip()
if not line:
if buf:
result.append(buf)
buf = ""
continue
if not buf:
buf = line
continue
if SENTENCE_ENDERS.search(buf[-1]):
result.append(buf)
buf = line
else:
buf += line
if buf:
result.append(buf)
return [r for r in result if r]
def _fix_paragraph_wraps(paragraphs: list[dict]) -> list[dict]:
"""段落级别的断行修复,合并被拆分的段落"""
if not paragraphs:
return paragraphs
merged = []
buf = paragraphs[0]
for p in paragraphs[1:]:
if not buf["text"]:
buf = p
continue
if SENTENCE_ENDERS.search(buf["text"][-1:]):
merged.append(buf)
buf = p
elif len(buf["text"]) < 50 and not SENTENCE_ENDERS.search(buf["text"][-1:]):
buf["text"] = buf["text"] + p["text"]
else:
merged.append(buf)
buf = p
if buf["text"]:
merged.append(buf)
return merged
def _table_to_markdown(table_tag) -> str:
rows = []
for tr in table_tag.find_all("tr"):
cells = [c.get_text(" ", strip=True) for c in tr.find_all(["td", "th"])]
rows.append(cells)
if not rows:
return ""
col_count = max(len(r) for r in rows)
for r in rows:
while len(r) < col_count:
r.append("")
col_widths = [max(len(r[i]) for r in rows) for i in range(col_count)]
lines = []
for i, row in enumerate(rows):
line = "| " + " | ".join(cell.ljust(w) for cell, w in zip(row, col_widths)) + " |"
lines.append(line)
if i == 0:
sep = "|" + "|".join("-" * (w + 2) for w in col_widths) + "|"
lines.append(sep)
return "\n".join(lines)
def detect_quality(text: str) -> tuple[int, list[str]]:
"""检测文本提取质量,返回 (score 0-100, issues)"""
score = 100
issues = []
lines = [l for l in text.split("\n") if l.strip()]
short_count = sum(1 for l in lines if len(l) < 10 and len(l) > 0)
if short_count > len(lines) * 0.3:
score -= 30
issues.append("大量短行,可能是伪 EPUB")
fullwidth = len(re.findall(r"[\u4e00-\u9fff]", text))
ascii_chars = len(re.findall(r"[a-zA-Z]", text))
if fullwidth + ascii_chars == 0:
score -= 50
issues.append("无有效文本内容")
if len(text) < 100 and fullwidth == 0:
score -= 40
issues.append("文本过短")
return max(score, 0), issues
+207
View File
@@ -0,0 +1,207 @@
import zipfile
import hashlib
import os
from lxml import etree
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.book import Book, Chapter, Segment
from app.epub.toc import (
parse_toc_tree, flatten_headings, build_heading_paths,
href_to_fragment, NS, _read_opf, _resolve_path, _dirname,
)
from app.epub.cleaner import extract_paragraphs, clean_html, detect_quality
from app.epub.chunker import chapter_to_chunks
from app.epub.cfi import make_cfi, make_element_id
async def parse_epub(file_path: str, db: AsyncSession) -> Book:
"""完整解析 EPUB,写入数据库,返回 Book 对象"""
file_hash = _hash_file(file_path)
existing = await db.execute(
__import__("sqlalchemy").select(Book).where(Book.file_hash == file_hash)
)
if existing.scalar_one_or_none():
raise ValueError("EPUB 已存在(根据文件哈希去重)")
filename = os.path.basename(file_path)
with zipfile.ZipFile(file_path) as zf:
opf_path, opf_root = _read_opf(zf)
opf_dir = _dirname(opf_path)
title, author, language = _extract_metadata(opf_root)
spine_items = _get_spine_items(zf, opf_root, opf_dir)
headings = parse_toc_tree(file_path)
flat = flatten_headings(headings)
flat_with_paths = build_heading_paths(flat, 0)
book = Book(
filename=filename,
title=title,
author=author,
language=language,
file_path=file_path,
file_hash=file_hash,
status="parsing",
)
db.add(book)
await db.flush()
total_chars = 0
spine_index_map = {}
with zipfile.ZipFile(file_path) as zf:
_, opf_root = _read_opf(zf)
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf_root.findall("opf:manifest/opf:item", NS)}
spine_ids = [itemref.attrib.get("idref") for itemref in opf_root.findall("opf:spine/opf:itemref", NS)]
for idx, idref in enumerate(spine_ids):
href = manifest.get(idref, "")
if href:
spine_index_map[href] = idx
with zipfile.ZipFile(file_path) as zf:
chapters_to_insert = _match_chapters_to_spine(
flat_with_paths, spine_items, zf, opf_dir,
)
chapter_order = 0
for ch_info in chapters_to_insert:
paragraphs = extract_paragraphs(ch_info["html"], fix_wraps=True)
chapter_text = "\n\n".join(p["text"] for p in paragraphs)
chapter_chars = len(chapter_text)
total_chars += chapter_chars
href = ch_info["href"]
spine_idx = spine_index_map.get(href, chapter_order)
cfi_start = make_cfi(spine_idx, href, ch_info.get("fragment", ""))
chapter = Chapter(
book_id=book.id,
order_idx=chapter_order,
title=ch_info["title"],
level=ch_info.get("level", 1),
heading_path=ch_info.get("heading_path", ""),
cfi_start=cfi_start,
cfi_end="",
char_count=chapter_chars,
)
db.add(chapter)
await db.flush()
chunks = chapter_to_chunks(
paragraphs,
heading_path=ch_info.get("heading_path", ""),
cfi_start=cfi_start,
book_char_total=total_chars,
)
for seg_order, chunk in enumerate(chunks):
segment = Segment(
chapter_id=chapter.id,
book_id=book.id,
order_idx=seg_order,
text=chunk["text"],
char_count=chunk["char_count"],
cfi=chunk["cfi"],
)
db.add(segment)
chapter_order += 1
book.char_count = total_chars
book.status = "ready"
quality_score, quality_issues = _assess_quality(zf=None, chapters=chapters_to_insert, file_path=file_path)
await db.flush()
return book
def _hash_file(file_path: str) -> str:
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _extract_metadata(opf: etree._Element) -> tuple[str, str, str]:
ns = {"dc": "http://purl.org/dc/elements/1.1/"}
title_el = opf.find(".//dc:title", ns)
creator_el = opf.find(".//dc:creator", ns)
lang_el = opf.find(".//dc:language", ns)
title = title_el.text.strip() if title_el is not None and title_el.text else "未知书名"
author = creator_el.text.strip() if creator_el is not None and creator_el.text else "未知作者"
language = lang_el.text.strip() if lang_el is not None and lang_el.text else "zh"
return title, author, language
def _get_spine_items(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[dict]:
"""获取 spine 中所有文件的 HTML 内容"""
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)]
items = []
for idref in spine_ids:
href = manifest.get(idref, "")
if not href:
continue
path = _resolve_path(opf_dir, href)
try:
html = zf.read(path)
items.append({"href": href, "html": html, "path": path})
except KeyError:
continue
return items
def _match_chapters_to_spine(
flat_headings: list[dict],
spine_items: list[dict],
zf: zipfile.ZipFile,
opf_dir: str,
) -> list[dict]:
"""将目录条目与 spine 文件匹配,按 href 对齐"""
chapter_map = {}
for h in flat_headings:
href = h.get("href", "")
base = href.split("#")[0] if "#" in href else href
frag = href.split("#", 1)[1] if "#" in href else ""
if base not in chapter_map:
chapter_map[base] = []
chapter_map[base].append({**h, "fragment": frag})
result = []
seen = set()
for item in spine_items:
href = item["href"]
base = href.split("#")[0] if "#" in href else href
if base in chapter_map:
for ch in chapter_map[base]:
if ch.get("title", "") not in seen:
seen.add(ch.get("title", ""))
result.append({**ch, "html": item["html"]})
else:
result.append({
"title": f"Section {len(result) + 1}",
"level": 1,
"href": href,
"heading_path": "",
"fragment": "",
"html": item["html"],
})
return result
def _assess_quality(zf, chapters: list[dict], file_path: str) -> tuple[int, list[str]]:
"""评估解析质量"""
if not chapters:
return 0, ["无有效章节"]
sample_text = "\n".join(
clean_html(ch["html"], fix_wraps=False)
for ch in chapters[:5]
if ch.get("html")
)
if not sample_text:
return 0, ["无法提取文本"]
return detect_quality(sample_text)
+216
View File
@@ -0,0 +1,216 @@
import zipfile
import re
from lxml import etree
from dataclasses import dataclass, field
@dataclass
class Heading:
title: str
level: int
href: str
cfi: str = ""
children: list["Heading"] = field(default_factory=list)
NS = {
"opf": "http://www.idpf.org/2007/opf",
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
"xhtml": "http://www.w3.org/1999/xhtml",
}
def parse_toc_tree(epub_path: str) -> list[Heading]:
"""从 EPUB 中提取完整目录树,4 级降级:nav.xhtml -> ncx -> spine -> h 标签"""
with zipfile.ZipFile(epub_path) as zf:
opf_path, opf_root = _read_opf(zf)
opf_dir = _dirname(opf_path)
headings = _try_nav(zf, opf_root, opf_dir)
if headings:
return headings
headings = _try_ncx(zf, opf_root, opf_dir)
if headings:
return headings
headings = _try_spine(zf, opf_root, opf_dir)
if headings:
return headings
return _fallback_spine(zf, opf_root, opf_dir)
def _read_opf(zf: zipfile.ZipFile) -> tuple[str, etree._Element]:
container = etree.fromstring(zf.read("META-INF/container.xml"))
rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
opf_path = rootfile.attrib["full-path"] if rootfile is not None else "OEBPS/content.opf"
opf_root = etree.fromstring(zf.read(opf_path))
return opf_path, opf_root
def _try_nav(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None:
"""EPUB3: nav.xhtml(最优先,有原生层级)"""
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)]
for idref in spine_ids:
href = manifest.get(idref, "")
if "nav" in href.lower() or "toc" in href.lower():
nav_path = _resolve_path(opf_dir, href)
try:
return _parse_nav_xhtml(zf, nav_path, opf_dir)
except Exception:
continue
return None
def _parse_nav_xhtml(zf: zipfile.ZipFile, nav_path: str, opf_dir: str) -> list[Heading]:
html = etree.fromstring(zf.read(nav_path))
ol = html.find(".//{http://www.w3.org/1999/xhtml}nav[@{http://www.w3.org/1999/xhtml}type='toc']//{http://www.w3.org/1999/xhtml}ol")
if ol is None:
ol = html.find(".//{http://www.w3.org/1999/xhtml}nav//{http://www.w3.org/1999/xhtml}ol")
if ol is None:
return []
return _parse_ol(ol, opf_dir, 1)
def _parse_ol(ol, opf_dir: str, base_level: int) -> list[Heading]:
result = []
for li in ol.findall("{http://www.w3.org/1999/xhtml}li"):
a = li.find("{http://www.w3.org/1999/xhtml}a")
if a is None:
continue
title = (a.text or "").strip()
href = a.attrib.get("href", "")
heading = Heading(title=title, level=base_level, href=href)
sub_ol = li.find("{http://www.w3.org/1999/xhtml}ol")
if sub_ol is not None:
heading.children = _parse_ol(sub_ol, opf_dir, base_level + 1)
result.append(heading)
return result
def _try_ncx(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None:
"""EPUB2: toc.ncx"""
spine = opf.find("opf:spine", NS)
if spine is None:
return None
ncx_id = spine.attrib.get("toc", "")
if not ncx_id:
return None
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
ncx_href = manifest.get(ncx_id)
if not ncx_href:
return None
ncx_path = _resolve_path(opf_dir, ncx_href)
try:
ncx_root = etree.fromstring(zf.read(ncx_path))
return _parse_ncx_navmap(ncx_root, opf_dir)
except Exception:
return None
def _parse_ncx_navmap(ncx: etree._Element, opf_dir: str) -> list[Heading]:
navmap = ncx.find("ncx:navMap", NS)
if navmap is None:
return []
return _parse_ncx_points(navmap, opf_dir, 1)
def _parse_ncx_points(parent, opf_dir: str, base_level: int) -> list[Heading]:
result = []
for np in parent.findall("ncx:navPoint", NS):
label = np.find("ncx:navLabel/ncx:text", NS)
content = np.find("ncx:content", NS)
title = _text(label) if label is not None else ""
href = content.attrib.get("src", "") if content is not None else ""
heading = Heading(title=title, level=base_level, href=href)
heading.children = _parse_ncx_points(np, opf_dir, base_level + 1)
result.append(heading)
return result
def _try_spine(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None:
"""退化方案:每个 spine 文件当作一章"""
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)]
headings = []
for i, idref in enumerate(spine_ids):
href = manifest.get(idref, "")
if not href:
continue
try:
path = _resolve_path(opf_dir, href)
html = zf.read(path)
soup = etree.fromstring(html)
title_tag = soup.find(".//{http://www.w3.org/1999/xhtml}title")
title = _text(title_tag) if title_tag is not None else f"Section {i + 1}"
headings.append(Heading(title=title, level=1, href=href))
except Exception:
continue
return headings if headings else None
def _fallback_spine(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading]:
"""最后兜底:直接读所有 xhtml 的 h1-h3"""
return _try_spine(zf, opf, opf_dir) or []
def flatten_headings(headings: list[Heading], level_offset: int = 0) -> list[dict]:
"""将树形目录展平为列表,包含 heading_path"""
result = []
for h in headings:
result.append({
"title": h.title,
"level": h.level + level_offset,
"href": h.href,
})
result.extend(flatten_headings(h.children, level_offset))
return result
def build_heading_paths(headings: list[dict], order_idx: int) -> list[dict]:
"""为每个扁平条目构建完整 heading_path(如 '第3章 > 3.2 前向传播')"""
stack = []
result = []
for h in headings:
while stack and stack[-1]["level"] >= h["level"]:
stack.pop()
stack.append(h)
path = " > ".join(s["title"] for s in stack)
result.append({**h, "heading_path": path, "order_idx": order_idx})
order_idx += 1
return result
def _resolve_path(opf_dir: str, href: str) -> str:
if href.startswith("/"):
return href.lstrip("/")
parts = [p for p in opf_dir.split("/") if p] if opf_dir else []
parts.append(href)
while ".." in parts:
idx = parts.index("..")
if idx > 0:
parts.pop(idx)
parts.pop(idx - 1)
else:
parts.pop(idx)
return "/".join(parts)
def _dirname(path: str) -> str:
parts = path.rsplit("/", 1)
return parts[0] if len(parts) > 1 else ""
def _text(el) -> str:
return "".join(el.itertext()).strip() if el is not None else ""
def href_to_fragment(href: str) -> str:
"""从 href 中提取 file 和 fragment 部分"""
if "#" in href:
return href.split("#", 1)[1]
return ""
View File
+277
View File
@@ -0,0 +1,277 @@
import json
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass
class ChatResponse:
content: str
model: str
prompt_tokens: int = 0
completion_tokens: int = 0
class LLMProvider(ABC):
name: str = "base"
max_context: int = 128_000
@abstractmethod
async def chat(self, messages: list[dict], temperature: float = 0.7) -> ChatResponse:
...
@abstractmethod
async def chat_stream(self, messages: list[dict], temperature: float = 0.7) -> AsyncGenerator[str, None]:
...
class OpenAIProvider(LLMProvider):
name = "openai"
max_context = 128_000
def __init__(self, api_key: str | None = None, base_url: str | None = None, model: str | None = None):
self.api_key = api_key if api_key is not None else os.getenv("OPENAI_API_KEY", "")
self.base_url = base_url if base_url is not None else os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "gpt-4o")
async def chat(self, messages: list[dict], temperature: float = 0.7) -> ChatResponse:
import httpx
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json={"model": self.model, "messages": messages, "temperature": temperature},
)
data = resp.json()
if "error" in data:
raise RuntimeError(f"OpenAI error: {data['error']}")
choice = data["choices"][0]
return ChatResponse(
content=choice["message"]["content"],
model=data["model"],
prompt_tokens=data.get("usage", {}).get("prompt_tokens", 0),
completion_tokens=data.get("usage", {}).get("completion_tokens", 0),
)
async def chat_stream(self, messages: list[dict], temperature: float = 0.7) -> AsyncGenerator[str, None]:
import httpx
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json={"model": self.model, "messages": messages, "temperature": temperature, "stream": True},
) as resp:
async for line in resp.aiter_lines():
if line.startswith("data: "):
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
data = json.loads(data_str)
# 流末尾可能来一个 usage-only chunk(choices 为空),直接跳过
choices = data.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
continue
class AnthropicProvider(LLMProvider):
name = "claude"
max_context = 200_000
def __init__(self, api_key: str | None = None, model: str | None = None):
self.api_key = api_key if api_key is not None else os.getenv("ANTHROPIC_API_KEY", "")
self.model = model if model is not None else os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")
def _to_anthropic_messages(self, messages: list[dict]) -> tuple[str | None, list[dict]]:
system_msg = None
converted = []
for m in messages:
if m["role"] == "system":
system_msg = m["content"]
else:
converted.append({"role": m["role"], "content": m["content"]})
return system_msg, converted
async def chat(self, messages: list[dict], temperature: float = 0.7) -> ChatResponse:
import httpx
system_msg, anthropic_msgs = self._to_anthropic_messages(messages)
body = {
"model": self.model,
"max_tokens": 4096,
"messages": anthropic_msgs,
"temperature": temperature,
}
if system_msg:
body["system"] = system_msg
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json=body,
)
data = resp.json()
if "error" in data:
raise RuntimeError(f"Anthropic error: {data['error']}")
content_blocks = data.get("content", [])
text = "".join(b.get("text", "") for b in content_blocks if b.get("type") == "text")
return ChatResponse(
content=text,
model=data["model"],
prompt_tokens=data.get("usage", {}).get("input_tokens", 0),
completion_tokens=data.get("usage", {}).get("output_tokens", 0),
)
async def chat_stream(self, messages: list[dict], temperature: float = 0.7) -> AsyncGenerator[str, None]:
import httpx
system_msg, anthropic_msgs = self._to_anthropic_messages(messages)
body = {
"model": self.model,
"max_tokens": 4096,
"messages": anthropic_msgs,
"temperature": temperature,
"stream": True,
}
if system_msg:
body["system"] = system_msg
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream(
"POST",
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json=body,
) as resp:
async for line in resp.aiter_lines():
if line.startswith("data: "):
data_str = line[6:]
try:
data = json.loads(data_str)
if data.get("type") == "content_block_delta":
delta = data.get("delta", {})
text = delta.get("text", "")
if text:
yield text
except json.JSONDecodeError:
continue
class DeepSeekProvider(OpenAIProvider):
name = "deepseek"
max_context = 128_000
def __init__(self, api_key: str | None = None, model: str | None = None):
super().__init__(
api_key=api_key if api_key is not None else os.getenv("DEEPSEEK_API_KEY", ""),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"),
model=model if model is not None else os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
)
# ---- 内置模型(.env)与用户自定义模型(ai_models 表)的统一解析 ----
def builtin_models() -> list[dict]:
"""扫描 .env 里已配置 key 的内置模型(无 key 的不返回,前端不会展示废选项)。
label 跟随 .env 的 model_id 动态生成,不硬编码(如 DEEPSEEK_MODEL=deepseek-flash
就显示「DeepSeek deepseek-flash」,避免与实际请求的模型对不上)。
"""
out = []
if os.getenv("DEEPSEEK_API_KEY"):
mid = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
out.append({
"name": "deepseek", "label": f"DeepSeek {mid}",
"kind": "openai_compatible", "base_url": os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"),
"api_key_masked": _mask_key(os.getenv("DEEPSEEK_API_KEY")),
"model_id": mid, "builtin": True,
})
if os.getenv("OPENAI_API_KEY"):
mid = os.getenv("OPENAI_MODEL", "gpt-4o")
out.append({
"name": "openai", "label": f"GPT {mid}",
"kind": "openai_compatible", "base_url": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
"api_key_masked": _mask_key(os.getenv("OPENAI_API_KEY")),
"model_id": mid, "builtin": True,
})
if os.getenv("ANTHROPIC_API_KEY"):
mid = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")
out.append({
"name": "claude", "label": f"Claude {mid}",
"kind": "anthropic", "base_url": "https://api.anthropic.com/v1",
"api_key_masked": _mask_key(os.getenv("ANTHROPIC_API_KEY")),
"model_id": mid, "builtin": True,
})
if os.getenv("OPENROUTER_API_KEY"):
mid = os.getenv("OPENROUTER_MODEL", "openrouter/auto")
out.append({
"name": "openrouter", "label": f"OpenRouter {mid}",
"kind": "openai_compatible", "base_url": "https://openrouter.ai/api/v1",
"api_key_masked": _mask_key(os.getenv("OPENROUTER_API_KEY")),
"model_id": mid, "builtin": True,
})
return out
def _mask_key(key: str | None) -> str:
if not key:
return ""
if len(key) <= 8:
return "****"
return f"{key[:4]}****{key[-4:]}"
def _builtin_provider(entry: dict) -> LLMProvider:
"""内置模型:key 从对应 .env 变量读取(builtin_models 只暴露掩码,不回传明文)。"""
name = entry["name"]
if name == "deepseek":
return DeepSeekProvider()
if name == "claude":
return AnthropicProvider()
if name == "openrouter":
return OpenAIProvider(
api_key=os.getenv("OPENROUTER_API_KEY", ""),
base_url="https://openrouter.ai/api/v1",
model=os.getenv("OPENROUTER_MODEL", "openrouter/auto"),
)
return OpenAIProvider() # openai
def get_provider(name: str) -> LLMProvider | None:
"""兼容旧的同步入口(仅内置模型)。"""
for m in builtin_models():
if m["name"] == name:
return _builtin_provider(m)
return None
async def resolve_provider(db: AsyncSession, name: str) -> LLMProvider | None:
"""按模型名解析 provider:内置名走 .env 默认构造,自定义走 ai_models 表;找不到返回 None。"""
from sqlalchemy import select
from app.models.ai_model import AiModel
for m in builtin_models():
if m["name"] == name:
return _builtin_provider(m)
result = await db.execute(select(AiModel).where(AiModel.name == name))
row = result.scalars().first()
if row is None:
return None
if row.kind == "anthropic":
return AnthropicProvider(api_key=row.api_key, model=row.model_id)
return OpenAIProvider(api_key=row.api_key, base_url=row.base_url, model=row.model_id)
+42
View File
@@ -0,0 +1,42 @@
from contextlib import asynccontextmanager
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import init_db, Base
from app.api.books import router as books_router
from app.api.chat import router as chat_router
from app.api.models import router as models_router
from app.models.book import Book, Chapter, Segment
from app.models.chat import Conversation, Message
from app.models.ai_model import AiModel
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title="BookMate", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(books_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
app.include_router(models_router, prefix="/api")
@app.get("/api/health")
async def health():
return {"status": "ok"}
View File
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Integer, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
def gen_uuid():
return str(uuid.uuid4())
class AiModel(Base):
"""用户从页面配置的 AI 模型(OpenAI 兼容 / Anthropic)。
.env 内置模型不落这张表,由 hub.builtin_models() 动态读取。
"""
__tablename__ = "ai_models"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
name: Mapped[str] = mapped_column(String(200), unique=True) # 解析标识(请求模型 id)
label: Mapped[str] = mapped_column(String(200)) # 前端显示名
kind: Mapped[str] = mapped_column(String(50), default="openai_compatible") # openai_compatible | anthropic
base_url: Mapped[str] = mapped_column(String(500), default="")
api_key: Mapped[str] = mapped_column(Text, default="") # 明文本地存储(与 .env 同级信任)
model_id: Mapped[str] = mapped_column(String(200)) # 实际请求模型 id
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+60
View File
@@ -0,0 +1,60 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Float, Text, DateTime, ForeignKey, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def gen_uuid():
return str(uuid.uuid4())
class Book(Base):
__tablename__ = "books"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
filename: Mapped[str] = mapped_column(String(500), nullable=False)
title: Mapped[str | None] = mapped_column(String(500))
author: Mapped[str | None] = mapped_column(String(500))
language: Mapped[str] = mapped_column(String(20), default="zh")
file_path: Mapped[str] = mapped_column(String(1000), nullable=False)
file_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
char_count: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(20), default="uploaded")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
chapters: Mapped[list["Chapter"]] = relationship(back_populates="book", cascade="all, delete-orphan")
class Chapter(Base):
__tablename__ = "chapters"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"))
order_idx: Mapped[int] = mapped_column(Integer, default=0)
title: Mapped[str | None] = mapped_column(String(500))
level: Mapped[int] = mapped_column(Integer, default=1)
heading_path: Mapped[str | None] = mapped_column(Text)
cfi_start: Mapped[str | None] = mapped_column(String(500))
cfi_end: Mapped[str | None] = mapped_column(String(500))
char_count: Mapped[int] = mapped_column(Integer, default=0)
book: Mapped["Book"] = relationship(back_populates="chapters")
segments: Mapped[list["Segment"]] = relationship(back_populates="chapter", cascade="all, delete-orphan")
class Segment(Base):
__tablename__ = "segments"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
chapter_id: Mapped[str] = mapped_column(String(36), ForeignKey("chapters.id", ondelete="CASCADE"))
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"))
order_idx: Mapped[int] = mapped_column(Integer, default=0)
text: Mapped[str] = mapped_column(Text, nullable=False)
char_count: Mapped[int] = mapped_column(Integer, default=0)
cfi: Mapped[str | None] = mapped_column(String(500))
page_estimate: Mapped[float | None] = mapped_column(Float)
metadata_: Mapped[str | None] = mapped_column("metadata", Text)
embedding: Mapped[str | None] = mapped_column(Text) # JSON 浮点数组(向量检索用);NULL = 尚未生成向量
chapter: Mapped["Chapter"] = relationship(back_populates="segments")
+43
View File
@@ -0,0 +1,43 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def gen_uuid():
return str(uuid.uuid4())
class Conversation(Base):
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"))
title: Mapped[str | None] = mapped_column(String(500))
model: Mapped[str] = mapped_column(String(50), default="claude")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation", cascade="all, delete-orphan",
order_by="Message.created_at"
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
conversation_id: Mapped[str] = mapped_column(String(36), ForeignKey("conversations.id", ondelete="CASCADE"))
parent_message_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("messages.id", ondelete="SET NULL"))
role: Mapped[str] = mapped_column(String(20))
content: Mapped[str] = mapped_column(Text, nullable=False)
citations: Mapped[str | None] = mapped_column("citations_json", Text)
model: Mapped[str | None] = mapped_column(String(50))
prompt_tokens: Mapped[int | None] = mapped_column()
completion_tokens: Mapped[int | None] = mapped_column()
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
parent: Mapped["Message | None"] = relationship("Message", remote_side="Message.id")
View File
+286
View File
@@ -0,0 +1,286 @@
import re
import jieba
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import async_session
from app.models.book import Book, Chapter, Segment
from app.rag.vector import (
embed_texts, rerank_documents, load_vectors, vector_search,
rrf_fuse, ensure_embeddings, get_status, RERANK_ENABLED,
)
CITATION_RE = re.compile(r"\[\[ref:(\d+)\]\]")
# 保守停用词:只过滤纯功能词,避免误删有检索价值的实词
STOPWORDS = set(
"的 了 呢 吗 吧 啊 呀 嘛 哦 啦 哈 么 这 那 是 在 和 与 或 之 对 从 有 就 都 也 很 还 又 而 却 但 但是 然而 因此 所以 因为 如果 我们 你们 他们 它们 她们 什么 怎么 为什么 如何 这个 那个 一些 一个 本书 这本书 内容 主题 大概 比如 例如 应该 可能 可以 时候 里面 当中 其中 一些 部分 相关 关于 请问 告诉 解释 介绍 总结 分析".split()
)
def _tokenize(query: str) -> list[str]:
"""分词:jieba 词 + 整句(精确子串)+ 双字 bigram,过滤停用词与纯符号。"""
tokens: set[str] = set()
for w in jieba.cut(query):
w = w.strip().lower()
if not w or w in STOPWORDS or re.fullmatch(r"[\s\W_]+", w):
continue
if len(w) >= 2:
tokens.add(w)
q = query.strip().lower()
if len(q) >= 4 and q not in STOPWORDS:
tokens.add(q)
# 中文连续串生成 bigram,增强长句召回(SQL LIKE 无法分词匹配)
for part in re.findall(r"[一-鿿]+", q):
if len(part) >= 4:
tokens.update(part[i:i + 2] for i in range(len(part) - 1))
# 过滤 bigram 中出现的停用字组合(如「的」开头的)
tokens = {t for t in tokens if not any(c in STOPWORDS for c in t) or len(t) >= 4}
return list(tokens)
def match_chapters(chapters: list[Chapter], query: str, tokens: list[str] | None = None) -> list[Chapter]:
"""章节标题匹配:查询词命中标题,或标题词命中查询。按目录顺序返回。"""
if not chapters:
return []
if tokens is None:
tokens = _tokenize(query)
q = query.lower()
hits = []
for ch in chapters:
title = (ch.title or "").strip().lower()
if not title or len(title) < 2:
continue
if any(w and w in title for w in tokens):
hits.append(ch)
continue
title_tokens = [
w.strip() for w in jieba.cut(title)
if len(w.strip()) >= 2 and w.strip() not in STOPWORDS
]
if any(tw in q for tw in title_tokens):
hits.append(ch)
hits.sort(key=lambda c: c.order_idx)
return hits
async def _keyword_candidates(db: AsyncSession, book_id: str, tokens: list[str], limit: int) -> list[Segment]:
"""关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。"""
if not tokens:
return []
ordered = sorted(tokens, key=len, reverse=True)[:8]
conds = [Segment.text.contains(w) for w in ordered]
q = (
select(Segment)
.where(Segment.book_id == book_id, or_(*conds))
.order_by(Segment.order_idx)
.limit(limit * 4)
)
result = await db.execute(q)
segs = list(result.scalars().all())
scored = []
for s in segs:
text = s.text.lower()
score = sum(3 if len(w) >= 4 else 1 for w in ordered if w in text)
scored.append((score, s))
scored.sort(key=lambda x: -x[0])
return [s for _, s in scored[:limit]]
async def _empty_fallback(db: AsyncSession, book_id: str, chapters: list[Chapter], top_k: int) -> list[Segment]:
"""空检索兜底:返回目录最靠前的章节的开头段(全书开头),让 AI 至少有所依据。"""
result = await db.execute(
select(Segment)
.where(Segment.book_id == book_id)
.order_by(Segment.order_idx)
.limit(top_k * 3)
)
segs = [s for s in result.scalars().all() if (s.text or "").strip()]
return segs[:top_k]
async def search_segments(
db: AsyncSession,
book_id: str,
query: str,
top_k: int = 15,
chapters: list[Chapter] | None = None,
) -> list[Segment]:
"""混合检索:jieba 关键词 + API 向量余弦 → RRF 融合 → rerank 精排 → top_k。
未配置 / 未生成向量 / API 失败时自动降级为纯关键词检索,绝不空手返回。
"""
tokens = _tokenize(query)
# 懒补向量(后台任务没跑完 / 重启后缺失时)
if get_status(book_id) != "done":
# 用独立会话执行:ensure_embeddings 失败时的 rollback 会过期会话内已加载的实例,
# 不能让调用方持有的 book/chapters 一起被过期(异步惰性访问会抛 MissingGreenlet)
async with async_session() as embed_session:
status = await ensure_embeddings(embed_session, book_id)
if status == "running":
# 后台正在生成,本次先用关键词,避免阻塞
status = "failed"
# 1. 关键词候选
kw_candidates = await _keyword_candidates(db, book_id, tokens, top_k * 3)
# 2. 向量候选
vec_candidates: list[Segment] = []
if tokens:
qvec = await embed_texts([query])
if qvec and qvec[0]:
mat, vec_segments = await load_vectors(db, book_id)
if mat is not None and len(mat) > 0:
idxs = vector_search(qvec[0], mat, top_k * 3)
vec_candidates = [vec_segments[i] for i in idxs]
# 3. RRF 融合
if kw_candidates and vec_candidates:
by_id = {s.id: s for s in kw_candidates + vec_candidates}
fused = rrf_fuse(
[s.id for s in kw_candidates],
[s.id for s in vec_candidates],
)
merged = [by_id[sid] for sid in fused if sid in by_id]
elif vec_candidates:
merged = vec_candidates
else:
merged = kw_candidates
# 4. rerank 精排(对融合后的 top50 段打分)
if merged and RERANK_ENABLED:
docs = [s.text[:800] for s in merged[:50]]
idxs = await rerank_documents(query, docs, top_n=top_k)
if idxs is not None:
merged = [merged[i] for i in idxs if i < len(merged)]
# 5. 兜底:完全没召回时给全书开头段,而不是空着
if not merged:
merged = await _empty_fallback(db, book_id, chapters or [], top_k)
return merged[:top_k]
def _chapter_full_text(chapter_segments: list[Segment], budget_tokens: int = 6000) -> str:
"""整章注入:按 token 预算截取命中章节的原文。"""
parts = []
used = 0
for seg in chapter_segments:
t = seg.text
cost = estimate_tokens(t)
if parts and used + cost > budget_tokens:
break
parts.append(t)
used += cost
return "\n\n".join(parts)
def build_chat_prompt(
book: Book,
chapters: list[Chapter],
segments: list[Segment],
history: list[dict],
query: str,
chapter_segments: list[Segment] | None = None,
) -> list[dict]:
system = f"""# 你是 BookMate,一个帮助用户阅读书籍的 AI 助手。
## 当前书籍
- 书名:《{book.title or '未知'}》
- 作者:{book.author or '未知'}
- 总字数:约 {book.char_count:,} 字
## 目录结构
"""
for ch in chapters[:40]:
indent = " " * (ch.level - 1)
system += f"{indent}- {ch.title or '(无标题)'}\n"
if len(chapters) > 40:
system += f" ...(共 {len(chapters)} 章)\n"
# 命中章节的整章原文:全局性问题(如「第3章讲了什么」)直接给全文
chapter_text = _chapter_full_text(chapter_segments or [])
if chapter_text:
system += "\n## 与问题直接相关的整章原文\n"
system += chapter_text + "\n"
if segments:
system += "\n## 检索到的相关原文段落\n"
for i, seg in enumerate(segments, 1):
text_preview = seg.text[:800].replace("\n", " ")
system += f"\n[[ref:{i}]] {text_preview}\n"
else:
system += (
"\n## 检索说明\n"
"本次没有检索到与问题直接匹配的原文段落。请基于上方目录结构、"
"书名与你的常识,指出书中哪些章节最可能涉及该主题,"
"并明确说明「这是基于目录的推断」,不要编造具体原文内容。\n"
)
system += """
## 回答要求
1. 优先基于「检索到的相关原文段落」和「整章原文」回答,不要臆造书里没有的内容
2. 引用原文时必须带 [[ref:N]] 标记(N 对应上方段落编号);同一段落只需引用一次,不要反复出现相同的 [[ref:N]];整个回答的引用控制在 5 条左右,只引用你真正依据的段落
3. 如果检索内容不足以回答,坦诚说明,并给出基于目录的建议
4. 用中文回答
5. 当用户要求「思维导图 / 脑图 / mind map」时,输出 mermaid mindmap 语法,放在 ```mermaid 代码块里。格式:第一行写 `mindmap`,根节点写 `root((主题))`,子节点通过缩进表示层级,例如:
```mermaid
mindmap
root((主题))
一级分支
二级分支
```
不要把其他解释文字塞进代码块,代码块外可以有简短说明。"""
messages = [{"role": "system", "content": system}]
if history:
messages.extend(history[-10:])
messages.append({"role": "user", "content": query})
return messages
def _trim_messages(messages: list[dict], budget_tokens: int) -> list[dict]:
"""内容感知裁剪:保留 system(含检索原文)与最新提问,从最旧历史开始丢弃。
替代原先粗糙的 `messages[:1] + messages[-(len//2):]`,避免裁掉检索上下文。
"""
if estimate_tokens("".join(m["content"] for m in messages)) <= budget_tokens:
return messages
system = messages[0]
tail = messages[-1]
used = estimate_tokens(system["content"] + tail["content"])
kept = []
for m in reversed(messages[1:-1]):
cost = estimate_tokens(m["content"])
if used + cost <= budget_tokens:
kept.append(m)
used += cost
else:
break
kept.reverse()
return [system] + kept + [tail]
def parse_citations(content: str, segments: list[Segment]) -> dict:
citations = []
for m in CITATION_RE.finditer(content):
idx = int(m.group(1)) - 1
if 0 <= idx < len(segments):
seg = segments[idx]
citations.append({
"segment_id": seg.id,
"cfi": seg.cfi,
"excerpt": seg.text[:200],
})
clean_text = CITATION_RE.sub("", content)
return {"text": clean_text, "citations": citations}
def estimate_tokens(text: str) -> int:
"""粗略估算 token 数:中文每字约 1.5 token,英文每词约 1.3 token"""
chinese_chars = len(re.findall(r"[一-鿿]", text))
english_words = len(re.findall(r"[a-zA-Z]+", text))
return int(chinese_chars * 1.5 + english_words * 1.3 + (len(text) - chinese_chars - len(re.findall(r"[a-zA-Z]", text))) * 0.5)
+218
View File
@@ -0,0 +1,218 @@
"""向量检索服务(B 档)。
- embedding:调用 OpenAI 兼容 /embeddings API(如硅基流动 `BAAI/bge-m3`,1024 维)
- rerank:调用 /v1/rerank API(如硅基流动 `BAAI/bge-reranker-v2-m3`)
- 检索:书本段规模几千~几万,直接全量 numpy 余弦扫描,无需引入向量数据库
- 融合:关键词结果 + 向量结果做 RRF(Reciprocal Rank Fusion)
- 降级:未配置 API Key / 调用失败时自动退回纯关键词模式,绝不阻塞主流程
"""
import json
import logging
import threading
import numpy as np
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.book import Segment
logger = logging.getLogger(__name__)
EMBEDDING_ENABLED = bool(settings.embedding_api_key)
RERANK_ENABLED = bool(settings.rerank_api_key)
# book_id -> 向量生成状态:"done" / "running" / "failed"
_vector_status: dict[str, str] = {}
_status_lock = threading.Lock()
# ======================= API 客户端 =======================
async def embed_texts(texts: list[str]) -> list[list[float] | None] | None:
"""生成一批文本的向量,返回与 texts 同序的列表(单条失败置 None,其余正常)。
单次请求过大 / 服务端失败时自动对半拆开递归重试,直到单条;
单条超模型输入上限时截断重试一次,仍失败则跳过该条(置 None)。
未配置时返回 None;鉴权/配置类错误(API 不可用)直接抛出。
"""
if not EMBEDDING_ENABLED or not texts:
return None
url = settings.embedding_base_url.rstrip("/") + "/embeddings"
headers = {"Authorization": f"Bearer {settings.embedding_api_key}"}
out: list[list[float] | None] = [None] * len(texts)
# 空文本无法向量化(OpenRouter 对空串直接 400 too_small),预过滤不送请求
tasks: list[tuple[int, str]] = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
if not tasks:
return out
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=15.0)) as client:
async def _call(items: list[str], idxs: list[int]) -> None:
if not items:
return
try:
resp = await client.post(
url, headers=headers,
json={"model": settings.embedding_model, "input": items},
)
resp.raise_for_status()
rows = resp.json().get("data") or []
for j, row in enumerate(rows):
idx = row.get("index", j)
if 0 <= idx < len(idxs) and isinstance(row.get("embedding"), list):
out[idxs[idx]] = [float(v) for v in row["embedding"]]
except Exception as e: # noqa: BLE001
# 鉴权/配置错误拆分也救不了,直接整体失败
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code in (401, 403, 404, 405):
raise
if len(items) > 1:
mid = len(items) // 2
await _call(items[:mid], idxs[:mid])
await _call(items[mid:], idxs[mid:])
elif len(items[0]) > 1500:
# 单条超模型输入上限:截断再试一次(超长段向量化本就意义有限)
try:
resp = await client.post(
url, headers=headers,
json={"model": settings.embedding_model, "input": [items[0][:1500]]},
)
resp.raise_for_status()
rows = resp.json().get("data") or []
if rows and isinstance(rows[0].get("embedding"), list):
out[idxs[0]] = [float(v) for v in rows[0]["embedding"]]
except Exception as e2: # noqa: BLE001
logger.warning("embedding 单条失败(跳过): %s", e)
else:
logger.warning("embedding 单条失败(跳过): %s", e)
await _call([t for _, t in tasks], [i for i, _ in tasks])
return out
async def rerank_documents(query: str, documents: list[str], top_n: int | None = None) -> list[int] | None:
"""调用 rerank API。返回按相关性降序的原始文档索引;未配置或失败返回 None。"""
if not RERANK_ENABLED or not documents:
return None
url = settings.rerank_base_url.rstrip("/") + "/rerank"
headers = {"Authorization": f"Bearer {settings.rerank_api_key}"}
payload = {"model": settings.rerank_model, "query": query, "documents": documents}
if top_n:
payload["top_n"] = top_n
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
results = resp.json()["results"]
ordered = sorted(results, key=lambda r: -float(r["relevance_score"]))
return [int(r["index"]) for r in ordered]
except Exception as e: # noqa: BLE001
logger.warning("rerank API 调用失败: %s", e)
return None
# ======================= 向量检索 =======================
async def load_vectors(db: AsyncSession, book_id: str) -> tuple[np.ndarray | None, list[Segment]]:
"""读取该书所有已生成向量的段。返回 (向量矩阵, 对应段列表)。"""
result = await db.execute(
select(Segment)
.where(Segment.book_id == book_id, Segment.embedding.is_not(None))
.order_by(Segment.order_idx)
)
segs = list(result.scalars().all())
vecs = []
kept = []
for s in segs:
try:
vecs.append(json.loads(s.embedding))
kept.append(s)
except (json.JSONDecodeError, TypeError):
continue
if not vecs:
return None, []
return np.asarray(vecs, dtype=np.float32), kept
def vector_search(query_vec: list[float], mat: np.ndarray, top_k: int) -> list[int]:
"""余弦相似度 top_k,返回 mat 行索引(降序)。"""
q = np.asarray(query_vec, dtype=np.float32)
q_norm = np.linalg.norm(q)
if q_norm == 0:
return []
sims = (mat @ q) / (np.linalg.norm(mat, axis=1) * q_norm + 1e-9)
top = np.argsort(-sims)[:top_k]
return [int(i) for i in top]
def rrf_fuse(*rankings: list[str], k: int = 60) -> list[str]:
"""Reciprocal Rank Fusion:合并多个按相关性降序的 segment_id 列表。"""
scores: dict[str, float] = {}
for ranking in rankings:
for rank, sid in enumerate(ranking):
scores[sid] = scores.get(sid, 0.0) + 1.0 / (k + rank + 1)
return [sid for sid, _ in sorted(scores.items(), key=lambda kv: -kv[1])]
# ======================= 向量生成(上传后后台 + 检索前懒补)=======================
def _set_status(book_id: str, status: str) -> None:
with _status_lock:
_vector_status[book_id] = status
def get_status(book_id: str) -> str | None:
with _status_lock:
return _vector_status.get(book_id)
async def ensure_embeddings(db: AsyncSession, book_id: str) -> str:
"""确保 book 的每个 Segment 都有向量。返回状态 done / running / failed / disabled。
- 后台任务与请求可能并发调用:running 状态会直接返回,由另一方补齐
- 只对缺少向量的段生成,重复调用是增量补差
"""
if not EMBEDDING_ENABLED:
_set_status(book_id, "failed")
return "disabled"
status = get_status(book_id)
if status == "running":
return "running"
result = await db.execute(
select(Segment)
.where(Segment.book_id == book_id, Segment.embedding.is_(None))
.order_by(Segment.order_idx)
)
missing = list(result.scalars().all())
# 空文本段无法向量化(API 对空串 400),也永远无检索价值,直接排除
missing = [s for s in missing if (s.text or "").strip()]
if not missing:
_set_status(book_id, "done")
return "done"
_set_status(book_id, "running")
try:
batch = settings.embedding_batch_size
for start in range(0, len(missing), batch):
segs = missing[start:start + batch]
vecs = await embed_texts([s.text for s in segs])
if vecs is None:
raise RuntimeError("embedding API 不可用")
# 单条失败时 vec 为 None,跳过不落库;其余照常持久化
for seg, vec in zip(segs, vecs):
if vec is not None:
seg.embedding = json.dumps([float(v) for v in vec], ensure_ascii=False)
await db.commit()
# 已尝试全部缺失段。个别被模型拒绝的段(内容/超限)由关键词档兜底,
# 标记 done 避免每次检索都重复重试;API 级故障会在 except 里落为 failed
_set_status(book_id, "done")
return "done"
except Exception as e: # noqa: BLE001
logger.warning("向量生成失败 book=%s: %s", book_id, e)
_set_status(book_id, "failed")
# 已提交的批次不受影响;rollback 仅丢弃本批次未提交变更
await db.rollback()
return "failed"
View File
+56
View File
@@ -0,0 +1,56 @@
from pydantic import BaseModel
from datetime import datetime
class BookOut(BaseModel):
id: str
filename: str
title: str | None
author: str | None
language: str
char_count: int
status: str
created_at: datetime
class Config:
from_attributes = True
class BookDetailOut(BookOut):
chapter_count: int = 0
segment_count: int = 0
class ChapterOut(BaseModel):
id: str
book_id: str
order_idx: int
title: str | None
level: int
heading_path: str | None
cfi_start: str | None
cfi_end: str | None
char_count: int
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: str
chapter_id: str
book_id: str
order_idx: int
text: str
char_count: int
cfi: str | None
class Config:
from_attributes = True
class ParseStatus(BaseModel):
book_id: str
status: str
title: str | None
char_count: int