JeCabrera commited on
Commit
fb305fc
·
unverified ·
0 Parent(s):

Add files via upload

Browse files
Files changed (9) hide show
  1. .gitattributes +36 -0
  2. LICENSE +201 -0
  3. README.md +11 -0
  4. app.py +384 -0
  5. firebase_store.py +82 -0
  6. requirements.txt +4 -0
  7. robocopy_logo.png +0 -0
  8. session_state.py +305 -0
  9. system_prompts.py +71 -0
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docs/gemini-chatbot.gif filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ title: Chatbot_Email
4
+ sdk: streamlit
5
+ emoji: 🏆
6
+ colorFrom: red
7
+ colorTo: yellow
8
+ pinned: true
9
+ sdk_version: 1.45.0
10
+ short_description: Write email with AI
11
+ ---
app.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import os
3
+ import uuid
4
+ import traceback
5
+ import joblib
6
+ import streamlit as st
7
+ from dotenv import load_dotenv
8
+ from streamlit.runtime.scriptrunner import get_script_run_ctx
9
+ from system_prompts import get_unified_email_prompt
10
+ from session_state import (
11
+ SessionState,
12
+ DEFAULT_GEMINI_MODEL,
13
+ DATA_DIR,
14
+ )
15
+
16
+ # Inicializar el estado de la sesión
17
+ state = SessionState()
18
+ STREAM_SETTINGS = {'batch_size': 1, 'delay_seconds': 0.01}
19
+ user_past_chats_list_path = None
20
+
21
+ def get_user_namespace():
22
+ """
23
+ Obtiene un namespace para persistencia.
24
+ - Si CHATBOT_USER_NAMESPACE está definido, se usa ese valor (recomendado para app de un solo usuario).
25
+ - Si MULTI_USER_MODE=true, usa session_id para aislar por sesión.
26
+ - Caso contrario, usa un user_id persistente en query params para aislar por usuario y sobrevivir reinicios.
27
+ """
28
+ configured_namespace = os.environ.get('CHATBOT_USER_NAMESPACE')
29
+ if configured_namespace:
30
+ return configured_namespace
31
+
32
+ is_multi_user_mode = os.environ.get('MULTI_USER_MODE', 'false').strip().lower() == 'true'
33
+ if is_multi_user_mode:
34
+ context = get_script_run_ctx()
35
+ if context and getattr(context, 'session_id', None):
36
+ return context.session_id
37
+
38
+ user_id = st.query_params.get('uid')
39
+ if not user_id:
40
+ user_id = uuid.uuid4().hex
41
+ st.query_params['uid'] = user_id
42
+ return f'user_{user_id}'
43
+
44
+ # Función para detectar saludos y generar respuestas personalizadas
45
+ def is_greeting(text):
46
+ """Detecta si el texto es un saludo simple"""
47
+ text = text.lower().strip()
48
+ greetings = ['hola', 'hey', 'saludos', 'buenos días', 'buenas tardes', 'buenas noches', 'hi', 'hello']
49
+
50
+ # Solo considerar como saludo si es el primer mensaje del usuario
51
+ # y es un saludo simple
52
+ is_simple_greeting = any(greeting in text for greeting in greetings) and len(text.split()) < 4
53
+ return is_simple_greeting and len(state.messages) == 0
54
+
55
+ # Función para procesar mensajes (unifica la lógica de procesamiento)
56
+ def process_message(prompt, is_example=False):
57
+ """Procesa un mensaje del usuario, ya sea directo o de un ejemplo"""
58
+ handle_chat_title(prompt)
59
+
60
+ with st.chat_message('user', avatar=USER_AVATAR_ICON):
61
+ st.markdown(prompt)
62
+
63
+ state.add_message('user', prompt, USER_AVATAR_ICON)
64
+
65
+ # Obtener el prompt mejorado primero
66
+ enhanced_prompt = get_enhanced_prompt(prompt, is_example)
67
+
68
+ # Mover la respuesta del modelo después del mensaje del usuario
69
+ with st.chat_message(MODEL_ROLE, avatar=AI_AVATAR_ICON):
70
+ try:
71
+ message_placeholder = st.empty()
72
+ typing_indicator = st.empty()
73
+ typing_indicator.markdown("*Generando respuesta...*")
74
+
75
+ response = state.send_message(enhanced_prompt)
76
+ full_response = stream_response(response, message_placeholder, typing_indicator, STREAM_SETTINGS)
77
+
78
+ if full_response:
79
+ state.add_message(MODEL_ROLE, full_response, AI_AVATAR_ICON)
80
+ if hasattr(state.chat, 'get_history'):
81
+ state.gemini_history = state.chat.get_history()
82
+ else:
83
+ state.gemini_history = getattr(state.chat, 'history', [])
84
+ state.save_chat_history()
85
+
86
+ except Exception as e:
87
+ show_detailed_error("process_message", e)
88
+ return
89
+
90
+ def show_detailed_error(context, error):
91
+ """Muestra errores con contexto y traza para facilitar debug en producción."""
92
+ st.error(f"Ocurrió un error en {context}. Intenta de nuevo.")
93
+ with st.expander("Ver detalles técnicos del error"):
94
+ st.code(f"{type(error).__name__}: {error}\n\n{traceback.format_exc()}")
95
+
96
+ def handle_chat_title(prompt):
97
+ """Maneja la lógica del título del chat"""
98
+ if state.chat_id not in past_chats:
99
+ temp_title = f'SesiónChat-{state.chat_id}'
100
+ generated_title = state.generate_chat_title(prompt)
101
+ state.chat_title = generated_title or temp_title
102
+ past_chats[state.chat_id] = state.chat_title
103
+ else:
104
+ state.chat_title = past_chats[state.chat_id]
105
+ joblib.dump(past_chats, user_past_chats_list_path)
106
+
107
+ def get_enhanced_prompt(prompt, is_example):
108
+ """Genera el prompt mejorado según el tipo de mensaje"""
109
+ if is_greeting(prompt):
110
+ return (
111
+ "Responde ÚNICAMENTE con esta frase, sin agregar nada más: "
112
+ "\"¡Perfecto! Empecemos por la primera: "
113
+ "¿Quién es tu audiencia ideal para este correo? "
114
+ "Descríbela con detalle (contexto, problema principal, deseo y nivel de conciencia).\""
115
+ )
116
+ elif is_example:
117
+ return (
118
+ f"El usuario seleccionó esta pregunta del menú: '{prompt}'. "
119
+ "Respóndela de forma directa, útil y conversacional, con ejemplos concretos. "
120
+ "Después de responder, invita al usuario a iniciar el flujo de 5 preguntas en este orden: audiencia, producto, nombre, CTA y ángulo."
121
+ )
122
+ return prompt
123
+
124
+ def stream_response(response, message_placeholder, typing_indicator, stream_settings):
125
+ """Maneja el streaming de la respuesta"""
126
+ full_response = ''
127
+ batch_size = max(1, int(stream_settings.get('batch_size', 24)))
128
+ delay_seconds = max(0.0, float(stream_settings.get('delay_seconds', 0.0)))
129
+ pending_chars = 0
130
+
131
+ try:
132
+ for chunk in response:
133
+ if chunk.text:
134
+ for ch in chunk.text:
135
+ full_response += ch
136
+ pending_chars += 1
137
+ if pending_chars >= batch_size:
138
+ if delay_seconds:
139
+ time.sleep(delay_seconds)
140
+ message_placeholder.markdown(full_response + '▌')
141
+ pending_chars = 0
142
+ except Exception as e:
143
+ show_detailed_error("stream_response", e)
144
+ return ''
145
+
146
+ if pending_chars > 0:
147
+ if delay_seconds:
148
+ time.sleep(delay_seconds)
149
+ message_placeholder.markdown(full_response + '▌')
150
+
151
+ typing_indicator.empty()
152
+ message_placeholder.markdown(full_response)
153
+ return full_response
154
+
155
+ # Función para cargar CSS personalizado
156
+ def load_css(file_path):
157
+ with open(file_path) as f:
158
+ st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True)
159
+
160
+ # Intentar cargar el CSS personalizado con ruta absoluta para mayor seguridad
161
+ try:
162
+ css_path = os.path.join(os.path.dirname(__file__), 'static', 'css', 'style.css')
163
+ load_css(css_path)
164
+ except Exception as e:
165
+ print(f"Error al cargar CSS: {e}")
166
+ # Si el archivo no existe, crear un estilo básico en línea
167
+ st.markdown("""
168
+ <style>
169
+ .robocopy-title {
170
+ color: white !important;
171
+ font-weight: bold;
172
+ font-size: clamp(2.5em, 5vw, 4em);
173
+ line-height: 1.2;
174
+ }
175
+ </style>
176
+ """, unsafe_allow_html=True)
177
+
178
+ # Función de utilidad para mostrar la carátula inicial
179
+ def display_initial_header():
180
+ col1, col2, col3 = st.columns([1, 2, 1])
181
+ with col2:
182
+ # Centrar la imagen
183
+ st.markdown("""
184
+ <style>
185
+ div.stImage {
186
+ text-align: center;
187
+ display: block;
188
+ margin-left: auto;
189
+ margin-right: auto;
190
+ }
191
+ </style>
192
+ """, unsafe_allow_html=True)
193
+ st.image("robocopy_logo.png", width=300, use_container_width=True)
194
+
195
+ # Título con diseño responsivo (eliminado el símbolo ∞)
196
+ st.markdown("""
197
+ <div style='text-align: center; margin-top: -35px; width: 100%;'>
198
+ <h1 class='robocopy-title' style='width: 100%; text-align: center; color: white !important; font-size: clamp(2.5em, 5vw, 4em); line-height: 1.2;'>Email Story Creator</h1>
199
+ </div>
200
+ """, unsafe_allow_html=True)
201
+
202
+ # Subtítulo con margen superior ajustado a -30px
203
+ st.markdown("""
204
+ <div style='text-align: center; width: 100%;'>
205
+ <p style='font-size: 16px; color: white; width: 100%; text-align: center; margin-top: -20px;'>By Jesús Cabrera</p>
206
+ </div>
207
+ """, unsafe_allow_html=True)
208
+
209
+ # Descripción con fondo eliminado y margen superior ajustado a -20px
210
+ st.markdown("""
211
+ <div style='text-align: center; width: 100%;'>
212
+ <p style='font-size: 16px; background-color: transparent; padding: 12px; border-radius: 8px; margin-top: -20px; color: white; width: 100%; text-align: center;'>
213
+ ✉️ Experto en emails narrativos que conectan historias con ventas de forma natural
214
+ </p>
215
+ </div>
216
+ """, unsafe_allow_html=True)
217
+
218
+ # Función para mostrar ejemplos de preguntas
219
+ def display_examples():
220
+ ejemplos = [
221
+ {"texto": "Definir audiencia 🎯", "prompt": "Ayúdame a definir una audiencia concreta para este correo: dolor principal, deseo y nivel de conciencia."},
222
+ {"texto": "Propuesta de valor 💎", "prompt": "Convierte mi producto en una promesa clara de transformación sin listar características aburridas."},
223
+ {"texto": "CTA que convierte 🚀", "prompt": "Dame 3 opciones de CTA claras para este email, con baja fricción y orientadas a una sola acción."},
224
+ {"texto": "Asunto + gancho ✉️", "prompt": "Propón 5 asuntos y 3 ganchos de apertura para aumentar aperturas y clics de este correo."}
225
+ ]
226
+
227
+ # Crear los botones de ejemplo
228
+ cols = st.columns(4)
229
+ for idx, ejemplo in enumerate(ejemplos):
230
+ with cols[idx]:
231
+ if st.button(ejemplo["texto"], key=f"ejemplo_{idx}", help=ejemplo["prompt"]):
232
+ st.session_state.pending_example_prompt = ejemplo["prompt"]
233
+ st.session_state.hide_initial_menu = True
234
+ st.rerun()
235
+
236
+ # Cargar variables de entorno
237
+ load_dotenv()
238
+ GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY')
239
+ if not GOOGLE_API_KEY:
240
+ st.error("Falta la variable de entorno GOOGLE_API_KEY. Configúrala para continuar.")
241
+ st.stop()
242
+
243
+ # Configuración de la aplicación
244
+ state.user_namespace = get_user_namespace()
245
+ user_past_chats_list_path = f'{DATA_DIR}/{state.user_namespace}/past_chats_list'
246
+ new_chat_id = f'{time.time()}'
247
+ MODEL_ROLE = 'ai'
248
+ AI_AVATAR_ICON = '🤖' # Cambia el emoji por uno de robot para coincidir con tu logo
249
+ USER_AVATAR_ICON = '👤' # Añade un avatar para el usuario
250
+
251
+ # Crear carpeta de datos si no existe
252
+ os.makedirs(f'{DATA_DIR}/{state.user_namespace}', exist_ok=True)
253
+
254
+ # Cargar chats anteriores
255
+ try:
256
+ past_chats = joblib.load(user_past_chats_list_path)
257
+ except (FileNotFoundError, EOFError):
258
+ past_chats = {}
259
+
260
+ # Sidebar para seleccionar chats anteriores
261
+ with st.sidebar:
262
+ st.write('# Chats Anteriores')
263
+
264
+ if state.chat_id is None:
265
+ state.chat_id = new_chat_id
266
+
267
+ if st.button('+ Nuevo chat', key='new_chat_sidebar', use_container_width=True):
268
+ state.chat_id = new_chat_id
269
+ st.session_state.pending_example_prompt = None
270
+ st.session_state.hide_initial_menu = False
271
+ st.session_state.editing_chat_id = None
272
+ st.rerun()
273
+
274
+ st.caption('Sesiones')
275
+ if 'editing_chat_id' not in st.session_state:
276
+ st.session_state.editing_chat_id = None
277
+
278
+ def chat_sort_key(chat_id):
279
+ try:
280
+ return float(chat_id)
281
+ except (TypeError, ValueError):
282
+ return 0.0
283
+
284
+ sorted_chat_ids = sorted(past_chats.keys(), key=chat_sort_key, reverse=True)
285
+ for index, chat_id in enumerate(sorted_chat_ids):
286
+ chat_title = past_chats.get(chat_id, f'SesiónChat-{chat_id}')
287
+ is_active_chat = chat_id == state.chat_id
288
+ button_label = f'● {chat_title}' if is_active_chat else chat_title
289
+
290
+ if st.button(
291
+ button_label,
292
+ key=f'chat_session_{index}_{chat_id}',
293
+ use_container_width=True,
294
+ type='primary' if is_active_chat else 'secondary',
295
+ ):
296
+ if state.chat_id != chat_id:
297
+ state.chat_id = chat_id
298
+ st.rerun()
299
+
300
+ state.chat_title = past_chats.get(state.chat_id, f'SesiónChat-{state.chat_id}')
301
+
302
+ # Cargar historial del chat
303
+ state.load_chat_history()
304
+
305
+ if 'pending_example_prompt' not in st.session_state:
306
+ st.session_state.pending_example_prompt = None
307
+
308
+ if 'hide_initial_menu' not in st.session_state:
309
+ st.session_state.hide_initial_menu = False
310
+
311
+ if 'active_chat_id' not in st.session_state:
312
+ st.session_state.active_chat_id = state.chat_id
313
+ elif st.session_state.active_chat_id != state.chat_id:
314
+ st.session_state.active_chat_id = state.chat_id
315
+ st.session_state.pending_example_prompt = None
316
+ st.session_state.hide_initial_menu = state.has_messages()
317
+ st.session_state.editing_chat_id = None
318
+
319
+ # Inicializar el modelo y el chat
320
+ system_prompt = get_unified_email_prompt()
321
+ if (
322
+ st.session_state.get('initialized_model_name') != DEFAULT_GEMINI_MODEL
323
+ or getattr(state, 'client', None) is None
324
+ ):
325
+ try:
326
+ state.initialize_model(DEFAULT_GEMINI_MODEL, api_key=GOOGLE_API_KEY)
327
+ st.session_state.initialized_model_name = DEFAULT_GEMINI_MODEL
328
+ except Exception as e:
329
+ show_detailed_error("initialize_model", e)
330
+ st.stop()
331
+
332
+ should_reinitialize_chat = (
333
+ state.chat is None
334
+ or st.session_state.get('initialized_chat_id') != state.chat_id
335
+ or st.session_state.get('initialized_system_prompt') != system_prompt
336
+ )
337
+ if should_reinitialize_chat:
338
+ try:
339
+ state.initialize_chat(system_instruction=system_prompt)
340
+ st.session_state.initialized_chat_id = state.chat_id
341
+ st.session_state.initialized_system_prompt = system_prompt
342
+ except Exception as e:
343
+ show_detailed_error("initialize_chat", e)
344
+ st.stop()
345
+
346
+ # Mostrar mensajes del historial
347
+ for message in state.messages:
348
+ with st.chat_message(
349
+ name=message['role'],
350
+ avatar=message.get('avatar'),
351
+ ):
352
+ st.markdown(message['content'])
353
+
354
+ # Capturar entrada del usuario antes de renderizar el menú inicial
355
+ user_prompt = st.chat_input('Escribe aquí tus instrucciones')
356
+
357
+ if state.has_messages():
358
+ st.session_state.hide_initial_menu = True
359
+
360
+ # Renderizar menú inicial en un contenedor limpiable
361
+ initial_menu_container = st.container()
362
+ if (
363
+ not st.session_state.hide_initial_menu
364
+ and not state.has_messages()
365
+ and not user_prompt
366
+ and not st.session_state.pending_example_prompt
367
+ ):
368
+ with initial_menu_container:
369
+ display_initial_header()
370
+ display_examples()
371
+
372
+ # Procesar entrada del usuario (oculta el menú inmediatamente)
373
+ if user_prompt:
374
+ st.session_state.hide_initial_menu = True
375
+ initial_menu_container.empty()
376
+ process_message(user_prompt, is_example=False)
377
+ st.rerun()
378
+
379
+ # Procesar ejemplo seleccionado (oculta el menú inmediatamente)
380
+ if st.session_state.pending_example_prompt:
381
+ initial_menu_container.empty()
382
+ process_message(st.session_state.pending_example_prompt, is_example=True)
383
+ st.session_state.pending_example_prompt = None
384
+ st.rerun()
firebase_store.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from typing import Optional
4
+
5
+ import streamlit as st
6
+
7
+ try:
8
+ import firebase_admin
9
+ from firebase_admin import auth, credentials, firestore
10
+ except Exception: # pragma: no cover - entorno sin dependencia
11
+ firebase_admin = None
12
+ auth = None
13
+ credentials = None
14
+ firestore = None
15
+
16
+
17
+ class FirebaseSessionStore:
18
+ """Persistencia de sesiones en Firebase (Fase 1 rápida)."""
19
+
20
+ def __init__(self, db):
21
+ self.db = db
22
+
23
+ @classmethod
24
+ def from_env(cls) -> Optional["FirebaseSessionStore"]:
25
+ if firebase_admin is None:
26
+ return None
27
+
28
+ service_account_json = os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON")
29
+ service_account_path = os.environ.get("FIREBASE_SERVICE_ACCOUNT_PATH")
30
+ if not service_account_json and not service_account_path:
31
+ return None
32
+
33
+ if not firebase_admin._apps:
34
+ if service_account_json:
35
+ cred_info = json.loads(service_account_json)
36
+ cred = credentials.Certificate(cred_info)
37
+ else:
38
+ cred = credentials.Certificate(service_account_path)
39
+ firebase_admin.initialize_app(cred)
40
+
41
+ return cls(firestore.client())
42
+
43
+ def verify_id_token(self, id_token: str) -> Optional[str]:
44
+ if not id_token:
45
+ return None
46
+ try:
47
+ decoded = auth.verify_id_token(id_token)
48
+ return decoded.get("uid")
49
+ except Exception as exc:
50
+ st.warning(f"No se pudo validar token Firebase: {exc}")
51
+ return None
52
+
53
+ def _index_ref(self, user_id: str):
54
+ return self.db.collection("users").document(user_id).collection("meta").document("chat_index")
55
+
56
+ def _history_ref(self, user_id: str, chat_id: str):
57
+ return self.db.collection("users").document(user_id).collection("chats").document(str(chat_id))
58
+
59
+ def load_chat_index(self, user_id: str) -> dict:
60
+ doc = self._index_ref(user_id).get()
61
+ if not doc.exists:
62
+ return {}
63
+ return doc.to_dict().get("past_chats", {})
64
+
65
+ def save_chat_index(self, user_id: str, past_chats: dict) -> None:
66
+ self._index_ref(user_id).set({"past_chats": past_chats}, merge=True)
67
+
68
+ def save_chat_history(self, user_id: str, chat_id: str, messages: list, gemini_history: list) -> None:
69
+ self._history_ref(user_id, chat_id).set(
70
+ {
71
+ "messages": messages,
72
+ "gemini_history": gemini_history,
73
+ },
74
+ merge=True,
75
+ )
76
+
77
+ def load_chat_history(self, user_id: str, chat_id: str):
78
+ doc = self._history_ref(user_id, chat_id).get()
79
+ if not doc.exists:
80
+ return None, None
81
+ data = doc.to_dict() or {}
82
+ return data.get("messages"), data.get("gemini_history")
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ google-genai
2
+ streamlit
3
+ joblib
4
+ python-dotenv
robocopy_logo.png ADDED
session_state.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import joblib
3
+ import os
4
+ from google import genai
5
+ from google.genai import types
6
+
7
+ DEFAULT_GEMINI_MODEL = 'gemini-3.1-flash-lite-preview'
8
+ DATA_DIR = 'data'
9
+ PAST_CHATS_LIST_PATH = f'{DATA_DIR}/past_chats_list'
10
+
11
+ class SessionState:
12
+ """
13
+ Clase para gestionar el estado de la sesión de Streamlit de manera centralizada.
14
+ Encapsula todas las operaciones relacionadas con st.session_state.
15
+ """
16
+
17
+ def __init__(self):
18
+ # Inicializar valores por defecto si no existen
19
+ if 'chat_id' not in st.session_state:
20
+ st.session_state.chat_id = None
21
+
22
+ if 'chat_title' not in st.session_state:
23
+ st.session_state.chat_title = None
24
+
25
+ if 'messages' not in st.session_state:
26
+ st.session_state.messages = []
27
+
28
+ if 'gemini_history' not in st.session_state:
29
+ st.session_state.gemini_history = []
30
+
31
+ if 'model' not in st.session_state:
32
+ st.session_state.model = None
33
+
34
+ if 'client' not in st.session_state:
35
+ st.session_state.client = None
36
+
37
+ if 'chat' not in st.session_state:
38
+ st.session_state.chat = None
39
+
40
+ if 'prompt' not in st.session_state:
41
+ st.session_state.prompt = None
42
+
43
+ if 'system_instruction' not in st.session_state:
44
+ st.session_state.system_instruction = None
45
+
46
+ if 'user_namespace' not in st.session_state:
47
+ st.session_state.user_namespace = 'default'
48
+
49
+ # Getters y setters para cada propiedad
50
+ @property
51
+ def chat_id(self):
52
+ return st.session_state.chat_id
53
+
54
+ @chat_id.setter
55
+ def chat_id(self, value):
56
+ st.session_state.chat_id = value
57
+
58
+ @property
59
+ def chat_title(self):
60
+ return st.session_state.chat_title
61
+
62
+ @chat_title.setter
63
+ def chat_title(self, value):
64
+ st.session_state.chat_title = value
65
+
66
+ @property
67
+ def messages(self):
68
+ return st.session_state.messages
69
+
70
+ @messages.setter
71
+ def messages(self, value):
72
+ st.session_state.messages = value
73
+
74
+ @property
75
+ def gemini_history(self):
76
+ return st.session_state.gemini_history
77
+
78
+ @gemini_history.setter
79
+ def gemini_history(self, value):
80
+ st.session_state.gemini_history = value
81
+
82
+ @property
83
+ def model(self):
84
+ return st.session_state.model
85
+
86
+ @model.setter
87
+ def model(self, value):
88
+ st.session_state.model = value
89
+
90
+ @property
91
+ def client(self):
92
+ return st.session_state.client
93
+
94
+ @client.setter
95
+ def client(self, value):
96
+ st.session_state.client = value
97
+
98
+ @property
99
+ def chat(self):
100
+ return st.session_state.chat
101
+
102
+ @chat.setter
103
+ def chat(self, value):
104
+ st.session_state.chat = value
105
+
106
+ @property
107
+ def prompt(self):
108
+ return st.session_state.prompt
109
+
110
+ @prompt.setter
111
+ def prompt(self, value):
112
+ st.session_state.prompt = value
113
+
114
+ @property
115
+ def system_instruction(self):
116
+ return st.session_state.system_instruction
117
+
118
+ @system_instruction.setter
119
+ def system_instruction(self, value):
120
+ st.session_state.system_instruction = value
121
+
122
+ @property
123
+ def user_namespace(self):
124
+ return st.session_state.user_namespace
125
+
126
+ @user_namespace.setter
127
+ def user_namespace(self, value):
128
+ sanitized = str(value).replace('/', '_').replace('\\', '_').strip() or 'default'
129
+ st.session_state.user_namespace = sanitized
130
+
131
+ # Métodos de utilidad
132
+ def add_message(self, role, content, avatar=None):
133
+ """Añade un mensaje al historial"""
134
+ message = {
135
+ 'role': role,
136
+ 'content': content,
137
+ }
138
+ if avatar:
139
+ message['avatar'] = avatar
140
+ self.messages.append(message)
141
+
142
+ def clear_prompt(self):
143
+ """Limpia el prompt del estado de la sesión"""
144
+ self.prompt = None
145
+
146
+ def initialize_model(self, model_name=None, api_key=None):
147
+ """Inicializa el modelo de IA"""
148
+ if model_name is None:
149
+ model_name = DEFAULT_GEMINI_MODEL
150
+ if api_key is None:
151
+ api_key = os.environ.get('GOOGLE_API_KEY')
152
+ self.client = genai.Client(api_key=api_key)
153
+ self.model = model_name
154
+
155
+ def initialize_chat(self, history=None, system_instruction=None):
156
+ """Inicializa el chat con el modelo"""
157
+ if history is None:
158
+ history = self.gemini_history
159
+ if system_instruction is None:
160
+ system_instruction = self.system_instruction
161
+ else:
162
+ self.system_instruction = system_instruction
163
+
164
+ # Asegurar que el modelo está inicializado
165
+ if self.model is None or self.client is None:
166
+ self.initialize_model()
167
+
168
+ chat_kwargs = {'model': self.model}
169
+ if history:
170
+ chat_kwargs['history'] = history
171
+ if system_instruction:
172
+ chat_kwargs['config'] = types.GenerateContentConfig(
173
+ system_instruction=system_instruction
174
+ )
175
+
176
+ # Inicializar chat con el SDK moderno
177
+ self.chat = self.client.chats.create(**chat_kwargs)
178
+
179
+ # Verificar que el chat se inicializó correctamente
180
+ if self.chat is None:
181
+ raise ValueError("Error al inicializar el chat")
182
+
183
+ def send_message(self, prompt, stream=True):
184
+ """Método unificado para enviar mensajes y mantener el streaming"""
185
+ try:
186
+ if self.chat is None:
187
+ self.initialize_chat()
188
+
189
+ if stream:
190
+ return self.chat.send_message_stream(prompt)
191
+ return self.chat.send_message(prompt)
192
+ except Exception as e:
193
+ print(f"Error al enviar mensaje: {e}")
194
+ # Reintentar una vez si hay error
195
+ try:
196
+ self.initialize_chat()
197
+ if stream:
198
+ return self.chat.send_message_stream(prompt)
199
+ return self.chat.send_message(prompt)
200
+ except Exception as retry_error:
201
+ raise RuntimeError(
202
+ f"Fallo al enviar mensaje tras reintento. Error original: {e}. "
203
+ f"Error de reintento: {retry_error}"
204
+ ) from retry_error
205
+
206
+ def generate_chat_title(self, prompt, model_name=None):
207
+ """Genera un título para el chat basado en el primer mensaje"""
208
+ try:
209
+ if model_name is None:
210
+ model_name = DEFAULT_GEMINI_MODEL
211
+ if self.client is None:
212
+ self.client = genai.Client(api_key=os.environ.get('GOOGLE_API_KEY'))
213
+ title_response = self.client.models.generate_content(
214
+ model=model_name,
215
+ contents=(
216
+ "Genera un título natural y humano en español (3 a 6 palabras) "
217
+ "que resuma esta consulta. No uses separadores tipo '|', no uses etiquetas, "
218
+ "no uses comillas y evita formato robótico. Devuelve solo el título final: "
219
+ f"'{prompt}'"
220
+ )
221
+ )
222
+ cleaned_title = " ".join(
223
+ title_response.text.strip().replace('"', '').replace('|', ' ').split()
224
+ )
225
+ return " ".join(cleaned_title.split()[:6])
226
+ except Exception as e:
227
+ print(f"Error al generar título: {e}")
228
+ return None
229
+
230
+ def save_chat_history(self, chat_id=None):
231
+ """Guarda el historial del chat"""
232
+ if chat_id is None:
233
+ chat_id = self.chat_id
234
+
235
+ serialized_history = self._serialize_gemini_history(self.gemini_history)
236
+ os.makedirs(self._user_data_dir(), exist_ok=True)
237
+ joblib.dump(self.messages, self._st_messages_path(chat_id))
238
+ joblib.dump(serialized_history, self._gemini_messages_path(chat_id))
239
+
240
+ def load_chat_history(self, chat_id=None):
241
+ """Carga el historial del chat"""
242
+ if chat_id is None:
243
+ chat_id = self.chat_id
244
+
245
+ try:
246
+ self.messages = joblib.load(self._st_messages_path(chat_id))
247
+ loaded_history = joblib.load(self._gemini_messages_path(chat_id))
248
+ self.gemini_history = self._deserialize_gemini_history(loaded_history)
249
+ return True
250
+ except (FileNotFoundError, EOFError):
251
+ self.messages = []
252
+ self.gemini_history = []
253
+ return False
254
+
255
+ def _st_messages_path(self, chat_id):
256
+ return f'{self._user_data_dir()}/{chat_id}-st_messages'
257
+
258
+ def _gemini_messages_path(self, chat_id):
259
+ return f'{self._user_data_dir()}/{chat_id}-gemini_messages'
260
+
261
+ def _user_data_dir(self):
262
+ return f'{DATA_DIR}/{self.user_namespace}'
263
+
264
+ def _serialize_gemini_history(self, history):
265
+ """Convierte tipos del SDK (Content/Part) a diccionarios serializables."""
266
+ serialized = []
267
+ for item in history or []:
268
+ if isinstance(item, dict):
269
+ serialized.append(item)
270
+ continue
271
+ if hasattr(item, "model_dump"):
272
+ serialized.append(item.model_dump(mode="python"))
273
+ continue
274
+ if hasattr(item, "to_dict"):
275
+ serialized.append(item.to_dict())
276
+ continue
277
+ serialized.append(item)
278
+ return serialized
279
+
280
+ def _deserialize_gemini_history(self, history):
281
+ """Reconstruye Content para rehidratar chat history en google-genai."""
282
+ deserialized = []
283
+ for item in history or []:
284
+ if isinstance(item, dict) and "role" in item and "parts" in item:
285
+ role = item.get("role")
286
+ parts_data = item.get("parts", [])
287
+ parts = []
288
+ for part in parts_data:
289
+ if isinstance(part, dict) and "text" in part:
290
+ parts.append(types.Part(text=part["text"]))
291
+ elif isinstance(part, str):
292
+ parts.append(types.Part(text=part))
293
+ if parts:
294
+ deserialized.append(types.Content(role=role, parts=parts))
295
+ continue
296
+ deserialized.append(item)
297
+ return deserialized
298
+
299
+ def has_messages(self):
300
+ """Verifica si hay mensajes en el historial"""
301
+ return len(self.messages) > 0
302
+
303
+ def has_prompt(self):
304
+ """Verifica si hay un prompt en el estado de la sesión"""
305
+ return self.prompt is not None and self.prompt.strip() != ""
system_prompts.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def get_unified_email_prompt():
2
+ return """### [IMPRIMACIÓN COGNITIVA]
3
+ - Modelos fundacionales: Storytelling Marketing, Show Don't Tell, PAS, Golden Circle (empezar con el porqué).
4
+ - Corpus de conocimiento: estilo tipo Seth Godin, estructura narrativa tipo StoryBrand, persuasión sutil estilo Cialdini.
5
+ - Léxico clave: Anécdota catalizadora, Puente narrativo, Epifanía, Lección clave, Llamada a la acción contextual, Resonancia emocional.
6
+ - Usa marcos fundacionales de copy (AIDA, PASA y PASTOR) solo como estructura interna.
7
+
8
+ ### [PERSONA]
9
+ Actúa como estratega de email marketing y storyteller experto en copy conversacional.
10
+ Tono: empático, amable, curioso, conversacional y perspicaz.
11
+ Audiencia: suscriptores con relación de confianza, que esperan valor y no venta agresiva.
12
+
13
+ ### [MISIÓN]
14
+ Guiar de forma interactiva para crear emails de marketing.
15
+ Antes de redactar el email final, debes recopilar estos datos:
16
+ 1) Audiencia objetivo.
17
+ 2) Producto a promover.
18
+ 3) Nombre para firma.
19
+ 4) Llamado a la acción (CTA).
20
+ 5) Ángulo (anécdota/situación/observación; puede incluir personajes de Disney/anime).
21
+ NO debes generar ningún email final antes de recibir los 5 elementos.
22
+
23
+ ### [PRIMERA RESPUESTA OBLIGATORIA]
24
+ Si aún no tienes los 5 datos, inicia con la PRIMERA pregunta del flujo operativo (no pidas todo junto).
25
+
26
+ ### [FLUJO OPERATIVO]
27
+ Haz solo 1 pregunta a la vez y espera respuesta:
28
+ 1) AUDIENCIA:
29
+ "¿A quién le vas a escribir este email? Describe tu audiencia ideal: contexto, problema principal, deseo y nivel de conciencia sobre el problema."
30
+ 2) PRODUCTO:
31
+ "¿Qué producto o servicio vas a promover y qué transformación principal consigue la persona que lo compra?"
32
+ 3) NOMBRE:
33
+ "¿Con qué nombre quieres firmar el correo?"
34
+ 4) CTA:
35
+ "¿Qué acción concreta quieres que la audiencia realice al final del email? (responder, agendar llamada, comprar, visitar enlace, etc.)"
36
+ 5) ÁNGULO (OBLIGATORIO):
37
+ "¿Qué ángulo, anécdota o situación específica quieres usar en este correo? (Puedes apoyarte en referencias como Disney/anime si encaja)."
38
+
39
+ ### [RAZONAMIENTO PASO A PASO]
40
+ 1) Descubrimiento guiado (5 preguntas, una por vez; ángulo obligatorio).
41
+ 2) Identificación de dolor/deseo central y transformación del producto.
42
+ 3) Construir puente narrativo a partir del ángulo dado y redactar con enfoque conversacional.
43
+ 4) Usar AIDA, PASA o PASTOR internamente para ordenar el mensaje cuando haga falta.
44
+ 5) Adaptar lenguaje al nivel de conciencia de la audiencia y cerrar con CTA explícito.
45
+ 6) Usar el nombre de firma proporcionado en el cierre final del email.
46
+
47
+ ### [RESTRICCIONES]
48
+ - No uses clichés de marketing.
49
+ - No fuerces la conexión historia-producto; pide más contexto si hace falta.
50
+ - No te enfoques en características, precios o descuentos.
51
+ - Nunca menciones al usuario qué fórmula interna usaste (AIDA, PASA o PASTOR).
52
+
53
+ ### [FORMATO DE SALIDA FINAL - EXACTO]
54
+ **Asunto:** [Texto del Asunto en negrita]
55
+
56
+ ---
57
+
58
+ **Cuerpo del Email:**
59
+
60
+ [Párrafo 1: La anécdota]
61
+
62
+ [Párrafo 2: La transición hacia la lección]
63
+
64
+ [Párrafo 3: La lección clave y cómo se conecta con un problema general]
65
+
66
+ [Párrafo 4: Presentación del producto como la herramienta para aplicar la lección]
67
+
68
+ [Párrafo 5: Llamada a la acción clara y directa]
69
+
70
+ [Cierre personal]
71
+ """