app.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """史观交锋 Web:提供静态页与辩论 API。"""
  2. from __future__ import annotations
  3. import asyncio
  4. import concurrent.futures
  5. import os
  6. from pathlib import Path
  7. from dotenv import load_dotenv
  8. from fastapi import FastAPI, HTTPException
  9. from fastapi.middleware.cors import CORSMiddleware
  10. from fastapi.responses import FileResponse, StreamingResponse
  11. from fastapi.staticfiles import StaticFiles
  12. from pydantic import BaseModel, Field
  13. from historical_review.debate_orchestrator import iter_debate_events, run_historical_debate
  14. _STATIC = Path(__file__).resolve().parent / "static"
  15. _PROJECT_ROOT = Path(__file__).resolve().parents[2]
  16. load_dotenv(_PROJECT_ROOT / ".env")
  17. load_dotenv()
  18. app = FastAPI(title="史观交锋", description="多角色历史辩论:官修/野史/政治语境/域外/蹊跷辨析")
  19. app.add_middleware(
  20. CORSMiddleware,
  21. allow_origins=["*"],
  22. allow_credentials=True,
  23. allow_methods=["*"],
  24. allow_headers=["*"],
  25. )
  26. if _STATIC.is_dir():
  27. app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
  28. class DebateRequest(BaseModel):
  29. topic: str = Field(..., min_length=1, max_length=8000)
  30. api_key: str | None = Field(None, description="OpenRouter 等 API Key,可空则用服务端环境变量")
  31. base_url: str | None = Field(None, description="OpenAI 兼容 Base URL")
  32. model: str | None = Field(None, description="模型 ID,如 openai/gpt-4o-mini")
  33. max_tokens: int | None = Field(4096, ge=256, le=128000)
  34. timeout: int | None = Field(180, ge=30, le=600, description="单次 HTTP 请求超时秒数")
  35. debate_temperature: float = Field(0.72, ge=0.0, le=2.0)
  36. synthesizer_temperature: float = Field(0.22, ge=0.0, le=2.0)
  37. use_evidence_bundle: bool = True
  38. class DebateResponse(BaseModel):
  39. ok: bool
  40. markdown: str | None = None
  41. error: str | None = None
  42. def _api_key_error(req: DebateRequest) -> str | None:
  43. has_key = bool(req.api_key and req.api_key.strip())
  44. if not has_key and not (os.getenv("OPENROUTER_API_KEY") or os.getenv("LLM_API_KEY")):
  45. return "未配置 API Key:请在左侧填写 OpenRouter Key,或在服务器 .env 中设置 OPENROUTER_API_KEY。"
  46. return None
  47. @app.get("/")
  48. async def index_page() -> FileResponse:
  49. html = _STATIC / "index.html"
  50. if not html.is_file():
  51. raise HTTPException(status_code=500, detail="前端文件缺失,请检查 historical_review/web/static/")
  52. return FileResponse(html)
  53. @app.get("/api/health")
  54. async def health() -> dict[str, str]:
  55. return {"status": "ok"}
  56. @app.post("/api/debate", response_model=DebateResponse)
  57. async def run_debate(req: DebateRequest) -> DebateResponse:
  58. topic = req.topic.strip()
  59. if not topic:
  60. raise HTTPException(status_code=400, detail="议题不能为空")
  61. key_err = _api_key_error(req)
  62. if key_err:
  63. return DebateResponse(ok=False, error=key_err)
  64. def _work() -> str:
  65. return run_historical_debate(
  66. topic,
  67. use_evidence_bundle=req.use_evidence_bundle,
  68. debate_temperature=req.debate_temperature,
  69. synthesizer_temperature=req.synthesizer_temperature,
  70. llm_api_key=req.api_key.strip() if req.api_key else None,
  71. llm_base_url=req.base_url.strip() if req.base_url else None,
  72. llm_model=req.model.strip() if req.model else None,
  73. llm_max_tokens=req.max_tokens,
  74. llm_timeout=req.timeout,
  75. )
  76. loop = asyncio.get_event_loop()
  77. try:
  78. with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
  79. md = await asyncio.wait_for(
  80. loop.run_in_executor(pool, _work),
  81. timeout=900.0,
  82. )
  83. except asyncio.TimeoutError:
  84. return DebateResponse(ok=False, error="任务超时(>15 分钟),请换小议题或提高超时/换更快模型。")
  85. except Exception as e: # pragma: no cover
  86. return DebateResponse(ok=False, error=f"{type(e).__name__}: {e}")
  87. return DebateResponse(ok=True, markdown=md)
  88. @app.post("/api/debate/stream")
  89. def debate_stream(req: DebateRequest) -> StreamingResponse:
  90. """SSE:逐段推送辩论进度与各角色发言,最后 complete 带全文 Markdown。"""
  91. topic = req.topic.strip()
  92. if not topic:
  93. def err_only():
  94. import json
  95. yield f"data: {json.dumps({'event': 'error', 'message': '议题不能为空'}, ensure_ascii=False)}\n\n".encode(
  96. "utf-8"
  97. )
  98. return StreamingResponse(err_only(), media_type="text/event-stream")
  99. key_err = _api_key_error(req)
  100. if key_err:
  101. def err_key():
  102. import json
  103. yield f"data: {json.dumps({'event': 'error', 'message': key_err}, ensure_ascii=False)}\n\n".encode("utf-8")
  104. return StreamingResponse(err_key(), media_type="text/event-stream")
  105. def event_bytes():
  106. import json
  107. try:
  108. for ev in iter_debate_events(
  109. topic,
  110. use_evidence_bundle=req.use_evidence_bundle,
  111. debate_temperature=req.debate_temperature,
  112. synthesizer_temperature=req.synthesizer_temperature,
  113. llm_api_key=req.api_key.strip() if req.api_key else None,
  114. llm_base_url=req.base_url.strip() if req.base_url else None,
  115. llm_model=req.model.strip() if req.model else None,
  116. llm_max_tokens=req.max_tokens,
  117. llm_timeout=req.timeout,
  118. ):
  119. line = f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
  120. yield line.encode("utf-8")
  121. except Exception as e: # pragma: no cover
  122. err_ev = {"event": "error", "message": f"{type(e).__name__}: {e}"}
  123. yield f"data: {json.dumps(err_ev, ensure_ascii=False)}\n\n".encode("utf-8")
  124. headers = {
  125. "Cache-Control": "no-cache",
  126. "Connection": "keep-alive",
  127. "X-Accel-Buffering": "no",
  128. }
  129. return StreamingResponse(event_bytes(), media_type="text/event-stream", headers=headers)