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.
 
 
 
 
 
mirosociety/backend/app/services/sim_logger.py

155 lines
5.6 KiB

from __future__ import annotations
import json
import logging
import os
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__)
LOGS_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
class SimulationLogger:
def __init__(self, sim_id: str):
self._sim_id = sim_id
LOGS_DIR.mkdir(parents=True, exist_ok=True)
self._path = LOGS_DIR / f"{sim_id}.jsonl"
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
def _write(self, record: dict[str, Any]):
if not self._fh:
return
try:
self._fh.write(json.dumps(record, default=str) + "\n")
self._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