@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.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. 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
+