Files
mirosociety/backend/app/services/sim_logger.py
T
2026-09-27 23:06:24 +08:00

189 lines
7.2 KiB
Python

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