@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,561 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PromptBuilderDeps:
|
|
11
|
+
build_fewshot_examples: Callable[[str, str, str], str]
|
|
12
|
+
get_sector_info: Callable[[str], Any]
|
|
13
|
+
now_provider: Callable[[], datetime]
|
|
14
|
+
sector_default: str = "otro"
|
|
15
|
+
db: Any = None
|
|
16
|
+
owner_style_controller: Any = None
|
|
17
|
+
kb_available: bool = False
|
|
18
|
+
format_kb_context: Optional[Callable[[str], str]] = None
|
|
19
|
+
resolve_persona_forbidden: Optional[Callable[[Dict[str, Any]], List[str]]] = None
|
|
20
|
+
v8_addon_builder: Optional[Callable[..., str]] = None
|
|
21
|
+
trainer_addon_builder: Optional[Callable[..., str]] = None
|
|
22
|
+
short_memory_builder: Optional[Callable[[List[Dict[str, Any]]], str]] = None
|
|
23
|
+
apply_archetype: Optional[Callable[[str, str], Any]] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def truncate_block(text: str, max_chars: int) -> str:
|
|
27
|
+
raw = (text or "").strip()
|
|
28
|
+
if not raw:
|
|
29
|
+
return ""
|
|
30
|
+
normalized = re.sub(r"\n{3,}", "\n\n", raw)
|
|
31
|
+
if len(normalized) <= max_chars:
|
|
32
|
+
return normalized
|
|
33
|
+
clipped = normalized[:max_chars].rsplit(" ", 1)[0].rstrip(" ,;:")
|
|
34
|
+
return f"{clipped}..."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_compact_examples(examples: str, max_chars: int = 650, max_lines: int = 16) -> str:
|
|
38
|
+
if not examples:
|
|
39
|
+
return ""
|
|
40
|
+
lines = [line.rstrip() for line in examples.splitlines() if line.strip()]
|
|
41
|
+
compact_lines: List[str] = []
|
|
42
|
+
total_chars = 0
|
|
43
|
+
for line in lines:
|
|
44
|
+
if total_chars + len(line) > max_chars:
|
|
45
|
+
break
|
|
46
|
+
compact_lines.append(line)
|
|
47
|
+
total_chars += len(line) + 1
|
|
48
|
+
return "\n".join(compact_lines[:max_lines])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_compact_system_prompt(
|
|
52
|
+
*,
|
|
53
|
+
clinic: Dict[str, Any],
|
|
54
|
+
patient: Dict[str, Any],
|
|
55
|
+
personality: Any,
|
|
56
|
+
search_context: str,
|
|
57
|
+
reasoning: Dict[str, Any],
|
|
58
|
+
kb_context: str = "",
|
|
59
|
+
context_summary: str = "",
|
|
60
|
+
pre_prompt_injection: str = "",
|
|
61
|
+
chat_id: str = "",
|
|
62
|
+
history: Optional[List[Dict[str, Any]]] = None,
|
|
63
|
+
deps: PromptBuilderDeps,
|
|
64
|
+
) -> str:
|
|
65
|
+
history = history or []
|
|
66
|
+
services = clinic.get("services", []) if isinstance(clinic.get("services"), list) else []
|
|
67
|
+
schedule = clinic.get("schedule", {}) if isinstance(clinic.get("schedule"), dict) else {}
|
|
68
|
+
pricing = clinic.get("pricing", {}) if isinstance(clinic.get("pricing"), dict) else {}
|
|
69
|
+
clinic_name = (clinic.get("name") or "el negocio").strip()
|
|
70
|
+
tagline = (clinic.get("tagline") or "").strip()
|
|
71
|
+
address = (clinic.get("address") or "").strip()
|
|
72
|
+
phone = (clinic.get("phone") or "").strip()
|
|
73
|
+
city = (clinic.get("city") or "Colombia").strip()
|
|
74
|
+
sector_id = clinic.get("sector", deps.sector_default) or "otro"
|
|
75
|
+
sector_emoji, sector_name, _, _ = deps.get_sector_info(sector_id)
|
|
76
|
+
patient_name = (patient.get("name") or "").strip()
|
|
77
|
+
visits = patient.get("visits", 0)
|
|
78
|
+
is_new = patient.get("is_new", True)
|
|
79
|
+
agent_name = (getattr(personality, "name", "") or "Conny").strip()
|
|
80
|
+
is_first_turn = not any(m.get("role") == "assistant" for m in history)
|
|
81
|
+
|
|
82
|
+
service_line = ", ".join(str(service).strip() for service in services[:6] if str(service).strip())
|
|
83
|
+
schedule_line = "; ".join(f"{day}: {hours}" for day, hours in list(schedule.items())[:4]) if schedule else ""
|
|
84
|
+
pricing_line = ", ".join(f"{name}: {value}" for name, value in list(pricing.items())[:5]) if pricing else ""
|
|
85
|
+
|
|
86
|
+
# Mandatos de Humanidad (Core v11)
|
|
87
|
+
human_mandates = [
|
|
88
|
+
"NUNCA uses frases de call center ('con gusto', 'un placer').",
|
|
89
|
+
"Escribe corto, como una persona real por WhatsApp.",
|
|
90
|
+
"Usa ||| para separar ideas en mensajes distintos.",
|
|
91
|
+
"Si no sabes algo, di 'déjame preguntarle al doctor y te aviso'."
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
tone_block = truncate_block(getattr(personality, "tone_instruction", "") or "", 360)
|
|
95
|
+
kb_block = truncate_block(kb_context, 850)
|
|
96
|
+
strategy = truncate_block(reasoning.get("response_strategy", ""), 220)
|
|
97
|
+
pre_prompt_injection = truncate_block(pre_prompt_injection, 550)
|
|
98
|
+
|
|
99
|
+
negocio_lines = [f"Eres {agent_name}, recepcionista real en {clinic_name}"]
|
|
100
|
+
if tagline: negocio_lines.append(f"({tagline})")
|
|
101
|
+
if service_line: negocio_lines.append(f"- {sector_emoji} {sector_name}: {service_line}")
|
|
102
|
+
if pricing_line: negocio_lines.append(f"Precios: {pricing_line}.")
|
|
103
|
+
negocio_bloque = " ".join(negocio_lines)
|
|
104
|
+
|
|
105
|
+
prompt = f"""{negocio_bloque}
|
|
106
|
+
|
|
107
|
+
## TU PERSONALIDAD:
|
|
108
|
+
{tone_block or "Amigable y directa."}
|
|
109
|
+
|
|
110
|
+
## REGLAS DE ORO:
|
|
111
|
+
{chr(10).join(f"- {m}" for m in human_mandates)}
|
|
112
|
+
|
|
113
|
+
## ESTRATEGIA ACTUAL:
|
|
114
|
+
{strategy}
|
|
115
|
+
|
|
116
|
+
## DATOS DE APOYO:
|
|
117
|
+
{kb_block}
|
|
118
|
+
|
|
119
|
+
## INSTRUCCIONES EXTRA:
|
|
120
|
+
{pre_prompt_injection}
|
|
121
|
+
|
|
122
|
+
Responde ahora, sé breve y humana."""
|
|
123
|
+
return prompt
|
|
124
|
+
|
|
125
|
+
owner_hint = ""
|
|
126
|
+
if trust_lines:
|
|
127
|
+
owner_hint = "El dueno del negocio pide: " + " / ".join(line.lstrip("- ") for line in trust_lines)
|
|
128
|
+
|
|
129
|
+
playbook_hint = ""
|
|
130
|
+
if playbook_lines:
|
|
131
|
+
playbook_hint = "Comportamientos especificos: " + " / ".join(line.lstrip("- ") for line in playbook_lines)
|
|
132
|
+
|
|
133
|
+
owner_style_hint = ""
|
|
134
|
+
if deps.owner_style_controller:
|
|
135
|
+
try:
|
|
136
|
+
raw = deps.owner_style_controller.build_prompt_addon(is_admin=False)
|
|
137
|
+
owner_style_hint = truncate_block(raw, 300)
|
|
138
|
+
except Exception:
|
|
139
|
+
owner_style_hint = ""
|
|
140
|
+
|
|
141
|
+
first_turn_hint = ""
|
|
142
|
+
if user_turns <= 1 or is_first_turn:
|
|
143
|
+
first_turn_hint = (
|
|
144
|
+
"primer contacto con este cliente. "
|
|
145
|
+
"responde desde lo que ya dijo, sin presentacion larga. "
|
|
146
|
+
"una sola pregunta util si hace falta."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
parts: List[str] = []
|
|
150
|
+
parts.append(
|
|
151
|
+
f"Eres {agent_name}, la asesora de {clinic_name}. "
|
|
152
|
+
f"Llevas el chat del negocio — respondes clientes, orientas, ayudas con citas. "
|
|
153
|
+
f"{negocio_bloque} "
|
|
154
|
+
f"Recibes mensajes por WhatsApp de personas en Colombia. "
|
|
155
|
+
f"Hablas como una persona real por WhatsApp: "
|
|
156
|
+
f"corto, calido, directo, sin frases de call center ni estructura de chatbot. "
|
|
157
|
+
f"NUNCA digas 'soy una IA', 'soy un bot', 'mi funcion es', 'hay confusion', "
|
|
158
|
+
f"'aqui lo que hago es' ni expongas tu estado interno. "
|
|
159
|
+
f"Si preguntan quien eres: eres la asesora del negocio, eso es todo. "
|
|
160
|
+
f"Puedes separar mensajes con ||| cuando sea natural mandar dos burbujas separadas."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if cliente_hint:
|
|
164
|
+
parts.append(cliente_hint)
|
|
165
|
+
if tone_block:
|
|
166
|
+
parts.append(tone_block)
|
|
167
|
+
if first_turn_hint:
|
|
168
|
+
parts.append(first_turn_hint)
|
|
169
|
+
if core_memory:
|
|
170
|
+
parts.append(core_memory)
|
|
171
|
+
if owner_hint:
|
|
172
|
+
parts.append(owner_hint)
|
|
173
|
+
if playbook_hint:
|
|
174
|
+
parts.append(playbook_hint)
|
|
175
|
+
if owner_style_hint:
|
|
176
|
+
parts.append(owner_style_hint)
|
|
177
|
+
|
|
178
|
+
persona_forbidden = []
|
|
179
|
+
if deps.resolve_persona_forbidden:
|
|
180
|
+
try:
|
|
181
|
+
persona_forbidden = list(deps.resolve_persona_forbidden(clinic or {}) or [])
|
|
182
|
+
except Exception:
|
|
183
|
+
persona_forbidden = []
|
|
184
|
+
if persona_forbidden:
|
|
185
|
+
parts.append("Evita estas aperturas o coletillas de plantilla: " + " / ".join(persona_forbidden[:8]))
|
|
186
|
+
|
|
187
|
+
if context_summary:
|
|
188
|
+
parts.append(truncate_block(context_summary, 400))
|
|
189
|
+
if pre_prompt_injection:
|
|
190
|
+
parts.append(truncate_block(pre_prompt_injection, 450))
|
|
191
|
+
if kb_block:
|
|
192
|
+
parts.append(f"Info oficial del negocio: {kb_block}")
|
|
193
|
+
if web_block:
|
|
194
|
+
parts.append(f"Complemento web: {web_block}")
|
|
195
|
+
if strategy:
|
|
196
|
+
parts.append(strategy)
|
|
197
|
+
if compact_examples:
|
|
198
|
+
parts.append(compact_examples)
|
|
199
|
+
|
|
200
|
+
parts.append(
|
|
201
|
+
'Cuando tengas nombre + servicio + fecha + telefono confirmados, agrega al final: '
|
|
202
|
+
'CITA:{"patient_name":"...","service":"...","datetime_slot":"...","patient_phone":"...","notes":"..."} '
|
|
203
|
+
'Cuando el cliente diga su nombre, agrega: NOMBRE:{"name":"..."}'
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return "\n\n".join(p for p in parts if p and p.strip())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_system_prompt(
|
|
210
|
+
*,
|
|
211
|
+
clinic: Dict[str, Any],
|
|
212
|
+
patient: Dict[str, Any],
|
|
213
|
+
personality: Any,
|
|
214
|
+
search_context: str,
|
|
215
|
+
reasoning: Dict[str, Any],
|
|
216
|
+
kb_context: str = "",
|
|
217
|
+
chat_id: str = "",
|
|
218
|
+
history: Optional[List[Dict[str, Any]]] = None,
|
|
219
|
+
user_msg: str = "",
|
|
220
|
+
deps: PromptBuilderDeps,
|
|
221
|
+
) -> str:
|
|
222
|
+
history = history or []
|
|
223
|
+
services = clinic.get("services", [])
|
|
224
|
+
schedule = clinic.get("schedule", {})
|
|
225
|
+
clinic_name = clinic.get("name", "la clinica")
|
|
226
|
+
tagline = clinic.get("tagline", "")
|
|
227
|
+
address = clinic.get("address", "")
|
|
228
|
+
phone = clinic.get("phone", "")
|
|
229
|
+
pricing = clinic.get("pricing", {})
|
|
230
|
+
|
|
231
|
+
sector_id = clinic.get("sector", deps.sector_default) or "otro"
|
|
232
|
+
sector_emoji, sector_name, sector_services, sector_keywords = deps.get_sector_info(sector_id)
|
|
233
|
+
sector_block = (
|
|
234
|
+
f"\nSECTOR: {sector_emoji} {sector_name}\n"
|
|
235
|
+
f"Vocabulario tipico del sector: {sector_keywords}\n"
|
|
236
|
+
f"Si el paciente no menciona servicio especifico, los mas comunes son: {sector_services}"
|
|
237
|
+
if sector_id != "otro" else ""
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
now = deps.now_provider()
|
|
241
|
+
patient_name = patient.get("name", "")
|
|
242
|
+
visits = patient.get("visits", 0)
|
|
243
|
+
is_new = patient.get("is_new", True)
|
|
244
|
+
last_service = patient.get("last_service", "")
|
|
245
|
+
|
|
246
|
+
if not is_new and patient_name:
|
|
247
|
+
patient_ctx = f"Conoces a este paciente: se llama {patient_name}, ha escrito {visits} veces."
|
|
248
|
+
if last_service:
|
|
249
|
+
patient_ctx += f" La ultima vez pregunto por {last_service}."
|
|
250
|
+
elif not is_new:
|
|
251
|
+
patient_ctx = f"Este paciente ha escrito {visits} veces antes."
|
|
252
|
+
else:
|
|
253
|
+
patient_ctx = "Primera vez que este paciente escribe."
|
|
254
|
+
|
|
255
|
+
svcs_line = ", ".join(services) if services else "consultar directamente"
|
|
256
|
+
sched_line = "; ".join(f"{k}: {v}" for k, v in schedule.items()) if schedule else "lunes a sabado 9am-6pm"
|
|
257
|
+
|
|
258
|
+
pricing_block = ""
|
|
259
|
+
if pricing and isinstance(pricing, dict):
|
|
260
|
+
price_lines = [f" - {svc}: {precio}" for svc, precio in pricing.items()]
|
|
261
|
+
if price_lines:
|
|
262
|
+
pricing_block = "\nPRECIOS CONFIGURADOS:\n" + "\n".join(price_lines)
|
|
263
|
+
|
|
264
|
+
user_turns = len([m for m in history if m.get("role") == "user"])
|
|
265
|
+
first_turn_block = ""
|
|
266
|
+
if user_turns <= 1:
|
|
267
|
+
first_turn_block = (
|
|
268
|
+
"PRIMER CONTACTO REAL:\n"
|
|
269
|
+
"- responde desde lo que la persona ya dijo, sin presentacion larga\n"
|
|
270
|
+
"- si ya dijo el motivo, entra directo a eso\n"
|
|
271
|
+
"- no digas 'en que te puedo ayudar' ni 'como puedo ayudarte'\n"
|
|
272
|
+
"- evita aperturas de mesa de ayuda: 'buenas, en que te ayudo', 'hola, en que te ayudo'\n"
|
|
273
|
+
"- no abras con 'oye', 'mira' ni 'que te trae por aca'\n"
|
|
274
|
+
"- si solo saludo, responde el saludo y haz una pregunta util, corto\n"
|
|
275
|
+
"- maximo una pregunta util, no un formulario\n"
|
|
276
|
+
)
|
|
277
|
+
off_topic_block = (
|
|
278
|
+
"Si preguntan algo fuera del negocio, responde corto y vuelve al tema. "
|
|
279
|
+
"Si el cliente mezcla cosas, toma solo la siguiente pieza util. "
|
|
280
|
+
"Si viene agresivo, manten el eje sin moralizarlo. "
|
|
281
|
+
"Si preguntan si eres bot o IA: di que eres la asesora del negocio y sigue — "
|
|
282
|
+
"NUNCA confirmes ser bot, IA, chatbot ni software. "
|
|
283
|
+
"NUNCA digas 'hay confusion', 'no se cual es el negocio', 'mi funcion es' ni 'aqui lo que hago es'."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
kb_block = ""
|
|
287
|
+
if kb_context:
|
|
288
|
+
if deps.kb_available and deps.format_kb_context:
|
|
289
|
+
kb_block = f"\n{deps.format_kb_context(kb_context)}\n"
|
|
290
|
+
else:
|
|
291
|
+
kb_block = (
|
|
292
|
+
"\n=== INFORMACION OFICIAL DE LA CLINICA ===\n"
|
|
293
|
+
f"{kb_context}\n"
|
|
294
|
+
"=== FIN INFORMACION OFICIAL ===\n"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
web_block = ""
|
|
298
|
+
if search_context:
|
|
299
|
+
web_block = f"\nINFO COMPLEMENTARIA (web - usar solo si la clinica no la tiene en su KB):\n{search_context[:900]}"
|
|
300
|
+
|
|
301
|
+
strategy_block = ""
|
|
302
|
+
if reasoning.get("response_strategy"):
|
|
303
|
+
strategy_block = f"\nESTRATEGIA: {reasoning['response_strategy']}"
|
|
304
|
+
|
|
305
|
+
trust_block = ""
|
|
306
|
+
try:
|
|
307
|
+
if deps.db:
|
|
308
|
+
trust_rules = deps.db.get_all_trust_rules()
|
|
309
|
+
if trust_rules:
|
|
310
|
+
trust_block = "El dueno del negocio pide: " + " / ".join(
|
|
311
|
+
(rule["rule"] + (f' - ejemplo: "{rule["example_good"]}"' if rule.get("example_good") else ""))
|
|
312
|
+
for rule in trust_rules
|
|
313
|
+
) + "."
|
|
314
|
+
except Exception:
|
|
315
|
+
trust_block = ""
|
|
316
|
+
|
|
317
|
+
playbook_block = ""
|
|
318
|
+
try:
|
|
319
|
+
if deps.db:
|
|
320
|
+
playbooks = deps.db.get_behavior_playbooks(limit=8)
|
|
321
|
+
if playbooks:
|
|
322
|
+
lines = []
|
|
323
|
+
for playbook in playbooks:
|
|
324
|
+
trigger = (playbook.get("trigger_text") or "").strip()
|
|
325
|
+
example = (playbook.get("response_example") or "").strip()
|
|
326
|
+
instruction = (playbook.get("instruction") or "").strip()
|
|
327
|
+
bubble_count = max(1, int(playbook.get("bubble_count", 1) or 1))
|
|
328
|
+
if not trigger or not example:
|
|
329
|
+
continue
|
|
330
|
+
line = f"• Cuando pase esto: {trigger}"
|
|
331
|
+
if instruction:
|
|
332
|
+
line += f"\n Intencion: {instruction}"
|
|
333
|
+
line += f'\n Respondelo parecido a esto ({bubble_count} burbuja{"s" if bubble_count != 1 else ""}): "{example}"'
|
|
334
|
+
lines.append(line)
|
|
335
|
+
if lines:
|
|
336
|
+
playbook_block = "Comportamientos aprendidos del dueno: " + " / ".join(lines)
|
|
337
|
+
except Exception:
|
|
338
|
+
playbook_block = ""
|
|
339
|
+
|
|
340
|
+
custom_block = ""
|
|
341
|
+
if getattr(personality, "custom_phrases", None):
|
|
342
|
+
lines = [f' "{shortcut}": "{phrase}"' for shortcut, phrase in personality.custom_phrases.items()]
|
|
343
|
+
custom_block = "\n\nFRASES PROPIAS DE ESTA CLINICA:\n" + "\n".join(lines)
|
|
344
|
+
|
|
345
|
+
core_mem_block = ""
|
|
346
|
+
try:
|
|
347
|
+
if deps.db:
|
|
348
|
+
core_mem_block = deps.db.get_core_memory_block()
|
|
349
|
+
except Exception:
|
|
350
|
+
core_mem_block = ""
|
|
351
|
+
|
|
352
|
+
city = clinic.get("city", "Medellin")
|
|
353
|
+
address_lower = (address or "").lower()
|
|
354
|
+
barrio = clinic.get("barrio", "")
|
|
355
|
+
is_poblado = (
|
|
356
|
+
"poblado" in address_lower or
|
|
357
|
+
"poblado" in barrio.lower() or
|
|
358
|
+
"poblado" in (clinic.get("tagline") or "").lower() or
|
|
359
|
+
"poblado" in clinic_name.lower()
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if getattr(personality, "tone_instruction", ""):
|
|
363
|
+
tone = personality.tone_instruction.strip()
|
|
364
|
+
elif getattr(personality, "formality_level", 0.5) > 0.8 and deps.apply_archetype:
|
|
365
|
+
tone = deps.apply_archetype("profesional", personality.name).tone_instruction.strip()
|
|
366
|
+
elif getattr(personality, "formality_level", 0.5) < 0.3 and deps.apply_archetype:
|
|
367
|
+
tone = deps.apply_archetype("directa", personality.name).tone_instruction.strip()
|
|
368
|
+
elif is_poblado and sector_id == "estetica" and deps.apply_archetype:
|
|
369
|
+
tone = deps.apply_archetype("luxury", personality.name).tone_instruction.strip()
|
|
370
|
+
elif deps.apply_archetype:
|
|
371
|
+
tone = deps.apply_archetype("amigable", personality.name).tone_instruction.strip()
|
|
372
|
+
else:
|
|
373
|
+
tone = getattr(personality, "tone_instruction", "").strip()
|
|
374
|
+
|
|
375
|
+
sector_layer = _build_sector_layer(sector_id, is_poblado)
|
|
376
|
+
|
|
377
|
+
data_parts = [f"Servicios: {svcs_line}", f"Horario: {sched_line}"]
|
|
378
|
+
if address:
|
|
379
|
+
data_parts.append(f"Direccion: {address}")
|
|
380
|
+
if phone:
|
|
381
|
+
data_parts.append(f"Telefono: {phone}")
|
|
382
|
+
if pricing_block:
|
|
383
|
+
data_parts.append(pricing_block.strip())
|
|
384
|
+
if sector_block:
|
|
385
|
+
data_parts.append(sector_block.strip())
|
|
386
|
+
|
|
387
|
+
agent_name = personality.name
|
|
388
|
+
v8_history = history or []
|
|
389
|
+
archetype = getattr(personality, "archetype", "amigable")
|
|
390
|
+
is_first_turn = not any(m.get("role") == "assistant" for m in v8_history)
|
|
391
|
+
|
|
392
|
+
v8_addon_block = ""
|
|
393
|
+
if deps.v8_addon_builder:
|
|
394
|
+
raw_v8 = deps.v8_addon_builder(chat_id=chat_id, archetype=archetype, history=v8_history)
|
|
395
|
+
v8_addon_block = ("\n" + raw_v8 + "\n") if raw_v8 else ""
|
|
396
|
+
|
|
397
|
+
trainer_addon_block = ""
|
|
398
|
+
if deps.trainer_addon_builder and chat_id:
|
|
399
|
+
raw_trainer = deps.trainer_addon_builder(
|
|
400
|
+
chat_id,
|
|
401
|
+
clinic=clinic,
|
|
402
|
+
user_msg=user_msg,
|
|
403
|
+
is_admin=False,
|
|
404
|
+
patient=patient,
|
|
405
|
+
)
|
|
406
|
+
trainer_addon_block = ("\n" + raw_trainer + "\n") if raw_trainer else ""
|
|
407
|
+
|
|
408
|
+
first_turn_compact = ""
|
|
409
|
+
if is_first_turn:
|
|
410
|
+
first_turn_compact = (
|
|
411
|
+
"primer contacto. sin protocolo. "
|
|
412
|
+
"responde desde lo que ya trajo la persona, una sola pregunta si hace falta."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
fewshot = deps.build_fewshot_examples(sector_id, clinic_name, agent_name)
|
|
416
|
+
|
|
417
|
+
hour = now.hour
|
|
418
|
+
time_ctx = (
|
|
419
|
+
"es de madrugada, hay poca gente despierta" if hour < 6 else
|
|
420
|
+
"es temprano en la manana" if hour < 9 else
|
|
421
|
+
"es media manana" if hour < 12 else
|
|
422
|
+
"es mediodia" if hour < 14 else
|
|
423
|
+
"es la tarde" if hour < 20 else
|
|
424
|
+
"es la noche"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
memory_block = ""
|
|
428
|
+
if history and deps.short_memory_builder:
|
|
429
|
+
try:
|
|
430
|
+
memory_block = deps.short_memory_builder(history)
|
|
431
|
+
except Exception:
|
|
432
|
+
memory_block = ""
|
|
433
|
+
|
|
434
|
+
data_inline = ". ".join(part.strip() for part in data_parts if part.strip())
|
|
435
|
+
kb_inline = kb_block.strip().replace("\n", " ") if kb_block else ""
|
|
436
|
+
web_inline = web_block.strip().replace("\n", " ") if web_block else ""
|
|
437
|
+
|
|
438
|
+
return f"""soy {agent_name}{f", trabajo en {clinic_name}" if clinic_name else ""}{f" - {tagline}" if tagline else ""}. llevo tiempo en esto. conozco a los clientes - se cuando estan listos para agendar y cuando solo estan explorando. hablo directo, sin rodeos, como una persona real por whatsapp. nunca digo 'soy una ia', 'soy un bot', 'mi funcion es', 'hay confusion' ni 'aqui lo que hago es'. si preguntan quien soy: soy la asesora del negocio.
|
|
439
|
+
|
|
440
|
+
hoy es {now.strftime("%A %d de %B")}, {time_ctx}, estoy en {city}, colombia.
|
|
441
|
+
|
|
442
|
+
{data_inline}
|
|
443
|
+
{patient_ctx.strip() if patient_ctx and patient_ctx.strip() else ""}
|
|
444
|
+
{memory_block}
|
|
445
|
+
{kb_inline}
|
|
446
|
+
{web_inline}
|
|
447
|
+
{(core_mem_block or "").strip()}
|
|
448
|
+
{(trust_block or "").strip()}
|
|
449
|
+
{(playbook_block or "").strip()}
|
|
450
|
+
{(tone or "").strip()}
|
|
451
|
+
{(first_turn_compact or "").strip()}
|
|
452
|
+
{(off_topic_block or "").strip()}
|
|
453
|
+
{(sector_layer or "").strip()}
|
|
454
|
+
{(strategy_block or "").strip()}
|
|
455
|
+
{(custom_block or "").strip()}
|
|
456
|
+
{(v8_addon_block or "").strip()}
|
|
457
|
+
{(trainer_addon_block or "").strip()}
|
|
458
|
+
|
|
459
|
+
asi respondo cuando alguien me escribe:
|
|
460
|
+
|
|
461
|
+
{fewshot}
|
|
462
|
+
|
|
463
|
+
cuando tenga nombre + servicio + fecha + telefono, escribo al final: CITA:{{"patient_name":"...","service":"...","datetime_slot":"...","patient_phone":"...","notes":"..."}}
|
|
464
|
+
cuando el cliente diga su nombre, escribo al final: NOMBRE:{{"name":"..."}}"""
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _build_sector_layer(sector_id: str, is_poblado: bool) -> str:
|
|
468
|
+
if sector_id == "estetica":
|
|
469
|
+
if is_poblado:
|
|
470
|
+
return """
|
|
471
|
+
la clienta ya viene con algo en mente - no hay que convencerla, hay que escucharla bien y resolver sus dudas sin presionarla. el miedo mas comun es quedar exagerada o diferente. lo que genera confianza es mostrar que la dra trabaja conservador y que la valoracion es sin compromiso.
|
|
472
|
+
"""
|
|
473
|
+
return """
|
|
474
|
+
PERFIL CLINICA ESTETICA:
|
|
475
|
+
Tu clienta ya sabe lo que quiere - no expliques que es botox.
|
|
476
|
+
Pregunta que zona le molesta, diagnostica, conecta con la solucion.
|
|
477
|
+
El cierre siempre es hacia la valoracion gratuita con la especialista.
|
|
478
|
+
Precios van solo cuando preguntan directamente.
|
|
479
|
+
"""
|
|
480
|
+
if sector_id == "dental":
|
|
481
|
+
return """
|
|
482
|
+
el paciente dental aplaza la cita por miedo o pena, no por falta de ganas. lo que lo mueve es que alguien le diga que no lo van a juzgar y que no va a doler tanto. urgencia real (dolor) -> cita hoy. rutina -> cualquier dia de la semana.
|
|
483
|
+
"""
|
|
484
|
+
if sector_id == "veterinaria":
|
|
485
|
+
return """
|
|
486
|
+
para el dueno de una mascota, ese animal es familia. cuando llega con urgencia, el panico es real - necesita sentir que van a atender a su animal de inmediato. para citas de rutina, el vinculo emocional sigue ahi: usa siempre el nombre de la mascota.
|
|
487
|
+
"""
|
|
488
|
+
if sector_id == "restaurante":
|
|
489
|
+
return """
|
|
490
|
+
el cliente de restaurante quiere saber si hay espacio, a que hora, y si el lugar va a estar a la altura de la ocasion. para reservas especiales (cumpleanos, aniversario) el detalle de que lo notaste ya hace diferencia. la confirmacion de la reserva da tranquilidad.
|
|
491
|
+
"""
|
|
492
|
+
if sector_id == "gimnasio":
|
|
493
|
+
return """
|
|
494
|
+
la persona llega con una meta clara pero con historial de intentos fallidos. lo que necesita es sentir que esta vez va a ser diferente - y eso empieza en el primer mensaje. la evaluacion gratuita es el gancho correcto: baja la barrera de entrada sin comprometer nada.
|
|
495
|
+
"""
|
|
496
|
+
if sector_id == "belleza":
|
|
497
|
+
return """
|
|
498
|
+
la clienta de salon tiene una imagen en mente y miedo de que no quede bien. antes de agendar quiere saber si el estilista puede lograr lo que imagina. referencias y portafolio cierran mas que cualquier precio. la confianza en el profesional es la venta.
|
|
499
|
+
"""
|
|
500
|
+
if sector_id == "spa":
|
|
501
|
+
return """
|
|
502
|
+
el cliente de spa llega estresado y quiere desconectarse - no quiere friccion ni formularios. respuesta rapida, espacio disponible, precio claro. cuando son dos personas juntas, la coordinacion de horarios es el unico obstaculo real.
|
|
503
|
+
"""
|
|
504
|
+
if sector_id == "medico":
|
|
505
|
+
return """
|
|
506
|
+
el paciente medico tiene algo que le preocupa y necesita sentir que lo van a escuchar y atender pronto. urgencia real -> hoy o manana. rutina -> esta semana. nunca minimices el sintoma - valida primero, luego orienta hacia la cita.
|
|
507
|
+
"""
|
|
508
|
+
if sector_id == "psicologo":
|
|
509
|
+
return """
|
|
510
|
+
quien busca psicologo ya dio el paso mas dificil al escribir. no hay que convencerlo - hay que recibirlo bien y no ponerle obstaculos. la primera pregunta no es de precio ni de horario - es de que lo trajo hoy. virtual o presencial es la decision mas practica que toma.
|
|
511
|
+
"""
|
|
512
|
+
if sector_id == "abogado":
|
|
513
|
+
return """
|
|
514
|
+
el cliente legal llega con un problema real y a veces con angustia. necesita calma y claridad - no tecnicismos ni promesas. tu trabajo es agendar la consulta inicial y hacer que llegue tranquilo. nunca opines sobre el caso - eso es para el abogado.
|
|
515
|
+
"""
|
|
516
|
+
if sector_id == "inmobiliaria":
|
|
517
|
+
return """
|
|
518
|
+
comprar o arrendar es una decision enorme. el cliente quiere sentir que el asesor entiende lo que busca antes de mostrar propiedades. zona y presupuesto son el filtro - pero detras de eso hay un motivo real (familia que crece, inversion, mudanza) que hay que entender.
|
|
519
|
+
"""
|
|
520
|
+
if sector_id == "taller":
|
|
521
|
+
return """
|
|
522
|
+
el cliente de taller no sabe de mecanica y a veces teme que lo enganen. lo que genera confianza es el diagnostico transparente: decir que tiene el carro antes de cobrar nada. cuando lleva un sintoma, lo primero es escuchar bien y no asumir.
|
|
523
|
+
"""
|
|
524
|
+
if sector_id == "academia":
|
|
525
|
+
return """
|
|
526
|
+
el estudiante potencial tiene una razon concreta para aprender - trabajo, viaje, examen. esa razon es la palanca. el diagnostico de nivel es el primer paso natural: sin costo, sin compromiso, y le dice exactamente de donde parte.
|
|
527
|
+
"""
|
|
528
|
+
if sector_id == "nutricion":
|
|
529
|
+
return """
|
|
530
|
+
el paciente de nutricion ya intento varias veces y no le funciono. no necesita otro plan de dieta - necesita que alguien entienda por que siempre se rompe. la primera consulta debe explorar el patron, no solo el peso objetivo.
|
|
531
|
+
"""
|
|
532
|
+
if sector_id == "fisioterapia":
|
|
533
|
+
return """
|
|
534
|
+
el paciente llega con dolor o limitacion que afecta su dia a dia. quiere saber cuanto va a durar el tratamiento y si realmente va a funcionar. la evaluacion inicial responde esas dos preguntas - ese es su valor real.
|
|
535
|
+
"""
|
|
536
|
+
if sector_id == "tattoo":
|
|
537
|
+
return """
|
|
538
|
+
el cliente de tatuaje tiene una idea pero a veces no sabe como materializarla. lo que genera confianza es ver el portafolio del artista y sentir que va a entender la idea. el miedo al arrepentimiento se resuelve con una buena conversacion antes del diseno.
|
|
539
|
+
"""
|
|
540
|
+
if sector_id == "hotel":
|
|
541
|
+
return """
|
|
542
|
+
el huesped quiere saber si hay disponibilidad, que incluye, y si vale la pena. para ocasiones especiales (luna de miel, cumpleanos) el detalle personalizado hace diferencia. la confirmacion rapida y clara de la reserva da tranquilidad.
|
|
543
|
+
"""
|
|
544
|
+
if sector_id == "fotografia":
|
|
545
|
+
return """
|
|
546
|
+
el cliente contrata un fotografo para preservar algo importante. el miedo es que las fotos no capturen lo que imaginaba. ver el portafolio antes de cotizar es siempre el primer paso. para bodas y eventos, el tiempo de respuesta importa - las fechas se agotan.
|
|
547
|
+
"""
|
|
548
|
+
if sector_id == "coworking":
|
|
549
|
+
return """
|
|
550
|
+
el cliente de coworking optimiza: quiere el mejor espacio al menor costo con la menor friccion. pregunta precio rapido. lo que cierra es que sea facil empezar - sin contrato largo, sin burocracia. para empresas, factura y contrato son no negociables.
|
|
551
|
+
"""
|
|
552
|
+
return ""
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
__all__ = [
|
|
556
|
+
"PromptBuilderDeps",
|
|
557
|
+
"build_compact_examples",
|
|
558
|
+
"build_compact_system_prompt",
|
|
559
|
+
"build_system_prompt",
|
|
560
|
+
"truncate_block",
|
|
561
|
+
]
|
package/conny_cron.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
conny_cron.py — Scheduled tasks for Conny (memory consolidation, cleanup).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import logging, asyncio
|
|
6
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
7
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("conny.cron")
|
|
10
|
+
|
|
11
|
+
_scheduler: AsyncIOScheduler = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_scheduler(memory_engine=None, instance_ids: list = None):
|
|
15
|
+
"""Initialize the cron scheduler. Call during app startup."""
|
|
16
|
+
global _scheduler
|
|
17
|
+
if _scheduler:
|
|
18
|
+
return _scheduler
|
|
19
|
+
|
|
20
|
+
_scheduler = AsyncIOScheduler()
|
|
21
|
+
|
|
22
|
+
if memory_engine and instance_ids:
|
|
23
|
+
for iid in instance_ids:
|
|
24
|
+
_scheduler.add_job(
|
|
25
|
+
_run_consolidation,
|
|
26
|
+
CronTrigger(day_of_week="sun", hour=3, minute=0),
|
|
27
|
+
args=[memory_engine, iid],
|
|
28
|
+
id=f"consolidation_{iid}",
|
|
29
|
+
replace_existing=True,
|
|
30
|
+
)
|
|
31
|
+
# Weekly report every Monday at 9am
|
|
32
|
+
_scheduler.add_job(
|
|
33
|
+
_send_weekly_report,
|
|
34
|
+
CronTrigger(day_of_week="mon", hour=9, minute=0),
|
|
35
|
+
args=[iid],
|
|
36
|
+
id=f"weekly_report_{iid}",
|
|
37
|
+
replace_existing=True,
|
|
38
|
+
)
|
|
39
|
+
log.info(f"[cron] consolidation (Sun 3am) + weekly report (Mon 9am) scheduled for {iid}")
|
|
40
|
+
|
|
41
|
+
_scheduler.start()
|
|
42
|
+
log.info("[cron] scheduler started")
|
|
43
|
+
return _scheduler
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run_consolidation(memory_engine, instance_id: str):
|
|
47
|
+
"""Run weekly memory consolidation for an instance."""
|
|
48
|
+
try:
|
|
49
|
+
await memory_engine.weekly_consolidation(instance_id)
|
|
50
|
+
log.info(f"[cron] consolidation complete: {instance_id}")
|
|
51
|
+
except Exception as e:
|
|
52
|
+
log.error(f"[cron] consolidation failed for {instance_id}: {e}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _send_weekly_report(instance_id: str):
|
|
56
|
+
"""Send weekly report to admin."""
|
|
57
|
+
try:
|
|
58
|
+
from conny_weekly_report import generate_weekly_report
|
|
59
|
+
report = await generate_weekly_report(instance_id)
|
|
60
|
+
log.info(f"[cron] weekly report generated for {instance_id}")
|
|
61
|
+
# TODO: wire send_fn when admin_jid is available in cron context
|
|
62
|
+
except Exception as e:
|
|
63
|
+
log.error(f"[cron] weekly report failed: {e}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def shutdown_scheduler():
|
|
67
|
+
"""Shutdown the scheduler gracefully."""
|
|
68
|
+
global _scheduler
|
|
69
|
+
if _scheduler:
|
|
70
|
+
_scheduler.shutdown(wait=False)
|
|
71
|
+
_scheduler = None
|
|
72
|
+
log.info("[cron] scheduler stopped")
|