agroplan-ai-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,538 @@
1
+ """
2
+ FastAPI Backend para AgroPlan AI
3
+ """
4
+
5
+ from fastapi import FastAPI, HTTPException, Request
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from pydantic import BaseModel
8
+ from typing import Optional
9
+ import sys
10
+ import os
11
+
12
+ # Adiciona o diretório backend ao path para imports
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ from core.loader import carregar_dados
16
+ from core.planner import gerar_cenarios, gerar_plano_genetico
17
+ from core.bruteforce_validator import comparar_ag_com_forca_bruta, executar_multiplas_rodadas
18
+ from core.report_generator import gerar_relatorio_completo
19
+
20
+ # Configurações de ambiente
21
+ HOST = os.getenv("HOST", "0.0.0.0")
22
+ PORT = int(os.getenv("PORT", "8000"))
23
+ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
24
+
25
+ # Cache em memória para resultados pesados
26
+ _resultados_cache = {}
27
+
28
+ def get_cache_key(nome, **params):
29
+ """Gera chave única para cache baseada no nome e parâmetros"""
30
+ return f"{nome}:" + ":".join(f"{k}={v}" for k, v in sorted(params.items()))
31
+
32
+ def get_or_compute_cache(key, compute_fn):
33
+ """Retorna valor do cache ou computa e armazena se não existir"""
34
+ if key not in _resultados_cache:
35
+ _resultados_cache[key] = compute_fn()
36
+ return _resultados_cache[key]
37
+
38
+ def get_ag_cacheado(objetivo="equilibrado", seed=42, geracoes=100, populacao=50):
39
+ """Retorna resultado do AG cacheado"""
40
+ culturas, talhoes, regras = get_dados()
41
+ key = get_cache_key("ag", objetivo=objetivo, seed=seed, geracoes=geracoes, populacao=populacao)
42
+ return get_or_compute_cache(key, lambda: gerar_plano_genetico(culturas, talhoes, regras, objetivo=objetivo, seed=seed, geracoes=geracoes, populacao=populacao))
43
+
44
+ # Inicializa FastAPI
45
+ app = FastAPI(
46
+ title="AgroPlan AI API",
47
+ description="API para Sistema Inteligente de Planejamento de Plantio",
48
+ version="5.0.0"
49
+ )
50
+
51
+ # Configura CORS
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=CORS_ORIGINS,
55
+ allow_credentials=True,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+ # Modelos Pydantic
61
+ class OtimizarRequest(BaseModel):
62
+ objetivo: str = "equilibrado"
63
+ seed: Optional[int] = 42
64
+ geracoes: Optional[int] = 100
65
+ populacao: Optional[int] = 50
66
+
67
+ class ValidarRequest(BaseModel):
68
+ objetivo: str = "equilibrado"
69
+ seed: Optional[int] = 42
70
+
71
+ class RelatorioRequest(BaseModel):
72
+ objetivo: str = "equilibrado"
73
+ formato: str = "md"
74
+
75
+ class RodadasRequest(BaseModel):
76
+ objetivo: str = "equilibrado"
77
+ rodadas: int = 5
78
+
79
+ # Cache de dados (carrega uma vez)
80
+ _dados_cache = None
81
+
82
+ def get_dados():
83
+ """Carrega dados com cache"""
84
+ global _dados_cache
85
+ if _dados_cache is None:
86
+ culturas, talhoes, regras = carregar_dados()
87
+ _dados_cache = {
88
+ 'culturas': culturas,
89
+ 'talhoes': talhoes,
90
+ 'regras': regras
91
+ }
92
+ return _dados_cache['culturas'], _dados_cache['talhoes'], _dados_cache['regras']
93
+
94
+ def converter_tipos_python(obj):
95
+ """Converte tipos numpy para tipos Python nativos recursivamente"""
96
+ import numpy as np
97
+
98
+ if isinstance(obj, dict):
99
+ return {k: converter_tipos_python(v) for k, v in obj.items()}
100
+ elif isinstance(obj, list):
101
+ return [converter_tipos_python(item) for item in obj]
102
+ elif isinstance(obj, (np.integer, np.int64, np.int32)):
103
+ return int(obj)
104
+ elif isinstance(obj, (np.floating, np.float64, np.float32)):
105
+ return float(obj)
106
+ elif isinstance(obj, (np.bool_, bool)):
107
+ return bool(obj)
108
+ elif isinstance(obj, str):
109
+ return str(obj)
110
+ else:
111
+ return obj
112
+
113
+ # Endpoints
114
+
115
+ @app.get("/")
116
+ def root():
117
+ """Endpoint raiz"""
118
+ return {
119
+ "message": "AgroPlan AI API",
120
+ "version": "5.0.0",
121
+ "docs": "/docs"
122
+ }
123
+
124
+ @app.get("/health")
125
+ def health():
126
+ """Verifica saúde da API"""
127
+ try:
128
+ culturas, talhoes, regras = get_dados()
129
+ return {
130
+ "status": "healthy",
131
+ "culturas": len(culturas),
132
+ "talhoes": len(talhoes),
133
+ "regras": len(regras),
134
+ "cache_items": len(_resultados_cache)
135
+ }
136
+ except Exception as e:
137
+ raise HTTPException(status_code=500, detail=str(e))
138
+
139
+ @app.get("/dashboard")
140
+ def get_dashboard():
141
+ """Retorna resumo do dashboard"""
142
+ try:
143
+ def montar_dashboard():
144
+ culturas, talhoes, regras = get_dados()
145
+
146
+ # Usa AG cacheado
147
+ resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
148
+
149
+ # Tenta validar
150
+ validacao = comparar_ag_com_forca_bruta(culturas, talhoes, regras, objetivo='equilibrado', seed=42)
151
+
152
+ # Se força bruta é inviável, retorna dados especiais
153
+ if validacao.get('erro'):
154
+ return {
155
+ "lucro_total": float(resultado_ag['lucro_total']),
156
+ "risco_medio": float(resultado_ag['risco_medio']),
157
+ "fitness": float(resultado_ag['fitness']),
158
+ "diversidade": int(resultado_ag['diversidade']),
159
+ "objetivo": str(resultado_ag['objetivo']),
160
+ "culturas_escolhidas": [str(p['cultura']) for p in resultado_ag['plano']],
161
+ "validacao": {
162
+ "otimo_global": False,
163
+ "total_combinacoes": int(validacao.get('total_combinacoes', 0))
164
+ },
165
+ "plano": [
166
+ {
167
+ "talhao": int(p['talhao']),
168
+ "area": float(p['area']),
169
+ "solo": str(p['solo']),
170
+ "clima": str(p['clima']),
171
+ "relevo": str(p['relevo']),
172
+ "agua": str(p['agua']),
173
+ "cultura": str(p['cultura']),
174
+ "lucro_estimado": float(p['lucro_estimado']),
175
+ "risco": float(p['risco']),
176
+ "nota": float(p['nota']),
177
+ "tempo": int(p['tempo'])
178
+ }
179
+ for p in resultado_ag['plano']
180
+ ]
181
+ }
182
+
183
+ # Converte tipos numpy para Python nativos
184
+ return {
185
+ "lucro_total": float(resultado_ag['lucro_total']),
186
+ "risco_medio": float(resultado_ag['risco_medio']),
187
+ "fitness": float(resultado_ag['fitness']),
188
+ "diversidade": int(resultado_ag['diversidade']),
189
+ "objetivo": str(resultado_ag['objetivo']),
190
+ "culturas_escolhidas": [str(p['cultura']) for p in resultado_ag['plano']],
191
+ "validacao": {
192
+ "otimo_global": bool(validacao.get('ag_encontrou_otimo_global', False)),
193
+ "total_combinacoes": int(validacao.get('forca_bruta', {}).get('total_combinacoes', 0))
194
+ },
195
+ "plano": [
196
+ {
197
+ "talhao": int(p['talhao']),
198
+ "area": float(p['area']),
199
+ "solo": str(p['solo']),
200
+ "clima": str(p['clima']),
201
+ "relevo": str(p['relevo']),
202
+ "agua": str(p['agua']),
203
+ "cultura": str(p['cultura']),
204
+ "lucro_estimado": float(p['lucro_estimado']),
205
+ "risco": float(p['risco']),
206
+ "nota": float(p['nota']),
207
+ "tempo": int(p['tempo'])
208
+ }
209
+ for p in resultado_ag['plano']
210
+ ]
211
+ }
212
+
213
+ # Usa cache para dashboard
214
+ key = get_cache_key("dashboard", objetivo="equilibrado", seed=42)
215
+ resultado = get_or_compute_cache(key, montar_dashboard)
216
+
217
+ # Converte tipos Python (por segurança)
218
+ return converter_tipos_python(resultado)
219
+ except Exception as e:
220
+ raise HTTPException(status_code=500, detail=str(e))
221
+ except Exception as e:
222
+ raise HTTPException(status_code=500, detail=str(e))
223
+
224
+ @app.get("/talhoes")
225
+ def get_talhoes():
226
+ """Retorna dados dos talhões"""
227
+ try:
228
+ culturas, talhoes, regras = get_dados()
229
+ return {
230
+ "talhoes": talhoes.to_dict('records')
231
+ }
232
+ except Exception as e:
233
+ raise HTTPException(status_code=500, detail=str(e))
234
+
235
+ @app.get("/recomendacoes")
236
+ def get_recomendacoes():
237
+ """Retorna recomendações de culturas por talhão"""
238
+ try:
239
+ culturas, talhoes, regras = get_dados()
240
+
241
+ # Usa AG cacheado para obter recomendações
242
+ resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
243
+
244
+ # Formata recomendações
245
+ recomendacoes = []
246
+ for p in resultado_ag['plano']:
247
+ recomendacoes.append({
248
+ "talhao": int(p['talhao']),
249
+ "cultura": str(p['cultura']),
250
+ "lucro_estimado": float(p['lucro_estimado']),
251
+ "risco": float(p['risco']),
252
+ "nota": float(p['nota']),
253
+ "area": float(p['area']),
254
+ "solo": str(p['solo']),
255
+ "clima": str(p['clima']),
256
+ "relevo": str(p['relevo']),
257
+ "agua": str(p['agua'])
258
+ })
259
+
260
+ return {
261
+ "recomendacoes": recomendacoes
262
+ }
263
+ except Exception as e:
264
+ raise HTTPException(status_code=500, detail=str(e))
265
+
266
+ @app.get("/culturas")
267
+ def get_culturas():
268
+ """Retorna dados das culturas"""
269
+ try:
270
+ culturas, talhoes, regras = get_dados()
271
+
272
+ # Combina culturas com regras
273
+ culturas_dict = culturas.to_dict('records')
274
+ regras_dict = regras.to_dict('records')
275
+
276
+ # Cria dicionário de regras por cultura
277
+ regras_map = {r['cultura']: r for r in regras_dict}
278
+
279
+ # Adiciona regras às culturas
280
+ for cultura in culturas_dict:
281
+ nome = cultura['nome']
282
+ if nome in regras_map:
283
+ cultura['regras'] = regras_map[nome]
284
+
285
+ return {
286
+ "culturas": culturas_dict
287
+ }
288
+ except Exception as e:
289
+ raise HTTPException(status_code=500, detail=str(e))
290
+
291
+ @app.get("/cenarios")
292
+ def get_cenarios():
293
+ """Retorna comparação de cenários"""
294
+ try:
295
+ def montar_cenarios():
296
+ culturas, talhoes, regras = get_dados()
297
+
298
+ # Gera todos os cenários
299
+ cenarios = gerar_cenarios(culturas, talhoes, regras)
300
+
301
+ # Usa AG cacheado
302
+ resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
303
+
304
+ # Cria um mapa de talhões para facilitar o acesso
305
+ talhoes_dict = {int(row['id']): row for _, row in talhoes.iterrows()}
306
+
307
+ # Formata resposta
308
+ cenarios_formatados = {}
309
+
310
+ for key, cenario in cenarios.items():
311
+ cenarios_formatados[key] = {
312
+ 'nome': str(cenario['nome']),
313
+ 'descricao': str(cenario['descricao']),
314
+ 'lucro_total': float(cenario['lucro_total']),
315
+ 'risco_medio': float(cenario['risco_medio']),
316
+ 'area_total': float(cenario['area_total']),
317
+ 'plano': [
318
+ {
319
+ "talhao": int(p['talhao']),
320
+ "area": float(p['area']),
321
+ "solo": str(talhoes_dict[int(p['talhao'])]['solo']),
322
+ "clima": str(talhoes_dict[int(p['talhao'])]['clima']),
323
+ "relevo": str(talhoes_dict[int(p['talhao'])]['relevo']),
324
+ "agua": str(talhoes_dict[int(p['talhao'])]['agua']),
325
+ "cultura": str(p['cultura']),
326
+ "lucro_estimado": float(p['lucro_estimado']),
327
+ "risco": float(p['risco']),
328
+ "nota": float(p['nota']),
329
+ "tempo": 0 # Não disponível nos cenários simples
330
+ }
331
+ for p in cenario['plano']
332
+ ]
333
+ }
334
+
335
+ # Adiciona AG
336
+ cenarios_formatados['genetico'] = {
337
+ 'nome': 'Algoritmo Genético',
338
+ 'descricao': 'Solução otimizada automaticamente',
339
+ 'lucro_total': float(resultado_ag['lucro_total']),
340
+ 'risco_medio': float(resultado_ag['risco_medio']),
341
+ 'area_total': float(resultado_ag['area_total']),
342
+ 'plano': [
343
+ {
344
+ "talhao": int(p['talhao']),
345
+ "area": float(p['area']),
346
+ "solo": str(p['solo']),
347
+ "clima": str(p['clima']),
348
+ "relevo": str(p['relevo']),
349
+ "agua": str(p['agua']),
350
+ "cultura": str(p['cultura']),
351
+ "lucro_estimado": float(p['lucro_estimado']),
352
+ "risco": float(p['risco']),
353
+ "nota": float(p['nota']),
354
+ "tempo": int(p['tempo'])
355
+ }
356
+ for p in resultado_ag['plano']
357
+ ]
358
+ }
359
+
360
+ return {
361
+ "cenarios": cenarios_formatados
362
+ }
363
+
364
+ # Usa cache para cenários
365
+ key = get_cache_key("cenarios")
366
+ resultado = get_or_compute_cache(key, montar_cenarios)
367
+
368
+ return converter_tipos_python(resultado)
369
+ except Exception as e:
370
+ import traceback
371
+ traceback.print_exc()
372
+ raise HTTPException(status_code=500, detail=str(e))
373
+
374
+ @app.post("/otimizar")
375
+ def otimizar(request: OtimizarRequest):
376
+ """Executa otimização com Algoritmo Genético"""
377
+ try:
378
+ # Valida objetivo
379
+ objetivos_validos = ['equilibrado', 'lucro', 'risco', 'sustentavel']
380
+ if request.objetivo not in objetivos_validos:
381
+ raise HTTPException(status_code=400, detail=f"Objetivo inválido. Use: {objetivos_validos}")
382
+
383
+ # Usa AG cacheado se parâmetros forem padrão
384
+ if (request.objetivo == "equilibrado" and
385
+ request.seed == 42 and
386
+ request.geracoes == 100 and
387
+ request.populacao == 50):
388
+ resultado = get_ag_cacheado(
389
+ objetivo=request.objetivo,
390
+ seed=request.seed,
391
+ geracoes=request.geracoes,
392
+ populacao=request.populacao
393
+ )
394
+ else:
395
+ # Executa AG sem cache para parâmetros customizados
396
+ culturas, talhoes, regras = get_dados()
397
+ resultado = gerar_plano_genetico(
398
+ culturas, talhoes, regras,
399
+ objetivo=request.objetivo,
400
+ geracoes=request.geracoes,
401
+ populacao=request.populacao,
402
+ seed=request.seed
403
+ )
404
+
405
+ # Converte tipos numpy para Python nativos
406
+ resultado_convertido = converter_tipos_python(resultado)
407
+
408
+ return resultado_convertido
409
+ except HTTPException:
410
+ raise
411
+ except Exception as e:
412
+ import traceback
413
+ traceback.print_exc()
414
+ raise HTTPException(status_code=500, detail=str(e))
415
+
416
+ @app.post("/validar")
417
+ def validar(request: ValidarRequest):
418
+ """Valida AG com força bruta"""
419
+ try:
420
+ culturas, talhoes, regras = get_dados()
421
+
422
+ # Valida objetivo
423
+ objetivos_validos = ['equilibrado', 'lucro', 'risco', 'sustentavel']
424
+ if request.objetivo not in objetivos_validos:
425
+ raise HTTPException(status_code=400, detail=f"Objetivo inválido. Use: {objetivos_validos}")
426
+
427
+ # Executa validação
428
+ resultado = comparar_ag_com_forca_bruta(
429
+ culturas, talhoes, regras,
430
+ objetivo=request.objetivo,
431
+ seed=request.seed
432
+ )
433
+
434
+ if resultado.get('erro'):
435
+ raise HTTPException(status_code=400, detail=resultado.get('mensagem'))
436
+
437
+ # Converte tipos numpy para Python nativos
438
+ resultado_convertido = converter_tipos_python(resultado)
439
+
440
+ return resultado_convertido
441
+ except HTTPException:
442
+ raise
443
+ except Exception as e:
444
+ raise HTTPException(status_code=500, detail=str(e))
445
+
446
+ @app.post("/rodadas")
447
+ def rodadas(request: RodadasRequest):
448
+ """Executa múltiplas rodadas do AG"""
449
+ try:
450
+ culturas, talhoes, regras = get_dados()
451
+
452
+ # Valida objetivo
453
+ objetivos_validos = ['equilibrado', 'lucro', 'risco', 'sustentavel']
454
+ if request.objetivo not in objetivos_validos:
455
+ raise HTTPException(status_code=400, detail=f"Objetivo inválido. Use: {objetivos_validos}")
456
+
457
+ # Executa rodadas
458
+ resultado = executar_multiplas_rodadas(
459
+ culturas, talhoes, regras,
460
+ objetivo=request.objetivo,
461
+ rodadas=request.rodadas
462
+ )
463
+
464
+ # Converte tipos numpy para Python nativos
465
+ resultado_convertido = converter_tipos_python(resultado)
466
+
467
+ return resultado_convertido
468
+ except HTTPException:
469
+ raise
470
+ except Exception as e:
471
+ raise HTTPException(status_code=500, detail=str(e))
472
+
473
+ @app.post("/relatorio")
474
+ def relatorio(request: RelatorioRequest):
475
+ """Gera relatório"""
476
+ try:
477
+ culturas, talhoes, regras = get_dados()
478
+
479
+ # Valida objetivo
480
+ objetivos_validos = ['equilibrado', 'lucro', 'risco', 'sustentavel']
481
+ if request.objetivo not in objetivos_validos:
482
+ raise HTTPException(status_code=400, detail=f"Objetivo inválido. Use: {objetivos_validos}")
483
+
484
+ # Valida formato
485
+ formatos_validos = ['md', 'txt']
486
+ if request.formato not in formatos_validos:
487
+ raise HTTPException(status_code=400, detail=f"Formato inválido. Use: {formatos_validos}")
488
+
489
+ # Gera relatório
490
+ caminho = gerar_relatorio_completo(
491
+ culturas, talhoes, regras,
492
+ objetivo=request.objetivo,
493
+ formato=request.formato
494
+ )
495
+
496
+ # Lê conteúdo
497
+ with open(caminho, 'r', encoding='utf-8') as f:
498
+ conteudo = f.read()
499
+
500
+ return {
501
+ "caminho": caminho,
502
+ "conteudo": conteudo,
503
+ "formato": request.formato
504
+ }
505
+ except HTTPException:
506
+ raise
507
+ except Exception as e:
508
+ raise HTTPException(status_code=500, detail=str(e))
509
+
510
+ @app.post("/cache/limpar")
511
+ def limpar_cache(request: Request):
512
+ """Limpa o cache de resultados pesados (protegido por token)"""
513
+ # Verifica token de administração
514
+ cache_admin_token = os.getenv("CACHE_ADMIN_TOKEN")
515
+
516
+ if cache_admin_token:
517
+ # Se token está configurado, verifica header
518
+ provided_token = request.headers.get("X-Cache-Token")
519
+ if not provided_token or provided_token != cache_admin_token:
520
+ raise HTTPException(
521
+ status_code=403,
522
+ detail="Token de administração inválido ou ausente. Use header X-Cache-Token."
523
+ )
524
+
525
+ # Limpa cache
526
+ global _resultados_cache
527
+ items_removidos = len(_resultados_cache)
528
+ _resultados_cache.clear()
529
+
530
+ return {
531
+ "status": "ok",
532
+ "message": f"Cache limpo. {items_removidos} itens removidos.",
533
+ "protected": bool(cache_admin_token)
534
+ }
535
+
536
+ if __name__ == "__main__":
537
+ import uvicorn
538
+ uvicorn.run(app, host=HOST, port=PORT)