Files
novel-app/routes/api.py
2026-08-17 17:11:30 +08:00

376 lines
13 KiB
Python

"""JSON API + SSE streaming blueprint: /api/* endpoints.
SSE 规则 (沿用 blog-app 教训): 所有 DB 数据与上下文必须在 view 体内
(application context 仍存活) 准备好; generator 在 view return 后才被消费,
闭包内不得再依赖 Flask g / request, job 落库用独立 sqlite3 连接.
"""
import json
import logging
import sqlite3
from flask import Blueprint, jsonify, request
import config
import security
from extensions import get_db
from repositories import (
chapter_repo,
character_repo,
foreshadowing_repo,
job_repo,
novel_repo,
)
from services import chapter_writer, deconstruct, llm
logger = logging.getLogger(__name__)
bp = Blueprint("api", __name__, url_prefix="/api")
_SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
def _payload():
"""JSON body first, fall back to form fields."""
if request.is_json:
return request.get_json(silent=True) or {}
return request.form.to_dict()
def _job_conn():
"""Fresh connection for generator-side job updates (outlives request)."""
conn = sqlite3.connect(config.DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ---------------------------------------------------------------- novels
@bp.route("/novels")
def api_novels():
try:
return jsonify(novel_repo.list_novels(get_db()))
except sqlite3.Error as exc:
logger.exception("api_novels failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/novels", methods=["POST"])
def api_novel_create():
"""创建小说 (拆书确认后调用): 基础字段 + 可选 chapters/characters/foreshadowing."""
security.validate_csrf()
data = _payload()
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title required"}), 400
try:
db = get_db()
novel_id = novel_repo.create_novel(
db,
title,
genre=data.get("genre"),
style=data.get("style"),
target_words=data.get("target_words"),
status="outlining",
)
for ch in data.get("outline_chapters") or []:
chapter_repo.create_chapter(
db, novel_id,
ch.get("volume") or 1,
ch.get("chapter_number") or 1,
title=ch.get("title"),
outline=ch.get("outline"),
status="outlined" if ch.get("outline") else "pending",
)
for c in data.get("characters") or []:
character_repo.create_character(
db, novel_id, c.get("name") or "未命名",
role=c.get("role"), description=c.get("description"),
)
for f in data.get("foreshadowing") or []:
foreshadowing_repo.create_foreshadowing(
db, novel_id, f.get("description") or "",
planted_chapter=f.get("planted_chapter"),
resolved_chapter=f.get("resolved_chapter"),
)
return jsonify({"id": novel_id}), 201
except sqlite3.Error as exc:
logger.exception("api_novel_create failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/novels/<int:novel_id>")
def api_novel_detail(novel_id):
try:
db = get_db()
novel = novel_repo.get_novel(db, novel_id)
if novel is None:
return jsonify({"error": "novel not found"}), 404
novel["stats"] = novel_repo.get_stats(db, novel_id)
return jsonify(novel)
except sqlite3.Error as exc:
logger.exception("api_novel_detail failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/novels/<int:novel_id>/status", methods=["POST"])
def api_novel_status(novel_id):
security.validate_csrf()
data = _payload()
try:
novel_repo.update_novel(get_db(), novel_id, status=data.get("status"))
return jsonify({"ok": True})
except sqlite3.Error as exc:
logger.exception("api_novel_status failed")
return jsonify({"error": str(exc)}), 500
# ---------------------------------------------------------------- chapters
@bp.route("/novels/<int:novel_id>/chapters")
def api_chapters(novel_id):
try:
return jsonify(chapter_repo.list_chapters(get_db(), novel_id))
except sqlite3.Error as exc:
logger.exception("api_chapters failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/novels/<int:novel_id>/chapters", methods=["POST"])
def api_chapter_create(novel_id):
security.validate_csrf()
data = _payload()
try:
chapter_id = chapter_repo.create_chapter(
get_db(), novel_id,
data.get("volume") or 1,
data.get("chapter_number") or 1,
title=data.get("title"),
outline=data.get("outline"),
status="outlined" if data.get("outline") else "pending",
)
return jsonify({"id": chapter_id}), 201
except sqlite3.Error as exc:
logger.exception("api_chapter_create failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/chapters/<int:chapter_id>")
def api_chapter_detail(chapter_id):
try:
chapter = chapter_repo.get_chapter(get_db(), chapter_id)
if chapter is None:
return jsonify({"error": "chapter not found"}), 404
return jsonify(chapter)
except sqlite3.Error as exc:
logger.exception("api_chapter_detail failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/chapters/<int:chapter_id>", methods=["POST"])
def api_chapter_update(chapter_id):
security.validate_csrf()
data = _payload()
fields = {k: data[k] for k in ("title", "outline", "content", "status") if k in data}
if "content" in fields:
fields["word_count"] = len(fields["content"] or "")
if not fields:
return jsonify({"error": "no updatable fields"}), 400
try:
chapter_repo.update_chapter(get_db(), chapter_id, **fields)
return jsonify({"ok": True})
except sqlite3.Error as exc:
logger.exception("api_chapter_update failed")
return jsonify({"error": str(exc)}), 500
# ---------------------------------------------------------------- characters / foreshadowing
@bp.route("/novels/<int:novel_id>/characters")
def api_characters(novel_id):
try:
return jsonify(character_repo.list_characters(get_db(), novel_id))
except sqlite3.Error as exc:
logger.exception("api_characters failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/characters/<int:character_id>", methods=["POST"])
def api_character_update(character_id):
security.validate_csrf()
data = _payload()
try:
character_repo.update_character(get_db(), character_id, **data)
return jsonify({"ok": True})
except sqlite3.Error as exc:
logger.exception("api_character_update failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/novels/<int:novel_id>/foreshadowing")
def api_foreshadowing(novel_id):
try:
return jsonify(foreshadowing_repo.list_foreshadowing(get_db(), novel_id))
except sqlite3.Error as exc:
logger.exception("api_foreshadowing failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/foreshadowing/<int:fs_id>", methods=["POST"])
def api_foreshadowing_update(fs_id):
security.validate_csrf()
data = _payload()
try:
foreshadowing_repo.update_foreshadowing(get_db(), fs_id, **data)
return jsonify({"ok": True})
except sqlite3.Error as exc:
logger.exception("api_foreshadowing_update failed")
return jsonify({"error": str(exc)}), 500
# ---------------------------------------------------------------- 拆书 SSE
@bp.route("/deconstruct/stream")
def api_deconstruct_stream():
"""SSE: 逐 section 拆书. 事件: section_start / token / section_end / error."""
security.validate_csrf()
section = request.args.get("section", "").strip()
if section not in deconstruct.SECTIONS:
return jsonify({"error": f"section must be one of {deconstruct.SECTIONS}"}), 400
confirmed_raw = request.args.get("confirmed", "").strip()
try:
confirmed = json.loads(confirmed_raw) if confirmed_raw else {}
except json.JSONDecodeError:
return jsonify({"error": "confirmed must be valid JSON"}), 400
inputs = {
"title": request.args.get("title", "").strip(),
"genre": request.args.get("genre", "").strip(),
"style": request.args.get("style", "").strip(),
"target_words": request.args.get("target_words", "").strip(),
"reference_novel": request.args.get("reference_novel", "").strip(),
"confirmed": confirmed,
}
if not inputs["title"]:
return jsonify({"error": "title required"}), 400
db = get_db()
job_id = job_repo.create_job(
db, None, "deconstruct", input_json=json.dumps(inputs, ensure_ascii=False)
)
def _wrap():
conn = _job_conn()
try:
for frame in deconstruct.stream_section(section, inputs):
yield frame
if frame.startswith("event: section_end"):
job_repo.update_job(conn, job_id, status="done", output_json=frame)
elif frame.startswith("event: error"):
job_repo.update_job(conn, job_id, status="failed", error=frame)
except Exception as exc: # pragma: no cover - defensive
logger.exception("deconstruct stream crashed")
job_repo.update_job(conn, job_id, status="failed", error=str(exc))
yield llm._sse("error", {"error": str(exc)})
finally:
conn.close()
return _wrap(), _SSE_HEADERS
# ---------------------------------------------------------------- 写书 SSE
@bp.route("/chapters/<int:chapter_id>/context")
def api_chapter_context(chapter_id):
"""章节上下文预览 (前端上下文面板)."""
try:
db = get_db()
chapter = chapter_repo.get_chapter(db, chapter_id)
if chapter is None:
return jsonify({"error": "chapter not found"}), 404
novel = novel_repo.get_novel(db, chapter["novel_id"])
return jsonify(chapter_writer.build_chapter_context(db, novel, chapter))
except sqlite3.Error as exc:
logger.exception("api_chapter_context failed")
return jsonify({"error": str(exc)}), 500
@bp.route("/chapters/<int:chapter_id>/generate")
def api_chapter_generate(chapter_id):
"""SSE: 流式写章节. 事件: context / token / done / error. ?prefix= 续写断点."""
security.validate_csrf()
prefix = request.args.get("prefix") or None
db = get_db()
chapter = chapter_repo.get_chapter(db, chapter_id)
if chapter is None:
return jsonify({"error": "chapter not found"}), 404
novel = novel_repo.get_novel(db, chapter["novel_id"])
if novel is None:
return jsonify({"error": "novel not found"}), 404
job_id = job_repo.create_job(
db, novel["id"], "chapter",
input_json=json.dumps(
{"chapter_id": chapter_id, "prefix_len": len(prefix or "")},
ensure_ascii=False,
),
)
# 关键: 在 view 体内就把 generator 物化成帧列表前的数据全部取好.
# chapter_writer.stream_chapter 需要 db 仅用于 build_chapter_context,
# 所以先在 view 内构建 context, generator 只做纯 LLM 流式.
try:
context = chapter_writer.build_chapter_context(db, novel, chapter)
except Exception as exc:
logger.exception("failed to build chapter context")
return jsonify({"error": f"failed to build context: {exc}"}), 500
def _wrap():
conn = _job_conn()
job_repo.update_job(conn, job_id, status="running")
try:
summary = (
f"已注入上下文: 前 {len(context['prev_chapters'])} 章正文, "
f"{len(context['earlier_summaries'])} 条更早摘要, "
f"{len(context['characters'])} 个角色, "
f"{len(context['open_foreshadowing'])} 条未回收伏笔, "
f"共约 {context['total_chars']} 字符"
+ (f"; 续写前缀 {len(prefix)} 字符" if prefix else "")
)
yield llm._sse("context", {"summary": summary})
messages = chapter_writer.build_chapter_prompts(context, chapter, prefix=prefix)
parts = []
for frame in llm.make_token_stream(messages, parts.append, max_tokens=8192):
yield frame
if frame.startswith("event: error"):
job_repo.update_job(conn, job_id, status="failed", error=frame)
return
full_text = (prefix or "") + "".join(parts)
chapter_repo.update_chapter(
conn, chapter_id,
content=full_text, word_count=len(full_text), status="drafting",
)
job_repo.update_job(
conn, job_id, status="done", output_json=json.dumps(
{"word_count": len(full_text)}, ensure_ascii=False
),
)
yield llm._sse("done", {"content": full_text, "word_count": len(full_text)})
except Exception as exc: # pragma: no cover - defensive
logger.exception("chapter generate stream crashed")
job_repo.update_job(conn, job_id, status="failed", error=str(exc))
yield llm._sse("error", {"error": str(exc)})
finally:
conn.close()
return _wrap(), _SSE_HEADERS