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.
155 lines
5.6 KiB
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
|
|
|