@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
package/run.sh ADDED
@@ -0,0 +1,47 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ CONNY_HOME="${CONNY_HOME:-$HOME/.conny}"
6
+ ENV_FILE="$SCRIPT_DIR/.env"
7
+
8
+ read_env_value() {
9
+ local key="$1"
10
+ if [ -f "$ENV_FILE" ]; then
11
+ grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d'=' -f2- | sed 's/^"//;s/"$//;s/^'\''//;s/'\''$//'
12
+ fi
13
+ }
14
+
15
+ PYTHON_OVERRIDE="${CONNY_PYTHON_BIN:-${PYTHON_BIN:-$(read_env_value CONNY_PYTHON_BIN)}}"
16
+ if [ -z "$PYTHON_OVERRIDE" ]; then
17
+ PYTHON_OVERRIDE="$(read_env_value PYTHON_BIN)"
18
+ fi
19
+
20
+ pick_python() {
21
+ local candidates=(
22
+ "$PYTHON_OVERRIDE"
23
+ "$SCRIPT_DIR/.venv/bin/python"
24
+ "$SCRIPT_DIR/.venv/bin/python3"
25
+ "$CONNY_HOME/runtime/bin/python"
26
+ "$CONNY_HOME/runtime/bin/python3"
27
+ "$(command -v python3 2>/dev/null || true)"
28
+ "$(command -v python 2>/dev/null || true)"
29
+ )
30
+ local candidate=""
31
+ for candidate in "${candidates[@]}"; do
32
+ if [ -n "$candidate" ] && [ -x "$candidate" ]; then
33
+ printf '%s\n' "$candidate"
34
+ return 0
35
+ fi
36
+ done
37
+ return 1
38
+ }
39
+
40
+ PYTHON_BIN_SELECTED="$(pick_python || true)"
41
+ if [ -z "$PYTHON_BIN_SELECTED" ]; then
42
+ echo "[conny] No encontré un intérprete Python ejecutable para esta instancia." >&2
43
+ exit 1
44
+ fi
45
+
46
+ cd "$SCRIPT_DIR"
47
+ exec "$PYTHON_BIN_SELECTED" "$SCRIPT_DIR/conny.py"
package/search.py ADDED
@@ -0,0 +1,465 @@
1
+ """
2
+ search.py — Motor de busqueda para Conny v2
3
+ SerpAPI (primario) → Brave Search (secundario) → Apify (fallback de emergencia)
4
+
5
+ Incluye:
6
+ - Cache en memoria con TTL configurable
7
+ - Busqueda medica/estetica especializada con answer_box de Google
8
+ - Deteccion automatica de procedimientos y edad en el texto
9
+ - Autodiscovery de clinicas
10
+ - SearchEngine compatible con conny.py
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import time
22
+ from typing import Dict, List, Optional, Tuple
23
+
24
+ import httpx
25
+
26
+ log = logging.getLogger("conny.search")
27
+
28
+
29
+ def _split_env_values(raw) -> List[str]:
30
+ if not raw:
31
+ return []
32
+ if isinstance(raw, (list, tuple, set)):
33
+ values = [str(x).strip() for x in raw if str(x).strip()]
34
+ else:
35
+ text = str(raw).strip()
36
+ if not text:
37
+ return []
38
+ parsed = None
39
+ if text.startswith("["):
40
+ try:
41
+ parsed = json.loads(text)
42
+ except Exception:
43
+ parsed = None
44
+ if isinstance(parsed, list):
45
+ values = [str(x).strip() for x in parsed if str(x).strip()]
46
+ else:
47
+ values = [part.strip() for part in re.split(r"[\n,]+", text) if part.strip()]
48
+ deduped: List[str] = []
49
+ seen = set()
50
+ for value in values:
51
+ if value not in seen:
52
+ deduped.append(value)
53
+ seen.add(value)
54
+ return deduped
55
+
56
+
57
+ def _collect_env_series(base_name: str) -> List[str]:
58
+ pattern = re.compile(rf"^{re.escape(base_name)}(?:_(\d+))?$")
59
+ ranked: List[Tuple[int, str]] = []
60
+ for env_name, env_value in os.environ.items():
61
+ match = pattern.match(env_name)
62
+ if not match:
63
+ continue
64
+ rank = int(match.group(1) or 1)
65
+ for value in _split_env_values(env_value):
66
+ ranked.append((rank, value))
67
+ ranked.sort(key=lambda item: item[0])
68
+ ordered = [value for _, value in ranked]
69
+ ordered.extend(_split_env_values(os.getenv(f"{base_name}S", "")))
70
+ ordered.extend(_split_env_values(os.getenv(f"{base_name}_LIST", "")))
71
+ deduped: List[str] = []
72
+ seen = set()
73
+ for value in ordered:
74
+ if value and value not in seen:
75
+ deduped.append(value)
76
+ seen.add(value)
77
+ return deduped
78
+
79
+ # ─── API Keys ────────────────────────────────────────────────────────────────
80
+ SERP_API_KEY = os.getenv("SERP_API_KEY", "")
81
+ BRAVE_API_KEY = os.getenv("BRAVE_API_KEY", "")
82
+ APIFY_API_KEY = os.getenv("APIFY_API_KEY", "")
83
+ APIFY_API_KEYS = _collect_env_series("APIFY_API_KEY")
84
+
85
+ # ─── Cache en memoria ────────────────────────────────────────────────────────
86
+ _search_cache: Dict[str, Tuple[str, float]] = {}
87
+ _CACHE_TTL = 1800 # 30 min
88
+
89
+
90
+ def _cache_key(query: str) -> str:
91
+ return hashlib.md5(query.lower().strip().encode()).hexdigest()[:16]
92
+
93
+
94
+ def _cache_get(query: str) -> Optional[str]:
95
+ k = _cache_key(query)
96
+ if k in _search_cache:
97
+ result, ts = _search_cache[k]
98
+ if time.time() - ts < _CACHE_TTL:
99
+ log.debug(f"[cache hit] {query[:50]}")
100
+ return result
101
+ return None
102
+
103
+
104
+ def _cache_set(query: str, result: str):
105
+ k = _cache_key(query)
106
+ _search_cache[k] = (result, time.time())
107
+ if len(_search_cache) > 500:
108
+ oldest = sorted(_search_cache.items(), key=lambda x: x[1][1])[:100]
109
+ for key, _ in oldest:
110
+ del _search_cache[key]
111
+
112
+
113
+ # ─── SerpAPI (primario) ──────────────────────────────────────────────────────
114
+
115
+ async def serp_search(query: str, count: int = 5,
116
+ location: str = "Medellin, Colombia") -> List[Dict]:
117
+ """
118
+ Busqueda via SerpAPI (Google results reales).
119
+ Captura answer_box y knowledge_graph ademas de resultados organicos.
120
+ """
121
+ if not SERP_API_KEY:
122
+ return []
123
+ try:
124
+ async with httpx.AsyncClient(timeout=12.0) as client:
125
+ r = await client.get(
126
+ "https://serpapi.com/search",
127
+ params={
128
+ "engine": "google",
129
+ "q": query,
130
+ "api_key": SERP_API_KEY,
131
+ "hl": "es",
132
+ "gl": "co",
133
+ "location": location,
134
+ "num": count,
135
+ "safe": "active",
136
+ },
137
+ )
138
+ r.raise_for_status()
139
+ data = r.json()
140
+
141
+ results = []
142
+
143
+ # Answer box — respuesta directa de Google (mejor fuente para preguntas medicas)
144
+ ab = data.get("answer_box", {})
145
+ if ab:
146
+ snippet = (
147
+ ab.get("answer") or
148
+ ab.get("snippet") or
149
+ ab.get("result") or ""
150
+ )
151
+ if snippet:
152
+ results.append({
153
+ "title": ab.get("title", "Respuesta directa"),
154
+ "url": ab.get("link", ""),
155
+ "description": snippet.strip()[:700],
156
+ "source": "answer_box",
157
+ })
158
+
159
+ # Knowledge graph — para procedimientos/tratamientos reconocidos
160
+ kg = data.get("knowledge_graph", {})
161
+ if kg and kg.get("description"):
162
+ results.append({
163
+ "title": kg.get("title", ""),
164
+ "url": kg.get("website", ""),
165
+ "description": kg["description"][:500],
166
+ "source": "knowledge_graph",
167
+ })
168
+
169
+ # Resultados organicos
170
+ for res in data.get("organic_results", [])[:count]:
171
+ desc = res.get("snippet", "").strip()
172
+ if desc:
173
+ results.append({
174
+ "title": res.get("title", ""),
175
+ "url": res.get("link", ""),
176
+ "description": desc[:400],
177
+ "source": "organic",
178
+ })
179
+
180
+ log.info(f"[serp] {len(results)} resultados — {query[:60]}")
181
+ return results
182
+
183
+ except Exception as e:
184
+ log.warning(f"[serp] error: {e}")
185
+ return []
186
+
187
+
188
+ # ─── Brave Search (secundario) ───────────────────────────────────────────────
189
+
190
+ async def brave_search(query: str, count: int = 5) -> List[Dict]:
191
+ if not BRAVE_API_KEY:
192
+ return []
193
+ try:
194
+ async with httpx.AsyncClient(timeout=10.0) as client:
195
+ r = await client.get(
196
+ "https://api.search.brave.com/res/v1/web/search",
197
+ headers={
198
+ "Accept": "application/json",
199
+ "Accept-Encoding": "gzip",
200
+ "X-Subscription-Token": BRAVE_API_KEY,
201
+ },
202
+ params={"q": query, "count": count, "search_lang": "es", "country": "ALL"},
203
+ )
204
+ r.raise_for_status()
205
+ return [
206
+ {"title": res.get("title", ""), "url": res.get("url", ""),
207
+ "description": res.get("description", ""), "source": "brave"}
208
+ for res in r.json().get("web", {}).get("results", [])
209
+ if res.get("description")
210
+ ]
211
+ except Exception as e:
212
+ log.warning(f"[brave] error: {e}")
213
+ return []
214
+
215
+
216
+ # ─── Apify (fallback de emergencia) ─────────────────────────────────────────
217
+
218
+ async def apify_search(query: str, count: int = 5) -> List[Dict]:
219
+ api_keys = APIFY_API_KEYS or ([APIFY_API_KEY] if APIFY_API_KEY else [])
220
+ if not api_keys:
221
+ return []
222
+ for api_key in api_keys:
223
+ try:
224
+ async with httpx.AsyncClient(timeout=25.0) as client:
225
+ r = await client.post(
226
+ "https://api.apify.com/v2/acts/apify~google-search-scraper/run-sync-get-dataset-items",
227
+ headers={"Authorization": f"Bearer {api_key}"},
228
+ json={"queries": query, "maxPagesPerQuery": 1,
229
+ "resultsPerPage": count, "languageCode": "es", "countryCode": "co"},
230
+ params={"timeout": 20, "memory": 256},
231
+ )
232
+ r.raise_for_status()
233
+ items = r.json()
234
+ if not items or not isinstance(items, list):
235
+ continue
236
+ results = []
237
+ for item in items[:1]:
238
+ for res in item.get("organicResults", [])[:count]:
239
+ if res.get("description"):
240
+ results.append({"title": res.get("title", ""),
241
+ "url": res.get("url", ""),
242
+ "description": res["description"],
243
+ "source": "apify"})
244
+ if results:
245
+ return results
246
+ except Exception as e:
247
+ log.warning(f"[apify] error: {e}")
248
+ continue
249
+ return []
250
+
251
+
252
+ # ─── Motor unificado con fallback automatico ─────────────────────────────────
253
+
254
+ async def web_search(query: str, count: int = 5) -> List[Dict]:
255
+ """SerpAPI → Brave → Apify. Retorna lista de resultados."""
256
+ results = await serp_search(query, count=count)
257
+ if results:
258
+ return results
259
+ log.info(f"[search] SerpAPI vacio, probando Brave")
260
+ results = await brave_search(query, count=count)
261
+ if results:
262
+ return results
263
+ log.info(f"[search] Brave vacio, probando Apify")
264
+ return await apify_search(query, count=count)
265
+
266
+
267
+ async def search_context(query: str, context: str = "", count: int = 5,
268
+ max_chars: int = 1400) -> str:
269
+ """
270
+ Busqueda y construccion de contexto para el LLM.
271
+ Prioriza answer_box (respuestas directas de Google) sobre resultados organicos.
272
+ """
273
+ cache_key = f"{context}|{query}"
274
+ cached = _cache_get(cache_key)
275
+ if cached is not None:
276
+ return cached
277
+
278
+ full_query = f"{context} {query}".strip() if context else query
279
+ results = await web_search(full_query, count=count)
280
+ if not results:
281
+ _cache_set(cache_key, "")
282
+ return ""
283
+
284
+ # Ordenar: answer_box primero (es la respuesta directa de Google)
285
+ priority = {"answer_box": 0, "knowledge_graph": 1, "organic": 2, "brave": 3, "apify": 4}
286
+ results.sort(key=lambda r: priority.get(r.get("source", "organic"), 5))
287
+
288
+ lines = []
289
+ for r in results:
290
+ desc = r.get("description", "").strip()
291
+ title = r.get("title", "").strip()
292
+ if desc:
293
+ lines.append(f"• {title}: {desc}" if title else f"• {desc}")
294
+
295
+ result_str = "\n".join(lines)[:max_chars]
296
+ _cache_set(cache_key, result_str)
297
+ return result_str
298
+
299
+
300
+ # ─── Busqueda medica/estetica especializada ───────────────────────────────────
301
+
302
+ # Aliases de procedimientos para construir queries ricas
303
+ PROCEDURE_QUERIES: Dict[str, str] = {
304
+ "botox": "toxina botulinica botox beneficios edad resultados duracion quien puede",
305
+ "bichectomia": "bichectomia extraccion bolas bichat resultados recuperacion candidatos",
306
+ "rellenos": "rellenos acido hialuronico labios mejillas volumizacion resultados duracion",
307
+ "hilos": "hilos tensores lifting facial resultados contraindicaciones edad candidatos",
308
+ "prp": "plasma rico plaquetas PRP piel facial rejuvenecimiento beneficios",
309
+ "laser": "laser rejuvenecimiento piel tipos sesiones resultados contraindicaciones",
310
+ "ipl": "luz pulsada intensa IPL manchas piel rosácea tratamiento sesiones",
311
+ "radiofrecuencia": "radiofrecuencia facial corporal colageno flacidez resultados sesiones",
312
+ "ultrasonido": "HIFU ultrasonido focalizado lifting sin cirugia resultados candidatos",
313
+ "lipolisis": "lipolisis inyectable grasa localizada papada abdomen resultados",
314
+ "cavitacion": "cavitacion ultrasonido adipocitos celulitis resultados sesiones",
315
+ "limpieza": "limpieza facial profunda tipos piel combinada grasa seca beneficios pasos",
316
+ "microdermoabrasion": "microdermoabrasion cristales diamante cicatrices manchas poros beneficios",
317
+ "peelings": "peeling quimico acidos TCA AHA BHA tipos piel recuperacion resultados",
318
+ "dermapen": "dermapen microagujas colágeno elastina cicatrices poros resultados sesiones",
319
+ "mesoterapia": "mesoterapia capilar caida cabello vitaminas resultados sesiones",
320
+ "presoterapia": "presoterapia drenaje linfático retención líquidos celulitis beneficios",
321
+ }
322
+
323
+ # Keywords para detectar procedimientos en texto del paciente
324
+ PROCEDURE_KEYWORDS: Dict[str, List[str]] = {
325
+ "botox": ["botox", "toxina", "botulinica", "bótox"],
326
+ "bichectomia": ["bichectomia", "bichat", "bolas de grasa", "mejillas grandes"],
327
+ "rellenos": ["relleno", "hialuronico", "labio", "pomulo", "mejilla", "ácido"],
328
+ "hilos": ["hilo", "lifting", "tensor"],
329
+ "prp": ["prp", "plasma", "plaqueta"],
330
+ "laser": ["laser", "láser"],
331
+ "ipl": ["ipl", "luz pulsada"],
332
+ "radiofrecuencia": ["radiofrecuencia", "rf facial", "rf corporal"],
333
+ "ultrasonido": ["hifu", "ultrasonido focalizado", "sin cirugia"],
334
+ "lipolisis": ["lipolisis", "lipólisis", "grasa localizada", "papada"],
335
+ "cavitacion": ["cavitacion", "cavitación", "celulitis"],
336
+ "limpieza": ["limpieza facial", "limpieza de piel", "limpiar la piel"],
337
+ "microdermoabrasion": ["microdermoabrasion", "dermabrasion"],
338
+ "peelings": ["peeling", "exfoliacion quimica", "acido en la cara"],
339
+ "dermapen": ["dermapen", "microagujas", "microaguja"],
340
+ "mesoterapia": ["mesoterapia", "coctel capilar", "caida de cabello"],
341
+ "presoterapia": ["presoterapia", "drenaje linfatico", "drenaje linfático"],
342
+ }
343
+
344
+
345
+ def detect_procedure(text: str) -> Optional[str]:
346
+ """Detecta si el texto menciona un procedimiento estetico. Retorna nombre canonico."""
347
+ text_lower = text.lower()
348
+ for procedure, keywords in PROCEDURE_KEYWORDS.items():
349
+ if any(kw in text_lower for kw in keywords):
350
+ return procedure
351
+ return None
352
+
353
+
354
+ def extract_age(text: str) -> Optional[int]:
355
+ """Extrae edad mencionada en el texto del paciente."""
356
+ m = re.search(r'\b(\d{2})\s*(años?|a[ñn]os?)', text.lower())
357
+ if m:
358
+ age = int(m.group(1))
359
+ if 15 <= age <= 90:
360
+ return age
361
+ return None
362
+
363
+
364
+ async def medical_search(procedure: str, question: str = "",
365
+ patient_age: Optional[int] = None,
366
+ clinic_services: Optional[List[str]] = None) -> str:
367
+ """
368
+ Busqueda medica especializada para un procedimiento especifico.
369
+ Construye queries ricas considerando edad del paciente y servicios de la clinica.
370
+ Retorna contexto rico para el LLM.
371
+ """
372
+ proc_lower = procedure.lower().strip()
373
+ base_query = PROCEDURE_QUERIES.get(proc_lower, f"{procedure} tratamiento estetico")
374
+
375
+ # Ajustar query segun edad
376
+ age_suffix = ""
377
+ if patient_age:
378
+ if patient_age >= 60:
379
+ age_suffix = "mayores 60 años contraindicaciones seguridad efectividad adultos mayores"
380
+ elif patient_age >= 50:
381
+ age_suffix = "50 años menopausia cambios hormonales beneficios anti-edad"
382
+ elif patient_age >= 40:
383
+ age_suffix = "40 años manchas flacidez rejuvenecimiento preventivo"
384
+ elif patient_age >= 30:
385
+ age_suffix = "30 años prevencion primeras lineas expresion"
386
+ else:
387
+ age_suffix = "jovenes 20 años preventivo acne cicatrices"
388
+
389
+ # Query principal
390
+ main_query = f"{base_query} {age_suffix} {question}".strip()
391
+
392
+ # Query de precios locales — siempre util para el paciente
393
+ price_query = f"{procedure} precio costo Medellin Colombia clinica estetica 2024"
394
+
395
+ # Buscar en paralelo para no perder tiempo
396
+ main_ctx, price_ctx = await asyncio.gather(
397
+ search_context(main_query, count=5, max_chars=1000),
398
+ search_context(price_query, count=3, max_chars=400),
399
+ )
400
+
401
+ parts = []
402
+ if main_ctx:
403
+ parts.append(f"INFORMACION DEL PROCEDIMIENTO:\n{main_ctx}")
404
+ if price_ctx:
405
+ parts.append(f"PRECIOS DE REFERENCIA EN COLOMBIA:\n{price_ctx}")
406
+
407
+ return "\n\n".join(parts) if parts else ""
408
+
409
+
410
+ # ─── Autodiscovery de clinica ────────────────────────────────────────────────
411
+
412
+ async def discover_clinic(clinic_name: str, city: str = "Medellin") -> str:
413
+ """
414
+ Busca info real de la clinica en Google y retorna contexto crudo.
415
+ """
416
+ queries = [
417
+ f"{clinic_name} {city} servicios precios horario telefono",
418
+ f'"{clinic_name}" clinica estetica {city}',
419
+ ]
420
+
421
+ snippets = []
422
+ for q in queries:
423
+ cached = _cache_get(q)
424
+ if cached is not None:
425
+ snippets.append(cached)
426
+ else:
427
+ results = await web_search(q, count=5)
428
+ if results:
429
+ block = "\n".join(
430
+ f"- {r['title']}: {r['description']}"
431
+ for r in results if r.get("description")
432
+ )[:800]
433
+ _cache_set(q, block)
434
+ snippets.append(block)
435
+
436
+ if snippets and len(snippets[0]) > 200:
437
+ break
438
+
439
+ return "\n".join(snippets)[:2000]
440
+
441
+
442
+ # ─── Clase compatible con conny.py ─────────────────────────────────────────
443
+
444
+ class SearchEngine:
445
+ """
446
+ Interfaz compatible con WebSearchEngine en conny.py.
447
+ Drop-in replacement con capacidades medicas adicionales.
448
+ """
449
+
450
+ async def search(self, query: str, context: str = "") -> str:
451
+ return await search_context(query, context=context)
452
+
453
+ async def discover(self, clinic_name: str, city: str = "Medellin") -> str:
454
+ return await discover_clinic(clinic_name, city)
455
+
456
+ async def medical(self, procedure: str, question: str = "",
457
+ patient_age: Optional[int] = None,
458
+ clinic_services: Optional[List[str]] = None) -> str:
459
+ return await medical_search(procedure, question, patient_age, clinic_services)
460
+
461
+ def detect_procedure(self, text: str) -> Optional[str]:
462
+ return detect_procedure(text)
463
+
464
+ def extract_age(self, text: str) -> Optional[int]:
465
+ return extract_age(text)