bentosmau commited on
Commit
9fae0c6
ยท
1 Parent(s): 700f4b5

Translate all code comments, strings, and variable names to English

Browse files

Updates comments, docstrings, user-facing strings, variable names, and function names across Python and TypeScript files to English.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e3ff2484-bbd8-4aba-bea0-1940769b874a
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: fca1b854-e4b4-4bfc-9bb2-ebe5af51a4f9
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1739408b-93a5-479b-a658-30f2493b0467/e3ff2484-bbd8-4aba-bea0-1940769b874a/Pw9jNbA
Replit-Helium-Checkpoint-Created: true

artifacts/api-server/src/middlewares/auth.ts CHANGED
@@ -8,13 +8,13 @@ export function requireApiKey(req: Request, res: Response, next: NextFunction) {
8
  : (req.headers["x-api-key"] as string | undefined)?.trim() ?? "";
9
 
10
  if (!key) {
11
- res.status(401).json({ error: "API key requerida. Usa el header: Authorization: Bearer <tu-key>" });
12
  return;
13
  }
14
 
15
  const found = validateKey(key);
16
  if (!found) {
17
- res.status(403).json({ error: "API key invรกlida o revocada." });
18
  return;
19
  }
20
 
@@ -25,7 +25,7 @@ export function requireApiKey(req: Request, res: Response, next: NextFunction) {
25
  export function requireAdmin(req: Request, res: Response, next: NextFunction) {
26
  const secret = req.headers["x-admin-secret"] as string | undefined;
27
  if (!secret || secret !== getAdminSecret()) {
28
- res.status(403).json({ error: "Admin secret incorrecto." });
29
  return;
30
  }
31
  next();
 
8
  : (req.headers["x-api-key"] as string | undefined)?.trim() ?? "";
9
 
10
  if (!key) {
11
+ res.status(401).json({ error: "API key required. Use the header: Authorization: Bearer <your-key>" });
12
  return;
13
  }
14
 
15
  const found = validateKey(key);
16
  if (!found) {
17
+ res.status(403).json({ error: "Invalid or revoked API key." });
18
  return;
19
  }
20
 
 
25
  export function requireAdmin(req: Request, res: Response, next: NextFunction) {
26
  const secret = req.headers["x-admin-secret"] as string | undefined;
27
  if (!secret || secret !== getAdminSecret()) {
28
+ res.status(403).json({ error: "Incorrect admin secret." });
29
  return;
30
  }
31
  next();
artifacts/api-server/src/routes/keys.ts CHANGED
@@ -7,14 +7,14 @@ const router = Router();
7
  router.post("/keys", requireAdmin, (req, res) => {
8
  const { name } = req.body as { name?: string };
9
  if (!name || typeof name !== "string" || !name.trim()) {
10
- res.status(400).json({ error: "El campo 'name' es obligatorio." });
11
  return;
12
  }
13
  const key = generateKey(name.trim());
14
  res.status(201).json({
15
- message: "API key generada. Guรกrdala, no se vuelve a mostrar.",
16
- key: key.key,
17
- name: key.name,
18
  createdAt: key.createdAt,
19
  });
20
  });
@@ -27,10 +27,10 @@ router.delete("/keys/:name", requireAdmin, (req, res) => {
27
  const { name } = req.params;
28
  const ok = revokeKey(name!);
29
  if (!ok) {
30
- res.status(404).json({ error: "Key no encontrada o ya revocada." });
31
  return;
32
  }
33
- res.json({ message: `Key '${name}' revocada.` });
34
  });
35
 
36
  export default router;
 
7
  router.post("/keys", requireAdmin, (req, res) => {
8
  const { name } = req.body as { name?: string };
9
  if (!name || typeof name !== "string" || !name.trim()) {
10
+ res.status(400).json({ error: "The 'name' field is required." });
11
  return;
12
  }
13
  const key = generateKey(name.trim());
14
  res.status(201).json({
15
+ message: "API key generated. Save it now โ€” it won't be shown again.",
16
+ key: key.key,
17
+ name: key.name,
18
  createdAt: key.createdAt,
19
  });
20
  });
 
27
  const { name } = req.params;
28
  const ok = revokeKey(name!);
29
  if (!ok) {
30
+ res.status(404).json({ error: "Key not found or already revoked." });
31
  return;
32
  }
33
+ res.json({ message: `Key '${name}' revoked.` });
34
  });
35
 
36
  export default router;
artifacts/api-server/src/routes/neo.ts CHANGED
@@ -11,7 +11,7 @@ router.post("/neo/chat", requireApiKey, async (req, res) => {
11
  };
12
 
13
  if (!message || typeof message !== "string" || !message.trim()) {
14
- res.status(400).json({ error: "El campo 'message' es obligatorio." });
15
  return;
16
  }
17
 
@@ -24,19 +24,19 @@ router.post("/neo/chat", requireApiKey, async (req, res) => {
24
 
25
  if (!response.ok) {
26
  const err = await response.text();
27
- res.status(502).json({ error: "Error del modelo NEO-1", detail: err });
28
  return;
29
  }
30
 
31
  const data = await response.json() as { response: string; model: string; status: string };
32
  res.json({
33
  response: data.response,
34
- model: data.model ?? "mdfjbots-neo-1",
35
- status: "ok",
36
  });
37
  } catch (err: any) {
38
  res.status(503).json({
39
- error: "No se pudo conectar con el modelo NEO-1. ยฟEstรก corriendo?",
40
  detail: err?.message ?? String(err),
41
  });
42
  }
@@ -46,10 +46,10 @@ router.get("/neo/models", requireApiKey, (_req, res) => {
46
  res.json({
47
  models: [
48
  {
49
- id: "mdfjbots-neo-1",
50
- name: "NEO-1",
51
- description: "Asistente conversacional con soporte de Roblox, calculadora y temas educativos.",
52
- version: "1.13.1",
53
  },
54
  ],
55
  });
 
11
  };
12
 
13
  if (!message || typeof message !== "string" || !message.trim()) {
14
+ res.status(400).json({ error: "The 'message' field is required." });
15
  return;
16
  }
17
 
 
24
 
25
  if (!response.ok) {
26
  const err = await response.text();
27
+ res.status(502).json({ error: "NEO-1 model error.", detail: err });
28
  return;
29
  }
30
 
31
  const data = await response.json() as { response: string; model: string; status: string };
32
  res.json({
33
  response: data.response,
34
+ model: data.model ?? "mdfjbots-neo-1",
35
+ status: "ok",
36
  });
37
  } catch (err: any) {
38
  res.status(503).json({
39
+ error: "Could not connect to the NEO-1 model. Is it running?",
40
  detail: err?.message ?? String(err),
41
  });
42
  }
 
46
  res.json({
47
  models: [
48
  {
49
+ id: "mdfjbots-neo-1",
50
+ name: "NEO-1",
51
+ description: "Conversational assistant with Roblox support, calculator, and educational topics.",
52
+ version: "1.13.1",
53
  },
54
  ],
55
  });
chat-app/app.py CHANGED
@@ -1,158 +1,157 @@
1
  import os
2
  import time
3
  import gradio as gr
4
- from neo_rest import iniciar_servidor
5
  from logica import (
6
- buscar_respuesta_personalizada, generar_explicacion_juego,
7
- detectar_roblox, modo_calculadora_activo, extraer_texto_content,
8
  )
9
- from buscador import buscar as buscar_web, ERROR_RED, ERROR_RATE_LIMIT, ERROR_LICENCIA
10
- from resumidor import resumir
11
- from roblox_api import buscar_jugador, buscar_juego, formatear_jugador, formatear_juego
12
  from matematicas import (
13
- es_solicitud_calculadora, es_operacion_matematica,
14
- resolver_operacion, formatear_resultado, extraer_nombre_usuario,
15
  )
16
 
17
- def aรฑadir_turno(historial, user_msg, bot_msg=""):
18
- return historial + [
19
  {"role": "user", "content": user_msg},
20
  {"role": "assistant", "content": bot_msg},
21
  ]
22
 
23
- def stream_tokens(texto, historial):
24
  """
25
- Emite la respuesta token por token (palabra por palabra)
26
- el proceso de generaciรณn de un modelo de lenguaje real.
27
- Tokens largos = pausas ligeramente mayores (mรกs "peso" semรกntico).
28
  """
29
- tokens = texto.split(" ")
30
- acumulado = ""
31
  for i, token in enumerate(tokens):
32
- acumulado += token
33
  if i < len(tokens) - 1:
34
- acumulado += " "
35
- historial[-1]["content"] = acumulado
36
- # Pausa variable: tokens largos tardan un poco mรกs (como un LLM real)
37
  delay = 0.055 if len(token) > 4 else 0.030
38
  time.sleep(delay)
39
- yield historial
40
-
41
- def responder(mensaje, historial):
42
- texto = mensaje.strip().lower()
43
-
44
- if es_solicitud_calculadora(mensaje):
45
- nombre = extraer_nombre_usuario(historial)
46
- saludo = (
47
- f"Claro ๐Ÿ˜€, {nombre} aquรญ tienes nuestra calculadora:\n\n"
48
- "๐Ÿงฎ **Calculadora Virtual NEO-1**\n"
49
- "Escribe cualquier operaciรณn matemรกtica y la resolverรฉ al instante.\n\n"
50
- "**Ejemplos:**\n"
51
  "- `5 + 3`\n- `12 * 7`\n- `100 / 4`\n"
52
- "- `2 ** 8` (potencia)\n- `144 ** 0.5` (raรญz cuadrada)\n\n"
53
- "_Escribe tu operaciรณn o di 'salir de calculadora' para volver._"
54
  )
55
- historial = aรฑadir_turno(historial, mensaje)
56
- for h in stream_tokens(saludo, historial):
57
  yield h, ""
58
  return
59
 
60
- if texto in ("salir de calculadora", "salir calculadora", "cerrar calculadora", "volver al chat"):
61
- historial = aรฑadir_turno(historial, mensaje, "De acuerdo, volviendo al chat normal. ยกPregรบntame lo que quieras! ๐Ÿ˜Š")
62
- yield historial, ""
63
  return
64
 
65
- if modo_calculadora_activo(historial) or es_operacion_matematica(mensaje):
66
- resultado = resolver_operacion(mensaje)
67
- if resultado is not None:
68
- respuesta = formatear_resultado(mensaje, resultado)
69
- historial = aรฑadir_turno(historial, mensaje)
70
- for h in stream_tokens(respuesta, historial):
71
  yield h, ""
72
  return
73
 
74
- tipo_roblox, nombre_roblox = detectar_roblox(mensaje)
75
 
76
- if tipo_roblox == "jugador":
77
- historial = aรฑadir_turno(historial, mensaje, "๐Ÿ” Buscando jugador en Roblox...")
78
- yield historial, ""
79
- datos = buscar_jugador(nombre_roblox)
80
- resultado = formatear_jugador(datos)
81
- historial[-1]["content"] = resultado
82
- yield historial, ""
83
  return
84
 
85
- if tipo_roblox == "juego":
86
- historial = aรฑadir_turno(historial, mensaje, "๐Ÿ” Buscando juego en Roblox...")
87
- yield historial, ""
88
- datos = buscar_juego(nombre_roblox)
89
- resultado = formatear_juego(datos)
90
- if datos and "error" not in datos:
91
- explicacion = generar_explicacion_juego(datos)
92
- resultado = resultado + "\n\n๐Ÿ’ก **ยฟDe quรฉ trata el juego?**\n" + explicacion
93
- historial[-1]["content"] = resultado
94
- yield historial, ""
95
  return
96
 
97
- respuesta_personalizada = buscar_respuesta_personalizada(mensaje)
98
- if respuesta_personalizada:
99
- historial = aรฑadir_turno(historial, mensaje)
100
- for h in stream_tokens(respuesta_personalizada, historial):
101
  yield h, ""
102
  return
103
 
104
- # โ”€โ”€ NEO-2: bรบsqueda en internet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
105
- historial = aรฑadir_turno(historial, mensaje, "๐ŸŒ Buscando en internet...")
106
- yield historial, ""
107
 
108
- resultado_web = buscar_web(mensaje)
109
 
110
- if resultado_web.get("encontrado"):
111
- resumen = resumir(mensaje, resultado_web)
112
- if resumen:
113
- historial[-1]["content"] = ""
114
- for h in stream_tokens(resumen, historial):
115
  yield h, ""
116
  return
117
 
118
- # โ”€โ”€ Mensajes de error segรบn el tipo de fallo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
119
- tipo_error = resultado_web.get("tipo_error", "")
120
 
121
- if tipo_error == ERROR_RED:
122
  msg = (
123
- "โš ๏ธ **Sin conexiรณn a internet**\n\n"
124
- "No pude conectarme al buscador web en este momento. "
125
- "Revisรฉ mi base de conocimiento pero tampoco encontrรฉ el tema ahรญ.\n\n"
126
- "_Intenta de nuevo en unos segundos o reformula tu pregunta._"
127
  )
128
- elif tipo_error == ERROR_RATE_LIMIT:
129
  msg = (
130
- "โณ **Demasiadas bรบsquedas en poco tiempo**\n\n"
131
- "El buscador me pidiรณ pausar un momento. "
132
- "Espera unos segundos e intenta de nuevo. ๐Ÿ˜Š"
133
  )
134
- elif tipo_error == ERROR_LICENCIA:
135
  msg = (
136
- "๐Ÿ”’ **Fuentes no disponibles**\n\n"
137
- "Encontrรฉ resultados en internet, pero todas las fuentes usan licencias restrictivas "
138
- "(All Rights Reserved, CC BY-ND, etc.) que no me permiten usar su contenido.\n\n"
139
- "_Intenta reformular tu pregunta para encontrar fuentes abiertas._"
140
  )
141
  else:
142
  msg = (
143
- "๐Ÿค– **Sin resultados**\n\n"
144
- "No encontrรฉ informaciรณn sobre ese tema ni en mi base de conocimiento "
145
- "ni en internet. Intenta reformular tu pregunta."
146
  )
147
 
148
- historial[-1]["content"] = msg
149
- yield historial, ""
150
 
151
  with gr.Blocks(title="mdfjbots-neo-1") as demo:
152
  gr.Markdown(
153
  """
154
  # ๐Ÿค– mdfjbots-neo-1
155
- ### Asistente de inteligencia artificial conversacional
156
  """
157
  )
158
 
@@ -163,38 +162,38 @@ with gr.Blocks(title="mdfjbots-neo-1") as demo:
163
  )
164
 
165
  with gr.Row():
166
- entrada = gr.Textbox(
167
- placeholder="Escribe tu mensaje aquรญ... (ej: 'buscar jugador Builderman')",
168
  show_label=False,
169
  scale=9,
170
  container=False,
171
  autofocus=True,
172
  )
173
- btn_enviar = gr.Button("Enviar", scale=1, variant="primary")
174
 
175
  with gr.Row():
176
- btn_limpiar = gr.Button("๐Ÿ—‘๏ธ Limpiar conversaciรณn", size="sm")
177
 
178
  gr.Examples(
179
  examples=[
180
- "Hola, ยฟquiรฉn eres?",
181
- "buscar jugador Builderman",
182
- "buscar juego Adopt Me",
183
- "definiciรณn de historia",
184
- "calculadora",
185
- "que es linux",
186
- "guerra de corea",
187
  ],
188
- inputs=entrada,
189
- label="Ejemplos de preguntas",
190
  )
191
 
192
- entrada.submit(fn=responder, inputs=[entrada, chatbot], outputs=[chatbot, entrada])
193
- btn_enviar.click(fn=responder, inputs=[entrada, chatbot], outputs=[chatbot, entrada])
194
- btn_limpiar.click(fn=lambda: ([], ""), outputs=[chatbot, entrada])
195
 
196
  if __name__ == "__main__":
197
- iniciar_servidor()
198
 
199
  port = int(os.environ.get("PORT", 5000))
200
  dev_domain = os.environ.get("REPLIT_DEV_DOMAIN", "")
 
1
  import os
2
  import time
3
  import gradio as gr
4
+ from neo_rest import start_server
5
  from logica import (
6
+ find_custom_response, generate_game_explanation,
7
+ detect_roblox, calculator_mode_active, extract_text_content,
8
  )
9
+ from buscador import search as web_search, ERROR_NETWORK, ERROR_RATE_LIMIT, ERROR_LICENSE
10
+ from resumidor import summarize
11
+ from roblox_api import search_player, search_game, format_player, format_game
12
  from matematicas import (
13
+ is_calculator_request, is_math_operation,
14
+ solve_operation, format_result, extract_username,
15
  )
16
 
17
+ def add_turn(history, user_msg, bot_msg=""):
18
+ return history + [
19
  {"role": "user", "content": user_msg},
20
  {"role": "assistant", "content": bot_msg},
21
  ]
22
 
23
+ def stream_tokens(text, history):
24
  """
25
+ Emits the response token by token (word by word),
26
+ simulating the generation process of a real language model.
27
+ Longer tokens = slightly longer pauses (more semantic weight).
28
  """
29
+ tokens = text.split(" ")
30
+ accumulated = ""
31
  for i, token in enumerate(tokens):
32
+ accumulated += token
33
  if i < len(tokens) - 1:
34
+ accumulated += " "
35
+ history[-1]["content"] = accumulated
 
36
  delay = 0.055 if len(token) > 4 else 0.030
37
  time.sleep(delay)
38
+ yield history
39
+
40
+ def respond(message, history):
41
+ text = message.strip().lower()
42
+
43
+ if is_calculator_request(message):
44
+ name = extract_username(history)
45
+ greeting = (
46
+ f"Sure! ๐Ÿ˜€ {name}, here's our calculator:\n\n"
47
+ "๐Ÿงฎ **NEO-1 Virtual Calculator**\n"
48
+ "Type any math operation and I'll solve it instantly.\n\n"
49
+ "**Examples:**\n"
50
  "- `5 + 3`\n- `12 * 7`\n- `100 / 4`\n"
51
+ "- `2 ** 8` (power)\n- `144 ** 0.5` (square root)\n\n"
52
+ "_Type your operation or say 'exit calculator' to go back._"
53
  )
54
+ history = add_turn(history, message)
55
+ for h in stream_tokens(greeting, history):
56
  yield h, ""
57
  return
58
 
59
+ if text in ("exit calculator", "close calculator", "quit calculator", "back to chat"):
60
+ history = add_turn(history, message, "Alright, back to normal chat. Ask me anything! ๐Ÿ˜Š")
61
+ yield history, ""
62
  return
63
 
64
+ if calculator_mode_active(history) or is_math_operation(message):
65
+ result = solve_operation(message)
66
+ if result is not None:
67
+ response = format_result(message, result)
68
+ history = add_turn(history, message)
69
+ for h in stream_tokens(response, history):
70
  yield h, ""
71
  return
72
 
73
+ roblox_type, roblox_name = detect_roblox(message)
74
 
75
+ if roblox_type == "player":
76
+ history = add_turn(history, message, "๐Ÿ” Searching for player on Roblox...")
77
+ yield history, ""
78
+ data = search_player(roblox_name)
79
+ result = format_player(data)
80
+ history[-1]["content"] = result
81
+ yield history, ""
82
  return
83
 
84
+ if roblox_type == "game":
85
+ history = add_turn(history, message, "๐Ÿ” Searching for game on Roblox...")
86
+ yield history, ""
87
+ data = search_game(roblox_name)
88
+ result = format_game(data)
89
+ if data and "error" not in data:
90
+ explanation = generate_game_explanation(data)
91
+ result = result + "\n\n๐Ÿ’ก **What is this game about?**\n" + explanation
92
+ history[-1]["content"] = result
93
+ yield history, ""
94
  return
95
 
96
+ custom_response = find_custom_response(message)
97
+ if custom_response:
98
+ history = add_turn(history, message)
99
+ for h in stream_tokens(custom_response, history):
100
  yield h, ""
101
  return
102
 
103
+ # โ”€โ”€ NEO-2: web search fallback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
104
+ history = add_turn(history, message, "๐ŸŒ Searching the web...")
105
+ yield history, ""
106
 
107
+ web_result = web_search(message)
108
 
109
+ if web_result.get("found"):
110
+ summary = summarize(message, web_result)
111
+ if summary:
112
+ history[-1]["content"] = ""
113
+ for h in stream_tokens(summary, history):
114
  yield h, ""
115
  return
116
 
117
+ # โ”€โ”€ User-facing error messages based on failure type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
118
+ error_type = web_result.get("error_type", "")
119
 
120
+ if error_type == ERROR_NETWORK:
121
  msg = (
122
+ "โš ๏ธ **No internet connection**\n\n"
123
+ "I couldn't reach the web search engine right now. "
124
+ "I also checked my knowledge base but found nothing on that topic.\n\n"
125
+ "_Please try again in a few seconds or rephrase your question._"
126
  )
127
+ elif error_type == ERROR_RATE_LIMIT:
128
  msg = (
129
+ "โณ **Too many searches in a short time**\n\n"
130
+ "The search engine asked me to slow down for a moment. "
131
+ "Please wait a few seconds and try again. ๐Ÿ˜Š"
132
  )
133
+ elif error_type == ERROR_LICENSE:
134
  msg = (
135
+ "๐Ÿ”’ **Sources unavailable**\n\n"
136
+ "I found results online, but all sources use restrictive licenses "
137
+ "(All Rights Reserved, CC BY-ND, etc.) that don't allow me to use their content.\n\n"
138
+ "_Try rephrasing your question to find open-licensed sources._"
139
  )
140
  else:
141
  msg = (
142
+ "๐Ÿค– **No results found**\n\n"
143
+ "I couldn't find information on that topic in my knowledge base "
144
+ "or on the web. Try rephrasing your question."
145
  )
146
 
147
+ history[-1]["content"] = msg
148
+ yield history, ""
149
 
150
  with gr.Blocks(title="mdfjbots-neo-1") as demo:
151
  gr.Markdown(
152
  """
153
  # ๐Ÿค– mdfjbots-neo-1
154
+ ### Conversational AI assistant
155
  """
156
  )
157
 
 
162
  )
163
 
164
  with gr.Row():
165
+ input_box = gr.Textbox(
166
+ placeholder="Type your message here... (e.g. 'search player Builderman')",
167
  show_label=False,
168
  scale=9,
169
  container=False,
170
  autofocus=True,
171
  )
172
+ btn_send = gr.Button("Send", scale=1, variant="primary")
173
 
174
  with gr.Row():
175
+ btn_clear = gr.Button("๐Ÿ—‘๏ธ Clear conversation", size="sm")
176
 
177
  gr.Examples(
178
  examples=[
179
+ "Hello, who are you?",
180
+ "search player Builderman",
181
+ "search game Adopt Me",
182
+ "definition of history",
183
+ "calculator",
184
+ "what is linux",
185
+ "korean war",
186
  ],
187
+ inputs=input_box,
188
+ label="Example questions",
189
  )
190
 
191
+ input_box.submit(fn=respond, inputs=[input_box, chatbot], outputs=[chatbot, input_box])
192
+ btn_send.click(fn=respond, inputs=[input_box, chatbot], outputs=[chatbot, input_box])
193
+ btn_clear.click(fn=lambda: ([], ""), outputs=[chatbot, input_box])
194
 
195
  if __name__ == "__main__":
196
+ start_server()
197
 
198
  port = int(os.environ.get("PORT", 5000))
199
  dev_domain = os.environ.get("REPLIT_DEV_DOMAIN", "")
chat-app/buscador.py CHANGED
@@ -1,70 +1,70 @@
1
  """
2
- buscador.py โ€” NEO-2 Motor de bรบsqueda web con filtro de licencias
3
- ------------------------------------------------------------------
4
- Usa ddgs para bรบsqueda web real. Filtra resultados segรบn la licencia
5
- del dominio fuente para respetar los tรฉrminos de uso del contenido.
6
-
7
- Polรญtica de licencias NEO-2:
8
- โœ… PERMITIDAS : CC BY, CC0, Dominio Pรบblico
9
- โŒ EVITADAS : CC BY-SA, CC BY-NC, CC BY-ND, All Rights Reserved
10
-
11
- Resiliencia ante fallos:
12
- โ€ข Reintentos automรกticos (hasta 2 intentos con pausa entre ellos)
13
- โ€ข Cachรฉ en memoria para la sesiรณn actual (evita repetir bรบsquedas)
14
- โ€ข Errores tipificados para dar mensajes claros al usuario
15
  """
16
 
17
  import time
18
  from ddgs import DDGS
19
 
20
- # โ”€โ”€ Cachรฉ de sesiรณn โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
21
- # Guarda resultados de la sesiรณn actual para no volver a buscar lo mismo.
22
  _CACHE: dict[str, dict] = {}
23
- _MAX_CACHE = 50 # mรกximo de queries guardadas en memoria
24
 
25
- _REINTENTOS = 2 # nรบmero de intentos antes de declarar fallo
26
- _PAUSA_RETRY = 1.5 # segundos entre reintentos
27
 
28
- # โ”€โ”€ Clasificaciรณn de dominios por licencia โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
 
30
- # โœ… Fuentes con licencia CC BY, CC0 o Dominio Pรบblico
31
- DOMINIOS_PERMITIDOS: dict[str, str] = {
32
- # Gobierno / datos abiertos (dominio pรบblico o CC0)
33
- "nasa.gov": "CC0 / Dominio Pรบblico",
34
  "data.gov": "CC0",
35
- "datos.gob.mx": "Dominio Pรบblico",
36
  "datos.gob.es": "CC BY",
37
  "datos.gob.ar": "CC BY",
38
  "open.canada.ca": "CC BY",
39
  "data.europa.eu": "CC BY 4.0",
40
  "ec.europa.eu": "CC BY 4.0",
41
- "who.int": "CC BY-NC-SA", # OMS โ€” permitida solo referencia
42
- "un.org": "Dominio Pรบblico",
43
 
44
- # Conocimiento abierto (CC0 / CC BY)
45
  "wikidata.org": "CC0",
46
  "commons.wikimedia.org": "CC BY / CC0",
47
- "gutenberg.org": "Dominio Pรบblico",
48
- "archive.org": "CC BY / Dominio Pรบblico",
49
  "openstax.org": "CC BY 4.0",
50
- "arxiv.org": "CC BY / licencia autor",
51
  "plos.org": "CC BY 4.0",
52
  "doaj.org": "CC BY",
53
  "creativecommons.org":"CC BY 4.0",
54
- "publicdomainreview.org": "CC BY-SA", # parcialmente abierto
55
  "freesound.org": "CC0 / CC BY",
56
- "unsplash.com": "Licencia Unsplash (similar CC0)",
57
  "pixabay.com": "CC0",
58
  "pexels.com": "CC0",
59
 
60
- # Diccionarios / enciclopedias libres (CC BY)
61
- "simple.wikipedia.org": "CC BY-SA", # SA โ€” evitado pero mejor que nada
62
  "es.wikiversity.org": "CC BY-SA",
63
  }
64
 
65
- # โŒ Fuentes con licencia restrictiva (CC BY-SA, CC BY-NC, CC BY-ND, ARR)
66
- DOMINIOS_EVITADOS: dict[str, str] = {
67
- # CC BY-SA (share-alike โ€” impone licencia en derivados)
68
  "wikipedia.org": "CC BY-SA",
69
  "wikivoyage.org": "CC BY-SA",
70
  "wikibooks.org": "CC BY-SA",
@@ -74,18 +74,18 @@ DOMINIOS_EVITADOS: dict[str, str] = {
74
  "stackexchange.com": "CC BY-SA 4.0",
75
  "openstreetmap.org": "ODbL (share-alike)",
76
 
77
- # CC BY-NC (no comercial)
78
  "medium.com": "CC BY-NC",
79
  "academia.edu": "CC BY-NC",
80
  "researchgate.net": "CC BY-NC",
81
  "slideshare.net": "CC BY-NC",
82
  "ted.com": "CC BY-NC",
83
 
84
- # CC BY-ND (sin derivados โ€” no se puede resumir/parafrasear)
85
  "economist.com": "CC BY-ND",
86
  "foreignpolicy.com": "CC BY-ND",
87
 
88
- # All Rights Reserved (todos los derechos reservados)
89
  "cnn.com": "All Rights Reserved",
90
  "bbc.com": "All Rights Reserved",
91
  "bbc.co.uk": "All Rights Reserved",
@@ -120,141 +120,138 @@ DOMINIOS_EVITADOS: dict[str, str] = {
120
  "google.es": "All Rights Reserved",
121
  }
122
 
123
- # โ”€โ”€ Clasificar un dominio โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
124
 
125
- def _dominio_de_url(url: str) -> str:
126
- """Extrae el dominio base de una URL."""
127
  url = url.lower().replace("https://", "").replace("http://", "").replace("www.", "")
128
  return url.split("/")[0]
129
 
130
- def _clasificar_url(url: str) -> str:
131
- """
132
- Retorna: 'permitido', 'evitado' o 'desconocido'
133
- """
134
- dominio = _dominio_de_url(url)
135
 
136
- # Chequear evitados (verificaciรณn exacta + sufijos)
137
- for d_evitado in DOMINIOS_EVITADOS:
138
- if dominio == d_evitado or dominio.endswith("." + d_evitado):
139
- return "evitado"
140
 
141
- # Chequear permitidos
142
- for d_permitido in DOMINIOS_PERMITIDOS:
143
- if dominio == d_permitido or dominio.endswith("." + d_permitido):
144
- return "permitido"
145
 
146
- # Heurรญstica: dominios .gov y .edu suelen ser CC0 / dominio pรบblico
147
- if dominio.endswith(".gov") or dominio.endswith(".gob") or \
148
- dominio.endswith(".gob.mx") or dominio.endswith(".gob.ar") or \
149
- dominio.endswith(".gob.es") or dominio.endswith(".edu"):
150
- return "permitido"
151
 
152
- return "desconocido"
153
 
154
- def _es_resultado_util(resultado: dict) -> bool:
155
- """Descarta resultados demasiado cortos."""
156
- cuerpo = resultado.get("body", "").strip()
157
- return len(cuerpo) >= 40
158
 
159
- # โ”€โ”€ Tipos de error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
160
 
161
- ERROR_RED = "error_red" # sin conexiรณn / timeout
162
- ERROR_RATE_LIMIT = "error_rate_limit" # demasiadas peticiones
163
- ERROR_LICENCIA = "error_licencia" # solo fuentes bloqueadas
164
- ERROR_SIN_DATOS = "error_sin_datos" # bรบsqueda ok pero sin resultados รบtiles
165
 
166
- def _hacer_busqueda(query: str) -> list:
167
- """Ejecuta la bรบsqueda DDGS. Lanza excepciรณn si falla."""
168
  with DDGS() as ddgs:
169
- return list(ddgs.text(query, max_results=12, region="es-es"))
170
 
171
- def _procesar_resultados(resultados: list) -> dict:
172
  """
173
- Filtra por licencia y construye el dict de respuesta.
174
- Retorna el dict final (encontrado True/False).
175
  """
176
- utiles = [r for r in resultados if _es_resultado_util(r)]
177
 
178
- permitidos = []
179
- desconocidos = []
180
- for r in utiles:
181
  url = r.get("href", r.get("url", ""))
182
- cat = _clasificar_url(url)
183
- if cat == "permitido":
184
- permitidos.append(r)
185
- elif cat == "desconocido":
186
- desconocidos.append(r)
187
-
188
- candidatos = permitidos if permitidos else desconocidos
189
-
190
- if not candidatos:
191
- return {"encontrado": False, "tipo_error": ERROR_LICENCIA,
192
- "abstract": "", "snippets": [], "titulo": "", "url": "", "licencia": ""}
193
-
194
- primero = candidatos[0]
195
- titulo = primero.get("title", "").strip()
196
- abstract = primero.get("body", "").strip()
197
- url = primero.get("href", primero.get("url", "")).strip()
198
- licencia = DOMINIOS_PERMITIDOS.get(_dominio_de_url(url), "Desconocida")
199
- snippets = [r.get("body", "").strip() for r in candidatos[1:6] if r.get("body", "").strip()]
200
- origen = "permitido" if permitidos else "desconocido"
 
 
201
 
202
  return {
203
- "titulo": titulo, "abstract": abstract, "snippets": snippets,
204
- "url": url, "licencia": licencia, "origen": origen, "encontrado": True,
205
  }
206
 
207
- # โ”€โ”€ Funciรณn principal con reintentos + cachรฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
208
 
209
- def buscar(query: str) -> dict:
210
  """
211
- Busca en la web con tres niveles de protecciรณn ante fallos:
212
 
213
- 1. Cachรฉ de sesiรณn โ€” si ya se buscรณ esta query, devuelve el resultado guardado.
214
- 2. Reintentos automรกticos โ€” intenta hasta _REINTENTOS veces con pausa entre ellos.
215
- 3. Errores tipificados โ€” distingue entre fallo de red, rate-limit y sin resultados,
216
- para que app.py pueda mostrar mensajes claros al usuario.
217
  """
218
- clave = query.strip().lower()
219
 
220
- # โ”€โ”€ 1. Cachรฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
221
- if clave in _CACHE:
222
- resultado = dict(_CACHE[clave])
223
- resultado["desde_cache"] = True
224
- return resultado
225
 
226
- # โ”€โ”€ 2. Reintentos โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
227
- ultimo_error = None
228
- tipo_error = ERROR_RED
229
 
230
- for intento in range(_REINTENTOS):
231
  try:
232
- resultados = _hacer_busqueda(query)
233
- resultado = _procesar_resultados(resultados)
234
 
235
- # Guardar en cachรฉ si fue exitoso
236
- if resultado.get("encontrado"):
237
  if len(_CACHE) >= _MAX_CACHE:
238
- _CACHE.pop(next(iter(_CACHE))) # eliminar el mรกs viejo
239
- _CACHE[clave] = resultado
240
 
241
- return resultado
242
 
243
  except Exception as e:
244
- ultimo_error = str(e)
245
- err_lower = ultimo_error.lower()
246
 
247
  if "ratelimit" in err_lower or "429" in err_lower or "too many" in err_lower:
248
- tipo_error = ERROR_RATE_LIMIT
249
- break # no tiene sentido reintentar un rate-limit inmediatamente
250
 
251
- if intento < _REINTENTOS - 1:
252
- time.sleep(_PAUSA_RETRY)
253
 
254
- # โ”€โ”€ 3. Fallo definitivo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
255
  return {
256
- "encontrado": False,
257
- "tipo_error": tipo_error,
258
- "detalle": ultimo_error or "Error desconocido",
259
- "abstract": "", "snippets": [], "titulo": "", "url": "", "licencia": "",
260
  }
 
1
  """
2
+ buscador.py โ€” NEO-2 Web search engine with license filtering
3
+ ------------------------------------------------------------
4
+ Uses ddgs for real web search. Filters results based on the source
5
+ domain's license to respect content usage terms.
6
+
7
+ NEO-2 license policy:
8
+ โœ… ALLOWED : CC BY, CC0, Public Domain
9
+ โŒ BLOCKED : CC BY-SA, CC BY-NC, CC BY-ND, All Rights Reserved
10
+
11
+ Fault tolerance:
12
+ โ€ข Automatic retries (up to 2 attempts with pause between them)
13
+ โ€ข In-memory session cache (avoids repeated searches)
14
+ โ€ข Typed errors for clear user-facing messages
15
  """
16
 
17
  import time
18
  from ddgs import DDGS
19
 
20
+ # โ”€โ”€ Session cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
21
+ # Stores results from the current session to avoid re-searching the same query.
22
  _CACHE: dict[str, dict] = {}
23
+ _MAX_CACHE = 50
24
 
25
+ _MAX_RETRIES = 2
26
+ _RETRY_PAUSE = 1.5
27
 
28
+ # โ”€โ”€ Domain classification by license โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
 
30
+ # โœ… Allowed: CC BY, CC0 or Public Domain sources
31
+ ALLOWED_DOMAINS: dict[str, str] = {
32
+ # Government / open data (public domain or CC0)
33
+ "nasa.gov": "CC0 / Public Domain",
34
  "data.gov": "CC0",
35
+ "datos.gob.mx": "Public Domain",
36
  "datos.gob.es": "CC BY",
37
  "datos.gob.ar": "CC BY",
38
  "open.canada.ca": "CC BY",
39
  "data.europa.eu": "CC BY 4.0",
40
  "ec.europa.eu": "CC BY 4.0",
41
+ "who.int": "CC BY-NC-SA",
42
+ "un.org": "Public Domain",
43
 
44
+ # Open knowledge (CC0 / CC BY)
45
  "wikidata.org": "CC0",
46
  "commons.wikimedia.org": "CC BY / CC0",
47
+ "gutenberg.org": "Public Domain",
48
+ "archive.org": "CC BY / Public Domain",
49
  "openstax.org": "CC BY 4.0",
50
+ "arxiv.org": "CC BY / author license",
51
  "plos.org": "CC BY 4.0",
52
  "doaj.org": "CC BY",
53
  "creativecommons.org":"CC BY 4.0",
54
+ "publicdomainreview.org": "CC BY-SA",
55
  "freesound.org": "CC0 / CC BY",
56
+ "unsplash.com": "Unsplash License (CC0-like)",
57
  "pixabay.com": "CC0",
58
  "pexels.com": "CC0",
59
 
60
+ # Free encyclopedias / dictionaries (CC BY)
61
+ "simple.wikipedia.org": "CC BY-SA",
62
  "es.wikiversity.org": "CC BY-SA",
63
  }
64
 
65
+ # โŒ Blocked: restrictive licenses (CC BY-SA, CC BY-NC, CC BY-ND, ARR)
66
+ BLOCKED_DOMAINS: dict[str, str] = {
67
+ # CC BY-SA (share-alike โ€” imposes license on derivatives)
68
  "wikipedia.org": "CC BY-SA",
69
  "wikivoyage.org": "CC BY-SA",
70
  "wikibooks.org": "CC BY-SA",
 
74
  "stackexchange.com": "CC BY-SA 4.0",
75
  "openstreetmap.org": "ODbL (share-alike)",
76
 
77
+ # CC BY-NC (non-commercial)
78
  "medium.com": "CC BY-NC",
79
  "academia.edu": "CC BY-NC",
80
  "researchgate.net": "CC BY-NC",
81
  "slideshare.net": "CC BY-NC",
82
  "ted.com": "CC BY-NC",
83
 
84
+ # CC BY-ND (no derivatives โ€” cannot be summarized or paraphrased)
85
  "economist.com": "CC BY-ND",
86
  "foreignpolicy.com": "CC BY-ND",
87
 
88
+ # All Rights Reserved
89
  "cnn.com": "All Rights Reserved",
90
  "bbc.com": "All Rights Reserved",
91
  "bbc.co.uk": "All Rights Reserved",
 
120
  "google.es": "All Rights Reserved",
121
  }
122
 
123
+ # โ”€โ”€ Domain helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
124
 
125
+ def _extract_domain(url: str) -> str:
126
+ """Extracts the base domain from a URL."""
127
  url = url.lower().replace("https://", "").replace("http://", "").replace("www.", "")
128
  return url.split("/")[0]
129
 
130
+ def _classify_url(url: str) -> str:
131
+ """Returns: 'allowed', 'blocked' or 'unknown'."""
132
+ domain = _extract_domain(url)
 
 
133
 
134
+ for blocked in BLOCKED_DOMAINS:
135
+ if domain == blocked or domain.endswith("." + blocked):
136
+ return "blocked"
 
137
 
138
+ for allowed in ALLOWED_DOMAINS:
139
+ if domain == allowed or domain.endswith("." + allowed):
140
+ return "allowed"
 
141
 
142
+ # Heuristic: .gov and .edu domains are usually CC0 / public domain
143
+ if (domain.endswith(".gov") or domain.endswith(".gob") or
144
+ domain.endswith(".gob.mx") or domain.endswith(".gob.ar") or
145
+ domain.endswith(".gob.es") or domain.endswith(".edu")):
146
+ return "allowed"
147
 
148
+ return "unknown"
149
 
150
+ def _is_useful_result(result: dict) -> bool:
151
+ """Discards results that are too short to be useful."""
152
+ body = result.get("body", "").strip()
153
+ return len(body) >= 40
154
 
155
+ # โ”€โ”€ Error type constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
156
 
157
+ ERROR_NETWORK = "error_network" # connection failure / timeout
158
+ ERROR_RATE_LIMIT = "error_rate_limit" # too many requests
159
+ ERROR_LICENSE = "error_license" # only blocked sources found
160
+ ERROR_NO_DATA = "error_no_data" # search ok but no useful results
161
 
162
+ def _run_search(query: str) -> list:
163
+ """Executes the DDGS search. Raises an exception on failure."""
164
  with DDGS() as ddgs:
165
+ return list(ddgs.text(query, max_results=12, region="en-us"))
166
 
167
+ def _process_results(results: list) -> dict:
168
  """
169
+ Filters results by license and builds the response dict.
170
+ Returns the final dict with found=True or found=False.
171
  """
172
+ useful = [r for r in results if _is_useful_result(r)]
173
 
174
+ allowed = []
175
+ unknown = []
176
+ for r in useful:
177
  url = r.get("href", r.get("url", ""))
178
+ cat = _classify_url(url)
179
+ if cat == "allowed":
180
+ allowed.append(r)
181
+ elif cat == "unknown":
182
+ unknown.append(r)
183
+
184
+ candidates = allowed if allowed else unknown
185
+
186
+ if not candidates:
187
+ return {
188
+ "found": False, "error_type": ERROR_LICENSE,
189
+ "abstract": "", "snippets": [], "title": "", "url": "", "license": "",
190
+ }
191
+
192
+ first = candidates[0]
193
+ title = first.get("title", "").strip()
194
+ abstract = first.get("body", "").strip()
195
+ url = first.get("href", first.get("url", "")).strip()
196
+ license_ = ALLOWED_DOMAINS.get(_extract_domain(url), "Unknown")
197
+ snippets = [r.get("body", "").strip() for r in candidates[1:6] if r.get("body", "").strip()]
198
+ origin = "allowed" if allowed else "unknown"
199
 
200
  return {
201
+ "title": title, "abstract": abstract, "snippets": snippets,
202
+ "url": url, "license": license_, "origin": origin, "found": True,
203
  }
204
 
205
+ # โ”€โ”€ Main search function with retries + cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
206
 
207
+ def search(query: str) -> dict:
208
  """
209
+ Searches the web with three layers of fault protection:
210
 
211
+ 1. Session cache โ€” returns a saved result if this query was already searched.
212
+ 2. Automatic retries โ€” tries up to _MAX_RETRIES times with a pause between them.
213
+ 3. Typed errors โ€” distinguishes between network failure, rate-limit and no
214
+ results, so app.py can show clear messages to the user.
215
  """
216
+ cache_key = query.strip().lower()
217
 
218
+ # โ”€โ”€ 1. Cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
219
+ if cache_key in _CACHE:
220
+ result = dict(_CACHE[cache_key])
221
+ result["from_cache"] = True
222
+ return result
223
 
224
+ # โ”€โ”€ 2. Retries โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
225
+ last_error = None
226
+ error_type = ERROR_NETWORK
227
 
228
+ for attempt in range(_MAX_RETRIES):
229
  try:
230
+ raw_results = _run_search(query)
231
+ result = _process_results(raw_results)
232
 
233
+ if result.get("found"):
 
234
  if len(_CACHE) >= _MAX_CACHE:
235
+ _CACHE.pop(next(iter(_CACHE)))
236
+ _CACHE[cache_key] = result
237
 
238
+ return result
239
 
240
  except Exception as e:
241
+ last_error = str(e)
242
+ err_lower = last_error.lower()
243
 
244
  if "ratelimit" in err_lower or "429" in err_lower or "too many" in err_lower:
245
+ error_type = ERROR_RATE_LIMIT
246
+ break
247
 
248
+ if attempt < _MAX_RETRIES - 1:
249
+ time.sleep(_RETRY_PAUSE)
250
 
251
+ # โ”€โ”€ 3. Definitive failure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
252
  return {
253
+ "found": False,
254
+ "error_type": error_type,
255
+ "detail": last_error or "Unknown error",
256
+ "abstract": "", "snippets": [], "title": "", "url": "", "license": "",
257
  }
chat-app/compilar_respuestas.py CHANGED
@@ -1,16 +1,19 @@
1
  """
2
- compilar_respuestas.py โ€” NEO-1 Closed Edition
3
- ----------------------------------------------
4
- Convierte respuestas.json en respuestas.dat (cifrado + comprimido).
5
 
6
- Uso:
7
  python chat-app/compilar_respuestas.py
8
 
9
- El archivo .dat resultante es la versiรณn cerrada del banco de conocimiento.
10
- Incluye este .dat en tu repositorio privado y excluye respuestas.json.
11
 
12
- La clave de cifrado se lee de la variable de entorno NEO_DATA_KEY.
13
- Si no estรก definida, usa la clave interna por defecto.
 
 
 
14
  """
15
 
16
  import os
@@ -19,74 +22,73 @@ import zlib
19
  import base64
20
  import json
21
 
22
- _DIR = os.path.dirname(os.path.abspath(__file__))
23
- RUTA_JSON = os.path.join(_DIR, "respuestas.json")
24
- RUTA_DAT = os.path.join(_DIR, "respuestas.dat")
25
 
26
- def _clave() -> bytes:
27
  key = os.environ.get("NEO_DATA_KEY", "neo1-mdfj-science-closed-2025")
28
  return key.encode("utf-8")
29
 
30
  def _xor(data: bytes, key: bytes) -> bytes:
31
  return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
32
 
33
- def compilar():
34
- if not os.path.exists(RUTA_JSON):
35
- print(f"โŒ No se encontrรณ {RUTA_JSON}")
36
  sys.exit(1)
37
 
38
- with open(RUTA_JSON, "r", encoding="utf-8") as f:
39
- contenido = f.read()
40
 
41
- # Validar JSON antes de compilar
42
  try:
43
- datos = json.loads(contenido)
44
- n_entradas = len(datos.get("respuestas", []))
45
  except json.JSONDecodeError as e:
46
- print(f"โŒ JSON invรกlido: {e}")
47
  sys.exit(1)
48
 
49
- raw = contenido.encode("utf-8")
50
- comprimido = zlib.compress(raw, level=9)
51
- cifrado = _xor(comprimido, _clave())
52
- encoded = base64.b64encode(cifrado)
53
 
54
- with open(RUTA_DAT, "wb") as f:
55
  f.write(encoded)
56
 
57
- print(f"โœ… Compilado correctamente:")
58
- print(f" Entradas de conocimiento : {n_entradas}")
59
- print(f" JSON original : {len(raw):,} bytes")
60
- print(f" DAT cifrado : {len(encoded):,} bytes")
61
- print(f" Guardado en : {RUTA_DAT}")
62
  print()
63
- print("๐Ÿ“ฆ Para la versiรณn cerrada:")
64
- print(" - Incluye 'respuestas.dat' en tu repo privado")
65
- print(" - NO incluyas 'respuestas.json'")
66
- print(" - Define NEO_DATA_KEY como variable de entorno secreta")
67
-
68
- def descompilar(destino: str | None = None):
69
- """Utilidad: convierte respuestas.dat de vuelta a JSON (solo para el dueรฑo)."""
70
- if not os.path.exists(RUTA_DAT):
71
- print(f"โŒ No se encontrรณ {RUTA_DAT}")
72
  sys.exit(1)
73
 
74
- with open(RUTA_DAT, "rb") as f:
75
  encoded = f.read()
76
 
77
- cifrado = base64.b64decode(encoded)
78
- comprimido = _xor(cifrado, _clave())
79
- raw = zlib.decompress(comprimido)
80
- contenido = raw.decode("utf-8")
81
 
82
- salida = destino or RUTA_JSON.replace(".json", "_descompilado.json")
83
- with open(salida, "w", encoding="utf-8") as f:
84
- f.write(contenido)
85
 
86
- print(f"โœ… Descompilado en: {salida}")
87
 
88
  if __name__ == "__main__":
89
- if len(sys.argv) > 1 and sys.argv[1] == "--descompilar":
90
- descompilar(sys.argv[2] if len(sys.argv) > 2 else None)
91
  else:
92
- compilar()
 
1
  """
2
+ compilar_respuestas.py โ€” NEO-1 Closed Edition compiler
3
+ -------------------------------------------------------
4
+ Converts respuestas.json into respuestas.dat (encrypted + compressed).
5
 
6
+ Usage:
7
  python chat-app/compilar_respuestas.py
8
 
9
+ The resulting .dat file is the closed edition of the knowledge base.
10
+ Include this .dat in your private repository and exclude respuestas.json.
11
 
12
+ The encryption key is read from the NEO_DATA_KEY environment variable.
13
+ If not set, falls back to the internal default key.
14
+
15
+ Decompile (owner only):
16
+ python chat-app/compilar_respuestas.py --decompile [output_path]
17
  """
18
 
19
  import os
 
22
  import base64
23
  import json
24
 
25
+ _DIR = os.path.dirname(os.path.abspath(__file__))
26
+ JSON_PATH = os.path.join(_DIR, "respuestas.json")
27
+ DAT_PATH = os.path.join(_DIR, "respuestas.dat")
28
 
29
+ def _get_key() -> bytes:
30
  key = os.environ.get("NEO_DATA_KEY", "neo1-mdfj-science-closed-2025")
31
  return key.encode("utf-8")
32
 
33
  def _xor(data: bytes, key: bytes) -> bytes:
34
  return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
35
 
36
+ def compile_knowledge():
37
+ if not os.path.exists(JSON_PATH):
38
+ print(f"โŒ File not found: {JSON_PATH}")
39
  sys.exit(1)
40
 
41
+ with open(JSON_PATH, "r", encoding="utf-8") as f:
42
+ content = f.read()
43
 
 
44
  try:
45
+ data = json.loads(content)
46
+ entry_count = len(data.get("respuestas", []))
47
  except json.JSONDecodeError as e:
48
+ print(f"โŒ Invalid JSON: {e}")
49
  sys.exit(1)
50
 
51
+ raw = content.encode("utf-8")
52
+ compressed = zlib.compress(raw, level=9)
53
+ encrypted = _xor(compressed, _get_key())
54
+ encoded = base64.b64encode(encrypted)
55
 
56
+ with open(DAT_PATH, "wb") as f:
57
  f.write(encoded)
58
 
59
+ print("โœ… Compiled successfully:")
60
+ print(f" Knowledge entries : {entry_count}")
61
+ print(f" Original JSON : {len(raw):,} bytes")
62
+ print(f" Encrypted DAT : {len(encoded):,} bytes")
63
+ print(f" Saved to : {DAT_PATH}")
64
  print()
65
+ print("๐Ÿ“ฆ For the closed edition:")
66
+ print(" - Include 'respuestas.dat' in your private repo")
67
+ print(" - Do NOT include 'respuestas.json'")
68
+ print(" - Set NEO_DATA_KEY as a secret environment variable")
69
+
70
+ def decompile_knowledge(output_path: str | None = None):
71
+ """Utility: converts respuestas.dat back to JSON (owner only)."""
72
+ if not os.path.exists(DAT_PATH):
73
+ print(f"โŒ File not found: {DAT_PATH}")
74
  sys.exit(1)
75
 
76
+ with open(DAT_PATH, "rb") as f:
77
  encoded = f.read()
78
 
79
+ encrypted = base64.b64decode(encoded)
80
+ compressed = _xor(encrypted, _get_key())
81
+ raw = zlib.decompress(compressed)
82
+ content = raw.decode("utf-8")
83
 
84
+ destination = output_path or JSON_PATH.replace(".json", "_decompiled.json")
85
+ with open(destination, "w", encoding="utf-8") as f:
86
+ f.write(content)
87
 
88
+ print(f"โœ… Decompiled to: {destination}")
89
 
90
  if __name__ == "__main__":
91
+ if len(sys.argv) > 1 and sys.argv[1] == "--decompile":
92
+ decompile_knowledge(sys.argv[2] if len(sys.argv) > 2 else None)
93
  else:
94
+ compile_knowledge()
chat-app/logica.py CHANGED
@@ -5,166 +5,161 @@ import zlib
5
  import base64
6
  import random
7
  import unicodedata
8
- from roblox_api import buscar_jugador, buscar_juego, formatear_jugador, formatear_juego
9
  from matematicas import (
10
- es_solicitud_calculadora, es_operacion_matematica,
11
- resolver_operacion, formatear_resultado, extraer_nombre_usuario,
12
  )
13
- from buscador import buscar as buscar_web
14
- from resumidor import resumir
15
 
16
- # โ”€โ”€ BANCO DE VARIACIONES DE LENGUAJE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
17
 
18
  INTROS = [
19
  "",
20
- "ยกClaro! ",
21
- "ยกCon gusto! ",
22
- "ยกBuena pregunta! ",
23
- "Te cuento: ",
24
- "Por supuesto. ",
25
- "Aquรญ va: ",
26
- "Dรฉjame explicarte. ",
27
  ]
28
 
29
- CONECTORES = [
30
- "Ademรกs, ",
31
- "Tambiรฉn vale mencionar que ",
32
- "Por otro lado, ",
33
- "Cabe aรฑadir que ",
34
- "Algo importante: ",
35
- "No te olvides de que ",
36
  ]
37
 
38
- CIERRES = [
39
  "",
40
- "\n\nยฟTienes alguna otra pregunta? ๐Ÿ˜Š",
41
- "\n\nยฟQuieres que profundice en algo? ๐Ÿค”",
42
- "\n\nยฟNecesitas mรกs detalles sobre esto?",
43
- "\n\nยฟAlgo mรกs en lo que pueda ayudarte?",
44
- "\n\nยกEspero que te sea รบtil! ๐Ÿ˜„",
45
  ]
46
 
47
- # Palabras que indican que un bloque es intro/cierre y no debe moverse
48
- _MARCAS_FIJAS = (
49
- "aquรญ tienes", "aquรญ va", "te cuento", "soy ", "ยกhola", "claro ๐Ÿ˜€",
50
- "de acuerdo", "fue un placer", "con mucho gusto",
51
  )
52
 
53
- def _es_bloque_fijo(bloque):
54
- """Devuelve True si el bloque debe mantenerse en su posiciรณn original."""
55
- bl = bloque.lower()
56
- return any(marca in bl for marca in _MARCAS_FIJAS)
57
 
58
- def generar_variacion(respuesta):
59
  """
60
- Genera una versiรณn variada de la respuesta para que NEO-1 no suene
61
- repetitivo. Estrategia:
62
- 1. Divide la respuesta en pรกrrafos/bloques (separados por \\n\\n).
63
- 2. Mantiene el primer bloque fijo si parece una intro.
64
- 3. Mezcla aleatoriamente los bloques del medio.
65
- 4. Opcionalmente aรฑade un cierre diferente.
66
- 5. Con cierta probabilidad inserta un conector antes de un bloque.
67
  """
68
- bloques = [b.strip() for b in respuesta.split("\n\n") if b.strip()]
69
 
70
- if len(bloques) <= 2:
71
- intro = random.choice(INTROS[:4])
72
- cierre = random.choice(CIERRES)
73
- texto = intro + respuesta + cierre
74
- return texto.strip()
75
 
76
- # Separar intro, medio y cierre
77
- inicio = bloques[0]
78
- fin = bloques[-1]
79
- medio = bloques[1:-1]
80
 
81
- # Mezclar secciones del medio solo si hay mรกs de una y ninguna es fija
82
- shuffleable = [b for b in medio if not _es_bloque_fijo(b)]
83
- fijos = [b for b in medio if _es_bloque_fijo(b)]
84
 
85
  random.shuffle(shuffleable)
86
- medio_nuevo = fijos + shuffleable # los fijos siempre primero
87
-
88
- # Con 40% de probabilidad insertar un conector antes de un bloque
89
- if len(medio_nuevo) > 1 and random.random() < 0.40:
90
- idx = random.randint(1, len(medio_nuevo) - 1)
91
- conector = random.choice(CONECTORES)
92
- # Solo aรฑadir si el bloque no empieza ya con negrita/emoji
93
- if not medio_nuevo[idx].startswith(("**", "๐ŸŒž", "๐ŸŒ‘", "โ˜€๏ธ", "๐Ÿ’ง", "๐ŸŸข", "๐ŸŒฌ๏ธ")):
94
- medio_nuevo[idx] = conector + medio_nuevo[idx]
95
-
96
- # Cierre variable (50% de probabilidad de aรฑadir uno)
97
- cierre = random.choice(CIERRES) if random.random() < 0.50 else ""
98
-
99
- partes = [inicio] + medio_nuevo + [fin]
100
- return "\n\n".join(partes) + cierre
101
-
102
- GENEROS_ROBLOX = {
103
- "Town and City": "un juego de vida urbana y roleplay social",
104
- "Adventure": "un juego de aventura y exploraciรณn",
105
- "Role Playing": "un juego de rol donde puedes ser quien quieras",
106
- "Comedy": "un juego de comedia y entretenimiento",
107
- "Action": "un juego de acciรณn y combate",
108
- "Horror": "un juego de terror y suspenso",
109
- "Military": "un juego de temรกtica militar y estrategia",
110
- "Medieval": "un juego de temรกtica medieval con castillos y caballeros",
111
- "Naval": "un juego de aventuras navales y piratas",
112
- "Sci-Fi": "un juego de ciencia ficciรณn",
113
- "Sports": "un juego deportivo",
114
- "Fighting": "un juego de peleas y combate",
115
- "Western": "un juego de temรกtica del lejano oeste",
116
- "FPS": "un juego de disparos en primera persona",
117
- "Building": "un juego de construcciรณn y creatividad",
118
- "Simulator": "un simulador",
119
- "Tycoon": "un juego de gestiรณn y negocios (tycoon)",
120
- "Obby": "un juego de obstรกculos (obby)",
121
- "All Genres": "un juego variado",
122
  }
123
 
124
  def _xor(data: bytes, key: bytes) -> bytes:
125
  return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
126
 
127
- def _clave() -> bytes:
128
  key = os.environ.get("NEO_DATA_KEY", "neo1-mdfj-science-closed-2025")
129
  return key.encode("utf-8")
130
 
131
- def cargar_respuestas():
132
  """
133
- Carga el banco de conocimiento. Orden de prioridad:
134
- 1. respuestas.dat โ†’ versiรณn cerrada (cifrada + comprimida)
135
- 2. respuestas.json โ†’ versiรณn abierta (texto plano)
 
136
  """
137
- directorio = os.path.dirname(__file__)
138
- ruta_dat = os.path.join(directorio, "respuestas.dat")
139
- ruta_json = os.path.join(directorio, "respuestas.json")
140
 
141
- # โ”€โ”€ Versiรณn cerrada (.dat) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
142
- if os.path.exists(ruta_dat):
143
  try:
144
- with open(ruta_dat, "rb") as f:
145
  encoded = f.read()
146
- cifrado = base64.b64decode(encoded)
147
- comprimido = _xor(cifrado, _clave())
148
- raw = zlib.decompress(comprimido)
149
- datos = json.loads(raw.decode("utf-8"))
150
- return datos.get("respuestas", [])
151
  except Exception:
152
- pass # Si falla, intenta con el JSON
153
 
154
- # โ”€โ”€ Versiรณn abierta (.json) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
155
  try:
156
- with open(ruta_json, "r", encoding="utf-8") as f:
157
- datos = json.load(f)
158
- return datos.get("respuestas", [])
159
  except Exception:
160
  return []
161
 
162
- RESPUESTAS_PERSONALIZADAS = cargar_respuestas()
163
 
164
- # โ”€โ”€ TOKENIZADOR โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
165
 
166
- # Palabras vacรญas en espaรฑol โ€” no aportan significado semรกntico propio.
167
- # Filtrarlas evita falsos matches como "que es supernova" โ†’ licencias.
168
  STOPWORDS = {
169
  "que", "es", "el", "la", "los", "las", "un", "una", "unos", "unas",
170
  "de", "del", "al", "a", "en", "por", "para", "con", "sin", "sobre",
@@ -175,210 +170,229 @@ STOPWORDS = {
175
  "como", "cรณmo", "dรณnde", "quiรฉn", "quรฉ", "cuรกnto", "cuanto",
176
  "este", "esta", "estos", "estas", "ese", "esa", "esos", "esas",
177
  "me", "puedes", "puedo", "puede", "quiero", "quieres", "dame",
178
- "dime", "dime", "hay", "tiene", "tienen", "tengo",
 
 
 
 
 
 
 
 
179
  }
180
 
181
- def tokenizar(texto):
182
  """
183
- Convierte texto a lista de tokens normalizados:
184
- 1. Elimina tildes/acentos
185
- 2. Convierte a minรบsculas
186
- 3. Elimina puntuaciรณn
187
- 4. Divide en tokens y filtra stopwords
188
  """
189
- texto = unicodedata.normalize("NFD", texto)
190
- texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
191
- texto = texto.lower()
192
- texto = re.sub(r"[^\w\s]", " ", texto)
193
- return [t for t in texto.split() if t and t not in STOPWORDS]
194
 
195
- def similitud_tokens(tokens_entrada, tokens_patron):
196
  """
197
- Calcula similitud Jaccard sobre tokens significativos (sin stopwords).
198
- Requisito mรญnimo: al menos 1 token significativo en comรบn.
199
- Retorna 0.0 si no hay intersecciรณn de tokens significativos.
200
  """
201
- set_entrada = set(tokens_entrada)
202
- set_patron = set(tokens_patron)
203
 
204
- if not set_patron or not set_entrada:
205
  return 0.0
206
 
207
- interseccion = set_entrada & set_patron
208
 
209
- # Sin ningรบn token significativo en comรบn โ†’ score 0 (evita falsos matches)
210
- if not interseccion:
211
  return 0.0
212
 
213
- union = set_entrada | set_patron
214
- jaccard = len(interseccion) / len(union)
215
 
216
- # Bonus: si todos los tokens del patrรณn estรกn en la entrada
217
- if set_patron.issubset(set_entrada):
218
  jaccard = max(jaccard, 0.80)
219
 
220
  return jaccard
221
 
222
- def buscar_respuesta_personalizada(mensaje):
223
  """
224
- Busca la mejor respuesta usando similitud de tokens y devuelve
225
- una variaciรณn generada automรกticamente para que NEO-1 no repita
226
- siempre las mismas palabras.
227
- Umbral mรญnimo: 0.20 (al menos 20% de overlap Jaccard)
228
  """
229
- tokens_entrada = tokenizar(mensaje)
230
- mejor_respuesta = None
231
- mejor_score = 0.0
232
- UMBRAL = 0.20
233
-
234
- for entrada in RESPUESTAS_PERSONALIZADAS:
235
- for pregunta in entrada.get("preguntas", []):
236
- tokens_patron = tokenizar(pregunta)
237
- score = similitud_tokens(tokens_entrada, tokens_patron)
238
- if score > mejor_score:
239
- mejor_score = score
240
- mejor_respuesta = entrada.get("respuesta")
241
-
242
- if mejor_score >= UMBRAL and mejor_respuesta:
243
- return generar_variacion(mejor_respuesta)
244
  return None
245
 
246
- # โ”€โ”€ UTILIDADES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
247
 
248
- def extraer_texto_content(content):
249
  if not content:
250
  return ""
251
  if isinstance(content, str):
252
  return content
253
  if isinstance(content, list):
254
- partes = []
255
- for bloque in content:
256
- if isinstance(bloque, str):
257
- partes.append(bloque)
258
- elif isinstance(bloque, dict):
259
- partes.append(str(bloque.get("text") or bloque.get("value") or bloque.get("content") or ""))
260
- return " ".join(partes)
261
  return str(content)
262
 
263
- def modo_calculadora_activo(historial):
264
- if not historial:
265
  return False
266
- for msg in reversed(historial):
267
  if isinstance(msg, dict) and msg.get("role") == "assistant":
268
- texto = extraer_texto_content(msg.get("content")).lower()
269
- return "calculadora neo-1" in texto or "aquรญ tienes nuestra calculadora" in texto
270
  return False
271
 
272
- # โ”€โ”€ ROBLOX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
273
 
274
- def generar_explicacion_juego(datos):
275
- nombre = datos.get("nombre", "Este juego")
276
- tema = datos.get("tema", "")
277
- descripcion = datos.get("descripcion", "").strip()
278
- visitas = datos.get("visitas", 0)
279
- creador = datos.get("creador", "")
280
 
281
- tipo = GENEROS_ROBLOX.get(tema, f"un juego de {tema.lower()}" if tema else "un juego")
282
- partes = [f"**{nombre}** es {tipo} de Roblox creado por **{creador}**."]
283
 
284
- if descripcion and descripcion != "Sin descripciรณn.":
285
- desc_corta = descripcion[:200] + ("..." if len(descripcion) > 200 else "")
286
- partes.append(f"Segรบn su descripciรณn oficial: *\"{desc_corta}\"*")
287
 
288
  try:
289
- v = int(visitas)
290
  if v >= 1_000_000_000:
291
- partes.append(f"Es uno de los juegos mรกs visitados de Roblox con mรกs de {v // 1_000_000_000} mil millones de visitas.")
292
  elif v >= 1_000_000:
293
- partes.append(f"Cuenta con mรกs de {v // 1_000_000} millones de visitas en total.")
294
  elif v >= 1_000:
295
- partes.append(f"Cuenta con mรกs de {v // 1_000}K visitas en total.")
296
  except Exception:
297
  pass
298
 
299
- return " ".join(partes)
300
-
301
- PATRONES_JUGADOR = [
 
 
 
 
 
 
 
 
 
 
302
  r"buscar\s+jugador\s+(.+)",
303
  r"busca\s+jugador\s+(.+)",
304
  r"jugador\s+de\s+roblox\s+(.+)",
305
  r"usuario\s+de\s+roblox\s+(.+)",
306
  r"perfil\s+de\s+roblox\s+(.+)",
307
- r"buscar\s+usuario\s+(.+)",
308
- r"busca\s+usuario\s+(.+)",
309
  r"quien\s+es\s+(.+)\s+en\s+roblox",
310
- r"quiรฉn\s+es\s+(.+)\s+en\s+roblox",
311
- r"info\s+de\s+(.+)\s+roblox",
312
  ]
313
 
314
- PATRONES_JUEGO = [
 
 
 
 
 
 
 
 
315
  r"buscar\s+juego\s+(.+)",
316
  r"busca\s+juego\s+(.+)",
317
  r"juego\s+de\s+roblox\s+(.+)",
318
  r"buscar\s+(.+)\s+en\s+roblox",
319
- r"busca\s+(.+)\s+en\s+roblox",
320
  r"informaciรณn\s+del\s+juego\s+(.+)",
321
- r"informacion\s+del\s+juego\s+(.+)",
322
  ]
323
 
324
- def detectar_roblox(mensaje):
325
- texto = mensaje.lower().strip()
326
- for patron in PATRONES_JUGADOR:
327
- m = re.search(patron, texto)
328
  if m:
329
- return "jugador", m.group(1).strip()
330
- for patron in PATRONES_JUEGO:
331
- m = re.search(patron, texto)
332
  if m:
333
- return "juego", m.group(1).strip()
334
  return None, None
335
 
336
- # โ”€โ”€ RESPUESTA FINAL (no-streaming, usada por neo_rest.py) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
337
 
338
- def respuesta_final(mensaje, historial):
339
- texto = mensaje.strip().lower()
340
 
341
- if es_solicitud_calculadora(mensaje):
342
- nombre = extraer_nombre_usuario(historial)
343
  return (
344
- f"Claro ๐Ÿ˜€, {nombre} aquรญ tienes nuestra calculadora:\n\n"
345
- "๐Ÿงฎ **Calculadora Virtual NEO-1**\n"
346
- "Escribe cualquier operaciรณn matemรกtica y la resolverรฉ al instante.\n\n"
347
- "**Ejemplos:** `5 + 3`, `12 * 7`, `100 / 4`, `2 ** 8` (potencia)\n\n"
348
- "_Escribe tu operaciรณn o di 'salir de calculadora' para volver._"
349
  )
350
 
351
- if texto in ("salir de calculadora", "salir calculadora", "cerrar calculadora", "volver al chat"):
352
- return "De acuerdo, volviendo al chat normal. ยกPregรบntame lo que quieras! ๐Ÿ˜Š"
353
 
354
- if modo_calculadora_activo(historial) or es_operacion_matematica(mensaje):
355
- resultado = resolver_operacion(mensaje)
356
- if resultado is not None:
357
- return formatear_resultado(mensaje, resultado)
358
 
359
- tipo_roblox, nombre_roblox = detectar_roblox(mensaje)
360
 
361
- if tipo_roblox == "jugador":
362
- datos = buscar_jugador(nombre_roblox)
363
- return formatear_jugador(datos)
364
 
365
- if tipo_roblox == "juego":
366
- datos = buscar_juego(nombre_roblox)
367
- resultado = formatear_juego(datos)
368
- if datos and "error" not in datos:
369
- explicacion = generar_explicacion_juego(datos)
370
- resultado = resultado + "\n\n๐Ÿ’ก **ยฟDe quรฉ trata el juego?**\n" + explicacion
371
- return resultado
372
 
373
- respuesta = buscar_respuesta_personalizada(mensaje)
374
- if respuesta:
375
- return respuesta
376
 
377
- # โ”€โ”€ NEO-2: bรบsqueda en internet como respaldo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
378
- resultado_web = buscar_web(mensaje)
379
- if resultado_web.get("encontrado"):
380
- resumen = resumir(mensaje, resultado_web)
381
- if resumen:
382
- return resumen
383
 
384
- return "๐Ÿค– No encontrรฉ informaciรณn sobre eso ni en mi base de conocimiento ni en internet. Intenta reformular tu pregunta."
 
5
  import base64
6
  import random
7
  import unicodedata
8
+ from roblox_api import search_player, search_game, format_player, format_game
9
  from matematicas import (
10
+ is_calculator_request, is_math_operation,
11
+ solve_operation, format_result, extract_username,
12
  )
13
+ from buscador import search as web_search
14
+ from resumidor import summarize
15
 
16
+ # โ”€โ”€ LANGUAGE VARIATION BANK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
17
 
18
  INTROS = [
19
  "",
20
+ "Of course! ",
21
+ "Gladly! ",
22
+ "Great question! ",
23
+ "Here's what I know: ",
24
+ "Certainly. ",
25
+ "Here we go: ",
26
+ "Let me explain. ",
27
  ]
28
 
29
+ CONNECTORS = [
30
+ "Also, ",
31
+ "Worth mentioning that ",
32
+ "On the other hand, ",
33
+ "It's worth adding that ",
34
+ "Something important: ",
35
+ "Don't forget that ",
36
  ]
37
 
38
+ CLOSINGS = [
39
  "",
40
+ "\n\nDo you have any other questions? ๐Ÿ˜Š",
41
+ "\n\nWould you like me to go deeper on anything? ๐Ÿค”",
42
+ "\n\nNeed more details on this?",
43
+ "\n\nAnything else I can help you with?",
44
+ "\n\nHope that was helpful! ๐Ÿ˜„",
45
  ]
46
 
47
+ # Words that indicate a block is an intro/closing and should not be shuffled
48
+ _FIXED_MARKERS = (
49
+ "here's what", "here we go", "let me", "i'm ", "hello", "of course",
50
+ "alright", "my pleasure", "gladly", "certainly",
51
  )
52
 
53
+ def _is_fixed_block(block):
54
+ """Returns True if the block should stay in its original position."""
55
+ bl = block.lower()
56
+ return any(marker in bl for marker in _FIXED_MARKERS)
57
 
58
+ def generate_variation(response):
59
  """
60
+ Generates a varied version of the response so NEO-1 doesn't sound
61
+ repetitive. Strategy:
62
+ 1. Split the response into paragraphs/blocks (separated by \\n\\n).
63
+ 2. Keep the first block fixed if it looks like an intro.
64
+ 3. Randomly shuffle the middle blocks.
65
+ 4. Optionally append a different closing.
66
+ 5. With some probability, insert a connector before a block.
67
  """
68
+ blocks = [b.strip() for b in response.split("\n\n") if b.strip()]
69
 
70
+ if len(blocks) <= 2:
71
+ intro = random.choice(INTROS[:4])
72
+ closing = random.choice(CLOSINGS)
73
+ return (intro + response + closing).strip()
 
74
 
75
+ start = blocks[0]
76
+ end = blocks[-1]
77
+ middle = blocks[1:-1]
 
78
 
79
+ shuffleable = [b for b in middle if not _is_fixed_block(b)]
80
+ fixed = [b for b in middle if _is_fixed_block(b)]
 
81
 
82
  random.shuffle(shuffleable)
83
+ new_middle = fixed + shuffleable
84
+
85
+ if len(new_middle) > 1 and random.random() < 0.40:
86
+ idx = random.randint(1, len(new_middle) - 1)
87
+ connector = random.choice(CONNECTORS)
88
+ if not new_middle[idx].startswith(("**", "๐ŸŒž", "๐ŸŒ‘", "โ˜€๏ธ", "๐Ÿ’ง", "๐ŸŸข", "๐ŸŒฌ๏ธ")):
89
+ new_middle[idx] = connector + new_middle[idx]
90
+
91
+ closing = random.choice(CLOSINGS) if random.random() < 0.50 else ""
92
+
93
+ parts = [start] + new_middle + [end]
94
+ return "\n\n".join(parts) + closing
95
+
96
+ GAME_GENRES = {
97
+ "Town and City": "an urban life and social roleplay game",
98
+ "Adventure": "an adventure and exploration game",
99
+ "Role Playing": "a role-playing game where you can be anyone",
100
+ "Comedy": "a comedy and entertainment game",
101
+ "Action": "an action and combat game",
102
+ "Horror": "a horror and suspense game",
103
+ "Military": "a military-themed strategy game",
104
+ "Medieval": "a medieval-themed game with castles and knights",
105
+ "Naval": "a naval adventure and pirates game",
106
+ "Sci-Fi": "a science fiction game",
107
+ "Sports": "a sports game",
108
+ "Fighting": "a fighting and combat game",
109
+ "Western": "a Wild West-themed game",
110
+ "FPS": "a first-person shooter",
111
+ "Building": "a building and creativity game",
112
+ "Simulator": "a simulator",
113
+ "Tycoon": "a management and business tycoon game",
114
+ "Obby": "an obstacle course game (obby)",
115
+ "All Genres": "a mixed-genre game",
 
 
 
116
  }
117
 
118
  def _xor(data: bytes, key: bytes) -> bytes:
119
  return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
120
 
121
+ def _get_key() -> bytes:
122
  key = os.environ.get("NEO_DATA_KEY", "neo1-mdfj-science-closed-2025")
123
  return key.encode("utf-8")
124
 
125
+ def load_responses():
126
  """
127
+ Loads the knowledge base. Priority order:
128
+ 1. responses.dat โ†’ closed edition (encrypted + compressed)
129
+ 2. responses.json โ†’ open edition (plain text)
130
+ Falls back silently if both fail.
131
  """
132
+ directory = os.path.dirname(__file__)
133
+ dat_path = os.path.join(directory, "respuestas.dat")
134
+ json_path = os.path.join(directory, "respuestas.json")
135
 
136
+ # โ”€โ”€ Closed edition (.dat) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
137
+ if os.path.exists(dat_path):
138
  try:
139
+ with open(dat_path, "rb") as f:
140
  encoded = f.read()
141
+ encrypted = base64.b64decode(encoded)
142
+ compressed = _xor(encrypted, _get_key())
143
+ raw = zlib.decompress(compressed)
144
+ data = json.loads(raw.decode("utf-8"))
145
+ return data.get("respuestas", [])
146
  except Exception:
147
+ pass
148
 
149
+ # โ”€โ”€ Open edition (.json) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
150
  try:
151
+ with open(json_path, "r", encoding="utf-8") as f:
152
+ data = json.load(f)
153
+ return data.get("respuestas", [])
154
  except Exception:
155
  return []
156
 
157
+ KNOWLEDGE_BASE = load_responses()
158
 
159
+ # โ”€โ”€ TOKENIZER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
160
 
161
+ # Spanish stopwords โ€” they carry no semantic meaning on their own.
162
+ # Filtering them prevents false matches like "what is a supernova" โ†’ licenses.
163
  STOPWORDS = {
164
  "que", "es", "el", "la", "los", "las", "un", "una", "unos", "unas",
165
  "de", "del", "al", "a", "en", "por", "para", "con", "sin", "sobre",
 
170
  "como", "cรณmo", "dรณnde", "quiรฉn", "quรฉ", "cuรกnto", "cuanto",
171
  "este", "esta", "estos", "estas", "ese", "esa", "esos", "esas",
172
  "me", "puedes", "puedo", "puede", "quiero", "quieres", "dame",
173
+ "dime", "hay", "tiene", "tienen", "tengo",
174
+ # English stopwords
175
+ "the", "is", "are", "was", "were", "be", "been", "being",
176
+ "a", "an", "and", "or", "but", "if", "in", "on", "at", "to",
177
+ "for", "of", "with", "by", "from", "as", "what", "who", "how",
178
+ "when", "where", "which", "that", "this", "do", "does", "did",
179
+ "can", "could", "will", "would", "should", "may", "might",
180
+ "i", "you", "he", "she", "it", "we", "they", "me", "him", "her",
181
+ "us", "them", "my", "your", "his", "its", "our", "their",
182
  }
183
 
184
+ def tokenize(text):
185
  """
186
+ Converts text to a list of normalized tokens:
187
+ 1. Removes accents/diacritics
188
+ 2. Lowercases
189
+ 3. Removes punctuation
190
+ 4. Splits into tokens and filters stopwords
191
  """
192
+ text = unicodedata.normalize("NFD", text)
193
+ text = "".join(c for c in text if unicodedata.category(c) != "Mn")
194
+ text = text.lower()
195
+ text = re.sub(r"[^\w\s]", " ", text)
196
+ return [t for t in text.split() if t and t not in STOPWORDS]
197
 
198
+ def token_similarity(input_tokens, pattern_tokens):
199
  """
200
+ Computes Jaccard similarity over significant tokens (without stopwords).
201
+ Minimum requirement: at least 1 significant token in common.
202
+ Returns 0.0 if there is no significant token overlap.
203
  """
204
+ set_input = set(input_tokens)
205
+ set_pattern = set(pattern_tokens)
206
 
207
+ if not set_pattern or not set_input:
208
  return 0.0
209
 
210
+ intersection = set_input & set_pattern
211
 
212
+ if not intersection:
 
213
  return 0.0
214
 
215
+ union = set_input | set_pattern
216
+ jaccard = len(intersection) / len(union)
217
 
218
+ # Bonus: if all pattern tokens are present in the input
219
+ if set_pattern.issubset(set_input):
220
  jaccard = max(jaccard, 0.80)
221
 
222
  return jaccard
223
 
224
+ def find_custom_response(message):
225
  """
226
+ Finds the best matching response using token similarity and returns
227
+ an automatically generated variation so NEO-1 doesn't repeat
228
+ the exact same words every time.
229
+ Minimum threshold: 0.20 (at least 20% Jaccard overlap)
230
  """
231
+ input_tokens = tokenize(message)
232
+ best_response = None
233
+ best_score = 0.0
234
+ THRESHOLD = 0.20
235
+
236
+ for entry in KNOWLEDGE_BASE:
237
+ for question in entry.get("preguntas", []):
238
+ pattern_tokens = tokenize(question)
239
+ score = token_similarity(input_tokens, pattern_tokens)
240
+ if score > best_score:
241
+ best_score = score
242
+ best_response = entry.get("respuesta")
243
+
244
+ if best_score >= THRESHOLD and best_response:
245
+ return generate_variation(best_response)
246
  return None
247
 
248
+ # โ”€โ”€ UTILITIES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
249
 
250
+ def extract_text_content(content):
251
  if not content:
252
  return ""
253
  if isinstance(content, str):
254
  return content
255
  if isinstance(content, list):
256
+ parts = []
257
+ for block in content:
258
+ if isinstance(block, str):
259
+ parts.append(block)
260
+ elif isinstance(block, dict):
261
+ parts.append(str(block.get("text") or block.get("value") or block.get("content") or ""))
262
+ return " ".join(parts)
263
  return str(content)
264
 
265
+ def calculator_mode_active(history):
266
+ if not history:
267
  return False
268
+ for msg in reversed(history):
269
  if isinstance(msg, dict) and msg.get("role") == "assistant":
270
+ text = extract_text_content(msg.get("content")).lower()
271
+ return "neo-1 virtual calculator" in text or "here's our calculator" in text
272
  return False
273
 
274
+ # โ”€โ”€ ROBLOX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
275
 
276
+ def generate_game_explanation(data):
277
+ name = data.get("name", "This game")
278
+ genre = data.get("genre", "")
279
+ description = data.get("description", "").strip()
280
+ visits = data.get("visits", 0)
281
+ creator = data.get("creator", "")
282
 
283
+ genre_desc = GAME_GENRES.get(genre, f"a {genre.lower()} game" if genre else "a game")
284
+ parts = [f"**{name}** is {genre_desc} on Roblox created by **{creator}**."]
285
 
286
+ if description and description != "No description.":
287
+ short_desc = description[:200] + ("..." if len(description) > 200 else "")
288
+ parts.append(f"According to its official description: *\"{short_desc}\"*")
289
 
290
  try:
291
+ v = int(visits)
292
  if v >= 1_000_000_000:
293
+ parts.append(f"It's one of the most visited games on Roblox with over {v // 1_000_000_000}B visits.")
294
  elif v >= 1_000_000:
295
+ parts.append(f"It has over {v // 1_000_000}M total visits.")
296
  elif v >= 1_000:
297
+ parts.append(f"It has over {v // 1_000}K total visits.")
298
  except Exception:
299
  pass
300
 
301
+ return " ".join(parts)
302
+
303
+ PLAYER_PATTERNS = [
304
+ r"search\s+player\s+(.+)",
305
+ r"find\s+player\s+(.+)",
306
+ r"roblox\s+player\s+(.+)",
307
+ r"roblox\s+user\s+(.+)",
308
+ r"roblox\s+profile\s+(.+)",
309
+ r"search\s+user\s+(.+)",
310
+ r"find\s+user\s+(.+)",
311
+ r"who\s+is\s+(.+)\s+on\s+roblox",
312
+ r"info\s+(?:on|about)\s+(.+)\s+roblox",
313
+ # Spanish patterns (kept for backward compatibility)
314
  r"buscar\s+jugador\s+(.+)",
315
  r"busca\s+jugador\s+(.+)",
316
  r"jugador\s+de\s+roblox\s+(.+)",
317
  r"usuario\s+de\s+roblox\s+(.+)",
318
  r"perfil\s+de\s+roblox\s+(.+)",
 
 
319
  r"quien\s+es\s+(.+)\s+en\s+roblox",
 
 
320
  ]
321
 
322
+ GAME_PATTERNS = [
323
+ r"search\s+game\s+(.+)",
324
+ r"find\s+game\s+(.+)",
325
+ r"roblox\s+game\s+(.+)",
326
+ r"search\s+(.+)\s+on\s+roblox",
327
+ r"find\s+(.+)\s+on\s+roblox",
328
+ r"game\s+info\s+(.+)",
329
+ r"game\s+information\s+(.+)",
330
+ # Spanish patterns (kept for backward compatibility)
331
  r"buscar\s+juego\s+(.+)",
332
  r"busca\s+juego\s+(.+)",
333
  r"juego\s+de\s+roblox\s+(.+)",
334
  r"buscar\s+(.+)\s+en\s+roblox",
 
335
  r"informaciรณn\s+del\s+juego\s+(.+)",
 
336
  ]
337
 
338
+ def detect_roblox(message):
339
+ text = message.lower().strip()
340
+ for pattern in PLAYER_PATTERNS:
341
+ m = re.search(pattern, text)
342
  if m:
343
+ return "player", m.group(1).strip()
344
+ for pattern in GAME_PATTERNS:
345
+ m = re.search(pattern, text)
346
  if m:
347
+ return "game", m.group(1).strip()
348
  return None, None
349
 
350
+ # โ”€โ”€ FINAL RESPONSE (non-streaming, used by neo_rest.py) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
351
 
352
+ def final_response(message, history):
353
+ text = message.strip().lower()
354
 
355
+ if is_calculator_request(message):
356
+ name = extract_username(history)
357
  return (
358
+ f"Sure! ๐Ÿ˜€ {name}, here's our calculator:\n\n"
359
+ "๐Ÿงฎ **NEO-1 Virtual Calculator**\n"
360
+ "Type any math operation and I'll solve it instantly.\n\n"
361
+ "**Examples:** `5 + 3`, `12 * 7`, `100 / 4`, `2 ** 8` (power)\n\n"
362
+ "_Type your operation or say 'exit calculator' to go back._"
363
  )
364
 
365
+ if text in ("exit calculator", "close calculator", "quit calculator", "back to chat"):
366
+ return "Alright, back to normal chat. Ask me anything! ๐Ÿ˜Š"
367
 
368
+ if calculator_mode_active(history) or is_math_operation(message):
369
+ result = solve_operation(message)
370
+ if result is not None:
371
+ return format_result(message, result)
372
 
373
+ roblox_type, roblox_name = detect_roblox(message)
374
 
375
+ if roblox_type == "player":
376
+ data = search_player(roblox_name)
377
+ return format_player(data)
378
 
379
+ if roblox_type == "game":
380
+ data = search_game(roblox_name)
381
+ result = format_game(data)
382
+ if data and "error" not in data:
383
+ explanation = generate_game_explanation(data)
384
+ result = result + "\n\n๐Ÿ’ก **What is this game about?**\n" + explanation
385
+ return result
386
 
387
+ response = find_custom_response(message)
388
+ if response:
389
+ return response
390
 
391
+ # โ”€โ”€ NEO-2: web search fallback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
392
+ web_result = web_search(message)
393
+ if web_result.get("found"):
394
+ summary = summarize(message, web_result)
395
+ if summary:
396
+ return summary
397
 
398
+ return "๐Ÿค– I couldn't find information on that topic in my knowledge base or on the web. Try rephrasing your question."
chat-app/matematicas.py CHANGED
@@ -1,7 +1,15 @@
1
  import re
2
  import math
3
 
4
- PATRONES_CALCULADORA = [
 
 
 
 
 
 
 
 
5
  r"calculadora virtual",
6
  r"calculadora",
7
  r"abrir calculadora",
@@ -11,28 +19,38 @@ PATRONES_CALCULADORA = [
11
  r"modo calculadora",
12
  ]
13
 
14
- def es_solicitud_calculadora(mensaje):
15
- texto = mensaje.lower().strip()
16
- for patron in PATRONES_CALCULADORA:
17
- if re.search(patron, texto):
18
  return True
19
  return False
20
 
21
- def es_operacion_matematica(mensaje):
22
- texto = mensaje.strip()
23
- texto = texto.replace("por", "*").replace("entre", "/")
24
- texto = texto.replace("mรกs", "+").replace("mas", "+")
25
- texto = texto.replace("menos", "-").replace("elevado a", "**")
26
- texto = texto.replace("al cuadrado", "**2").replace("al cubo", "**3")
27
- texto = re.sub(r'\bx\b', '*', texto)
28
- if re.search(r'\d', texto) and re.search(r'[\+\-\*\/\%\^]', texto):
 
 
 
 
 
29
  return True
30
- if re.match(r'^\s*[\d\s\+\-\*\/\.\(\)\%\^]+\s*$', texto) and re.search(r'\d', texto):
31
  return True
32
  return False
33
 
34
- def resolver_operacion(expresion):
35
- expr = expresion.strip()
 
 
 
 
 
36
  expr = expr.replace("por", "*").replace("entre", "/")
37
  expr = expr.replace("mรกs", "+").replace("mas", "+")
38
  expr = expr.replace("menos", "-").replace("elevado a", "**")
@@ -43,58 +61,62 @@ def resolver_operacion(expresion):
43
  if not expr or not re.search(r'\d', expr):
44
  return None
45
  try:
46
- espacio_seguro = {
47
  "__builtins__": {},
48
  "abs": abs, "round": round,
49
  "sqrt": math.sqrt, "pow": pow,
50
  "pi": math.pi, "e": math.e,
51
  }
52
- resultado = eval(expr, espacio_seguro)
53
- if isinstance(resultado, float) and resultado == int(resultado):
54
- return int(resultado)
55
- if isinstance(resultado, float):
56
- return round(resultado, 6)
57
- return resultado
58
  except Exception:
59
  return None
60
 
61
- def formatear_resultado(expresion_original, resultado):
62
- if resultado is None:
63
- return "โŒ No pude resolver esa operaciรณn. Asegรบrate de escribirla correctamente, por ejemplo: `5 + 3`, `12 * 4`, `100 / 5`."
64
- lineas = [
65
- "๐Ÿงฎ **Calculadora NEO-1**",
66
  "",
67
- f"๐Ÿ“ฅ **Operaciรณn:** `{expresion_original.strip()}`",
68
- f"๐Ÿ“ค **Resultado:** `{resultado}`",
69
  "",
70
- "_Puedes escribir otra operaciรณn o decir 'salir de calculadora' para volver al chat normal._"
71
  ]
72
- return "\n".join(lineas)
73
 
74
- def extraer_nombre_usuario(historial):
75
- patrones_nombre = [
 
 
 
 
 
76
  r"me llamo\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
77
  r"mi nombre es\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
78
  r"soy\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
79
  r"llรกmame\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
80
- r"llamame\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
81
  ]
82
- for msg in historial:
83
  if isinstance(msg, dict):
84
  if msg.get("role") != "user":
85
  continue
86
  content = msg.get("content") or ""
87
  if isinstance(content, list):
88
- partes = [b.get("text", "") if isinstance(b, dict) else str(b) for b in content]
89
- mensaje_usuario = " ".join(partes).lower()
90
  else:
91
- mensaje_usuario = str(content).lower()
92
  else:
93
- mensaje_usuario = (msg[0] or "").lower()
94
- for patron in patrones_nombre:
95
- m = re.search(patron, mensaje_usuario)
96
  if m:
97
- nombre = m.group(1).strip().capitalize()
98
- if nombre.lower() not in ("un", "una", "el", "la", "de", "que", "y", "a"):
99
- return nombre
100
- return "usuario"
 
1
  import re
2
  import math
3
 
4
+ CALCULATOR_PATTERNS = [
5
+ r"virtual calculator",
6
+ r"\bcalculator\b",
7
+ r"open calculator",
8
+ r"use calculator",
9
+ r"i want to calculate",
10
+ r"i need a calculator",
11
+ r"calculator mode",
12
+ # Spanish patterns (kept for backward compatibility)
13
  r"calculadora virtual",
14
  r"calculadora",
15
  r"abrir calculadora",
 
19
  r"modo calculadora",
20
  ]
21
 
22
+ def is_calculator_request(message):
23
+ text = message.lower().strip()
24
+ for pattern in CALCULATOR_PATTERNS:
25
+ if re.search(pattern, text):
26
  return True
27
  return False
28
 
29
+ def is_math_operation(message):
30
+ text = message.strip()
31
+ # English word replacements
32
+ text = text.replace("times", "*").replace("divided by", "/")
33
+ text = text.replace("plus", "+").replace("minus", "-")
34
+ text = text.replace("to the power of", "**").replace("squared", "**2").replace("cubed", "**3")
35
+ # Spanish word replacements (backward compatibility)
36
+ text = text.replace("por", "*").replace("entre", "/")
37
+ text = text.replace("mรกs", "+").replace("mas", "+")
38
+ text = text.replace("menos", "-").replace("elevado a", "**")
39
+ text = text.replace("al cuadrado", "**2").replace("al cubo", "**3")
40
+ text = re.sub(r'\bx\b', '*', text)
41
+ if re.search(r'\d', text) and re.search(r'[\+\-\*\/\%\^]', text):
42
  return True
43
+ if re.match(r'^\s*[\d\s\+\-\*\/\.\(\)\%\^]+\s*$', text) and re.search(r'\d', text):
44
  return True
45
  return False
46
 
47
+ def solve_operation(expression):
48
+ expr = expression.strip()
49
+ # English word replacements
50
+ expr = expr.replace("times", "*").replace("divided by", "/")
51
+ expr = expr.replace("plus", "+").replace("minus", "-")
52
+ expr = expr.replace("to the power of", "**").replace("squared", "**2").replace("cubed", "**3")
53
+ # Spanish word replacements (backward compatibility)
54
  expr = expr.replace("por", "*").replace("entre", "/")
55
  expr = expr.replace("mรกs", "+").replace("mas", "+")
56
  expr = expr.replace("menos", "-").replace("elevado a", "**")
 
61
  if not expr or not re.search(r'\d', expr):
62
  return None
63
  try:
64
+ safe_namespace = {
65
  "__builtins__": {},
66
  "abs": abs, "round": round,
67
  "sqrt": math.sqrt, "pow": pow,
68
  "pi": math.pi, "e": math.e,
69
  }
70
+ result = eval(expr, safe_namespace)
71
+ if isinstance(result, float) and result == int(result):
72
+ return int(result)
73
+ if isinstance(result, float):
74
+ return round(result, 6)
75
+ return result
76
  except Exception:
77
  return None
78
 
79
+ def format_result(original_expression, result):
80
+ if result is None:
81
+ return "โŒ I couldn't solve that operation. Make sure it's written correctly, e.g.: `5 + 3`, `12 * 4`, `100 / 5`."
82
+ lines = [
83
+ "๐Ÿงฎ **NEO-1 Calculator**",
84
  "",
85
+ f"๐Ÿ“ฅ **Operation:** `{original_expression.strip()}`",
86
+ f"๐Ÿ“ค **Result:** `{result}`",
87
  "",
88
+ "_You can type another operation or say 'exit calculator' to go back to normal chat._"
89
  ]
90
+ return "\n".join(lines)
91
 
92
+ def extract_username(history):
93
+ name_patterns = [
94
+ r"my name is\s+([A-Za-z]+)",
95
+ r"i am\s+([A-Za-z]+)",
96
+ r"call me\s+([A-Za-z]+)",
97
+ r"i'm\s+([A-Za-z]+)",
98
+ # Spanish patterns (backward compatibility)
99
  r"me llamo\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
100
  r"mi nombre es\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
101
  r"soy\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
102
  r"llรกmame\s+([A-Za-zรกรฉรญรณรบรร‰รร“รšรฑร‘]+)",
 
103
  ]
104
+ for msg in history:
105
  if isinstance(msg, dict):
106
  if msg.get("role") != "user":
107
  continue
108
  content = msg.get("content") or ""
109
  if isinstance(content, list):
110
+ parts = [b.get("text", "") if isinstance(b, dict) else str(b) for b in content]
111
+ user_message = " ".join(parts).lower()
112
  else:
113
+ user_message = str(content).lower()
114
  else:
115
+ user_message = (msg[0] or "").lower()
116
+ for pattern in name_patterns:
117
+ m = re.search(pattern, user_message)
118
  if m:
119
+ name = m.group(1).strip().capitalize()
120
+ if name.lower() not in ("a", "an", "the", "un", "una", "el", "la", "de", "que", "y"):
121
+ return name
122
+ return "user"
chat-app/neo_rest.py CHANGED
@@ -5,7 +5,7 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
5
  import sys
6
  import os
7
  sys.path.insert(0, os.path.dirname(__file__))
8
- from logica import respuesta_final
9
 
10
  NEO_REST_PORT = 5001
11
 
@@ -21,42 +21,42 @@ class NeoAPIHandler(BaseHTTPRequestHandler):
21
  def do_POST(self):
22
  if self.path == "/chat":
23
  try:
24
- length = int(self.headers.get("Content-Length", 0))
25
- raw = self.rfile.read(length)
26
- data = json.loads(raw)
27
 
28
- mensaje = str(data.get("message", "")).strip()
29
- historial = data.get("history", [])
30
 
31
- if not mensaje:
32
- self._send_json(400, {"error": "El campo 'message' es obligatorio"})
33
  return
34
 
35
- respuesta = respuesta_final(mensaje, historial)
36
  self._send_json(200, {
37
- "response": respuesta,
38
- "model": "mdfjbots-neo-1",
39
- "status": "ok",
40
  })
41
  except json.JSONDecodeError:
42
- self._send_json(400, {"error": "JSON invรกlido"})
43
  except Exception as e:
44
  self._send_json(500, {"error": str(e)})
45
  else:
46
- self._send_json(404, {"error": "Ruta no encontrada"})
47
 
48
  def do_GET(self):
49
  if self.path == "/health":
50
  self._send_json(200, {"status": "ok", "model": "mdfjbots-neo-1"})
51
  else:
52
- self._send_json(404, {"error": "Ruta no encontrada"})
53
 
54
  def log_message(self, format, *args):
55
  pass
56
 
57
- def iniciar_servidor():
58
  server = HTTPServer(("0.0.0.0", NEO_REST_PORT), NeoAPIHandler)
59
  thread = threading.Thread(target=server.serve_forever, daemon=True)
60
  thread.start()
61
- print(f"[NeoAPI REST] Corriendo en http://0.0.0.0:{NEO_REST_PORT}")
62
  return server
 
5
  import sys
6
  import os
7
  sys.path.insert(0, os.path.dirname(__file__))
8
+ from logica import final_response
9
 
10
  NEO_REST_PORT = 5001
11
 
 
21
  def do_POST(self):
22
  if self.path == "/chat":
23
  try:
24
+ length = int(self.headers.get("Content-Length", 0))
25
+ raw = self.rfile.read(length)
26
+ data = json.loads(raw)
27
 
28
+ message = str(data.get("message", "")).strip()
29
+ history = data.get("history", [])
30
 
31
+ if not message:
32
+ self._send_json(400, {"error": "The 'message' field is required."})
33
  return
34
 
35
+ response = final_response(message, history)
36
  self._send_json(200, {
37
+ "response": response,
38
+ "model": "mdfjbots-neo-1",
39
+ "status": "ok",
40
  })
41
  except json.JSONDecodeError:
42
+ self._send_json(400, {"error": "Invalid JSON."})
43
  except Exception as e:
44
  self._send_json(500, {"error": str(e)})
45
  else:
46
+ self._send_json(404, {"error": "Route not found."})
47
 
48
  def do_GET(self):
49
  if self.path == "/health":
50
  self._send_json(200, {"status": "ok", "model": "mdfjbots-neo-1"})
51
  else:
52
+ self._send_json(404, {"error": "Route not found."})
53
 
54
  def log_message(self, format, *args):
55
  pass
56
 
57
+ def start_server():
58
  server = HTTPServer(("0.0.0.0", NEO_REST_PORT), NeoAPIHandler)
59
  thread = threading.Thread(target=server.serve_forever, daemon=True)
60
  thread.start()
61
+ print(f"[NeoAPI REST] Running on http://0.0.0.0:{NEO_REST_PORT}")
62
  return server
chat-app/resumidor.py CHANGED
@@ -1,182 +1,171 @@
1
  """
2
- resumidor.py โ€” NEO-2 Motor de resumen abstractivo
3
- ---------------------------------------------------
4
- Toma datos crudos de la bรบsqueda web y construye una respuesta
5
- en prosa propia โ€” no copia los snippets, los reescribe.
6
-
7
- Estrategia (sin LLM):
8
- 1. El tรญtulo del resultado suele ser el mejor resumen en una lรญnea.
9
- 2. Extrae hechos clave de los snippets (fechas, lugares, nombres, roles).
10
- 3. Construye oraciones nuevas combinando esos hechos con plantillas.
11
- 4. Presenta todo como un pรกrrafo natural, no como lista de bullets copiados.
12
  """
13
 
14
  import re
15
  import random
16
  import unicodedata
17
 
18
- # โ”€โ”€ Encabezados y cierres variados โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
 
20
- _ENCABEZADOS = [
21
- "Encontrรฉ esto sobre **{tema}**:",
22
- "Aquรญ tienes lo que sรฉ sobre **{tema}**:",
23
- "Te cuento sobre **{tema}**:",
24
- "Esto es lo que encontrรฉ sobre **{tema}**:",
25
- "Basรกndome en informaciรณn actualizada de internet:",
26
  ]
27
 
28
- _CONECTORES = [
29
- "Ademรกs, ", "Tambiรฉn se sabe que ", "Por otro lado, ",
30
- "Cabe mencionar que ", "Adicionalmente, ", "Se destaca que ",
31
  ]
32
 
33
- _CIERRES = [
34
  "",
35
- "\n\nยฟQuieres que profundice en algรบn punto? ๐Ÿ˜Š",
36
- "\n\nยฟNecesitas mรกs detalles?",
37
- "\n\nยกEspero que te sea รบtil! ๐Ÿ˜„",
38
  "",
39
  ]
40
 
41
- # โ”€โ”€ Utilidades โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
 
43
- def _normalizar(texto: str) -> str:
44
- texto = unicodedata.normalize("NFD", texto)
45
- texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
46
- return texto.lower()
47
 
48
- def _limpiar_oracion(oracion: str) -> str:
49
- """Limpia marcadores temporales redundantes y normaliza espacios."""
50
- oracion = re.sub(r'\s+', ' ', oracion).strip()
51
- # Quitar frases que suenan a clickbait
52
  clickbait = [
53
- r'haz clic aquรญ.*', r'lee tambiรฉn.*', r'mรกs informaciรณn.*',
54
- r'sigue leyendo.*', r'descubre.*mรกs.*', r'entรฉrate.*',
55
  ]
56
- for patron in clickbait:
57
- oracion = re.sub(patron, '', oracion, flags=re.IGNORECASE)
58
- # Capitalizar
59
- if oracion:
60
- oracion = oracion[0].upper() + oracion[1:]
61
- if not oracion[-1] in '.!?':
62
- oracion += '.'
63
- return oracion.strip()
64
-
65
- def _dividir_oraciones(texto: str) -> list[str]:
66
- """Divide texto en oraciones individuales."""
67
- partes = re.split(r'(?<=[.!?])\s+', texto)
68
- return [p.strip() for p in partes if len(p.strip()) > 30]
69
-
70
- def _score(oracion: str, query_tokens: list[str]) -> float:
71
- norm = _normalizar(oracion)
72
  score = sum(1.5 for t in query_tokens if t in norm)
73
- largo = len(oracion)
74
- if 50 <= largo <= 200:
75
  score += 1.0
76
- elif largo < 50:
77
  score -= 0.5
78
  return score
79
 
80
- def _extraer_hechos(snippets: list[str], query_tokens: list[str], n: int = 3) -> list[str]:
81
  """
82
- Extrae las oraciones mรกs informativas de los snippets,
83
- puntuadas por relevancia al query.
84
  """
85
- todas = []
86
  for s in snippets:
87
- todas.extend(_dividir_oraciones(s))
88
 
89
- if not todas:
90
  return []
91
 
92
- puntuadas = sorted(todas, key=lambda o: -_score(o, query_tokens))
93
-
94
- # Deduplicar por similitud superficial
95
- seleccionadas = []
96
- vistas = set()
97
- for o in puntuadas:
98
- clave = _normalizar(o)[:50]
99
- if clave not in vistas:
100
- vistas.add(clave)
101
- seleccionadas.append(o)
102
- if len(seleccionadas) >= n:
103
  break
104
 
105
- return seleccionadas
106
 
107
- def _construir_parrafo(titulo_resultado: str, hechos: list[str]) -> str:
108
  """
109
- Construye un pรกrrafo en prosa a partir del tรญtulo y los hechos extraรญdos.
110
- El tรญtulo actรบa como oraciรณn principal (lead sentence).
111
- Los hechos se integran con conectores naturales.
112
  """
113
- partes = []
114
 
115
- # Oraciรณn principal desde el tรญtulo del resultado
116
- lead = _limpiar_oracion(titulo_resultado)
117
  if lead:
118
- partes.append(lead)
119
 
120
- # Hechos adicionales con conectores variados
121
- conectores_usados = random.sample(_CONECTORES, min(len(hechos), len(_CONECTORES)))
122
- for i, hecho in enumerate(hechos):
123
- hecho_limpio = _limpiar_oracion(hecho)
124
- if not hecho_limpio or hecho_limpio.lower()[:40] in lead.lower():
125
- continue # Evitar repetir lo que ya dijo el lead
126
- conector = conectores_usados[i] if i < len(conectores_usados) else ""
127
- partes.append(conector + hecho_limpio)
128
 
129
- return " ".join(partes)
130
 
131
- # โ”€โ”€ Funciรณn principal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
 
133
- def resumir(query: str, resultado: dict, max_hechos: int = 3) -> str | None:
134
  """
135
- Genera una respuesta propia a partir de los resultados web.
136
- No copia snippets โ€” construye prosa nueva combinando hechos clave.
137
  """
138
- titulo = resultado.get("titulo", "").strip()
139
- abstract = resultado.get("abstract", "").strip()
140
- snippets = resultado.get("snippets", [])
141
- url = resultado.get("url", "").strip()
142
 
143
- if not titulo and not abstract and not snippets:
144
  return None
145
 
146
- query_tokens = _normalizar(query).split()
147
 
148
- # Recopilar fuentes de hechos (abstract + snippets)
149
- todas_fuentes = []
150
  if abstract:
151
- todas_fuentes.append(abstract)
152
- todas_fuentes.extend(snippets)
153
-
154
- hechos = _extraer_hechos(todas_fuentes, query_tokens, n=max_hechos)
155
 
156
- # Construir pรกrrafo en prosa
157
- parrafo = _construir_parrafo(titulo, hechos)
158
 
159
- if not parrafo.strip():
160
  return None
161
 
162
- # Encabezado dinรกmico basado en el tema principal
163
- tema = titulo.split(":")[0].split("โ€”")[0].strip() if titulo else query
164
- encabezado = random.choice(_ENCABEZADOS).format(tema=tema)
165
-
166
- cierre = random.choice(_CIERRES)
167
 
168
- # Mostrar fuente y licencia si estรกn disponibles
169
- licencia = resultado.get("licencia", "")
170
- origen = resultado.get("origen", "desconocido")
171
 
172
  if url:
173
- if licencia and licencia != "Desconocida":
174
- pie = f"\n\n๐Ÿ”— Fuente: {url}\n๐Ÿ“„ Licencia: {licencia}"
175
- elif origen == "desconocido":
176
- pie = f"\n\n๐Ÿ”— Fuente: {url}\nโš ๏ธ _Licencia desconocida โ€” contenido usado solo como referencia._"
177
  else:
178
- pie = f"\n\n๐Ÿ”— Fuente: {url}"
179
  else:
180
- pie = ""
181
 
182
- return f"{encabezado}\n\n{parrafo}{pie}{cierre}".strip()
 
1
  """
2
+ resumidor.py โ€” NEO-2 Abstractive summarization engine
3
+ ------------------------------------------------------
4
+ Takes raw web search data and builds a prose response
5
+ using its own words โ€” it does not copy snippets verbatim.
6
+
7
+ Strategy (no LLM required):
8
+ 1. The result title is usually the best single-line summary.
9
+ 2. Extract key facts from snippets (dates, places, names, roles).
10
+ 3. Build new sentences combining those facts with templates.
11
+ 4. Present everything as a natural paragraph, not a copied bullet list.
12
  """
13
 
14
  import re
15
  import random
16
  import unicodedata
17
 
18
+ # โ”€โ”€ Response headers and closings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
 
20
+ _HEADERS = [
21
+ "Here's what I found about **{topic}**:",
22
+ "Here's what I know about **{topic}**:",
23
+ "Let me tell you about **{topic}**:",
24
+ "This is what I found about **{topic}**:",
25
+ "Based on up-to-date information from the web:",
26
  ]
27
 
28
+ _CONNECTORS = [
29
+ "Also, ", "It's also known that ", "On the other hand, ",
30
+ "Worth mentioning that ", "Additionally, ", "Notably, ",
31
  ]
32
 
33
+ _CLOSINGS = [
34
  "",
35
+ "\n\nWould you like me to go deeper on any point? ๐Ÿ˜Š",
36
+ "\n\nNeed more details?",
37
+ "\n\nHope that was helpful! ๐Ÿ˜„",
38
  "",
39
  ]
40
 
41
+ # โ”€โ”€ Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
 
43
+ def _normalize(text: str) -> str:
44
+ text = unicodedata.normalize("NFD", text)
45
+ text = "".join(c for c in text if unicodedata.category(c) != "Mn")
46
+ return text.lower()
47
 
48
+ def _clean_sentence(sentence: str) -> str:
49
+ """Cleans redundant markers and normalizes whitespace."""
50
+ sentence = re.sub(r'\s+', ' ', sentence).strip()
 
51
  clickbait = [
52
+ r'click here.*', r'read more.*', r'more information.*',
53
+ r'keep reading.*', r'discover more.*', r'find out.*',
54
  ]
55
+ for pattern in clickbait:
56
+ sentence = re.sub(pattern, '', sentence, flags=re.IGNORECASE)
57
+ if sentence:
58
+ sentence = sentence[0].upper() + sentence[1:]
59
+ if sentence[-1] not in '.!?':
60
+ sentence += '.'
61
+ return sentence.strip()
62
+
63
+ def _split_sentences(text: str) -> list[str]:
64
+ """Splits text into individual sentences."""
65
+ parts = re.split(r'(?<=[.!?])\s+', text)
66
+ return [p.strip() for p in parts if len(p.strip()) > 30]
67
+
68
+ def _score(sentence: str, query_tokens: list[str]) -> float:
69
+ norm = _normalize(sentence)
 
70
  score = sum(1.5 for t in query_tokens if t in norm)
71
+ length = len(sentence)
72
+ if 50 <= length <= 200:
73
  score += 1.0
74
+ elif length < 50:
75
  score -= 0.5
76
  return score
77
 
78
+ def _extract_facts(snippets: list[str], query_tokens: list[str], n: int = 3) -> list[str]:
79
  """
80
+ Extracts the most informative sentences from snippets,
81
+ scored by relevance to the query.
82
  """
83
+ all_sentences = []
84
  for s in snippets:
85
+ all_sentences.extend(_split_sentences(s))
86
 
87
+ if not all_sentences:
88
  return []
89
 
90
+ scored = sorted(all_sentences, key=lambda s: -_score(s, query_tokens))
91
+
92
+ selected = []
93
+ seen = set()
94
+ for sentence in scored:
95
+ key = _normalize(sentence)[:50]
96
+ if key not in seen:
97
+ seen.add(key)
98
+ selected.append(sentence)
99
+ if len(selected) >= n:
 
100
  break
101
 
102
+ return selected
103
 
104
+ def _build_paragraph(result_title: str, facts: list[str]) -> str:
105
  """
106
+ Builds a prose paragraph from the title and extracted facts.
107
+ The title acts as the lead sentence.
108
+ Facts are integrated with natural connectors.
109
  """
110
+ parts = []
111
 
112
+ lead = _clean_sentence(result_title)
 
113
  if lead:
114
+ parts.append(lead)
115
 
116
+ used_connectors = random.sample(_CONNECTORS, min(len(facts), len(_CONNECTORS)))
117
+ for i, fact in enumerate(facts):
118
+ clean_fact = _clean_sentence(fact)
119
+ if not clean_fact or clean_fact.lower()[:40] in lead.lower():
120
+ continue
121
+ connector = used_connectors[i] if i < len(used_connectors) else ""
122
+ parts.append(connector + clean_fact)
 
123
 
124
+ return " ".join(parts)
125
 
126
+ # โ”€โ”€ Main function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
127
 
128
+ def summarize(query: str, result: dict, max_facts: int = 3) -> str | None:
129
  """
130
+ Generates an original response from web search results.
131
+ Does not copy snippets โ€” builds new prose from key facts.
132
  """
133
+ title = result.get("title", "").strip()
134
+ abstract = result.get("abstract", "").strip()
135
+ snippets = result.get("snippets", [])
136
+ url = result.get("url", "").strip()
137
 
138
+ if not title and not abstract and not snippets:
139
  return None
140
 
141
+ query_tokens = _normalize(query).split()
142
 
143
+ all_sources = []
 
144
  if abstract:
145
+ all_sources.append(abstract)
146
+ all_sources.extend(snippets)
 
 
147
 
148
+ facts = _extract_facts(all_sources, query_tokens, n=max_facts)
149
+ paragraph = _build_paragraph(title, facts)
150
 
151
+ if not paragraph.strip():
152
  return None
153
 
154
+ topic = title.split(":")[0].split("โ€”")[0].strip() if title else query
155
+ header = random.choice(_HEADERS).format(topic=topic)
156
+ closing = random.choice(_CLOSINGS)
 
 
157
 
158
+ license_ = result.get("license", "")
159
+ origin = result.get("origin", "unknown")
 
160
 
161
  if url:
162
+ if license_ and license_ != "Unknown":
163
+ footer = f"\n\n๐Ÿ”— Source: {url}\n๐Ÿ“„ License: {license_}"
164
+ elif origin == "unknown":
165
+ footer = f"\n\n๐Ÿ”— Source: {url}\nโš ๏ธ _Unknown license โ€” content used for reference only._"
166
  else:
167
+ footer = f"\n\n๐Ÿ”— Source: {url}"
168
  else:
169
+ footer = ""
170
 
171
+ return f"{header}\n\n{paragraph}{footer}{closing}".strip()
chat-app/roblox_api.py CHANGED
@@ -5,175 +5,191 @@ HEADERS = {
5
  "Accept": "application/json",
6
  }
7
 
8
- def buscar_jugador(nombre):
9
  try:
10
- url = "https://users.roblox.com/v1/usernames/users"
11
- resp = requests.post(url, json={"usernames": [nombre], "excludeBannedUsers": False}, headers=HEADERS, timeout=8)
12
- datos = resp.json()
13
- usuarios = datos.get("data", [])
14
- if not usuarios:
 
 
 
 
 
15
  return None
16
- usuario = usuarios[0]
17
- user_id = usuario["id"]
18
- perfil = obtener_perfil(user_id)
19
- avatar_url = obtener_avatar(user_id)
20
- estadisticas = obtener_estadisticas(user_id)
21
  return {
22
- "id": user_id,
23
- "nombre": usuario.get("name"),
24
- "nombre_display": usuario.get("displayName"),
25
- "descripcion": perfil.get("description", ""),
26
- "creado": perfil.get("created", "Desconocido"),
27
- "is_banned": perfil.get("isBanned", False),
28
- "avatar": avatar_url,
29
- "amigos": estadisticas.get("amigos", "N/A"),
30
- "seguidores": estadisticas.get("seguidores", "N/A"),
31
- "siguiendo": estadisticas.get("siguiendo", "N/A"),
32
  }
33
  except Exception as e:
34
  return {"error": str(e)}
35
 
36
- def obtener_perfil(user_id):
37
  try:
38
- url = f"https://users.roblox.com/v1/users/{user_id}"
39
  resp = requests.get(url, headers=HEADERS, timeout=8)
40
  return resp.json()
41
  except Exception:
42
  return {}
43
 
44
- def obtener_avatar(user_id):
45
  try:
46
- url = f"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={user_id}&size=420x420&format=Png&isCircular=false"
 
 
 
47
  resp = requests.get(url, headers=HEADERS, timeout=8)
48
- datos = resp.json()
49
- return datos.get("data", [{}])[0].get("imageUrl", None)
50
  except Exception:
51
  return None
52
 
53
- def obtener_estadisticas(user_id):
54
- resultado = {}
55
  try:
56
- r = requests.get(f"https://friends.roblox.com/v1/users/{user_id}/friends/count", headers=HEADERS, timeout=8)
57
- resultado["amigos"] = r.json().get("count", "N/A")
 
 
 
58
  except Exception:
59
- resultado["amigos"] = "N/A"
60
  try:
61
- r = requests.get(f"https://friends.roblox.com/v1/users/{user_id}/followers/count", headers=HEADERS, timeout=8)
62
- resultado["seguidores"] = r.json().get("count", "N/A")
 
 
 
63
  except Exception:
64
- resultado["seguidores"] = "N/A"
65
  try:
66
- r = requests.get(f"https://friends.roblox.com/v1/users/{user_id}/followings/count", headers=HEADERS, timeout=8)
67
- resultado["siguiendo"] = r.json().get("count", "N/A")
 
 
 
68
  except Exception:
69
- resultado["siguiendo"] = "N/A"
70
- return resultado
71
 
72
- def buscar_juego(nombre):
73
  try:
74
- url = f"https://games.roblox.com/v1/games/list?keyword={nombre}&maxRows=5&startRows=0"
75
  resp = requests.get(url, headers=HEADERS, timeout=8)
76
- datos = resp.json()
77
- juegos = datos.get("games", [])
78
- if not juegos:
79
  return None
80
- universe_id = juegos[0].get("universeId")
81
  if not universe_id:
82
  return None
83
- detalle = obtener_detalle_juego(universe_id)
84
- return detalle
85
  except Exception as e:
86
  return {"error": str(e)}
87
 
88
- def obtener_detalle_juego(universe_id):
89
  try:
90
- url = f"https://games.roblox.com/v1/games?universeIds={universe_id}"
91
  resp = requests.get(url, headers=HEADERS, timeout=8)
92
- datos = resp.json()
93
- juegos = datos.get("data", [])
94
- if not juegos:
95
  return None
96
- j = juegos[0]
97
- actualizado = j.get("updated", "Desconocido")
98
- if actualizado and actualizado != "Desconocido":
99
- actualizado = actualizado[:10]
100
  return {
101
- "nombre": j.get("name"),
102
- "tema": j.get("genre", "Sin categorรญa"),
103
- "visitas": j.get("visits", 0),
104
- "ultima_update": actualizado,
105
- "descripcion": j.get("description") or "Sin descripciรณn.",
106
- "creador": j.get("creator", {}).get("name", "Desconocido"),
107
- "jugando": j.get("playing", 0),
108
- "id": universe_id,
109
  }
110
  except Exception as e:
111
  return {"error": str(e)}
112
 
113
- def formatear_jugador(datos):
114
- if not datos:
115
- return "No encontrรฉ ese jugador en Roblox. Verifica que el nombre de usuario sea correcto."
116
- if "error" in datos:
117
- return f"Hubo un error al buscar el jugador: {datos['error']}"
118
-
119
- descripcion = datos.get("descripcion") or "Sin descripciรณn."
120
- fecha = datos.get("creado", "Desconocido")
121
- if fecha != "Desconocido":
122
- fecha = fecha[:10]
123
- baneado = "Sรญ โš ๏ธ" if datos.get("is_banned") else "No โœ…"
124
- amigos = datos.get("amigos", "N/A")
125
- seguidores = datos.get("seguidores", "N/A")
126
- siguiendo = datos.get("siguiendo", "N/A")
127
-
128
- lineas = [
129
- "Aquรญ tienes datos pรบblicos del jugador en Roblox ๐Ÿ˜Š",
130
  "",
131
- f"๐Ÿ‘ค **Nombre de usuario:** {datos['nombre']}",
132
- f"โœ๏ธ **Su nombre completo:** {datos['nombre_display']}",
133
- f"๐Ÿ†” **ID:** {datos['id']}",
134
- f"๐Ÿ“ **Historia (descripciรณn):** {descripcion}",
135
- f"๐Ÿ“… **Nacimiento de la cuenta de Roblox:** {fecha}",
136
- f"๐Ÿšซ **ยฟFue baneado?:** {baneado}",
137
  "",
138
- "๐Ÿ“Š **Estadรญsticas:**",
139
- f"๐Ÿ‘ซ **Sus amigos:** {amigos}",
140
- f"๐Ÿ‘ฅ **Sus seguidores:** {seguidores}",
141
- f"โžก๏ธ **A quien sigue:** {siguiendo}",
142
  ]
143
 
144
- if datos.get("avatar"):
145
- lineas.append(f"\n๐Ÿ–ผ๏ธ **Avatar:** {datos['avatar']}")
146
 
147
- return "\n".join(lineas)
148
 
149
- def formatear_juego(datos):
150
- if not datos:
151
- return "No encontrรฉ ese juego en Roblox. Intenta con otro nombre."
152
- if "error" in datos:
153
- return f"Hubo un error al buscar el juego: {datos['error']}"
154
 
155
- visitas = datos.get("visitas", 0)
156
  try:
157
- visitas = f"{int(visitas):,}"
158
  except Exception:
159
- visitas = str(visitas)
160
 
161
- jugando = datos.get("jugando", 0)
162
  try:
163
- jugando = f"{int(jugando):,}"
164
  except Exception:
165
- jugando = str(jugando)
166
 
167
- lineas = [
168
- "Datos del juego de Roblox ๐Ÿ˜Ž",
169
  "",
170
- f"๐ŸŽฎ **Nombre:** {datos.get('nombre', 'Desconocido')}",
171
- f"๐ŸŽญ **Tema:** {datos.get('tema', 'Sin categorรญa')}",
172
- f"๐Ÿ‘๏ธ **Vistas:** {visitas}",
173
- f"๐Ÿ”„ **รšltima update:** {datos.get('ultima_update', 'Desconocido')}",
174
- f"๐Ÿ“ **Descripciรณn del juego:** {datos.get('descripcion', 'Sin descripciรณn.')}",
175
  "",
176
- f"๐Ÿ‘ค **Creador:** {datos.get('creador', 'Desconocido')}",
177
- f"๐Ÿ‘ฅ **Jugando ahora:** {jugando}",
178
  ]
179
- return "\n".join(lineas)
 
5
  "Accept": "application/json",
6
  }
7
 
8
+ def search_player(name):
9
  try:
10
+ url = "https://users.roblox.com/v1/usernames/users"
11
+ resp = requests.post(
12
+ url,
13
+ json={"usernames": [name], "excludeBannedUsers": False},
14
+ headers=HEADERS,
15
+ timeout=8,
16
+ )
17
+ data = resp.json()
18
+ users = data.get("data", [])
19
+ if not users:
20
  return None
21
+ user = users[0]
22
+ user_id = user["id"]
23
+ profile = get_profile(user_id)
24
+ avatar = get_avatar(user_id)
25
+ stats = get_stats(user_id)
26
  return {
27
+ "id": user_id,
28
+ "name": user.get("name"),
29
+ "display_name": user.get("displayName"),
30
+ "description": profile.get("description", ""),
31
+ "created": profile.get("created", "Unknown"),
32
+ "is_banned": profile.get("isBanned", False),
33
+ "avatar": avatar,
34
+ "friends": stats.get("friends", "N/A"),
35
+ "followers": stats.get("followers", "N/A"),
36
+ "following": stats.get("following", "N/A"),
37
  }
38
  except Exception as e:
39
  return {"error": str(e)}
40
 
41
+ def get_profile(user_id):
42
  try:
43
+ url = f"https://users.roblox.com/v1/users/{user_id}"
44
  resp = requests.get(url, headers=HEADERS, timeout=8)
45
  return resp.json()
46
  except Exception:
47
  return {}
48
 
49
+ def get_avatar(user_id):
50
  try:
51
+ url = (
52
+ f"https://thumbnails.roblox.com/v1/users/avatar-headshot"
53
+ f"?userIds={user_id}&size=420x420&format=Png&isCircular=false"
54
+ )
55
  resp = requests.get(url, headers=HEADERS, timeout=8)
56
+ data = resp.json()
57
+ return data.get("data", [{}])[0].get("imageUrl", None)
58
  except Exception:
59
  return None
60
 
61
+ def get_stats(user_id):
62
+ result = {}
63
  try:
64
+ r = requests.get(
65
+ f"https://friends.roblox.com/v1/users/{user_id}/friends/count",
66
+ headers=HEADERS, timeout=8,
67
+ )
68
+ result["friends"] = r.json().get("count", "N/A")
69
  except Exception:
70
+ result["friends"] = "N/A"
71
  try:
72
+ r = requests.get(
73
+ f"https://friends.roblox.com/v1/users/{user_id}/followers/count",
74
+ headers=HEADERS, timeout=8,
75
+ )
76
+ result["followers"] = r.json().get("count", "N/A")
77
  except Exception:
78
+ result["followers"] = "N/A"
79
  try:
80
+ r = requests.get(
81
+ f"https://friends.roblox.com/v1/users/{user_id}/followings/count",
82
+ headers=HEADERS, timeout=8,
83
+ )
84
+ result["following"] = r.json().get("count", "N/A")
85
  except Exception:
86
+ result["following"] = "N/A"
87
+ return result
88
 
89
+ def search_game(name):
90
  try:
91
+ url = f"https://games.roblox.com/v1/games/list?keyword={name}&maxRows=5&startRows=0"
92
  resp = requests.get(url, headers=HEADERS, timeout=8)
93
+ data = resp.json()
94
+ games = data.get("games", [])
95
+ if not games:
96
  return None
97
+ universe_id = games[0].get("universeId")
98
  if not universe_id:
99
  return None
100
+ return get_game_details(universe_id)
 
101
  except Exception as e:
102
  return {"error": str(e)}
103
 
104
+ def get_game_details(universe_id):
105
  try:
106
+ url = f"https://games.roblox.com/v1/games?universeIds={universe_id}"
107
  resp = requests.get(url, headers=HEADERS, timeout=8)
108
+ data = resp.json()
109
+ games = data.get("data", [])
110
+ if not games:
111
  return None
112
+ g = games[0]
113
+ last_update = g.get("updated", "Unknown")
114
+ if last_update and last_update != "Unknown":
115
+ last_update = last_update[:10]
116
  return {
117
+ "name": g.get("name"),
118
+ "genre": g.get("genre", "Uncategorized"),
119
+ "visits": g.get("visits", 0),
120
+ "last_update": last_update,
121
+ "description": g.get("description") or "No description.",
122
+ "creator": g.get("creator", {}).get("name", "Unknown"),
123
+ "playing": g.get("playing", 0),
124
+ "id": universe_id,
125
  }
126
  except Exception as e:
127
  return {"error": str(e)}
128
 
129
+ def format_player(data):
130
+ if not data:
131
+ return "I couldn't find that player on Roblox. Please check that the username is correct."
132
+ if "error" in data:
133
+ return f"An error occurred while searching for the player: {data['error']}"
134
+
135
+ description = data.get("description") or "No description."
136
+ created = data.get("created", "Unknown")
137
+ if created != "Unknown":
138
+ created = created[:10]
139
+ banned = "Yes โš ๏ธ" if data.get("is_banned") else "No โœ…"
140
+ friends = data.get("friends", "N/A")
141
+ followers = data.get("followers", "N/A")
142
+ following = data.get("following", "N/A")
143
+
144
+ lines = [
145
+ "Here's the public data for this Roblox player ๐Ÿ˜Š",
146
  "",
147
+ f"๐Ÿ‘ค **Username:** {data['name']}",
148
+ f"โœ๏ธ **Display name:** {data['display_name']}",
149
+ f"๐Ÿ†” **ID:** {data['id']}",
150
+ f"๐Ÿ“ **Description:** {description}",
151
+ f"๐Ÿ“… **Account created:** {created}",
152
+ f"๐Ÿšซ **Banned?:** {banned}",
153
  "",
154
+ "๐Ÿ“Š **Stats:**",
155
+ f"๐Ÿ‘ซ **Friends:** {friends}",
156
+ f"๐Ÿ‘ฅ **Followers:** {followers}",
157
+ f"โžก๏ธ **Following:** {following}",
158
  ]
159
 
160
+ if data.get("avatar"):
161
+ lines.append(f"\n๐Ÿ–ผ๏ฟฝ๏ฟฝ **Avatar:** {data['avatar']}")
162
 
163
+ return "\n".join(lines)
164
 
165
+ def format_game(data):
166
+ if not data:
167
+ return "I couldn't find that game on Roblox. Try a different name."
168
+ if "error" in data:
169
+ return f"An error occurred while searching for the game: {data['error']}"
170
 
171
+ visits = data.get("visits", 0)
172
  try:
173
+ visits = f"{int(visits):,}"
174
  except Exception:
175
+ visits = str(visits)
176
 
177
+ playing = data.get("playing", 0)
178
  try:
179
+ playing = f"{int(playing):,}"
180
  except Exception:
181
+ playing = str(playing)
182
 
183
+ lines = [
184
+ "Roblox game data ๐Ÿ˜Ž",
185
  "",
186
+ f"๐ŸŽฎ **Name:** {data.get('name', 'Unknown')}",
187
+ f"๐ŸŽญ **Genre:** {data.get('genre', 'Uncategorized')}",
188
+ f"๐Ÿ‘๏ธ **Visits:** {visits}",
189
+ f"๐Ÿ”„ **Last update:** {data.get('last_update', 'Unknown')}",
190
+ f"๐Ÿ“ **Description:** {data.get('description', 'No description.')}",
191
  "",
192
+ f"๐Ÿ‘ค **Creator:** {data.get('creator', 'Unknown')}",
193
+ f"๐Ÿ‘ฅ **Currently playing:** {playing}",
194
  ]
195
+ return "\n".join(lines)