agroplan-ai-cli 1.0.15 → 1.0.17

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,7 +319,242 @@ 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]:
323
+ """
324
+ Retorna status do ZARC sem carregar dados
325
+
326
+ MEMORY SAFE: Não carrega CSV, apenas verifica arquivos
327
+
328
+ Returns:
329
+ Status do ZARC (configuração, cache, etc)
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
+
384
+ try:
385
+ with open(index_path, 'r', encoding='utf-8') as f:
386
+ index = json.load(f)
387
+
388
+ # Cachear em memória
389
+ _zarc_index_cache[safra] = index
390
+
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}"
444
+
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)
466
+
467
+ delimiter = ';' if ';' in primeira_linha else ','
468
+
469
+ reader = csv.DictReader(f, delimiter=delimiter)
470
+
471
+ for row in reader:
472
+ yield row
473
+
474
+ def ensure_zarc_file(safra: str = ZARC_SAFRA_DEFAULT) -> Optional[Dict[str, Any]]:
475
+ """
476
+ Garante que arquivo ZARC existe, baixando se necessário
477
+
478
+ MEMORY SAFE: Não carrega registros, apenas gerencia arquivo
479
+
480
+ Returns:
481
+ Metadata do arquivo ou None se não disponível
482
+ """
483
+ cache_path = get_cache_path(safra)
484
+
485
+ # Verificar cache válido
486
+ if is_cache_valid(cache_path):
487
+ return {
488
+ "file_path": cache_path,
489
+ "source": "zarc-cache",
490
+ "fallback": False,
491
+ "error": None
492
+ }
493
+
494
+ # Tentar download se source for official
495
+ if ZARC_SOURCE == "official":
496
+ url = ZARC_URLS.get(safra)
497
+ if url:
498
+ try:
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
+
515
+ return {
516
+ "file_path": cache_path,
517
+ "source": "zarc-oficial",
518
+ "fallback": False,
519
+ "error": None
520
+ }
521
+ except Exception as 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
+ }
530
+
531
+ # Usar cache antigo se existir (mesmo expirado)
532
+ if os.path.exists(cache_path):
533
+ return {
534
+ "file_path": cache_path,
535
+ "source": "zarc-cache",
536
+ "fallback": False,
537
+ "error": "Cache expirado mas usado"
538
+ }
539
+
540
+ # Nenhum arquivo disponível
541
+ return None
542
+
543
+ # OBSOLETO: Funções antigas que carregavam CSV inteiro em memória
544
+ # Mantidas apenas para referência, não devem ser usadas
545
+ # Use: ensure_zarc_file() + iter_zarc_records() + buscar_zarc()
546
+
547
+ # def download_zarc_dataset(safra: str) -> Optional[str]:
548
+ # """OBSOLETO - Não usar, causa problemas de memória"""
549
+ # pass
550
+
551
+ # def get_zarc_dataset(safra: str = ZARC_SAFRA_DEFAULT) -> Dict[str, Any]:
552
+ # """OBSOLETO - Não usar, carrega 1M+ registros em memória"""
553
+ # pass
554
+
555
+ # def load_zarc_from_file(file_path: str) -> List[Dict[str, Any]]:
556
+ # """OBSOLETO - Não usar, carrega CSV inteiro em lista"""
557
+ # pass
318
558
  """
319
559
  Baixa dataset ZARC oficial
320
560
 
@@ -576,6 +816,8 @@ def buscar_zarc(
576
816
  """
577
817
  Busca dados ZARC para cultura/região específica
578
818
 
819
+ PERFORMANCE: Tenta índice primeiro (rápido), depois streaming (lento)
820
+
579
821
  Args:
580
822
  cultura: Nome da cultura
581
823
  uf: Unidade Federativa (opcional)
@@ -586,27 +828,51 @@ def buscar_zarc(
586
828
  Returns:
587
829
  Dicionário com dados ZARC ou None se não encontrar
588
830
  """
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"]
831
+ # FAST PATH: Tentar índice primeiro (O(1) lookup)
832
+ if ZARC_FAST_INDEX_ENABLED:
833
+ resultado_indexado = buscar_zarc_indexado(cultura, uf, municipio, solo, safra)
834
+ if resultado_indexado:
835
+ return resultado_indexado
836
+
837
+ # SLOW PATH: Full scan no CSV (apenas se permitido)
838
+ if not ZARC_ALLOW_FULL_SCAN:
839
+ # Não encontrou no índice e full scan não é permitido
840
+ # Tentar fallback
841
+ return buscar_zarc_fallback(cultura, uf, municipio, solo, safra)
842
+
843
+ # Full scan permitido (desenvolvimento local)
844
+ return buscar_zarc_streaming(cultura, uf, municipio, solo, safra)
845
+
846
+ def buscar_zarc_streaming(
847
+ cultura: str,
848
+ uf: Optional[str] = None,
849
+ municipio: Optional[str] = None,
850
+ solo: Optional[str] = None,
851
+ safra: str = ZARC_SAFRA_DEFAULT
852
+ ) -> Optional[Dict[str, Any]]:
853
+ """
854
+ Busca ZARC usando streaming no CSV (LENTO mas memory-safe)
596
855
 
856
+ PERFORMANCE: O(n) - varre todo o CSV
857
+ Usar apenas em desenvolvimento ou quando índice não disponível
858
+ """
597
859
  # Normalizar parâmetros de busca
598
860
  cultura_norm = normalizar_cultura(cultura)
599
861
  uf_norm = normalizar_uf(uf) if uf else None
600
862
  municipio_norm = normalizar_municipio(municipio) if municipio else None
601
863
  solo_norm = normalizar_solo(solo) if solo else None
602
864
 
603
- # Se estiver usando dados oficiais, processar decêndios
604
- if not is_fallback:
605
- # Buscar no CSV oficial
865
+ # Tentar obter arquivo ZARC
866
+ file_info = ensure_zarc_file(safra)
867
+
868
+ if file_info:
869
+ # Usar arquivo oficial/cache com streaming
870
+ source = file_info["source"]
606
871
  melhor_match = None
607
872
  melhor_score = 0
608
873
 
609
- for registro in dataset:
874
+ # Processar CSV em streaming (linha por linha)
875
+ for registro in iter_zarc_records(file_info["file_path"]):
610
876
  score = 0
611
877
 
612
878
  # Cultura deve bater
@@ -628,9 +894,10 @@ def buscar_zarc(
628
894
  if normalizar_solo(solo_registro) == solo_norm:
629
895
  score += 2
630
896
 
897
+ # Manter apenas o melhor match (não acumula lista)
631
898
  if score > melhor_score:
632
899
  melhor_score = score
633
- melhor_match = registro
900
+ melhor_match = registro.copy() # Copia apenas este registro
634
901
 
635
902
  if melhor_match:
636
903
  # Extrair janelas de plantio dos decêndios
@@ -684,11 +951,31 @@ def buscar_zarc(
684
951
  "message": "Nenhuma recomendação ZARC encontrada para os parâmetros informados."
685
952
  }
686
953
 
687
- # Fallback: usar dados simplificados
954
+ # Arquivo não disponível, usar fallback
955
+ return buscar_zarc_fallback(cultura, uf, municipio, solo, safra)
956
+
957
+ def buscar_zarc_fallback(
958
+ cultura: str,
959
+ uf: Optional[str] = None,
960
+ municipio: Optional[str] = None,
961
+ solo: Optional[str] = None,
962
+ safra: str = ZARC_SAFRA_DEFAULT
963
+ ) -> Optional[Dict[str, Any]]:
964
+ """
965
+ Busca ZARC em dados simplificados (fallback)
966
+ """
967
+ # Normalizar parâmetros
968
+ cultura_norm = normalizar_cultura(cultura)
969
+ uf_norm = normalizar_uf(uf) if uf else None
970
+ municipio_norm = normalizar_municipio(municipio) if municipio else None
971
+ solo_norm = normalizar_solo(solo) if solo else None
972
+
973
+ # Fallback: usar dados simplificados (lista pequena em memória)
974
+ fallback_data = get_zarc_fallback()
688
975
  melhor_match = None
689
976
  melhor_score = 0
690
977
 
691
- for registro in dataset:
978
+ for registro in fallback_data:
692
979
  score = 0
693
980
 
694
981
  # Cultura deve bater
@@ -0,0 +1,256 @@
1
+ """
2
+ Script para construir índice ZARC compacto
3
+
4
+ Processa o CSV oficial ZARC e gera um índice JSON pequeno
5
+ contendo apenas as regiões e culturas de interesse do AgroPlan.
6
+
7
+ Uso:
8
+ python scripts/build_zarc_index.py
9
+ """
10
+ import sys
11
+ import os
12
+ import json
13
+ from datetime import datetime
14
+
15
+ # Adicionar diretório pai ao path para importar providers
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
17
+
18
+ from providers.zarc_provider import (
19
+ ensure_zarc_file,
20
+ iter_zarc_records,
21
+ normalizar_cultura,
22
+ normalizar_municipio,
23
+ normalizar_uf,
24
+ normalizar_solo,
25
+ mapear_codigo_solo,
26
+ extrair_janelas_plantio,
27
+ escolher_melhor_janela,
28
+ ZARC_CACHE_DIR
29
+ )
30
+
31
+ # Regiões de interesse
32
+ REGIOES_INTERESSE = [
33
+ {"uf": "SP", "municipio": "Clementina"},
34
+ {"uf": "SP", "municipio": "São Paulo"},
35
+ {"uf": "SP", "municipio": "Ribeirão Preto"},
36
+ {"uf": "MS", "municipio": "Campo Grande"},
37
+ {"uf": "PR", "municipio": "Londrina"},
38
+ {"uf": "DF", "municipio": "Brasília"},
39
+ ]
40
+
41
+ # Culturas de interesse
42
+ CULTURAS_INTERESSE = [
43
+ "soja",
44
+ "milho",
45
+ "feijao",
46
+ "trigo",
47
+ "algodao",
48
+ "cafe",
49
+ "cana",
50
+ "arroz",
51
+ "sorgo",
52
+ "mandioca"
53
+ ]
54
+
55
+ # Solos de interesse
56
+ SOLOS_INTERESSE = ["arenoso", "medio", "argiloso", "misto"]
57
+
58
+ def build_zarc_index(safra: str = "2025/2026"):
59
+ """
60
+ Constrói índice ZARC compacto
61
+
62
+ Args:
63
+ safra: Safra para processar
64
+ """
65
+ print(f"🌾 Construindo índice ZARC para safra {safra}...")
66
+ print()
67
+
68
+ # Garantir que arquivo ZARC existe
69
+ file_info = ensure_zarc_file(safra)
70
+
71
+ if not file_info:
72
+ print("❌ Erro: Arquivo ZARC não disponível")
73
+ return False
74
+
75
+ print(f"✅ Arquivo ZARC: {file_info['file_path']}")
76
+ print(f" Fonte: {file_info['source']}")
77
+ print()
78
+
79
+ # Normalizar regiões de interesse
80
+ regioes_norm = []
81
+ for regiao in REGIOES_INTERESSE:
82
+ regioes_norm.append({
83
+ "uf": normalizar_uf(regiao["uf"]),
84
+ "municipio": normalizar_municipio(regiao["municipio"]),
85
+ "municipio_original": regiao["municipio"]
86
+ })
87
+
88
+ # Normalizar culturas de interesse
89
+ culturas_norm = [normalizar_cultura(c) for c in CULTURAS_INTERESSE]
90
+
91
+ print("📍 Regiões de interesse:")
92
+ for r in regioes_norm:
93
+ print(f" - {r['municipio_original']}/{r['uf']}")
94
+ print()
95
+
96
+ print("🌱 Culturas de interesse:")
97
+ for c in CULTURAS_INTERESSE:
98
+ print(f" - {c}")
99
+ print()
100
+
101
+ # Processar CSV em streaming
102
+ print("🔄 Processando CSV oficial...")
103
+
104
+ index_records = {}
105
+ registros_processados = 0
106
+ registros_incluidos = 0
107
+
108
+ for registro in iter_zarc_records(file_info['file_path']):
109
+ registros_processados += 1
110
+
111
+ # Mostrar progresso a cada 100k registros
112
+ if registros_processados % 100000 == 0:
113
+ print(f" Processados: {registros_processados:,} registros...")
114
+
115
+ # Verificar se é cultura de interesse
116
+ cultura_csv = normalizar_cultura(registro.get("Nome_cultura", ""))
117
+ if cultura_csv not in culturas_norm:
118
+ continue
119
+
120
+ # Verificar se é região de interesse
121
+ uf_csv = normalizar_uf(registro.get("UF", ""))
122
+ municipio_csv = normalizar_municipio(registro.get("municipio", ""))
123
+
124
+ regiao_match = None
125
+ for regiao in regioes_norm:
126
+ if uf_csv == regiao["uf"] and municipio_csv == regiao["municipio"]:
127
+ regiao_match = regiao
128
+ break
129
+
130
+ if not regiao_match:
131
+ continue
132
+
133
+ # Solo
134
+ solo_codigo = registro.get("Cod_Solo", "")
135
+ solo_nome = mapear_codigo_solo(solo_codigo)
136
+ solo_norm = normalizar_solo(solo_nome)
137
+
138
+ if solo_norm not in SOLOS_INTERESSE and solo_norm != "desconhecido":
139
+ continue
140
+
141
+ # Extrair janelas de plantio
142
+ janelas = extrair_janelas_plantio(registro)
143
+ melhor_janela = escolher_melhor_janela(janelas)
144
+
145
+ if not melhor_janela:
146
+ # Sem janelas válidas, pular
147
+ continue
148
+
149
+ # Criar chave: UF|municipio|cultura|solo
150
+ chave = f"{uf_csv}|{municipio_csv}|{cultura_csv}|{solo_norm}"
151
+
152
+ # Se já existe, manter o de menor risco
153
+ if chave in index_records:
154
+ risco_atual = index_records[chave]["risco"]
155
+ risco_novo = melhor_janela["risco_predominante"]
156
+
157
+ ordem_risco = {"baixo": 1, "medio": 2, "alto": 3}
158
+
159
+ if ordem_risco.get(risco_novo, 999) < ordem_risco.get(risco_atual, 999):
160
+ # Novo tem risco menor, substituir
161
+ pass
162
+ else:
163
+ # Manter o atual
164
+ continue
165
+
166
+ # Adicionar ao índice
167
+ index_records[chave] = {
168
+ "source": "zarc-oficial-derived",
169
+ "fallback": False,
170
+ "cultura": registro.get("Nome_cultura"),
171
+ "uf": uf_csv.upper(),
172
+ "municipio": regiao_match["municipio_original"],
173
+ "solo": solo_nome,
174
+ "safra": safra,
175
+ "janela_plantio": {
176
+ "inicio": melhor_janela["inicio"],
177
+ "fim": melhor_janela["fim"]
178
+ },
179
+ "risco": melhor_janela["risco_predominante"],
180
+ "decendios_recomendados": melhor_janela["decendios"],
181
+ "geocodigo": registro.get("geocodigo", ""),
182
+ "encontrado": True,
183
+ "observacao": "Dados derivados da Tábua de Risco oficial do ZARC."
184
+ }
185
+
186
+ registros_incluidos += 1
187
+
188
+ print(f"✅ Processamento concluído!")
189
+ print(f" Total processado: {registros_processados:,} registros")
190
+ print(f" Incluídos no índice: {registros_incluidos} registros")
191
+ print()
192
+
193
+ # Criar estrutura do índice
194
+ index = {
195
+ "metadata": {
196
+ "source": "zarc-oficial-derived",
197
+ "safra": safra,
198
+ "generated_at": datetime.now().isoformat(),
199
+ "generated_from": file_info["source"],
200
+ "regions": [f"{r['municipio_original']}/{r['uf']}" for r in regioes_norm],
201
+ "cultures": CULTURAS_INTERESSE,
202
+ "soils": SOLOS_INTERESSE,
203
+ "total_records": registros_incluidos
204
+ },
205
+ "records": index_records
206
+ }
207
+
208
+ # Salvar índice
209
+ safra_filename = safra.replace("/", "-")
210
+ index_path = os.path.join(ZARC_CACHE_DIR, f"zarc_index_{safra_filename}.json")
211
+
212
+ with open(index_path, 'w', encoding='utf-8') as f:
213
+ json.dump(index, f, ensure_ascii=False, indent=2)
214
+
215
+ # Calcular tamanho
216
+ size_bytes = os.path.getsize(index_path)
217
+ size_kb = size_bytes / 1024
218
+
219
+ print(f"💾 Índice salvo em: {index_path}")
220
+ print(f" Tamanho: {size_kb:.2f} KB")
221
+ print()
222
+
223
+ # Estatísticas por região
224
+ print("📊 Estatísticas por região:")
225
+ for regiao in regioes_norm:
226
+ count = sum(1 for k in index_records.keys()
227
+ if k.startswith(f"{regiao['uf']}|{regiao['municipio']}|"))
228
+ print(f" {regiao['municipio_original']}/{regiao['uf']}: {count} registros")
229
+ print()
230
+
231
+ # Estatísticas por cultura
232
+ print("📊 Estatísticas por cultura:")
233
+ for cultura in culturas_norm:
234
+ count = sum(1 for k in index_records.keys()
235
+ if f"|{cultura}|" in k)
236
+ print(f" {cultura}: {count} registros")
237
+ print()
238
+
239
+ print("✅ Índice ZARC construído com sucesso!")
240
+ return True
241
+
242
+ if __name__ == "__main__":
243
+ import argparse
244
+
245
+ parser = argparse.ArgumentParser(description="Construir índice ZARC compacto")
246
+ parser.add_argument(
247
+ "--safra",
248
+ default="2025/2026",
249
+ help="Safra para processar (padrão: 2025/2026)"
250
+ )
251
+
252
+ args = parser.parse_args()
253
+
254
+ success = build_zarc_index(args.safra)
255
+
256
+ sys.exit(0 if success else 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroplan-ai-cli",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "CLI global para AgroPlan AI - modo local acelerado",
5
5
  "type": "module",
6
6
  "bin": {