feat: add BookMate AI reading workspace and full EPUB reader
This commit is contained in:
@@ -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}
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user