Files
infra/stacks/books/app/main.py
T

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]}