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.
280 lines
10 KiB
280 lines
10 KiB
from __future__ import annotations
|
|
import statistics
|
|
from collections import defaultdict
|
|
|
|
from app.models.action import ActionEntry, ActionType
|
|
from app.models.agent import AgentPersona
|
|
from app.models.world import WorldMetrics
|
|
|
|
|
|
METRIC_LABELS = {
|
|
"stability": "Stability",
|
|
"prosperity": "Prosperity",
|
|
"trust": "Trust",
|
|
"freedom": "Freedom",
|
|
"conflict": "Conflict",
|
|
"brand_sentiment": "Brand Sentiment",
|
|
"purchase_intent": "Purchase Intent",
|
|
"word_of_mouth": "Word of Mouth",
|
|
"churn_risk": "Churn Risk",
|
|
"adoption_rate": "Adoption Rate",
|
|
}
|
|
|
|
INVERSE_METRICS = {"conflict", "churn_risk"}
|
|
|
|
MARKET_METRICS = {"brand_sentiment", "purchase_intent", "word_of_mouth", "churn_risk", "adoption_rate"}
|
|
|
|
SOCIAL_METRICS = {"stability", "prosperity", "trust", "freedom", "conflict"}
|
|
|
|
|
|
def _rate(value_100: float, inverse: bool) -> str:
|
|
v = (100 - value_100) if inverse else value_100
|
|
if v >= 81:
|
|
return "excellent"
|
|
if v >= 61:
|
|
return "strong"
|
|
if v >= 41:
|
|
return "moderate"
|
|
if v >= 21:
|
|
return "weak"
|
|
return "critical"
|
|
|
|
|
|
def _trend(start: float, end: float) -> str:
|
|
delta = end - start
|
|
if delta > 0.05:
|
|
return "up"
|
|
if delta < -0.05:
|
|
return "down"
|
|
return "flat"
|
|
|
|
|
|
class ReportAnalyzer:
|
|
|
|
@staticmethod
|
|
def compute_metric_stats(metrics_history: list[dict], is_market: bool) -> list[dict]:
|
|
if not metrics_history:
|
|
return []
|
|
|
|
active_keys = set(SOCIAL_METRICS)
|
|
if is_market:
|
|
active_keys |= MARKET_METRICS
|
|
|
|
results = []
|
|
for key in active_keys:
|
|
values = [m.get(key, 0.5) for m in metrics_history]
|
|
start_val = values[0]
|
|
end_val = values[-1]
|
|
volatility = statistics.stdev(values) if len(values) > 1 else 0.0
|
|
|
|
value_100 = round(end_val * 100)
|
|
start_100 = round(start_val * 100)
|
|
inverse = key in INVERSE_METRICS
|
|
|
|
results.append({
|
|
"metric": key,
|
|
"label": METRIC_LABELS[key],
|
|
"value": value_100,
|
|
"start_value": start_100,
|
|
"end_raw": end_val,
|
|
"start_raw": start_val,
|
|
"min": round(min(values) * 100),
|
|
"max": round(max(values) * 100),
|
|
"trend": _trend(start_val, end_val),
|
|
"volatility": round(volatility, 4),
|
|
"rating": _rate(value_100, inverse),
|
|
"inverse": inverse,
|
|
})
|
|
|
|
return sorted(results, key=lambda x: x["metric"])
|
|
|
|
@staticmethod
|
|
def compute_agent_stats(agents: list[AgentPersona], actions: list[ActionEntry]) -> list[dict]:
|
|
action_counts: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
speeches: dict[int, list[str]] = defaultdict(list)
|
|
thoughts: dict[int, list[str]] = defaultdict(list)
|
|
|
|
for a in actions:
|
|
action_counts[a.agent_id][a.action_type.value] += 1
|
|
if a.speech:
|
|
speeches[a.agent_id].append(a.speech)
|
|
if a.internal_thought:
|
|
thoughts[a.agent_id].append(a.internal_thought)
|
|
|
|
results = []
|
|
for agent in agents:
|
|
results.append({
|
|
"id": agent.id,
|
|
"name": agent.name,
|
|
"role": agent.role,
|
|
"faction": agent.faction,
|
|
"emotional_state": agent.emotional_state,
|
|
"personality": agent.personality.model_dump(),
|
|
"action_counts": dict(action_counts.get(agent.id, {})),
|
|
"speeches": speeches.get(agent.id, [])[-10:],
|
|
"thoughts": thoughts.get(agent.id, [])[-10:],
|
|
"total_actions": sum(action_counts.get(agent.id, {}).values()),
|
|
})
|
|
return results
|
|
|
|
@staticmethod
|
|
def compute_segment_stats(agent_stats: list[dict]) -> list[dict]:
|
|
segments: dict[str, list[dict]] = defaultdict(list)
|
|
for a in agent_stats:
|
|
seg_key = a["role"] or "General"
|
|
segments[seg_key].append(a)
|
|
|
|
results = []
|
|
for seg_name, seg_agents in segments.items():
|
|
total = len(seg_agents)
|
|
purchased = sum(1 for a in seg_agents if a["action_counts"].get("PURCHASE", 0) > 0)
|
|
abandoned = sum(1 for a in seg_agents if a["action_counts"].get("ABANDON", 0) > 0)
|
|
recommended = sum(1 for a in seg_agents if a["action_counts"].get("RECOMMEND", 0) > 0)
|
|
compared = sum(1 for a in seg_agents if a["action_counts"].get("COMPARE", 0) > 0)
|
|
|
|
adopted = purchased - abandoned if purchased > abandoned else 0
|
|
churned = abandoned
|
|
|
|
results.append({
|
|
"name": seg_name,
|
|
"agent_count": total,
|
|
"funnel": {
|
|
"aware": total,
|
|
"interested": purchased + compared + recommended,
|
|
"tried": purchased,
|
|
"adopted": adopted,
|
|
"churned": churned,
|
|
},
|
|
"adoption_pct": round((adopted / total) * 100) if total > 0 else 0,
|
|
"advocate_count": recommended,
|
|
"top_speeches": [],
|
|
"top_thoughts": [],
|
|
})
|
|
|
|
all_speeches = []
|
|
all_thoughts = []
|
|
for a in seg_agents:
|
|
all_speeches.extend(a["speeches"][-3:])
|
|
all_thoughts.extend(a["thoughts"][-3:])
|
|
results[-1]["top_speeches"] = all_speeches[-15:]
|
|
results[-1]["top_thoughts"] = all_thoughts[-15:]
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def detect_inflection_points(metrics_history: list[dict], threshold: float = 0.05) -> list[dict]:
|
|
if len(metrics_history) < 2:
|
|
return []
|
|
|
|
points = []
|
|
metric_keys = list(METRIC_LABELS.keys())
|
|
|
|
for i in range(1, len(metrics_history)):
|
|
curr = metrics_history[i]
|
|
prev = metrics_history[i - 1]
|
|
round_num = curr.get("round", i)
|
|
day = round_num // 3 + 1
|
|
|
|
for key in metric_keys:
|
|
delta = abs(curr.get(key, 0.5) - prev.get(key, 0.5))
|
|
if delta >= threshold:
|
|
direction = "spike" if curr.get(key, 0.5) > prev.get(key, 0.5) else "drop"
|
|
points.append({
|
|
"day": day,
|
|
"round": round_num,
|
|
"metric": key,
|
|
"label": METRIC_LABELS[key],
|
|
"delta": round(delta, 4),
|
|
"direction": direction,
|
|
"value_before": round(prev.get(key, 0.5), 4),
|
|
"value_after": round(curr.get(key, 0.5), 4),
|
|
})
|
|
|
|
points.sort(key=lambda x: x["delta"], reverse=True)
|
|
return points[:20]
|
|
|
|
@staticmethod
|
|
def compute_confidence(metrics_history: list[dict], agent_count: int) -> str:
|
|
rounds = len(metrics_history)
|
|
if rounds >= 30 and agent_count >= 15:
|
|
return "high"
|
|
if rounds >= 15 and agent_count >= 8:
|
|
return "medium"
|
|
return "low"
|
|
|
|
@staticmethod
|
|
def format_causal_links(causal_links: list) -> list[dict]:
|
|
"""Format CausalLink objects for the report."""
|
|
return [
|
|
{
|
|
"cause": l.cause,
|
|
"cause_label": l.cause.replace("_", " ").title(),
|
|
"effect": l.effect,
|
|
"effect_label": l.effect.replace("_", " ").title(),
|
|
"lag": l.lag,
|
|
"p_value": l.p_value,
|
|
"strength": l.strength,
|
|
"description": (
|
|
f"{l.cause.replace('_', ' ').title()} Granger-causes "
|
|
f"{l.effect.replace('_', ' ').title()} with a {l.lag}-round lag "
|
|
f"(p={l.p_value:.3f}, {l.strength} evidence)"
|
|
),
|
|
}
|
|
for l in causal_links
|
|
]
|
|
|
|
@staticmethod
|
|
def format_counterfactuals(counterfactuals: list) -> list[dict]:
|
|
"""Format CounterfactualResult objects for the report."""
|
|
results = []
|
|
for c in counterfactuals:
|
|
significant_impacts = {
|
|
k: v for k, v in c.metric_impacts.items() if abs(v) > 0.03
|
|
}
|
|
if not significant_impacts:
|
|
continue
|
|
results.append({
|
|
"event_round": c.event_round,
|
|
"event_description": c.event_description,
|
|
"impacts": [
|
|
{
|
|
"metric": k,
|
|
"label": k.replace("_", " ").title(),
|
|
"delta": round(v, 3),
|
|
"direction": "increased" if v > 0 else "decreased",
|
|
"description": (
|
|
f"{k.replace('_', ' ').title()} {'increased' if v > 0 else 'decreased'} "
|
|
f"by {abs(v):.2f} due to this event"
|
|
),
|
|
}
|
|
for k, v in sorted(significant_impacts.items(), key=lambda x: abs(x[1]), reverse=True)
|
|
],
|
|
})
|
|
return results
|
|
|
|
@staticmethod
|
|
def format_coherence_scores(coherence_scores: dict) -> dict:
|
|
"""Summarize agent coherence for the report."""
|
|
if not coherence_scores:
|
|
return {"overall_score": 1.0, "total_agents": 0, "coherent_count": 0, "flagged_agents": []}
|
|
|
|
scores = list(coherence_scores.values())
|
|
overall = sum(s.score for s in scores) / len(scores) if scores else 1.0
|
|
coherent = sum(1 for s in scores if s.score >= 0.5)
|
|
flagged = [
|
|
{
|
|
"agent_id": s.agent_id,
|
|
"score": round(s.score, 2),
|
|
"contradictions": s.contradictions,
|
|
}
|
|
for s in sorted(scores, key=lambda x: x.score)
|
|
if s.score < 0.5
|
|
][:5]
|
|
|
|
return {
|
|
"overall_score": round(overall, 2),
|
|
"total_agents": len(scores),
|
|
"coherent_count": coherent,
|
|
"coherent_pct": round((coherent / len(scores)) * 100) if scores else 100,
|
|
"flagged_agents": flagged,
|
|
}
|
|
|