440 lines
16 KiB
Python
440 lines
16 KiB
Python
import json
|
||
import logging
|
||
from typing import List
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
import services.tools.fiction_tools # noqa: F401 — register fiction tools
|
||
from models.fiction_models import (
|
||
CreateFictionBookRequest,
|
||
EmotionFlowCatalog,
|
||
FictionBookMeta,
|
||
FictionBookMetadata,
|
||
FictionBookSettings,
|
||
FictionBookSummary,
|
||
FictionChapter,
|
||
FictionChapterSummary,
|
||
FictionGenerationRequest,
|
||
FictionGuideWorldbook,
|
||
FictionPipelineTickResult,
|
||
FictionRunState,
|
||
FictionStartReadingResult,
|
||
GuideGlobalEntries,
|
||
OpenBookRequest,
|
||
OpenBookResult,
|
||
UpdateFictionBookSettingsRequest,
|
||
UpdateFictionProgressRequest,
|
||
)
|
||
from services.fiction_chapter_service import ensure_chapter, run_chapter
|
||
from services.fiction_coarse_service import run_coarse_outline
|
||
from services.fiction_event_plan_service import (
|
||
iter_event_plan,
|
||
run_event_plan,
|
||
stream_event_plan_subscribe,
|
||
)
|
||
from services.fiction_metadata_service import fiction_metadata_service
|
||
from services.fiction_open_book_service import run_open_book
|
||
from services.fiction_planning_service import (
|
||
ensure_chapter_plan,
|
||
ensure_event_chain,
|
||
ensure_volume,
|
||
)
|
||
from services.fiction_orchestrator_service import (
|
||
get_pending_stages,
|
||
get_pipeline_run,
|
||
start_reading_pipeline,
|
||
tick_reading_pipeline,
|
||
)
|
||
from services.fiction_service import fiction_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/fiction", tags=["fiction"])
|
||
|
||
|
||
@router.get("/books", response_model=List[FictionBookSummary])
|
||
async def list_fiction_books():
|
||
try:
|
||
return fiction_service.list_books()
|
||
except Exception as e:
|
||
logger.error("Failed to list fiction books: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books", response_model=FictionBookMeta)
|
||
async def create_fiction_book(req: CreateFictionBookRequest):
|
||
try:
|
||
return fiction_service.create_book(req)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileExistsError as e:
|
||
raise HTTPException(status_code=409, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to create fiction book: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}", response_model=FictionBookMeta)
|
||
async def get_fiction_book_meta(book_id: str):
|
||
try:
|
||
return fiction_service.get_book_meta(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get fiction book %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.delete("/books/{book_id}")
|
||
async def delete_fiction_book(book_id: str):
|
||
try:
|
||
fiction_service.delete_book(book_id)
|
||
return {"ok": True}
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to delete fiction book %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/settings", response_model=FictionBookSettings)
|
||
async def get_fiction_book_settings(book_id: str):
|
||
try:
|
||
return fiction_service.get_book_settings(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get settings for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.put("/books/{book_id}/settings", response_model=FictionBookSettings)
|
||
async def update_fiction_book_settings(
|
||
book_id: str, req: UpdateFictionBookSettingsRequest
|
||
):
|
||
try:
|
||
return fiction_service.update_book_settings(book_id, req)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to update settings for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/guide", response_model=FictionGuideWorldbook)
|
||
async def get_fiction_book_guide(book_id: str):
|
||
try:
|
||
return fiction_service.get_book_guide(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get guide for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/emotion-flows/catalog", response_model=EmotionFlowCatalog)
|
||
async def get_emotion_flow_catalog():
|
||
try:
|
||
return fiction_service.get_emotion_catalog()
|
||
except Exception as e:
|
||
logger.error("Failed to get emotion catalog: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/guide-global/entries", response_model=GuideGlobalEntries)
|
||
async def get_guide_global_entries():
|
||
try:
|
||
return fiction_service.get_guide_global_entries()
|
||
except Exception as e:
|
||
logger.error("Failed to get guide global entries: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/open-book", response_model=OpenBookResult)
|
||
async def open_fiction_book(req: OpenBookRequest):
|
||
try:
|
||
return await run_open_book(
|
||
req.inspiration,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to open fiction book: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/metadata", response_model=FictionBookMetadata)
|
||
async def get_fiction_book_metadata(book_id: str):
|
||
try:
|
||
return fiction_metadata_service.get_metadata(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get metadata for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/run", response_model=FictionRunState)
|
||
async def get_fiction_book_run(book_id: str):
|
||
try:
|
||
return fiction_metadata_service.get_run(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get run for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/start", response_model=FictionStartReadingResult)
|
||
async def start_fiction_reading(book_id: str, req: FictionGenerationRequest):
|
||
"""进入阅读时自动触发粗纲 / 事件纲要流水线(后台异步)。"""
|
||
try:
|
||
return await start_reading_pipeline(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to start reading pipeline for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/pipeline/tick", response_model=FictionPipelineTickResult)
|
||
async def tick_fiction_pipeline(book_id: str, req: FictionGenerationRequest):
|
||
"""检查并推进流水线(尊重半自动设置)。"""
|
||
try:
|
||
return await tick_reading_pipeline(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to tick pipeline for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/pipeline/status")
|
||
async def get_fiction_pipeline_status(book_id: str):
|
||
"""流水线 run 状态 + 待手动阶段列表。"""
|
||
try:
|
||
run = get_pipeline_run(book_id)
|
||
pending = get_pending_stages(book_id)
|
||
return {"run": run, "pendingStages": pending}
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get pipeline status for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/volumes/ensure", response_model=FictionBookMetadata)
|
||
async def ensure_fiction_volume(book_id: str, req: FictionGenerationRequest):
|
||
try:
|
||
return await ensure_volume(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to ensure volume for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/events/ensure", response_model=FictionBookMetadata)
|
||
async def ensure_fiction_event_chain(book_id: str, req: FictionGenerationRequest):
|
||
try:
|
||
return await ensure_event_chain(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to ensure event chain for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/chapter-plans/ensure", response_model=FictionBookMetadata)
|
||
async def ensure_fiction_chapter_plan(book_id: str, req: FictionGenerationRequest):
|
||
try:
|
||
return await ensure_chapter_plan(
|
||
book_id,
|
||
event_id=req.event_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to ensure chapter plan for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/chapters/ensure", response_model=FictionChapter)
|
||
async def ensure_fiction_chapter(book_id: str, req: FictionGenerationRequest):
|
||
try:
|
||
return await ensure_chapter(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
seq=req.seq,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to ensure chapter for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/coarse-outline", response_model=FictionBookMetadata)
|
||
async def generate_coarse_outline(book_id: str, req: FictionGenerationRequest):
|
||
try:
|
||
return await ensure_volume(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to ensure volume for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/event-plan", response_model=FictionBookMetadata)
|
||
async def generate_event_plan(book_id: str, req: FictionGenerationRequest):
|
||
if req.stream:
|
||
|
||
async def ndjson_stream():
|
||
try:
|
||
async for event in iter_event_plan(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
event_id=req.event_id,
|
||
):
|
||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||
except FileNotFoundError as e:
|
||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||
except ValueError as e:
|
||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||
except Exception as e:
|
||
logger.error("Failed to stream event plan for %s: %s", book_id, e)
|
||
yield json.dumps(
|
||
{"type": "error", "message": str(e)}, ensure_ascii=False
|
||
) + "\n"
|
||
|
||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||
|
||
try:
|
||
return await ensure_chapter_plan(
|
||
book_id,
|
||
event_id=req.event_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to generate event plan for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/event-plan/stream")
|
||
async def subscribe_event_plan_stream(book_id: str):
|
||
"""订阅事件纲要生成进度(NDJSON),适用于后台流水线已启动时。"""
|
||
try:
|
||
fiction_service.get_book_meta(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
|
||
async def ndjson_stream():
|
||
try:
|
||
async for event in stream_event_plan_subscribe(book_id):
|
||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||
except FileNotFoundError as e:
|
||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||
except Exception as e:
|
||
logger.error("Failed to subscribe event plan stream for %s: %s", book_id, e)
|
||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||
|
||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||
|
||
|
||
@router.get("/books/{book_id}/chapters", response_model=List[FictionChapterSummary])
|
||
async def list_fiction_chapters(book_id: str):
|
||
try:
|
||
return fiction_service.list_chapter_summaries(book_id)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to list chapters for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/books/{book_id}/chapters/{seq}", response_model=FictionChapter)
|
||
async def get_fiction_chapter(book_id: str, seq: int):
|
||
try:
|
||
return fiction_service.get_chapter(book_id, seq)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to get chapter %s for %s: %s", seq, book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/books/{book_id}/chapter", response_model=FictionChapter)
|
||
async def generate_fiction_chapter(book_id: str, req: FictionGenerationRequest):
|
||
"""撰写下一章或指定 seq 的章节(本地已存在则直接返回)。"""
|
||
try:
|
||
return await run_chapter(
|
||
book_id,
|
||
profile_id=req.profile_id,
|
||
api_config=req.api_config,
|
||
seq=req.seq,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to generate chapter for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.patch("/books/{book_id}/progress", response_model=FictionBookMetadata)
|
||
async def update_fiction_progress(book_id: str, req: UpdateFictionProgressRequest):
|
||
try:
|
||
return fiction_metadata_service.update_progress(
|
||
book_id,
|
||
current_chapter_seq=req.currentChapterSeq,
|
||
char_offset=req.charOffset,
|
||
)
|
||
except FileNotFoundError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("Failed to update progress for %s: %s", book_id, e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|