You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
130 lines
4.6 KiB
130 lines
4.6 KiB
from __future__ import annotations
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.config import settings
|
|
from app.db.store import SimulationStore
|
|
from app.services.llm import LLMClient
|
|
from app.services.world_generator import WorldGenerator
|
|
from app.services.citizen_generator import CitizenGenerator
|
|
from app.services.tension import TensionEngine
|
|
from app.services.resolver import ActionResolver
|
|
from app.services.narrator import Narrator
|
|
from app.services.engine import SimulationEngine
|
|
from app.services.research import ResearchService
|
|
from app.services.gossip import GossipEngine
|
|
from app.services.life_engine import LifeEngine
|
|
from app.services.forecast import ForecastService
|
|
from app.services.i18n import parse_accept_language
|
|
from app.api.simulate import router as simulate_router
|
|
from app.api.agents import router as agents_router
|
|
from app.api.gallery import router as gallery_router
|
|
from app.api.presets import router as presets_router
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logs_dir = Path(__file__).resolve().parent.parent / "logs"
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
store = SimulationStore(settings.database_dir)
|
|
await store.init()
|
|
|
|
# Sweep up any simulations that were "running" when the previous process
|
|
# died — without this they'd stay stuck in `running` forever and clients
|
|
# polling /report would never see a terminal status. We mark them
|
|
# `interrupted` so the API can surface a clear error.
|
|
try:
|
|
interrupted = await store.sweep_orphan_running_sims()
|
|
if interrupted:
|
|
logger.warning("Marked %d orphan simulations as interrupted", interrupted)
|
|
except Exception as e: # pragma: no cover - best effort
|
|
logger.warning("Orphan sweep failed: %s", e)
|
|
|
|
llm = LLMClient(
|
|
api_key=settings.llm_api_key,
|
|
base_url=settings.llm_base_url,
|
|
model=settings.llm_model,
|
|
max_concurrent=settings.max_concurrent_llm_calls,
|
|
)
|
|
|
|
world_gen = WorldGenerator(llm)
|
|
citizen_gen = CitizenGenerator(llm)
|
|
tension = TensionEngine(llm)
|
|
resolver = ActionResolver()
|
|
forecast = ForecastService()
|
|
narrator = Narrator(llm, forecast=forecast)
|
|
research = ResearchService(
|
|
llm,
|
|
enabled=settings.search_enabled,
|
|
max_per_round=settings.max_searches_per_round,
|
|
)
|
|
gossip = GossipEngine()
|
|
life_engine = LifeEngine(llm=llm)
|
|
engine = SimulationEngine(llm, store, tension, resolver, narrator, research=research, gossip=gossip, life_engine=life_engine, forecast=forecast)
|
|
|
|
app.state.store = store
|
|
app.state.llm = llm
|
|
app.state.world_generator = world_gen
|
|
app.state.citizen_generator = citizen_gen
|
|
app.state.engine = engine
|
|
app.state.forecast = forecast
|
|
app.state.narrator = narrator
|
|
app.state.event_queues = {}
|
|
app.state.cancelled_sims = set()
|
|
|
|
logger.info("MiroSociety started — LLM: %s @ %s", settings.llm_model, settings.llm_base_url)
|
|
yield
|
|
logger.info("MiroSociety shutting down")
|
|
|
|
|
|
app = FastAPI(title="MiroSociety", version="0.1.0", lifespan=lifespan)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def locale_middleware(request: Request, call_next):
|
|
"""Resolve Accept-Language once per request and stash it for handlers."""
|
|
request.state.locale = parse_accept_language(request.headers.get("accept-language"))
|
|
response = await call_next(request)
|
|
response.headers.setdefault("Content-Language", request.state.locale)
|
|
return response
|
|
|
|
app.include_router(simulate_router)
|
|
app.include_router(agents_router)
|
|
app.include_router(gallery_router)
|
|
app.include_router(presets_router)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "model": settings.llm_model}
|
|
|
|
|
|
static_dir = Path(__file__).parent / "static"
|
|
if static_dir.is_dir():
|
|
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
|
|
|
@app.get("/{path:path}")
|
|
async def serve_spa(path: str):
|
|
file_path = (static_dir / path).resolve()
|
|
if file_path.is_relative_to(static_dir.resolve()) and file_path.is_file():
|
|
return FileResponse(file_path)
|
|
return FileResponse(static_dir / "index.html")
|
|
|