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

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")