fredcaixeta commited on
Commit
ab421f9
·
1 Parent(s): 7b51852
Files changed (5) hide show
  1. Dockerfile +0 -9
  2. app.py +42 -470
  3. client_agent.py +55 -224
  4. mcp_server.py +66 -208
  5. requirements.txt +3 -1
Dockerfile DELETED
@@ -1,9 +0,0 @@
1
- FROM python:3.11-slim
2
-
3
- WORKDIR /app
4
- COPY . /app
5
-
6
- RUN pip install --no-cache-dir -r requirements.txt
7
-
8
- EXPOSE 7860
9
- CMD ["python", "app.py"]
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,502 +1,74 @@
 
1
  import gradio as gr
2
- import uuid
3
- import subprocess
4
- import time
5
  import asyncio
6
  import re
7
 
8
- USER_ID = str(uuid.uuid4())
9
-
10
- # Iniciar o servidor MCP em background
11
- mcp_process = subprocess.Popen(
12
- ["python", "mcp_server.py"],
13
- stdout=subprocess.PIPE,
14
- stderr=subprocess.PIPE
15
- )
16
-
17
- time.sleep(3)
18
-
19
  from client_agent import stream_agent_response_safe, get_current_chart, PLACEHOLDER_IMAGE
20
 
21
- # CSS Profissional
22
- CUSTOM_CSS = """
23
- /* Importar fontes profissionais do Google Fonts */
24
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
25
-
26
- /* Aplicar fonte Inter em todo o app */
27
- * {
28
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
29
- }
30
-
31
- /* Fonte monospace para código */
32
- code, pre, .message code {
33
- font-family: 'JetBrains Mono', 'Courier New', monospace !important;
34
- }
35
-
36
- /* Header principal */
37
- .gradio-container h1 {
38
- font-size: 2.5rem !important;
39
- font-weight: 700 !important;
40
- background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
41
- -webkit-background-clip: text;
42
- -webkit-text-fill-color: transparent;
43
- background-clip: text;
44
- margin-bottom: 1rem !important;
45
- letter-spacing: -0.02em;
46
- }
47
-
48
- /* Subtítulos */
49
- .gradio-container h3 {
50
- font-size: 1.25rem !important;
51
- font-weight: 600 !important;
52
- color: #1e293b;
53
- margin-bottom: 1rem !important;
54
- }
55
-
56
- /* Descrições */
57
- .gradio-container p {
58
- font-size: 1rem !important;
59
- color: #475569;
60
- line-height: 1.6;
61
- }
62
-
63
- /* Tabs - Estilo profissional */
64
- .tabs button {
65
- font-size: 1rem !important;
66
- font-weight: 600 !important;
67
- padding: 0.75rem 1.5rem !important;
68
- border-radius: 0.5rem 0.5rem 0 0 !important;
69
- transition: all 0.2s ease !important;
70
- }
71
-
72
- .tabs button[aria-selected="true"] {
73
- background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%) !important;
74
- color: white !important;
75
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1) !important;
76
- }
77
-
78
- .tabs button:not([aria-selected="true"]) {
79
- background: #f1f5f9 !important;
80
- color: #64748b !important;
81
- }
82
-
83
- .tabs button:hover:not([aria-selected="true"]) {
84
- background: #e2e8f0 !important;
85
- color: #334155 !important;
86
- }
87
-
88
- /* Botões principais */
89
- button[variant="primary"] {
90
- background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%) !important;
91
- border: none !important;
92
- font-weight: 600 !important;
93
- font-size: 1rem !important;
94
- padding: 0.75rem 1.5rem !important;
95
- border-radius: 0.5rem !important;
96
- box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3) !important;
97
- transition: all 0.3s ease !important;
98
- }
99
-
100
- button[variant="primary"]:hover {
101
- transform: translateY(-2px);
102
- box-shadow: 0 10px 15px -3px rgba(59, 130, 246, 0.4) !important;
103
- }
104
-
105
- /* Botões secundários */
106
- button[variant="secondary"] {
107
- background: #f8fafc !important;
108
- border: 2px solid #cbd5e1 !important;
109
- color: #475569 !important;
110
- font-weight: 500 !important;
111
- border-radius: 0.5rem !important;
112
- transition: all 0.2s ease !important;
113
- }
114
-
115
- button[variant="secondary"]:hover {
116
- background: #e2e8f0 !important;
117
- border-color: #94a3b8 !important;
118
- }
119
-
120
- /* Chatbot - Design limpo e profissional */
121
- .message-wrap {
122
- padding: 1rem !important;
123
- margin: 0.5rem 0 !important;
124
- border-radius: 0.75rem !important;
125
- font-size: 1rem !important;
126
- line-height: 1.6 !important;
127
- }
128
-
129
- .message-wrap.user {
130
- background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%) !important;
131
- border-left: 4px solid #3b82f6 !important;
132
- }
133
-
134
- .message-wrap.bot {
135
- background: #f8fafc !important;
136
- border-left: 4px solid #10b981 !important;
137
- }
138
-
139
- /* Input de texto */
140
- .gr-textbox textarea {
141
- font-size: 1rem !important;
142
- padding: 0.875rem !important;
143
- border-radius: 0.5rem !important;
144
- border: 2px solid #e2e8f0 !important;
145
- transition: all 0.2s ease !important;
146
- }
147
-
148
- .gr-textbox textarea:focus {
149
- border-color: #3b82f6 !important;
150
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
151
- outline: none !important;
152
- }
153
-
154
- /* Exemplos */
155
- .examples {
156
- border-radius: 0.75rem !important;
157
- padding: 1rem !important;
158
- background: #f8fafc !important;
159
- border: 1px solid #e2e8f0 !important;
160
- }
161
-
162
- .examples button {
163
- background: white !important;
164
- border: 1px solid #e2e8f0 !important;
165
- border-radius: 0.5rem !important;
166
- padding: 0.75rem 1rem !important;
167
- font-size: 0.95rem !important;
168
- color: #475569 !important;
169
- transition: all 0.2s ease !important;
170
- }
171
-
172
- .examples button:hover {
173
- background: #f1f5f9 !important;
174
- border-color: #3b82f6 !important;
175
- color: #1e3a8a !important;
176
- transform: translateY(-1px);
177
- }
178
-
179
- /* Imagem de visualização */
180
- .gr-image {
181
- border-radius: 0.75rem !important;
182
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
183
- border: 1px solid #e2e8f0 !important;
184
- }
185
-
186
- /* Labels */
187
- label {
188
- font-weight: 600 !important;
189
- color: #334155 !important;
190
- font-size: 0.95rem !important;
191
- margin-bottom: 0.5rem !important;
192
- }
193
-
194
- /* Container principal */
195
- .gradio-container {
196
- max-width: 1400px !important;
197
- margin: 0 auto !important;
198
- }
199
-
200
- /* Animação suave */
201
- @keyframes fadeIn {
202
- from {
203
- opacity: 0;
204
- transform: translateY(10px);
205
- }
206
- to {
207
- opacity: 1;
208
- transform: translateY(0);
209
- }
210
- }
211
-
212
-
213
- /* Badge de status */
214
- .status-badge {
215
- display: inline-block;
216
- padding: 0.25rem 0.75rem;
217
- border-radius: 9999px;
218
- font-size: 0.875rem;
219
- font-weight: 600;
220
- background: #dcfce7;
221
- color: #166534;
222
- }
223
-
224
- /* Scrollbar personalizada */
225
- ::-webkit-scrollbar {
226
- width: 10px;
227
- }
228
-
229
- ::-webkit-scrollbar-track {
230
- background: #f1f5f9;
231
- border-radius: 10px;
232
- }
233
-
234
- ::-webkit-scrollbar-thumb {
235
- background: #cbd5e1;
236
- border-radius: 10px;
237
- }
238
-
239
- ::-webkit-scrollbar-thumb:hover {
240
- background: #94a3b8;
241
- }
242
- """
243
 
244
  async def simulate_streaming_adaptive(full_text: str):
245
- """Streaming adaptativo com delays diferentes para pontuação"""
246
- words = re.findall(r'\S+\s*', full_text)
247
- current_text = ""
248
-
249
- for word in words:
250
- current_text += word
251
-
252
- if word.strip().endswith(('.', '!', '?')):
253
- delay = 0.15
254
- elif word.strip().endswith((',', ';', ':')):
255
- delay = 0.08
256
  else:
257
- delay = 0.04
258
-
259
- await asyncio.sleep(delay)
260
- yield current_text
261
-
262
-
263
- # ✅ NATIVE STREAMING DO PYDANTIC AI (prioridade)
264
- async def respond_native_stream(message, history):
265
- """Streaming nativo usando agent.stream_text() - MAIS RÁPIDO e sem tools"""
266
- try:
267
- print(f"🎯 Native streaming: {message}")
268
-
269
- history.append(gr.ChatMessage(role="user", content=message))
270
- history.append(gr.ChatMessage(role="assistant", content=""))
271
-
272
- # Tenta streaming nativo SEM tools (mais rápido)
273
- async with gr.context():
274
- # Aqui você usaria agent.stream_text(message) se disponível
275
- # Por enquanto usa o iter() que já funciona
276
- async with agent.iter(message, deps=deps) as agent_run:
277
- full_text = ""
278
- async for node in agent_run:
279
- if hasattr(node, 'text') and node.text:
280
- full_text += node.text
281
- history[-1] = gr.ChatMessage(role="assistant", content=full_text)
282
- yield history, get_current_chart()
283
- elif isinstance(node, End) and agent_run.result:
284
- final_text = str(agent_run.result.output)
285
- history[-1] = gr.ChatMessage(role="assistant", content=final_text)
286
- yield history, get_current_chart()
287
- return
288
-
289
- except Exception as e:
290
- print(f"❌ Native stream falhou: {e}")
291
- # Fallback para streaming simulado
292
- yield "Erro. Comunique o desenvolvedor."
293
-
294
- # ✅ FALLBACK SIMULADO (só se nativo falhar)
295
- async def respond_fallback(message, history):
296
- """Fallback com streaming simulado adaptativo"""
297
- try:
298
- print(f"🔄 Fallback streaming: {message}")
299
- full_response = await stream_agent_response_safe(message)
300
-
301
- history.append(gr.ChatMessage(role="user", content=message))
302
- history.append(gr.ChatMessage(role="assistant", content=""))
303
-
304
- async for text_chunk in simulate_streaming_adaptive(full_response):
305
- history[-1] = gr.ChatMessage(role="assistant", content=text_chunk)
306
- yield history, get_current_chart()
307
-
308
- except Exception as e:
309
- print(f"❌ Fallback falhou: {e}")
310
- history.append(gr.ChatMessage(role="user", content=message))
311
- history.append(gr.ChatMessage(role="assistant", content=f"⚠️ Erro: {str(e)}"))
312
- yield history, get_current_chart()
313
-
314
- # ✅ SIMULADOR ADAPTATIVO (melhorado)
315
- async def simulate_streaming_adaptive(full_response):
316
- """Simula streaming inteligente baseado no conteúdo"""
317
- if not full_response or len(full_response) < 10:
318
- yield full_response
319
- return
320
-
321
- # Velocidades adaptativas por tipo de conteúdo
322
- words = full_response.split()
323
- if "gráfico" in full_response.lower() or "chart" in full_response.lower():
324
- delay = 0.08 # Mais lento para análise visual
325
- elif len(words) > 50:
326
- delay = 0.06 # Médio para respostas longas
327
- else:
328
- delay = 0.03 # Rápido para respostas curtas
329
-
330
- chars_yielded = 0
331
- for i in range(0, len(full_response), max(1, len(full_response)//30)):
332
- chunk = full_response[chars_yielded:i+1]
333
- if chunk:
334
- yield chunk
335
- chars_yielded = i + 1
336
- await asyncio.sleep(delay)
337
 
338
- # ✅ FUNÇÃO PRINCIPAL SIMPLIFICADA (sem híbrido complexo)
339
  async def respond(message, history):
340
- """Resposta robusta: sempre retorna 2 valores para Gradio"""
341
  if not message or not message.strip():
342
  yield history, get_current_chart()
343
  return
344
-
345
- print(f"🚀 Processing: {message[:50]}...")
346
-
347
- # Adiciona mensagem do usuário
348
- updated_history = history + [gr.ChatMessage(role="user", content=message)]
349
- updated_history.append(gr.ChatMessage(role="assistant", content=""))
350
-
351
  try:
352
- # ✅ Usa a função que JÁ FUNCIONA
353
  full_response = await stream_agent_response_safe(message)
354
- print(f"✅ Resposta completa: {len(full_response)} chars")
355
-
356
- # Streaming simulado PERFEITO
357
- streamed_text = ""
358
- async for chunk in simulate_streaming_perfect(full_response):
359
- streamed_text += chunk
360
- # Atualiza última mensagem do assistente
361
- updated_history[-1] = gr.ChatMessage(role="assistant", content=streamed_text)
362
- yield updated_history, get_current_chart()
363
-
364
- # Yield final (Gradio precisa)
365
- yield updated_history, get_current_chart()
366
-
367
  except Exception as e:
368
- print(f" Erro: {e}")
369
- import traceback
370
- traceback.print_exc()
371
-
372
- error_msg = f"⚠️ Erro: {str(e)}"
373
- updated_history[-1] = gr.ChatMessage(role="assistant", content=error_msg)
374
- yield updated_history, get_current_chart()
375
 
376
  def refresh_chart():
377
- """Atualiza a visualização do gráfico"""
378
  return get_current_chart()
379
 
380
- # ✅ INTERFACE GRADIO (sem mudanças)
381
  if __name__ == "__main__":
382
- with gr.Blocks(
383
- title="Barcelona Analytics Platform",
384
- theme=gr.themes.Soft(
385
- primary_hue="blue",
386
- secondary_hue="slate",
387
- neutral_hue="slate",
388
- font=[gr.themes.GoogleFont("Inter"), "sans-serif"],
389
- font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"]
390
- ),
391
- css="""
392
- .status-badge { font-size: 0.875rem; color: #64748b; font-style: italic; }
393
- .gradio-container { max-width: 1400px !important; }
394
- """
395
- ) as demo:
396
-
397
- gr.Markdown(
398
- """
399
- # 🤖 AI Graph Data Analyst Agent for FC Barcelona
400
- """
401
- )
402
-
403
- with gr.Tabs() as tabs:
404
- # ABA 1: ANALYSIS & CHAT
405
- with gr.Tab("Analysis & Chat", id=0):
406
- #gr.Markdown("### Intelligent Assistant *(Streaming Nativo)*")
407
-
408
- chatbot = gr.Chatbot(
409
- type="messages",
410
- label="",
411
- height=520,
412
- show_copy_button=True,
413
- show_label=False,
414
- avatar_images=["🧠", "⚽"]
415
- )
416
-
417
  with gr.Row():
418
- msg = gr.Textbox(
419
- placeholder="Ask about players, matches, or request visualizations... (ex: 'Create bar chart top players')",
420
- label="Your Query",
421
- lines=2,
422
- scale=4,
423
- show_label=False
424
- )
425
- submit_btn = gr.Button("🚀 Analyze", variant="primary", scale=1, size="lg")
426
-
427
- with gr.Accordion("📋 Query Examples", open=False):
428
- gr.Examples(
429
- examples=[
430
- "List the top 5 players that participated in goals for each season",
431
- "Create a bar chart showing top 5 players with most passes in 2020/2021",
432
- "Show line chart of goal sequences across all seasons",
433
- "Horizontal bar chart: top 10 players by total passes",
434
- "Who passed most frequently to Messi in 2019/2020?"
435
- ],
436
- inputs=msg,
437
- label=""
438
- )
439
-
440
- # ABA 2: DATA VISUALIZATION
441
- with gr.Tab("Data Visualization", id=1):
442
- gr.Markdown(
443
- """
444
- ### 📊 Interactive Visualizations
445
-
446
- Charts generated by AI appear **live** here.
447
- Request in Analysis tab → see results instantly!
448
- """
449
- )
450
-
451
  chart_display = gr.Image(
452
  value=PLACEHOLDER_IMAGE,
453
- label="",
454
  type="pil",
455
  height=620,
456
- show_label=False,
457
  show_download_button=True,
458
  show_share_button=False,
459
- container=True,
460
- interactive=False
461
  )
462
-
463
- with gr.Row():
464
- refresh_btn = gr.Button("🔄 Refresh Chart", variant="secondary", size="lg")
465
- status = gr.Markdown("_🟢 Live - Updated automatically_", elem_classes="status-badge")
466
-
467
- # Footer
468
- gr.Markdown(
469
- """
470
- ---
471
- **Data:** Neo4j Graph (Barcelona 2016-2021)
472
- """
473
  )
474
-
475
- # 🎯 EVENTOS SIMPLIFICADOS
476
- submit_btn.click(
477
- fn=respond,
478
- inputs=[msg, chatbot],
479
- outputs=[chatbot, chart_display]
480
- ).then(lambda: "", None, [msg])
481
-
482
- msg.submit(
483
- fn=respond,
484
- inputs=[msg, chatbot],
485
- outputs=[chatbot, chart_display]
486
- ).then(lambda: "", None, [msg])
487
-
488
- refresh_btn.click(
489
- fn=refresh_chart,
490
- outputs=[chart_display]
491
  )
492
-
493
- print("🎯 Barcelona AI Analyst rodando em http://0.0.0.0:7860")
494
-
495
- demo.launch(
496
- ssr_mode=False,
497
- share=False,
498
- server_name="0.0.0.0",
499
- server_port=7860,
500
- show_api=False,
501
- favicon_path=None
502
- )
 
1
+ # app.py
2
  import gradio as gr
 
 
 
3
  import asyncio
4
  import re
5
 
 
 
 
 
 
 
 
 
 
 
 
6
  from client_agent import stream_agent_response_safe, get_current_chart, PLACEHOLDER_IMAGE
7
 
8
+ CUSTOM_CSS = """(seu css)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  async def simulate_streaming_adaptive(full_text: str):
11
+ words = re.findall(r"\S+|\s+", full_text)
12
+ current = ""
13
+ for w in words:
14
+ current += w
15
+ if w.strip().endswith((".", "!", "?")):
16
+ await asyncio.sleep(0.12)
17
+ elif w.strip().endswith((",", ";", ":")):
18
+ await asyncio.sleep(0.06)
 
 
 
19
  else:
20
+ await asyncio.sleep(0.02)
21
+ yield current
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
 
23
  async def respond(message, history):
 
24
  if not message or not message.strip():
25
  yield history, get_current_chart()
26
  return
27
+
28
+ history = history + [gr.ChatMessage(role="user", content=message)]
29
+ history.append(gr.ChatMessage(role="assistant", content=""))
30
+
 
 
 
31
  try:
 
32
  full_response = await stream_agent_response_safe(message)
33
+ async for partial in simulate_streaming_adaptive(full_response):
34
+ history[-1] = gr.ChatMessage(role="assistant", content=partial)
35
+ yield history, get_current_chart()
36
+ yield history, get_current_chart()
 
 
 
 
 
 
 
 
 
37
  except Exception as e:
38
+ history[-1] = gr.ChatMessage(role="assistant", content=f"⚠️ Error: {e}")
39
+ yield history, get_current_chart()
 
 
 
 
 
40
 
41
  def refresh_chart():
 
42
  return get_current_chart()
43
 
 
44
  if __name__ == "__main__":
45
+ with gr.Blocks(title="Barcelona Analytics Platform", css=CUSTOM_CSS) as demo:
46
+ gr.Markdown("# AI Data Analyst Agent for FC Barcelona")
47
+
48
+ with gr.Tabs():
49
+ with gr.Tab("Analysis & Chat"):
50
+ chatbot = gr.Chatbot(type="messages", height=520, show_copy_button=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  with gr.Row():
52
+ msg = gr.Textbox(placeholder="Ask...", lines=2, scale=4, show_label=False)
53
+ submit_btn = gr.Button("Send", variant="primary", scale=1)
54
+
55
+ with gr.Tab("Data Visualization"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  chart_display = gr.Image(
57
  value=PLACEHOLDER_IMAGE,
 
58
  type="pil",
59
  height=620,
 
60
  show_download_button=True,
61
  show_share_button=False,
62
+ show_label=False,
 
63
  )
64
+ refresh_btn = gr.Button("🔄 Refresh Visualization", variant="secondary")
65
+
66
+ submit_btn.click(fn=respond, inputs=[msg, chatbot], outputs=[chatbot, chart_display]).then(
67
+ lambda: "", None, [msg]
 
 
 
 
 
 
 
68
  )
69
+ msg.submit(fn=respond, inputs=[msg, chatbot], outputs=[chatbot, chart_display]).then(
70
+ lambda: "", None, [msg]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  )
72
+ refresh_btn.click(fn=refresh_chart, inputs=[], outputs=[chart_display])
73
+
74
+ demo.launch(ssr_mode=False, share=False)
 
 
 
 
 
 
 
 
client_agent.py CHANGED
@@ -1,154 +1,49 @@
 
 
1
  import asyncio
 
 
 
 
2
  from pydantic_ai import Agent, RunContext
3
- from pydantic_ai.mcp import MCPServerStreamableHTTP
4
  from pydantic_ai.providers.groq import GroqProvider
5
  from pydantic_ai.models.groq import GroqModel
6
- from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
7
  from pydantic_graph import End
 
8
  from tools.searching import SearchingTools
9
 
10
  import pandas as pd
11
  from typing import List, Dict, Any, Optional
12
-
13
  import matplotlib
14
- matplotlib.use('Agg')
15
  import matplotlib.pyplot as plt
16
  import io
17
  from PIL import Image, ImageDraw, ImageFont
18
 
19
-
20
- from dataclasses import dataclass
21
- from dotenv import load_dotenv
22
- load_dotenv()
23
- import os
24
-
25
- # Criar servidor MCP via stdio
26
- neo4j_server = MCPServerStreamableHTTP(
27
- url = "http://127.0.0.1:8000/mcp"
28
- )
29
-
30
- SYSTEM_PROMPT = """
31
- # You are an Expert Football Analyst with access to Barcelona graphdatabase from five seasons - from 2016/2017 to 2020/2021.
32
- Use the tools available to respond the user query. You are connected to neo4j by a MCP server created by Frederico Caixeta.
33
- Base your analysis **ONLY** by the query results. If the database can't provide what the user is
34
- asking for, report that in a professional way. Limit your answer in **1500** characters.
35
- When the user asks for visualizations, graphs, or charts, you MUST use the create_chart tool
36
- to generate the appropriate visualization. The chart will be displayed in the Visualization tab.
37
- # Below you can find some cypher queries as example, so you can understand which artifacts and metadatas are available in the database:
38
- // General Overview: players, connections, goals per temporada
39
- MATCH (p:Player {team: "Barcelona"})
40
- OPTIONAL MATCH (p)-[r:PASSED_TO]->()
41
- OPTIONAL MATCH (g:GoalSequence {team: "Barcelona"})
42
- RETURN
43
- count(DISTINCT p) as TotalPlayers,
44
- count(DISTINCT r) as TotalPassConnections,
45
- sum(r.weight) as TotalPasses,
46
- count(DISTINCT g) as TotalGoalSequences,
47
- collect(DISTINCT p.season_date) as Seasons
48
- // List all seasons available in neo4j
49
- MATCH (p:Player)
50
- RETURN DISTINCT p.season_date as Season, p.season_id as SeasonID
51
- ORDER BY p.season_date
52
- // Top 5 connections per season
53
- MATCH (p1:Player)-[r:PASSED_TO]->(p2:Player)
54
- WITH p1.season_date as Season, p1.name as P1, p2.name as P2, r.weight as Weight
55
- ORDER BY Weight DESC
56
- WITH Season, collect({passer: P1, receiver: P2, passes: Weight})[0..5] as TopConnections
57
- RETURN Season, TopConnections
58
- ORDER BY Season
59
- // Connections between different zones in field
60
- MATCH (p1:Player)-[r:PASSED_TO]->(p2:Player)
61
- WHERE p1.season_id = 90
62
- WITH p1, p2, r,
63
- CASE WHEN p1.avg_x < 40 THEN 'Def' WHEN p1.avg_x < 80 THEN 'Mid' ELSE 'Att' END as Zone1,
64
- CASE WHEN p2.avg_x < 40 THEN 'Def' WHEN p2.avg_x < 80 THEN 'Mid' ELSE 'Att' END as Zone2
65
- WHERE Zone1 <> Zone2
66
- RETURN Zone1 + ' -> ' + Zone2 as Transition, sum(r.weight) as TotalPasses
67
- ORDER BY TotalPasses DESC
68
- // Total number of sequences of goals that Rakitić (a player) was involved
69
- MATCH (p:Player {name: "Ivan Rakitić"})-[:INVOLVED_IN]->(g:GoalSequence)
70
- RETURN count(g) as TotalGoalSequences
71
- # The Property Keys available are: avg_x, avg_y, data, end_x, end_y, id, match_id, name, nodes, num_passes, order, possession, relationships, season_date, season_id, sequence_id, style, team, visualisation, weight, x, y.
72
- The Nodes are: Player, GoalSequence.
73
- The Relationships are: INVOLVED_IN, PASSED_IN_SEQUENCE, PASSED_TO.
74
- The seasons ids and their dates: [{90: '2020/2021'}, {42: '2019/2020'}, {4: '2018/2019'}, {1: '2017/2018'}, {2: '2016/2017'}]
75
- # All players played in all seasons are:
76
- Abel Ruiz Ortega
77
- Aleix Vidal Parreu
78
- André Filipe Tavares Gomes
79
- Andrés Iniesta Luján
80
- Anssumane Fati
81
- Antoine Griezmann
82
- Arda Turan
83
- Arthur Henrique Ramos de Oliveira Melo
84
- Arturo Erasmo Vidal Pardo
85
- Carles Aleña Castillo
86
- Carles Pérez Sayol
87
- Claudio Andrés Bravo Muñoz
88
- Clément Lenglet
89
- Denis Suárez Fernández
90
- Francisco Alcácer García
91
- Francisco António Machado Mota de Castro Trincão
92
- Frenkie de Jong
93
- Gerard Deulofeu Lázaro
94
- Gerard Piqué Bernabéu
95
- Héctor Junior Firpo Adames
96
- Ivan Rakitić
97
- Jasper Cillessen
98
- Javier Alejandro Mascherano
99
- Jean-Clair Todibo
100
- Jordi Alba Ramos
101
- José Manuel Arnáiz Díaz
102
- José Paulo Bezzera Maciel Júnior
103
- Jérémy Mathieu
104
- Kevin-Prince Boateng
105
- Lionel Andrés Messi Cuccittini
106
- Lucas Digne
107
- Luis Alberto Suárez Díaz
108
- Malcom Filipe Silva de Oliveira
109
- Marc-André ter Stegen
110
- Marlon Santos da Silva Barbosa
111
- Martin Braithwaite Christensen
112
- Miralem Pjanić
113
- Moriba Kourouma Kourouma
114
- Moussa Wagué
115
- Munir El Haddadi Mohamed
116
- Neymar da Silva Santos Junior
117
- Norberto Murara Neto
118
- Nélson Cabral Semedo
119
- Ousmane Dembélé
120
- Pedro González López
121
- Philippe Coutinho Correia
122
- Rafael Alcântara do Nascimento
123
- Ricard Puig Martí
124
- Ronald Federico Araújo da Silva
125
- Samuel Yves Umtiti
126
- Sergi Roberto Carnicer
127
- Sergino Dest
128
- Sergio Busquets i Burgos
129
- Thomas Vermaelen
130
- Yerry Fernando Mina González
131
- Álex Collado Gutiérrez
132
- Óscar Mingueza García
133
- """
134
-
135
-
136
-
137
 
138
  api_key = os.getenv("GROQ_DEV_API_KEY")
139
  groq_model = GroqModel(
140
- "moonshotai/kimi-k2-instruct-0905",
141
- provider=GroqProvider(api_key=api_key)
 
 
 
 
 
 
 
 
142
  )
143
 
144
  @dataclass
145
  class SearchAgentDeps:
146
  tools: SearchingTools
147
-
148
- # Criar agent com o servidor MCP
149
  agent = Agent(
150
  model=groq_model,
151
- toolsets=[neo4j_server],
152
  system_prompt=SYSTEM_PROMPT,
153
  deps_type=SearchAgentDeps,
154
  )
@@ -156,52 +51,32 @@ agent = Agent(
156
  tools_instance = SearchingTools()
157
  deps = SearchAgentDeps(tools=tools_instance)
158
 
159
- @agent.tool(name="web_search",retries=3)
160
  async def procura_web(ctx: RunContext[SearchAgentDeps], search_query: str) -> str:
161
  """Pesquisa na web"""
162
- return ctx.deps.tools.search_web(
163
- search_query=search_query,
164
- max_results=15
165
- )
166
 
167
- # ============= NOVA FUNCIONALIDADE: VISUALIZAÇÕES =============
168
-
169
- # Variável global para armazenar o último gráfico
170
  last_chart_image = None
171
 
172
  def create_placeholder_image():
173
- """Cria uma imagem placeholder simples com texto"""
174
- # Cores profissionais
175
- bg_color = (248, 250, 252) # Slate-50
176
- text_color = (100, 116, 139) # Slate-500
177
-
178
- img = Image.new('RGB', (800, 600), color=bg_color)
179
  draw = ImageDraw.Draw(img)
180
-
181
- # Carregar fonte
182
  try:
183
  font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 48)
184
- except:
185
  font = ImageFont.load_default()
186
-
187
- # Texto centralizado
188
  text = "Waiting for plot..."
189
  bbox = draw.textbbox((0, 0), text, font=font)
190
- text_width = bbox[2] - bbox[0]
191
- text_height = bbox[3] - bbox[1]
192
-
193
- x = (800 - text_width) // 2
194
- y = (600 - text_height) // 2
195
-
196
  draw.text((x, y), text, fill=text_color, font=font)
197
-
198
  return img
199
 
200
-
201
-
202
  PLACEHOLDER_IMAGE = create_placeholder_image()
203
 
204
- @agent.tool(name="create_chart", retries=2)
205
  async def create_chart(
206
  ctx: RunContext[SearchAgentDeps],
207
  data: List[Dict[str, Any]],
@@ -212,100 +87,56 @@ async def create_chart(
212
  x_title: Optional[str] = None,
213
  y_title: Optional[str] = None,
214
  ) -> str:
215
- """
216
- Cria uma visualização dos dados.
217
-
218
- Args:
219
- data: Lista de dicionários com os dados
220
- x_column: Coluna do eixo X
221
- y_column: Coluna do eixo Y
222
- chart_type: Tipo ('bar', 'line', 'scatter', 'horizontal_bar')
223
- title: Título do gráfico
224
- x_title: Título do eixo X
225
- y_title: Título do eixo Y
226
- """
227
  global last_chart_image
228
-
229
  try:
230
  df = pd.DataFrame(data)
231
-
232
  if x_column not in df.columns or y_column not in df.columns:
233
  return f"❌ Erro: Colunas não encontradas. Disponíveis: {list(df.columns)}"
234
-
235
  fig, ax = plt.subplots(figsize=(10, 6))
236
-
237
  if chart_type == "bar":
238
- bars = ax.bar(df[x_column], df[y_column], color='#4682B4',
239
- edgecolor='#2F4F7F', linewidth=1.5)
240
- for bar in bars:
241
- height = bar.get_height()
242
- ax.text(bar.get_x() + bar.get_width()/2., height,
243
- f'{height:.0f}', ha='center', va='bottom', fontsize=9)
244
-
245
  elif chart_type == "horizontal_bar":
246
- ax.barh(df[x_column], df[y_column], color='#4682B4',
247
- edgecolor='#2F4F7F', linewidth=1.5)
248
-
249
  elif chart_type == "line":
250
- ax.plot(df[x_column], df[y_column], marker='o', linewidth=2.5,
251
- markersize=8, color='#4682B4', markerfacecolor='#FF6B6B')
252
- ax.grid(True, alpha=0.3, linestyle='--')
253
-
254
  elif chart_type == "scatter":
255
- ax.scatter(df[x_column], df[y_column], s=100, alpha=0.6,
256
- color='#4682B4', edgecolors='#2F4F7F', linewidth=1.5)
257
- ax.grid(True, alpha=0.3, linestyle='--')
258
  else:
259
  return f"❌ Tipo '{chart_type}' não suportado"
260
-
261
- ax.set_title(title or f"{y_column} por {x_column}",
262
- fontsize=16, fontweight='bold', pad=20)
263
- ax.set_xlabel(x_title or x_column, fontsize=12, fontweight='bold')
264
- ax.set_ylabel(y_title or y_column, fontsize=12, fontweight='bold')
265
-
266
- if chart_type != "horizontal_bar":
267
- plt.xticks(rotation=45, ha='right')
268
-
269
- ax.set_axisbelow(True)
270
- ax.yaxis.grid(True, alpha=0.3, linestyle='--')
271
  plt.tight_layout()
272
-
273
- # Salvar imagem
274
  buf = io.BytesIO()
275
- plt.savefig(buf, format='png', dpi=150, bbox_inches='tight',
276
- facecolor='white')
277
  buf.seek(0)
278
  last_chart_image = Image.open(buf).copy()
279
  plt.close()
280
-
281
- return f"✅ Gráfico de **{chart_type}** criado! {len(df)} registros. Confira na aba **Visualização** 📊"
282
-
283
  except Exception as e:
284
  return f"❌ Erro ao criar gráfico: {str(e)}"
285
 
286
  def get_current_chart():
287
- """Retorna o gráfico atual ou placeholder"""
288
  global last_chart_image
289
  return last_chart_image if last_chart_image is not None else PLACEHOLDER_IMAGE
290
 
291
-
292
  async def stream_agent_response_safe(user_query: str) -> str:
293
- """
294
- Executa o agente e retorna apenas a resposta final completa.
295
- """
296
  async with agent.iter(user_query, deps=deps) as agent_run:
297
  async for node in agent_run:
298
- if isinstance(node, End):
299
- if agent_run.result:
300
- return str(agent_run.result.output)
301
  return "Erro na execução do agente"
302
 
303
- if __name__ == '__main__':
304
- async def test_safe():
305
- print("=== Teste stream_agent_response_safe ===")
306
- response = await stream_agent_response_safe(
307
- "List the 5 top players that participated in goals, each season"
308
- )
309
- print(response)
310
-
311
- asyncio.run(test_safe())
 
1
+ # client_agent.py
2
+ import os
3
  import asyncio
4
+ from dataclasses import dataclass
5
+ from dotenv import load_dotenv
6
+ load_dotenv()
7
+
8
  from pydantic_ai import Agent, RunContext
9
+ from pydantic_ai.mcp import MCPServerStdio
10
  from pydantic_ai.providers.groq import GroqProvider
11
  from pydantic_ai.models.groq import GroqModel
 
12
  from pydantic_graph import End
13
+
14
  from tools.searching import SearchingTools
15
 
16
  import pandas as pd
17
  from typing import List, Dict, Any, Optional
 
18
  import matplotlib
19
+ matplotlib.use("Agg")
20
  import matplotlib.pyplot as plt
21
  import io
22
  from PIL import Image, ImageDraw, ImageFont
23
 
24
+ SYSTEM_PROMPT = """(seu prompt grande aqui)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  api_key = os.getenv("GROQ_DEV_API_KEY")
27
  groq_model = GroqModel(
28
+ "llama-3.1-70b-versatile",
29
+ provider=GroqProvider(api_key=api_key),
30
+ )
31
+
32
+ # ✅ MCP via stdio (subprocess) — ideal no Hugging Face
33
+ neo4j_server = MCPServerStdio(
34
+ "python",
35
+ args=["mcp_server.py"],
36
+ tool_prefix="neo4j", # ex: neo4j_execute_cypher_query
37
+ timeout=30,
38
  )
39
 
40
  @dataclass
41
  class SearchAgentDeps:
42
  tools: SearchingTools
43
+
 
44
  agent = Agent(
45
  model=groq_model,
46
+ toolsets=[neo4j_server], # ✅ server MCP anexado
47
  system_prompt=SYSTEM_PROMPT,
48
  deps_type=SearchAgentDeps,
49
  )
 
51
  tools_instance = SearchingTools()
52
  deps = SearchAgentDeps(tools=tools_instance)
53
 
54
+ @agent.tool(name="web_search", retries=3)
55
  async def procura_web(ctx: RunContext[SearchAgentDeps], search_query: str) -> str:
56
  """Pesquisa na web"""
57
+ return ctx.deps.tools.search_web(search_query=search_query, max_results=15)
 
 
 
58
 
 
 
 
59
  last_chart_image = None
60
 
61
  def create_placeholder_image():
62
+ bg_color = (248, 250, 252)
63
+ text_color = (100, 116, 139)
64
+ img = Image.new("RGB", (800, 600), color=bg_color)
 
 
 
65
  draw = ImageDraw.Draw(img)
 
 
66
  try:
67
  font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 48)
68
+ except Exception:
69
  font = ImageFont.load_default()
 
 
70
  text = "Waiting for plot..."
71
  bbox = draw.textbbox((0, 0), text, font=font)
72
+ x = (800 - (bbox[2] - bbox[0])) // 2
73
+ y = (600 - (bbox[3] - bbox[1])) // 2
 
 
 
 
74
  draw.text((x, y), text, fill=text_color, font=font)
 
75
  return img
76
 
 
 
77
  PLACEHOLDER_IMAGE = create_placeholder_image()
78
 
79
+ @agent.tool(name="create_chart", retries=2, timeout=30.0)
80
  async def create_chart(
81
  ctx: RunContext[SearchAgentDeps],
82
  data: List[Dict[str, Any]],
 
87
  x_title: Optional[str] = None,
88
  y_title: Optional[str] = None,
89
  ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
90
  global last_chart_image
 
91
  try:
92
  df = pd.DataFrame(data)
 
93
  if x_column not in df.columns or y_column not in df.columns:
94
  return f"❌ Erro: Colunas não encontradas. Disponíveis: {list(df.columns)}"
95
+
96
  fig, ax = plt.subplots(figsize=(10, 6))
97
+
98
  if chart_type == "bar":
99
+ ax.bar(df[x_column], df[y_column])
 
 
 
 
 
 
100
  elif chart_type == "horizontal_bar":
101
+ ax.barh(df[x_column], df[y_column])
 
 
102
  elif chart_type == "line":
103
+ ax.plot(df[x_column], df[y_column], marker="o")
 
 
 
104
  elif chart_type == "scatter":
105
+ ax.scatter(df[x_column], df[y_column])
 
 
106
  else:
107
  return f"❌ Tipo '{chart_type}' não suportado"
108
+
109
+ ax.set_title(title or f"{y_column} por {x_column}")
110
+ ax.set_xlabel(x_title or x_column)
111
+ ax.set_ylabel(y_title or y_column)
 
 
 
 
 
 
 
112
  plt.tight_layout()
113
+
 
114
  buf = io.BytesIO()
115
+ plt.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor="white")
 
116
  buf.seek(0)
117
  last_chart_image = Image.open(buf).copy()
118
  plt.close()
119
+
120
+ return f"✅ Gráfico '{chart_type}' criado ({len(df)} registros)."
 
121
  except Exception as e:
122
  return f"❌ Erro ao criar gráfico: {str(e)}"
123
 
124
  def get_current_chart():
 
125
  global last_chart_image
126
  return last_chart_image if last_chart_image is not None else PLACEHOLDER_IMAGE
127
 
 
128
  async def stream_agent_response_safe(user_query: str) -> str:
129
+ # ✅ manter iter() que já funciona
 
 
130
  async with agent.iter(user_query, deps=deps) as agent_run:
131
  async for node in agent_run:
132
+ if isinstance(node, End) and agent_run.result:
133
+ return str(agent_run.result.output)
 
134
  return "Erro na execução do agente"
135
 
136
+ __all__ = [
137
+ "agent",
138
+ "deps",
139
+ "PLACEHOLDER_IMAGE",
140
+ "get_current_chart",
141
+ "stream_agent_response_safe",
142
+ ]
 
 
mcp_server.py CHANGED
@@ -1,194 +1,77 @@
 
1
  import os
2
- from typing import Optional, List, Dict, Any
3
  from dotenv import load_dotenv
4
  from neo4j import GraphDatabase
5
- from mcp.server.fastmcp import FastMCP, Context
6
- #
7
- # Carregar variáveis de ambiente
8
  load_dotenv()
9
 
10
- # Configuração do Neo4j
11
  NEO4J_URI: str = os.getenv("NEO4J_URI", "")
12
  NEO4J_USER = os.getenv("NEO4J_USER")
13
  NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
14
 
15
  assert NEO4J_URI, "NEO4J_URI deve ser fornecida (não pode ser None ou vazia)"
16
- # Criar conexão global Neo4j
17
  class Neo4jConnection:
18
  def __init__(self):
19
  self.driver = GraphDatabase.driver(
20
- NEO4J_URI,
21
- auth=(NEO4J_USER, NEO4J_PASSWORD)
22
  )
23
  print(f"✓ Conectado ao Neo4j: {NEO4J_URI}")
24
-
25
  def execute_query(self, query: str, parameters: Optional[Dict[str, Any]] = None):
26
  with self.driver.session() as session:
27
  result = session.run(query, parameters or {})
28
  return [record.data() for record in result]
29
-
30
  def close(self):
31
  self.driver.close()
32
 
33
- # Instância global
34
  neo4j = Neo4jConnection()
35
 
36
- # Criar servidor MCP
37
  mcp = FastMCP(name="Neo4j Football Analytics")
38
 
39
- # Tool 1: Query Cypher customizada
40
- @mcp.tool()
41
- async def execute_cypher_query(
42
- query: str,
43
- parameters: Optional[Dict[str, Any]] = None,
44
- limit: int = 100
45
- ) -> str:
46
- """
47
- Executa uma query Cypher READ-ONLY no banco de dados Neo4j.
48
-
49
- Args:
50
- query: Query Cypher a ser executada (apenas MATCH/RETURN)
51
- parameters: Dicionário de parâmetros opcionais
52
- limit: Número máximo de resultados (padrão: 100)
53
-
54
- Returns:
55
- String formatada com os resultados da query
56
- """
57
- # Validação de segurança
58
- query_upper = query.upper().strip()
59
- dangerous = ['DELETE', 'DETACH', 'REMOVE', 'SET', 'CREATE', 'MERGE', 'DROP']
60
-
61
- if any(keyword in query_upper for keyword in dangerous):
62
  return "❌ ERRO: Apenas queries de leitura (MATCH/RETURN) são permitidas."
63
-
 
 
 
 
 
 
 
 
64
  try:
65
- # Adicionar LIMIT se não existir
66
- if 'LIMIT' not in query_upper:
67
- query += f" LIMIT {limit}"
68
-
69
  results = neo4j.execute_query(query, parameters)
70
-
71
  if not results:
72
  return "✓ Query executada, mas nenhum resultado encontrado."
73
-
74
- # Formatar resultados
75
- output = [f"📊 Resultados ({len(results)} encontrados):\n"]
76
-
77
  for i, record in enumerate(results[:10], 1):
78
  items = [f"{k}={v}" for k, v in record.items()]
79
- output.append(f"{i}. {', '.join(items)}")
80
-
81
  if len(results) > 10:
82
- output.append(f"\n... e mais {len(results) - 10} resultados.")
83
-
84
- return "\n".join(output)
85
-
86
- except Exception as e:
87
- return f"❌ Erro ao executar query: {str(e)}"
88
-
89
-
90
- @mcp.tool()
91
- async def count_goal_initiations(
92
- player_name: str
93
- ) -> str:
94
- """
95
- Conta quantos gols/chutes começam com passes de um jogador específico.
96
-
97
- Args:
98
- player_name: Nome do jogador a analisar
99
- """
100
- query = """
101
- MATCH (rak:Player {name: $player_name})-[r:PASSED_IN_SEQUENCE]->(receiver:Player)
102
- WITH rak, receiver, r, r.sequence_id as seqId
103
- MATCH (g:GoalSequence {sequence_id: seqId})
104
- RETURN receiver.name as PassedTo,
105
- count(DISTINCT seqId) as NumberOfGoalSequences,
106
- avg(r.order) as AvgPassPosition,
107
- collect(DISTINCT g.match_id)[0..3] as SampleMatches
108
- ORDER BY NumberOfGoalSequences DESC
109
- LIMIT 5
110
- """
111
-
112
- try:
113
- results = neo4j.execute_query(query, {"player_name": player_name})
114
-
115
- if not results:
116
- return f"Nenhum dado encontrado para {player_name}"
117
-
118
- return_data = [f"## Estatísticas de Gol - {player_name}:\n"]
119
-
120
- for record in results:
121
- receiver = record['PassedTo']
122
- goals = record['NumberOfGoalSequences']
123
- avg_pos = round(record['AvgPassPosition'], 2)
124
- return_data.append(
125
- f"→ Para {receiver}: {goals} sequências de gol (posição média no passe: {avg_pos})"
126
- )
127
-
128
- return "\n".join(return_data)
129
-
130
- except Exception as e:
131
- return f"Erro ao executar query: {str(e)}"
132
 
133
- # Tool 3: Cadeias de passes
134
- @mcp.tool()
135
- async def analyze_pass_chains(
136
- player_name: str,
137
- chain_length: int = 2,
138
- top_n: int = 5
139
- ) -> str:
140
- """
141
- Analisa cadeias de passes começando por um jogador.
142
-
143
- Args:
144
- player_name: Nome do jogador inicial
145
- chain_length: Comprimento da cadeia (número de passes)
146
- top_n: Número de cadeias mais frequentes a retornar
147
-
148
- Returns:
149
- Análise das cadeias de passes mais frequentes
150
- """
151
- query = f"""
152
- MATCH path = (p1:Player {{name: $player_name}})-[:PASSED_TO*{chain_length}]->(p2:Player)
153
- WITH [node IN nodes(path) | node.name] as PassChain, count(path) as Frequency
154
- RETURN PassChain, Frequency
155
- ORDER BY Frequency DESC
156
- LIMIT $top_n
157
- """
158
-
159
- try:
160
- results = neo4j.execute_query(
161
- query,
162
- {"player_name": player_name, "top_n": top_n}
163
- )
164
-
165
- if not results:
166
- return f"❌ Nenhuma cadeia de {chain_length} passes encontrada para {player_name}"
167
-
168
- output = [f"🔗 Top {len(results)} cadeias de {chain_length} passes de {player_name}:\n"]
169
-
170
- for i, record in enumerate(results, 1):
171
- chain = " → ".join(record['PassChain'])
172
- freq = record['Frequency']
173
- output.append(f"{i}. {chain} (frequência: {freq})")
174
-
175
- return "\n".join(output)
176
-
177
  except Exception as e:
178
- return f"❌ Erro: {str(e)}"
179
 
180
- # Tool 4: Eficiência de passes em gols
181
  @mcp.tool()
182
- async def player_efficiency(player_name: str) -> str:
183
- """
184
- Calcula a eficiência de um jogador: passes totais vs passes que resultam em gol.
185
-
186
- Args:
187
- player_name: Nome do jogador a analisar
188
-
189
- Returns:
190
- Estatísticas de eficiência formatadas
191
- """
192
  query = """
193
  MATCH (p:Player {name: $player_name})
194
  OPTIONAL MATCH (p)-[total:PASSED_TO]->()
@@ -199,84 +82,59 @@ async def player_efficiency(player_name: str) -> str:
199
  RETURN p.name as Player,
200
  TotalPasses,
201
  PassesInGoals,
202
- CASE
203
- WHEN TotalPasses > 0
204
- THEN round(PassesInGoals * 100.0 / TotalPasses, 2)
205
- ELSE 0
206
  END as EfficiencyPercent
207
  """
208
-
209
  try:
210
  results = neo4j.execute_query(query, {"player_name": player_name})
211
-
212
- if not results or not results[0]['TotalPasses']:
213
  return f"❌ Dados insuficientes para {player_name}"
214
-
215
  r = results[0]
216
- output = [
217
- f"📈 Eficiência de {r['Player']}:",
218
- f"Total de passes: {r['TotalPasses']}",
219
- f"Passes em sequências de gol: {r['PassesInGoals']}",
220
- f"Taxa de eficiência: {r['EfficiencyPercent']}%"
221
- ]
222
-
223
- return "\n".join(output)
224
-
225
  except Exception as e:
226
  return f"❌ Erro: {str(e)}"
227
 
228
- # Tool 5: Estatísticas específicas do Rakitić
229
  @mcp.tool()
230
- async def rakitic_goal_statistics() -> str:
231
- """
232
- Retorna estatísticas detalhadas de Ivan Rakitić em sequências de gol.
233
-
234
- Returns:
235
- Análise completa do desempenho de Rakitić
236
- """
237
  query = """
238
  MATCH (rak:Player {name: "Ivan Rakitić"})-[r:PASSED_IN_SEQUENCE]->(receiver:Player)
239
- WITH rak, receiver, r, r.sequence_id as seqId
240
  MATCH (g:GoalSequence {sequence_id: seqId})
241
  RETURN receiver.name as PassedTo,
242
  count(DISTINCT seqId) as NumberOfGoalSequences,
243
- round(avg(r.order), 2) as AvgPassPosition,
244
- collect(DISTINCT g.match_id)[0..3] as SampleMatches
245
  ORDER BY NumberOfGoalSequences DESC
246
  LIMIT 10
247
  """
248
-
249
  try:
250
  results = neo4j.execute_query(query)
251
-
252
  if not results:
253
  return "❌ Nenhum dado encontrado para Ivan Rakitić"
254
-
255
- output = ["⭐ Estatísticas de Ivan Rakitić em Sequências de Gol:\n"]
256
-
257
- total_sequences = sum(r['NumberOfGoalSequences'] for r in results)
258
- output.append(f"Total de sequências envolvidas: {total_sequences}\n")
259
- output.append("🎯 Principais destinatários de passes:")
260
-
261
- for i, record in enumerate(results, 1):
262
- output.append(
263
- f"{i}. {record['PassedTo']}: {record['NumberOfGoalSequences']} sequências "
264
- f"(posição média: {record['AvgPassPosition']})"
265
  )
266
-
267
- return "\n".join(output)
268
-
269
  except Exception as e:
270
  return f"❌ Erro: {str(e)}"
271
 
272
- # Executar servidor
273
- if __name__ == '__main__':
274
- async def run():
275
- try:
276
- # Escolher transporte: 'stdio', 'sse', ou 'streamable-http'
277
- print("🚀 Iniciando servidor MCP Neo4j...")
278
- await mcp.run_streamable_http_async() # Usar 'streamable-http' para HTTP
279
- finally:
280
- neo4j.close()
281
- import asyncio
282
- asyncio.run(run())
 
1
+ # mcp_server.py
2
  import os
3
+ from typing import Optional, Dict, Any
4
  from dotenv import load_dotenv
5
  from neo4j import GraphDatabase
6
+ from mcp.server.fastmcp import FastMCP
7
+
 
8
  load_dotenv()
9
 
 
10
  NEO4J_URI: str = os.getenv("NEO4J_URI", "")
11
  NEO4J_USER = os.getenv("NEO4J_USER")
12
  NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
13
 
14
  assert NEO4J_URI, "NEO4J_URI deve ser fornecida (não pode ser None ou vazia)"
15
+
16
  class Neo4jConnection:
17
  def __init__(self):
18
  self.driver = GraphDatabase.driver(
19
+ NEO4J_URI,
20
+ auth=(NEO4J_USER, NEO4J_PASSWORD),
21
  )
22
  print(f"✓ Conectado ao Neo4j: {NEO4J_URI}")
23
+
24
  def execute_query(self, query: str, parameters: Optional[Dict[str, Any]] = None):
25
  with self.driver.session() as session:
26
  result = session.run(query, parameters or {})
27
  return [record.data() for record in result]
28
+
29
  def close(self):
30
  self.driver.close()
31
 
 
32
  neo4j = Neo4jConnection()
33
 
 
34
  mcp = FastMCP(name="Neo4j Football Analytics")
35
 
36
+ def _ensure_read_only(query: str) -> Optional[str]:
37
+ q = query.upper().strip()
38
+ dangerous = ["DELETE", "DETACH", "REMOVE", "SET", "CREATE", "MERGE", "DROP", "CALL"]
39
+ if any(k in q for k in dangerous):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  return "❌ ERRO: Apenas queries de leitura (MATCH/RETURN) são permitidas."
41
+ return None
42
+
43
+ @mcp.tool()
44
+ def execute_cypher_query(query: str, parameters: Optional[Dict[str, Any]] = None, limit: int = 100) -> str:
45
+ """Executa uma query Cypher READ-ONLY no Neo4j (MATCH/RETURN)."""
46
+ err = _ensure_read_only(query)
47
+ if err:
48
+ return err
49
+
50
  try:
51
+ q_upper = query.upper()
52
+ if "LIMIT" not in q_upper:
53
+ query = f"{query}\nLIMIT {limit}"
54
+
55
  results = neo4j.execute_query(query, parameters)
56
+
57
  if not results:
58
  return "✓ Query executada, mas nenhum resultado encontrado."
59
+
60
+ out = [f"📊 Resultados ({len(results)} encontrados):"]
 
 
61
  for i, record in enumerate(results[:10], 1):
62
  items = [f"{k}={v}" for k, v in record.items()]
63
+ out.append(f"{i}. {', '.join(items)}")
64
+
65
  if len(results) > 10:
66
+ out.append(f"... e mais {len(results) - 10} resultados.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ return "\n".join(out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  except Exception as e:
70
+ return f"❌ Erro ao executar query: {str(e)}"
71
 
 
72
  @mcp.tool()
73
+ def player_efficiency(player_name: str) -> str:
74
+ """Eficiência: passes totais vs passes em sequências de gol."""
 
 
 
 
 
 
 
 
75
  query = """
76
  MATCH (p:Player {name: $player_name})
77
  OPTIONAL MATCH (p)-[total:PASSED_TO]->()
 
82
  RETURN p.name as Player,
83
  TotalPasses,
84
  PassesInGoals,
85
+ CASE
86
+ WHEN TotalPasses > 0
87
+ THEN round(PassesInGoals * 100.0 / TotalPasses, 2)
88
+ ELSE 0
89
  END as EfficiencyPercent
90
  """
 
91
  try:
92
  results = neo4j.execute_query(query, {"player_name": player_name})
93
+ if not results or results[0].get("TotalPasses") is None:
 
94
  return f"❌ Dados insuficientes para {player_name}"
95
+
96
  r = results[0]
97
+ return "\n".join(
98
+ [
99
+ f"📈 Eficiência de {r['Player']}:",
100
+ f"Total de passes: {r['TotalPasses']}",
101
+ f"Passes em sequências de gol: {r['PassesInGoals']}",
102
+ f"Taxa de eficiência: {r['EfficiencyPercent']}%",
103
+ ]
104
+ )
 
105
  except Exception as e:
106
  return f"❌ Erro: {str(e)}"
107
 
 
108
  @mcp.tool()
109
+ def rakitic_goal_statistics() -> str:
110
+ """Estatísticas de Ivan Rakitić em sequências de gol."""
 
 
 
 
 
111
  query = """
112
  MATCH (rak:Player {name: "Ivan Rakitić"})-[r:PASSED_IN_SEQUENCE]->(receiver:Player)
113
+ WITH receiver, r, r.sequence_id as seqId
114
  MATCH (g:GoalSequence {sequence_id: seqId})
115
  RETURN receiver.name as PassedTo,
116
  count(DISTINCT seqId) as NumberOfGoalSequences,
117
+ round(avg(r.order), 2) as AvgPassPosition
 
118
  ORDER BY NumberOfGoalSequences DESC
119
  LIMIT 10
120
  """
 
121
  try:
122
  results = neo4j.execute_query(query)
 
123
  if not results:
124
  return "❌ Nenhum dado encontrado para Ivan Rakitić"
125
+
126
+ lines = ["⭐ Estatísticas de Ivan Rakitić em Sequências de Gol:"]
127
+ for i, r in enumerate(results, 1):
128
+ lines.append(
129
+ f"{i}. {r['PassedTo']}: {r['NumberOfGoalSequences']} sequências (posição média: {r['AvgPassPosition']})"
 
 
 
 
 
 
130
  )
131
+ return "\n".join(lines)
 
 
132
  except Exception as e:
133
  return f"❌ Erro: {str(e)}"
134
 
135
+ if __name__ == "__main__":
136
+ try:
137
+ # Padrão dos docs: stdio
138
+ mcp.run()
139
+ finally:
140
+ neo4j.close()
 
 
 
 
 
requirements.txt CHANGED
@@ -3,4 +3,6 @@ tavily-python==0.7.12
3
  neo4j==6.0.2
4
  matplotlib==3.10.7
5
  pandas==2.3.3
6
- mcp==1.25.0
 
 
 
3
  neo4j==6.0.2
4
  matplotlib==3.10.7
5
  pandas==2.3.3
6
+ mcp==1.25.0
7
+ python-dotenv
8
+ pillow