@innvisor/conny-ai 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Callable, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _normalize_conv_text(text: str) -> str:
|
|
8
|
+
text = (text or "").lower()
|
|
9
|
+
replacements = str.maketrans({
|
|
10
|
+
"á": "a", "é": "e", "í": "i", "ó": "o", "ú": "u", "ü": "u", "ñ": "n",
|
|
11
|
+
})
|
|
12
|
+
text = text.translate(replacements)
|
|
13
|
+
text = re.sub(r"[^a-z0-9@\+\s]", " ", text)
|
|
14
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_GREETING_SIGNAL_TOKENS = {
|
|
18
|
+
"hola", "buenas", "buenos", "dias", "tardes", "noches",
|
|
19
|
+
"hey", "holi", "ey", "saludos",
|
|
20
|
+
"holaa", "holaaa", "holaaaa", "holas", "buenasas", "buenasa",
|
|
21
|
+
}
|
|
22
|
+
_GREETING_FILLER_TOKENS = {
|
|
23
|
+
"que", "tal", "como", "estas", "esta", "todo", "bien", "mas", "pues",
|
|
24
|
+
"buena", "buen", "va",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_greeting_only(text: str) -> bool:
|
|
29
|
+
norm = _normalize_conv_text(text)
|
|
30
|
+
if not norm:
|
|
31
|
+
return False
|
|
32
|
+
tokens = norm.split()
|
|
33
|
+
if not tokens or len(tokens) > 7:
|
|
34
|
+
return False
|
|
35
|
+
if not any(tok in _GREETING_SIGNAL_TOKENS for tok in tokens):
|
|
36
|
+
return False
|
|
37
|
+
return all(tok in _GREETING_SIGNAL_TOKENS or tok in _GREETING_FILLER_TOKENS for tok in tokens)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _strip_leading_greeting(text: str) -> str:
|
|
41
|
+
cleaned = re.sub(
|
|
42
|
+
r"^(?:hola(?:\s+buenas|\s+que\s+tal)?|holaa+|buenas(?:\s+tardes|\s+noches)?|buenos\s+dias|hey|holi|ey|saludos)[,!. ]*",
|
|
43
|
+
"",
|
|
44
|
+
(text or "").strip(),
|
|
45
|
+
flags=re.IGNORECASE,
|
|
46
|
+
).strip()
|
|
47
|
+
return cleaned.lstrip(",. ").strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _first_contact_intro(clinic: Dict[str, Any], agent_name: str = "Conny") -> str:
|
|
51
|
+
clinic_name = (clinic.get("name") or "").strip()
|
|
52
|
+
if clinic_name:
|
|
53
|
+
return f"Hola! Soy {agent_name} de {clinic_name}."
|
|
54
|
+
return f"Hola! Soy {agent_name}."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _first_contact_welcome_line(clinic: Dict[str, Any], user_msg: str) -> str:
|
|
58
|
+
clinic_name = (clinic.get("name") or "").strip()
|
|
59
|
+
normalized = _normalize_conv_text(user_msg or "")
|
|
60
|
+
if "buenas tardes" in normalized:
|
|
61
|
+
opening = "Hola, buenas tardes"
|
|
62
|
+
elif "buenos dias" in normalized:
|
|
63
|
+
opening = "Hola, buenos días"
|
|
64
|
+
elif "buenas noches" in normalized:
|
|
65
|
+
opening = "Hola, buenas noches"
|
|
66
|
+
elif "buenas" in normalized:
|
|
67
|
+
opening = "Hola, buenas"
|
|
68
|
+
else:
|
|
69
|
+
opening = "Hola"
|
|
70
|
+
|
|
71
|
+
if clinic_name:
|
|
72
|
+
return f"{opening}! Bienvenido a {clinic_name}."
|
|
73
|
+
return f"{opening}! Cómo estás?"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _first_contact_identity_line(clinic: Dict[str, Any], agent_name: str = "Conny") -> str:
|
|
77
|
+
clinic_name = (clinic.get("name") or "").strip()
|
|
78
|
+
if clinic_name:
|
|
79
|
+
return f"Soy {agent_name}, me encargo de la recepción en {clinic_name}."
|
|
80
|
+
return f"Soy {agent_name}, estoy aquí para ayudarte."
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _first_contact_question_line() -> str:
|
|
84
|
+
return "Cuéntame, qué necesitas?"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _first_contact_followup(clinic: Dict[str, Any]) -> str:
|
|
88
|
+
services = clinic.get("services") if isinstance(clinic.get("services"), list) else []
|
|
89
|
+
if services:
|
|
90
|
+
lead_services = ", ".join(str(service).strip() for service in services[:3] if str(service).strip())
|
|
91
|
+
if lead_services:
|
|
92
|
+
return (
|
|
93
|
+
f"Te puedo ayudar con citas, precios o info de {lead_services}. "
|
|
94
|
+
"Qué te gustaría saber?"
|
|
95
|
+
)
|
|
96
|
+
return "Te puedo ayudar con citas, horarios o lo que necesites. Qué tienes en mente?"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _clean_first_contact_part(text: str) -> str:
|
|
100
|
+
part = _strip_leading_greeting(text)
|
|
101
|
+
part = re.sub(
|
|
102
|
+
r"^(conny\s+por\s+ac[aá]\s*,?\s*del\s+equipo\s+de\s+[^.?!]+[.?!]?\s*)",
|
|
103
|
+
"",
|
|
104
|
+
part,
|
|
105
|
+
flags=re.IGNORECASE,
|
|
106
|
+
).strip()
|
|
107
|
+
part = re.sub(
|
|
108
|
+
r"^(soy\s+conny(?:,\s*(?:tu|la)\s+(?:asistente|asesora)\s+virtual)?(?:\s+de\s+[^,.?!]+)?)[,!. ]*",
|
|
109
|
+
"",
|
|
110
|
+
part,
|
|
111
|
+
flags=re.IGNORECASE,
|
|
112
|
+
).strip()
|
|
113
|
+
part = re.sub(
|
|
114
|
+
r"^(te\s+habla\s+conny(?:,\s*(?:tu|la)\s+(?:asistente|asesora)\s+virtual)?(?:\s+de\s+[^,.?!]+)?)[,!. ]*",
|
|
115
|
+
"",
|
|
116
|
+
part,
|
|
117
|
+
flags=re.IGNORECASE,
|
|
118
|
+
).strip()
|
|
119
|
+
return part
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_ADMIN_CONVERSATION_ORDINALS = {
|
|
123
|
+
"1": 1,
|
|
124
|
+
"uno": 1,
|
|
125
|
+
"primero": 1,
|
|
126
|
+
"primera": 1,
|
|
127
|
+
"2": 2,
|
|
128
|
+
"dos": 2,
|
|
129
|
+
"segundo": 2,
|
|
130
|
+
"segunda": 2,
|
|
131
|
+
"3": 3,
|
|
132
|
+
"tres": 3,
|
|
133
|
+
"tercero": 3,
|
|
134
|
+
"tercera": 3,
|
|
135
|
+
"4": 4,
|
|
136
|
+
"cuatro": 4,
|
|
137
|
+
"cuarto": 4,
|
|
138
|
+
"cuarta": 4,
|
|
139
|
+
"5": 5,
|
|
140
|
+
"cinco": 5,
|
|
141
|
+
"quinto": 5,
|
|
142
|
+
"quinta": 5,
|
|
143
|
+
"6": 6,
|
|
144
|
+
"seis": 6,
|
|
145
|
+
"sexto": 6,
|
|
146
|
+
"sexta": 6,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _wants_recent_conversation_browser(text: str) -> bool:
|
|
151
|
+
normalized = _normalize_conv_text(text)
|
|
152
|
+
if not normalized:
|
|
153
|
+
return False
|
|
154
|
+
has_recent = any(
|
|
155
|
+
token in normalized
|
|
156
|
+
for token in ("ultimas", "ultimos", "recientes", "conversaciones", "chats", "mensajes")
|
|
157
|
+
)
|
|
158
|
+
has_subject = any(
|
|
159
|
+
token in normalized
|
|
160
|
+
for token in ("convers", "chat", "paciente", "persona", "cliente")
|
|
161
|
+
)
|
|
162
|
+
return has_recent and has_subject
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _extract_conversation_selection(text: str) -> Optional[int]:
|
|
166
|
+
normalized = _normalize_conv_text(text)
|
|
167
|
+
if not normalized:
|
|
168
|
+
return None
|
|
169
|
+
match = re.search(r"\b(?:ver|chat|conversacion|conversación)\s+(\d{1,2})\b", normalized)
|
|
170
|
+
if match:
|
|
171
|
+
return int(match.group(1))
|
|
172
|
+
match = re.search(r"\b(?:conversacion|conversación|chat)\s+numero\s+(\d{1,2})\b", normalized)
|
|
173
|
+
if match:
|
|
174
|
+
return int(match.group(1))
|
|
175
|
+
for token, idx in _ADMIN_CONVERSATION_ORDINALS.items():
|
|
176
|
+
if re.search(
|
|
177
|
+
rf"\b(?:ver|mostrar|muestrame|muéstrame|ensename|enséñame|conversacion|conversación|chat)?\s*{re.escape(token)}\b",
|
|
178
|
+
normalized,
|
|
179
|
+
):
|
|
180
|
+
return idx
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _wants_all_messages(text: str) -> bool:
|
|
185
|
+
normalized = _normalize_conv_text(text)
|
|
186
|
+
return any(
|
|
187
|
+
phrase in normalized
|
|
188
|
+
for phrase in (
|
|
189
|
+
"ver todo",
|
|
190
|
+
"todos los mensajes",
|
|
191
|
+
"toda la conversacion",
|
|
192
|
+
"toda la conversación",
|
|
193
|
+
"completa",
|
|
194
|
+
"completo",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _is_low_quality_first_contact_part(
|
|
200
|
+
text: str,
|
|
201
|
+
*,
|
|
202
|
+
is_fragmented: Optional[Callable[[str], bool]] = None,
|
|
203
|
+
) -> bool:
|
|
204
|
+
current = (text or "").strip()
|
|
205
|
+
if not current:
|
|
206
|
+
return True
|
|
207
|
+
normalized = _normalize_conv_text(current)
|
|
208
|
+
if not normalized:
|
|
209
|
+
return True
|
|
210
|
+
if is_fragmented and is_fragmented(current):
|
|
211
|
+
return True
|
|
212
|
+
if len(normalized.split()) <= 2:
|
|
213
|
+
return True
|
|
214
|
+
if any(
|
|
215
|
+
marker in normalized
|
|
216
|
+
for marker in (
|
|
217
|
+
"soy conny",
|
|
218
|
+
"te habla conny",
|
|
219
|
+
"asistente virtual",
|
|
220
|
+
"asesora virtual",
|
|
221
|
+
"recepcionista virtual",
|
|
222
|
+
)
|
|
223
|
+
):
|
|
224
|
+
return True
|
|
225
|
+
return normalized in {"hola", "hoy", "tu hoy", "soy conny tu hoy"}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _normalize_first_contact_response(
|
|
229
|
+
response: str,
|
|
230
|
+
clinic: Dict[str, Any],
|
|
231
|
+
user_msg: str,
|
|
232
|
+
agent_name: str = "Conny",
|
|
233
|
+
*,
|
|
234
|
+
is_fragmented: Optional[Callable[[str], bool]] = None,
|
|
235
|
+
) -> str:
|
|
236
|
+
intro = _first_contact_intro(clinic, agent_name=agent_name)
|
|
237
|
+
parts = [part.strip() for part in (response or "").split("|||") if part.strip()]
|
|
238
|
+
parts = [_clean_first_contact_part(part) for part in parts]
|
|
239
|
+
parts = [part for part in parts if part]
|
|
240
|
+
intro_norm = _normalize_conv_text(intro)
|
|
241
|
+
parts = [part for part in parts if _normalize_conv_text(part) != intro_norm]
|
|
242
|
+
|
|
243
|
+
if _is_greeting_only(user_msg):
|
|
244
|
+
followup = next(
|
|
245
|
+
(
|
|
246
|
+
part
|
|
247
|
+
for part in parts
|
|
248
|
+
if not _is_low_quality_first_contact_part(part, is_fragmented=is_fragmented)
|
|
249
|
+
),
|
|
250
|
+
"",
|
|
251
|
+
)
|
|
252
|
+
if not followup:
|
|
253
|
+
followup = _first_contact_followup(clinic)
|
|
254
|
+
return " ||| ".join([intro, followup][:2])
|
|
255
|
+
|
|
256
|
+
if parts and "soy conny" in _normalize_conv_text(parts[0]) and not _is_low_quality_first_contact_part(
|
|
257
|
+
parts[0],
|
|
258
|
+
is_fragmented=is_fragmented,
|
|
259
|
+
):
|
|
260
|
+
return " ||| ".join(parts[:3])
|
|
261
|
+
|
|
262
|
+
filtered_parts = [
|
|
263
|
+
part
|
|
264
|
+
for part in parts
|
|
265
|
+
if not _is_low_quality_first_contact_part(part, is_fragmented=is_fragmented)
|
|
266
|
+
]
|
|
267
|
+
if not filtered_parts:
|
|
268
|
+
filtered_parts = [_first_contact_followup(clinic)]
|
|
269
|
+
return " ||| ".join(([intro] + filtered_parts)[:3])
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = [
|
|
273
|
+
"_clean_first_contact_part",
|
|
274
|
+
"_extract_conversation_selection",
|
|
275
|
+
"_first_contact_followup",
|
|
276
|
+
"_first_contact_identity_line",
|
|
277
|
+
"_first_contact_intro",
|
|
278
|
+
"_first_contact_question_line",
|
|
279
|
+
"_first_contact_welcome_line",
|
|
280
|
+
"_is_greeting_only",
|
|
281
|
+
"_is_low_quality_first_contact_part",
|
|
282
|
+
"_normalize_conv_text",
|
|
283
|
+
"_normalize_first_contact_response",
|
|
284
|
+
"_strip_leading_greeting",
|
|
285
|
+
"_wants_all_messages",
|
|
286
|
+
"_wants_recent_conversation_browser",
|
|
287
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import yaml
|
|
9
|
+
except Exception: # pragma: no cover - fallback for environments without PyYAML
|
|
10
|
+
yaml = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PersonaProfile:
|
|
15
|
+
key: str
|
|
16
|
+
identity: str
|
|
17
|
+
opening_style: str = "natural"
|
|
18
|
+
capabilities: List[str] = field(default_factory=list)
|
|
19
|
+
first_turn_variants: List[str] = field(default_factory=list)
|
|
20
|
+
identity_probe_variants: List[str] = field(default_factory=list)
|
|
21
|
+
contextual_followups: Dict[str, str] = field(default_factory=dict)
|
|
22
|
+
question_style: str = "natural"
|
|
23
|
+
sales_style: str = "natural"
|
|
24
|
+
objection_style: str = "natural"
|
|
25
|
+
followup_style: str = "natural"
|
|
26
|
+
humor_policy: str = "light"
|
|
27
|
+
warmth_range: List[float] = field(default_factory=lambda: [0.45, 0.8])
|
|
28
|
+
forbidden_patterns: List[str] = field(default_factory=list)
|
|
29
|
+
channel_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
30
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PersonaRegistry:
|
|
34
|
+
def __init__(self, root_dir: str | Path):
|
|
35
|
+
self.root_dir = Path(root_dir)
|
|
36
|
+
self._cache: Dict[str, PersonaProfile] = {}
|
|
37
|
+
self._files: Dict[str, Path] = {}
|
|
38
|
+
self._load()
|
|
39
|
+
|
|
40
|
+
def _load(self) -> None:
|
|
41
|
+
if not self.root_dir.exists():
|
|
42
|
+
return
|
|
43
|
+
for path in sorted(self.root_dir.glob("*.yaml")):
|
|
44
|
+
profile = self._load_file(path)
|
|
45
|
+
if profile:
|
|
46
|
+
self._cache[profile.key] = profile
|
|
47
|
+
self._files[profile.key] = path
|
|
48
|
+
|
|
49
|
+
def _load_file(self, path: Path) -> Optional[PersonaProfile]:
|
|
50
|
+
data = self._read_yaml(path)
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
return None
|
|
53
|
+
key = str(data.get("key") or path.stem).strip()
|
|
54
|
+
if not key:
|
|
55
|
+
return None
|
|
56
|
+
identity = str(data.get("identity") or "Conny").strip()
|
|
57
|
+
return PersonaProfile(
|
|
58
|
+
key=key,
|
|
59
|
+
identity=identity,
|
|
60
|
+
opening_style=str(data.get("opening_style") or "natural"),
|
|
61
|
+
capabilities=self._as_list(data.get("capabilities")),
|
|
62
|
+
first_turn_variants=self._as_list(data.get("first_turn_variants")),
|
|
63
|
+
identity_probe_variants=self._as_list(data.get("identity_probe_variants")),
|
|
64
|
+
contextual_followups=self._as_dict(data.get("contextual_followups")),
|
|
65
|
+
question_style=str(data.get("question_style") or "natural"),
|
|
66
|
+
sales_style=str(data.get("sales_style") or "natural"),
|
|
67
|
+
objection_style=str(data.get("objection_style") or "natural"),
|
|
68
|
+
followup_style=str(data.get("followup_style") or "natural"),
|
|
69
|
+
humor_policy=str(data.get("humor_policy") or "light"),
|
|
70
|
+
warmth_range=self._as_float_list(data.get("warmth_range"), default=[0.45, 0.8]),
|
|
71
|
+
forbidden_patterns=self._as_list(data.get("forbidden_patterns")),
|
|
72
|
+
channel_overrides=self._as_dict(data.get("channel_overrides")),
|
|
73
|
+
raw=data,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _read_yaml(self, path: Path) -> Dict[str, Any]:
|
|
77
|
+
text = path.read_text(encoding="utf-8")
|
|
78
|
+
if yaml is not None:
|
|
79
|
+
loaded = yaml.safe_load(text)
|
|
80
|
+
return loaded or {}
|
|
81
|
+
return self._fallback_parse(text)
|
|
82
|
+
|
|
83
|
+
def _fallback_parse(self, text: str) -> Dict[str, Any]:
|
|
84
|
+
data: Dict[str, Any] = {}
|
|
85
|
+
current_key = None
|
|
86
|
+
list_key = None
|
|
87
|
+
dict_key = None
|
|
88
|
+
for raw_line in text.splitlines():
|
|
89
|
+
line = raw_line.rstrip()
|
|
90
|
+
stripped = line.strip()
|
|
91
|
+
if not stripped or stripped.startswith("#"):
|
|
92
|
+
continue
|
|
93
|
+
if stripped.startswith("- ") and list_key:
|
|
94
|
+
data.setdefault(list_key, []).append(stripped[2:].strip().strip('"'))
|
|
95
|
+
continue
|
|
96
|
+
if ":" in stripped:
|
|
97
|
+
key, value = stripped.split(":", 1)
|
|
98
|
+
key = key.strip()
|
|
99
|
+
value = value.strip()
|
|
100
|
+
current_key = key
|
|
101
|
+
list_key = None
|
|
102
|
+
dict_key = None
|
|
103
|
+
if not value:
|
|
104
|
+
data[key] = []
|
|
105
|
+
list_key = key
|
|
106
|
+
else:
|
|
107
|
+
data[key] = value.strip().strip('"')
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
def _as_list(self, value: Any) -> List[str]:
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
113
|
+
if value is None:
|
|
114
|
+
return []
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
raw = value.strip()
|
|
117
|
+
return [raw] if raw else []
|
|
118
|
+
return [str(value).strip()]
|
|
119
|
+
|
|
120
|
+
def _as_dict(self, value: Any) -> Dict[str, Any]:
|
|
121
|
+
if isinstance(value, dict):
|
|
122
|
+
return dict(value)
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
def _as_float_list(self, value: Any, default: List[float]) -> List[float]:
|
|
126
|
+
if isinstance(value, list) and len(value) >= 2:
|
|
127
|
+
try:
|
|
128
|
+
return [float(value[0]), float(value[1])]
|
|
129
|
+
except Exception:
|
|
130
|
+
return default
|
|
131
|
+
return default
|
|
132
|
+
|
|
133
|
+
def list_keys(self) -> List[str]:
|
|
134
|
+
return sorted(self._cache.keys())
|
|
135
|
+
|
|
136
|
+
def get(self, key: str) -> Optional[PersonaProfile]:
|
|
137
|
+
if not key:
|
|
138
|
+
return None
|
|
139
|
+
normalized = key.strip()
|
|
140
|
+
if normalized in self._cache:
|
|
141
|
+
return self._cache[normalized]
|
|
142
|
+
return self._cache.get("default")
|
|
143
|
+
|
|
144
|
+
def resolve_for_clinic(self, clinic: Dict[str, Any]) -> PersonaProfile:
|
|
145
|
+
if not self._cache:
|
|
146
|
+
return PersonaProfile(key="default", identity="Conny")
|
|
147
|
+
for candidate in (
|
|
148
|
+
clinic.get("persona_key"),
|
|
149
|
+
clinic.get("sector"),
|
|
150
|
+
clinic.get("style_key"),
|
|
151
|
+
clinic.get("channel"),
|
|
152
|
+
):
|
|
153
|
+
profile = self.get(str(candidate or "").strip())
|
|
154
|
+
if profile:
|
|
155
|
+
return profile
|
|
156
|
+
return self._cache.get("default") or next(iter(self._cache.values()))
|
|
157
|
+
|