agroplan-ai-cli 1.0.15 → 1.0.18

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.
@@ -16,6 +16,11 @@ ZARC_CACHE_DIR = os.path.join(os.path.dirname(__file__), '..', 'data', 'zarc')
16
16
  ZARC_CACHE_TTL = int(os.getenv("ZARC_CACHE_TTL", "86400")) # 24 horas
17
17
  ZARC_SOURCE = os.getenv("ZARC_SOURCE", "official") # official, fallback
18
18
  ZARC_SAFRA_DEFAULT = os.getenv("ZARC_SAFRA", "2025/2026")
19
+ ZARC_FAST_INDEX_ENABLED = os.getenv("ZARC_FAST_INDEX_ENABLED", "true").lower() == "true"
20
+ ZARC_ALLOW_FULL_SCAN = os.getenv("ZARC_ALLOW_FULL_SCAN", "false").lower() == "true"
21
+
22
+ # Cache do índice em memória (pequeno, pode ficar em RAM)
23
+ _zarc_index_cache = {}
19
24
 
20
25
  # URLs oficiais do Portal de Dados Abertos do Ministério da Agricultura
21
26
  ZARC_URLS = {
@@ -314,163 +319,275 @@ def is_cache_valid(cache_path: str) -> bool:
314
319
  file_age = datetime.now() - datetime.fromtimestamp(os.path.getmtime(cache_path))
315
320
  return file_age.total_seconds() < ZARC_CACHE_TTL
316
321
 
317
- def download_zarc_dataset(safra: str) -> Optional[str]:
322
+ def get_zarc_status(safra: str = ZARC_SAFRA_DEFAULT) -> Dict[str, Any]:
318
323
  """
319
- Baixa dataset ZARC oficial
324
+ Retorna status do ZARC sem carregar dados
325
+
326
+ MEMORY SAFE: Não carrega CSV, apenas verifica arquivos
320
327
 
321
328
  Returns:
322
- Caminho do arquivo baixado ou None se falhar
329
+ Status do ZARC (configuração, cache, etc)
323
330
  """
331
+ cache_path = get_cache_path(safra)
332
+
333
+ # Verificar índice
334
+ safra_filename = safra.replace("/", "-")
335
+ index_path = os.path.join(ZARC_CACHE_DIR, f"zarc_index_{safra_filename}.json")
336
+
337
+ status = {
338
+ "status": "configured",
339
+ "safra": safra,
340
+ "source": ZARC_SOURCE,
341
+ "fast_index": ZARC_FAST_INDEX_ENABLED,
342
+ "full_scan": ZARC_ALLOW_FULL_SCAN,
343
+ "index_exists": os.path.exists(index_path),
344
+ "cache_exists": os.path.exists(cache_path),
345
+ "cache_valid": False,
346
+ "cache_size_mb": 0
347
+ }
348
+
349
+ if os.path.exists(cache_path):
350
+ try:
351
+ # Tamanho do arquivo em MB
352
+ size_bytes = os.path.getsize(cache_path)
353
+ status["cache_size_mb"] = round(size_bytes / (1024 * 1024), 2)
354
+
355
+ # Verificar se cache é válido
356
+ status["cache_valid"] = is_cache_valid(cache_path)
357
+ except Exception:
358
+ pass
359
+
360
+ return status
361
+
362
+ def load_zarc_index(safra: str = ZARC_SAFRA_DEFAULT) -> Optional[Dict[str, Any]]:
363
+ """
364
+ Carrega índice ZARC compacto em memória
365
+
366
+ MEMORY SAFE: Índice é pequeno (~35KB), pode ficar em RAM
367
+
368
+ Returns:
369
+ Índice ZARC ou None se não existir
370
+ """
371
+ global _zarc_index_cache
372
+
373
+ # Verificar cache em memória
374
+ if safra in _zarc_index_cache:
375
+ return _zarc_index_cache[safra]
376
+
377
+ # Carregar do arquivo
378
+ safra_filename = safra.replace("/", "-")
379
+ index_path = os.path.join(ZARC_CACHE_DIR, f"zarc_index_{safra_filename}.json")
380
+
381
+ if not os.path.exists(index_path):
382
+ return None
383
+
324
384
  try:
325
- url = ZARC_URLS.get(safra)
326
- if not url:
327
- print(f"URL não disponível para safra {safra}")
328
- return None
385
+ with open(index_path, 'r', encoding='utf-8') as f:
386
+ index = json.load(f)
329
387
 
330
- cache_path = get_cache_path(safra)
388
+ # Cachear em memória
389
+ _zarc_index_cache[safra] = index
331
390
 
332
- print(f"Baixando ZARC oficial de {url}...")
333
-
334
- # Criar request com User-Agent
335
- req = urllib.request.Request(
336
- url,
337
- headers={
338
- 'User-Agent': 'AgroPlan-AI/1.0 (https://github.com/Kuuhaku-Allan/agroplan-ai)'
339
- }
340
- )
391
+ return index
392
+ except Exception as e:
393
+ print(f"Erro ao carregar índice ZARC: {e}")
394
+ return None
395
+
396
+ def buscar_zarc_indexado(
397
+ cultura: str,
398
+ uf: Optional[str] = None,
399
+ municipio: Optional[str] = None,
400
+ solo: Optional[str] = None,
401
+ safra: str = ZARC_SAFRA_DEFAULT
402
+ ) -> Optional[Dict[str, Any]]:
403
+ """
404
+ Busca ZARC no índice compacto (rápido)
405
+
406
+ PERFORMANCE: Lookup O(1) em vez de O(n) no CSV
407
+
408
+ Returns:
409
+ Dados ZARC ou None se não encontrar no índice
410
+ """
411
+ if not ZARC_FAST_INDEX_ENABLED:
412
+ return None
413
+
414
+ index = load_zarc_index(safra)
415
+ if not index:
416
+ return None
417
+
418
+ # Normalizar parâmetros
419
+ cultura_norm = normalizar_cultura(cultura)
420
+
421
+ if not uf or not municipio:
422
+ return None
423
+
424
+ uf_norm = normalizar_uf(uf)
425
+ municipio_norm = normalizar_municipio(municipio)
426
+
427
+ # Tentar diferentes combinações de solo
428
+ solos_tentar = []
429
+ if solo:
430
+ solo_norm = normalizar_solo(solo)
431
+ # Tentar o solo especificado primeiro, depois outros como fallback
432
+ solos_tentar = [solo_norm, "medio", "arenoso", "argiloso", "misto"]
433
+ # Remover duplicatas mantendo ordem
434
+ seen = set()
435
+ solos_tentar = [s for s in solos_tentar if not (s in seen or seen.add(s))]
436
+ else:
437
+ # Se não especificou solo, tentar todos (preferir medio/argiloso)
438
+ solos_tentar = ["medio", "argiloso", "arenoso", "misto"]
439
+
440
+ # Buscar no índice
441
+ for solo_test in solos_tentar:
442
+ # Chave: UF|municipio|cultura|solo
443
+ chave = f"{uf_norm}|{municipio_norm}|{cultura_norm}|{solo_test}"
341
444
 
342
- # Download
343
- with urllib.request.urlopen(req, timeout=30) as response:
344
- content = response.read().decode('utf-8')
445
+ if chave in index["records"]:
446
+ return index["records"][chave]
447
+
448
+ return None
449
+
450
+ def iter_zarc_records(file_path: str):
451
+ """
452
+ Itera sobre registros ZARC em streaming
453
+
454
+ MEMORY SAFE: Usa yield para processar linha por linha
455
+
456
+ Args:
457
+ file_path: Caminho do arquivo CSV
458
+
459
+ Yields:
460
+ Dicionário com dados de cada linha
461
+ """
462
+ with open(file_path, 'r', encoding='utf-8-sig', newline='') as f:
463
+ # Detectar delimitador
464
+ primeira_linha = f.readline()
465
+ f.seek(0)
345
466
 
346
- # Salvar
347
- with open(cache_path, 'w', encoding='utf-8') as f:
348
- f.write(content)
467
+ delimiter = ';' if ';' in primeira_linha else ','
349
468
 
350
- print(f"ZARC oficial baixado e salvo em {cache_path}")
351
- return cache_path
469
+ reader = csv.DictReader(f, delimiter=delimiter)
352
470
 
353
- except Exception as e:
354
- print(f"Erro ao baixar ZARC oficial: {e}")
355
- return None
471
+ for row in reader:
472
+ yield row
356
473
 
357
- def get_zarc_dataset(safra: str = ZARC_SAFRA_DEFAULT) -> Dict[str, Any]:
474
+ def ensure_zarc_file(safra: str = ZARC_SAFRA_DEFAULT) -> Optional[Dict[str, Any]]:
358
475
  """
359
- Obtém dataset ZARC (cache ou download)
476
+ Garante que arquivo ZARC existe, baixando se necessário
477
+
478
+ MEMORY SAFE: Não carrega registros, apenas gerencia arquivo
360
479
 
361
480
  Returns:
362
- Dicionário com:
363
- - records: Lista de registros ZARC
364
- - source: "zarc-oficial" | "zarc-cache" | "zarc-fallback"
365
- - fallback: bool
366
- - cache_path: str ou None
367
- - error: str ou None
481
+ Metadata do arquivo ou None se não disponível
368
482
  """
369
483
  cache_path = get_cache_path(safra)
370
484
 
371
485
  # Verificar cache válido
372
486
  if is_cache_valid(cache_path):
373
- try:
374
- records = load_zarc_from_file(cache_path)
375
- return {
376
- "records": records,
377
- "source": "zarc-cache",
378
- "fallback": False,
379
- "cache_path": cache_path,
380
- "error": None
381
- }
382
- except Exception as e:
383
- print(f"Erro ao carregar cache ZARC: {e}")
487
+ return {
488
+ "file_path": cache_path,
489
+ "source": "zarc-cache",
490
+ "fallback": False,
491
+ "error": None
492
+ }
384
493
 
385
494
  # Tentar download se source for official
386
495
  if ZARC_SOURCE == "official":
387
- downloaded_path = download_zarc_dataset(safra)
388
- if downloaded_path:
496
+ url = ZARC_URLS.get(safra)
497
+ if url:
389
498
  try:
390
- records = load_zarc_from_file(downloaded_path)
499
+ # Criar request com User-Agent
500
+ req = urllib.request.Request(
501
+ url,
502
+ headers={
503
+ 'User-Agent': 'AgroPlan-AI/1.0 (https://github.com/Kuuhaku-Allan/agroplan-ai)'
504
+ }
505
+ )
506
+
507
+ # Download
508
+ with urllib.request.urlopen(req, timeout=30) as response:
509
+ content = response.read().decode('utf-8')
510
+
511
+ # Salvar
512
+ with open(cache_path, 'w', encoding='utf-8') as f:
513
+ f.write(content)
514
+
391
515
  return {
392
- "records": records,
516
+ "file_path": cache_path,
393
517
  "source": "zarc-oficial",
394
518
  "fallback": False,
395
- "cache_path": downloaded_path,
396
519
  "error": None
397
520
  }
398
521
  except Exception as e:
399
- print(f"Erro ao carregar ZARC baixado: {e}")
522
+ # Se download falhar, tentar usar cache antigo
523
+ if os.path.exists(cache_path):
524
+ return {
525
+ "file_path": cache_path,
526
+ "source": "zarc-cache",
527
+ "fallback": False,
528
+ "error": f"Download falhou, usando cache antigo: {str(e)}"
529
+ }
400
530
 
401
531
  # Usar cache antigo se existir (mesmo expirado)
402
532
  if os.path.exists(cache_path):
403
- try:
404
- records = load_zarc_from_file(cache_path)
405
- return {
406
- "records": records,
407
- "source": "zarc-cache",
408
- "fallback": False,
409
- "cache_path": cache_path,
410
- "error": "Cache expirado mas usado"
411
- }
412
- except Exception as e:
413
- print(f"Erro ao carregar cache antigo: {e}")
533
+ return {
534
+ "file_path": cache_path,
535
+ "source": "zarc-cache",
536
+ "fallback": False,
537
+ "error": "Cache expirado mas usado"
538
+ }
414
539
 
415
- # Fallback para dados simplificados
416
- print("Usando fallback ZARC simplificado")
417
- return {
418
- "records": get_zarc_fallback(),
419
- "source": "zarc-fallback",
420
- "fallback": True,
421
- "cache_path": None,
422
- "error": "CSV oficial não disponível, usando dados simplificados"
423
- }
540
+ # Nenhum arquivo disponível
541
+ return None
542
+
543
+ # REMOVIDO: Funções obsoletas que carregavam CSV inteiro em memória
544
+ # Essas funções foram removidas para evitar problemas de memória
545
+ # Use: ensure_zarc_file() + iter_zarc_records() + buscar_zarc_indexado()
424
546
 
425
- def load_zarc_from_file(file_path: str) -> List[Dict[str, Any]]:
426
- """Carrega dados ZARC de arquivo CSV"""
427
- registros = []
547
+ def download_zarc_dataset(*args, **kwargs):
548
+ """
549
+ REMOVIDO: Esta função carregava o CSV inteiro em memória (214 MB).
428
550
 
429
- with open(file_path, 'r', encoding='utf-8-sig') as f: # utf-8-sig remove BOM
430
- # Detectar delimitador (CSV oficial usa ponto-e-vírgula)
431
- primeira_linha = f.readline()
432
- f.seek(0)
433
-
434
- delimiter = ';' if ';' in primeira_linha else ','
435
-
436
- reader = csv.DictReader(f, delimiter=delimiter)
437
-
438
- # Log das colunas encontradas (primeira vez)
439
- if reader.fieldnames:
440
- print(f"Colunas ZARC encontradas ({len(reader.fieldnames)} colunas, delimiter='{delimiter}')")
441
-
442
- for row in reader:
443
- registros.append(row)
551
+ Use: ensure_zarc_file() para gerenciar o arquivo sem carregar dados
552
+ """
553
+ raise RuntimeError(
554
+ "download_zarc_dataset() foi removido por causar problemas de memória. "
555
+ "Use ensure_zarc_file() para gerenciar o arquivo ZARC."
556
+ )
557
+
558
+ def get_zarc_dataset(*args, **kwargs):
559
+ """
560
+ REMOVIDO: Esta função carregava 1M+ registros em memória.
444
561
 
445
- return registros
562
+ Use: buscar_zarc_indexado() para lookup rápido O(1)
563
+ Use: iter_zarc_records() para processar em streaming
564
+ """
565
+ raise RuntimeError(
566
+ "get_zarc_dataset() foi removido por carregar 1M+ registros em memória. "
567
+ "Use buscar_zarc_indexado() para lookup rápido ou iter_zarc_records() para streaming."
568
+ )
446
569
 
447
- def inspect_zarc_columns(safra: str = ZARC_SAFRA_DEFAULT) -> Optional[List[str]]:
570
+ def load_zarc_from_file(*args, **kwargs):
448
571
  """
449
- Inspeciona colunas do CSV ZARC oficial
572
+ REMOVIDO: Esta função carregava o CSV inteiro em uma lista.
450
573
 
451
- Returns:
452
- Lista de nomes de colunas ou None se falhar
574
+ Use: iter_zarc_records() para processar linha por linha
453
575
  """
454
- try:
455
- dataset = get_zarc_dataset(safra)
456
-
457
- if not dataset or not dataset.get("records"):
458
- print("Nenhum registro ZARC disponível")
459
- return None
460
-
461
- # Pegar colunas do primeiro registro
462
- if dataset["records"]:
463
- colunas = list(dataset["records"][0].keys())
464
- print(f"\nColunas do CSV ZARC ({dataset['source']}):")
465
- for i, col in enumerate(colunas, 1):
466
- print(f" {i}. {col}")
467
- return colunas
468
-
469
- return None
470
-
471
- except Exception as e:
472
- print(f"Erro ao inspecionar colunas ZARC: {e}")
473
- return None
576
+ raise RuntimeError(
577
+ "load_zarc_from_file() foi removido por carregar CSV inteiro em lista. "
578
+ "Use iter_zarc_records() para processar linha por linha."
579
+ )
580
+
581
+ def inspect_zarc_columns(*args, **kwargs):
582
+ """
583
+ REMOVIDO: Esta função dependia de get_zarc_dataset().
584
+
585
+ Use: iter_zarc_records() e inspecione a primeira linha
586
+ """
587
+ raise RuntimeError(
588
+ "inspect_zarc_columns() foi removido por depender de get_zarc_dataset(). "
589
+ "Use iter_zarc_records() e inspecione a primeira linha."
590
+ )
474
591
 
475
592
  def get_zarc_fallback() -> List[Dict[str, Any]]:
476
593
  """
@@ -576,6 +693,8 @@ def buscar_zarc(
576
693
  """
577
694
  Busca dados ZARC para cultura/região específica
578
695
 
696
+ PERFORMANCE: Tenta índice primeiro (rápido), depois streaming (lento)
697
+
579
698
  Args:
580
699
  cultura: Nome da cultura
581
700
  uf: Unidade Federativa (opcional)
@@ -586,27 +705,51 @@ def buscar_zarc(
586
705
  Returns:
587
706
  Dicionário com dados ZARC ou None se não encontrar
588
707
  """
589
- dataset_info = get_zarc_dataset(safra)
590
- if not dataset_info or not dataset_info.get("records"):
591
- return None
592
-
593
- dataset = dataset_info["records"]
594
- source = dataset_info["source"]
595
- is_fallback = dataset_info["fallback"]
708
+ # FAST PATH: Tentar índice primeiro (O(1) lookup)
709
+ if ZARC_FAST_INDEX_ENABLED:
710
+ resultado_indexado = buscar_zarc_indexado(cultura, uf, municipio, solo, safra)
711
+ if resultado_indexado:
712
+ return resultado_indexado
713
+
714
+ # SLOW PATH: Full scan no CSV (apenas se permitido)
715
+ if not ZARC_ALLOW_FULL_SCAN:
716
+ # Não encontrou no índice e full scan não é permitido
717
+ # Tentar fallback
718
+ return buscar_zarc_fallback(cultura, uf, municipio, solo, safra)
719
+
720
+ # Full scan permitido (desenvolvimento local)
721
+ return buscar_zarc_streaming(cultura, uf, municipio, solo, safra)
722
+
723
+ def buscar_zarc_streaming(
724
+ cultura: str,
725
+ uf: Optional[str] = None,
726
+ municipio: Optional[str] = None,
727
+ solo: Optional[str] = None,
728
+ safra: str = ZARC_SAFRA_DEFAULT
729
+ ) -> Optional[Dict[str, Any]]:
730
+ """
731
+ Busca ZARC usando streaming no CSV (LENTO mas memory-safe)
596
732
 
733
+ PERFORMANCE: O(n) - varre todo o CSV
734
+ Usar apenas em desenvolvimento ou quando índice não disponível
735
+ """
597
736
  # Normalizar parâmetros de busca
598
737
  cultura_norm = normalizar_cultura(cultura)
599
738
  uf_norm = normalizar_uf(uf) if uf else None
600
739
  municipio_norm = normalizar_municipio(municipio) if municipio else None
601
740
  solo_norm = normalizar_solo(solo) if solo else None
602
741
 
603
- # Se estiver usando dados oficiais, processar decêndios
604
- if not is_fallback:
605
- # Buscar no CSV oficial
742
+ # Tentar obter arquivo ZARC
743
+ file_info = ensure_zarc_file(safra)
744
+
745
+ if file_info:
746
+ # Usar arquivo oficial/cache com streaming
747
+ source = file_info["source"]
606
748
  melhor_match = None
607
749
  melhor_score = 0
608
750
 
609
- for registro in dataset:
751
+ # Processar CSV em streaming (linha por linha)
752
+ for registro in iter_zarc_records(file_info["file_path"]):
610
753
  score = 0
611
754
 
612
755
  # Cultura deve bater
@@ -628,9 +771,10 @@ def buscar_zarc(
628
771
  if normalizar_solo(solo_registro) == solo_norm:
629
772
  score += 2
630
773
 
774
+ # Manter apenas o melhor match (não acumula lista)
631
775
  if score > melhor_score:
632
776
  melhor_score = score
633
- melhor_match = registro
777
+ melhor_match = registro.copy() # Copia apenas este registro
634
778
 
635
779
  if melhor_match:
636
780
  # Extrair janelas de plantio dos decêndios
@@ -684,11 +828,31 @@ def buscar_zarc(
684
828
  "message": "Nenhuma recomendação ZARC encontrada para os parâmetros informados."
685
829
  }
686
830
 
687
- # Fallback: usar dados simplificados
831
+ # Arquivo não disponível, usar fallback
832
+ return buscar_zarc_fallback(cultura, uf, municipio, solo, safra)
833
+
834
+ def buscar_zarc_fallback(
835
+ cultura: str,
836
+ uf: Optional[str] = None,
837
+ municipio: Optional[str] = None,
838
+ solo: Optional[str] = None,
839
+ safra: str = ZARC_SAFRA_DEFAULT
840
+ ) -> Optional[Dict[str, Any]]:
841
+ """
842
+ Busca ZARC em dados simplificados (fallback)
843
+ """
844
+ # Normalizar parâmetros
845
+ cultura_norm = normalizar_cultura(cultura)
846
+ uf_norm = normalizar_uf(uf) if uf else None
847
+ municipio_norm = normalizar_municipio(municipio) if municipio else None
848
+ solo_norm = normalizar_solo(solo) if solo else None
849
+
850
+ # Fallback: usar dados simplificados (lista pequena em memória)
851
+ fallback_data = get_zarc_fallback()
688
852
  melhor_match = None
689
853
  melhor_score = 0
690
854
 
691
- for registro in dataset:
855
+ for registro in fallback_data:
692
856
  score = 0
693
857
 
694
858
  # Cultura deve bater