from __future__ import annotations import json import logging from datetime import datetime, timezone from pathlib import Path from typing import Any from app.models.agent import AgentPersona from app.models.action import ActionEntry, ActionType from app.models.world import WorldMetrics logger = logging.getLogger(__name__) BACKEND_LOGS_DIR = Path(__file__).resolve().parent.parent.parent / "logs" PROJECT_LOGS_DIR = Path(__file__).resolve().parents[3] / "logs" class SimulationLogger: def __init__(self, sim_id: str): self._sim_id = sim_id # Keep the structured per-simulation log for programmatic inspection, # and create a human-readable text log in the project root at the # moment the user clicks "Create World". BACKEND_LOGS_DIR.mkdir(parents=True, exist_ok=True) PROJECT_LOGS_DIR.mkdir(parents=True, exist_ok=True) self._created_at = datetime.now().astimezone() self._path = BACKEND_LOGS_DIR / f"{sim_id}.jsonl" self.text_path = PROJECT_LOGS_DIR / ( f"logs_{self._created_at.strftime('%Y%m%d_%H%M%S_%f')}.txt" ) try: self._fh = open(self._path, "a", encoding="utf-8") except OSError as exc: logger.warning("SimulationLogger: cannot open %s: %s", self._path, exc) self._fh = None try: self._text_fh = open(self.text_path, "a", encoding="utf-8") self._text_fh.write( f"[{self._created_at.isoformat()}] simulation_started " f"sim_id={self._sim_id}\n" ) self._text_fh.flush() except OSError as exc: logger.warning("SimulationLogger: cannot open %s: %s", self.text_path, exc) self._text_fh = None def _write(self, record: dict[str, Any]): if not self._fh: return try: serialized = json.dumps(record, ensure_ascii=False, default=str) self._fh.write(serialized + "\n") self._fh.flush() if self._text_fh: ts = record.get("ts", datetime.now().astimezone().isoformat()) self._text_fh.write(f"[{ts}] {serialized}\n") self._text_fh.flush() except Exception as exc: logger.debug("SimulationLogger write error: %s", exc) def log_round( self, *, round_num: int, day: int, time_of_day: str, agents: list[AgentPersona], entries: list[ActionEntry], metrics: WorldMetrics, coherence_scores: dict[int, Any] | None = None, personality_mandates: dict[int, str] | None = None, cross_agent_repeated: str = "", ): action_map: dict[int, list[ActionEntry]] = {} for e in entries: action_map.setdefault(e.agent_id, []).append(e) agent_records = [] abandon_count = 0 defect_count = 0 sentiment_dist = {"negative": 0, "positive": 0, "neutral": 0} for agent in agents: agent_entries = action_map.get(agent.id, []) p = agent.personality for ae in agent_entries: if ae.action_type == ActionType.ABANDON: abandon_count += 1 elif ae.action_type == ActionType.DEFECT: defect_count += 1 if ae.speech: lower = ae.speech.lower() neg_kw = {"frustrated", "disappointed", "betrayed", "cancel", "leaving", "ridiculous", "greed", "overpriced"} pos_kw = {"love", "great", "worth", "enjoy", "staying", "loyal"} has_neg = any(kw in lower for kw in neg_kw) has_pos = any(kw in lower for kw in pos_kw) if has_neg and not has_pos: sentiment_dist["negative"] += 1 elif has_pos and not has_neg: sentiment_dist["positive"] += 1 else: sentiment_dist["neutral"] += 1 coh_score = None if coherence_scores and agent.id in coherence_scores: cs = coherence_scores[agent.id] coh_score = round(cs.score, 2) if hasattr(cs, "score") else None mandate_str = "" if personality_mandates and agent.id in personality_mandates: mandate_str = personality_mandates[agent.id][:200] rec = { "id": agent.id, "name": agent.name, "personality": { "brand_loyalty": round(p.brand_loyalty, 2), "price_sensitivity": round(p.price_sensitivity, 2), "conformity": round(p.conformity, 2), "social_proof": round(p.social_proof, 2), "novelty_seeking": round(p.novelty_seeking, 2), }, "emotional_state": agent.emotional_state, "actions": [ { "type": ae.action_type.value, "speech": (ae.speech[:120] if ae.speech else None), } for ae in agent_entries ], "coherence_score": coh_score, "has_abandoned": list(agent.abandoned_products), "has_defected": agent.has_defected, "mandate": mandate_str, } agent_records.append(rec) record = { "ts": datetime.now(timezone.utc).isoformat(), "sim_id": self._sim_id, "round": round_num, "day": day, "time_of_day": time_of_day, "agents": agent_records, "summary": { "abandon_count": abandon_count, "defect_count": defect_count, "sentiment_distribution": sentiment_dist, "cross_agent_repeated_phrases": cross_agent_repeated[:300], }, "metrics": { "brand_sentiment": round(metrics.brand_sentiment, 3), "purchase_intent": round(metrics.purchase_intent, 3), "churn_risk": round(metrics.churn_risk, 3), "adoption_rate": round(metrics.adoption_rate, 3), "stability": round(metrics.stability, 3), "trust": round(metrics.trust, 3), }, } self._write(record) def log_event(self, event_type: str, data: dict[str, Any]): self._write({ "ts": datetime.now(timezone.utc).isoformat(), "sim_id": self._sim_id, "event": event_type, "data": data, }) def close(self): if self._fh: try: self._fh.close() except Exception: pass self._fh = None if self._text_fh: try: self._text_fh.write( f"[{datetime.now().astimezone().isoformat()}] simulation_log_closed\n" ) self._text_fh.flush() self._text_fh.close() except Exception: pass self._text_fh = None