@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,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Módulo de generación LLM para Conny Ultra.
|
|
3
|
+
|
|
4
|
+
Contiene la lógica de generación de respuestas:
|
|
5
|
+
- _llm(): Generación básica
|
|
6
|
+
- _llm_conv_pitch(): Generación con pitch para prospectos confuse
|
|
7
|
+
- _llm_conv(): Generación conversacional con historial
|
|
8
|
+
- _demo_llm_conv_quality_chain(): Chain con validación y repair
|
|
9
|
+
- _demo_llm_quality_chain(): Chain de calidad con retries
|
|
10
|
+
|
|
11
|
+
Este módulo fue extraído de conny.py para reducir su tamaño y mejorar mantenibilidad.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from conny_config import Config
|
|
20
|
+
except ImportError:
|
|
21
|
+
class Config:
|
|
22
|
+
GROQ_API_KEY = ""
|
|
23
|
+
GEMINI_API_KEY = ""
|
|
24
|
+
GEMINI_API_KEY_2 = ""
|
|
25
|
+
GEMINI_API_KEY_3 = ""
|
|
26
|
+
OPENROUTER_API_KEY = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GeneratorManager:
|
|
30
|
+
"""
|
|
31
|
+
Gestor de generación LLM para Conny Ultra.
|
|
32
|
+
|
|
33
|
+
Proporciona métodos para:
|
|
34
|
+
- Generación básica de texto
|
|
35
|
+
- Generación conversacional con historial
|
|
36
|
+
- Generación con pitch especializado
|
|
37
|
+
- Chains de calidad con validación y retry
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, llm_engine=None, generator=None):
|
|
41
|
+
self._llm_engine = llm_engine
|
|
42
|
+
self._generator = generator
|
|
43
|
+
|
|
44
|
+
def _get_demo_engine(self, demo_model_pref: str = "auto", bmodel_key: str = ""):
|
|
45
|
+
"""
|
|
46
|
+
Devuelve el proveedor LLM según preferencia de sesión.
|
|
47
|
+
|
|
48
|
+
Formatos soportados:
|
|
49
|
+
auto → engine global
|
|
50
|
+
gemini → GeminiProvider con gemini-2.5-flash
|
|
51
|
+
gemini:gemini-2.5-pro → GeminiProvider con modelo específico
|
|
52
|
+
groq → GroqProvider con llama-3.3-70b-versatile
|
|
53
|
+
groq:llama-3.1-8b-instant → GroqProvider con modelo específico
|
|
54
|
+
openrouter → OpenRouterProvider
|
|
55
|
+
openrouter:anthropic/claude-sonnet-4 → OpenRouter con modelo específico
|
|
56
|
+
"""
|
|
57
|
+
from llm_engine import (
|
|
58
|
+
GeminiProvider, GroqProvider, OpenRouterProvider
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
pref = demo_model_pref
|
|
62
|
+
|
|
63
|
+
if ":" in pref:
|
|
64
|
+
provider, model_name = pref.split(":", 1)
|
|
65
|
+
else:
|
|
66
|
+
provider, model_name = pref, None
|
|
67
|
+
|
|
68
|
+
if provider == "groq" and Config.GROQ_API_KEY:
|
|
69
|
+
eng = GroqProvider(Config.GROQ_API_KEY)
|
|
70
|
+
if model_name:
|
|
71
|
+
eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
|
|
72
|
+
return eng
|
|
73
|
+
|
|
74
|
+
elif provider == "gemini":
|
|
75
|
+
key = Config.GEMINI_API_KEY or Config.GEMINI_API_KEY_2 or Config.GEMINI_API_KEY_3
|
|
76
|
+
if key:
|
|
77
|
+
eng = GeminiProvider(key, "gemini_demo")
|
|
78
|
+
if model_name:
|
|
79
|
+
eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
|
|
80
|
+
else:
|
|
81
|
+
eng.MDLS = {
|
|
82
|
+
"reasoning": "gemini-2.5-flash",
|
|
83
|
+
"fast": "gemini-2.5-flash",
|
|
84
|
+
"lite": "gemini-2.5-flash-lite"
|
|
85
|
+
}
|
|
86
|
+
return eng
|
|
87
|
+
if Config.OPENROUTER_API_KEY:
|
|
88
|
+
eng = OpenRouterProvider(Config.OPENROUTER_API_KEY)
|
|
89
|
+
m = model_name or "google/gemini-2.5-flash"
|
|
90
|
+
eng.MDLS = {"reasoning": m, "fast": m, "lite": m}
|
|
91
|
+
return eng
|
|
92
|
+
|
|
93
|
+
elif provider == "openrouter" and Config.OPENROUTER_API_KEY:
|
|
94
|
+
eng = OpenRouterProvider(Config.OPENROUTER_API_KEY)
|
|
95
|
+
if model_name:
|
|
96
|
+
eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
|
|
97
|
+
return eng
|
|
98
|
+
|
|
99
|
+
return self._llm_engine or (self._generator.llm if self._generator else None)
|
|
100
|
+
|
|
101
|
+
async def _llm(
|
|
102
|
+
self,
|
|
103
|
+
sys_p: str,
|
|
104
|
+
usr_p: str,
|
|
105
|
+
temp: float = 0.82,
|
|
106
|
+
max_t: int = 8192,
|
|
107
|
+
model_tier: str = "fast",
|
|
108
|
+
demo_model_pref: str = "auto"
|
|
109
|
+
) -> Optional[str]:
|
|
110
|
+
"""
|
|
111
|
+
Generación básica de texto con LLM.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
sys_p: Prompt del sistema
|
|
115
|
+
usr_p: Prompt del usuario
|
|
116
|
+
temp: Temperatura de generación
|
|
117
|
+
max_t: Máximo de tokens de salida
|
|
118
|
+
model_tier: Tier del modelo (fast, reasoning, lite)
|
|
119
|
+
demo_model_pref: Preferencia de modelo demo
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Texto generado o None en caso de error
|
|
123
|
+
"""
|
|
124
|
+
import logging
|
|
125
|
+
log = logging.getLogger("conny_generator")
|
|
126
|
+
|
|
127
|
+
msgs = [{"role": "system", "content": sys_p}, {"role": "user", "content": usr_p}]
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
eng = self._get_demo_engine(demo_model_pref)
|
|
131
|
+
if not eng:
|
|
132
|
+
raise RuntimeError("LLM no init")
|
|
133
|
+
|
|
134
|
+
r, meta = await eng.complete(
|
|
135
|
+
msgs,
|
|
136
|
+
model_tier=model_tier,
|
|
137
|
+
temperature=temp,
|
|
138
|
+
max_tokens=max_t,
|
|
139
|
+
use_cache=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
log.info(f"[demo] {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
|
|
143
|
+
|
|
144
|
+
if self._generator:
|
|
145
|
+
return self._generator._postprocess(r, self._generator.PersonalityProfile())
|
|
146
|
+
return r
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
log.error(f"[demo] llm error: {e}")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
async def _llm_conv_pitch(
|
|
153
|
+
self,
|
|
154
|
+
pitch_sys: str,
|
|
155
|
+
history: List[Dict[str, Any]],
|
|
156
|
+
text: str,
|
|
157
|
+
temp: float = 0.85,
|
|
158
|
+
max_t: int = 8192,
|
|
159
|
+
recent_limit: int = 12,
|
|
160
|
+
demo_model_pref: str = "auto"
|
|
161
|
+
) -> Optional[str]:
|
|
162
|
+
"""
|
|
163
|
+
LLM con el pitch de Black One para prospectos confundidos.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
pitch_sys: Prompt de sistema con el pitch
|
|
167
|
+
history: Historial de conversación
|
|
168
|
+
text: Mensaje actual
|
|
169
|
+
temp: Temperatura
|
|
170
|
+
max_t: Máximo de tokens
|
|
171
|
+
recent_limit: Límite de mensajes recientes a incluir
|
|
172
|
+
demo_model_pref: Preferencia de modelo
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Texto generado o None
|
|
176
|
+
"""
|
|
177
|
+
import logging
|
|
178
|
+
log = logging.getLogger("conny_generator")
|
|
179
|
+
|
|
180
|
+
msgs = [{"role": "system", "content": pitch_sys}]
|
|
181
|
+
for m in history[-recent_limit:]:
|
|
182
|
+
msgs.append({"role": m["role"], "content": m["content"]})
|
|
183
|
+
msgs.append({"role": "user", "content": text})
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
eng = self._get_demo_engine(demo_model_pref)
|
|
187
|
+
if not eng:
|
|
188
|
+
raise RuntimeError("LLM no init")
|
|
189
|
+
|
|
190
|
+
r, meta = await eng.complete(
|
|
191
|
+
msgs,
|
|
192
|
+
model_tier="fast",
|
|
193
|
+
temperature=temp,
|
|
194
|
+
max_tokens=max_t,
|
|
195
|
+
use_cache=False
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
log.info(f"[demo][pitch] {meta.get('provider','?')}")
|
|
199
|
+
|
|
200
|
+
if self._generator:
|
|
201
|
+
return self._generator._postprocess(r, self._generator.PersonalityProfile())
|
|
202
|
+
return r
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
log.error(f"[demo][pitch] error: {e}")
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
async def _llm_conv(
|
|
209
|
+
self,
|
|
210
|
+
sys_p: str,
|
|
211
|
+
history: List[Dict[str, Any]],
|
|
212
|
+
text: str,
|
|
213
|
+
temp: float = 0.85,
|
|
214
|
+
max_t: int = 8192,
|
|
215
|
+
model_tier: str = "fast",
|
|
216
|
+
recent_limit: int = 12,
|
|
217
|
+
demo_model_pref: str = "auto"
|
|
218
|
+
) -> Optional[str]:
|
|
219
|
+
"""
|
|
220
|
+
Generación conversacional con historial.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
sys_p: Prompt del sistema
|
|
224
|
+
history: Historial de conversación
|
|
225
|
+
text: Mensaje actual
|
|
226
|
+
temp: Temperatura
|
|
227
|
+
max_t: Máximo de tokens
|
|
228
|
+
model_tier: Tier del modelo
|
|
229
|
+
recent_limit: Límite de mensajes recientes
|
|
230
|
+
demo_model_pref: Preferencia de modelo
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Texto generado o None
|
|
234
|
+
"""
|
|
235
|
+
import logging
|
|
236
|
+
log = logging.getLogger("conny_generator")
|
|
237
|
+
|
|
238
|
+
msgs = [{"role": "system", "content": sys_p}]
|
|
239
|
+
for m in history[-recent_limit:]:
|
|
240
|
+
msgs.append({"role": m["role"], "content": m["content"]})
|
|
241
|
+
msgs.append({"role": "user", "content": text})
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
eng = self._get_demo_engine(demo_model_pref)
|
|
245
|
+
if not eng:
|
|
246
|
+
raise RuntimeError("LLM no init")
|
|
247
|
+
|
|
248
|
+
r, meta = await eng.complete(
|
|
249
|
+
msgs,
|
|
250
|
+
model_tier=model_tier,
|
|
251
|
+
temperature=temp,
|
|
252
|
+
max_tokens=max_t,
|
|
253
|
+
use_cache=False,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
log.info(f"[demo] {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
|
|
257
|
+
|
|
258
|
+
if self._generator:
|
|
259
|
+
return self._generator._postprocess(r, self._generator.PersonalityProfile())
|
|
260
|
+
return r
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
log.error(f"[demo] llm_conv error: {e}")
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
async def _demo_llm_conv_quality_chain(
|
|
267
|
+
self,
|
|
268
|
+
system_prompt: str,
|
|
269
|
+
validator: Callable[[str], bool],
|
|
270
|
+
repair_instructions: str,
|
|
271
|
+
history: List[Dict[str, Any]],
|
|
272
|
+
text: str,
|
|
273
|
+
temp: float = 0.72,
|
|
274
|
+
max_t: int = 8192,
|
|
275
|
+
model_tier: str = "fast",
|
|
276
|
+
recent_limit: int = 8,
|
|
277
|
+
demo_model_pref: str = "auto"
|
|
278
|
+
) -> Tuple[Optional[str], bool]:
|
|
279
|
+
"""
|
|
280
|
+
Chain de calidad con validación y repair para conversaciones demo.
|
|
281
|
+
|
|
282
|
+
Intenta primero la generación normal, si falla la validación
|
|
283
|
+
reintenta con instrucciones de repair.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
system_prompt: Prompt del sistema
|
|
287
|
+
validator: Función que valida la respuesta
|
|
288
|
+
repair_instructions: Instrucciones para repair si falla validación
|
|
289
|
+
history: Historial de conversación
|
|
290
|
+
text: Mensaje actual
|
|
291
|
+
temp: Temperatura
|
|
292
|
+
max_t: Máximo de tokens
|
|
293
|
+
model_tier: Tier del modelo
|
|
294
|
+
recent_limit: Límite de mensajes recientes
|
|
295
|
+
demo_model_pref: Preferencia de modelo
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Tuple (respuesta_valida o None, tuvo_output)
|
|
299
|
+
"""
|
|
300
|
+
import logging
|
|
301
|
+
log = logging.getLogger("conny_generator")
|
|
302
|
+
|
|
303
|
+
_chain_start = time.time()
|
|
304
|
+
_CHAIN_TIMEOUT_S = 45 # Timeout entre intentos
|
|
305
|
+
|
|
306
|
+
attempts = [
|
|
307
|
+
(system_prompt, temp, max_t, model_tier, recent_limit),
|
|
308
|
+
(
|
|
309
|
+
system_prompt
|
|
310
|
+
+ "\n\nREPARA LA RESPUESTA:\n"
|
|
311
|
+
+ repair_instructions.strip()
|
|
312
|
+
+ "\n- no repitas introducciones\n- no suenes a bot ni a guion de demo",
|
|
313
|
+
0.58,
|
|
314
|
+
max_t,
|
|
315
|
+
"reasoning",
|
|
316
|
+
recent_limit,
|
|
317
|
+
),
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
had_output = False
|
|
321
|
+
|
|
322
|
+
for prompt_now, temp_now, max_now, tier_now, limit_now in attempts:
|
|
323
|
+
if time.time() - _chain_start > _CHAIN_TIMEOUT_S:
|
|
324
|
+
log.warning("[demo] conv_quality_chain abortada por timeout (%ds)", _CHAIN_TIMEOUT_S)
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
candidate = await self._llm_conv(
|
|
328
|
+
prompt_now,
|
|
329
|
+
history=history,
|
|
330
|
+
text=text,
|
|
331
|
+
temp=temp_now,
|
|
332
|
+
max_t=max_now,
|
|
333
|
+
model_tier=tier_now,
|
|
334
|
+
recent_limit=limit_now,
|
|
335
|
+
demo_model_pref=demo_model_pref,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if candidate and candidate.strip():
|
|
339
|
+
had_output = True
|
|
340
|
+
if validator(candidate):
|
|
341
|
+
return candidate, True
|
|
342
|
+
|
|
343
|
+
return None, had_output
|
|
344
|
+
|
|
345
|
+
async def _demo_llm_quality_chain(
|
|
346
|
+
self,
|
|
347
|
+
system_prompt: str,
|
|
348
|
+
validator: Callable[[str], bool],
|
|
349
|
+
repair_instructions: str,
|
|
350
|
+
user_message: str,
|
|
351
|
+
temp: float = 0.72,
|
|
352
|
+
max_t: int = 8192,
|
|
353
|
+
model_tier: str = "fast",
|
|
354
|
+
demo_model_pref: str = "auto"
|
|
355
|
+
) -> Tuple[Optional[str], bool]:
|
|
356
|
+
"""
|
|
357
|
+
Chain de calidad simple (sin historial) para generación demo.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
system_prompt: Prompt del sistema
|
|
361
|
+
validator: Función que valida la respuesta
|
|
362
|
+
repair_instructions: Instrucciones para repair
|
|
363
|
+
user_message: Mensaje del usuario
|
|
364
|
+
temp: Temperatura
|
|
365
|
+
max_t: Máximo de tokens
|
|
366
|
+
model_tier: Tier del modelo
|
|
367
|
+
demo_model_pref: Preferencia de modelo
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Tuple (respuesta o None, tuvo_output)
|
|
371
|
+
"""
|
|
372
|
+
import logging
|
|
373
|
+
log = logging.getLogger("conny_generator")
|
|
374
|
+
|
|
375
|
+
_chain_start = time.time()
|
|
376
|
+
_CHAIN_TIMEOUT_S = 45
|
|
377
|
+
|
|
378
|
+
attempts = [
|
|
379
|
+
(system_prompt, temp, max_t, model_tier),
|
|
380
|
+
(
|
|
381
|
+
system_prompt
|
|
382
|
+
+ "\n\nREPARA LA RESPUESTA:\n"
|
|
383
|
+
+ repair_instructions.strip()
|
|
384
|
+
+ "\n- no repitas introducciones\n- no suenes a bot",
|
|
385
|
+
0.58,
|
|
386
|
+
max_t,
|
|
387
|
+
"reasoning",
|
|
388
|
+
),
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
had_output = False
|
|
392
|
+
|
|
393
|
+
for prompt_now, temp_now, max_now, tier_now in attempts:
|
|
394
|
+
if time.time() - _chain_start > _CHAIN_TIMEOUT_S:
|
|
395
|
+
log.warning("[demo] quality_chain abortada por timeout")
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
candidate = await self._llm(
|
|
399
|
+
prompt_now,
|
|
400
|
+
user_message,
|
|
401
|
+
temp=temp_now,
|
|
402
|
+
max_t=max_now,
|
|
403
|
+
model_tier=tier_now,
|
|
404
|
+
demo_model_pref=demo_model_pref,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if candidate and candidate.strip():
|
|
408
|
+
had_output = True
|
|
409
|
+
if validator(candidate):
|
|
410
|
+
return candidate, True
|
|
411
|
+
|
|
412
|
+
return None, had_output
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def estimate_tokens(text: str) -> int:
|
|
416
|
+
"""
|
|
417
|
+
Estima el número de tokens en un texto.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
text: Texto a estimar
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Estimación de tokens (approx 4 chars por token)
|
|
424
|
+
"""
|
|
425
|
+
return len(text) // 4
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def budget_tokens(
|
|
429
|
+
system_prompt: str,
|
|
430
|
+
max_t: int = 8192,
|
|
431
|
+
safety_margin: float = 0.9
|
|
432
|
+
) -> Tuple[int, int]:
|
|
433
|
+
"""
|
|
434
|
+
Calcula presupuesto de tokens para generación.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
system_prompt: Prompt del sistema
|
|
438
|
+
max_t: Máximo de tokens disponibles
|
|
439
|
+
safety_margin: Margen de seguridad (0.0-1.0)
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Tuple (tokens_disponibles_output, tokens_reservados)
|
|
443
|
+
"""
|
|
444
|
+
system_tokens = estimate_tokens(system_prompt)
|
|
445
|
+
available = int(max_t * safety_margin)
|
|
446
|
+
output_budget = max(available - system_tokens, 512)
|
|
447
|
+
return output_budget, system_tokens
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""conny_google_auth.py — Google OAuth2 flow via WhatsApp chat.
|
|
2
|
+
OpenClaw-inspired: credentials live in vault files, NOT in .env.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("conny.google_auth")
|
|
16
|
+
|
|
17
|
+
SCOPES = [
|
|
18
|
+
"https://www.googleapis.com/auth/calendar",
|
|
19
|
+
"https://www.googleapis.com/auth/calendar.events",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
VAULT_DIR = Path("integrations/vault")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_google_creds(instance_id: str = "default") -> Dict[str, str]:
|
|
26
|
+
"""Load Google credentials from vault (OpenClaw pattern: vault > env)."""
|
|
27
|
+
# Priority 1: Vault file (uploaded by admin)
|
|
28
|
+
creds_file = VAULT_DIR / instance_id / "google_credentials.json"
|
|
29
|
+
if creds_file.exists():
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(creds_file.read_text())
|
|
32
|
+
inner = data.get("installed") or data.get("web") or data
|
|
33
|
+
return {
|
|
34
|
+
"client_id": inner.get("client_id", ""),
|
|
35
|
+
"client_secret": inner.get("client_secret", ""),
|
|
36
|
+
"redirect_uri": inner.get("redirect_uris", ["urn:ietf:wg:oauth:2.0:oob"])[0],
|
|
37
|
+
}
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Priority 2: Environment variables (fallback)
|
|
42
|
+
return {
|
|
43
|
+
"client_id": os.getenv("GOOGLE_CLIENT_ID", ""),
|
|
44
|
+
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET", ""),
|
|
45
|
+
"redirect_uri": os.getenv("GOOGLE_REDIRECT_URI", "urn:ietf:wg:oauth:2.0:oob"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_oauth_url(instance_id: str = "default") -> Optional[str]:
|
|
50
|
+
"""Generate Google OAuth2 consent URL."""
|
|
51
|
+
creds = _load_google_creds(instance_id)
|
|
52
|
+
if not creds["client_id"]:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
params = {
|
|
56
|
+
"client_id": creds["client_id"],
|
|
57
|
+
"redirect_uri": creds["redirect_uri"],
|
|
58
|
+
"response_type": "code",
|
|
59
|
+
"scope": " ".join(SCOPES),
|
|
60
|
+
"access_type": "offline",
|
|
61
|
+
"prompt": "consent",
|
|
62
|
+
"state": instance_id,
|
|
63
|
+
}
|
|
64
|
+
return f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def exchange_code_for_tokens(code: str, instance_id: str = "default") -> Optional[Dict]:
|
|
68
|
+
"""Exchange authorization code for access + refresh tokens."""
|
|
69
|
+
creds = _load_google_creds(instance_id)
|
|
70
|
+
if not creds["client_id"] or not creds["client_secret"]:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
74
|
+
r = await client.post(
|
|
75
|
+
"https://oauth2.googleapis.com/token",
|
|
76
|
+
data={
|
|
77
|
+
"code": code.strip(),
|
|
78
|
+
"client_id": creds["client_id"],
|
|
79
|
+
"client_secret": creds["client_secret"],
|
|
80
|
+
"redirect_uri": creds["redirect_uri"],
|
|
81
|
+
"grant_type": "authorization_code",
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
if r.status_code == 200:
|
|
85
|
+
tokens = r.json()
|
|
86
|
+
tokens_dir = VAULT_DIR / instance_id
|
|
87
|
+
tokens_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
(tokens_dir / "google_tokens.json").write_text(json.dumps(tokens, indent=2))
|
|
89
|
+
log.info(f"[google_auth] tokens saved for {instance_id}")
|
|
90
|
+
return tokens
|
|
91
|
+
else:
|
|
92
|
+
log.error(f"[google_auth] exchange failed: {r.status_code} {r.text[:200]}")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def refresh_access_token(instance_id: str = "default") -> Optional[str]:
|
|
97
|
+
"""Refresh access token using stored refresh token."""
|
|
98
|
+
tokens_file = VAULT_DIR / instance_id / "google_tokens.json"
|
|
99
|
+
if not tokens_file.exists():
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
tokens = json.loads(tokens_file.read_text())
|
|
103
|
+
refresh_token = tokens.get("refresh_token")
|
|
104
|
+
if not refresh_token:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
creds = _load_google_creds(instance_id)
|
|
108
|
+
|
|
109
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
110
|
+
r = await client.post(
|
|
111
|
+
"https://oauth2.googleapis.com/token",
|
|
112
|
+
data={
|
|
113
|
+
"client_id": creds["client_id"],
|
|
114
|
+
"client_secret": creds["client_secret"],
|
|
115
|
+
"refresh_token": refresh_token,
|
|
116
|
+
"grant_type": "refresh_token",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
if r.status_code == 200:
|
|
120
|
+
new_tokens = r.json()
|
|
121
|
+
tokens["access_token"] = new_tokens["access_token"]
|
|
122
|
+
tokens["expires_in"] = new_tokens.get("expires_in", 3600)
|
|
123
|
+
tokens_file.write_text(json.dumps(tokens, indent=2))
|
|
124
|
+
return new_tokens["access_token"]
|
|
125
|
+
else:
|
|
126
|
+
log.error(f"[google_auth] refresh failed: {r.status_code}")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def get_access_token(instance_id: str = "default") -> Optional[str]:
|
|
131
|
+
"""Get a valid access token (refresh if needed)."""
|
|
132
|
+
tokens_file = VAULT_DIR / instance_id / "google_tokens.json"
|
|
133
|
+
if not tokens_file.exists():
|
|
134
|
+
return None
|
|
135
|
+
tokens = json.loads(tokens_file.read_text())
|
|
136
|
+
# Always refresh to ensure valid token
|
|
137
|
+
refreshed = await refresh_access_token(instance_id)
|
|
138
|
+
return refreshed or tokens.get("access_token")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def has_credentials(instance_id: str = "default") -> bool:
|
|
142
|
+
"""Check if instance has Google OAuth credentials configured."""
|
|
143
|
+
creds = _load_google_creds(instance_id)
|
|
144
|
+
return bool(creds["client_id"])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def has_tokens(instance_id: str = "default") -> bool:
|
|
148
|
+
"""Check if instance has completed OAuth (has tokens)."""
|
|
149
|
+
return (VAULT_DIR / instance_id / "google_tokens.json").exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_oauth_code(text: str) -> bool:
|
|
153
|
+
"""Detect if a message looks like a Google OAuth authorization code."""
|
|
154
|
+
t = text.strip()
|
|
155
|
+
if t.startswith("4/") and len(t) > 30:
|
|
156
|
+
return True
|
|
157
|
+
if len(t) >= 30 and len(t) <= 120 and "/" in t and " " not in t:
|
|
158
|
+
return True
|
|
159
|
+
return False
|