agroplan-ai-cli 1.0.30 → 1.0.31

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "cli_version": "1.0.30",
3
- "backend_template_version": "1.0.30",
2
+ "cli_version": "1.0.31",
3
+ "backend_template_version": "1.0.31",
4
4
  "zarc_index_version": "2025-2026-fast-index-v2",
5
5
  "price_index_version": "2025-05-reference-v1",
6
6
  "features": [
@@ -18,7 +18,9 @@
18
18
  "market_profit_confidence_refinement",
19
19
  "market_profit_comparative_evaluation",
20
20
  "market_profit_experimental_optimizer",
21
- "smart_crop_calendar_engine"
21
+ "smart_crop_calendar_engine",
22
+ "manual_field_registration",
23
+ "crop_calendar_from_manual_field"
22
24
  ],
23
- "generated_at": "2026-05-10T15:30:00Z"
25
+ "generated_at": "2026-05-10T16:15:00Z"
24
26
  }
@@ -1417,6 +1417,189 @@ def obter_cultura_info(cultura: str):
1417
1417
  except Exception as e:
1418
1418
  raise HTTPException(status_code=500, detail=str(e))
1419
1419
 
1420
+ # Endpoints de Talhões Manuais
1421
+
1422
+ @app.get("/planejamento/talhoes")
1423
+ def listar_talhoes():
1424
+ """Lista todos os talhões cadastrados pelo usuário"""
1425
+ try:
1426
+ from core.field_storage import listar_talhoes_usuario
1427
+
1428
+ fields = listar_talhoes_usuario()
1429
+
1430
+ return {
1431
+ "total": len(fields),
1432
+ "talhoes": fields
1433
+ }
1434
+ except Exception as e:
1435
+ raise HTTPException(status_code=500, detail=str(e))
1436
+
1437
+ @app.post("/planejamento/talhoes")
1438
+ def criar_talhao(field_data: dict):
1439
+ """Cria um novo talhão"""
1440
+ try:
1441
+ from core.field_storage import criar_talhao_usuario
1442
+ from core.planning_models import ManualFieldCreate
1443
+
1444
+ # Validar dados
1445
+ validated = ManualFieldCreate(**field_data)
1446
+
1447
+ # Criar talhão
1448
+ new_field = criar_talhao_usuario(validated.model_dump())
1449
+
1450
+ return new_field
1451
+ except ValueError as e:
1452
+ raise HTTPException(status_code=400, detail=str(e))
1453
+ except Exception as e:
1454
+ raise HTTPException(status_code=500, detail=str(e))
1455
+
1456
+ @app.get("/planejamento/talhoes/{field_id}")
1457
+ def obter_talhao(field_id: str):
1458
+ """Obtém um talhão pelo ID"""
1459
+ try:
1460
+ from core.field_storage import obter_talhao_usuario
1461
+
1462
+ field = obter_talhao_usuario(field_id)
1463
+
1464
+ if not field:
1465
+ raise HTTPException(
1466
+ status_code=404,
1467
+ detail=f"Talhão '{field_id}' não encontrado"
1468
+ )
1469
+
1470
+ return field
1471
+ except HTTPException:
1472
+ raise
1473
+ except Exception as e:
1474
+ raise HTTPException(status_code=500, detail=str(e))
1475
+
1476
+ @app.put("/planejamento/talhoes/{field_id}")
1477
+ def atualizar_talhao(field_id: str, field_data: dict):
1478
+ """Atualiza um talhão existente"""
1479
+ try:
1480
+ from core.field_storage import atualizar_talhao_usuario
1481
+ from core.planning_models import ManualFieldUpdate
1482
+
1483
+ # Validar dados
1484
+ validated = ManualFieldUpdate(**field_data)
1485
+
1486
+ # Atualizar apenas campos fornecidos
1487
+ update_data = {k: v for k, v in validated.model_dump().items() if v is not None}
1488
+
1489
+ # Atualizar talhão
1490
+ updated_field = atualizar_talhao_usuario(field_id, update_data)
1491
+
1492
+ if not updated_field:
1493
+ raise HTTPException(
1494
+ status_code=404,
1495
+ detail=f"Talhão '{field_id}' não encontrado"
1496
+ )
1497
+
1498
+ return updated_field
1499
+ except HTTPException:
1500
+ raise
1501
+ except ValueError as e:
1502
+ raise HTTPException(status_code=400, detail=str(e))
1503
+ except Exception as e:
1504
+ raise HTTPException(status_code=500, detail=str(e))
1505
+
1506
+ @app.delete("/planejamento/talhoes/{field_id}")
1507
+ def remover_talhao(field_id: str):
1508
+ """Remove um talhão"""
1509
+ try:
1510
+ from core.field_storage import remover_talhao_usuario
1511
+
1512
+ removed = remover_talhao_usuario(field_id)
1513
+
1514
+ if not removed:
1515
+ raise HTTPException(
1516
+ status_code=404,
1517
+ detail=f"Talhão '{field_id}' não encontrado"
1518
+ )
1519
+
1520
+ return {
1521
+ "message": "Talhão removido com sucesso",
1522
+ "id": field_id
1523
+ }
1524
+ except HTTPException:
1525
+ raise
1526
+ except Exception as e:
1527
+ raise HTTPException(status_code=500, detail=str(e))
1528
+
1529
+ @app.post("/planejamento/talhoes/{field_id}/calendario")
1530
+ def gerar_calendario_talhao(field_id: str, request: dict):
1531
+ """Gera calendário agrícola para um talhão cadastrado"""
1532
+ try:
1533
+ from core.field_storage import obter_talhao_usuario
1534
+ from core.crop_calendar_engine import gerar_calendario_cultura
1535
+ from core.planning_models import Field, SoilType, Slope, WaterAvailability, GenerateCalendarRequest
1536
+ from datetime import datetime
1537
+
1538
+ # Validar request
1539
+ validated = GenerateCalendarRequest(**request)
1540
+
1541
+ # Buscar talhão
1542
+ field_data = obter_talhao_usuario(field_id)
1543
+
1544
+ if not field_data:
1545
+ raise HTTPException(
1546
+ status_code=404,
1547
+ detail=f"Talhão '{field_id}' não encontrado"
1548
+ )
1549
+
1550
+ # Parsear data de plantio
1551
+ try:
1552
+ planting_date = datetime.fromisoformat(validated.planting_date).date()
1553
+ except Exception:
1554
+ raise HTTPException(
1555
+ status_code=400,
1556
+ detail="Formato de data inválido. Use ISO 8601 (YYYY-MM-DD)"
1557
+ )
1558
+
1559
+ # Criar objeto Field
1560
+ field = Field(
1561
+ id=field_data["id"],
1562
+ property_id="user",
1563
+ name=field_data["name"],
1564
+ area_ha=field_data["area_ha"],
1565
+ soil_type=SoilType(field_data["soil_type"]),
1566
+ slope=Slope(field_data["slope"]),
1567
+ water_availability=WaterAvailability(field_data["water_availability"])
1568
+ )
1569
+
1570
+ # Gerar calendário
1571
+ resultado = gerar_calendario_cultura(
1572
+ cultura=validated.cultura,
1573
+ planting_date=planting_date,
1574
+ field=field,
1575
+ crop_plan_id=None
1576
+ )
1577
+
1578
+ # Adicionar dados do talhão na resposta
1579
+ resultado["field_data"] = field_data
1580
+
1581
+ return converter_tipos_python(resultado)
1582
+
1583
+ except HTTPException:
1584
+ raise
1585
+ except ValueError as e:
1586
+ raise HTTPException(status_code=400, detail=str(e))
1587
+ except Exception as e:
1588
+ import traceback
1589
+ if DEBUG_ERRORS:
1590
+ raise HTTPException(
1591
+ status_code=500,
1592
+ detail={
1593
+ "error": str(e),
1594
+ "traceback": traceback.format_exc()
1595
+ }
1596
+ )
1597
+ else:
1598
+ raise HTTPException(
1599
+ status_code=500,
1600
+ detail="Erro ao gerar calendário para o talhão."
1601
+ )
1602
+
1420
1603
  @app.post("/cache/limpar")
1421
1604
  def limpar_cache(request: Request):
1422
1605
  """Limpa o cache de resultados pesados (protegido por token)"""
@@ -0,0 +1,175 @@
1
+ """
2
+ Storage simples para talhões do usuário
3
+
4
+ Persistência em JSON local:
5
+ - API Local: dados persistem no PC do usuário (~/.agroplan/backend/data/user_fields/)
6
+ - API Render: dados temporários/voláteis (perdem-se ao reiniciar)
7
+
8
+ Fase futura: Migrar para banco de dados PostgreSQL
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import uuid
14
+ from datetime import datetime
15
+ from typing import List, Dict, Optional
16
+ from pathlib import Path
17
+
18
+
19
+ # Diretório de dados
20
+ DATA_DIR = Path(__file__).parent.parent / "data" / "user_fields"
21
+ FIELDS_FILE = DATA_DIR / "fields.json"
22
+
23
+
24
+ def _ensure_data_dir():
25
+ """Garante que o diretório de dados existe"""
26
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+ # Criar arquivo vazio se não existir
29
+ if not FIELDS_FILE.exists():
30
+ with open(FIELDS_FILE, 'w', encoding='utf-8') as f:
31
+ json.dump([], f)
32
+
33
+
34
+ def _load_fields() -> List[Dict]:
35
+ """Carrega todos os talhões do arquivo JSON"""
36
+ _ensure_data_dir()
37
+
38
+ try:
39
+ with open(FIELDS_FILE, 'r', encoding='utf-8') as f:
40
+ return json.load(f)
41
+ except (json.JSONDecodeError, FileNotFoundError):
42
+ return []
43
+
44
+
45
+ def _save_fields(fields: List[Dict]):
46
+ """
47
+ Salva talhões no arquivo JSON com escrita segura.
48
+
49
+ Usa arquivo temporário + rename para evitar corrupção.
50
+ """
51
+ _ensure_data_dir()
52
+
53
+ # Escrever em arquivo temporário
54
+ temp_file = FIELDS_FILE.with_suffix('.tmp')
55
+
56
+ try:
57
+ with open(temp_file, 'w', encoding='utf-8') as f:
58
+ json.dump(fields, f, indent=2, ensure_ascii=False)
59
+
60
+ # Renomear atomicamente
61
+ temp_file.replace(FIELDS_FILE)
62
+ except Exception as e:
63
+ # Limpar arquivo temporário em caso de erro
64
+ if temp_file.exists():
65
+ temp_file.unlink()
66
+ raise e
67
+
68
+
69
+ def listar_talhoes_usuario() -> List[Dict]:
70
+ """
71
+ Lista todos os talhões do usuário.
72
+
73
+ Returns:
74
+ Lista de talhões
75
+ """
76
+ return _load_fields()
77
+
78
+
79
+ def criar_talhao_usuario(data: Dict) -> Dict:
80
+ """
81
+ Cria um novo talhão.
82
+
83
+ Args:
84
+ data: Dados do talhão (sem id, created_at, updated_at)
85
+
86
+ Returns:
87
+ Talhão criado com id e timestamps
88
+ """
89
+ fields = _load_fields()
90
+
91
+ # Gerar novo talhão
92
+ now = datetime.now().isoformat()
93
+ new_field = {
94
+ "id": str(uuid.uuid4()),
95
+ **data,
96
+ "created_at": now,
97
+ "updated_at": now
98
+ }
99
+
100
+ fields.append(new_field)
101
+ _save_fields(fields)
102
+
103
+ return new_field
104
+
105
+
106
+ def obter_talhao_usuario(field_id: str) -> Optional[Dict]:
107
+ """
108
+ Obtém um talhão pelo ID.
109
+
110
+ Args:
111
+ field_id: ID do talhão
112
+
113
+ Returns:
114
+ Talhão ou None se não encontrado
115
+ """
116
+ fields = _load_fields()
117
+
118
+ for field in fields:
119
+ if field.get("id") == field_id:
120
+ return field
121
+
122
+ return None
123
+
124
+
125
+ def atualizar_talhao_usuario(field_id: str, data: Dict) -> Optional[Dict]:
126
+ """
127
+ Atualiza um talhão existente.
128
+
129
+ Args:
130
+ field_id: ID do talhão
131
+ data: Novos dados (sem id, created_at, updated_at)
132
+
133
+ Returns:
134
+ Talhão atualizado ou None se não encontrado
135
+ """
136
+ fields = _load_fields()
137
+
138
+ for i, field in enumerate(fields):
139
+ if field.get("id") == field_id:
140
+ # Preservar id e created_at, atualizar updated_at
141
+ updated_field = {
142
+ "id": field["id"],
143
+ **data,
144
+ "created_at": field["created_at"],
145
+ "updated_at": datetime.now().isoformat()
146
+ }
147
+
148
+ fields[i] = updated_field
149
+ _save_fields(fields)
150
+
151
+ return updated_field
152
+
153
+ return None
154
+
155
+
156
+ def remover_talhao_usuario(field_id: str) -> bool:
157
+ """
158
+ Remove um talhão.
159
+
160
+ Args:
161
+ field_id: ID do talhão
162
+
163
+ Returns:
164
+ True se removido, False se não encontrado
165
+ """
166
+ fields = _load_fields()
167
+
168
+ original_length = len(fields)
169
+ fields = [f for f in fields if f.get("id") != field_id]
170
+
171
+ if len(fields) < original_length:
172
+ _save_fields(fields)
173
+ return True
174
+
175
+ return False
@@ -11,12 +11,18 @@ Define as entidades principais do sistema de planejamento agrícola:
11
11
  - UserObservation: Observação do usuário
12
12
  - Intervention: Intervenção/replanejamento
13
13
  - PlanningSession: Sessão de planejamento
14
+
15
+ Modelos Pydantic para API:
16
+ - ManualFieldCreate: Criação de talhão manual
17
+ - ManualFieldUpdate: Atualização de talhão manual
18
+ - ManualFieldResponse: Resposta de talhão manual
14
19
  """
15
20
 
16
21
  from dataclasses import dataclass, field
17
22
  from datetime import date, datetime
18
23
  from typing import Optional, List, Dict
19
24
  from enum import Enum
25
+ from pydantic import BaseModel, Field as PydanticField, field_validator
20
26
 
21
27
 
22
28
  # Enums para tipos padronizados
@@ -364,3 +370,115 @@ class PlanningSession:
364
370
  "cultures_recommended": self.cultures_recommended,
365
371
  "created_at": self.created_at.isoformat()
366
372
  }
373
+
374
+
375
+ # Modelos Pydantic para API
376
+
377
+ class ManualFieldCreate(BaseModel):
378
+ """Modelo para criação de talhão manual"""
379
+ name: str = PydanticField(..., min_length=1, max_length=100, description="Nome do talhão")
380
+ area_ha: float = PydanticField(..., gt=0, description="Área em hectares")
381
+ soil_type: str = PydanticField(..., description="Tipo de solo")
382
+ slope: str = PydanticField(..., description="Tipo de relevo")
383
+ water_availability: str = PydanticField(..., description="Disponibilidade de água")
384
+ uf: Optional[str] = PydanticField(None, min_length=2, max_length=2, description="UF")
385
+ municipio: Optional[str] = PydanticField(None, max_length=100, description="Município")
386
+ lat: Optional[float] = PydanticField(None, ge=-90, le=90, description="Latitude")
387
+ lon: Optional[float] = PydanticField(None, ge=-180, le=180, description="Longitude")
388
+
389
+ @field_validator('soil_type')
390
+ @classmethod
391
+ def validate_soil_type(cls, v: str) -> str:
392
+ allowed = ['argiloso', 'arenoso', 'misto', 'siltoso']
393
+ if v not in allowed:
394
+ raise ValueError(f'soil_type deve ser um de: {", ".join(allowed)}')
395
+ return v
396
+
397
+ @field_validator('slope')
398
+ @classmethod
399
+ def validate_slope(cls, v: str) -> str:
400
+ allowed = ['plano', 'suave', 'moderado', 'ingreme']
401
+ if v not in allowed:
402
+ raise ValueError(f'slope deve ser um de: {", ".join(allowed)}')
403
+ return v
404
+
405
+ @field_validator('water_availability')
406
+ @classmethod
407
+ def validate_water_availability(cls, v: str) -> str:
408
+ allowed = ['baixa', 'media', 'alta']
409
+ if v not in allowed:
410
+ raise ValueError(f'water_availability deve ser um de: {", ".join(allowed)}')
411
+ return v
412
+
413
+
414
+ class ManualFieldUpdate(BaseModel):
415
+ """Modelo para atualização de talhão manual"""
416
+ name: Optional[str] = PydanticField(None, min_length=1, max_length=100)
417
+ area_ha: Optional[float] = PydanticField(None, gt=0)
418
+ soil_type: Optional[str] = None
419
+ slope: Optional[str] = None
420
+ water_availability: Optional[str] = None
421
+ uf: Optional[str] = PydanticField(None, min_length=2, max_length=2)
422
+ municipio: Optional[str] = PydanticField(None, max_length=100)
423
+ lat: Optional[float] = PydanticField(None, ge=-90, le=90)
424
+ lon: Optional[float] = PydanticField(None, ge=-180, le=180)
425
+
426
+ @field_validator('soil_type')
427
+ @classmethod
428
+ def validate_soil_type(cls, v: Optional[str]) -> Optional[str]:
429
+ if v is not None:
430
+ allowed = ['argiloso', 'arenoso', 'misto', 'siltoso']
431
+ if v not in allowed:
432
+ raise ValueError(f'soil_type deve ser um de: {", ".join(allowed)}')
433
+ return v
434
+
435
+ @field_validator('slope')
436
+ @classmethod
437
+ def validate_slope(cls, v: Optional[str]) -> Optional[str]:
438
+ if v is not None:
439
+ allowed = ['plano', 'suave', 'moderado', 'ingreme']
440
+ if v not in allowed:
441
+ raise ValueError(f'slope deve ser um de: {", ".join(allowed)}')
442
+ return v
443
+
444
+ @field_validator('water_availability')
445
+ @classmethod
446
+ def validate_water_availability(cls, v: Optional[str]) -> Optional[str]:
447
+ if v is not None:
448
+ allowed = ['baixa', 'media', 'alta']
449
+ if v not in allowed:
450
+ raise ValueError(f'water_availability deve ser um de: {", ".join(allowed)}')
451
+ return v
452
+
453
+
454
+ class ManualFieldResponse(BaseModel):
455
+ """Modelo de resposta de talhão manual"""
456
+ id: str
457
+ name: str
458
+ area_ha: float
459
+ soil_type: str
460
+ slope: str
461
+ water_availability: str
462
+ uf: Optional[str] = None
463
+ municipio: Optional[str] = None
464
+ lat: Optional[float] = None
465
+ lon: Optional[float] = None
466
+ created_at: str
467
+ updated_at: str
468
+
469
+ class Config:
470
+ from_attributes = True
471
+
472
+
473
+ class GenerateCalendarRequest(BaseModel):
474
+ """Modelo para geração de calendário"""
475
+ cultura: str = PydanticField(..., description="Nome da cultura")
476
+ planting_date: str = PydanticField(..., description="Data de plantio (YYYY-MM-DD)")
477
+
478
+ @field_validator('cultura')
479
+ @classmethod
480
+ def validate_cultura(cls, v: str) -> str:
481
+ allowed = ['soja', 'milho', 'feijao']
482
+ if v not in allowed:
483
+ raise ValueError(f'cultura deve ser uma de: {", ".join(allowed)}')
484
+ return v
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroplan-ai-cli",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "CLI global para AgroPlan AI - modo local acelerado",
5
5
  "type": "module",
6
6
  "bin": {