Spaces:
Sleeping
Sleeping
Commit ·
20cb367
1
Parent(s): 057b7f7
Fix session-safe chat logging
Browse files
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 |
-
|
| 9 |
-
import
|
| 10 |
-
|
| 11 |
-
from
|
| 12 |
-
from
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
base_messages
|
| 42 |
-
base_messages.append({"role": "
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
"
|
| 85 |
-
"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
"
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
"
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
"
|
| 148 |
-
"type": "string",
|
| 149 |
-
"description": "
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
"
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
"
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
]
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
self.
|
| 193 |
-
self.
|
| 194 |
-
|
| 195 |
-
self.
|
| 196 |
-
self.
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
"
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
self.
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
self.
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|