feat(router): integrate router API for AI model selection and suggestions
Build and Push Reader API Image / docker (push) Successful in 1m1s

This commit is contained in:
2026-05-04 20:59:26 +07:00
parent 212d4df42f
commit d93c26757f
2 changed files with 164 additions and 48 deletions
+2 -4
View File
@@ -24,10 +24,8 @@ class Settings(BaseSettings):
chapter_content_mode: str = "nas_first" chapter_content_mode: str = "nas_first"
auto_schema_bootstrap: str = "false" auto_schema_bootstrap: str = "false"
deepseek_key: str = "" router_api_key: str = ""
deepseek_model: str = "deepseek-chat" router_base_url: str = "https://openrouter.ai/api/v1"
openrouter_key: str = ""
openrouter_paused: str = "true"
@property @property
def google_client_id_list(self) -> list[str]: def google_client_id_list(self) -> list[str]:
+162 -44
View File
@@ -10,6 +10,7 @@ import random
import re import re
import secrets import secrets
import tempfile import tempfile
import time
import uuid import uuid
import zipfile import zipfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -3507,15 +3508,63 @@ def _map_genres_to_existing(candidates: list[str], existing_genres: list[str], *
return output return output
async def _deepseek_ai_suggest( _ROUTER_MODEL_CACHE: dict[str, Any] = {"expires_at": 0.0, "models": []}
async def _router_pick_models() -> list[str]:
api_key = (settings.router_api_key or "").strip()
now = time.time()
if _ROUTER_MODEL_CACHE.get("expires_at", 0.0) > now:
return list(_ROUTER_MODEL_CACHE.get("models") or [])
candidates: list[tuple[int, str]] = []
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.get(
f"{str(settings.router_base_url).rstrip('/')}/models",
headers=headers,
)
response.raise_for_status()
for item in (response.json().get("data") or []):
model_id = str(item.get("id") or "").strip()
if not model_id:
continue
low = model_id.lower()
if any(x in low for x in ["vision", "image", "audio", "realtime", "embedding", "moderation"]):
continue
score = 0
if "gpt-5.5" in low:
score += 1000
elif "gpt-5" in low:
score += 900
elif "claude" in low:
score += 700
elif "gemini" in low:
score += 650
else:
score += 100
candidates.append((score, model_id))
except Exception:
candidates = []
candidates.sort(key=lambda x: x[0], reverse=True)
picked = [m for _, m in candidates[:6]]
_ROUTER_MODEL_CACHE["models"] = picked
_ROUTER_MODEL_CACHE["expires_at"] = now + 600
return picked
async def _router_ai_suggest(
title: str, title: str,
author: str, author: str,
chapters: list[dict[str, Any]], chapters: list[dict[str, Any]],
existing_genres: list[str], existing_genres: list[str],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
api_key = (settings.deepseek_key or "").strip() api_key = (settings.router_api_key or "").strip()
if not api_key:
return None
samples: list[str] = [] samples: list[str] = []
if chapters: if chapters:
@@ -3530,11 +3579,15 @@ async def _deepseek_ai_suggest(
system_prompt = ( system_prompt = (
"You are a Vietnamese fiction metadata assistant. " "You are a Vietnamese fiction metadata assistant. "
"Return strict JSON with keys: genres, shortDescription, confidence. " "Return ONLY valid JSON (no markdown, no explanation) with exactly keys: genres, shortDescription, confidence. "
"genres must be array of 1-6 concise genre strings. " "genres must be an array of 1-6 concise Vietnamese labels. "
"Prioritize selecting from existingGenres first; only create new genres when truly needed. " "Prefer selecting from existingGenres when semantically close; create new genres only when no close match exists. "
"shortDescription must be 2-4 Vietnamese sentences. " "Do not output duplicates, slug format, or punctuation-only variants. "
"confidence is number 0..1." "shortDescription must be 6-7 Vietnamese sentences, each sentence on a new line using newline characters. "
"Match tone and diction to the likely genre and make it emotionally engaging to increase reader curiosity. "
"No major spoilers, no quotes. "
"confidence must be a number from 0 to 1. "
"If uncertain, use broader/common genres rather than inventing niche ones."
) )
user_prompt = { user_prompt = {
"title": title, "title": title,
@@ -3549,8 +3602,7 @@ async def _deepseek_ai_suggest(
}, },
} }
payload = { base_payload = {
"model": settings.deepseek_model,
"messages": [ "messages": [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_prompt, ensure_ascii=False)}, {"role": "user", "content": json.dumps(user_prompt, ensure_ascii=False)},
@@ -3560,38 +3612,44 @@ async def _deepseek_ai_suggest(
"response_format": {"type": "json_object"}, "response_format": {"type": "json_object"},
} }
try: models = await _router_pick_models()
async with httpx.AsyncClient(timeout=30.0) as client: if not models:
response = await client.post(
"https://api.deepseek.com/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=payload,
)
response.raise_for_status()
data = response.json()
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
parsed = json.loads(content) if isinstance(content, str) else {}
raw_genres = [str(g).strip() for g in (parsed.get("genres") or []) if str(g).strip()][:6]
genres = _map_genres_to_existing(raw_genres, existing_genres, limit=6)
short_description = str(parsed.get("shortDescription") or "").strip()
try:
confidence = float(parsed.get("confidence") or 0.0)
except Exception:
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
if not short_description or not genres:
return None
return {
"suggestedGenres": genres,
"shortDescription": short_description,
"confidence": confidence,
}
except Exception:
return None return None
headers = {
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000",
"X-Title": "reader-import-ai-suggest",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
for model_id in models:
payload = dict(base_payload)
payload["model"] = model_id
try:
async with httpx.AsyncClient(timeout=45.0) as client:
response = await client.post(
f"{str(settings.router_base_url).rstrip('/')}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
parsed = json.loads(content) if isinstance(content, str) else {}
raw_genres = [str(g).strip() for g in (parsed.get("genres") or []) if str(g).strip()][:6]
genres = _map_genres_to_existing(raw_genres, existing_genres, limit=6)
short_description = str(parsed.get("shortDescription") or "").strip()
try:
confidence = float(parsed.get("confidence") or 0.0)
except Exception:
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
if not short_description or not genres:
continue
return {"suggestedGenres": genres, "shortDescription": short_description, "confidence": confidence, "model": model_id}
except Exception:
continue
return None
async def _resolve_chapter_content(chapter_id: str, db: AsyncSession) -> str | None: async def _resolve_chapter_content(chapter_id: str, db: AsyncSession) -> str | None:
@@ -3802,6 +3860,65 @@ async def upload_epub_and_preview(
pass pass
@app.post("/api/mod/epub/ai-suggest")
async def mod_epub_ai_suggest(
file: UploadFile = File(...),
splitMode: str | None = Form(default=None),
chapterRegex: str | None = Form(default=None),
title: str | None = Form(default=None),
authorName: str | None = Form(default=None),
db: AsyncSession = Depends(get_db_session),
user: dict = Depends(require_current_user),
):
if user.get("role") not in ("MOD", "ADMIN"):
raise HTTPException(status_code=403, detail="Forbidden")
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty EPUB")
with tempfile.NamedTemporaryFile(delete=False, suffix=".epub") as tmp:
tmp.write(raw)
tmp_path = Path(tmp.name)
try:
mode = "regex" if (splitMode or "").lower() == "regex" else "toc"
pattern = (chapterRegex or "").strip() or None
chapters = _epub_extract_with_mode(tmp_path, mode, pattern)
meta = _extract_epub_metadata(tmp_path)
resolved_title = " ".join((title or str(meta.get("title") or tmp_path.stem)).split()).strip() or tmp_path.stem
resolved_author = " ".join((authorName or str(meta.get("author") or "Unknown")).split()).strip() or "Unknown"
existing_genres = [
str(r.get("name") or "")
for r in (await db.execute(text('SELECT name FROM "Genre" ORDER BY name ASC'))).mappings().all()
if str(r.get("name") or "").strip()
]
ai_result = await _router_ai_suggest(resolved_title, resolved_author, chapters, existing_genres)
if ai_result:
return {
"suggestedGenres": ai_result["suggestedGenres"][:6],
"shortDescription": ai_result["shortDescription"],
"confidence": ai_result["confidence"],
"source": "router_dynamic",
"model": ai_result.get("model"),
}
fallback_genres = _map_genres_to_existing(_build_ai_genre_suggestions(chapters), existing_genres, limit=6)
fallback_desc = _build_ai_description(resolved_title, resolved_author, chapters)
return {
"suggestedGenres": fallback_genres[:6],
"shortDescription": fallback_desc,
"confidence": 0.62,
"source": "rule_based_fallback",
}
finally:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
@app.get("/api/import/assets/{asset_id}/preview-cover") @app.get("/api/import/assets/{asset_id}/preview-cover")
async def preview_source_asset_cover( async def preview_source_asset_cover(
asset_id: str, asset_id: str,
@@ -3942,14 +4059,15 @@ async def ai_suggest_source_asset(
if str(r.get("name") or "").strip() if str(r.get("name") or "").strip()
] ]
ai_result = await _deepseek_ai_suggest(title, author, chapters, existing_genres) ai_result = await _router_ai_suggest(title, author, chapters, existing_genres)
if ai_result: if ai_result:
return { return {
"assetId": asset_id, "assetId": asset_id,
"suggestedGenres": ai_result["suggestedGenres"][:6], "suggestedGenres": ai_result["suggestedGenres"][:6],
"shortDescription": ai_result["shortDescription"], "shortDescription": ai_result["shortDescription"],
"confidence": ai_result["confidence"], "confidence": ai_result["confidence"],
"source": "deepseek", "source": "router_dynamic",
"model": ai_result.get("model"),
"existingGenresCount": len(existing_genres), "existingGenresCount": len(existing_genres),
} }