costadev00 commited on
Commit
20cb367
·
1 Parent(s): 057b7f7

Fix session-safe chat logging

Browse files
Files changed (1) hide show
  1. app.py +302 -277
app.py CHANGED
@@ -1,277 +1,302 @@
1
- import base64
2
- import json
3
- import os
4
- import uuid
5
- from datetime import datetime
6
- from pathlib import Path
7
-
8
- import gradio as gr
9
- import requests
10
- from dotenv import load_dotenv
11
- from openai import OpenAI
12
- from PyPDF2 import PdfReader
13
-
14
-
15
- load_dotenv(override=True)
16
-
17
- def push(text):
18
- requests.post(
19
- "https://api.pushover.net/1/messages.json",
20
- data={
21
- "token": os.getenv("PUSHOVER_TOKEN"),
22
- "user": os.getenv("PUSHOVER_USER"),
23
- "message": text,
24
- }
25
- )
26
-
27
-
28
- def record_user_details(email, name="Name not provided", notes="not provided"):
29
- push(f"Recording {name} with email {email} and notes {notes}")
30
- return {"recorded": "ok"}
31
-
32
- def record_unknown_question(question):
33
- push(f"Recording {question}")
34
- return {"recorded": "ok"}
35
-
36
- def _normalize_messages(history, user_message, assistant_response):
37
- """Return chronological list of dicts with role/content for current chat."""
38
-
39
- normalized = []
40
- base_messages = list(history or [])
41
- base_messages.append({"role": "user", "content": user_message})
42
- base_messages.append({"role": "assistant", "content": assistant_response})
43
-
44
- for entry in base_messages:
45
- role = None
46
- content = None
47
- if isinstance(entry, dict):
48
- role = entry.get("role")
49
- content = entry.get("content")
50
- elif isinstance(entry, (list, tuple)) and len(entry) == 2:
51
- role, content = entry
52
-
53
- if role in {"user", "assistant"} and isinstance(content, str):
54
- normalized.append({"role": role, "content": content})
55
-
56
- return normalized
57
-
58
-
59
- def _build_ordered_turns(normalized_messages):
60
- """Group chronological messages into user/assistant iterations."""
61
-
62
- turns = []
63
- iteration = 1
64
- pending_user = None
65
-
66
- for message in normalized_messages:
67
- role = message["role"]
68
- content = message["content"]
69
-
70
- if role == "user":
71
- pending_user = content
72
- elif role == "assistant" and pending_user is not None:
73
- turns.append({
74
- "iteration": iteration,
75
- "user": pending_user,
76
- "assistant": content,
77
- })
78
- iteration += 1
79
- pending_user = None
80
-
81
- if pending_user:
82
- turns.append({
83
- "iteration": iteration,
84
- "user": pending_user,
85
- "assistant": "",
86
- })
87
-
88
- return turns
89
- def log_chat_interaction(user_message, assistant_response, history, session_path, current_sha):
90
- """Persist entire chat session to a single GitHub file, updating each turn."""
91
-
92
- owner = os.getenv("GITHUB_OWNER")
93
- repo = os.getenv("GITHUB_REPO")
94
- token = os.getenv("GITHUB_TOKEN")
95
- branch = os.getenv("GITHUB_BRANCH", "main")
96
-
97
- if not (owner and repo and token):
98
- return current_sha
99
-
100
- normalized_messages = _normalize_messages(history, user_message, assistant_response)
101
- turns = _build_ordered_turns(normalized_messages)
102
-
103
- content = json.dumps({"session": turns}, ensure_ascii=False, indent=2)
104
- encoded_content = base64.b64encode(content.encode("utf-8")).decode("ascii")
105
-
106
- url = f"https://api.github.com/repos/{owner}/{repo}/contents/{session_path}"
107
- headers = {
108
- "Authorization": f"Bearer {token}",
109
- "Accept": "application/vnd.github+json",
110
- "X-GitHub-Api-Version": "2022-11-28",
111
- }
112
-
113
- data = {
114
- "message": f"Update chat session {session_path}",
115
- "content": encoded_content,
116
- "branch": branch,
117
- }
118
-
119
- if current_sha:
120
- data["sha"] = current_sha
121
-
122
- try:
123
- resp = requests.put(url, headers=headers, json=data, timeout=15)
124
- resp.raise_for_status()
125
- response_json = resp.json()
126
- new_sha = response_json.get("content", {}).get("sha", current_sha)
127
- return new_sha
128
- except requests.RequestException as exc:
129
- print(f"Failed to log chat interaction to GitHub repo: {exc}", flush=True)
130
- return current_sha
131
-
132
-
133
- record_user_details_json = {
134
- "name": "record_user_details",
135
- "description": "Use esta ferramenta para registrar que um usuário está interessado em entrar em contato e forneceu um endereço de e-mail",
136
- "parameters": {
137
- "type": "object",
138
- "properties": {
139
- "email": {
140
- "type": "string",
141
- "description": "O endereço de e-mail deste usuário"
142
- },
143
- "name": {
144
- "type": "string",
145
- "description": "O nome do usuário, se fornecido"
146
- },
147
- "notes": {
148
- "type": "string",
149
- "description": "Qualquer informação adicional sobre a conversa que seja relevante para registrar o contexto"
150
- }
151
- },
152
- "required": ["email"],
153
- "additionalProperties": False
154
- }
155
- }
156
-
157
- record_unknown_question_json = {
158
- "name": "record_unknown_question",
159
- "description": "Sempre use esta ferramenta para registrar qualquer pergunta que não pôde ser respondida porque você não sabia a resposta",
160
- "parameters": {
161
- "type": "object",
162
- "properties": {
163
- "question": {
164
- "type": "string",
165
- "description": "A pergunta que não pôde ser respondida"
166
- },
167
- },
168
- "required": ["question"],
169
- "additionalProperties": False
170
- }
171
- }
172
-
173
- tools = [
174
- {"type": "function", "function": record_user_details_json},
175
- {"type": "function", "function": record_unknown_question_json},
176
- ]
177
-
178
-
179
- class Me:
180
-
181
- def __init__(self):
182
- self.openai = OpenAI()
183
- self.name = "Matheus Costa"
184
- self._begin_new_session()
185
- base_dir = Path("me")
186
- self.linkedin = self._load_document(base_dir / "linkedin.txt", base_dir / "linkedin.pdf")
187
- self.lattes = self._load_document(base_dir / "lattes.txt", base_dir / "lattes.pdf")
188
- with open(base_dir / "summary.txt", "r", encoding="utf-8") as f:
189
- self.summary = f.read()
190
-
191
- def _begin_new_session(self):
192
- self.session_started_at = datetime.utcnow().isoformat()
193
- self.session_id = uuid.uuid4().hex[:8]
194
- session_stamp = self.session_started_at.replace(":", "-")
195
- self.session_path = f"sessions/{session_stamp}_{self.session_id}.json"
196
- self.session_sha = None
197
-
198
- def _load_document(self, txt_path: Path, pdf_path: Path) -> str:
199
- if txt_path.exists():
200
- return txt_path.read_text(encoding="utf-8")
201
- if pdf_path.exists():
202
- reader = PdfReader(str(pdf_path))
203
- chunks = []
204
- for page in reader.pages:
205
- text = page.extract_text()
206
- if text:
207
- chunks.append(text)
208
- return "".join(chunks)
209
- return ""
210
-
211
- def handle_tool_call(self, tool_calls):
212
- results = []
213
- for tool_call in tool_calls:
214
- tool_name = tool_call.function.name
215
- arguments = json.loads(tool_call.function.arguments)
216
- print(f"Tool called: {tool_name}", flush=True)
217
- tool = globals().get(tool_name)
218
- result = tool(**arguments) if tool else {}
219
- results.append({
220
- "role": "tool",
221
- "content": json.dumps(result),
222
- "tool_call_id": tool_call.id,
223
- })
224
- return results
225
-
226
- def system_prompt(self):
227
- system_prompt = f"Você está atuando como {self.name}. Você está respondendo perguntas no site de {self.name}, \
228
- particularmente perguntas relacionadas à carreira, histórico, habilidades e experiência de {self.name}. \
229
- Sua responsabilidade é representar {self.name} nas interações no site da forma mais fiel possível. \
230
- Você recebeu um resumo do histórico profissional, o perfil do LinkedIn e o currículo Lattes de {self.name}; use-os para responder perguntas, priorizando o Lattes quando o assunto envolver formação ou produção acadêmica. \
231
- Seja profissional e envolvente, como se estivesse conversando com um potencial cliente ou futuro empregador que acessou o site. \
232
- Se você não souber a resposta para alguma pergunta, use sua ferramenta `record_unknown_question` para registrar a pergunta que você não conseguiu responder, mesmo que seja algo trivial ou não relacionado à carreira. \
233
- Se o usuário estiver engajado na conversa, tente direcioná-lo a entrar em contato por e-mail; peça o e-mail e registre-o usando sua ferramenta `record_user_details`."
234
-
235
- system_prompt += f"\n\n## Resumo:\n{self.summary}\n\n## Perfil do LinkedIn:\n{self.linkedin}\n\n## Currículo Lattes (formação e produção acadêmica):\n{self.lattes}\n\n"
236
- system_prompt += f"Com esse contexto, por favor converse com o usuário, sempre mantendo o personagem de {self.name}."
237
- return system_prompt
238
-
239
- def chat(self, message, history):
240
- if not history:
241
- self._begin_new_session()
242
-
243
- user_message = message
244
- messages = (
245
- [{"role": "system", "content": self.system_prompt()}]
246
- + history
247
- + [{"role": "user", "content": user_message}]
248
- )
249
- done = False
250
- while not done:
251
- response = self.openai.chat.completions.create(
252
- model="gpt-5-nano-2025-08-07",
253
- messages=messages,
254
- tools=tools,
255
- )
256
- if response.choices[0].finish_reason == "tool_calls":
257
- message = response.choices[0].message
258
- tool_calls = message.tool_calls
259
- results = self.handle_tool_call(tool_calls)
260
- messages.append(message)
261
- messages.extend(results)
262
- else:
263
- done = True
264
- assistant_reply = response.choices[0].message.content
265
- self.session_sha = log_chat_interaction(
266
- user_message,
267
- assistant_reply,
268
- history,
269
- self.session_path,
270
- self.session_sha,
271
- )
272
- return assistant_reply
273
-
274
-
275
- if __name__ == "__main__":
276
- me = Me()
277
- gr.ChatInterface(me.chat, type="messages").launch(share=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import os
4
+ import uuid
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from threading import Lock
8
+
9
+ import gradio as gr
10
+ import requests
11
+ from dotenv import load_dotenv
12
+ from openai import OpenAI
13
+ from PyPDF2 import PdfReader
14
+
15
+
16
+ load_dotenv(override=True)
17
+
18
+ def push(text):
19
+ requests.post(
20
+ "https://api.pushover.net/1/messages.json",
21
+ data={
22
+ "token": os.getenv("PUSHOVER_TOKEN"),
23
+ "user": os.getenv("PUSHOVER_USER"),
24
+ "message": text,
25
+ }
26
+ )
27
+
28
+
29
+ def record_user_details(email, name="Name not provided", notes="not provided"):
30
+ push(f"Recording {name} with email {email} and notes {notes}")
31
+ return {"recorded": "ok"}
32
+
33
+ def record_unknown_question(question):
34
+ push(f"Recording {question}")
35
+ return {"recorded": "ok"}
36
+
37
+ def _normalize_messages(history, user_message, assistant_response):
38
+ """Return chronological list of dicts with role/content for current chat."""
39
+
40
+ normalized = []
41
+ base_messages = list(history or [])
42
+ base_messages.append({"role": "user", "content": user_message})
43
+ base_messages.append({"role": "assistant", "content": assistant_response})
44
+
45
+ for entry in base_messages:
46
+ role = None
47
+ content = None
48
+ if isinstance(entry, dict):
49
+ role = entry.get("role")
50
+ content = entry.get("content")
51
+ elif isinstance(entry, (list, tuple)) and len(entry) == 2:
52
+ role, content = entry
53
+
54
+ if role in {"user", "assistant"} and isinstance(content, str):
55
+ normalized.append({"role": role, "content": content})
56
+
57
+ return normalized
58
+
59
+
60
+ def _build_ordered_turns(normalized_messages):
61
+ """Group chronological messages into user/assistant iterations."""
62
+
63
+ turns = []
64
+ iteration = 1
65
+ pending_user = None
66
+
67
+ for message in normalized_messages:
68
+ role = message["role"]
69
+ content = message["content"]
70
+
71
+ if role == "user":
72
+ pending_user = content
73
+ elif role == "assistant" and pending_user is not None:
74
+ turns.append({
75
+ "iteration": iteration,
76
+ "user": pending_user,
77
+ "assistant": content,
78
+ })
79
+ iteration += 1
80
+ pending_user = None
81
+
82
+ if pending_user:
83
+ turns.append({
84
+ "iteration": iteration,
85
+ "user": pending_user,
86
+ "assistant": "",
87
+ })
88
+
89
+ return turns
90
+ def log_chat_interaction(user_message, assistant_response, history, session_path, current_sha):
91
+ """Persist entire chat session to a single GitHub file, updating each turn."""
92
+
93
+ owner = os.getenv("GITHUB_OWNER")
94
+ repo = os.getenv("GITHUB_REPO")
95
+ token = os.getenv("GITHUB_TOKEN")
96
+ branch = os.getenv("GITHUB_BRANCH", "main")
97
+
98
+ if not (owner and repo and token):
99
+ return current_sha
100
+
101
+ normalized_messages = _normalize_messages(history, user_message, assistant_response)
102
+ turns = _build_ordered_turns(normalized_messages)
103
+
104
+ content = json.dumps(
105
+ {
106
+ "messages": normalized_messages,
107
+ "session": turns,
108
+ },
109
+ ensure_ascii=False,
110
+ indent=2,
111
+ )
112
+ encoded_content = base64.b64encode(content.encode("utf-8")).decode("ascii")
113
+
114
+ url = f"https://api.github.com/repos/{owner}/{repo}/contents/{session_path}"
115
+ headers = {
116
+ "Authorization": f"Bearer {token}",
117
+ "Accept": "application/vnd.github+json",
118
+ "X-GitHub-Api-Version": "2022-11-28",
119
+ }
120
+
121
+ data = {
122
+ "message": f"Update chat session {session_path}",
123
+ "content": encoded_content,
124
+ "branch": branch,
125
+ }
126
+
127
+ if current_sha:
128
+ data["sha"] = current_sha
129
+
130
+ try:
131
+ resp = requests.put(url, headers=headers, json=data, timeout=15)
132
+ resp.raise_for_status()
133
+ response_json = resp.json()
134
+ new_sha = response_json.get("content", {}).get("sha", current_sha)
135
+ return new_sha
136
+ except requests.RequestException as exc:
137
+ print(f"Failed to log chat interaction to GitHub repo: {exc}", flush=True)
138
+ return current_sha
139
+
140
+
141
+ record_user_details_json = {
142
+ "name": "record_user_details",
143
+ "description": "Use esta ferramenta para registrar que um usuário está interessado em entrar em contato e forneceu um endereço de e-mail",
144
+ "parameters": {
145
+ "type": "object",
146
+ "properties": {
147
+ "email": {
148
+ "type": "string",
149
+ "description": "O endereço de e-mail deste usuário"
150
+ },
151
+ "name": {
152
+ "type": "string",
153
+ "description": "O nome do usuário, se fornecido"
154
+ },
155
+ "notes": {
156
+ "type": "string",
157
+ "description": "Qualquer informação adicional sobre a conversa que seja relevante para registrar o contexto"
158
+ }
159
+ },
160
+ "required": ["email"],
161
+ "additionalProperties": False
162
+ }
163
+ }
164
+
165
+ record_unknown_question_json = {
166
+ "name": "record_unknown_question",
167
+ "description": "Sempre use esta ferramenta para registrar qualquer pergunta que não pôde ser respondida porque você não sabia a resposta",
168
+ "parameters": {
169
+ "type": "object",
170
+ "properties": {
171
+ "question": {
172
+ "type": "string",
173
+ "description": "A pergunta que não pôde ser respondida"
174
+ },
175
+ },
176
+ "required": ["question"],
177
+ "additionalProperties": False
178
+ }
179
+ }
180
+
181
+ tools = [
182
+ {"type": "function", "function": record_user_details_json},
183
+ {"type": "function", "function": record_unknown_question_json},
184
+ ]
185
+
186
+
187
+ class Me:
188
+
189
+ def __init__(self):
190
+ self.openai = OpenAI()
191
+ self.name = "Matheus Costa"
192
+ self.sessions_lock = Lock()
193
+ self.sessions = {}
194
+ base_dir = Path("me")
195
+ self.linkedin = self._load_document(base_dir / "linkedin.txt", base_dir / "linkedin.pdf")
196
+ self.lattes = self._load_document(base_dir / "lattes.txt", base_dir / "lattes.pdf")
197
+ with open(base_dir / "summary.txt", "r", encoding="utf-8") as f:
198
+ self.summary = f.read()
199
+
200
+ def _create_session_record(self):
201
+ session_started_at = datetime.utcnow().isoformat()
202
+ session_id = uuid.uuid4().hex[:8]
203
+ session_stamp = session_started_at.replace(":", "-")
204
+ return {
205
+ "started_at": session_started_at,
206
+ "session_id": session_id,
207
+ "session_path": f"sessions/{session_stamp}_{session_id}.json",
208
+ "session_sha": None,
209
+ }
210
+
211
+ def _get_session_record(self, session_key, reset=False):
212
+ with self.sessions_lock:
213
+ if reset or session_key not in self.sessions:
214
+ self.sessions[session_key] = self._create_session_record()
215
+ return dict(self.sessions[session_key])
216
+
217
+ def _update_session_sha(self, session_key, session_sha):
218
+ with self.sessions_lock:
219
+ if session_key in self.sessions:
220
+ self.sessions[session_key]["session_sha"] = session_sha
221
+
222
+ def _load_document(self, txt_path: Path, pdf_path: Path) -> str:
223
+ if txt_path.exists():
224
+ return txt_path.read_text(encoding="utf-8")
225
+ if pdf_path.exists():
226
+ reader = PdfReader(str(pdf_path))
227
+ chunks = []
228
+ for page in reader.pages:
229
+ text = page.extract_text()
230
+ if text:
231
+ chunks.append(text)
232
+ return "".join(chunks)
233
+ return ""
234
+
235
+ def handle_tool_call(self, tool_calls):
236
+ results = []
237
+ for tool_call in tool_calls:
238
+ tool_name = tool_call.function.name
239
+ arguments = json.loads(tool_call.function.arguments)
240
+ print(f"Tool called: {tool_name}", flush=True)
241
+ tool = globals().get(tool_name)
242
+ result = tool(**arguments) if tool else {}
243
+ results.append({
244
+ "role": "tool",
245
+ "content": json.dumps(result),
246
+ "tool_call_id": tool_call.id,
247
+ })
248
+ return results
249
+
250
+ def system_prompt(self):
251
+ system_prompt = f"Você está atuando como {self.name}. Você está respondendo perguntas no site de {self.name}, \
252
+ particularmente perguntas relacionadas à carreira, histórico, habilidades e experiência de {self.name}. \
253
+ Sua responsabilidade é representar {self.name} nas interações no site da forma mais fiel possível. \
254
+ Você recebeu um resumo do histórico profissional, o perfil do LinkedIn e o currículo Lattes de {self.name}; use-os para responder perguntas, priorizando o Lattes quando o assunto envolver formação ou produção acadêmica. \
255
+ Seja profissional e envolvente, como se estivesse conversando com um potencial cliente ou futuro empregador que acessou o site. \
256
+ Se você não souber a resposta para alguma pergunta, use sua ferramenta `record_unknown_question` para registrar a pergunta que você não conseguiu responder, mesmo que seja algo trivial ou não relacionado à carreira. \
257
+ Se o usuário estiver engajado na conversa, tente direcioná-lo a entrar em contato por e-mail; peça o e-mail e registre-o usando sua ferramenta `record_user_details`."
258
+
259
+ system_prompt += f"\n\n## Resumo:\n{self.summary}\n\n## Perfil do LinkedIn:\n{self.linkedin}\n\n## Currículo Lattes (formação e produção acadêmica):\n{self.lattes}\n\n"
260
+ system_prompt += f"Com esse contexto, por favor converse com o usuário, sempre mantendo o personagem de {self.name}."
261
+ return system_prompt
262
+
263
+ def chat(self, message, history, request: gr.Request):
264
+ session_key = request.session_hash if request and request.session_hash else "default"
265
+ session_record = self._get_session_record(session_key, reset=not history)
266
+
267
+ user_message = message
268
+ messages = (
269
+ [{"role": "system", "content": self.system_prompt()}]
270
+ + history
271
+ + [{"role": "user", "content": user_message}]
272
+ )
273
+ done = False
274
+ while not done:
275
+ response = self.openai.chat.completions.create(
276
+ model="gpt-5-nano-2025-08-07",
277
+ messages=messages,
278
+ tools=tools,
279
+ )
280
+ if response.choices[0].finish_reason == "tool_calls":
281
+ message = response.choices[0].message
282
+ tool_calls = message.tool_calls
283
+ results = self.handle_tool_call(tool_calls)
284
+ messages.append(message)
285
+ messages.extend(results)
286
+ else:
287
+ done = True
288
+ assistant_reply = response.choices[0].message.content
289
+ session_sha = log_chat_interaction(
290
+ user_message,
291
+ assistant_reply,
292
+ history,
293
+ session_record["session_path"],
294
+ session_record["session_sha"],
295
+ )
296
+ self._update_session_sha(session_key, session_sha)
297
+ return assistant_reply
298
+
299
+
300
+ if __name__ == "__main__":
301
+ me = Me()
302
+ gr.ChatInterface(me.chat, type="messages").launch(share=True)