@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
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)
|