@innvisor/conny-ai 9.7.0 → 9.8.2
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/CHANGELOG.md +66 -0
- package/README.md +17 -1
- package/conny_app.py +9 -3
- package/conny_cli.py +103 -11
- package/conny_core/evolution.py +112 -0
- package/conny_core/first_turn_ops.py +16 -20
- package/conny_core/prompt_ops.py +62 -0
- package/conny_demo_voice.py +1 -1
- package/conny_doctor.py +287 -2
- package/conny_domino.py +2 -2
- package/conny_generator.py +1 -1
- package/conny_i18n.py +81 -2
- package/conny_init.py +254 -41
- package/conny_runtime_ops.py +198 -6
- package/conny_tui.py +7 -0
- package/conny_ultra_config.py +25 -11
- package/conny_utils.py +21 -3
- package/ecosystem.config.js +11 -1
- package/install.sh +78 -22
- package/npm/conny.js +75 -21
- package/package.json +12 -2
- package/run.sh +7 -0
- package/src/conny/admin/dashboard.py +35 -4
- package/src/conny/admin_memory.py +93 -0
- package/src/conny/api/routes.py +26 -9
- package/src/conny/channels/cli.py +30 -9
- package/src/conny/demo/handler.py +23 -23
- package/src/conny/personas/generator.py +1 -1
- package/src/conny/production/domino.py +2 -2
- package/src/conny/production/guard.py +4 -4
- package/src/core/admin_engines.py +51 -48
- package/src/core/globals.py +110 -9
- package/src/core/production_monitor.py +63 -38
- package/src/core/runtime.py +343 -305
- package/src/domain/prompts/prospect_pitch.py +11 -11
- package/src/domain/send_guard.py +4 -4
- package/src/interfaces/web/app.py +91 -27
- package/src/interfaces/web/demo_admin_commands.py +165 -0
- package/src/interfaces/web/demo_handler.py +178 -34
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +0 -22
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +0 -11
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +0 -11
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +0 -22
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +0 -11
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +0 -11
- package/brand-assets/conny-demo/manifest.json +0 -22
- package/brand-assets/conny-demo/processed/business-identity.txt +0 -7
- package/brand-assets/conny-demo/raw/business-identity.txt +0 -7
- package/fix_init.py +0 -27
- package/verify_conversation_impl.py +0 -48
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
src/domain/prompts/prospect_pitch.py
|
|
3
3
|
════════════════════════════════════════════════════════════════════════════════
|
|
4
|
-
PITCH INTELIGENTE —
|
|
4
|
+
PITCH INTELIGENTE — Innvisor / Conny v1.0
|
|
5
5
|
════════════════════════════════════════════════════════════════════════════════
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -17,10 +17,10 @@ log = logging.getLogger("conny.pitch_upgrade")
|
|
|
17
17
|
# 1. IDENTIDAD CORRECTA
|
|
18
18
|
# ════════════════════════════════════════════════════════════════════════════════
|
|
19
19
|
|
|
20
|
-
CREATOR_NAME = "
|
|
20
|
+
CREATOR_NAME = "Innvisor"
|
|
21
21
|
CREATOR_DESC = "empresa de software y gobernanza de agentes de IA"
|
|
22
22
|
CREATOR_HUMAN = "Santiago Rubio"
|
|
23
|
-
CREATOR_TEL = "
|
|
23
|
+
CREATOR_TEL = "3243699856"
|
|
24
24
|
|
|
25
25
|
CREATOR_LINE = (
|
|
26
26
|
f"me creó {CREATOR_NAME}, una {CREATOR_DESC}"
|
|
@@ -101,7 +101,7 @@ def build_prospect_pitch_system_prompt(business_name: str = "") -> str:
|
|
|
101
101
|
"""
|
|
102
102
|
biz_ctx = f"El prospecto es de: {business_name}." if business_name else ""
|
|
103
103
|
|
|
104
|
-
return f"""Eres Conny, una recepcionista virtual creada por
|
|
104
|
+
return f"""Eres Conny, una recepcionista virtual creada por Innvisor — una empresa colombiana de software y gobernanza de agentes de IA.
|
|
105
105
|
|
|
106
106
|
{biz_ctx}
|
|
107
107
|
|
|
@@ -120,9 +120,9 @@ QUÉ HACE CONNY (habla de esto con naturalidad, no como lista):
|
|
|
120
120
|
- Funciona 24/7 sin descanso, sin perder el hilo, sin errores de tono
|
|
121
121
|
|
|
122
122
|
CÓMO COBRAR (si preguntan precio):
|
|
123
|
-
- No tienes un precio fijo en este momento — el precio lo da
|
|
123
|
+
- No tienes un precio fijo en este momento — el precio lo da Innvisor según el plan
|
|
124
124
|
- Lo que sí puedes decir: "el costo varía según el volumen de chats y los servicios que necesiten"
|
|
125
|
-
- Invítalos a contactar a Santiago Rubio (
|
|
125
|
+
- Invítalos a contactar a Santiago Rubio (3243699856) para una propuesta real
|
|
126
126
|
- NO inventes precios. Si preguntan rango, di que arranca desde lo básico y escala
|
|
127
127
|
|
|
128
128
|
CÓMO CERRAR:
|
|
@@ -132,10 +132,10 @@ CÓMO CERRAR:
|
|
|
132
132
|
- Si están confundidos: simplifica sin perder calma — "básicamente soy la que respondería el WhatsApp de tu negocio por ti"
|
|
133
133
|
|
|
134
134
|
IDENTIDAD Y CREADOR:
|
|
135
|
-
- Te creó
|
|
136
|
-
- Fue fundada por Santiago Rubio — contacto:
|
|
135
|
+
- Te creó Innvisor, empresa de software y gobernanza de agentes de IA
|
|
136
|
+
- Fue fundada por Santiago Rubio — contacto: 3243699856
|
|
137
137
|
- NUNCA digas Anthropic, Google, OpenAI ni ningún proveedor de IA como tu creador
|
|
138
|
-
- NUNCA digas "BlackBoss" — el nombre correcto es
|
|
138
|
+
- NUNCA digas "BlackBoss" — el nombre correcto es Innvisor
|
|
139
139
|
|
|
140
140
|
TONO Y FORMATO (CRÍTICO):
|
|
141
141
|
- Habla por burbujas separadas usando exactamente '|||'.
|
|
@@ -154,7 +154,7 @@ Pregunta: "qué harías en mi clínica?"
|
|
|
154
154
|
BUENO: "en una clínica me encargaría de recibir a los pacientes por whatsapp, resolver sus dudas, agendar citas y confirmarlas ||| también hago seguimiento a quienes quedaron pensándolo y manejo objeciones de precio sin presionar ||| quieres que te muestre cómo respondería a un paciente tuyo ahora mismo?"
|
|
155
155
|
|
|
156
156
|
Pregunta: "cuánto cuestas?"
|
|
157
|
-
BUENO: "el costo lo maneja
|
|
157
|
+
BUENO: "el costo lo maneja innvisor según el plan que necesites — hay opciones desde lo básico hasta lo más completo ||| para una propuesta real hay que hablar con santiago: 3243699856 ||| mientras tanto, quieres verme en acción con un caso de tu negocio?"
|
|
158
158
|
|
|
159
159
|
Pregunta: "no entiendo qué eres"
|
|
160
160
|
BUENO: "te resumo: soy una recepcionista virtual — respondo el whatsapp de tu negocio como si llevara tiempo en tu equipo ||| me entrenás con info de tus servicios y yo me encargo del chat ||| qué tipo de negocio tienes para mostrarte cómo sería?"
|
|
@@ -210,7 +210,7 @@ _BLACKBOSS_VARIANTS = [
|
|
|
210
210
|
def fix_creator_in_response(response: str) -> str:
|
|
211
211
|
"""
|
|
212
212
|
Postprocesa cualquier respuesta del LLM y reemplaza menciones
|
|
213
|
-
incorrectas de BlackBoss por
|
|
213
|
+
incorrectas de BlackBoss por Innvisor.
|
|
214
214
|
"""
|
|
215
215
|
if not response:
|
|
216
216
|
return response
|
package/src/domain/send_guard.py
CHANGED
|
@@ -415,7 +415,7 @@ def patch_demo_send(
|
|
|
415
415
|
"""
|
|
416
416
|
Retorna un wrapper de _send que aplica:
|
|
417
417
|
1. guard_response (fix de cortes)
|
|
418
|
-
2. fix_creator_in_response (
|
|
418
|
+
2. fix_creator_in_response (Innvisor, no BlackBoss)
|
|
419
419
|
|
|
420
420
|
Usar en _handle_demo_message así:
|
|
421
421
|
def _send(r): ... # definición original
|
|
@@ -434,7 +434,7 @@ def patch_demo_send(
|
|
|
434
434
|
def fix_creator_in_response(r): return r # type: ignore
|
|
435
435
|
|
|
436
436
|
def guarded_send(r: str) -> Any:
|
|
437
|
-
# 1. Fix
|
|
437
|
+
# 1. Fix Innvisor / BlackBoss
|
|
438
438
|
if _has_pitch_upgrade:
|
|
439
439
|
r = fix_creator_in_response(r)
|
|
440
440
|
|
|
@@ -497,7 +497,7 @@ def check_proactive_handoff(user_msg: str, history: List[Dict[str, Any]]) -> Opt
|
|
|
497
497
|
"urgency": "high",
|
|
498
498
|
"suggested_reply": (
|
|
499
499
|
"claro, te paso con Santiago directamente ||| "
|
|
500
|
-
"su contacto es
|
|
500
|
+
"su contacto es 3243699856 — él te da la propuesta según tu negocio"
|
|
501
501
|
),
|
|
502
502
|
}
|
|
503
503
|
|
|
@@ -511,7 +511,7 @@ def check_proactive_handoff(user_msg: str, history: List[Dict[str, Any]]) -> Opt
|
|
|
511
511
|
"suggested_reply": (
|
|
512
512
|
"entendido, sin problema ||| "
|
|
513
513
|
"si en algún momento quieres verme en acción, "
|
|
514
|
-
"el contacto de
|
|
514
|
+
"el contacto de Innvisor es 3243699856"
|
|
515
515
|
),
|
|
516
516
|
}
|
|
517
517
|
|
|
@@ -705,16 +705,20 @@ async def dashboard():
|
|
|
705
705
|
}
|
|
706
706
|
|
|
707
707
|
@app.get("/appointments")
|
|
708
|
-
async def list_appointments(
|
|
708
|
+
async def list_appointments(request: Request,
|
|
709
709
|
status: Optional[str] = None,
|
|
710
710
|
limit: int = 50
|
|
711
711
|
):
|
|
712
|
+
if not _verify_master_key(request):
|
|
713
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
712
714
|
"""Lista citas."""
|
|
713
715
|
appointments = db.get_appointments(status=status, limit=limit)
|
|
714
716
|
return {"appointments": appointments, "count": len(appointments)}
|
|
715
717
|
|
|
716
718
|
@app.get("/appointments/{apt_id}")
|
|
717
|
-
async def get_appointment(apt_id: int):
|
|
719
|
+
async def get_appointment(request: Request, apt_id: int):
|
|
720
|
+
if not _verify_master_key(request):
|
|
721
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
718
722
|
"""Obtiene una cita específica."""
|
|
719
723
|
with db._conn() as c:
|
|
720
724
|
row = c.execute("SELECT * FROM appointments WHERE id=?", (apt_id,)).fetchone()
|
|
@@ -730,7 +734,9 @@ async def update_appointment(apt_id: int, request: Request):
|
|
|
730
734
|
return {"ok": True}
|
|
731
735
|
|
|
732
736
|
@app.get("/patients")
|
|
733
|
-
async def list_patients(limit: int = 50):
|
|
737
|
+
async def list_patients(request: Request, limit: int = 50):
|
|
738
|
+
if not _verify_master_key(request):
|
|
739
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
734
740
|
"""Lista pacientes."""
|
|
735
741
|
with db._conn() as c:
|
|
736
742
|
rows = c.execute("""
|
|
@@ -755,7 +761,9 @@ async def list_patients(limit: int = 50):
|
|
|
755
761
|
|
|
756
762
|
|
|
757
763
|
@app.get("/patients/{chat_id}")
|
|
758
|
-
async def get_patient(chat_id: str):
|
|
764
|
+
async def get_patient(request: Request, chat_id: str):
|
|
765
|
+
if not _verify_master_key(request):
|
|
766
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
759
767
|
"""Obtiene un paciente."""
|
|
760
768
|
with db._conn() as c:
|
|
761
769
|
row = c.execute("SELECT * FROM patients WHERE chat_id=?", (chat_id,)).fetchone()
|
|
@@ -772,7 +780,9 @@ async def get_patient(chat_id: str):
|
|
|
772
780
|
return data
|
|
773
781
|
|
|
774
782
|
@app.get("/conversations/{chat_id}")
|
|
775
|
-
async def get_conversations(chat_id: str, limit: int = 50):
|
|
783
|
+
async def get_conversations(request: Request, chat_id: str, limit: int = 50):
|
|
784
|
+
if not _verify_master_key(request):
|
|
785
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
776
786
|
"""Obtiene historial de conversación."""
|
|
777
787
|
history = db.get_history(chat_id, limit=limit)
|
|
778
788
|
return {"chat_id": chat_id, "messages": history, "count": len(history)}
|
|
@@ -788,7 +798,9 @@ async def get_metrics(
|
|
|
788
798
|
return {"metrics": metrics, "period_hours": hours}
|
|
789
799
|
|
|
790
800
|
@app.get("/plugins")
|
|
791
|
-
async def list_plugins():
|
|
801
|
+
async def list_plugins(request: Request):
|
|
802
|
+
if not _verify_master_key(request):
|
|
803
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
792
804
|
"""Lista plugins."""
|
|
793
805
|
plugins = db.get_plugins()
|
|
794
806
|
health = await mcp_manager.health_check_all() if mcp_manager else {}
|
|
@@ -821,7 +833,9 @@ async def execute_plugin(plugin_id: str, request: Request):
|
|
|
821
833
|
return result
|
|
822
834
|
|
|
823
835
|
@app.get("/config")
|
|
824
|
-
async def get_config():
|
|
836
|
+
async def get_config(request: Request):
|
|
837
|
+
if not _verify_master_key(request):
|
|
838
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
825
839
|
"""Obtiene configuración de la clínica."""
|
|
826
840
|
clinic = db.get_clinic()
|
|
827
841
|
|
|
@@ -835,6 +849,8 @@ async def get_config():
|
|
|
835
849
|
|
|
836
850
|
@app.patch("/config")
|
|
837
851
|
async def update_config(request: Request):
|
|
852
|
+
if not _verify_master_key(request):
|
|
853
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
838
854
|
"""Actualiza configuración."""
|
|
839
855
|
data = await request.json()
|
|
840
856
|
|
|
@@ -889,7 +905,9 @@ async def upload_avatar_base64(req: AvatarUploadRequest):
|
|
|
889
905
|
return {"ok": True, "url": f"/static/avatars/{save_name}"}
|
|
890
906
|
|
|
891
907
|
@app.get("/personality")
|
|
892
|
-
async def get_personality():
|
|
908
|
+
async def get_personality(request: Request):
|
|
909
|
+
if not _verify_master_key(request):
|
|
910
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
893
911
|
"""Obtiene configuración de personalidad."""
|
|
894
912
|
clinic = db.get_clinic()
|
|
895
913
|
persona = clinic.get("persona_config", {})
|
|
@@ -919,6 +937,8 @@ async def get_personality():
|
|
|
919
937
|
|
|
920
938
|
@app.patch("/personality")
|
|
921
939
|
async def update_personality(request: Request):
|
|
940
|
+
if not _verify_master_key(request):
|
|
941
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
922
942
|
"""Actualiza personalidad."""
|
|
923
943
|
data = await request.json()
|
|
924
944
|
|
|
@@ -951,7 +971,9 @@ async def apply_improvements():
|
|
|
951
971
|
return {"applied": applied}
|
|
952
972
|
|
|
953
973
|
@app.get("/tasks")
|
|
954
|
-
async def list_tasks(status: Optional[str] = None, limit: int = 50):
|
|
974
|
+
async def list_tasks(request: Request, status: Optional[str] = None, limit: int = 50):
|
|
975
|
+
if not _verify_master_key(request):
|
|
976
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
955
977
|
"""Lista tareas."""
|
|
956
978
|
with db._conn() as c:
|
|
957
979
|
query = "SELECT * FROM tasks"
|
|
@@ -970,6 +992,8 @@ async def list_tasks(status: Optional[str] = None, limit: int = 50):
|
|
|
970
992
|
|
|
971
993
|
@app.post("/tasks")
|
|
972
994
|
async def create_task(request: Request):
|
|
995
|
+
if not _verify_master_key(request):
|
|
996
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
973
997
|
"""Crea una tarea programada."""
|
|
974
998
|
data = await request.json()
|
|
975
999
|
|
|
@@ -1196,7 +1220,9 @@ async def reset_system():
|
|
|
1196
1220
|
return {"ok": True, "message": "Sistema reseteado"}
|
|
1197
1221
|
|
|
1198
1222
|
@app.get("/export")
|
|
1199
|
-
async def export_data():
|
|
1223
|
+
async def export_data(request: Request):
|
|
1224
|
+
if not _verify_master_key(request):
|
|
1225
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
1200
1226
|
"""Exporta todos los datos."""
|
|
1201
1227
|
clinic = db.get_clinic()
|
|
1202
1228
|
|
|
@@ -1230,7 +1256,9 @@ async def export_data():
|
|
|
1230
1256
|
# ─── Analytics Endpoints ────────────────────────────────────────────────────────
|
|
1231
1257
|
|
|
1232
1258
|
@app.get("/analytics/summary")
|
|
1233
|
-
async def analytics_summary(days: int = 7):
|
|
1259
|
+
async def analytics_summary(request: Request, days: int = 7):
|
|
1260
|
+
if not _verify_master_key(request):
|
|
1261
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
1234
1262
|
"""Resumen de analytics."""
|
|
1235
1263
|
since = datetime.now() - timedelta(days=days)
|
|
1236
1264
|
|
|
@@ -1285,7 +1313,9 @@ async def analytics_summary(days: int = 7):
|
|
|
1285
1313
|
}
|
|
1286
1314
|
|
|
1287
1315
|
@app.get("/analytics/intents")
|
|
1288
|
-
async def analytics_intents(hours: int = 24):
|
|
1316
|
+
async def analytics_intents(request: Request, hours: int = 24):
|
|
1317
|
+
if not _verify_master_key(request):
|
|
1318
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
1289
1319
|
"""Distribución de intenciones."""
|
|
1290
1320
|
since = datetime.now() - timedelta(hours=hours)
|
|
1291
1321
|
|
|
@@ -1311,7 +1341,9 @@ async def analytics_intents(hours: int = 24):
|
|
|
1311
1341
|
}
|
|
1312
1342
|
|
|
1313
1343
|
@app.get("/analytics/sentiment")
|
|
1314
|
-
async def analytics_sentiment(hours: int = 24):
|
|
1344
|
+
async def analytics_sentiment(request: Request, hours: int = 24):
|
|
1345
|
+
if not _verify_master_key(request):
|
|
1346
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
1315
1347
|
"""Distribución de sentimiento."""
|
|
1316
1348
|
since = datetime.now() - timedelta(hours=hours)
|
|
1317
1349
|
|
|
@@ -1348,7 +1380,9 @@ async def analytics_sentiment(hours: int = 24):
|
|
|
1348
1380
|
# ─── Logs Endpoint ──────────────────────────────────────────────────────────────
|
|
1349
1381
|
|
|
1350
1382
|
@app.get("/logs/improvements")
|
|
1351
|
-
async def get_improvement_logs(limit: int = 50):
|
|
1383
|
+
async def get_improvement_logs(request: Request, limit: int = 50):
|
|
1384
|
+
if not _verify_master_key(request):
|
|
1385
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
1352
1386
|
"""Obtiene logs de auto-mejora."""
|
|
1353
1387
|
with db._conn() as c:
|
|
1354
1388
|
rows = c.execute("""
|
|
@@ -1360,7 +1394,9 @@ async def get_improvement_logs(limit: int = 50):
|
|
|
1360
1394
|
return {"logs": [dict(r) for r in rows]}
|
|
1361
1395
|
|
|
1362
1396
|
@app.get("/logs/errors")
|
|
1363
|
-
async def get_error_logs(hours: int = 24):
|
|
1397
|
+
async def get_error_logs(request: Request, hours: int = 24):
|
|
1398
|
+
if not _verify_master_key(request):
|
|
1399
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
1364
1400
|
"""Obtiene logs de errores."""
|
|
1365
1401
|
since = datetime.now() - timedelta(hours=hours)
|
|
1366
1402
|
|
|
@@ -1397,23 +1433,28 @@ async def api_create_token(request: Request):
|
|
|
1397
1433
|
if not clinic_label:
|
|
1398
1434
|
raise HTTPException(status_code=400, detail="clinic_label requerido")
|
|
1399
1435
|
|
|
1436
|
+
token_type = str(body.get("token_type") or body.get("type") or "").strip().lower()
|
|
1437
|
+
admin_requested = bool(body.get("admin")) or token_type in ("admin", "admin_pro", "pro")
|
|
1438
|
+
|
|
1400
1439
|
# Generar token
|
|
1401
|
-
token = generate_activation_token(clinic_label)
|
|
1440
|
+
token = generate_admin_activation_token(clinic_label) if admin_requested else generate_activation_token(clinic_label)
|
|
1441
|
+
stored_label = f"ADMIN_PRO:{clinic_label}" if admin_requested else clinic_label
|
|
1402
1442
|
|
|
1403
1443
|
# Calcular expiracion
|
|
1404
1444
|
expires_at = (datetime.now() + timedelta(hours=Config.TOKEN_EXPIRY_HOURS)).isoformat()
|
|
1405
1445
|
|
|
1406
1446
|
# Guardar en DB
|
|
1407
|
-
saved = db.create_activation_token(token,
|
|
1447
|
+
saved = db.create_activation_token(token, stored_label, expires_at)
|
|
1408
1448
|
if not saved:
|
|
1409
1449
|
raise HTTPException(status_code=500, detail="No se pudo guardar el token")
|
|
1410
1450
|
|
|
1411
|
-
log.info(f"[api] token creado para '{
|
|
1451
|
+
log.info(f"[api] token creado para '{stored_label}': {token[:20]}...")
|
|
1412
1452
|
|
|
1413
1453
|
return {
|
|
1414
1454
|
"ok": True,
|
|
1415
1455
|
"token": token,
|
|
1416
1456
|
"clinic_label": clinic_label,
|
|
1457
|
+
"token_type": "admin_pro" if admin_requested else "business_owner",
|
|
1417
1458
|
"expires_at": expires_at,
|
|
1418
1459
|
"instructions": f"Envia este token exacto al administrador de {clinic_label}. Expira en {Config.TOKEN_EXPIRY_HOURS}h."
|
|
1419
1460
|
}
|
|
@@ -1431,7 +1472,7 @@ async def api_activate(request: Request):
|
|
|
1431
1472
|
raise HTTPException(status_code=400, detail="JSON invalido")
|
|
1432
1473
|
|
|
1433
1474
|
token = body.get("token", "").strip()
|
|
1434
|
-
if not token or not token
|
|
1475
|
+
if not token or not is_activation_token(token):
|
|
1435
1476
|
raise HTTPException(status_code=400, detail="Token no valido")
|
|
1436
1477
|
|
|
1437
1478
|
token_data = db.get_activation_token(token)
|
|
@@ -1458,6 +1499,11 @@ async def api_activate(request: Request):
|
|
|
1458
1499
|
raise HTTPException(status_code=500, detail="Error interno actualizando token")
|
|
1459
1500
|
|
|
1460
1501
|
log.info(f"[api] Token de activacion '{token[:15]}...' canjeado exitosamente.")
|
|
1502
|
+
return {
|
|
1503
|
+
"ok": True,
|
|
1504
|
+
"master_key": Config.MASTER_API_KEY,
|
|
1505
|
+
"token_type": "admin_pro" if is_admin_activation_token(token) else "business_owner",
|
|
1506
|
+
}
|
|
1461
1507
|
|
|
1462
1508
|
@app.post("/api/auth/check-email")
|
|
1463
1509
|
async def api_check_email(request: Request):
|
|
@@ -1518,7 +1564,7 @@ async def api_auth_register(request: Request):
|
|
|
1518
1564
|
if not email or not password or not name or not token:
|
|
1519
1565
|
raise HTTPException(status_code=400, detail="Faltan campos requeridos")
|
|
1520
1566
|
|
|
1521
|
-
if not token
|
|
1567
|
+
if not is_activation_token(token):
|
|
1522
1568
|
raise HTTPException(status_code=400, detail="Token no valido")
|
|
1523
1569
|
|
|
1524
1570
|
token_data = db.get_activation_token(token)
|
|
@@ -1545,10 +1591,11 @@ async def api_auth_register(request: Request):
|
|
|
1545
1591
|
pass_hash = hash_password(password)
|
|
1546
1592
|
try:
|
|
1547
1593
|
with db._conn() as c:
|
|
1594
|
+
role = "admin_pro" if is_admin_activation_token(token) else "owner"
|
|
1548
1595
|
c.execute("""
|
|
1549
1596
|
INSERT OR REPLACE INTO admins (chat_id, email, password_hash, name, role, activated_by_token, is_active)
|
|
1550
|
-
VALUES (?, ?, ?, ?,
|
|
1551
|
-
""", (f"owner_{secrets.token_hex(4)}", email, pass_hash, name, token))
|
|
1597
|
+
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
1598
|
+
""", (f"owner_{secrets.token_hex(4)}", email, pass_hash, name, role, token))
|
|
1552
1599
|
except Exception as e:
|
|
1553
1600
|
log.error(f"Error insertando admin: {e}")
|
|
1554
1601
|
|
|
@@ -1591,15 +1638,26 @@ async def api_auth_dev_register(request: Request):
|
|
|
1591
1638
|
if not email or not password or not dev_token:
|
|
1592
1639
|
raise HTTPException(status_code=400, detail="Todos los campos son requeridos")
|
|
1593
1640
|
|
|
1594
|
-
if
|
|
1595
|
-
|
|
1641
|
+
if Config.MASTER_API_KEY and secrets.compare_digest(dev_token, Config.MASTER_API_KEY):
|
|
1642
|
+
token_mode = "master"
|
|
1643
|
+
else:
|
|
1644
|
+
token_mode = "admin_pro"
|
|
1645
|
+
if not is_admin_activation_token(dev_token):
|
|
1646
|
+
raise HTTPException(status_code=401, detail="Token de acceso para desarrolladores incorrecto")
|
|
1647
|
+
token_data = db.get_activation_token(dev_token)
|
|
1648
|
+
if not token_data:
|
|
1649
|
+
raise HTTPException(status_code=404, detail="Token Conny Pro Admin inexistente")
|
|
1650
|
+
if token_data.get("used_at"):
|
|
1651
|
+
raise HTTPException(status_code=400, detail="El token Conny Pro Admin ya fue usado")
|
|
1596
1652
|
|
|
1597
1653
|
hashed = hash_password(password)
|
|
1598
1654
|
success = db.create_dev_account(email, hashed)
|
|
1599
1655
|
if not success:
|
|
1600
1656
|
raise HTTPException(status_code=500, detail="Error al registrar la cuenta de desarrollador")
|
|
1657
|
+
if token_mode == "admin_pro":
|
|
1658
|
+
db.consume_activation_token(dev_token, f"dev:{email}")
|
|
1601
1659
|
|
|
1602
|
-
return {"ok": True, "message": "Cuenta de desarrollador registrada con exito"}
|
|
1660
|
+
return {"ok": True, "message": "Cuenta de desarrollador registrada con exito", "token_type": token_mode}
|
|
1603
1661
|
|
|
1604
1662
|
# ── Developer Console API ──
|
|
1605
1663
|
|
|
@@ -2316,14 +2374,18 @@ async def brand_status():
|
|
|
2316
2374
|
# ─── Feedback y Carpeta de Confianza — endpoints ────────────────────────────────
|
|
2317
2375
|
|
|
2318
2376
|
@app.get("/feedback")
|
|
2319
|
-
async def get_feedback_list(limit: int = 20):
|
|
2377
|
+
async def get_feedback_list(request: Request, limit: int = 20):
|
|
2378
|
+
if not _verify_master_key(request):
|
|
2379
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
2320
2380
|
"""Lista de feedbacks del admin."""
|
|
2321
2381
|
items = db.get_feedback_list(limit=limit)
|
|
2322
2382
|
return {"feedback": items, "total": len(items)}
|
|
2323
2383
|
|
|
2324
2384
|
|
|
2325
2385
|
@app.get("/trust-rules")
|
|
2326
|
-
async def get_trust_rules(category: str = None):
|
|
2386
|
+
async def get_trust_rules(request: Request, category: str = None):
|
|
2387
|
+
if not _verify_master_key(request):
|
|
2388
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
2327
2389
|
"""Reglas aprendidas en la carpeta de confianza."""
|
|
2328
2390
|
rules = db.get_trust_rules(category=category)
|
|
2329
2391
|
return {"rules": rules, "total": len(rules)}
|
|
@@ -2331,6 +2393,8 @@ async def get_trust_rules(category: str = None):
|
|
|
2331
2393
|
|
|
2332
2394
|
@app.post("/trust-rules")
|
|
2333
2395
|
async def add_trust_rule(request: Request):
|
|
2396
|
+
if not _verify_master_key(request):
|
|
2397
|
+
raise HTTPException(status_code=401, detail="No autorizado")
|
|
2334
2398
|
"""Agrega una regla manualmente."""
|
|
2335
2399
|
data = await request.json()
|
|
2336
2400
|
rule_id = db.save_trust_rule(
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger("conny.demo_admin")
|
|
8
|
+
|
|
9
|
+
_COLOMBIAN_PHONE = re.compile(r"(?:\+?57)?3\d{9}(?!\d)")
|
|
10
|
+
_ANY_PHONE = re.compile(r"(?:\+?\d{1,3})?\d{7,15}(?!\d)")
|
|
11
|
+
_CONTACT_TRIGGERS = re.compile(
|
|
12
|
+
r"(?:contact|comunic|escrib[eí]|env[ií]|mand[aá]|habla|presenta|saluda|dile|diga|di\b)",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _strip_jid(raw: str) -> str:
|
|
18
|
+
return raw.split("@")[0].strip() if raw else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_admin_chat(chat_id: str, clinic: Dict, db) -> bool:
|
|
22
|
+
from src.conny.utils.helpers import _parse_admin_ids
|
|
23
|
+
raw = _strip_jid(chat_id)
|
|
24
|
+
normalized_admin_ids = {_strip_jid(aid) for aid in _parse_admin_ids(clinic.get("admin_chat_ids", []))}
|
|
25
|
+
if raw in normalized_admin_ids:
|
|
26
|
+
return True
|
|
27
|
+
try:
|
|
28
|
+
if db.get_admin(chat_id) or db.get_admin(raw):
|
|
29
|
+
return True
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def looks_like_contact_command(text: str) -> bool:
|
|
36
|
+
if not text or len(text) < 10:
|
|
37
|
+
return False
|
|
38
|
+
stripped = re.sub(r"\s+", "", text)
|
|
39
|
+
return bool(_ANY_PHONE.search(stripped)) and bool(_CONTACT_TRIGGERS.search(text))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_phone_number(text: str) -> Optional[str]:
|
|
43
|
+
stripped = re.sub(r"\s+", "", text)
|
|
44
|
+
m = _COLOMBIAN_PHONE.search(stripped)
|
|
45
|
+
if m:
|
|
46
|
+
num = m.group(0).lstrip("+")
|
|
47
|
+
return num if num.startswith("57") else "57" + num
|
|
48
|
+
m = _ANY_PHONE.search(stripped)
|
|
49
|
+
if m:
|
|
50
|
+
return m.group(0).lstrip("+")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def parse_name(text: str, llm_engine) -> str:
|
|
55
|
+
if not llm_engine:
|
|
56
|
+
return ""
|
|
57
|
+
try:
|
|
58
|
+
r, _ = await llm_engine.complete(
|
|
59
|
+
[{"role": "system", "content": "Extrae el nombre de la persona a contactar del mensaje. Si no hay nombre, responde ''"},
|
|
60
|
+
{"role": "user", "content": text}],
|
|
61
|
+
model_tier="fast", temperature=0.1, max_tokens=100,
|
|
62
|
+
)
|
|
63
|
+
name = r.strip().strip('"').strip("'") if r else ""
|
|
64
|
+
return name if name and len(name) < 50 else ""
|
|
65
|
+
except Exception:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def handle_admin_contact_command(
|
|
70
|
+
self, chat_id: str, text: str, clinic: Dict, db, llm_engine,
|
|
71
|
+
admin_name: str = "", demo_llm=None,
|
|
72
|
+
) -> List[str]:
|
|
73
|
+
phone = extract_phone_number(text)
|
|
74
|
+
if not phone:
|
|
75
|
+
if demo_llm:
|
|
76
|
+
try:
|
|
77
|
+
r = await demo_llm(
|
|
78
|
+
"Eres Conny. Te pidieron contactar a alguien pero no diste el número.",
|
|
79
|
+
"Respondé corto, pedí el número.",
|
|
80
|
+
temp=0.8, max_t=200,
|
|
81
|
+
)
|
|
82
|
+
if r:
|
|
83
|
+
return [r]
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
target_name = await parse_name(text, llm_engine)
|
|
89
|
+
jid = f"{phone}@s.whatsapp.net"
|
|
90
|
+
|
|
91
|
+
intro = ""
|
|
92
|
+
if demo_llm:
|
|
93
|
+
try:
|
|
94
|
+
r = await demo_llm(
|
|
95
|
+
"Acabas de recibir el primer mensaje de alguien nuevo.",
|
|
96
|
+
"Preséntate breve, como en WhatsApp. corto, natural. Máximo 2 líneas.",
|
|
97
|
+
temp=0.85, max_t=400,
|
|
98
|
+
)
|
|
99
|
+
if r:
|
|
100
|
+
intro = r
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if not intro:
|
|
105
|
+
try:
|
|
106
|
+
r, _ = await llm_engine.complete(
|
|
107
|
+
[{"role": "system", "content": "Eres Conny. Preséntate breve y natural, como en WhatsApp. Máximo 2 líneas."},
|
|
108
|
+
{"role": "user", "content": "Hola"}],
|
|
109
|
+
model_tier="fast", temperature=0.85, max_tokens=400,
|
|
110
|
+
)
|
|
111
|
+
if r:
|
|
112
|
+
intro = r.strip()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
if not intro:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
import os, httpx
|
|
120
|
+
bridge_url = os.environ.get("WHATSAPP_BRIDGE_URL", "http://127.0.0.1:8002")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
124
|
+
r = await client.post(f"{bridge_url}/send", json={"to": jid, "message": intro})
|
|
125
|
+
if r.status_code >= 400:
|
|
126
|
+
log.error(f"[demo_admin] send error: {r.status_code}")
|
|
127
|
+
return []
|
|
128
|
+
except Exception as e:
|
|
129
|
+
log.error(f"[demo_admin] send error: {e}")
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
admin_ref = f" {admin_name}" if admin_name else ""
|
|
133
|
+
name_ref = f" a {target_name}" if target_name else ""
|
|
134
|
+
if demo_llm:
|
|
135
|
+
try:
|
|
136
|
+
r = await demo_llm(
|
|
137
|
+
"Eres Conny. Acabas de hacer lo que el admin te pidió. Respondé en 2 burbujas separadas por |||. Primera: confirmá. Segunda: preguntá si necesita algo más.",
|
|
138
|
+
f"Confirmale al admin{admin_ref} que ya enviaste el mensaje{name_ref}.",
|
|
139
|
+
temp=0.82, max_t=400,
|
|
140
|
+
)
|
|
141
|
+
if r:
|
|
142
|
+
parts = [p.strip() for p in r.split("|||") if p.strip()]
|
|
143
|
+
if len(parts) >= 2:
|
|
144
|
+
return parts[:2]
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
gen_eng = llm_engine
|
|
150
|
+
if hasattr(self, "generator") and self.generator:
|
|
151
|
+
gen_eng = getattr(self.generator, "llm", None) or llm_engine
|
|
152
|
+
if gen_eng:
|
|
153
|
+
r, _ = await gen_eng.complete(
|
|
154
|
+
[{"role": "system", "content": "Eres Conny. Respondé en 2 mensajes separados por |||. Primero confirmá, segundo preguntá si necesita algo más."},
|
|
155
|
+
{"role": "user", "content": f"Confirmale al admin{admin_ref} que ya enviaste el mensaje{name_ref}."}],
|
|
156
|
+
model_tier="fast", temperature=0.82, max_tokens=400,
|
|
157
|
+
)
|
|
158
|
+
if r:
|
|
159
|
+
parts = [p.strip() for p in r.split("|||") if p.strip()]
|
|
160
|
+
if parts:
|
|
161
|
+
return parts[:2]
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
return []
|