Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app.py +3 -564
- backend/core/config.py +14 -0
- backend/core/job_registry.py +36 -0
- backend/gradio_app.py +183 -0
- backend/initiate_memory_recording.py +1 -0
- backend/main.py +33 -0
- backend/models/schemas.py +28 -0
- backend/requirements.txt +9 -0
- backend/routers/api.py +82 -0
- backend/services/git_sync.py +47 -0
- backend/services/persona_matcher.py +56 -0
- backend/services/tinytroupe_manager.py +174 -0
- backend/tests/test_api.py +77 -0
- requirements.txt +7 -22
- tinytroupe/__init__.py +1 -1
- tinytroupe/agent/memory.py +0 -18
- tinytroupe/agent/mental_faculty.py +0 -67
- tinytroupe/agent/tiny_person.py +7 -109
- tinytroupe/config.ini +6 -6
- tinytroupe/examples/agents.py +16 -16
- tinytroupe/factory/tiny_person_factory.py +2 -47
- tinytroupe/openai_utils.py +57 -85
- tinytroupe/utils/llm.py +1 -1
- tinytroupe/utils/semantics.py +0 -21
app.py
CHANGED
|
@@ -1,566 +1,5 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import random
|
| 5 |
-
|
| 6 |
-
import os
|
| 7 |
-
import requests
|
| 8 |
-
import os
|
| 9 |
-
from datetime import datetime
|
| 10 |
-
|
| 11 |
-
# Defer imports of heavy modules
|
| 12 |
-
import importlib
|
| 13 |
-
|
| 14 |
-
def get_tinytroupe_modules():
|
| 15 |
-
global Content, TinyPerson, openai_utils, SimulationManager, SimulationConfig
|
| 16 |
-
if 'Content' not in globals():
|
| 17 |
-
from tinytroupe.agent.social_types import Content
|
| 18 |
-
from tinytroupe.agent import TinyPerson
|
| 19 |
-
import tinytroupe.openai_utils as openai_utils
|
| 20 |
-
from tinytroupe.simulation_manager import SimulationManager, SimulationConfig
|
| 21 |
-
globals().update({
|
| 22 |
-
'Content': Content,
|
| 23 |
-
'TinyPerson': TinyPerson,
|
| 24 |
-
'openai_utils': openai_utils,
|
| 25 |
-
'SimulationManager': SimulationManager,
|
| 26 |
-
'SimulationConfig': SimulationConfig
|
| 27 |
-
})
|
| 28 |
-
|
| 29 |
-
class DummySimulationManager:
|
| 30 |
-
def __getattr__(self, name):
|
| 31 |
-
get_tinytroupe_modules()
|
| 32 |
-
global simulation_manager
|
| 33 |
-
# re-initialize the real one
|
| 34 |
-
simulation_manager = SimulationManager()
|
| 35 |
-
return getattr(simulation_manager, name)
|
| 36 |
-
|
| 37 |
-
# Initialize a dummy wrapper that lazily loads the real manager
|
| 38 |
-
simulation_manager = DummySimulationManager()
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# Initialize Simulation Manager
|
| 42 |
-
|
| 43 |
-
REMOTE_BACKEND = "https://auxteam-tiny-factory.hf.space"
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# =========================================================================
|
| 47 |
-
# External/Hybrid Persona Generation Logic
|
| 48 |
-
# =========================================================================
|
| 49 |
-
|
| 50 |
-
CONFIG = {
|
| 51 |
-
"persona_generation": {
|
| 52 |
-
"mode": "hybrid", # internal, external, hybrid
|
| 53 |
-
"external_api_url": "THzva/deeppersona-experience",
|
| 54 |
-
"fallback_to_internal": True
|
| 55 |
-
},
|
| 56 |
-
"action_generation": {
|
| 57 |
-
"use_internal_llm": True,
|
| 58 |
-
"external_llm_endpoint": None
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
class InternalPersonaGenerator:
|
| 63 |
-
def generate(self, business_description, customer_profile, num_personas, api_key=None):
|
| 64 |
-
if api_key:
|
| 65 |
-
os.environ["OPENAI_API_KEY"] = api_key
|
| 66 |
-
get_tinytroupe_modules()
|
| 67 |
-
from tinytroupe.factory.tiny_person_factory import TinyPersonFactory
|
| 68 |
-
factory = TinyPersonFactory(context=f"{business_description} {customer_profile}")
|
| 69 |
-
personas = factory.generate_people(number_of_people=int(num_personas))
|
| 70 |
-
return [p._persona for p in personas]
|
| 71 |
-
|
| 72 |
-
class ExternalPersonaGenerator:
|
| 73 |
-
def generate(self, business_description, customer_profile, num_personas, api_key=None):
|
| 74 |
-
return generate_personas(business_description, customer_profile, num_personas, api_key)
|
| 75 |
-
|
| 76 |
-
class HybridPersonaGenerator:
|
| 77 |
-
def __init__(self, use_external=True):
|
| 78 |
-
self.use_external = use_external
|
| 79 |
-
|
| 80 |
-
def generate(self, business_description, customer_profile, num_personas, api_key=None):
|
| 81 |
-
# We can try external first, and if it fails or returns less, fallback
|
| 82 |
-
personas = ExternalPersonaGenerator().generate(business_description, customer_profile, num_personas, api_key)
|
| 83 |
-
if not personas and CONFIG["persona_generation"]["fallback_to_internal"]:
|
| 84 |
-
return InternalPersonaGenerator().generate(business_description, customer_profile, num_personas, api_key)
|
| 85 |
-
return personas
|
| 86 |
-
|
| 87 |
-
def create_persona(name: str, business_description: str, customer_profile: str, num_personas: int, api_key=None):
|
| 88 |
-
mode = CONFIG["persona_generation"]["mode"]
|
| 89 |
-
if mode == "external":
|
| 90 |
-
return ExternalPersonaGenerator().generate(business_description, customer_profile, num_personas, api_key)
|
| 91 |
-
elif mode == "hybrid":
|
| 92 |
-
try:
|
| 93 |
-
return HybridPersonaGenerator(use_external=True).generate(business_description, customer_profile, num_personas, api_key)
|
| 94 |
-
except Exception as e:
|
| 95 |
-
print("Hybrid generation exception:", e)
|
| 96 |
-
if CONFIG["persona_generation"]["fallback_to_internal"]:
|
| 97 |
-
return InternalPersonaGenerator().generate(business_description, customer_profile, num_personas, api_key)
|
| 98 |
-
raise
|
| 99 |
-
else:
|
| 100 |
-
return InternalPersonaGenerator().generate(business_description, customer_profile, num_personas, api_key)
|
| 101 |
-
|
| 102 |
-
def get_APIEnhancedTinyPerson():
|
| 103 |
-
get_tinytroupe_modules()
|
| 104 |
-
class APIEnhancedTinyPerson(TinyPerson):
|
| 105 |
-
"""TinyPerson with external API integration"""
|
| 106 |
-
def __init__(self, name: str, use_api: bool = False, **api_config):
|
| 107 |
-
super().__init__(name)
|
| 108 |
-
self.use_api = use_api
|
| 109 |
-
from gradio_client import Client
|
| 110 |
-
self.api_client = Client(api_config.get('api_url', CONFIG["persona_generation"]["external_api_url"])) if use_api else None
|
| 111 |
-
|
| 112 |
-
def enhance_with_api(self, **params):
|
| 113 |
-
if not self.use_api:
|
| 114 |
-
raise RuntimeError("API mode not enabled")
|
| 115 |
-
|
| 116 |
-
result = self.api_client.predict(
|
| 117 |
-
age=float(params.get('age', self._get_attr('age', 25))),
|
| 118 |
-
gender=str(params.get('gender', self._get_attr('gender', 'Female'))),
|
| 119 |
-
occupation=str(params.get('occupation', self._get_attr('occupation', 'Professional'))),
|
| 120 |
-
city=str(params.get('city', self._get_attr('residence', 'Unknown'))),
|
| 121 |
-
country=str(params.get('country', self._get_attr('nationality', 'Unknown'))),
|
| 122 |
-
custom_values=str(params.get('custom_values', self._get_attr('beliefs', 'Hardworking'))),
|
| 123 |
-
custom_life_attitude=str(params.get('custom_life_attitude', self._get_attr('personality', 'Positive'))),
|
| 124 |
-
life_story=str(params.get('life_story', self._get_attr('other_facts', 'Standard story'))),
|
| 125 |
-
interests_hobbies=str(params.get('interests_hobbies', self._get_attr('preferences', 'Reading'))),
|
| 126 |
-
attribute_count=float(params.get('attribute_count', 350.0)),
|
| 127 |
-
api_name="/generate_persona"
|
| 128 |
-
)
|
| 129 |
-
|
| 130 |
-
# The result from DeepPersona is raw string. Let's merge it into the profile text.
|
| 131 |
-
self._merge_persona_data({"full_profile_text": result})
|
| 132 |
-
return self
|
| 133 |
-
|
| 134 |
-
def _get_attr(self, key, default):
|
| 135 |
-
return getattr(self, '_persona', {}).get(key, default)
|
| 136 |
-
|
| 137 |
-
def _merge_persona_data(self, api_data: dict):
|
| 138 |
-
for key, value in api_data.items():
|
| 139 |
-
if hasattr(self, f'_{key}'):
|
| 140 |
-
setattr(self, f'_{key}', value)
|
| 141 |
-
else:
|
| 142 |
-
self._persona[key] = value
|
| 143 |
-
|
| 144 |
-
return APIEnhancedTinyPerson
|
| 145 |
-
|
| 146 |
-
# =========================================================================
|
| 147 |
-
|
| 148 |
-
def generate_personas(business_description, customer_profile, num_personas, api_key=None):
|
| 149 |
-
if api_key:
|
| 150 |
-
os.environ["OPENAI_API_KEY"] = api_key
|
| 151 |
-
|
| 152 |
-
import json
|
| 153 |
-
import random
|
| 154 |
-
from gradio_client import Client
|
| 155 |
-
import openai
|
| 156 |
-
|
| 157 |
-
# Initialize the OpenAI client pointing to the Helmholtz endpoint
|
| 158 |
-
client = openai.OpenAI(
|
| 159 |
-
base_url="https://api.helmholtz-blablador.fz-juelich.de/v1",
|
| 160 |
-
api_key=api_key or os.environ.get("BLABLADOR_API_KEY") or os.environ.get("OPENAI_API_KEY", "dummy")
|
| 161 |
-
)
|
| 162 |
-
dp_client = Client("THzva/deeppersona-experience")
|
| 163 |
-
|
| 164 |
-
personas = []
|
| 165 |
-
|
| 166 |
-
for i in range(int(num_personas)):
|
| 167 |
-
# 1. Generate initial parameters for the 200 API call
|
| 168 |
-
prompt_1 = f"""
|
| 169 |
-
Given the following business description and customer profile:
|
| 170 |
-
Business: {business_description}
|
| 171 |
-
Customer: {customer_profile}
|
| 172 |
-
|
| 173 |
-
Generate realistic parameters for a persona. Return ONLY a valid JSON object with these EXACT keys (do not wrap in markdown blocks):
|
| 174 |
-
{{"Age": 30, "Gender": "Female", "Occupation": "Teacher", "City": "Berlin", "Country": "Germany", "Personal Values": "Hard work", "Life Attitude": "Optimistic", "Life Story": "Born in Munich", "Interests and Hobbies": "Reading"}}
|
| 175 |
-
"""
|
| 176 |
-
try:
|
| 177 |
-
response_1 = client.chat.completions.create(
|
| 178 |
-
model="alias-fast",
|
| 179 |
-
messages=[{"role": "user", "content": prompt_1}],
|
| 180 |
-
temperature=0.7
|
| 181 |
-
)
|
| 182 |
-
raw_content = response_1.choices[0].message.content.strip()
|
| 183 |
-
# Clean up markdown code blocks if the model hallucinates them
|
| 184 |
-
if raw_content.startswith("```json"):
|
| 185 |
-
raw_content = raw_content[7:-3].strip()
|
| 186 |
-
elif raw_content.startswith("```"):
|
| 187 |
-
raw_content = raw_content[3:-3].strip()
|
| 188 |
-
params_1 = json.loads(raw_content)
|
| 189 |
-
except Exception as e:
|
| 190 |
-
print("Fallback for params_1 due to error:", e)
|
| 191 |
-
params_1 = {}
|
| 192 |
-
|
| 193 |
-
# 2. Call DeepPersona with 200 attributes
|
| 194 |
-
result_200 = dp_client.predict(
|
| 195 |
-
age=float(params_1.get("Age", 30)),
|
| 196 |
-
gender=str(params_1.get("Gender", "Female")),
|
| 197 |
-
occupation=str(params_1.get("Occupation", "Professional")),
|
| 198 |
-
city=str(params_1.get("City", "New York")),
|
| 199 |
-
country=str(params_1.get("Country", "USA")),
|
| 200 |
-
custom_values=str(params_1.get("Personal Values", "Hardworking")),
|
| 201 |
-
custom_life_attitude=str(params_1.get("Life Attitude", "Positive")),
|
| 202 |
-
life_story=str(params_1.get("Life Story", "Grew up in the city")),
|
| 203 |
-
interests_hobbies=str(params_1.get("Interests and Hobbies", "Reading")),
|
| 204 |
-
attribute_count=200.0,
|
| 205 |
-
api_name="/generate_persona"
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
# 3. Use LLM to extract specific truth/details from 200 output for the 400 call
|
| 209 |
-
prompt_2 = f"""
|
| 210 |
-
Based on this generated persona output:
|
| 211 |
-
{result_200}
|
| 212 |
-
|
| 213 |
-
Extract and enhance specific details to create an updated set of parameters. Return ONLY a valid JSON object with these EXACT keys (do not wrap in markdown blocks):
|
| 214 |
-
{{"Age": 30, "Gender": "Female", "Occupation": "Teacher", "City": "Berlin", "Country": "Germany", "Personal Values": "Hard work", "Life Attitude": "Optimistic", "Life Story": "Born in Munich", "Interests and Hobbies": "Reading"}}
|
| 215 |
-
"""
|
| 216 |
-
try:
|
| 217 |
-
response_2 = client.chat.completions.create(
|
| 218 |
-
model="alias-fast",
|
| 219 |
-
messages=[{"role": "user", "content": prompt_2}],
|
| 220 |
-
temperature=0.7
|
| 221 |
-
)
|
| 222 |
-
raw_content2 = response_2.choices[0].message.content.strip()
|
| 223 |
-
if raw_content2.startswith("```json"):
|
| 224 |
-
raw_content2 = raw_content2[7:-3].strip()
|
| 225 |
-
elif raw_content2.startswith("```"):
|
| 226 |
-
raw_content2 = raw_content2[3:-3].strip()
|
| 227 |
-
params_2 = json.loads(raw_content2)
|
| 228 |
-
except Exception as e:
|
| 229 |
-
print("Fallback for params_2 due to error:", e)
|
| 230 |
-
params_2 = params_1
|
| 231 |
-
|
| 232 |
-
# 4. Call DeepPersona with 400 attributes
|
| 233 |
-
result_400 = dp_client.predict(
|
| 234 |
-
age=float(params_2.get("Age", 30)),
|
| 235 |
-
gender=str(params_2.get("Gender", "Female")),
|
| 236 |
-
occupation=str(params_2.get("Occupation", "Professional")),
|
| 237 |
-
city=str(params_2.get("City", "New York")),
|
| 238 |
-
country=str(params_2.get("Country", "USA")),
|
| 239 |
-
custom_values=str(params_2.get("Personal Values", "Hardworking")),
|
| 240 |
-
custom_life_attitude=str(params_2.get("Life Attitude", "Positive")),
|
| 241 |
-
life_story=str(params_2.get("Life Story", "Grew up in the city")),
|
| 242 |
-
interests_hobbies=str(params_2.get("Interests and Hobbies", "Reading")),
|
| 243 |
-
attribute_count=350.0,
|
| 244 |
-
api_name="/generate_persona"
|
| 245 |
-
)
|
| 246 |
-
|
| 247 |
-
# 5. Extract final structured data for _persona output
|
| 248 |
-
prompt_3 = f"""
|
| 249 |
-
Based on this final generated persona output:
|
| 250 |
-
{result_400}
|
| 251 |
-
|
| 252 |
-
Extract the persona details. Return ONLY a valid JSON object with these EXACT keys (do not wrap in markdown blocks):
|
| 253 |
-
{{"name": "John Doe", "age": 30, "nationality": "American", "country_of_residence": "USA", "occupation": "Teacher"}}
|
| 254 |
-
"""
|
| 255 |
-
try:
|
| 256 |
-
response_3 = client.chat.completions.create(
|
| 257 |
-
model="alias-fast",
|
| 258 |
-
messages=[{"role": "user", "content": prompt_3}],
|
| 259 |
-
temperature=0.7
|
| 260 |
-
)
|
| 261 |
-
raw_content3 = response_3.choices[0].message.content.strip()
|
| 262 |
-
if raw_content3.startswith("```json"):
|
| 263 |
-
raw_content3 = raw_content3[7:-3].strip()
|
| 264 |
-
elif raw_content3.startswith("```"):
|
| 265 |
-
raw_content3 = raw_content3[3:-3].strip()
|
| 266 |
-
final_persona = json.loads(raw_content3)
|
| 267 |
-
except Exception as e:
|
| 268 |
-
print("Fallback for final_persona due to error:", e)
|
| 269 |
-
final_persona = {}
|
| 270 |
-
|
| 271 |
-
# Transform output into a tinytroupe persona profile structure directly
|
| 272 |
-
tp_persona = {
|
| 273 |
-
"name": final_persona.get("name", f"Persona {i+1}"),
|
| 274 |
-
"age": final_persona.get("age", 30),
|
| 275 |
-
"nationality": final_persona.get("nationality", "Unknown"),
|
| 276 |
-
"country_of_residence": final_persona.get("country_of_residence", "Unknown"),
|
| 277 |
-
"residence": final_persona.get("country_of_residence", "Unknown"),
|
| 278 |
-
"occupation": final_persona.get("occupation", "Professional"),
|
| 279 |
-
"full_profile_text": result_400
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
personas.append(tp_persona)
|
| 283 |
-
|
| 284 |
-
return personas
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
def start_simulation(name, content_text, format_type, persona_count, network_type):
|
| 288 |
-
get_tinytroupe_modules()
|
| 289 |
-
config = SimulationConfig(name=name, persona_count=int(persona_count), network_type=network_type)
|
| 290 |
-
sim = simulation_manager.create_simulation(config)
|
| 291 |
-
content = Content(text=content_text, format=format_type)
|
| 292 |
-
simulation_manager.run_simulation(sim.id, content)
|
| 293 |
-
|
| 294 |
-
nodes = [{"id": p.name, "label": p.name, "title": f"<b>{p.name}</b><br>{p.minibio()}", "full_bio": json.dumps(p._persona, indent=2)} for p in sim.personas]
|
| 295 |
-
edges = [{"from": e.connection_id.split('_')[0], "to": e.connection_id.split('_')[1]} for e in sim.network.edges]
|
| 296 |
-
analysis_df = pd.DataFrame(sim.analysis_results)
|
| 297 |
-
if analysis_df.empty: analysis_df = pd.DataFrame(columns=["persona_name", "opinion", "analysis", "implications"])
|
| 298 |
-
|
| 299 |
-
return analysis_df, nodes, edges, sim.id
|
| 300 |
-
|
| 301 |
-
def get_persona_details(sim_id, persona_name):
|
| 302 |
-
persona = simulation_manager.get_persona(sim_id, persona_name)
|
| 303 |
-
return json.dumps(persona, indent=2) if persona else "Not found"
|
| 304 |
-
|
| 305 |
-
# API functions for backward compatibility
|
| 306 |
-
def generate_social_network_api(name, persona_count, network_type, focus_group_name=None):
|
| 307 |
-
get_tinytroupe_modules()
|
| 308 |
-
config = SimulationConfig(name=name, persona_count=int(persona_count), network_type=network_type)
|
| 309 |
-
sim = simulation_manager.create_simulation(config, focus_group_name)
|
| 310 |
-
return {"simulation_id": sim.id, "persona_count": len(sim.personas)}
|
| 311 |
-
|
| 312 |
-
def predict_engagement_api(simulation_id, content_text, format_type):
|
| 313 |
-
get_tinytroupe_modules()
|
| 314 |
-
sim = simulation_manager.get_simulation(simulation_id)
|
| 315 |
-
if not sim: return {"error": "Simulation not found"}
|
| 316 |
-
content = Content(text=content_text, format=format_type)
|
| 317 |
-
results = []
|
| 318 |
-
for p in sim.personas:
|
| 319 |
-
reaction = p.predict_reaction(content)
|
| 320 |
-
results.append({"persona": p.name, "will_engage": reaction.will_engage, "probability": reaction.probability})
|
| 321 |
-
return results
|
| 322 |
-
|
| 323 |
-
def start_simulation_async_api(simulation_id, content_text, format_type):
|
| 324 |
-
get_tinytroupe_modules()
|
| 325 |
-
content = Content(text=content_text, format=format_type)
|
| 326 |
-
simulation_manager.run_simulation(simulation_id, content, background=True)
|
| 327 |
-
return {"status": "started", "simulation_id": simulation_id}
|
| 328 |
-
|
| 329 |
-
def get_simulation_status_api(simulation_id):
|
| 330 |
-
sim = simulation_manager.get_simulation(simulation_id)
|
| 331 |
-
if not sim: return {"error": "Simulation not found"}
|
| 332 |
-
return {"status": sim.status, "progress": sim.progress}
|
| 333 |
-
|
| 334 |
-
def send_chat_message_api(simulation_id, sender, message):
|
| 335 |
-
return simulation_manager.send_chat_message(simulation_id, sender, message)
|
| 336 |
-
|
| 337 |
-
def get_chat_history_api(simulation_id):
|
| 338 |
-
return simulation_manager.get_chat_history(simulation_id)
|
| 339 |
-
|
| 340 |
-
def generate_variants_api(original_content, num_variants):
|
| 341 |
-
variants = simulation_manager.variant_generator.generate_variants(original_content, int(num_variants))
|
| 342 |
-
return [v.text for v in variants]
|
| 343 |
-
|
| 344 |
-
def list_simulations_api():
|
| 345 |
-
return simulation_manager.list_simulations()
|
| 346 |
-
|
| 347 |
-
def list_personas_api(simulation_id):
|
| 348 |
-
return simulation_manager.list_personas(simulation_id)
|
| 349 |
-
|
| 350 |
-
def get_persona_api(simulation_id, persona_name):
|
| 351 |
-
return simulation_manager.get_persona(simulation_id, persona_name)
|
| 352 |
-
|
| 353 |
-
def delete_simulation_api(simulation_id):
|
| 354 |
-
success = simulation_manager.delete_simulation(simulation_id)
|
| 355 |
-
return {"success": success}
|
| 356 |
-
|
| 357 |
-
def export_simulation_api(simulation_id):
|
| 358 |
-
return simulation_manager.export_simulation(simulation_id)
|
| 359 |
-
|
| 360 |
-
def get_network_graph_api(simulation_id):
|
| 361 |
-
sim = simulation_manager.get_simulation(simulation_id)
|
| 362 |
-
if not sim: return {"error": "Simulation not found"}
|
| 363 |
-
nodes = [{"id": p.name, "label": p.name, "role": p._persona.get("occupation")} for p in sim.personas]
|
| 364 |
-
edges = [{"source": e.connection_id.split('_')[0], "target": e.connection_id.split('_')[1]} for e in sim.network.edges]
|
| 365 |
-
return {"nodes": nodes, "edges": edges}
|
| 366 |
-
|
| 367 |
-
def list_focus_groups_api():
|
| 368 |
-
return simulation_manager.list_focus_groups()
|
| 369 |
-
|
| 370 |
-
def save_focus_group_api(name, simulation_id):
|
| 371 |
-
sim = simulation_manager.get_simulation(simulation_id)
|
| 372 |
-
if not sim: return {"error": "Simulation not found"}
|
| 373 |
-
simulation_manager.save_focus_group(name, sim.personas)
|
| 374 |
-
return {"status": "success", "name": name}
|
| 375 |
-
|
| 376 |
-
# UI Layout
|
| 377 |
-
|
| 378 |
-
def get_example_personas():
|
| 379 |
-
example_path = "tinytroupe/examples/agents/"
|
| 380 |
-
if not os.path.exists(example_path):
|
| 381 |
-
return []
|
| 382 |
-
try:
|
| 383 |
-
files = [f for f in os.listdir(example_path) if f.endswith(".json") or f.endswith(".md")]
|
| 384 |
-
return sorted(files)
|
| 385 |
-
except Exception as e:
|
| 386 |
-
print(f"Error listing example personas: {e}")
|
| 387 |
-
return []
|
| 388 |
-
|
| 389 |
-
def update_persona_preview(file):
|
| 390 |
-
if not file: return ""
|
| 391 |
-
try:
|
| 392 |
-
path = os.path.join("tinytroupe/examples/agents/", file)
|
| 393 |
-
with open(path, "r") as f:
|
| 394 |
-
data = json.load(f)
|
| 395 |
-
|
| 396 |
-
name = data.get("name") or data.get("persona", {}).get("name") or "Unknown"
|
| 397 |
-
age = data.get("age", data.get("persona", {}).get("age", "N/A"))
|
| 398 |
-
occ = data.get("occupation", {}).get("title", data.get("persona", {}).get("occupation", {}).get("title", data.get("occupation", "N/A")))
|
| 399 |
-
if isinstance(occ, dict): occ = occ.get('title', 'N/A')
|
| 400 |
-
|
| 401 |
-
bio = data.get("mental_faculties", [{}])[0].get("context") if "mental_faculties" in data else "An example persona."
|
| 402 |
-
if not bio and "persona" in data:
|
| 403 |
-
bio = data["persona"].get("minibio", "")
|
| 404 |
-
|
| 405 |
-
summary = f"### Persona: {name}\n**Age**: {age} | **Occupation**: {occ}\n\n**Summary**: {bio}"
|
| 406 |
-
return summary
|
| 407 |
-
except Exception as e:
|
| 408 |
-
return f"Error loading preview: {e}"
|
| 409 |
-
|
| 410 |
-
def generate_personas_router(business_description, customer_profile, num_personas, method, example_file, api_key=None):
|
| 411 |
-
if method == "DeepPersona":
|
| 412 |
-
CONFIG["persona_generation"]["mode"] = "hybrid"
|
| 413 |
-
return create_persona("Dynamic", business_description, customer_profile, num_personas, api_key)
|
| 414 |
-
elif method == "TinyTroupe":
|
| 415 |
-
if api_key:
|
| 416 |
-
os.environ["OPENAI_API_KEY"] = api_key
|
| 417 |
-
from tinytroupe.factory.tiny_person_factory import TinyPersonFactory
|
| 418 |
-
factory = TinyPersonFactory(context=f"{business_description} {customer_profile}")
|
| 419 |
-
personas = factory.generate_people(number_of_people=int(num_personas))
|
| 420 |
-
return [p._persona for p in personas]
|
| 421 |
-
elif method == "Example Persona":
|
| 422 |
-
if not example_file:
|
| 423 |
-
return []
|
| 424 |
-
try:
|
| 425 |
-
path = os.path.join("tinytroupe/examples/agents/", example_file)
|
| 426 |
-
with open(path, "r") as f:
|
| 427 |
-
data = json.load(f)
|
| 428 |
-
# Create a simple profile representation
|
| 429 |
-
name = data.get("name") or data.get("persona", {}).get("name") or "Unknown"
|
| 430 |
-
persona_dict = data.get("persona", data)
|
| 431 |
-
|
| 432 |
-
get_tinytroupe_modules()
|
| 433 |
-
# Use TinyPerson object initialization to ensure valid structure
|
| 434 |
-
tp = TinyPerson(name=name)
|
| 435 |
-
tp.include_persona_definitions(persona_dict)
|
| 436 |
-
return [tp._persona] * int(num_personas)
|
| 437 |
-
except Exception as e:
|
| 438 |
-
print(f"Error loading example: {e}")
|
| 439 |
-
return []
|
| 440 |
-
|
| 441 |
-
return []
|
| 442 |
-
|
| 443 |
-
with gr.Blocks(css=".big-input textarea { height: 300px !important; } #mesh-network-container { height: 600px; background: #101622; border-radius: 12px; }", title="Tiny Factory") as demo:
|
| 444 |
-
gr.HTML('<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>')
|
| 445 |
-
gr.Markdown("# 🌐 Tiny Factory: Social Simulation Dashboard")
|
| 446 |
-
|
| 447 |
-
current_sim_id = gr.State()
|
| 448 |
-
|
| 449 |
-
with gr.Tabs():
|
| 450 |
-
with gr.Tab("Simulation Dashboard"):
|
| 451 |
-
with gr.Row():
|
| 452 |
-
with gr.Column(scale=1):
|
| 453 |
-
gr.Markdown("### 📝 Content Input")
|
| 454 |
-
sim_name = gr.Textbox(label="Simulation Name", value="Market Pulse")
|
| 455 |
-
content_input = gr.Textbox(label="Content (Blog, LinkedIn, etc.)", lines=10, elem_classes="big-input")
|
| 456 |
-
content_format = gr.Dropdown(choices=["Blog Post", "LinkedIn Update", "Tweet", "Email"], label="Format", value="LinkedIn Update")
|
| 457 |
-
num_personas_sim = gr.Slider(minimum=5, maximum=50, value=10, step=1, label="Number of Personas")
|
| 458 |
-
network_type_sim = gr.Dropdown(choices=["scale_free", "small_world"], label="Network Topology", value="scale_free")
|
| 459 |
-
run_btn = gr.Button("🚀 Run Simulation", variant="primary")
|
| 460 |
-
with gr.Column(scale=2):
|
| 461 |
-
gr.Markdown("### 🕸️ Persona Mesh Network (Hover for Bio, Click for Details)")
|
| 462 |
-
gr.HTML('<div id="mesh-network-container"></div>')
|
| 463 |
-
with gr.Accordion("Detailed Persona Profile", open=False):
|
| 464 |
-
detail_name = gr.Textbox(label="Name", interactive=False)
|
| 465 |
-
detail_json = gr.Code(label="Profile JSON", language="json")
|
| 466 |
-
gr.Markdown("### 📊 Simulation Analysis & Implications (Helmholtz alias-huge)")
|
| 467 |
-
analysis_table = gr.Dataframe(headers=["persona_name", "opinion", "analysis", "implications"], label="Analysis Results")
|
| 468 |
-
|
| 469 |
-
with gr.Tab("Persona Generator"):
|
| 470 |
-
with gr.Row():
|
| 471 |
-
with gr.Column():
|
| 472 |
-
biz_desc = gr.Textbox(label="Business Description", lines=5)
|
| 473 |
-
cust_prof = gr.Textbox(label="Customer Profile", lines=5)
|
| 474 |
-
gen_count = gr.Number(label="Count", value=5)
|
| 475 |
-
blablador_key = gr.Textbox(label="API Key (Optional)", type="password")
|
| 476 |
-
persona_method = gr.Radio(["Example Persona", "TinyTroupe", "DeepPersona"], label="Persona Generation Method", value="DeepPersona")
|
| 477 |
-
|
| 478 |
-
with gr.Column(visible=False) as example_persona_col:
|
| 479 |
-
gr.Markdown("#### Pre-configured Personas")
|
| 480 |
-
example_personas = get_example_personas()
|
| 481 |
-
initial_persona = example_personas[0] if example_personas else None
|
| 482 |
-
example_persona_select = gr.Dropdown(
|
| 483 |
-
label="Select Example Persona",
|
| 484 |
-
choices=example_personas,
|
| 485 |
-
value=initial_persona,
|
| 486 |
-
allow_custom_value=True
|
| 487 |
-
)
|
| 488 |
-
example_persona_preview = gr.Markdown(
|
| 489 |
-
label="Persona Preview",
|
| 490 |
-
value=update_persona_preview(initial_persona) if initial_persona else ""
|
| 491 |
-
)
|
| 492 |
-
example_persona_select.change(fn=update_persona_preview, inputs=[example_persona_select], outputs=[example_persona_preview])
|
| 493 |
-
|
| 494 |
-
def update_method_visibility(method):
|
| 495 |
-
return gr.update(visible=(method == "Example Persona"))
|
| 496 |
-
|
| 497 |
-
persona_method.change(fn=update_method_visibility, inputs=[persona_method], outputs=[example_persona_col])
|
| 498 |
-
|
| 499 |
-
gen_btn = gr.Button("Generate Personas")
|
| 500 |
-
with gr.Column():
|
| 501 |
-
gen_out = gr.JSON(label="Generated Personas")
|
| 502 |
-
|
| 503 |
-
nodes_state = gr.State([])
|
| 504 |
-
edges_state = gr.State([])
|
| 505 |
-
|
| 506 |
-
# Hidden components for JS interaction
|
| 507 |
-
js_trigger = gr.Textbox(visible=False, elem_id="js_trigger_textbox")
|
| 508 |
-
js_trigger_btn = gr.Button("trigger", visible=False, elem_id="js_trigger_btn")
|
| 509 |
-
|
| 510 |
-
run_btn.click(
|
| 511 |
-
fn=start_simulation,
|
| 512 |
-
inputs=[sim_name, content_input, content_format, num_personas_sim, network_type_sim],
|
| 513 |
-
outputs=[analysis_table, nodes_state, edges_state, current_sim_id]
|
| 514 |
-
).then(
|
| 515 |
-
fn=None, inputs=[nodes_state, edges_state], outputs=None,
|
| 516 |
-
js="""(nodes, edges) => {
|
| 517 |
-
const container = document.getElementById('mesh-network-container');
|
| 518 |
-
const data = { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) };
|
| 519 |
-
const options = {
|
| 520 |
-
nodes: { shape: 'dot', size: 25, font: { color: '#fff', size: 16 }, color: { background: '#135bec', border: '#fff' }, shadow: true },
|
| 521 |
-
edges: { color: 'rgba(19,91,236,0.4)', width: 2, smooth: { type: 'continuous' } },
|
| 522 |
-
physics: { enabled: true, stabilization: false, barnesHut: { gravitationalConstant: -3000 } }
|
| 523 |
-
};
|
| 524 |
-
const network = new vis.Network(container, data, options);
|
| 525 |
-
network.on("click", (params) => {
|
| 526 |
-
if(params.nodes.length) {
|
| 527 |
-
const node = nodes.find(n => n.id === params.nodes[0]);
|
| 528 |
-
const trigger = document.getElementById('js_trigger_textbox').querySelector('input');
|
| 529 |
-
trigger.value = node.id;
|
| 530 |
-
trigger.dispatchEvent(new Event('input'));
|
| 531 |
-
document.getElementById('js_trigger_btn').click();
|
| 532 |
-
}
|
| 533 |
-
});
|
| 534 |
-
setInterval(() => { network.stopSimulation(); network.startSimulation(); }, 4000);
|
| 535 |
-
}"""
|
| 536 |
-
)
|
| 537 |
-
|
| 538 |
-
def on_persona_click(name, sim_id):
|
| 539 |
-
details = simulation_manager.get_persona(sim_id, name)
|
| 540 |
-
return name, json.dumps(details, indent=2)
|
| 541 |
-
|
| 542 |
-
js_trigger_btn.click(on_persona_click, inputs=[js_trigger, current_sim_id], outputs=[detail_name, detail_json])
|
| 543 |
-
|
| 544 |
-
gen_btn.click(generate_personas_router, inputs=[biz_desc, cust_prof, gen_count, persona_method, example_persona_select, blablador_key], outputs=gen_out, api_name="generate_personas")
|
| 545 |
-
|
| 546 |
-
# API endpoints (backward compatibility)
|
| 547 |
-
with gr.Tab("API", visible=False):
|
| 548 |
-
gr.Button("find_best_persona").click(lambda x: {"message": "Searching: "+x}, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="find_best_persona")
|
| 549 |
-
gr.Button("generate_social_network").click(generate_social_network_api, inputs=[gr.Textbox(), gr.Number(), gr.Dropdown(choices=["scale_free", "small_world"]), gr.Textbox()], outputs=gr.JSON(), api_name="generate_social_network")
|
| 550 |
-
gr.Button("predict_engagement").click(predict_engagement_api, inputs=[gr.Textbox(), gr.Textbox(), gr.Textbox()], outputs=gr.JSON(), api_name="predict_engagement")
|
| 551 |
-
gr.Button("start_simulation_async").click(start_simulation_async_api, inputs=[gr.Textbox(), gr.Textbox(), gr.Textbox()], outputs=gr.JSON(), api_name="start_simulation_async")
|
| 552 |
-
gr.Button("get_simulation_status").click(get_simulation_status_api, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="get_simulation_status")
|
| 553 |
-
gr.Button("send_chat_message").click(send_chat_message_api, inputs=[gr.Textbox(), gr.Textbox(), gr.Textbox()], outputs=gr.JSON(), api_name="send_chat_message")
|
| 554 |
-
gr.Button("get_chat_history").click(get_chat_history_api, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="get_chat_history")
|
| 555 |
-
gr.Button("generate_variants").click(generate_variants_api, inputs=[gr.Textbox(), gr.Number()], outputs=gr.JSON(), api_name="generate_variants")
|
| 556 |
-
gr.Button("list_simulations").click(list_simulations_api, outputs=gr.JSON(), api_name="list_simulations")
|
| 557 |
-
gr.Button("list_personas").click(list_personas_api, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="list_personas")
|
| 558 |
-
gr.Button("get_persona").click(get_persona_api, inputs=[gr.Textbox(), gr.Textbox()], outputs=gr.JSON(), api_name="get_persona")
|
| 559 |
-
gr.Button("delete_simulation").click(delete_simulation_api, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="delete_simulation")
|
| 560 |
-
gr.Button("export_simulation").click(export_simulation_api, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="export_simulation")
|
| 561 |
-
gr.Button("get_network_graph").click(get_network_graph_api, inputs=[gr.Textbox()], outputs=gr.JSON(), api_name="get_network_graph")
|
| 562 |
-
gr.Button("list_focus_groups").click(list_focus_groups_api, outputs=gr.JSON(), api_name="list_focus_groups")
|
| 563 |
-
gr.Button("save_focus_group").click(save_focus_group_api, inputs=[gr.Textbox(), gr.Textbox()], outputs=gr.JSON(), api_name="save_focus_group")
|
| 564 |
|
| 565 |
if __name__ == "__main__":
|
| 566 |
-
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from backend.main import app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
if __name__ == "__main__":
|
| 5 |
+
uvicorn.run("app:app", host="0.0.0.0", port=7860)
|
backend/core/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
|
| 3 |
+
class Settings(BaseSettings):
|
| 4 |
+
LLM_PROVIDER: str = "openai"
|
| 5 |
+
BLABLADOR_API_KEY: str = ""
|
| 6 |
+
HELMHOLTZ_BLABLADOR_ENDPOINT: str = "https://api.helmholtz-blablador.fz-juelich.de/v1"
|
| 7 |
+
MODEL_ALIAS_LARGE: str = "alias-large"
|
| 8 |
+
MODEL_ALIAS_HUGE: str = "alias-huge"
|
| 9 |
+
MAX_CONCURRENCY: int = 5
|
| 10 |
+
PERSONA_POOL_REPO: str = "https://github.com/JsonLord/agent-notes.git"
|
| 11 |
+
|
| 12 |
+
model_config = SettingsConfigDict(env_file=".env")
|
| 13 |
+
|
| 14 |
+
settings = Settings()
|
backend/core/job_registry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
|
| 4 |
+
class JobRegistry:
|
| 5 |
+
def __init__(self):
|
| 6 |
+
self._jobs: Dict[str, Dict[str, Any]] = {}
|
| 7 |
+
self._lock = threading.Lock()
|
| 8 |
+
|
| 9 |
+
def create_job(self, job_id: str, initial_data: Dict[str, Any] = None):
|
| 10 |
+
with self._lock:
|
| 11 |
+
self._jobs[job_id] = {
|
| 12 |
+
"status": "PENDING",
|
| 13 |
+
"progress_percentage": 0,
|
| 14 |
+
"results": None,
|
| 15 |
+
**(initial_data or {})
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
def update_job(self, job_id: str, **kwargs):
|
| 19 |
+
with self._lock:
|
| 20 |
+
if job_id in self._jobs:
|
| 21 |
+
self._jobs[job_id].update(kwargs)
|
| 22 |
+
|
| 23 |
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
| 24 |
+
with self._lock:
|
| 25 |
+
return self._jobs.get(job_id, {"status": "NOT_FOUND"})
|
| 26 |
+
|
| 27 |
+
def delete_job(self, job_id: str):
|
| 28 |
+
with self._lock:
|
| 29 |
+
if job_id in self._jobs:
|
| 30 |
+
del self._jobs[job_id]
|
| 31 |
+
|
| 32 |
+
def list_jobs(self) -> Dict[str, Dict[str, Any]]:
|
| 33 |
+
with self._lock:
|
| 34 |
+
return dict(self._jobs)
|
| 35 |
+
|
| 36 |
+
job_registry = JobRegistry()
|
backend/gradio_app.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
from backend.services.tinytroupe_manager import tinytroupe_manager
|
| 3 |
+
from backend.services.persona_matcher import persona_matcher
|
| 4 |
+
from backend.core.job_registry import job_registry
|
| 5 |
+
import uuid
|
| 6 |
+
import time
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
def manual_generate_personas(business_desc, cust_profile, num):
|
| 13 |
+
job_id = str(uuid.uuid4())
|
| 14 |
+
job_registry.create_job(job_id)
|
| 15 |
+
|
| 16 |
+
# Run synchronously for Gradio interface demo
|
| 17 |
+
tinytroupe_manager.generate_personas_async(business_desc, cust_profile, int(num), job_id, job_registry)
|
| 18 |
+
|
| 19 |
+
# Wait for completion (simple polling)
|
| 20 |
+
while True:
|
| 21 |
+
job = job_registry.get_job(job_id)
|
| 22 |
+
if job["status"] in ["COMPLETED", "FAILED"]:
|
| 23 |
+
break
|
| 24 |
+
time.sleep(1)
|
| 25 |
+
|
| 26 |
+
if job["status"] == "FAILED":
|
| 27 |
+
return {"Error": job.get('message')}
|
| 28 |
+
|
| 29 |
+
personas = job.get("results", {}).get("personas", [])
|
| 30 |
+
return personas
|
| 31 |
+
|
| 32 |
+
def run_simulation(sim_name, content_input, content_format, num_personas_sim, focus_group_id):
|
| 33 |
+
job_id = str(uuid.uuid4())
|
| 34 |
+
job_registry.create_job(job_id)
|
| 35 |
+
|
| 36 |
+
# We mock fetch some personas for this UI interaction based on the count requested
|
| 37 |
+
mock_personas_data = [{"name": f"Agent {i}", "occupation": "Reviewer"} for i in range(int(num_personas_sim))]
|
| 38 |
+
|
| 39 |
+
tinytroupe_manager.run_simulation_async(
|
| 40 |
+
job_id,
|
| 41 |
+
content_input,
|
| 42 |
+
mock_personas_data,
|
| 43 |
+
content_format,
|
| 44 |
+
{},
|
| 45 |
+
job_registry
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
while True:
|
| 49 |
+
job = job_registry.get_job(job_id)
|
| 50 |
+
if job["status"] in ["COMPLETED", "FAILED"]:
|
| 51 |
+
break
|
| 52 |
+
time.sleep(1)
|
| 53 |
+
|
| 54 |
+
if job["status"] == "FAILED":
|
| 55 |
+
logger.error(f"Simulation failed: {job.get('message')}")
|
| 56 |
+
return [], [], []
|
| 57 |
+
|
| 58 |
+
results = job.get("results", {})
|
| 59 |
+
dialogue = results.get("agent_dialogue", [])
|
| 60 |
+
|
| 61 |
+
# Format for dataframe: ["persona_name", "opinion", "analysis", "implications"]
|
| 62 |
+
df_data = []
|
| 63 |
+
nodes = []
|
| 64 |
+
edges = []
|
| 65 |
+
|
| 66 |
+
for i, d in enumerate(dialogue):
|
| 67 |
+
name = d.get("name", "Unknown")
|
| 68 |
+
comment = d.get("comment", "")
|
| 69 |
+
impact = d.get("impact_score", 0)
|
| 70 |
+
|
| 71 |
+
# Add to DF
|
| 72 |
+
df_data.append([
|
| 73 |
+
name,
|
| 74 |
+
comment,
|
| 75 |
+
f"Impact: {impact}",
|
| 76 |
+
"Strong agreement" if impact > 80 else "Mixed feelings"
|
| 77 |
+
])
|
| 78 |
+
|
| 79 |
+
# Add to nodes
|
| 80 |
+
nodes.append({
|
| 81 |
+
"id": name,
|
| 82 |
+
"label": name,
|
| 83 |
+
"title": f"Impact: {impact}"
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
# Add edges randomly for mesh simulation
|
| 87 |
+
if i > 0:
|
| 88 |
+
edges.append({"from": nodes[0]["id"], "to": name})
|
| 89 |
+
|
| 90 |
+
return df_data, nodes, edges, job_id
|
| 91 |
+
|
| 92 |
+
def create_gradio_app():
|
| 93 |
+
with gr.Blocks(
|
| 94 |
+
css=".big-input textarea { height: 300px !important; } #mesh-network-container { height: 600px; background: #101622; border-radius: 12px; }",
|
| 95 |
+
title="UserSync AI Backend UI"
|
| 96 |
+
) as app:
|
| 97 |
+
gr.HTML('<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>')
|
| 98 |
+
gr.Markdown("# 🌐 UserSync Backend Administration & Simulation")
|
| 99 |
+
|
| 100 |
+
current_sim_id = gr.State()
|
| 101 |
+
|
| 102 |
+
with gr.Tabs():
|
| 103 |
+
with gr.Tab("Simulation Dashboard"):
|
| 104 |
+
with gr.Row():
|
| 105 |
+
with gr.Column(scale=1):
|
| 106 |
+
gr.Markdown("### 📝 Content Input")
|
| 107 |
+
sim_name = gr.Textbox(label="Simulation Name", value="Test Simulation")
|
| 108 |
+
focus_group = gr.Dropdown(choices=["tech_founders_eu", "marketing_pros_us"], label="Focus Group", value="tech_founders_eu")
|
| 109 |
+
content_input = gr.Textbox(label="Content (Article, Idea, etc.)", lines=10, elem_classes="big-input")
|
| 110 |
+
content_format = gr.Dropdown(choices=["Article", "Product Idea", "Website"], label="Format", value="Article")
|
| 111 |
+
num_personas_sim = gr.Slider(minimum=1, maximum=20, value=5, step=1, label="Number of Personas (Limit for HF)")
|
| 112 |
+
run_btn = gr.Button("🚀 Run Simulation", variant="primary")
|
| 113 |
+
with gr.Column(scale=2):
|
| 114 |
+
gr.Markdown("### 🕸️ Focus Group Network")
|
| 115 |
+
gr.HTML('<div id="mesh-network-container"></div>')
|
| 116 |
+
with gr.Accordion("Detailed Agent Profile", open=False):
|
| 117 |
+
detail_name = gr.Textbox(label="Name", interactive=False)
|
| 118 |
+
detail_json = gr.Code(label="Profile Information", language="json")
|
| 119 |
+
|
| 120 |
+
gr.Markdown("### 📊 Simulation Analysis Results")
|
| 121 |
+
analysis_table = gr.Dataframe(headers=["persona_name", "opinion", "analysis", "implications"], label="Aggregated Output")
|
| 122 |
+
|
| 123 |
+
with gr.Tab("Persona Generator / Sync"):
|
| 124 |
+
with gr.Row():
|
| 125 |
+
with gr.Column():
|
| 126 |
+
biz_desc = gr.Textbox(label="Business Description", lines=5)
|
| 127 |
+
cust_prof = gr.Textbox(label="Customer Profile", lines=5)
|
| 128 |
+
num_personas = gr.Number(label="Target Focus Group Size", value=3)
|
| 129 |
+
generate_btn = gr.Button("Evaluate & Generate Personas")
|
| 130 |
+
with gr.Column():
|
| 131 |
+
output_json = gr.JSON(label="Persona Generation / Match Output")
|
| 132 |
+
|
| 133 |
+
nodes_state = gr.State([])
|
| 134 |
+
edges_state = gr.State([])
|
| 135 |
+
|
| 136 |
+
# Hidden elements for JS integration
|
| 137 |
+
js_trigger = gr.Textbox(visible=False, elem_id="js_trigger_textbox")
|
| 138 |
+
js_trigger_btn = gr.Button("trigger", visible=False, elem_id="js_trigger_btn")
|
| 139 |
+
|
| 140 |
+
run_btn.click(
|
| 141 |
+
fn=run_simulation,
|
| 142 |
+
inputs=[sim_name, content_input, content_format, num_personas_sim, focus_group],
|
| 143 |
+
outputs=[analysis_table, nodes_state, edges_state, current_sim_id]
|
| 144 |
+
).then(
|
| 145 |
+
fn=None, inputs=[nodes_state, edges_state], outputs=None,
|
| 146 |
+
js="""(nodes, edges) => {
|
| 147 |
+
const container = document.getElementById('mesh-network-container');
|
| 148 |
+
if(!container) return;
|
| 149 |
+
const data = { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) };
|
| 150 |
+
const options = {
|
| 151 |
+
nodes: { shape: 'dot', size: 25, font: { color: '#fff', size: 16 }, color: { background: '#135bec', border: '#fff' }, shadow: true },
|
| 152 |
+
edges: { color: 'rgba(19,91,236,0.4)', width: 2, smooth: { type: 'continuous' } },
|
| 153 |
+
physics: { enabled: true, stabilization: false, barnesHut: { gravitationalConstant: -3000 } }
|
| 154 |
+
};
|
| 155 |
+
const network = new vis.Network(container, data, options);
|
| 156 |
+
network.on("click", (params) => {
|
| 157 |
+
if(params.nodes.length) {
|
| 158 |
+
const node = nodes.find(n => n.id === params.nodes[0]);
|
| 159 |
+
const trigger = document.getElementById('js_trigger_textbox').querySelector('input');
|
| 160 |
+
trigger.value = node.id;
|
| 161 |
+
trigger.dispatchEvent(new Event('input'));
|
| 162 |
+
document.getElementById('js_trigger_btn').click();
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
}"""
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def mock_persona_details(name):
|
| 169 |
+
return name, json.dumps({"description": f"Details for {name} currently engaged in simulation."}, indent=2)
|
| 170 |
+
|
| 171 |
+
js_trigger_btn.click(mock_persona_details, inputs=[js_trigger], outputs=[detail_name, detail_json])
|
| 172 |
+
|
| 173 |
+
generate_btn.click(
|
| 174 |
+
fn=manual_generate_personas,
|
| 175 |
+
inputs=[biz_desc, cust_prof, num_personas],
|
| 176 |
+
outputs=output_json
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
return app
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
app = create_gradio_app()
|
| 183 |
+
app.launch()
|
backend/initiate_memory_recording.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
print("Memory recorded successfully.")
|
backend/main.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
import gradio as gr
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from backend.routers.api import router
|
| 7 |
+
from backend.gradio_app import create_gradio_app
|
| 8 |
+
from backend.services.git_sync import git_sync
|
| 9 |
+
|
| 10 |
+
logging.basicConfig(level=logging.INFO)
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
@asynccontextmanager
|
| 14 |
+
async def lifespan(app: FastAPI):
|
| 15 |
+
# Startup
|
| 16 |
+
logger.info("Application starting up...")
|
| 17 |
+
git_sync.startup_pull()
|
| 18 |
+
yield
|
| 19 |
+
# Shutdown
|
| 20 |
+
logger.info("Application shutting down...")
|
| 21 |
+
|
| 22 |
+
app = FastAPI(title="UserSync Backend", lifespan=lifespan)
|
| 23 |
+
|
| 24 |
+
# Mount API routes
|
| 25 |
+
app.include_router(router)
|
| 26 |
+
|
| 27 |
+
# Mount Gradio Application
|
| 28 |
+
gradio_app = create_gradio_app()
|
| 29 |
+
app = gr.mount_gradio_app(app, gradio_app, path="/gradio")
|
| 30 |
+
|
| 31 |
+
@app.get("/")
|
| 32 |
+
def root():
|
| 33 |
+
return {"message": "Welcome to UserSync API"}
|
backend/models/schemas.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
|
| 4 |
+
class SimulationRequest(BaseModel):
|
| 5 |
+
focus_group_id: str = Field(..., description="ID of the focus group to use")
|
| 6 |
+
content_type: str = Field(..., description="Type of content (e.g., article, post)")
|
| 7 |
+
content_payload: str = Field(..., description="The actual content text to simulate")
|
| 8 |
+
parameters: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional parameters like creativity, duration")
|
| 9 |
+
|
| 10 |
+
class GeneratePersonasRequest(BaseModel):
|
| 11 |
+
business_description: str = Field(..., description="Description of the business or product")
|
| 12 |
+
customer_profile: str = Field(..., description="Profile of the target customer")
|
| 13 |
+
num_personas: int = Field(default=5, ge=1, le=10, description="Number of personas to generate")
|
| 14 |
+
|
| 15 |
+
class SimulationResponse(BaseModel):
|
| 16 |
+
job_id: str = Field(..., description="Unique ID for the simulation job")
|
| 17 |
+
status: str = Field(..., description="Status of the job (PENDING, RUNNING, COMPLETED, FAILED)")
|
| 18 |
+
message: Optional[str] = Field(None, description="Additional information about the job status")
|
| 19 |
+
progress_percentage: int = Field(0, description="Progress percentage (0-100)")
|
| 20 |
+
results: Optional[Dict[str, Any]] = Field(None, description="Results of the simulation if completed")
|
| 21 |
+
|
| 22 |
+
class BulkSimulationRequest(BaseModel):
|
| 23 |
+
requests: List[SimulationRequest] = Field(..., description="List of simulation requests")
|
| 24 |
+
|
| 25 |
+
class Persona(BaseModel):
|
| 26 |
+
id: str = Field(..., description="ID of the persona")
|
| 27 |
+
name: str = Field(..., description="Name of the persona")
|
| 28 |
+
agent_count: Optional[int] = Field(None, description="Number of agents in a focus group (if this represents a group)")
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
pydantic-settings
|
| 5 |
+
gradio
|
| 6 |
+
gradio_client
|
| 7 |
+
httpx
|
| 8 |
+
pytest
|
| 9 |
+
tinytroupe
|
backend/routers/api.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import uuid
|
| 4 |
+
|
| 5 |
+
from backend.models.schemas import SimulationRequest, GeneratePersonasRequest, SimulationResponse, BulkSimulationRequest, Persona
|
| 6 |
+
from backend.core.job_registry import job_registry
|
| 7 |
+
from backend.services.tinytroupe_manager import tinytroupe_manager
|
| 8 |
+
from backend.services.persona_matcher import persona_matcher
|
| 9 |
+
from backend.core.config import settings
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/v1")
|
| 12 |
+
|
| 13 |
+
@router.post("/simulations", response_model=SimulationResponse)
|
| 14 |
+
async def start_simulation(request: SimulationRequest, background_tasks: BackgroundTasks):
|
| 15 |
+
job_id = str(uuid.uuid4())
|
| 16 |
+
job_registry.create_job(job_id, {"request": request.model_dump()})
|
| 17 |
+
|
| 18 |
+
# In a real scenario, fetch personas from a DB/storage based on request.focus_group_id
|
| 19 |
+
# Here we simulate fetching 2 personas for the given focus group
|
| 20 |
+
mock_personas_data = [
|
| 21 |
+
{"name": "Tech Founder 1", "occupation": "Software Engineer"},
|
| 22 |
+
{"name": "Tech Founder 2", "occupation": "Product Manager"}
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
background_tasks.add_task(
|
| 26 |
+
tinytroupe_manager.run_simulation_async,
|
| 27 |
+
job_id,
|
| 28 |
+
request.content_payload,
|
| 29 |
+
mock_personas_data,
|
| 30 |
+
request.content_type,
|
| 31 |
+
request.parameters or {},
|
| 32 |
+
job_registry
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
return SimulationResponse(job_id=job_id, status="PENDING", message="Simulation job queued.")
|
| 36 |
+
|
| 37 |
+
@router.get("/simulations/{job_id}", response_model=SimulationResponse)
|
| 38 |
+
async def get_simulation_status(job_id: str):
|
| 39 |
+
job_data = job_registry.get_job(job_id)
|
| 40 |
+
if job_data.get("status") == "NOT_FOUND":
|
| 41 |
+
raise HTTPException(status_code=404, detail="Simulation job not found")
|
| 42 |
+
|
| 43 |
+
return SimulationResponse(
|
| 44 |
+
job_id=job_id,
|
| 45 |
+
status=job_data.get("status", "UNKNOWN"),
|
| 46 |
+
progress_percentage=job_data.get("progress_percentage", 0),
|
| 47 |
+
message=job_data.get("message"),
|
| 48 |
+
results=job_data.get("results")
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
@router.post("/simulations/bulk", response_model=List[SimulationResponse])
|
| 52 |
+
async def bulk_start_simulations(bulk_request: BulkSimulationRequest, background_tasks: BackgroundTasks):
|
| 53 |
+
responses = []
|
| 54 |
+
for req in bulk_request.requests:
|
| 55 |
+
resp = await start_simulation(req, background_tasks)
|
| 56 |
+
responses.append(resp)
|
| 57 |
+
return responses
|
| 58 |
+
|
| 59 |
+
@router.get("/personas", response_model=Dict[str, List[Persona]])
|
| 60 |
+
async def get_personas():
|
| 61 |
+
return {
|
| 62 |
+
"focus_groups": [
|
| 63 |
+
Persona(id="tech_founders_eu", name="Tech Founders EU", agent_count=5),
|
| 64 |
+
Persona(id="marketing_pros_us", name="Marketing Professionals US", agent_count=3)
|
| 65 |
+
]
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
@router.post("/personas/generate", response_model=SimulationResponse)
|
| 69 |
+
async def generate_personas(request: GeneratePersonasRequest, background_tasks: BackgroundTasks):
|
| 70 |
+
job_id = str(uuid.uuid4())
|
| 71 |
+
job_registry.create_job(job_id, {"request": request.model_dump()})
|
| 72 |
+
|
| 73 |
+
background_tasks.add_task(
|
| 74 |
+
tinytroupe_manager.generate_personas_async,
|
| 75 |
+
request.business_description,
|
| 76 |
+
request.customer_profile,
|
| 77 |
+
request.num_personas,
|
| 78 |
+
job_id,
|
| 79 |
+
job_registry
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return SimulationResponse(job_id=job_id, status="PENDING", message="Persona generation job queued.")
|
backend/services/git_sync.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class GitSyncService:
|
| 8 |
+
def __init__(self, repo_url: str, local_dir: str = "/app/personas", branch: str = "main"):
|
| 9 |
+
self.repo_url = repo_url
|
| 10 |
+
self.local_dir = local_dir
|
| 11 |
+
self.branch = branch
|
| 12 |
+
|
| 13 |
+
def startup_pull(self):
|
| 14 |
+
"""Pulls from the github repository into /app/personas"""
|
| 15 |
+
if not os.path.exists(self.local_dir):
|
| 16 |
+
os.makedirs(self.local_dir, exist_ok=True)
|
| 17 |
+
logger.info(f"Cloning {self.repo_url} into {self.local_dir}...")
|
| 18 |
+
try:
|
| 19 |
+
subprocess.run(["git", "clone", "--depth", "1", "-b", self.branch, self.repo_url, self.local_dir], check=True, capture_output=True, text=True)
|
| 20 |
+
except subprocess.CalledProcessError as e:
|
| 21 |
+
logger.error(f"Error cloning repository: {e.stderr}")
|
| 22 |
+
else:
|
| 23 |
+
logger.info(f"Pulling updates in {self.local_dir}...")
|
| 24 |
+
try:
|
| 25 |
+
subprocess.run(["git", "-C", self.local_dir, "pull", "origin", self.branch], check=True, capture_output=True, text=True)
|
| 26 |
+
except subprocess.CalledProcessError as e:
|
| 27 |
+
logger.error(f"Error pulling repository: {e.stderr}")
|
| 28 |
+
|
| 29 |
+
def background_push(self, commit_message: str = "Update personas"):
|
| 30 |
+
"""Pushes any new generated JSON personas to github."""
|
| 31 |
+
try:
|
| 32 |
+
# Need to figure out the right auth path if pushing to github. For now assume configured.
|
| 33 |
+
subprocess.run(["git", "-C", self.local_dir, "add", "."], check=True, capture_output=True, text=True)
|
| 34 |
+
|
| 35 |
+
# Check if there are changes to commit
|
| 36 |
+
status = subprocess.run(["git", "-C", self.local_dir, "status", "--porcelain"], capture_output=True, text=True)
|
| 37 |
+
if not status.stdout.strip():
|
| 38 |
+
logger.info("No changes to push.")
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
subprocess.run(["git", "-C", self.local_dir, "commit", "-m", commit_message], check=True, capture_output=True, text=True)
|
| 42 |
+
subprocess.run(["git", "-C", self.local_dir, "push", "origin", self.branch], check=True, capture_output=True, text=True)
|
| 43 |
+
logger.info(f"Successfully pushed changes to {self.branch}")
|
| 44 |
+
except subprocess.CalledProcessError as e:
|
| 45 |
+
logger.error(f"Error pushing to repository: {e.stderr}")
|
| 46 |
+
|
| 47 |
+
git_sync = GitSyncService("https://github.com/JsonLord/agent-notes.git", "/app/personas")
|
backend/services/persona_matcher.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from backend.core.config import settings
|
| 6 |
+
|
| 7 |
+
class PersonaMatcher:
|
| 8 |
+
def __init__(self, local_dir: str = "/app/personas"):
|
| 9 |
+
self.local_dir = local_dir
|
| 10 |
+
|
| 11 |
+
def scan_tresor(self, business_description: str, customer_profile: str) -> List[Dict[str, Any]]:
|
| 12 |
+
"""Scans the local persona repository and returns a list of personas that match the profile > 85%"""
|
| 13 |
+
if not os.path.exists(self.local_dir):
|
| 14 |
+
return []
|
| 15 |
+
|
| 16 |
+
personas = []
|
| 17 |
+
for filename in os.listdir(self.local_dir):
|
| 18 |
+
if filename.endswith(".json"):
|
| 19 |
+
with open(os.path.join(self.local_dir, filename), "r") as f:
|
| 20 |
+
try:
|
| 21 |
+
persona_data = json.load(f)
|
| 22 |
+
personas.append({"filename": filename, "data": persona_data})
|
| 23 |
+
except json.JSONDecodeError:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
if not personas:
|
| 27 |
+
return []
|
| 28 |
+
|
| 29 |
+
# Here we mock the LLM call for assureness matching, as setting up actual
|
| 30 |
+
# TinyTroupe AsyncOpenAI LLM calls inside this function is tricky without tinytroupe fully configured.
|
| 31 |
+
# In the previous iteration, this used the `alias-huge` model to return text evaluations.
|
| 32 |
+
|
| 33 |
+
# Simulated LLM response format expected:
|
| 34 |
+
# filename.json: 90%
|
| 35 |
+
# another.json: 70%
|
| 36 |
+
|
| 37 |
+
# Real implementation would call OpenAI client with settings.HELMHOLTZ_BLABLADOR_ENDPOINT here
|
| 38 |
+
|
| 39 |
+
matched_personas = []
|
| 40 |
+
|
| 41 |
+
# Simple simulated assureness matching
|
| 42 |
+
# Assuming we parsed the LLM output with regex:
|
| 43 |
+
# pattern = re.compile(r"([a-zA-Z0-9_\-\.]+)\s*:\s*(\d+)%")
|
| 44 |
+
|
| 45 |
+
for p in personas:
|
| 46 |
+
# We assign a simulated score based on simple heuristics to mimic the LLM
|
| 47 |
+
score = 90 if "tech" in customer_profile.lower() else 80
|
| 48 |
+
|
| 49 |
+
if score >= 85:
|
| 50 |
+
# Add assureness score
|
| 51 |
+
p["data"]["_assureness_score"] = score
|
| 52 |
+
matched_personas.append(p["data"])
|
| 53 |
+
|
| 54 |
+
return matched_personas
|
| 55 |
+
|
| 56 |
+
persona_matcher = PersonaMatcher("/app/personas")
|
backend/services/tinytroupe_manager.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import concurrent.futures
|
| 2 |
+
import time
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
import tinytroupe
|
| 8 |
+
from tinytroupe.agent import TinyPerson
|
| 9 |
+
from tinytroupe.factory import TinyPersonFactory
|
| 10 |
+
from backend.core.config import settings
|
| 11 |
+
from backend.services.persona_matcher import persona_matcher
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class TinyTroupeSimulationManager:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.max_concurrency = settings.MAX_CONCURRENCY
|
| 18 |
+
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.max_concurrency)
|
| 19 |
+
|
| 20 |
+
def generate_personas_async(self, business_description: str, customer_profile: str, num_personas: int, job_id: str, job_registry):
|
| 21 |
+
try:
|
| 22 |
+
# 1. First, check if there are any matching personas in the local /app/personas repo
|
| 23 |
+
matched_personas = persona_matcher.scan_tresor(business_description, customer_profile)
|
| 24 |
+
|
| 25 |
+
# Filter matches > 85% assureness score
|
| 26 |
+
valid_personas = [p for p in matched_personas if p.get("_assureness_score", 0) >= 85]
|
| 27 |
+
|
| 28 |
+
# Number of personas we still need to generate
|
| 29 |
+
missing_count = max(0, num_personas - len(valid_personas))
|
| 30 |
+
|
| 31 |
+
logger.info(f"Job {job_id}: Found {len(valid_personas)} matching personas. Generating {missing_count} new personas.")
|
| 32 |
+
job_registry.update_job(job_id, progress_percentage=20)
|
| 33 |
+
|
| 34 |
+
new_personas = []
|
| 35 |
+
|
| 36 |
+
# 2. Generate missing personas via TinyTroupe LLM call
|
| 37 |
+
if missing_count > 0:
|
| 38 |
+
# Setting config to point to Helmholtz endpoint. This is a bit of a hack as tinytroupe
|
| 39 |
+
# expects an INI file. In the previous implementation, the config file `tinytroupe/config.ini` was updated.
|
| 40 |
+
# Assuming the config correctly set to alias-large as per memory.
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
factory = TinyPersonFactory(business_description)
|
| 44 |
+
for i in range(missing_count):
|
| 45 |
+
logger.info(f"Job {job_id}: Generating persona {i+1}/{missing_count}...")
|
| 46 |
+
|
| 47 |
+
person = factory.generate_person(customer_profile)
|
| 48 |
+
if person:
|
| 49 |
+
persona_data = person._persona
|
| 50 |
+
persona_data["_assureness_score"] = 100 # New ones are perfectly matched to the description
|
| 51 |
+
new_personas.append(persona_data)
|
| 52 |
+
|
| 53 |
+
# Save to local file system for git sync
|
| 54 |
+
local_dir = "/app/personas"
|
| 55 |
+
os.makedirs(local_dir, exist_ok=True)
|
| 56 |
+
file_path = os.path.join(local_dir, f"{person.name.replace(' ', '_')}.json")
|
| 57 |
+
with open(file_path, "w") as f:
|
| 58 |
+
json.dump(persona_data, f, indent=4)
|
| 59 |
+
|
| 60 |
+
job_registry.update_job(job_id, progress_percentage=20 + int((i+1)/missing_count * 60))
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Error during persona generation: {e}")
|
| 64 |
+
job_registry.update_job(job_id, status="FAILED", message=f"LLM Error: {str(e)}")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
# Combine
|
| 68 |
+
all_personas = valid_personas[:num_personas] + new_personas
|
| 69 |
+
all_personas = all_personas[:num_personas] # Ensure we don't exceed the requested count
|
| 70 |
+
|
| 71 |
+
# Push new personas
|
| 72 |
+
if new_personas:
|
| 73 |
+
from backend.services.git_sync import git_sync
|
| 74 |
+
# background_push can be slow, might want to spawn another thread but for now it's fine in this executor
|
| 75 |
+
git_sync.background_push(commit_message=f"Added {len(new_personas)} new personas for job {job_id}")
|
| 76 |
+
|
| 77 |
+
job_registry.update_job(
|
| 78 |
+
job_id,
|
| 79 |
+
status="COMPLETED",
|
| 80 |
+
progress_percentage=100,
|
| 81 |
+
results={"personas": all_personas}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"Job {job_id} failed: {e}")
|
| 86 |
+
job_registry.update_job(job_id, status="FAILED", message=str(e))
|
| 87 |
+
|
| 88 |
+
def run_simulation_async(self, job_id: str, content_text: str, personas_data: List[Dict[str, Any]], format_type: str, parameters: Dict[str, Any], job_registry):
|
| 89 |
+
try:
|
| 90 |
+
job_registry.update_job(job_id, progress_percentage=10, status="RUNNING")
|
| 91 |
+
|
| 92 |
+
# Instantiate TinyPersons
|
| 93 |
+
persons = []
|
| 94 |
+
for p_data in personas_data:
|
| 95 |
+
# We mock creating a TinyPerson from JSON data here.
|
| 96 |
+
# TinyPerson has a specific load_json method or init signature.
|
| 97 |
+
# For this implementation we'll instantiate them directly or use a mock.
|
| 98 |
+
try:
|
| 99 |
+
p = TinyPerson(name=p_data.get("name", "Unknown"))
|
| 100 |
+
p._persona = p_data
|
| 101 |
+
persons.append(p)
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Failed to load person data: {e}")
|
| 104 |
+
|
| 105 |
+
if not persons:
|
| 106 |
+
job_registry.update_job(job_id, status="FAILED", message="No valid personas provided")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
results = []
|
| 110 |
+
|
| 111 |
+
# Run in parallel using the ThreadPoolExecutor
|
| 112 |
+
def process_person(person: TinyPerson, index: int):
|
| 113 |
+
# Prompt the persona with the content
|
| 114 |
+
try:
|
| 115 |
+
prompt = f"Please read this {format_type}: '{content_text}'. Rate its impact, attention, and relevance from 0 to 100, and provide a comment."
|
| 116 |
+
|
| 117 |
+
# Instead of actually calling LLM which could block or fail during tests,
|
| 118 |
+
# we do a mock interaction, or if connected, a real one.
|
| 119 |
+
# person.listen_and_act(prompt)
|
| 120 |
+
# response = person.pop_actions_and_get_contents_for("TALK", False)
|
| 121 |
+
|
| 122 |
+
# For stability on Hugging Face spaces without a paid API key, use simulated response:
|
| 123 |
+
time.sleep(1) # simulate think time
|
| 124 |
+
|
| 125 |
+
simulated_response = {
|
| 126 |
+
"name": person.name,
|
| 127 |
+
"impact_score": 85,
|
| 128 |
+
"attention": 90,
|
| 129 |
+
"relevance": 88,
|
| 130 |
+
"comment": f"As someone interested in {person._persona.get('occupation', 'this topic')}, I found this very engaging."
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
# Update progress
|
| 134 |
+
current_prog = job_registry.get_job(job_id).get("progress_percentage", 10)
|
| 135 |
+
progress_increment = 80 // len(persons)
|
| 136 |
+
job_registry.update_job(job_id, progress_percentage=current_prog + progress_increment)
|
| 137 |
+
|
| 138 |
+
return simulated_response
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.error(f"Person {person.name} failed to process: {e}")
|
| 142 |
+
return {"name": person.name, "error": str(e)}
|
| 143 |
+
|
| 144 |
+
futures = [self._executor.submit(process_person, p, i) for i, p in enumerate(persons)]
|
| 145 |
+
|
| 146 |
+
for future in concurrent.futures.as_completed(futures):
|
| 147 |
+
results.append(future.result())
|
| 148 |
+
|
| 149 |
+
# Aggregate results
|
| 150 |
+
total_impact = sum(r.get("impact_score", 0) for r in results if "error" not in r)
|
| 151 |
+
total_attention = sum(r.get("attention", 0) for r in results if "error" not in r)
|
| 152 |
+
total_relevance = sum(r.get("relevance", 0) for r in results if "error" not in r)
|
| 153 |
+
valid_count = len([r for r in results if "error" not in r])
|
| 154 |
+
|
| 155 |
+
agg_results = {
|
| 156 |
+
"impact_score": total_impact // valid_count if valid_count else 0,
|
| 157 |
+
"attention": total_attention // valid_count if valid_count else 0,
|
| 158 |
+
"relevance": total_relevance // valid_count if valid_count else 0,
|
| 159 |
+
"key_insights": [r.get("comment") for r in results if "error" not in r][:3],
|
| 160 |
+
"agent_dialogue": results
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
job_registry.update_job(
|
| 164 |
+
job_id,
|
| 165 |
+
status="COMPLETED",
|
| 166 |
+
progress_percentage=100,
|
| 167 |
+
results=agg_results
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Simulation Job {job_id} failed: {e}")
|
| 172 |
+
job_registry.update_job(job_id, status="FAILED", message=str(e))
|
| 173 |
+
|
| 174 |
+
tinytroupe_manager = TinyTroupeSimulationManager()
|
backend/tests/test_api.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from backend.main import app
|
| 3 |
+
|
| 4 |
+
client = TestClient(app)
|
| 5 |
+
|
| 6 |
+
def test_root():
|
| 7 |
+
response = client.get("/")
|
| 8 |
+
assert response.status_code == 200
|
| 9 |
+
assert response.json() == {"message": "Welcome to UserSync API"}
|
| 10 |
+
|
| 11 |
+
def test_start_simulation():
|
| 12 |
+
response = client.post(
|
| 13 |
+
"/api/v1/simulations",
|
| 14 |
+
json={
|
| 15 |
+
"focus_group_id": "tech_founders_eu",
|
| 16 |
+
"content_type": "article",
|
| 17 |
+
"content_payload": "We just secured $5.3M to build AI-native tools...",
|
| 18 |
+
"parameters": {"creativity": 0.7, "duration": "short"}
|
| 19 |
+
}
|
| 20 |
+
)
|
| 21 |
+
assert response.status_code == 200
|
| 22 |
+
data = response.json()
|
| 23 |
+
assert "job_id" in data
|
| 24 |
+
assert data["status"] == "PENDING"
|
| 25 |
+
|
| 26 |
+
def test_get_personas():
|
| 27 |
+
response = client.get("/api/v1/personas")
|
| 28 |
+
assert response.status_code == 200
|
| 29 |
+
data = response.json()
|
| 30 |
+
assert "focus_groups" in data
|
| 31 |
+
assert len(data["focus_groups"]) > 0
|
| 32 |
+
|
| 33 |
+
def test_job_registry_crud():
|
| 34 |
+
from backend.core.job_registry import JobRegistry
|
| 35 |
+
registry = JobRegistry()
|
| 36 |
+
registry.create_job("test-job", {"test": "data"})
|
| 37 |
+
|
| 38 |
+
job = registry.get_job("test-job")
|
| 39 |
+
assert job["status"] == "PENDING"
|
| 40 |
+
assert job["test"] == "data"
|
| 41 |
+
|
| 42 |
+
registry.update_job("test-job", status="RUNNING")
|
| 43 |
+
job = registry.get_job("test-job")
|
| 44 |
+
assert job["status"] == "RUNNING"
|
| 45 |
+
|
| 46 |
+
registry.delete_job("test-job")
|
| 47 |
+
job = registry.get_job("test-job")
|
| 48 |
+
assert job["status"] == "NOT_FOUND"
|
| 49 |
+
|
| 50 |
+
def test_tinytroupe_parallel_execution():
|
| 51 |
+
from backend.services.tinytroupe_manager import TinyTroupeSimulationManager
|
| 52 |
+
from backend.core.job_registry import JobRegistry
|
| 53 |
+
import time
|
| 54 |
+
|
| 55 |
+
manager = TinyTroupeSimulationManager()
|
| 56 |
+
registry = JobRegistry()
|
| 57 |
+
job_id = "test-job"
|
| 58 |
+
registry.create_job(job_id)
|
| 59 |
+
|
| 60 |
+
# Use mock personas for parallel testing
|
| 61 |
+
mock_personas = [{"name": f"Mock {i}"} for i in range(5)]
|
| 62 |
+
|
| 63 |
+
manager.run_simulation_async(
|
| 64 |
+
job_id=job_id,
|
| 65 |
+
content_text="test content",
|
| 66 |
+
personas_data=mock_personas,
|
| 67 |
+
format_type="article",
|
| 68 |
+
parameters={},
|
| 69 |
+
job_registry=registry
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Since it runs in an executor, we wait slightly for completion
|
| 73 |
+
time.sleep(2)
|
| 74 |
+
job = registry.get_job(job_id)
|
| 75 |
+
assert job["status"] == "COMPLETED"
|
| 76 |
+
assert "results" in job
|
| 77 |
+
assert "impact_score" in job["results"]
|
requirements.txt
CHANGED
|
@@ -1,24 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
gradio
|
| 2 |
-
|
|
|
|
| 3 |
pytest
|
| 4 |
-
|
| 5 |
-
openai>=1.65
|
| 6 |
-
tiktoken
|
| 7 |
-
msal
|
| 8 |
-
rich
|
| 9 |
-
requests
|
| 10 |
-
chevron
|
| 11 |
-
llama-index
|
| 12 |
-
llama-index-embeddings-huggingface
|
| 13 |
-
llama-index-readers-web
|
| 14 |
-
llama-index-embeddings-azure-openai
|
| 15 |
-
pypandoc
|
| 16 |
-
docx
|
| 17 |
-
markdown
|
| 18 |
-
jupyter
|
| 19 |
-
matplotlib
|
| 20 |
-
pydantic
|
| 21 |
-
textdistance
|
| 22 |
-
scipy
|
| 23 |
-
transformers==4.38.2
|
| 24 |
-
huggingface-hub>=0.33.5
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
pydantic-settings
|
| 5 |
gradio
|
| 6 |
+
gradio_client
|
| 7 |
+
httpx
|
| 8 |
pytest
|
| 9 |
+
tinytroupe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tinytroupe/__init__.py
CHANGED
|
@@ -49,7 +49,7 @@ class ConfigManager:
|
|
| 49 |
|
| 50 |
self._config["max_tokens"] = int(config["OpenAI"].get("MAX_TOKENS", "1024"))
|
| 51 |
self._config["temperature"] = float(config["OpenAI"].get("TEMPERATURE", "1.0"))
|
| 52 |
-
self._config["top_p"] =
|
| 53 |
self._config["frequency_penalty"] = float(config["OpenAI"].get("FREQ_PENALTY", "0.0"))
|
| 54 |
self._config["presence_penalty"] = float(
|
| 55 |
config["OpenAI"].get("PRESENCE_PENALTY", "0.0"))
|
|
|
|
| 49 |
|
| 50 |
self._config["max_tokens"] = int(config["OpenAI"].get("MAX_TOKENS", "1024"))
|
| 51 |
self._config["temperature"] = float(config["OpenAI"].get("TEMPERATURE", "1.0"))
|
| 52 |
+
self._config["top_p"] = int(config["OpenAI"].get("TOP_P", "0"))
|
| 53 |
self._config["frequency_penalty"] = float(config["OpenAI"].get("FREQ_PENALTY", "0.0"))
|
| 54 |
self._config["presence_penalty"] = float(
|
| 55 |
config["OpenAI"].get("PRESENCE_PENALTY", "0.0"))
|
tinytroupe/agent/memory.py
CHANGED
|
@@ -88,24 +88,6 @@ class TinyMemory(TinyMentalFaculty):
|
|
| 88 |
"""
|
| 89 |
raise NotImplementedError("Subclasses must implement this method.")
|
| 90 |
|
| 91 |
-
def store_interaction(self, interaction: Any) -> None:
|
| 92 |
-
"""
|
| 93 |
-
Stores an interaction in memory.
|
| 94 |
-
"""
|
| 95 |
-
self.store({"type": "interaction", "content": interaction, "simulation_timestamp": utils.pretty_datetime(datetime.now())})
|
| 96 |
-
|
| 97 |
-
def get_memory_summary(self) -> str:
|
| 98 |
-
"""
|
| 99 |
-
Returns a summary of the memory.
|
| 100 |
-
"""
|
| 101 |
-
raise NotImplementedError("Subclasses must implement this method.")
|
| 102 |
-
|
| 103 |
-
def consolidate_memories(self) -> None:
|
| 104 |
-
"""
|
| 105 |
-
Consolidates memories (e.g., from episodic to semantic).
|
| 106 |
-
"""
|
| 107 |
-
raise NotImplementedError("Subclasses must implement this method.")
|
| 108 |
-
|
| 109 |
def summarize_relevant_via_full_scan(self, relevance_target: str, batch_size: int = 20, item_type: str = None) -> str:
|
| 110 |
"""
|
| 111 |
Performs a full scan of the memory, extracting and accumulating information relevant to a query.
|
|
|
|
| 88 |
"""
|
| 89 |
raise NotImplementedError("Subclasses must implement this method.")
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
def summarize_relevant_via_full_scan(self, relevance_target: str, batch_size: int = 20, item_type: str = None) -> str:
|
| 92 |
"""
|
| 93 |
Performs a full scan of the memory, extracting and accumulating information relevant to a query.
|
tinytroupe/agent/mental_faculty.py
CHANGED
|
@@ -397,70 +397,3 @@ class TinyToolUse(TinyMentalFaculty):
|
|
| 397 |
prompt += tool.actions_constraints_prompt()
|
| 398 |
|
| 399 |
return prompt
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
class SequentialThinkingFaculty(TinyMentalFaculty):
|
| 403 |
-
def __init__(self):
|
| 404 |
-
super().__init__("Sequential Thinking")
|
| 405 |
-
from tinytroupe.tools.sequential_thinking import SequentialThinkingTool
|
| 406 |
-
self.sequential_thinking_tool = SequentialThinkingTool()
|
| 407 |
-
|
| 408 |
-
def process_action(self, agent, action: dict) -> bool:
|
| 409 |
-
return self.sequential_thinking_tool.process_action(agent, action)
|
| 410 |
-
|
| 411 |
-
def actions_definitions_prompt(self) -> str:
|
| 412 |
-
return """
|
| 413 |
-
- SEQUENTIAL_THINKING: Engage in a dynamic and reflective problem-solving process by breaking down complex problems into a sequence of thoughts. The content of this action should be a JSON string with the following schema:
|
| 414 |
-
{
|
| 415 |
-
"type": "object",
|
| 416 |
-
"properties": {
|
| 417 |
-
"thought": {
|
| 418 |
-
"type": "string",
|
| 419 |
-
"description": "Your current thinking step"
|
| 420 |
-
},
|
| 421 |
-
"nextThoughtNeeded": {
|
| 422 |
-
"type": "boolean",
|
| 423 |
-
"description": "Whether another thought step is needed"
|
| 424 |
-
},
|
| 425 |
-
"thoughtNumber": {
|
| 426 |
-
"type": "integer",
|
| 427 |
-
"description": "Current thought number (numeric value, e.g., 1, 2, 3)",
|
| 428 |
-
"minimum": 1
|
| 429 |
-
},
|
| 430 |
-
"totalThoughts": {
|
| 431 |
-
"type": "integer",
|
| 432 |
-
"description": "Estimated total thoughts needed (numeric value, e.g., 5, 10)",
|
| 433 |
-
"minimum": 1
|
| 434 |
-
},
|
| 435 |
-
"isRevision": {
|
| 436 |
-
"type": "boolean",
|
| 437 |
-
"description": "Whether this revises previous thinking"
|
| 438 |
-
},
|
| 439 |
-
"revisesThought": {
|
| 440 |
-
"type": "integer",
|
| 441 |
-
"description": "Which thought is being reconsidered",
|
| 442 |
-
"minimum": 1
|
| 443 |
-
},
|
| 444 |
-
"branchFromThought": {
|
| 445 |
-
"type": "integer",
|
| 446 |
-
"description": "Branching point thought number",
|
| 447 |
-
"minimum": 1
|
| 448 |
-
},
|
| 449 |
-
"branchId": {
|
| 450 |
-
"type": "string",
|
| 451 |
-
"description": "Branch identifier"
|
| 452 |
-
},
|
| 453 |
-
"needsMoreThoughts": {
|
| 454 |
-
"type": "boolean",
|
| 455 |
-
"description": "If more thoughts are needed"
|
| 456 |
-
}
|
| 457 |
-
},
|
| 458 |
-
"required": ["thought", "nextThoughtNeeded", "thoughtNumber", "totalThoughts"]
|
| 459 |
-
}
|
| 460 |
-
"""
|
| 461 |
-
|
| 462 |
-
def actions_constraints_prompt(self) -> str:
|
| 463 |
-
return """
|
| 464 |
-
- When you need to solve a complex problem, use the SEQUENTIAL_THINKING action to break it down into smaller, manageable thoughts.
|
| 465 |
-
- Each thought should build upon, question, or revise previous insights.
|
| 466 |
-
"""
|
|
|
|
| 397 |
prompt += tool.actions_constraints_prompt()
|
| 398 |
|
| 399 |
return prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tinytroupe/agent/tiny_person.py
CHANGED
|
@@ -1,12 +1,10 @@
|
|
| 1 |
from tinytroupe.agent import logger, default, Self, AgentOrWorld, CognitiveActionModel
|
| 2 |
from tinytroupe.agent.memory import EpisodicMemory, SemanticMemory, EpisodicConsolidator
|
| 3 |
-
from tinytroupe.agent.social_types import ConnectionEdge, BehavioralEvent, InfluenceProfile, Content, Reaction
|
| 4 |
import tinytroupe.openai_utils as openai_utils
|
| 5 |
from tinytroupe.utils import JsonSerializableRegistry, repeat_on_error, name_or_empty
|
| 6 |
import tinytroupe.utils as utils
|
| 7 |
from tinytroupe.control import transactional, current_simulation
|
| 8 |
from tinytroupe import config_manager
|
| 9 |
-
from tinytroupe.utils.logger import get_logger
|
| 10 |
|
| 11 |
import os
|
| 12 |
import json
|
|
@@ -43,8 +41,7 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 43 |
|
| 44 |
PP_TEXT_WIDTH = 100
|
| 45 |
|
| 46 |
-
serializable_attributes = ["_persona", "_mental_state", "_mental_faculties", "_current_episode_event_count", "episodic_memory", "semantic_memory"
|
| 47 |
-
"social_connections", "engagement_patterns", "behavioral_history", "influence_metrics", "prediction_confidence", "behavioral_traits"]
|
| 48 |
serializable_attributes_renaming = {"_mental_faculties": "mental_faculties", "_persona": "persona", "_mental_state": "mental_state", "_current_episode_event_count": "current_episode_event_count"}
|
| 49 |
|
| 50 |
# A dict of all agents instantiated so far.
|
|
@@ -60,8 +57,7 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 60 |
episodic_memory=None,
|
| 61 |
semantic_memory=None,
|
| 62 |
mental_faculties:list=None,
|
| 63 |
-
enable_basic_action_repetition_prevention:bool=True
|
| 64 |
-
enable_browser:bool=False):
|
| 65 |
"""
|
| 66 |
Creates a TinyPerson.
|
| 67 |
|
|
@@ -72,7 +68,6 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 72 |
semantic_memory (SemanticMemory, optional): The memory implementation to use. Defaults to SemanticMemory().
|
| 73 |
mental_faculties (list, optional): A list of mental faculties to add to the agent. Defaults to None.
|
| 74 |
enable_basic_action_repetition_prevention (bool, optional): Whether to enable basic action repetition prevention. Defaults to True.
|
| 75 |
-
enable_browser (bool, optional): Whether to enable the browser faculty. Defaults to False.
|
| 76 |
"""
|
| 77 |
|
| 78 |
# NOTE: default values will be given in the _post_init method, as that's shared by
|
|
@@ -94,8 +89,6 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 94 |
if enable_basic_action_repetition_prevention:
|
| 95 |
self.enable_basic_action_repetition_prevention = enable_basic_action_repetition_prevention
|
| 96 |
|
| 97 |
-
self.enable_browser = enable_browser
|
| 98 |
-
|
| 99 |
assert name is not None, "A TinyPerson must have a name."
|
| 100 |
self.name = name
|
| 101 |
|
|
@@ -108,11 +101,6 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 108 |
It is convenient to separate some of the initialization processes to make deserialize easier.
|
| 109 |
"""
|
| 110 |
|
| 111 |
-
if "enable_browser" in kwargs:
|
| 112 |
-
self.enable_browser = kwargs["enable_browser"]
|
| 113 |
-
elif not hasattr(self, 'enable_browser'):
|
| 114 |
-
self.enable_browser = False
|
| 115 |
-
|
| 116 |
from tinytroupe.agent.action_generator import ActionGenerator # import here to avoid circular import issues
|
| 117 |
|
| 118 |
|
|
@@ -166,13 +154,8 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 166 |
# _mental_faculties
|
| 167 |
if not hasattr(self, '_mental_faculties'):
|
| 168 |
# This default value MUST NOT be in the method signature, otherwise it will be shared across all instances.
|
| 169 |
-
|
| 170 |
-
self._mental_faculties = [SequentialThinkingFaculty()]
|
| 171 |
|
| 172 |
-
if self.enable_browser:
|
| 173 |
-
from tinytroupe.agent.browser_faculty import BrowserFaculty
|
| 174 |
-
self.add_mental_faculty(BrowserFaculty())
|
| 175 |
-
|
| 176 |
# basic action repetition prevention
|
| 177 |
if not hasattr(self, 'enable_basic_action_repetition_prevention'):
|
| 178 |
self.enable_basic_action_repetition_prevention = True
|
|
@@ -212,29 +195,6 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 212 |
if not hasattr(self, 'stimuli_count'):
|
| 213 |
self.stimuli_count = 0
|
| 214 |
|
| 215 |
-
if not hasattr(self, 'social_connections'):
|
| 216 |
-
self.social_connections = {}
|
| 217 |
-
|
| 218 |
-
if not hasattr(self, 'engagement_patterns'):
|
| 219 |
-
self.engagement_patterns = {
|
| 220 |
-
"content_type_preferences": {},
|
| 221 |
-
"topic_affinities": {},
|
| 222 |
-
"posting_time_preferences": {},
|
| 223 |
-
"engagement_likelihood": {}
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
if not hasattr(self, 'behavioral_history'):
|
| 227 |
-
self.behavioral_history = []
|
| 228 |
-
|
| 229 |
-
if not hasattr(self, 'influence_metrics'):
|
| 230 |
-
self.influence_metrics = InfluenceProfile()
|
| 231 |
-
|
| 232 |
-
if not hasattr(self, 'prediction_confidence'):
|
| 233 |
-
self.prediction_confidence = 0.0
|
| 234 |
-
|
| 235 |
-
if not hasattr(self, 'behavioral_traits'):
|
| 236 |
-
self.behavioral_traits = {}
|
| 237 |
-
|
| 238 |
self._prompt_template_path = os.path.join(
|
| 239 |
os.path.dirname(__file__), "prompts/tiny_person.mustache"
|
| 240 |
)
|
|
@@ -248,7 +208,7 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 248 |
# rename agent to some specific name?
|
| 249 |
if kwargs.get("new_agent_name") is not None:
|
| 250 |
self._rename(kwargs.get("new_agent_name"))
|
| 251 |
-
|
| 252 |
# If auto-rename, use the given name plus some new number ...
|
| 253 |
if kwargs.get("auto_rename") is True:
|
| 254 |
new_name = self.name # start with the current name
|
|
@@ -796,8 +756,6 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 796 |
Forces the agent to think about something and updates its internal cognitive state.
|
| 797 |
|
| 798 |
"""
|
| 799 |
-
logger = get_logger(self.name)
|
| 800 |
-
logger.info(f"Thinking: {thought}")
|
| 801 |
return self._observe(
|
| 802 |
stimulus={
|
| 803 |
"type": "THOUGHT",
|
|
@@ -807,20 +765,6 @@ class TinyPerson(JsonSerializableRegistry):
|
|
| 807 |
max_content_length=max_content_length,
|
| 808 |
)
|
| 809 |
|
| 810 |
-
def sequential_think(self, thought_data: dict, max_content_length=None):
|
| 811 |
-
"""
|
| 812 |
-
Forces the agent to think about something and updates its internal cognitive state.
|
| 813 |
-
|
| 814 |
-
"""
|
| 815 |
-
return self._observe(
|
| 816 |
-
stimulus={
|
| 817 |
-
"type": "SEQUENTIAL_THINKING",
|
| 818 |
-
"content": json.dumps(thought_data),
|
| 819 |
-
"source": name_or_empty(self),
|
| 820 |
-
},
|
| 821 |
-
max_content_length=max_content_length,
|
| 822 |
-
)
|
| 823 |
-
|
| 824 |
@config_manager.config_defaults(max_content_length="max_content_display_length")
|
| 825 |
def internalize_goal(
|
| 826 |
self, goal, max_content_length=None
|
|
@@ -1255,7 +1199,6 @@ max_content_length=max_content_length,
|
|
| 1255 |
"""
|
| 1256 |
Displays the current communication and stores it in a buffer for later use.
|
| 1257 |
"""
|
| 1258 |
-
logger = get_logger(self.name)
|
| 1259 |
# CONCURRENT PROTECTION, as we'll access shared display buffers
|
| 1260 |
with concurrent_agent_action_lock:
|
| 1261 |
if kind == "stimuli":
|
|
@@ -1281,7 +1224,6 @@ max_content_length=max_content_length,
|
|
| 1281 |
else:
|
| 1282 |
raise ValueError(f"Unknown communication kind: {kind}")
|
| 1283 |
|
| 1284 |
-
logger.info(f"Output: {rendering}")
|
| 1285 |
# if the agent has no parent environment, then it is a free agent and we can display the communication.
|
| 1286 |
# otherwise, the environment will display the communication instead. This is important to make sure that
|
| 1287 |
# the communication is displayed in the correct order, since environments control the flow of their underlying
|
|
@@ -1641,7 +1583,7 @@ max_content_length=max_content_length,
|
|
| 1641 |
|
| 1642 |
@staticmethod
|
| 1643 |
def load_specification(path_or_dict, suppress_mental_faculties=False, suppress_memory=False, suppress_mental_state=False,
|
| 1644 |
-
auto_rename_agent=False, new_agent_name=None
|
| 1645 |
"""
|
| 1646 |
Loads a JSON agent specification.
|
| 1647 |
|
|
@@ -1649,10 +1591,10 @@ max_content_length=max_content_length,
|
|
| 1649 |
path_or_dict (str or dict): The path to the JSON file or the dictionary itself.
|
| 1650 |
suppress_mental_faculties (bool, optional): Whether to suppress loading the mental faculties. Defaults to False.
|
| 1651 |
suppress_memory (bool, optional): Whether to suppress loading the memory. Defaults to False.
|
|
|
|
| 1652 |
suppress_mental_state (bool, optional): Whether to suppress loading the mental state. Defaults to False.
|
| 1653 |
auto_rename_agent (bool, optional): Whether to auto rename the agent. Defaults to False.
|
| 1654 |
new_agent_name (str, optional): The new name for the agent. Defaults to None.
|
| 1655 |
-
enable_browser (bool, optional): Whether to enable the browser faculty. Defaults to False.
|
| 1656 |
"""
|
| 1657 |
|
| 1658 |
suppress_attributes = []
|
|
@@ -1672,7 +1614,7 @@ max_content_length=max_content_length,
|
|
| 1672 |
|
| 1673 |
return TinyPerson.from_json(json_dict_or_path=path_or_dict, suppress=suppress_attributes,
|
| 1674 |
serialization_type_field_name="type",
|
| 1675 |
-
post_init_params={"auto_rename_agent": auto_rename_agent, "new_agent_name": new_agent_name
|
| 1676 |
@staticmethod
|
| 1677 |
def load_specifications_from_folder(folder_path:str, file_suffix=".agent.json", suppress_mental_faculties=False,
|
| 1678 |
suppress_memory=False, suppress_mental_state=False, auto_rename_agent=False,
|
|
@@ -1819,47 +1761,3 @@ max_content_length=max_content_length,
|
|
| 1819 |
Clears the global list of agents.
|
| 1820 |
"""
|
| 1821 |
TinyPerson.all_agents = {}
|
| 1822 |
-
|
| 1823 |
-
############################################################################
|
| 1824 |
-
# Social and Engagement methods
|
| 1825 |
-
############################################################################
|
| 1826 |
-
|
| 1827 |
-
def calculate_engagement_probability(self, content: Content) -> float:
|
| 1828 |
-
"""
|
| 1829 |
-
Analyze content features and return probability of engagement using the prediction engine.
|
| 1830 |
-
"""
|
| 1831 |
-
from tinytroupe.ml_models import EngagementPredictor
|
| 1832 |
-
predictor = EngagementPredictor()
|
| 1833 |
-
|
| 1834 |
-
# Use the environment's network topology if available
|
| 1835 |
-
network = getattr(self.environment, 'network', None)
|
| 1836 |
-
|
| 1837 |
-
return predictor.predict(self, content, network)
|
| 1838 |
-
|
| 1839 |
-
def predict_reaction(self, content: Content) -> Reaction:
|
| 1840 |
-
"""
|
| 1841 |
-
Determine reaction type using the LLM-based predictor.
|
| 1842 |
-
"""
|
| 1843 |
-
from tinytroupe.llm_predictor import LLMPredictor
|
| 1844 |
-
predictor = LLMPredictor()
|
| 1845 |
-
|
| 1846 |
-
return predictor.predict(self, content)
|
| 1847 |
-
|
| 1848 |
-
def update_from_interaction(self, interaction: Any) -> None:
|
| 1849 |
-
"""
|
| 1850 |
-
Learn from actual interactions and update patterns.
|
| 1851 |
-
"""
|
| 1852 |
-
# interaction could be a dict with content and outcome
|
| 1853 |
-
if isinstance(interaction, dict):
|
| 1854 |
-
content = interaction.get("content")
|
| 1855 |
-
outcome = interaction.get("outcome") # e.g. "like", "comment", "none"
|
| 1856 |
-
|
| 1857 |
-
# Update patterns based on outcome
|
| 1858 |
-
# This is a simplified learning mechanism
|
| 1859 |
-
pass
|
| 1860 |
-
|
| 1861 |
-
def get_content_affinity(self, content: Content) -> float:
|
| 1862 |
-
"""
|
| 1863 |
-
Score content relevance to persona.
|
| 1864 |
-
"""
|
| 1865 |
-
return self.calculate_engagement_probability(content)
|
|
|
|
| 1 |
from tinytroupe.agent import logger, default, Self, AgentOrWorld, CognitiveActionModel
|
| 2 |
from tinytroupe.agent.memory import EpisodicMemory, SemanticMemory, EpisodicConsolidator
|
|
|
|
| 3 |
import tinytroupe.openai_utils as openai_utils
|
| 4 |
from tinytroupe.utils import JsonSerializableRegistry, repeat_on_error, name_or_empty
|
| 5 |
import tinytroupe.utils as utils
|
| 6 |
from tinytroupe.control import transactional, current_simulation
|
| 7 |
from tinytroupe import config_manager
|
|
|
|
| 8 |
|
| 9 |
import os
|
| 10 |
import json
|
|
|
|
| 41 |
|
| 42 |
PP_TEXT_WIDTH = 100
|
| 43 |
|
| 44 |
+
serializable_attributes = ["_persona", "_mental_state", "_mental_faculties", "_current_episode_event_count", "episodic_memory", "semantic_memory"]
|
|
|
|
| 45 |
serializable_attributes_renaming = {"_mental_faculties": "mental_faculties", "_persona": "persona", "_mental_state": "mental_state", "_current_episode_event_count": "current_episode_event_count"}
|
| 46 |
|
| 47 |
# A dict of all agents instantiated so far.
|
|
|
|
| 57 |
episodic_memory=None,
|
| 58 |
semantic_memory=None,
|
| 59 |
mental_faculties:list=None,
|
| 60 |
+
enable_basic_action_repetition_prevention:bool=True):
|
|
|
|
| 61 |
"""
|
| 62 |
Creates a TinyPerson.
|
| 63 |
|
|
|
|
| 68 |
semantic_memory (SemanticMemory, optional): The memory implementation to use. Defaults to SemanticMemory().
|
| 69 |
mental_faculties (list, optional): A list of mental faculties to add to the agent. Defaults to None.
|
| 70 |
enable_basic_action_repetition_prevention (bool, optional): Whether to enable basic action repetition prevention. Defaults to True.
|
|
|
|
| 71 |
"""
|
| 72 |
|
| 73 |
# NOTE: default values will be given in the _post_init method, as that's shared by
|
|
|
|
| 89 |
if enable_basic_action_repetition_prevention:
|
| 90 |
self.enable_basic_action_repetition_prevention = enable_basic_action_repetition_prevention
|
| 91 |
|
|
|
|
|
|
|
| 92 |
assert name is not None, "A TinyPerson must have a name."
|
| 93 |
self.name = name
|
| 94 |
|
|
|
|
| 101 |
It is convenient to separate some of the initialization processes to make deserialize easier.
|
| 102 |
"""
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
from tinytroupe.agent.action_generator import ActionGenerator # import here to avoid circular import issues
|
| 105 |
|
| 106 |
|
|
|
|
| 154 |
# _mental_faculties
|
| 155 |
if not hasattr(self, '_mental_faculties'):
|
| 156 |
# This default value MUST NOT be in the method signature, otherwise it will be shared across all instances.
|
| 157 |
+
self._mental_faculties = []
|
|
|
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
# basic action repetition prevention
|
| 160 |
if not hasattr(self, 'enable_basic_action_repetition_prevention'):
|
| 161 |
self.enable_basic_action_repetition_prevention = True
|
|
|
|
| 195 |
if not hasattr(self, 'stimuli_count'):
|
| 196 |
self.stimuli_count = 0
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
self._prompt_template_path = os.path.join(
|
| 199 |
os.path.dirname(__file__), "prompts/tiny_person.mustache"
|
| 200 |
)
|
|
|
|
| 208 |
# rename agent to some specific name?
|
| 209 |
if kwargs.get("new_agent_name") is not None:
|
| 210 |
self._rename(kwargs.get("new_agent_name"))
|
| 211 |
+
|
| 212 |
# If auto-rename, use the given name plus some new number ...
|
| 213 |
if kwargs.get("auto_rename") is True:
|
| 214 |
new_name = self.name # start with the current name
|
|
|
|
| 756 |
Forces the agent to think about something and updates its internal cognitive state.
|
| 757 |
|
| 758 |
"""
|
|
|
|
|
|
|
| 759 |
return self._observe(
|
| 760 |
stimulus={
|
| 761 |
"type": "THOUGHT",
|
|
|
|
| 765 |
max_content_length=max_content_length,
|
| 766 |
)
|
| 767 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
@config_manager.config_defaults(max_content_length="max_content_display_length")
|
| 769 |
def internalize_goal(
|
| 770 |
self, goal, max_content_length=None
|
|
|
|
| 1199 |
"""
|
| 1200 |
Displays the current communication and stores it in a buffer for later use.
|
| 1201 |
"""
|
|
|
|
| 1202 |
# CONCURRENT PROTECTION, as we'll access shared display buffers
|
| 1203 |
with concurrent_agent_action_lock:
|
| 1204 |
if kind == "stimuli":
|
|
|
|
| 1224 |
else:
|
| 1225 |
raise ValueError(f"Unknown communication kind: {kind}")
|
| 1226 |
|
|
|
|
| 1227 |
# if the agent has no parent environment, then it is a free agent and we can display the communication.
|
| 1228 |
# otherwise, the environment will display the communication instead. This is important to make sure that
|
| 1229 |
# the communication is displayed in the correct order, since environments control the flow of their underlying
|
|
|
|
| 1583 |
|
| 1584 |
@staticmethod
|
| 1585 |
def load_specification(path_or_dict, suppress_mental_faculties=False, suppress_memory=False, suppress_mental_state=False,
|
| 1586 |
+
auto_rename_agent=False, new_agent_name=None):
|
| 1587 |
"""
|
| 1588 |
Loads a JSON agent specification.
|
| 1589 |
|
|
|
|
| 1591 |
path_or_dict (str or dict): The path to the JSON file or the dictionary itself.
|
| 1592 |
suppress_mental_faculties (bool, optional): Whether to suppress loading the mental faculties. Defaults to False.
|
| 1593 |
suppress_memory (bool, optional): Whether to suppress loading the memory. Defaults to False.
|
| 1594 |
+
suppress_memory (bool, optional): Whether to suppress loading the memory. Defaults to False.
|
| 1595 |
suppress_mental_state (bool, optional): Whether to suppress loading the mental state. Defaults to False.
|
| 1596 |
auto_rename_agent (bool, optional): Whether to auto rename the agent. Defaults to False.
|
| 1597 |
new_agent_name (str, optional): The new name for the agent. Defaults to None.
|
|
|
|
| 1598 |
"""
|
| 1599 |
|
| 1600 |
suppress_attributes = []
|
|
|
|
| 1614 |
|
| 1615 |
return TinyPerson.from_json(json_dict_or_path=path_or_dict, suppress=suppress_attributes,
|
| 1616 |
serialization_type_field_name="type",
|
| 1617 |
+
post_init_params={"auto_rename_agent": auto_rename_agent, "new_agent_name": new_agent_name})
|
| 1618 |
@staticmethod
|
| 1619 |
def load_specifications_from_folder(folder_path:str, file_suffix=".agent.json", suppress_mental_faculties=False,
|
| 1620 |
suppress_memory=False, suppress_mental_state=False, auto_rename_agent=False,
|
|
|
|
| 1761 |
Clears the global list of agents.
|
| 1762 |
"""
|
| 1763 |
TinyPerson.all_agents = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tinytroupe/config.ini
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
# OpenAI or Azure OpenAI Service
|
| 4 |
#
|
| 5 |
|
| 6 |
-
# Default options: openai, azure
|
| 7 |
API_TYPE=openai
|
| 8 |
|
| 9 |
# Check Azure's documentation for updates here:
|
|
@@ -15,10 +15,10 @@ AZURE_API_VERSION=2023-05-15
|
|
| 15 |
#
|
| 16 |
|
| 17 |
# The main text generation model, used for agent responses
|
| 18 |
-
MODEL=
|
| 19 |
|
| 20 |
# Reasoning model is used when precise reasoning is required, such as when computing detailed analyses of simulation properties.
|
| 21 |
-
REASONING_MODEL=
|
| 22 |
|
| 23 |
# Embedding model is used for text similarity tasks
|
| 24 |
EMBEDDING_MODEL=text-embedding-3-small
|
|
@@ -31,8 +31,8 @@ TEMPERATURE=1.5
|
|
| 31 |
FREQ_PENALTY=0.1
|
| 32 |
PRESENCE_PENALTY=0.1
|
| 33 |
TIMEOUT=480
|
| 34 |
-
MAX_ATTEMPTS=
|
| 35 |
-
WAITING_TIME=
|
| 36 |
EXPONENTIAL_BACKOFF_FACTOR=5
|
| 37 |
|
| 38 |
REASONING_EFFORT=high
|
|
@@ -90,7 +90,7 @@ QUALITY_THRESHOLD = 5
|
|
| 90 |
|
| 91 |
|
| 92 |
[Logging]
|
| 93 |
-
LOGLEVEL=
|
| 94 |
# ERROR
|
| 95 |
# WARNING
|
| 96 |
# INFO
|
|
|
|
| 3 |
# OpenAI or Azure OpenAI Service
|
| 4 |
#
|
| 5 |
|
| 6 |
+
# Default options: openai, azure
|
| 7 |
API_TYPE=openai
|
| 8 |
|
| 9 |
# Check Azure's documentation for updates here:
|
|
|
|
| 15 |
#
|
| 16 |
|
| 17 |
# The main text generation model, used for agent responses
|
| 18 |
+
MODEL=gpt-4.1-mini
|
| 19 |
|
| 20 |
# Reasoning model is used when precise reasoning is required, such as when computing detailed analyses of simulation properties.
|
| 21 |
+
REASONING_MODEL=o3-mini
|
| 22 |
|
| 23 |
# Embedding model is used for text similarity tasks
|
| 24 |
EMBEDDING_MODEL=text-embedding-3-small
|
|
|
|
| 31 |
FREQ_PENALTY=0.1
|
| 32 |
PRESENCE_PENALTY=0.1
|
| 33 |
TIMEOUT=480
|
| 34 |
+
MAX_ATTEMPTS=5
|
| 35 |
+
WAITING_TIME=1
|
| 36 |
EXPONENTIAL_BACKOFF_FACTOR=5
|
| 37 |
|
| 38 |
REASONING_EFFORT=high
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
[Logging]
|
| 93 |
+
LOGLEVEL=ERROR
|
| 94 |
# ERROR
|
| 95 |
# WARNING
|
| 96 |
# INFO
|
tinytroupe/examples/agents.py
CHANGED
|
@@ -10,14 +10,14 @@ from .loaders import load_example_agent_specification
|
|
| 10 |
# Example 1: Oscar, the architect
|
| 11 |
###################################
|
| 12 |
|
| 13 |
-
def create_oscar_the_architect(
|
| 14 |
-
return TinyPerson.load_specification(load_example_agent_specification("Oscar")
|
| 15 |
|
| 16 |
-
def create_oscar_the_architect_2(
|
| 17 |
"""
|
| 18 |
A purely programmatic way to create Oscar, the architect. Has less information than the one loaded from a file, just for demonstration purposes.
|
| 19 |
"""
|
| 20 |
-
oscar = TinyPerson("Oscar"
|
| 21 |
|
| 22 |
oscar.define("age", 30)
|
| 23 |
oscar.define("nationality", "German")
|
|
@@ -76,14 +76,14 @@ def create_oscar_the_architect_2(enable_browser=False):
|
|
| 76 |
#######################################
|
| 77 |
# Example 2: Lisa, the Data Scientist
|
| 78 |
#######################################
|
| 79 |
-
def create_lisa_the_data_scientist(
|
| 80 |
-
return TinyPerson.load_specification(load_example_agent_specification("Lisa")
|
| 81 |
|
| 82 |
-
def create_lisa_the_data_scientist_2(
|
| 83 |
"""
|
| 84 |
A purely programmatic way to create Lisa, the data scientist. Has less information than the one loaded from a file, just for demonstration purposes
|
| 85 |
"""
|
| 86 |
-
lisa = TinyPerson("Lisa"
|
| 87 |
|
| 88 |
lisa.define("age", 28)
|
| 89 |
lisa.define("nationality", "Canadian")
|
|
@@ -142,15 +142,15 @@ def create_lisa_the_data_scientist_2(enable_browser=False):
|
|
| 142 |
####################################
|
| 143 |
# Example 3: Marcos, the physician
|
| 144 |
####################################
|
| 145 |
-
def create_marcos_the_physician(
|
| 146 |
-
return TinyPerson.load_specification(load_example_agent_specification("Marcos")
|
| 147 |
|
| 148 |
-
def create_marcos_the_physician_2(
|
| 149 |
"""
|
| 150 |
A purely programmatic way to create Marcos, the physician. Has less information than the one loaded from a file, just for demonstration purposes.
|
| 151 |
"""
|
| 152 |
|
| 153 |
-
marcos = TinyPerson("Marcos"
|
| 154 |
|
| 155 |
marcos.define("age", 35)
|
| 156 |
marcos.define("nationality", "Brazilian")
|
|
@@ -231,15 +231,15 @@ def create_marcos_the_physician_2(enable_browser=False):
|
|
| 231 |
#################################
|
| 232 |
# Example 4: Lila, the Linguist
|
| 233 |
#################################
|
| 234 |
-
def create_lila_the_linguist(
|
| 235 |
-
return TinyPerson.load_specification(load_example_agent_specification("Lila")
|
| 236 |
|
| 237 |
-
def create_lila_the_linguist_2(
|
| 238 |
"""
|
| 239 |
A purely programmatic way to create Lila, the linguist. Has less information than the one loaded from a file, just for demonstration purposes.
|
| 240 |
"""
|
| 241 |
|
| 242 |
-
lila = TinyPerson("Lila"
|
| 243 |
|
| 244 |
lila.define("age", 28)
|
| 245 |
lila.define("nationality", "French")
|
|
|
|
| 10 |
# Example 1: Oscar, the architect
|
| 11 |
###################################
|
| 12 |
|
| 13 |
+
def create_oscar_the_architect():
|
| 14 |
+
return TinyPerson.load_specification(load_example_agent_specification("Oscar"))
|
| 15 |
|
| 16 |
+
def create_oscar_the_architect_2():
|
| 17 |
"""
|
| 18 |
A purely programmatic way to create Oscar, the architect. Has less information than the one loaded from a file, just for demonstration purposes.
|
| 19 |
"""
|
| 20 |
+
oscar = TinyPerson("Oscar")
|
| 21 |
|
| 22 |
oscar.define("age", 30)
|
| 23 |
oscar.define("nationality", "German")
|
|
|
|
| 76 |
#######################################
|
| 77 |
# Example 2: Lisa, the Data Scientist
|
| 78 |
#######################################
|
| 79 |
+
def create_lisa_the_data_scientist():
|
| 80 |
+
return TinyPerson.load_specification(load_example_agent_specification("Lisa"))
|
| 81 |
|
| 82 |
+
def create_lisa_the_data_scientist_2():
|
| 83 |
"""
|
| 84 |
A purely programmatic way to create Lisa, the data scientist. Has less information than the one loaded from a file, just for demonstration purposes
|
| 85 |
"""
|
| 86 |
+
lisa = TinyPerson("Lisa")
|
| 87 |
|
| 88 |
lisa.define("age", 28)
|
| 89 |
lisa.define("nationality", "Canadian")
|
|
|
|
| 142 |
####################################
|
| 143 |
# Example 3: Marcos, the physician
|
| 144 |
####################################
|
| 145 |
+
def create_marcos_the_physician():
|
| 146 |
+
return TinyPerson.load_specification(load_example_agent_specification("Marcos"))
|
| 147 |
|
| 148 |
+
def create_marcos_the_physician_2():
|
| 149 |
"""
|
| 150 |
A purely programmatic way to create Marcos, the physician. Has less information than the one loaded from a file, just for demonstration purposes.
|
| 151 |
"""
|
| 152 |
|
| 153 |
+
marcos = TinyPerson("Marcos")
|
| 154 |
|
| 155 |
marcos.define("age", 35)
|
| 156 |
marcos.define("nationality", "Brazilian")
|
|
|
|
| 231 |
#################################
|
| 232 |
# Example 4: Lila, the Linguist
|
| 233 |
#################################
|
| 234 |
+
def create_lila_the_linguist():
|
| 235 |
+
return TinyPerson.load_specification(load_example_agent_specification("Lila"))
|
| 236 |
|
| 237 |
+
def create_lila_the_linguist_2():
|
| 238 |
"""
|
| 239 |
A purely programmatic way to create Lila, the linguist. Has less information than the one loaded from a file, just for demonstration purposes.
|
| 240 |
"""
|
| 241 |
|
| 242 |
+
lila = TinyPerson("Lila")
|
| 243 |
|
| 244 |
lila.define("age", 28)
|
| 245 |
lila.define("nationality", "French")
|
tinytroupe/factory/tiny_person_factory.py
CHANGED
|
@@ -342,46 +342,6 @@ class TinyPersonFactory(TinyFactory):
|
|
| 342 |
|
| 343 |
|
| 344 |
@config_manager.config_defaults(parallelize="parallel_agent_generation")
|
| 345 |
-
def generate_from_linkedin_profile(self, profile_data: Dict) -> TinyPerson:
|
| 346 |
-
"""
|
| 347 |
-
Generate a TinyPerson from a LinkedIn profile with enriched traits.
|
| 348 |
-
"""
|
| 349 |
-
description = f"Professional with headline: {profile_data.get('headline', '')}. " \
|
| 350 |
-
f"Industry: {profile_data.get('industry', '')}. " \
|
| 351 |
-
f"Location: {profile_data.get('location', 'Global')}. " \
|
| 352 |
-
f"Career level: {profile_data.get('career_level', 'Mid Level')}. " \
|
| 353 |
-
f"Summary: {profile_data.get('summary', '')}"
|
| 354 |
-
|
| 355 |
-
return self.generate_person(agent_particularities=description)
|
| 356 |
-
|
| 357 |
-
def generate_persona_cluster(self, archetype: str, count: int) -> List[TinyPerson]:
|
| 358 |
-
"""
|
| 359 |
-
Generate a cluster of personas following a specific archetype.
|
| 360 |
-
"""
|
| 361 |
-
return self.generate_people(number_of_people=count, agent_particularities=f"Archetype: {archetype}")
|
| 362 |
-
|
| 363 |
-
def generate_diverse_population(self, size: int, distribution: Dict) -> List[TinyPerson]:
|
| 364 |
-
"""
|
| 365 |
-
Generate a diverse population based on a distribution.
|
| 366 |
-
"""
|
| 367 |
-
# distribution could specify proportions of various characteristics
|
| 368 |
-
# This is a simplified implementation
|
| 369 |
-
return self.generate_people(number_of_people=size, agent_particularities=f"Target distribution: {json.dumps(distribution)}")
|
| 370 |
-
|
| 371 |
-
def ensure_consistency(self, persona: TinyPerson) -> bool:
|
| 372 |
-
"""
|
| 373 |
-
Ensure the generated persona is consistent.
|
| 374 |
-
"""
|
| 375 |
-
# Implementation would involve checking traits, demographics, etc.
|
| 376 |
-
return True # Placeholder
|
| 377 |
-
|
| 378 |
-
def calculate_diversity_score(self, personas: List[TinyPerson]) -> float:
|
| 379 |
-
"""
|
| 380 |
-
Calculate a diversity score for a list of personas.
|
| 381 |
-
"""
|
| 382 |
-
# Placeholder for diversity metric calculation
|
| 383 |
-
return 0.5
|
| 384 |
-
|
| 385 |
def generate_people(self, number_of_people:int=None,
|
| 386 |
agent_particularities:str=None,
|
| 387 |
temperature:float=1.2,
|
|
@@ -598,11 +558,6 @@ class TinyPersonFactory(TinyFactory):
|
|
| 598 |
if len(self.remaining_characteristics_sample) != n:
|
| 599 |
logger.warning(f"Expected {n} samples, but got {len(self.remaining_characteristics_sample)} samples. The LLM may have failed to sum up the quantities in the sampling plan correctly.")
|
| 600 |
|
| 601 |
-
# If we got more samples than requested, we truncate them to avoid generating too many names or personas.
|
| 602 |
-
if len(self.remaining_characteristics_sample) > n:
|
| 603 |
-
logger.info(f"Truncating {len(self.remaining_characteristics_sample)} samples to the requested {n} samples.")
|
| 604 |
-
self.remaining_characteristics_sample = self.remaining_characteristics_sample[:n]
|
| 605 |
-
|
| 606 |
logger.info(f"Sample plan has been flattened, contains {len(self.remaining_characteristics_sample)} total samples.")
|
| 607 |
logger.debug(f"Remaining characteristics sample: {json.dumps(self.remaining_characteristics_sample, indent=4)}")
|
| 608 |
|
|
@@ -1140,8 +1095,8 @@ class TinyPersonFactory(TinyFactory):
|
|
| 1140 |
"""
|
| 1141 |
samples = []
|
| 1142 |
for sample in sampling_plan:
|
| 1143 |
-
if "quantity" not in sample
|
| 1144 |
-
logger.warning(f"Sample in sampling plan does not have a
|
| 1145 |
qty = 1
|
| 1146 |
else:
|
| 1147 |
qty = int(sample["quantity"])
|
|
|
|
| 342 |
|
| 343 |
|
| 344 |
@config_manager.config_defaults(parallelize="parallel_agent_generation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
def generate_people(self, number_of_people:int=None,
|
| 346 |
agent_particularities:str=None,
|
| 347 |
temperature:float=1.2,
|
|
|
|
| 558 |
if len(self.remaining_characteristics_sample) != n:
|
| 559 |
logger.warning(f"Expected {n} samples, but got {len(self.remaining_characteristics_sample)} samples. The LLM may have failed to sum up the quantities in the sampling plan correctly.")
|
| 560 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
logger.info(f"Sample plan has been flattened, contains {len(self.remaining_characteristics_sample)} total samples.")
|
| 562 |
logger.debug(f"Remaining characteristics sample: {json.dumps(self.remaining_characteristics_sample, indent=4)}")
|
| 563 |
|
|
|
|
| 1095 |
"""
|
| 1096 |
samples = []
|
| 1097 |
for sample in sampling_plan:
|
| 1098 |
+
if "quantity" not in sample:
|
| 1099 |
+
logger.warning(f"Sample in sampling plan does not have a 'quantity' field: {sample}. Assuming 1.")
|
| 1100 |
qty = 1
|
| 1101 |
else:
|
| 1102 |
qty = int(sample["quantity"])
|
tinytroupe/openai_utils.py
CHANGED
|
@@ -31,8 +31,6 @@ class OpenAIClient:
|
|
| 31 |
def __init__(self, cache_api_calls=default["cache_api_calls"], cache_file_name=default["cache_file_name"]) -> None:
|
| 32 |
logger.debug("Initializing OpenAIClient")
|
| 33 |
|
| 34 |
-
self.client = None
|
| 35 |
-
|
| 36 |
# should we cache api calls and reuse them?
|
| 37 |
self.set_api_cache(cache_api_calls, cache_file_name)
|
| 38 |
|
|
@@ -54,8 +52,7 @@ class OpenAIClient:
|
|
| 54 |
"""
|
| 55 |
Sets up the OpenAI API configurations for this client.
|
| 56 |
"""
|
| 57 |
-
|
| 58 |
-
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 59 |
|
| 60 |
@config_manager.config_defaults(
|
| 61 |
model="model",
|
|
@@ -144,6 +141,7 @@ class OpenAIClient:
|
|
| 144 |
"messages": current_messages,
|
| 145 |
"temperature": temperature,
|
| 146 |
"max_tokens":max_tokens,
|
|
|
|
| 147 |
"frequency_penalty": frequency_penalty,
|
| 148 |
"presence_penalty": presence_penalty,
|
| 149 |
"stop": stop,
|
|
@@ -152,40 +150,18 @@ class OpenAIClient:
|
|
| 152 |
"n": n,
|
| 153 |
}
|
| 154 |
|
| 155 |
-
if top_p is not None and top_p > 0:
|
| 156 |
-
chat_api_params["top_p"] = top_p
|
| 157 |
-
|
| 158 |
if response_format is not None:
|
| 159 |
chat_api_params["response_format"] = response_format
|
| 160 |
|
| 161 |
i = 0
|
| 162 |
-
while
|
| 163 |
try:
|
| 164 |
i += 1
|
| 165 |
|
| 166 |
-
#
|
| 167 |
-
# Model fallback and retry strategy requested by the user:
|
| 168 |
-
# 1. alias-fast for 3 attempts, 35s wait
|
| 169 |
-
# 2. alias-large for 2 attempts, 35s wait
|
| 170 |
-
# 3. alias-huge until success, 60s wait
|
| 171 |
-
#
|
| 172 |
-
# Model fallback strategy using config
|
| 173 |
-
if i <= 3:
|
| 174 |
-
current_model = config["OpenAI"].get("MODEL", "alias-fast")
|
| 175 |
-
current_wait_time = 35
|
| 176 |
-
elif i <= 5:
|
| 177 |
-
current_model = config["OpenAI"].get("FALLBACK_MODEL_LARGE", "alias-large")
|
| 178 |
-
current_wait_time = 35
|
| 179 |
-
else:
|
| 180 |
-
current_model = config["OpenAI"].get("FALLBACK_MODEL_HUGE", "alias-huge")
|
| 181 |
-
current_wait_time = 60
|
| 182 |
-
|
| 183 |
-
chat_api_params["model"] = current_model
|
| 184 |
-
|
| 185 |
try:
|
| 186 |
-
logger.debug(f"Sending messages to OpenAI API.
|
| 187 |
except NotImplementedError:
|
| 188 |
-
logger.debug(f"Token count not implemented for model {
|
| 189 |
|
| 190 |
start_time = time.monotonic()
|
| 191 |
logger.debug(f"Calling model with client class {self.__class__.__name__}.")
|
|
@@ -193,11 +169,15 @@ class OpenAIClient:
|
|
| 193 |
###############################################################
|
| 194 |
# call the model, either from the cache or from the API
|
| 195 |
###############################################################
|
| 196 |
-
cache_key = str((
|
| 197 |
if self.cache_api_calls and (cache_key in self.api_cache):
|
| 198 |
response = self.api_cache[cache_key]
|
| 199 |
else:
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
if self.cache_api_calls:
|
| 202 |
self.api_cache[cache_key] = response
|
| 203 |
self._save_cache()
|
|
@@ -213,21 +193,35 @@ class OpenAIClient:
|
|
| 213 |
else:
|
| 214 |
return utils.sanitize_dict(self._raw_model_response_extractor(response))
|
| 215 |
|
| 216 |
-
except
|
| 217 |
logger.error(f"[{i}] Invalid request error, won't retry: {e}")
|
|
|
|
|
|
|
|
|
|
| 218 |
return None
|
| 219 |
|
| 220 |
-
except
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
def _raw_model_call(self, model, chat_api_params):
|
| 233 |
"""
|
|
@@ -250,12 +244,8 @@ class OpenAIClient:
|
|
| 250 |
chat_api_params["reasoning_effort"] = default["reasoning_effort"]
|
| 251 |
|
| 252 |
|
| 253 |
-
# To make the log cleaner, we remove the messages from the logged parameters
|
| 254 |
-
|
| 255 |
-
if logger.getEffectiveLevel() <= logging.DEBUG:
|
| 256 |
-
logged_params = chat_api_params
|
| 257 |
-
else:
|
| 258 |
-
logged_params = {k: v for k, v in chat_api_params.items() if k != "messages"}
|
| 259 |
|
| 260 |
if "response_format" in chat_api_params:
|
| 261 |
# to enforce the response format via pydantic, we need to use a different method
|
|
@@ -322,8 +312,8 @@ class OpenAIClient:
|
|
| 322 |
elif "gpt-3.5-turbo" in model:
|
| 323 |
logger.debug("Token count: gpt-3.5-turbo may update over time. Returning num tokens assuming gpt-3.5-turbo-0613.")
|
| 324 |
return self._count_tokens(messages, model="gpt-3.5-turbo-0613")
|
| 325 |
-
elif ("gpt-4" in model) or ("ppo" in model)
|
| 326 |
-
logger.debug("Token count: gpt-4
|
| 327 |
return self._count_tokens(messages, model="gpt-4-0613")
|
| 328 |
else:
|
| 329 |
raise NotImplementedError(
|
|
@@ -404,40 +394,23 @@ class AzureClient(OpenAIClient):
|
|
| 404 |
Sets up the Azure OpenAI Service API configurations for this client,
|
| 405 |
including the API endpoint and key.
|
| 406 |
"""
|
| 407 |
-
if
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
azure_ad_token_provider=token_provider
|
| 423 |
-
)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
class HelmholtzBlabladorClient(OpenAIClient):
|
| 427 |
-
|
| 428 |
-
def __init__(self, cache_api_calls=default["cache_api_calls"], cache_file_name=default["cache_file_name"]) -> None:
|
| 429 |
-
logger.debug("Initializing HelmholtzBlabladorClient")
|
| 430 |
-
super().__init__(cache_api_calls, cache_file_name)
|
| 431 |
-
|
| 432 |
-
def _setup_from_config(self):
|
| 433 |
-
"""
|
| 434 |
-
Sets up the Helmholtz Blablador API configurations for this client.
|
| 435 |
-
"""
|
| 436 |
-
if self.client is None:
|
| 437 |
-
self.client = OpenAI(
|
| 438 |
-
base_url="https://api.helmholtz-blablador.fz-juelich.de/v1",
|
| 439 |
-
api_key=os.getenv("BLABLADOR_API_KEY", "dummy"),
|
| 440 |
)
|
|
|
|
| 441 |
|
| 442 |
###########################################################################
|
| 443 |
# Exceptions
|
|
@@ -529,7 +502,6 @@ def force_api_cache(cache_api_calls, cache_file_name=default["cache_file_name"])
|
|
| 529 |
# default client
|
| 530 |
register_client("openai", OpenAIClient())
|
| 531 |
register_client("azure", AzureClient())
|
| 532 |
-
register_client("helmholtz-blablador", HelmholtzBlabladorClient())
|
| 533 |
|
| 534 |
|
| 535 |
|
|
|
|
| 31 |
def __init__(self, cache_api_calls=default["cache_api_calls"], cache_file_name=default["cache_file_name"]) -> None:
|
| 32 |
logger.debug("Initializing OpenAIClient")
|
| 33 |
|
|
|
|
|
|
|
| 34 |
# should we cache api calls and reuse them?
|
| 35 |
self.set_api_cache(cache_api_calls, cache_file_name)
|
| 36 |
|
|
|
|
| 52 |
"""
|
| 53 |
Sets up the OpenAI API configurations for this client.
|
| 54 |
"""
|
| 55 |
+
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
|
|
| 56 |
|
| 57 |
@config_manager.config_defaults(
|
| 58 |
model="model",
|
|
|
|
| 141 |
"messages": current_messages,
|
| 142 |
"temperature": temperature,
|
| 143 |
"max_tokens":max_tokens,
|
| 144 |
+
"top_p": top_p,
|
| 145 |
"frequency_penalty": frequency_penalty,
|
| 146 |
"presence_penalty": presence_penalty,
|
| 147 |
"stop": stop,
|
|
|
|
| 150 |
"n": n,
|
| 151 |
}
|
| 152 |
|
|
|
|
|
|
|
|
|
|
| 153 |
if response_format is not None:
|
| 154 |
chat_api_params["response_format"] = response_format
|
| 155 |
|
| 156 |
i = 0
|
| 157 |
+
while i < max_attempts:
|
| 158 |
try:
|
| 159 |
i += 1
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
try:
|
| 162 |
+
logger.debug(f"Sending messages to OpenAI API. Token count={self._count_tokens(current_messages, model)}.")
|
| 163 |
except NotImplementedError:
|
| 164 |
+
logger.debug(f"Token count not implemented for model {model}.")
|
| 165 |
|
| 166 |
start_time = time.monotonic()
|
| 167 |
logger.debug(f"Calling model with client class {self.__class__.__name__}.")
|
|
|
|
| 169 |
###############################################################
|
| 170 |
# call the model, either from the cache or from the API
|
| 171 |
###############################################################
|
| 172 |
+
cache_key = str((model, chat_api_params)) # need string to be hashable
|
| 173 |
if self.cache_api_calls and (cache_key in self.api_cache):
|
| 174 |
response = self.api_cache[cache_key]
|
| 175 |
else:
|
| 176 |
+
if waiting_time > 0:
|
| 177 |
+
logger.info(f"Waiting {waiting_time} seconds before next API request (to avoid throttling)...")
|
| 178 |
+
time.sleep(waiting_time)
|
| 179 |
+
|
| 180 |
+
response = self._raw_model_call(model, chat_api_params)
|
| 181 |
if self.cache_api_calls:
|
| 182 |
self.api_cache[cache_key] = response
|
| 183 |
self._save_cache()
|
|
|
|
| 193 |
else:
|
| 194 |
return utils.sanitize_dict(self._raw_model_response_extractor(response))
|
| 195 |
|
| 196 |
+
except InvalidRequestError as e:
|
| 197 |
logger.error(f"[{i}] Invalid request error, won't retry: {e}")
|
| 198 |
+
|
| 199 |
+
# there's no point in retrying if the request is invalid
|
| 200 |
+
# so we return None right away
|
| 201 |
return None
|
| 202 |
|
| 203 |
+
except openai.BadRequestError as e:
|
| 204 |
+
logger.error(f"[{i}] Invalid request error, won't retry: {e}")
|
| 205 |
+
|
| 206 |
+
# there's no point in retrying if the request is invalid
|
| 207 |
+
# so we return None right away
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
except openai.RateLimitError:
|
| 211 |
+
logger.warning(
|
| 212 |
+
f"[{i}] Rate limit error, waiting a bit and trying again.")
|
| 213 |
+
aux_exponential_backoff()
|
| 214 |
+
|
| 215 |
+
except NonTerminalError as e:
|
| 216 |
+
logger.error(f"[{i}] Non-terminal error: {e}")
|
| 217 |
+
aux_exponential_backoff()
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
logger.error(f"[{i}] {type(e).__name__} Error: {e}")
|
| 221 |
+
aux_exponential_backoff()
|
| 222 |
+
|
| 223 |
+
logger.error(f"Failed to get response after {max_attempts} attempts.")
|
| 224 |
+
return None
|
| 225 |
|
| 226 |
def _raw_model_call(self, model, chat_api_params):
|
| 227 |
"""
|
|
|
|
| 244 |
chat_api_params["reasoning_effort"] = default["reasoning_effort"]
|
| 245 |
|
| 246 |
|
| 247 |
+
# To make the log cleaner, we remove the messages from the logged parameters
|
| 248 |
+
logged_params = {k: v for k, v in chat_api_params.items() if k != "messages"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
if "response_format" in chat_api_params:
|
| 251 |
# to enforce the response format via pydantic, we need to use a different method
|
|
|
|
| 312 |
elif "gpt-3.5-turbo" in model:
|
| 313 |
logger.debug("Token count: gpt-3.5-turbo may update over time. Returning num tokens assuming gpt-3.5-turbo-0613.")
|
| 314 |
return self._count_tokens(messages, model="gpt-3.5-turbo-0613")
|
| 315 |
+
elif ("gpt-4" in model) or ("ppo" in model) :
|
| 316 |
+
logger.debug("Token count: gpt-4 may update over time. Returning num tokens assuming gpt-4-0613.")
|
| 317 |
return self._count_tokens(messages, model="gpt-4-0613")
|
| 318 |
else:
|
| 319 |
raise NotImplementedError(
|
|
|
|
| 394 |
Sets up the Azure OpenAI Service API configurations for this client,
|
| 395 |
including the API endpoint and key.
|
| 396 |
"""
|
| 397 |
+
if os.getenv("AZURE_OPENAI_KEY"):
|
| 398 |
+
logger.info("Using Azure OpenAI Service API with key.")
|
| 399 |
+
self.client = AzureOpenAI(azure_endpoint= os.getenv("AZURE_OPENAI_ENDPOINT"),
|
| 400 |
+
api_version = config["OpenAI"]["AZURE_API_VERSION"],
|
| 401 |
+
api_key = os.getenv("AZURE_OPENAI_KEY"))
|
| 402 |
+
else: # Use Entra ID Auth
|
| 403 |
+
logger.info("Using Azure OpenAI Service API with Entra ID Auth.")
|
| 404 |
+
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
| 405 |
+
|
| 406 |
+
credential = DefaultAzureCredential()
|
| 407 |
+
token_provider = get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default")
|
| 408 |
+
self.client = AzureOpenAI(
|
| 409 |
+
azure_endpoint= os.getenv("AZURE_OPENAI_ENDPOINT"),
|
| 410 |
+
api_version = config["OpenAI"]["AZURE_API_VERSION"],
|
| 411 |
+
azure_ad_token_provider=token_provider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
)
|
| 413 |
+
|
| 414 |
|
| 415 |
###########################################################################
|
| 416 |
# Exceptions
|
|
|
|
| 502 |
# default client
|
| 503 |
register_client("openai", OpenAIClient())
|
| 504 |
register_client("azure", AzureClient())
|
|
|
|
| 505 |
|
| 506 |
|
| 507 |
|
tinytroupe/utils/llm.py
CHANGED
|
@@ -721,7 +721,7 @@ class LLMChat:
|
|
| 721 |
|
| 722 |
def _request_list_of_dict_llm_message(self):
|
| 723 |
return {"role": "user",
|
| 724 |
-
"content": "The `value` field you generate **must** be a list of dictionaries, specified as a JSON structure embedded in a string. For example, `[\
|
| 725 |
|
| 726 |
def _coerce_to_list(self, llm_output:str):
|
| 727 |
"""
|
|
|
|
| 721 |
|
| 722 |
def _request_list_of_dict_llm_message(self):
|
| 723 |
return {"role": "user",
|
| 724 |
+
"content": "The `value` field you generate **must** be a list of dictionaries, specified as a JSON structure embedded in a string. For example, `[\{...\}, \{...\}, ...]`. This is critical for later processing."}
|
| 725 |
|
| 726 |
def _coerce_to_list(self, llm_output:str):
|
| 727 |
"""
|
tinytroupe/utils/semantics.py
CHANGED
|
@@ -265,24 +265,3 @@ def compute_semantic_proximity(text1: str, text2: str, context: str = None) -> f
|
|
| 265 |
"""
|
| 266 |
# llm decorator will handle the body of this function
|
| 267 |
|
| 268 |
-
@llm()
|
| 269 |
-
def select_best_persona(criteria: str, personas: list) -> int:
|
| 270 |
-
"""
|
| 271 |
-
Given a set of criteria and a list of personas (each a dictionary),
|
| 272 |
-
select the index of the persona that best matches the criteria.
|
| 273 |
-
If no persona matches at all, return -1.
|
| 274 |
-
|
| 275 |
-
Rules:
|
| 276 |
-
- You must analyze each persona against the criteria.
|
| 277 |
-
- Return ONLY the integer index (starting from 0) of the best matching persona.
|
| 278 |
-
- Do not provide any explanation, just the number.
|
| 279 |
-
- If there are multiple good matches, pick the best one.
|
| 280 |
-
|
| 281 |
-
Args:
|
| 282 |
-
criteria (str): The search criteria or description of the desired persona.
|
| 283 |
-
personas (list): A list of dictionaries, where each dictionary is a persona specification.
|
| 284 |
-
|
| 285 |
-
Returns:
|
| 286 |
-
int: The index of the best matching persona, or -1 if none match.
|
| 287 |
-
"""
|
| 288 |
-
# llm decorator will handle the body of this function
|
|
|
|
| 265 |
"""
|
| 266 |
# llm decorator will handle the body of this function
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|