fr1: books.ruzzy.dev — читалка с ИИ (PDF/EPUB → главы, рекапы, перевод/объяснение выделенного, вопросы с RAG; Claude Code по подписке без инструментов; вход через Telegram)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8fd84239be
commit
9aab5b3f40
@@ -0,0 +1,316 @@
|
||||
"""books.ruzzy.dev — читалка с ИИ: библиотека, главы, рекапы, перевод/объяснение выделенного, вопросы по книгам."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
|
||||
import library as L
|
||||
import llm
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
TG_TOKEN = os.environ.get("TG_BOT_TOKEN", "")
|
||||
TG_BOT = os.environ.get("TG_BOT_NAME", "")
|
||||
ALLOWED = {int(x) for x in os.environ.get("ALLOWED_TG_IDS", "607015").split(",") if x.strip()}
|
||||
SECRET = os.environ.get("SESSION_SECRET") or ""
|
||||
DEV = os.environ.get("BOOKS_DEV") == "1" # локальная отладка без Telegram
|
||||
MAX_UPLOAD = 200 * 1024 * 1024
|
||||
SESSION_DAYS = 30
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
|
||||
tpl = Jinja2Templates(directory=BASE / "templates")
|
||||
signer = URLSafeTimedSerializer(SECRET or "dev-only", salt="books-session")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- вход через Telegram
|
||||
|
||||
def user_of(req: Request):
|
||||
if DEV:
|
||||
return 607015
|
||||
tok = req.cookies.get("s")
|
||||
if not tok or not SECRET:
|
||||
return None
|
||||
try:
|
||||
uid = signer.loads(tok, max_age=SESSION_DAYS * 86400)
|
||||
except BadSignature:
|
||||
return None
|
||||
return uid if uid in ALLOWED else None
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def guard(req: Request, call_next):
|
||||
p = req.url.path
|
||||
if p.startswith(("/static/", "/login", "/auth/telegram", "/robots.txt")):
|
||||
return await call_next(req)
|
||||
if user_of(req) is None:
|
||||
if p.startswith("/api/"):
|
||||
return PlainTextResponse("нужен вход", status_code=401)
|
||||
return RedirectResponse("/login")
|
||||
resp = await call_next(req)
|
||||
resp.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
def robots():
|
||||
return "User-agent: *\nDisallow: /\n"
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
def login(req: Request, err: str = ""):
|
||||
return tpl.TemplateResponse(req, "login.html", {"bot": TG_BOT, "err": err})
|
||||
|
||||
|
||||
@app.get("/auth/telegram")
|
||||
def auth_telegram(req: Request):
|
||||
"""Проверка подписи Telegram Login Widget: HMAC-SHA256 c ключом sha256(токен бота)."""
|
||||
q = dict(req.query_params)
|
||||
h = q.pop("hash", "")
|
||||
if not TG_TOKEN or not SECRET or not h:
|
||||
return RedirectResponse("/login?err=не настроен вход")
|
||||
check = "\n".join(f"{k}={q[k]}" for k in sorted(q))
|
||||
good = hmac.new(hashlib.sha256(TG_TOKEN.encode()).digest(), check.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(good, h):
|
||||
return RedirectResponse("/login?err=подпись не сошлась")
|
||||
if time.time() - int(q.get("auth_date", "0")) > 86400:
|
||||
return RedirectResponse("/login?err=ссылка входа устарела")
|
||||
uid = int(q.get("id", "0"))
|
||||
if uid not in ALLOWED:
|
||||
return RedirectResponse("/login?err=доступ только владельцу")
|
||||
r = RedirectResponse("/")
|
||||
r.set_cookie("s", signer.dumps(uid), max_age=SESSION_DAYS * 86400, httponly=True, secure=True, samesite="lax")
|
||||
return r
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
def logout():
|
||||
r = RedirectResponse("/login")
|
||||
r.delete_cookie("s")
|
||||
return r
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- страницы
|
||||
|
||||
def book_or_404(bid):
|
||||
b = L.db().execute("SELECT * FROM book WHERE id=?", (bid,)).fetchone()
|
||||
if not b:
|
||||
raise HTTPException(404, "нет такой книги")
|
||||
return b
|
||||
|
||||
|
||||
def chapter_or_404(bid, idx):
|
||||
ch = L.db().execute("SELECT * FROM chapter WHERE book_id=? AND idx=?", (bid, idx)).fetchone()
|
||||
if not ch:
|
||||
raise HTTPException(404, "нет такой главы")
|
||||
return ch
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(req: Request):
|
||||
c = L.db()
|
||||
books = c.execute("""SELECT b.*, (SELECT count(*) FROM chapter WHERE book_id=b.id) n,
|
||||
(SELECT count(*) FROM chapter WHERE book_id=b.id AND read=1) nread,
|
||||
(SELECT count(*) FROM recap r JOIN chapter ch ON ch.id=r.chapter_id WHERE ch.book_id=b.id) nrecap
|
||||
FROM book b ORDER BY b.id DESC""").fetchall()
|
||||
return tpl.TemplateResponse(req, "index.html", {"books": books})
|
||||
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload(file: UploadFile = File(...)):
|
||||
data = await file.read(MAX_UPLOAD + 1)
|
||||
if len(data) > MAX_UPLOAD:
|
||||
raise HTTPException(413, "файл больше 200 МБ")
|
||||
try:
|
||||
bid = L.add_book(file.filename or "book.pdf", data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return RedirectResponse(f"/book/{bid}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/book/{bid}", response_class=HTMLResponse)
|
||||
def book_page(req: Request, bid: int):
|
||||
b = book_or_404(bid)
|
||||
c = L.db()
|
||||
chapters = c.execute("""SELECT ch.id, ch.idx, ch.title, ch.level, ch.p1, ch.p2, ch.chars, ch.read,
|
||||
r.md IS NOT NULL AND r.md != '' AS has_recap FROM chapter ch LEFT JOIN recap r ON r.chapter_id=ch.id
|
||||
WHERE ch.book_id=? ORDER BY ch.idx""", (bid,)).fetchall()
|
||||
qa = c.execute("SELECT * FROM qa WHERE book_id=? AND kind='ask' ORDER BY id DESC LIMIT 20", (bid,)).fetchall()
|
||||
return tpl.TemplateResponse(req, "book.html", {"b": b, "chapters": chapters, "qa": qa})
|
||||
|
||||
|
||||
@app.post("/book/{bid}/delete")
|
||||
def book_delete(bid: int, confirm: str = Form("")):
|
||||
if confirm != "да":
|
||||
raise HTTPException(400, "подтверди удаление: впиши «да»")
|
||||
L.delete_book(bid)
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@app.post("/book/{bid}/title")
|
||||
def book_title(bid: int, title: str = Form(...), author: str = Form("")):
|
||||
c = L.db()
|
||||
c.execute("UPDATE book SET title=?, author=? WHERE id=?", (title.strip(), author.strip(), bid))
|
||||
c.commit()
|
||||
return RedirectResponse(f"/book/{bid}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/book/{bid}/file")
|
||||
def book_file(bid: int):
|
||||
b = book_or_404(bid)
|
||||
media = "application/pdf" if b["fmt"] == "pdf" else "application/epub+zip"
|
||||
return FileResponse(L.FILES / b["file"], media_type=media, filename=b["orig"] or b["file"],
|
||||
content_disposition_type="inline")
|
||||
|
||||
|
||||
def render_text(text):
|
||||
"""Текст главы → HTML-абзацы с отметками страниц (данные экранируем)."""
|
||||
import html
|
||||
out = []
|
||||
for part in re.split(r"\[\[p(\d+)\]\]\n?", text):
|
||||
if part.isdigit():
|
||||
out.append(f'<div class="pg" id="p{part}">— с. {part} —</div>')
|
||||
continue
|
||||
for para in re.split(r"\n\s*\n", part):
|
||||
para = para.strip()
|
||||
if para:
|
||||
out.append("<p>" + html.escape(para).replace("\n", " ") + "</p>")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@app.get("/book/{bid}/ch/{idx}", response_class=HTMLResponse)
|
||||
def chapter_page(req: Request, bid: int, idx: int):
|
||||
b = book_or_404(bid)
|
||||
ch = chapter_or_404(bid, idx)
|
||||
c = L.db()
|
||||
r = c.execute("SELECT * FROM recap WHERE chapter_id=?", (ch["id"],)).fetchone()
|
||||
n = c.execute("SELECT max(idx) FROM chapter WHERE book_id=?", (bid,)).fetchone()[0]
|
||||
c.execute("UPDATE book SET last_ch=? WHERE id=?", (idx, bid))
|
||||
c.commit()
|
||||
return tpl.TemplateResponse(req, "chapter.html", {"b": b, "ch": ch, "recap": r, "n": n,
|
||||
"body": render_text(ch["text"])})
|
||||
|
||||
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
def search_page(req: Request, q: str = "", book: int | None = None):
|
||||
hits = L.search(q, book, k=20) if q.strip() else []
|
||||
return tpl.TemplateResponse(req, "search.html", {"q": q, "hits": hits, "book": book})
|
||||
|
||||
|
||||
@app.get("/book/{bid}/recaps.md")
|
||||
def recaps_md(bid: int):
|
||||
b = book_or_404(bid)
|
||||
rows = L.db().execute("""SELECT ch.idx, ch.title, r.md FROM chapter ch JOIN recap r ON r.chapter_id=ch.id
|
||||
WHERE ch.book_id=? AND r.md != '' ORDER BY ch.idx""", (bid,)).fetchall()
|
||||
md = f"# {b['title']}\n\n" + (f"{b['author']}\n\n" if b["author"] else "")
|
||||
md += "\n\n".join(f"## Глава {r['idx']}. {r['title']}\n\n{r['md'].strip()}" for r in rows) + "\n"
|
||||
name = re.sub(r'[\\/:*?"<>|]', " ", b["title"] or "book")[:80]
|
||||
return PlainTextResponse(md, media_type="text/markdown",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{urllib.parse.quote(name)}.md"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- API
|
||||
|
||||
def text_stream(gen, on_done=None):
|
||||
def run():
|
||||
acc = []
|
||||
for piece in gen:
|
||||
acc.append(piece)
|
||||
yield piece
|
||||
if on_done:
|
||||
on_done("".join(acc))
|
||||
return StreamingResponse(run(), media_type="text/plain; charset=utf-8",
|
||||
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
def log_qa(bid, idx, kind, q, a):
|
||||
c = L.db()
|
||||
c.execute("INSERT INTO qa(book_id, chapter_idx, kind, q, a, ts) VALUES (?,?,?,?,?,?)", (bid, idx, kind, q, a, L.now()))
|
||||
c.commit()
|
||||
|
||||
|
||||
@app.post("/api/recap/{bid}/{idx}")
|
||||
def api_recap(bid: int, idx: int):
|
||||
b = book_or_404(bid)
|
||||
ch = chapter_or_404(bid, idx)
|
||||
|
||||
def save(text):
|
||||
if not text or text.startswith("⚠️"):
|
||||
return
|
||||
c = L.db()
|
||||
old = c.execute("SELECT * FROM recap WHERE chapter_id=?", (ch["id"],)).fetchone()
|
||||
# правленый вручную рекап не затираем: новый ИИ-вариант кладём в ai_md
|
||||
md = text if not old or not old["md"] or old["md"] == old["ai_md"] else old["md"]
|
||||
c.execute("INSERT OR REPLACE INTO recap(chapter_id, md, ai_md, updated) VALUES (?,?,?,?)", (ch["id"], md, text, L.now()))
|
||||
c.commit()
|
||||
L.index_recap(ch["id"])
|
||||
return text_stream(llm.stream(llm.recap_prompt(b, ch)), save)
|
||||
|
||||
|
||||
@app.put("/api/recap/{bid}/{idx}")
|
||||
async def api_recap_save(bid: int, idx: int, req: Request):
|
||||
ch = chapter_or_404(bid, idx)
|
||||
md = (await req.json()).get("md", "")
|
||||
c = L.db()
|
||||
old = c.execute("SELECT ai_md FROM recap WHERE chapter_id=?", (ch["id"],)).fetchone()
|
||||
c.execute("INSERT OR REPLACE INTO recap(chapter_id, md, ai_md, updated) VALUES (?,?,?,?)",
|
||||
(ch["id"], md, old["ai_md"] if old else None, L.now()))
|
||||
c.commit()
|
||||
L.index_recap(ch["id"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/read/{bid}/{idx}")
|
||||
async def api_read(bid: int, idx: int, req: Request):
|
||||
ch = chapter_or_404(bid, idx)
|
||||
val = 1 if (await req.json()).get("read") else 0
|
||||
c = L.db()
|
||||
c.execute("UPDATE chapter SET read=?, read_at=? WHERE id=?", (val, L.now() if val else None, ch["id"]))
|
||||
c.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/selection/{action}")
|
||||
async def api_selection(action: str, req: Request):
|
||||
d = await req.json()
|
||||
b = book_or_404(int(d["book"]))
|
||||
ch = chapter_or_404(b["id"], int(d["ch"]))
|
||||
sel = (d.get("text") or "").strip()
|
||||
if not sel:
|
||||
raise HTTPException(400, "пустое выделение")
|
||||
ctx = L.find_selection(b["id"], ch["idx"], sel)
|
||||
if action == "translate":
|
||||
prompt, model = llm.translate_prompt(b, ch["title"], sel, ctx), llm.MODEL_FAST
|
||||
elif action == "explain":
|
||||
prompt, model = llm.explain_prompt(b, ch["title"], sel, ctx), llm.MODEL_FAST
|
||||
else:
|
||||
raise HTTPException(400, "неизвестное действие")
|
||||
return text_stream(llm.stream(prompt, model), lambda a: log_qa(b["id"], ch["idx"], action, sel, a))
|
||||
|
||||
|
||||
@app.post("/api/ask")
|
||||
async def api_ask(req: Request):
|
||||
d = await req.json()
|
||||
q = (d.get("q") or "").strip()
|
||||
if not q:
|
||||
raise HTTPException(400, "пустой вопрос")
|
||||
b = book_or_404(int(d["book"])) if d.get("book") else None
|
||||
ch = chapter_or_404(b["id"], int(d["ch"])) if b and d.get("ch") else None
|
||||
hits = L.search(q + " " + (d.get("selection") or "")[:300], b["id"] if b else None, k=10)
|
||||
prompt = llm.ask_prompt(q, b, hits, ch, d.get("selection"), bool(d.get("web")))
|
||||
on_done = (lambda a: log_qa(b["id"], ch["idx"] if ch else None, "ask", q, a)) if b else None
|
||||
return text_stream(llm.stream(prompt, llm.MODEL_MAIN, bool(d.get("web"))), on_done)
|
||||
|
||||
|
||||
@app.get("/api/status/{bid}")
|
||||
def api_status(bid: int):
|
||||
b = book_or_404(bid)
|
||||
return {"status": b["status"], "note": b["note"]}
|
||||
Reference in New Issue
Block a user