824 lines
34 KiB
Python
824 lines
34 KiB
Python
"""
|
||
Private Impact Profile Generator
|
||
|
||
Converts Zep graph entities into RelationalAgentProfile instances for the
|
||
Private Impact simulation mode.
|
||
|
||
Extends OasisProfileGenerator with relational dimensions:
|
||
- Relationship type with the decision maker (hierarchical, client, peer, ...)
|
||
- Trust level, financial sensitivity, equity tolerance, institutional loyalty
|
||
- Natural reaction mode (internalize, confront, silent_leave, coalition_build)
|
||
- Cascade influence graph (which agents this agent can expose)
|
||
|
||
Key design principle (from IDEE-FORK-MIROFISH.md §4):
|
||
The `persona` field is the sole behavioral vector injected into the LLM
|
||
system prompt each round. All relational dimensions are encoded as natural
|
||
language inside `persona` — no engine modification required.
|
||
"""
|
||
|
||
import json
|
||
import random
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from threading import Lock
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
import concurrent.futures
|
||
|
||
from openai import OpenAI
|
||
from zep_cloud.client import Zep
|
||
|
||
from ..config import Config
|
||
from ..utils.logger import get_logger
|
||
from ..utils.locale import get_language_instruction, get_locale, set_locale
|
||
from .oasis_profile_generator import OasisAgentProfile, OasisProfileGenerator
|
||
from .zep_entity_reader import EntityNode
|
||
|
||
logger = get_logger('mirofish.private_impact_profile')
|
||
|
||
|
||
# ── RelationalAgentProfile ────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class RelationalAgentProfile(OasisAgentProfile):
|
||
"""
|
||
Extended OASIS Agent Profile with relational network dimensions.
|
||
|
||
All relational fields are encoded into the `persona` text field via
|
||
_encode_relational_persona() before being stored. The inherited `persona`
|
||
is what gets injected into the LLM system prompt each simulation round.
|
||
"""
|
||
|
||
# Relationship with the decision maker
|
||
relational_link_type: str = "peer" # hierarchical | client | peer | family | competitor
|
||
relational_seniority_years: int = 0
|
||
relational_trust_level: float = 0.5 # 0.0 → 1.0
|
||
|
||
# Psycho-social dimensions
|
||
financial_sensitivity: float = 0.5 # Sensitivity to wealth signals
|
||
equity_tolerance: float = 0.5 # Tolerance for status disparities
|
||
institutional_loyalty: float = 0.5 # Loyalty to the org vs the person
|
||
|
||
# Natural reaction mode when facing a triggering decision
|
||
private_reaction_mode: str = "internalize" # internalize | confront | silent_leave | coalition_build
|
||
|
||
# Cascade influence graph: agent_ids this agent can expose
|
||
cascade_influence: List[int] = field(default_factory=list)
|
||
|
||
def to_private_format(self) -> Dict[str, Any]:
|
||
"""
|
||
Serialize to the format expected by run_private_simulation.py.
|
||
|
||
The simulation engine reads agent_configs as plain dicts, accessing:
|
||
agent_id, entity_name, persona, cascade_influence, active_hours,
|
||
activity_level.
|
||
"""
|
||
return {
|
||
"agent_id": self.user_id,
|
||
"entity_name": self.name,
|
||
"user_name": self.user_name,
|
||
"bio": self.bio,
|
||
"persona": self.persona, # Encoded with relational context
|
||
"cascade_influence": self.cascade_influence,
|
||
"relational_link_type": self.relational_link_type,
|
||
"relational_seniority_years": self.relational_seniority_years,
|
||
"relational_trust_level": self.relational_trust_level,
|
||
"financial_sensitivity": self.financial_sensitivity,
|
||
"equity_tolerance": self.equity_tolerance,
|
||
"institutional_loyalty": self.institutional_loyalty,
|
||
"private_reaction_mode": self.private_reaction_mode,
|
||
"age": self.age,
|
||
"gender": self.gender,
|
||
"mbti": self.mbti,
|
||
"country": self.country,
|
||
"profession": self.profession,
|
||
"source_entity_uuid": self.source_entity_uuid,
|
||
"source_entity_type": self.source_entity_type,
|
||
"created_at": self.created_at,
|
||
}
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""Full dict representation including relational fields."""
|
||
base = super().to_dict()
|
||
base.update({
|
||
"relational_link_type": self.relational_link_type,
|
||
"relational_seniority_years": self.relational_seniority_years,
|
||
"relational_trust_level": self.relational_trust_level,
|
||
"financial_sensitivity": self.financial_sensitivity,
|
||
"equity_tolerance": self.equity_tolerance,
|
||
"institutional_loyalty": self.institutional_loyalty,
|
||
"private_reaction_mode": self.private_reaction_mode,
|
||
"cascade_influence": self.cascade_influence,
|
||
})
|
||
return base
|
||
|
||
|
||
# ── PrivateImpactProfileGenerator ────────────────────────────────────────────
|
||
|
||
class PrivateImpactProfileGenerator(OasisProfileGenerator):
|
||
"""
|
||
Generates RelationalAgentProfile instances for the Private Impact simulation.
|
||
|
||
Extends OasisProfileGenerator with:
|
||
- Relational entity types (Employee, Manager, Client, ...)
|
||
- LLM prompt enriched with relational dimensions
|
||
- Relational rule-based fallback by entity type
|
||
- persona encoding that injects relational context as natural language
|
||
|
||
Pipeline (same as OasisProfileGenerator):
|
||
EntityNode (Zep) → _build_entity_context() → LLM / rule-based
|
||
→ RelationalAgentProfile (relational fields encoded into persona)
|
||
"""
|
||
|
||
# Relational entity types — map to default behavioral parameters
|
||
RELATIONAL_ENTITY_TYPES = [
|
||
"employee", "manager", "client", "competitor",
|
||
"partner", "familymember", "colleague", "investor",
|
||
]
|
||
|
||
# Default behavioral parameters by relational type
|
||
# (trust_level, financial_sensitivity, equity_tolerance,
|
||
# institutional_loyalty, reaction_mode, activity_level, active_hours)
|
||
RELATIONAL_DEFAULTS: Dict[str, Dict[str, Any]] = {
|
||
"employee": {
|
||
"trust_level": 0.6,
|
||
"financial_sensitivity": 0.7,
|
||
"equity_tolerance": 0.4,
|
||
"institutional_loyalty": 0.6,
|
||
"reaction_mode": "internalize",
|
||
"activity_level": 0.6,
|
||
"active_hours": list(range(8, 19)),
|
||
"influence_weight": 0.8,
|
||
},
|
||
"manager": {
|
||
"trust_level": 0.7,
|
||
"financial_sensitivity": 0.5,
|
||
"equity_tolerance": 0.5,
|
||
"institutional_loyalty": 0.7,
|
||
"reaction_mode": "confront",
|
||
"activity_level": 0.5,
|
||
"active_hours": list(range(8, 20)),
|
||
"influence_weight": 1.5,
|
||
},
|
||
"client": {
|
||
"trust_level": 0.4,
|
||
"financial_sensitivity": 0.3,
|
||
"equity_tolerance": 0.6,
|
||
"institutional_loyalty": 0.3,
|
||
"reaction_mode": "silent_leave",
|
||
"activity_level": 0.3,
|
||
"active_hours": list(range(9, 13)) + list(range(17, 21)),
|
||
"influence_weight": 1.2,
|
||
},
|
||
"competitor": {
|
||
"trust_level": 0.2,
|
||
"financial_sensitivity": 0.4,
|
||
"equity_tolerance": 0.7,
|
||
"institutional_loyalty": 0.1,
|
||
"reaction_mode": "coalition_build",
|
||
"activity_level": 0.2,
|
||
"active_hours": list(range(9, 19)),
|
||
"influence_weight": 1.0,
|
||
},
|
||
"partner": {
|
||
"trust_level": 0.6,
|
||
"financial_sensitivity": 0.4,
|
||
"equity_tolerance": 0.6,
|
||
"institutional_loyalty": 0.5,
|
||
"reaction_mode": "internalize",
|
||
"activity_level": 0.4,
|
||
"active_hours": list(range(9, 18)),
|
||
"influence_weight": 1.3,
|
||
},
|
||
"familymember": {
|
||
"trust_level": 0.8,
|
||
"financial_sensitivity": 0.8,
|
||
"equity_tolerance": 0.5,
|
||
"institutional_loyalty": 0.2,
|
||
"reaction_mode": "confront",
|
||
"activity_level": 0.7,
|
||
"active_hours": list(range(7, 10)) + list(range(18, 24)),
|
||
"influence_weight": 0.9,
|
||
},
|
||
}
|
||
|
||
# Reaction mode descriptions for LLM prompt injection
|
||
REACTION_MODE_DESCRIPTIONS: Dict[str, str] = {
|
||
"internalize": "processes the news internally without immediate visible action; absorbs tension before potentially acting later",
|
||
"confront": "tends to address the issue head-on, speaking directly to the decision maker or raising concerns openly",
|
||
"silent_leave": "quietly disengages — reduces commitment, starts looking for alternatives, without announcing it",
|
||
"coalition_build": "looks for allies among peers before taking any visible action; builds shared narratives",
|
||
}
|
||
|
||
def generate_profile_from_entity(
|
||
self,
|
||
entity: EntityNode,
|
||
user_id: int,
|
||
use_llm: bool = True,
|
||
cascade_influence: Optional[List[int]] = None,
|
||
) -> RelationalAgentProfile:
|
||
"""
|
||
Generate a RelationalAgentProfile from a Zep entity node.
|
||
|
||
Divergence from OasisProfileGenerator.generate_profile_from_entity:
|
||
Returns RelationalAgentProfile instead of OasisAgentProfile.
|
||
Relational dimensions are encoded into the persona text field.
|
||
|
||
Args:
|
||
entity: Zep entity node.
|
||
user_id: Agent ID in the simulation.
|
||
use_llm: Whether to use LLM for profile generation.
|
||
cascade_influence: List of agent_ids this agent can expose (optional).
|
||
|
||
Returns:
|
||
RelationalAgentProfile with relational context encoded in persona.
|
||
"""
|
||
entity_type = entity.get_entity_type() or "peer"
|
||
name = entity.name
|
||
user_name = self._generate_username(name)
|
||
context = self._build_entity_context(entity)
|
||
|
||
if use_llm:
|
||
profile_data = self._generate_relational_profile_with_llm(
|
||
entity_name=name,
|
||
entity_type=entity_type,
|
||
entity_summary=entity.summary,
|
||
entity_attributes=entity.attributes,
|
||
context=context,
|
||
)
|
||
else:
|
||
profile_data = self._generate_relational_profile_rule_based(
|
||
entity_name=name,
|
||
entity_type=entity_type,
|
||
entity_summary=entity.summary,
|
||
)
|
||
|
||
# Extract relational dimensions from LLM output
|
||
relational_link_type = profile_data.get("relational_link_type", "peer")
|
||
seniority_years = int(profile_data.get("relational_seniority_years", 0))
|
||
trust_level = float(profile_data.get("relational_trust_level", 0.5))
|
||
financial_sensitivity = float(profile_data.get("financial_sensitivity", 0.5))
|
||
equity_tolerance = float(profile_data.get("equity_tolerance", 0.5))
|
||
institutional_loyalty = float(profile_data.get("institutional_loyalty", 0.5))
|
||
reaction_mode = profile_data.get("private_reaction_mode", "internalize")
|
||
|
||
# Clamp floats to [0.0, 1.0]
|
||
trust_level = max(0.0, min(1.0, trust_level))
|
||
financial_sensitivity = max(0.0, min(1.0, financial_sensitivity))
|
||
equity_tolerance = max(0.0, min(1.0, equity_tolerance))
|
||
institutional_loyalty = max(0.0, min(1.0, institutional_loyalty))
|
||
|
||
# Encode relational context into the persona text
|
||
base_persona = profile_data.get(
|
||
"persona", entity.summary or f"A {entity_type} named {name}."
|
||
)
|
||
enriched_persona = self._encode_relational_persona(
|
||
base_persona=base_persona,
|
||
name=name,
|
||
relational_link_type=relational_link_type,
|
||
seniority_years=seniority_years,
|
||
trust_level=trust_level,
|
||
financial_sensitivity=financial_sensitivity,
|
||
equity_tolerance=equity_tolerance,
|
||
institutional_loyalty=institutional_loyalty,
|
||
reaction_mode=reaction_mode,
|
||
)
|
||
|
||
return RelationalAgentProfile(
|
||
user_id=user_id,
|
||
user_name=user_name,
|
||
name=name,
|
||
bio=profile_data.get("bio", f"{entity_type}: {name}"),
|
||
persona=enriched_persona,
|
||
karma=profile_data.get("karma", random.randint(500, 3000)),
|
||
friend_count=profile_data.get("friend_count", random.randint(20, 300)),
|
||
follower_count=profile_data.get("follower_count", random.randint(30, 500)),
|
||
statuses_count=profile_data.get("statuses_count", random.randint(50, 1000)),
|
||
age=profile_data.get("age"),
|
||
gender=profile_data.get("gender"),
|
||
mbti=profile_data.get("mbti"),
|
||
country=profile_data.get("country"),
|
||
profession=profile_data.get("profession"),
|
||
interested_topics=profile_data.get("interested_topics", []),
|
||
source_entity_uuid=entity.uuid,
|
||
source_entity_type=entity_type,
|
||
relational_link_type=relational_link_type,
|
||
relational_seniority_years=seniority_years,
|
||
relational_trust_level=trust_level,
|
||
financial_sensitivity=financial_sensitivity,
|
||
equity_tolerance=equity_tolerance,
|
||
institutional_loyalty=institutional_loyalty,
|
||
private_reaction_mode=reaction_mode,
|
||
cascade_influence=cascade_influence or [],
|
||
)
|
||
|
||
# ── Persona encoding ──────────────────────────────────────────────────────
|
||
|
||
def _encode_relational_persona(
|
||
self,
|
||
base_persona: str,
|
||
name: str,
|
||
relational_link_type: str,
|
||
seniority_years: int,
|
||
trust_level: float,
|
||
financial_sensitivity: float,
|
||
equity_tolerance: float,
|
||
institutional_loyalty: float,
|
||
reaction_mode: str,
|
||
) -> str:
|
||
"""
|
||
Encode relational dimensions into natural language appended to persona.
|
||
|
||
This is the central mechanism: OASIS (and our private simulation) inject
|
||
the persona field as-is into the LLM system prompt. By appending a
|
||
structured relational context block, we guide agent behavior without
|
||
modifying the simulation engine.
|
||
|
||
Args:
|
||
base_persona: Base persona text from LLM or rule-based fallback.
|
||
name: Agent name.
|
||
relational_link_type: Type of relationship with the decision maker.
|
||
seniority_years: Years in this relational context.
|
||
trust_level: Trust level with decision maker (0–1).
|
||
financial_sensitivity: Sensitivity to wealth signals (0–1).
|
||
equity_tolerance: Tolerance for status disparities (0–1).
|
||
institutional_loyalty: Loyalty to the org vs the person (0–1).
|
||
reaction_mode: Natural reaction pattern.
|
||
|
||
Returns:
|
||
Enriched persona string with relational context block appended.
|
||
"""
|
||
# Trust descriptor
|
||
if trust_level >= 0.75:
|
||
trust_desc = "very high"
|
||
elif trust_level >= 0.5:
|
||
trust_desc = "moderate"
|
||
elif trust_level >= 0.25:
|
||
trust_desc = "low"
|
||
else:
|
||
trust_desc = "very low"
|
||
|
||
# Financial sensitivity descriptor
|
||
if financial_sensitivity >= 0.75:
|
||
fin_desc = "highly sensitive to wealth signals and perceived inequity"
|
||
elif financial_sensitivity >= 0.5:
|
||
fin_desc = "moderately sensitive to financial signals"
|
||
else:
|
||
fin_desc = "relatively indifferent to wealth signals"
|
||
|
||
# Equity tolerance descriptor
|
||
if equity_tolerance <= 0.25:
|
||
eq_desc = "very low tolerance for status disparities — notices and resents inequalities"
|
||
elif equity_tolerance <= 0.5:
|
||
eq_desc = "moderate discomfort with status disparities"
|
||
else:
|
||
eq_desc = "accepts status differences as normal"
|
||
|
||
reaction_desc = self.REACTION_MODE_DESCRIPTIONS.get(
|
||
reaction_mode,
|
||
"processes the situation and responds according to their character"
|
||
)
|
||
|
||
seniority_str = (
|
||
f"{seniority_years} year{'s' if seniority_years != 1 else ''}"
|
||
if seniority_years > 0 else "recent"
|
||
)
|
||
|
||
loyalty_desc = (
|
||
"strongly attached to the organization and its continuity"
|
||
if institutional_loyalty >= 0.7
|
||
else "balanced between personal interests and organizational ones"
|
||
if institutional_loyalty >= 0.4
|
||
else "primarily driven by personal interests over institutional ones"
|
||
)
|
||
|
||
relational_block = (
|
||
f"\n\n--- Relational Context (Private Impact Simulation) ---\n"
|
||
f"Your name is {name}.\n"
|
||
f"Your relationship with the decision maker: {relational_link_type} "
|
||
f"({seniority_str} of shared history).\n"
|
||
f"Trust level with the decision maker: {trust_desc} ({trust_level:.1f}/1.0).\n"
|
||
f"Financial sensitivity: {fin_desc} (score: {financial_sensitivity:.1f}).\n"
|
||
f"Equity tolerance: {eq_desc} (score: {equity_tolerance:.1f}).\n"
|
||
f"Institutional loyalty: {loyalty_desc} (score: {institutional_loyalty:.1f}).\n"
|
||
f"Your natural reaction mode: {reaction_mode} — you {reaction_desc}.\n"
|
||
f"--- End Relational Context ---"
|
||
)
|
||
|
||
return base_persona + relational_block
|
||
|
||
# ── LLM profile generation ────────────────────────────────────────────────
|
||
|
||
def _generate_relational_profile_with_llm(
|
||
self,
|
||
entity_name: str,
|
||
entity_type: str,
|
||
entity_summary: str,
|
||
entity_attributes: Dict[str, Any],
|
||
context: str,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Generate relational profile via LLM.
|
||
|
||
Divergence from OasisProfileGenerator._generate_profile_with_llm:
|
||
Adds relational dimension fields to the JSON output schema.
|
||
Falls back to rule-based generation on failure (same pattern as parent).
|
||
|
||
Args:
|
||
entity_name: Entity name.
|
||
entity_type: Entity type from Zep.
|
||
entity_summary: Entity summary from Zep.
|
||
entity_attributes: Entity attributes dict.
|
||
context: Enriched context from _build_entity_context().
|
||
|
||
Returns:
|
||
Profile data dict including relational dimensions.
|
||
"""
|
||
prompt = self._build_relational_persona_prompt(
|
||
entity_name=entity_name,
|
||
entity_type=entity_type,
|
||
entity_summary=entity_summary,
|
||
entity_attributes=entity_attributes,
|
||
context=context,
|
||
)
|
||
system_prompt = (
|
||
"You are an expert in organizational psychology and behavioral simulation. "
|
||
"Generate realistic relational agent profiles for private impact simulations. "
|
||
"Return valid JSON only — no markdown, no prose outside the JSON object. "
|
||
f"{get_language_instruction()}"
|
||
)
|
||
|
||
max_attempts = 3
|
||
last_error = None
|
||
|
||
for attempt in range(max_attempts):
|
||
try:
|
||
response = self.client.chat.completions.create(
|
||
model=self.model_name,
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
response_format={"type": "json_object"},
|
||
temperature=0.7 - (attempt * 0.1),
|
||
)
|
||
|
||
content = response.choices[0].message.content
|
||
finish_reason = response.choices[0].finish_reason
|
||
|
||
if finish_reason == 'length':
|
||
logger.warning(f"LLM output truncated (attempt {attempt + 1}), attempting fix...")
|
||
content = self._fix_truncated_json(content)
|
||
|
||
try:
|
||
result = json.loads(content)
|
||
|
||
# Ensure required fields
|
||
if not result.get("bio"):
|
||
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
|
||
if not result.get("persona"):
|
||
result["persona"] = entity_summary or f"{entity_name} is a {entity_type}."
|
||
|
||
return result
|
||
|
||
except json.JSONDecodeError as je:
|
||
logger.warning(f"JSON parse failed (attempt {attempt + 1}): {str(je)[:80]}")
|
||
result = self._try_fix_json(content, entity_name, entity_type, entity_summary)
|
||
if result.get("_fixed"):
|
||
del result["_fixed"]
|
||
return result
|
||
last_error = je
|
||
|
||
except Exception as e:
|
||
logger.warning(f"LLM call failed (attempt {attempt + 1}): {str(e)[:80]}")
|
||
last_error = e
|
||
time.sleep(1 * (attempt + 1))
|
||
|
||
logger.warning(
|
||
f"LLM profile generation failed after {max_attempts} attempts: {last_error}. "
|
||
f"Falling back to rule-based."
|
||
)
|
||
return self._generate_relational_profile_rule_based(
|
||
entity_name=entity_name,
|
||
entity_type=entity_type,
|
||
entity_summary=entity_summary,
|
||
)
|
||
|
||
def _build_relational_persona_prompt(
|
||
self,
|
||
entity_name: str,
|
||
entity_type: str,
|
||
entity_summary: str,
|
||
entity_attributes: Dict[str, Any],
|
||
context: str,
|
||
) -> str:
|
||
"""
|
||
Build the LLM prompt for relational profile generation.
|
||
|
||
Divergence from parent _build_individual_persona_prompt:
|
||
Adds relational dimension fields to the JSON schema.
|
||
"""
|
||
attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "none"
|
||
context_str = context[:3000] if context else "No additional context."
|
||
|
||
return f"""Generate a relational agent profile for a private impact simulation.
|
||
|
||
Entity name: {entity_name}
|
||
Entity type: {entity_type}
|
||
Entity summary: {entity_summary}
|
||
Entity attributes: {attrs_str}
|
||
|
||
Context:
|
||
{context_str}
|
||
|
||
Return a JSON object with these fields:
|
||
|
||
1. bio: Short profile description (max 200 characters)
|
||
2. persona: Detailed behavioral description (plain text, no line breaks inside the string, ~500 words):
|
||
- Background, personality, professional history
|
||
- Emotional patterns and communication style
|
||
- Relationship with authority and institutions
|
||
- Known reactions to organizational decisions
|
||
3. age: Integer (or null)
|
||
4. gender: "male", "female", or "other"
|
||
5. mbti: MBTI type (e.g. "INTJ") or null
|
||
6. country: Country name
|
||
7. profession: Current role or function
|
||
8. interested_topics: Array of relevant topics
|
||
|
||
Relational dimensions (required):
|
||
9. relational_link_type: One of "hierarchical", "client", "peer", "family", "competitor"
|
||
10. relational_seniority_years: Integer (years in this relational context)
|
||
11. relational_trust_level: Float 0.0–1.0 (trust in decision maker)
|
||
12. financial_sensitivity: Float 0.0–1.0 (sensitivity to wealth signals)
|
||
13. equity_tolerance: Float 0.0–1.0 (tolerance for status disparities)
|
||
14. institutional_loyalty: Float 0.0–1.0 (loyalty to org vs personal interests)
|
||
15. private_reaction_mode: One of "internalize", "confront", "silent_leave", "coalition_build"
|
||
|
||
Rules:
|
||
- All string values must not contain literal line breaks
|
||
- persona must be a single continuous text paragraph
|
||
- Float values must be between 0.0 and 1.0
|
||
- Infer relational dimensions from entity type and context when possible
|
||
"""
|
||
|
||
# ── Rule-based fallback ───────────────────────────────────────────────────
|
||
|
||
def _generate_relational_profile_rule_based(
|
||
self,
|
||
entity_name: str,
|
||
entity_type: str,
|
||
entity_summary: str,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Generate relational profile using predefined defaults by entity type.
|
||
|
||
Divergence from OasisProfileGenerator._generate_profile_rule_based:
|
||
Uses RELATIONAL_DEFAULTS table instead of social media entity types.
|
||
Covers: Employee, Manager, Client, Competitor, Partner, FamilyMember.
|
||
|
||
Args:
|
||
entity_name: Entity name.
|
||
entity_type: Relational entity type.
|
||
entity_summary: Entity summary for persona fallback.
|
||
|
||
Returns:
|
||
Profile data dict with relational dimensions set from defaults.
|
||
"""
|
||
type_key = entity_type.lower()
|
||
defaults = self.RELATIONAL_DEFAULTS.get(type_key, self.RELATIONAL_DEFAULTS["employee"])
|
||
|
||
base_persona = (
|
||
entity_summary
|
||
or f"{entity_name} is a {entity_type} connected to the decision maker's network."
|
||
)
|
||
|
||
return {
|
||
"bio": (
|
||
entity_summary[:150]
|
||
if entity_summary
|
||
else f"{entity_type}: {entity_name}"
|
||
),
|
||
"persona": base_persona,
|
||
"age": random.randint(25, 55),
|
||
"gender": random.choice(["male", "female"]),
|
||
"mbti": random.choice(self.MBTI_TYPES),
|
||
"country": random.choice(self.COUNTRIES),
|
||
"profession": entity_type.capitalize(),
|
||
"interested_topics": ["Professional Development", "Organizational Dynamics"],
|
||
# Relational dimensions from defaults table
|
||
"relational_link_type": type_key if type_key in (
|
||
"hierarchical", "client", "peer", "family", "competitor"
|
||
) else "peer",
|
||
"relational_seniority_years": random.randint(1, 8),
|
||
"relational_trust_level": defaults["trust_level"],
|
||
"financial_sensitivity": defaults["financial_sensitivity"],
|
||
"equity_tolerance": defaults.get("equity_tolerance", 0.5),
|
||
"institutional_loyalty": defaults.get("institutional_loyalty", 0.5),
|
||
"private_reaction_mode": defaults["reaction_mode"],
|
||
}
|
||
|
||
# ── Batch generation ──────────────────────────────────────────────────────
|
||
|
||
def generate_profiles_from_entities(
|
||
self,
|
||
entities: List[EntityNode],
|
||
use_llm: bool = True,
|
||
progress_callback: Optional[callable] = None,
|
||
graph_id: Optional[str] = None,
|
||
parallel_count: int = 5,
|
||
realtime_output_path: Optional[str] = None,
|
||
cascade_influence_map: Optional[Dict[int, List[int]]] = None,
|
||
**kwargs, # absorb unused parent kwargs (output_platform, etc.)
|
||
) -> List[RelationalAgentProfile]:
|
||
"""
|
||
Generate RelationalAgentProfile instances for all entities in parallel.
|
||
|
||
Divergence from OasisProfileGenerator.generate_profiles_from_entities:
|
||
Returns RelationalAgentProfile instances.
|
||
Accepts cascade_influence_map to assign relational graph edges per agent.
|
||
|
||
Args:
|
||
entities: List of Zep entity nodes.
|
||
use_llm: Whether to use LLM generation (falls back to rule-based).
|
||
progress_callback: Optional callback(current, total, message).
|
||
graph_id: Zep graph ID for context enrichment.
|
||
parallel_count: Number of concurrent generation threads.
|
||
realtime_output_path: Path to write profiles as they are generated.
|
||
cascade_influence_map: {agent_index: [influenced_agent_ids]}.
|
||
|
||
Returns:
|
||
List of RelationalAgentProfile instances.
|
||
"""
|
||
if graph_id:
|
||
self.graph_id = graph_id
|
||
|
||
cascade_influence_map = cascade_influence_map or {}
|
||
total = len(entities)
|
||
profiles: List[Optional[RelationalAgentProfile]] = [None] * total
|
||
completed_count = [0]
|
||
lock = Lock()
|
||
|
||
def save_realtime() -> None:
|
||
"""Write generated profiles to file as they complete."""
|
||
if not realtime_output_path:
|
||
return
|
||
with lock:
|
||
existing = [p for p in profiles if p is not None]
|
||
if not existing:
|
||
return
|
||
try:
|
||
data = [p.to_private_format() for p in existing]
|
||
with open(realtime_output_path, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
logger.warning(f"Realtime save failed: {e}")
|
||
|
||
current_locale = get_locale()
|
||
|
||
def generate_single(idx: int, entity: EntityNode) -> tuple:
|
||
set_locale(current_locale)
|
||
entity_type = entity.get_entity_type() or "peer"
|
||
cascade = cascade_influence_map.get(idx, [])
|
||
|
||
try:
|
||
profile = self.generate_profile_from_entity(
|
||
entity=entity,
|
||
user_id=idx,
|
||
use_llm=use_llm,
|
||
cascade_influence=cascade,
|
||
)
|
||
self._print_generated_relational_profile(entity.name, entity_type, profile)
|
||
return idx, profile, None
|
||
|
||
except Exception as e:
|
||
logger.error(f"Profile generation failed for {entity.name}: {e}")
|
||
fallback = RelationalAgentProfile(
|
||
user_id=idx,
|
||
user_name=self._generate_username(entity.name),
|
||
name=entity.name,
|
||
bio=f"{entity_type}: {entity.name}",
|
||
persona=(
|
||
entity.summary
|
||
or f"{entity.name} is a {entity_type} in the relational network."
|
||
),
|
||
source_entity_uuid=entity.uuid,
|
||
source_entity_type=entity_type,
|
||
cascade_influence=cascade,
|
||
)
|
||
return idx, fallback, str(e)
|
||
|
||
logger.info(
|
||
f"Starting parallel profile generation — {total} entities, "
|
||
f"parallel_count={parallel_count}"
|
||
)
|
||
print(f"\n{'='*60}")
|
||
print(f"Private Impact — Generating {total} relational profiles (parallel: {parallel_count})")
|
||
print(f"{'='*60}\n")
|
||
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor:
|
||
future_map = {
|
||
executor.submit(generate_single, idx, entity): (idx, entity)
|
||
for idx, entity in enumerate(entities)
|
||
}
|
||
|
||
for future in concurrent.futures.as_completed(future_map):
|
||
idx, entity = future_map[future]
|
||
entity_type = entity.get_entity_type() or "peer"
|
||
|
||
try:
|
||
result_idx, profile, error = future.result()
|
||
profiles[result_idx] = profile
|
||
|
||
with lock:
|
||
completed_count[0] += 1
|
||
current = completed_count[0]
|
||
|
||
save_realtime()
|
||
|
||
if progress_callback:
|
||
progress_callback(
|
||
current,
|
||
total,
|
||
f"Done {current}/{total}: {entity.name} ({entity_type})"
|
||
)
|
||
|
||
if error:
|
||
logger.warning(f"[{current}/{total}] {entity.name} using fallback: {error}")
|
||
else:
|
||
logger.info(f"[{current}/{total}] Generated: {entity.name} ({entity_type})")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing {entity.name}: {e}")
|
||
with lock:
|
||
completed_count[0] += 1
|
||
|
||
profiles[idx] = RelationalAgentProfile(
|
||
user_id=idx,
|
||
user_name=self._generate_username(entity.name),
|
||
name=entity.name,
|
||
bio=f"{entity_type}: {entity.name}",
|
||
persona=entity.summary or "A participant in the relational network.",
|
||
source_entity_uuid=entity.uuid,
|
||
source_entity_type=entity_type,
|
||
)
|
||
save_realtime()
|
||
|
||
valid_count = len([p for p in profiles if p is not None])
|
||
print(f"\n{'='*60}")
|
||
print(f"Profile generation complete — {valid_count} relational agents ready")
|
||
print(f"{'='*60}\n")
|
||
|
||
return [p for p in profiles if p is not None]
|
||
|
||
def save_profiles(
|
||
self,
|
||
profiles: List[RelationalAgentProfile],
|
||
file_path: str,
|
||
platform: str = "private",
|
||
) -> None:
|
||
"""
|
||
Save relational profiles to JSON.
|
||
|
||
Divergence from OasisProfileGenerator.save_profiles:
|
||
Always uses to_private_format() — no CSV output, no Reddit/Twitter format.
|
||
The output is a JSON array of agent config dicts consumed by
|
||
run_private_simulation.py.
|
||
|
||
Args:
|
||
profiles: List of RelationalAgentProfile instances.
|
||
file_path: Output path (.json).
|
||
platform: Ignored — always uses private format.
|
||
"""
|
||
data = [p.to_private_format() for p in profiles]
|
||
with open(file_path, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
logger.info(f"Saved {len(profiles)} relational profiles to {file_path}")
|
||
|
||
# ── Console output ────────────────────────────────────────────────────────
|
||
|
||
def _print_generated_relational_profile(
|
||
self,
|
||
entity_name: str,
|
||
entity_type: str,
|
||
profile: RelationalAgentProfile,
|
||
) -> None:
|
||
"""Print a summary of the generated relational profile to stdout."""
|
||
separator = "-" * 70
|
||
lines = [
|
||
f"\n{separator}",
|
||
f"[Private Impact] Profile generated: {entity_name} ({entity_type})",
|
||
separator,
|
||
f"Name: {profile.name} | Link: {profile.relational_link_type} "
|
||
f"| Reaction: {profile.private_reaction_mode}",
|
||
f"Trust: {profile.relational_trust_level:.2f} "
|
||
f"| Fin.Sensitivity: {profile.financial_sensitivity:.2f} "
|
||
f"| Loyalty: {profile.institutional_loyalty:.2f}",
|
||
f"Cascade influence: {profile.cascade_influence}",
|
||
f"",
|
||
f"[Bio] {profile.bio}",
|
||
separator,
|
||
]
|
||
print("\n".join(lines))
|