agroplan-ai-cli 1.0.6 → 1.0.8
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.
- package/backend-template/.env.example +7 -2
- package/backend-template/api.py +46 -2
- package/backend-template/providers/__init__.py +1 -0
- package/backend-template/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend-template/providers/__pycache__/cache.cpython-313.pyc +0 -0
- package/backend-template/providers/__pycache__/types.cpython-313.pyc +0 -0
- package/backend-template/providers/__pycache__/weather_provider.cpython-313.pyc +0 -0
- package/backend-template/providers/cache.py +49 -0
- package/backend-template/providers/types.py +40 -0
- package/backend-template/providers/weather_provider.py +161 -0
- package/dist/index.js +10 -11
- package/package.json +1 -1
|
@@ -12,8 +12,13 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
|
|
12
12
|
|
|
13
13
|
# Data Mode
|
|
14
14
|
# simulated: Uses CSV files with simulated data
|
|
15
|
-
# real: Uses real APIs
|
|
16
|
-
|
|
15
|
+
# real: Uses real APIs only
|
|
16
|
+
# hybrid: Uses real APIs with fallback to simulated data
|
|
17
|
+
DATA_MODE=hybrid
|
|
18
|
+
|
|
19
|
+
# Weather Provider Configuration
|
|
20
|
+
WEATHER_PROVIDER=open-meteo
|
|
21
|
+
PROVIDER_CACHE_TTL=3600
|
|
17
22
|
|
|
18
23
|
# Cache Administration (optional)
|
|
19
24
|
# If set, /cache/limpar endpoint requires X-Cache-Token header
|
package/backend-template/api.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
FastAPI Backend para AgroPlan AI
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from fastapi import FastAPI, HTTPException, Request
|
|
5
|
+
from fastapi import FastAPI, HTTPException, Request, Query
|
|
6
6
|
from fastapi.middleware.cors import CORSMiddleware
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
from typing import Optional
|
|
@@ -17,10 +17,17 @@ from core.planner import gerar_cenarios, gerar_plano_genetico
|
|
|
17
17
|
from core.bruteforce_validator import comparar_ag_com_forca_bruta, executar_multiplas_rodadas
|
|
18
18
|
from core.report_generator import gerar_relatorio_completo
|
|
19
19
|
|
|
20
|
+
# Importar provedores de dados reais
|
|
21
|
+
from providers.weather_provider import get_weather_summary
|
|
22
|
+
from providers.cache import clear_provider_cache, get_cache_stats
|
|
23
|
+
|
|
20
24
|
# Configurações de ambiente
|
|
21
25
|
HOST = os.getenv("HOST", "0.0.0.0")
|
|
22
26
|
PORT = int(os.getenv("PORT", "8000"))
|
|
23
27
|
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
|
28
|
+
DATA_MODE = os.getenv("DATA_MODE", "hybrid") # simulated, real, hybrid
|
|
29
|
+
WEATHER_PROVIDER = os.getenv("WEATHER_PROVIDER", "open-meteo")
|
|
30
|
+
PROVIDER_CACHE_TTL = int(os.getenv("PROVIDER_CACHE_TTL", "3600"))
|
|
24
31
|
|
|
25
32
|
# Cache em memória para resultados pesados
|
|
26
33
|
_resultados_cache = {}
|
|
@@ -126,16 +133,53 @@ def health():
|
|
|
126
133
|
"""Verifica saúde da API"""
|
|
127
134
|
try:
|
|
128
135
|
culturas, talhoes, regras = get_dados()
|
|
136
|
+
provider_cache_stats = get_cache_stats()
|
|
137
|
+
|
|
129
138
|
return {
|
|
130
139
|
"status": "healthy",
|
|
131
140
|
"culturas": len(culturas),
|
|
132
141
|
"talhoes": len(talhoes),
|
|
133
142
|
"regras": len(regras),
|
|
134
|
-
"cache_items": len(_resultados_cache)
|
|
143
|
+
"cache_items": len(_resultados_cache),
|
|
144
|
+
"data_mode": DATA_MODE,
|
|
145
|
+
"providers": {
|
|
146
|
+
"weather": "available" if WEATHER_PROVIDER else "disabled"
|
|
147
|
+
},
|
|
148
|
+
"provider_cache": provider_cache_stats
|
|
135
149
|
}
|
|
136
150
|
except Exception as e:
|
|
137
151
|
raise HTTPException(status_code=500, detail=str(e))
|
|
138
152
|
|
|
153
|
+
@app.get("/dados/clima")
|
|
154
|
+
def get_clima(
|
|
155
|
+
lat: Optional[float] = Query(None, description="Latitude"),
|
|
156
|
+
lon: Optional[float] = Query(None, description="Longitude"),
|
|
157
|
+
days: int = Query(30, description="Número de dias para análise")
|
|
158
|
+
):
|
|
159
|
+
"""Obtém dados climáticos reais ou simulados"""
|
|
160
|
+
try:
|
|
161
|
+
# Se lat ou lon não foram fornecidos, retornar mensagem amigável
|
|
162
|
+
if lat is None or lon is None:
|
|
163
|
+
return {
|
|
164
|
+
"message": "Informe latitude e longitude para consultar dados climáticos reais.",
|
|
165
|
+
"exemplo_sao_paulo": "/dados/clima?lat=-23.55&lon=-46.63&days=30",
|
|
166
|
+
"exemplo_brasilia": "/dados/clima?lat=-15.78&lon=-47.93&days=30",
|
|
167
|
+
"parametros": {
|
|
168
|
+
"lat": "Latitude da localização",
|
|
169
|
+
"lon": "Longitude da localização",
|
|
170
|
+
"days": "Número de dias analisados, padrão 30"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if days < 1 or days > 365:
|
|
175
|
+
raise HTTPException(status_code=400, detail="Days deve estar entre 1 e 365")
|
|
176
|
+
|
|
177
|
+
weather_data = get_weather_summary(lat, lon, days)
|
|
178
|
+
return weather_data.to_dict()
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
182
|
+
|
|
139
183
|
@app.get("/dashboard")
|
|
140
184
|
def get_dashboard():
|
|
141
185
|
"""Retorna resumo do dashboard"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Provedores de dados reais para AgroPlan AI
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache simples em memória para chamadas de APIs externas
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
# Cache global em memória
|
|
8
|
+
_cache = {}
|
|
9
|
+
|
|
10
|
+
def get_cache(key: str) -> Optional[Any]:
|
|
11
|
+
"""Recupera valor do cache se ainda válido"""
|
|
12
|
+
if key not in _cache:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
value, expiry = _cache[key]
|
|
16
|
+
if time.time() > expiry:
|
|
17
|
+
# Cache expirado, remove
|
|
18
|
+
del _cache[key]
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
def set_cache(key: str, value: Any, ttl_seconds: int = 3600) -> None:
|
|
24
|
+
"""Armazena valor no cache com TTL"""
|
|
25
|
+
expiry = time.time() + ttl_seconds
|
|
26
|
+
_cache[key] = (value, expiry)
|
|
27
|
+
|
|
28
|
+
def clear_provider_cache() -> None:
|
|
29
|
+
"""Limpa todo o cache de provedores"""
|
|
30
|
+
global _cache
|
|
31
|
+
_cache = {}
|
|
32
|
+
|
|
33
|
+
def get_cache_stats() -> dict:
|
|
34
|
+
"""Retorna estatísticas do cache"""
|
|
35
|
+
current_time = time.time()
|
|
36
|
+
valid_items = 0
|
|
37
|
+
expired_items = 0
|
|
38
|
+
|
|
39
|
+
for key, (value, expiry) in _cache.items():
|
|
40
|
+
if current_time <= expiry:
|
|
41
|
+
valid_items += 1
|
|
42
|
+
else:
|
|
43
|
+
expired_items += 1
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"total_items": len(_cache),
|
|
47
|
+
"valid_items": valid_items,
|
|
48
|
+
"expired_items": expired_items
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tipos de dados para provedores externos
|
|
3
|
+
"""
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class WeatherSummary:
|
|
9
|
+
"""Resumo de dados climáticos"""
|
|
10
|
+
source: str
|
|
11
|
+
latitude: float
|
|
12
|
+
longitude: float
|
|
13
|
+
temperatura_media: Optional[float] = None
|
|
14
|
+
temperatura_maxima: Optional[float] = None
|
|
15
|
+
temperatura_minima: Optional[float] = None
|
|
16
|
+
precipitacao_total: Optional[float] = None
|
|
17
|
+
evapotranspiracao: Optional[float] = None
|
|
18
|
+
umidade_media: Optional[float] = None
|
|
19
|
+
radiacao_solar: Optional[float] = None
|
|
20
|
+
risco_climatico_estimado: str = "indeterminado"
|
|
21
|
+
fallback: bool = False
|
|
22
|
+
error: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
"""Converte para dicionário para JSON"""
|
|
26
|
+
return {
|
|
27
|
+
"source": self.source,
|
|
28
|
+
"latitude": self.latitude,
|
|
29
|
+
"longitude": self.longitude,
|
|
30
|
+
"temperatura_media": self.temperatura_media,
|
|
31
|
+
"temperatura_maxima": self.temperatura_maxima,
|
|
32
|
+
"temperatura_minima": self.temperatura_minima,
|
|
33
|
+
"precipitacao_total": self.precipitacao_total,
|
|
34
|
+
"evapotranspiracao": self.evapotranspiracao,
|
|
35
|
+
"umidade_media": self.umidade_media,
|
|
36
|
+
"radiacao_solar": self.radiacao_solar,
|
|
37
|
+
"risco_climatico_estimado": self.risco_climatico_estimado,
|
|
38
|
+
"fallback": self.fallback,
|
|
39
|
+
"error": self.error
|
|
40
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provedor de dados climáticos usando Open-Meteo
|
|
3
|
+
"""
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from .types import WeatherSummary
|
|
10
|
+
from .cache import get_cache, set_cache
|
|
11
|
+
|
|
12
|
+
def estimar_risco_climatico(temp_media: Optional[float], precipitacao_total: Optional[float]) -> str:
|
|
13
|
+
"""Estima risco climático baseado em temperatura e precipitação"""
|
|
14
|
+
if temp_media is None or precipitacao_total is None:
|
|
15
|
+
return "indeterminado"
|
|
16
|
+
|
|
17
|
+
# Heurística simples inicial
|
|
18
|
+
if precipitacao_total < 30:
|
|
19
|
+
return "alto"
|
|
20
|
+
if temp_media > 34:
|
|
21
|
+
return "alto"
|
|
22
|
+
if precipitacao_total < 70:
|
|
23
|
+
return "medio"
|
|
24
|
+
return "baixo"
|
|
25
|
+
|
|
26
|
+
def get_weather_summary(lat: float, lon: float, days: int = 30) -> WeatherSummary:
|
|
27
|
+
"""
|
|
28
|
+
Obtém resumo climático usando Open-Meteo
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
lat: Latitude
|
|
32
|
+
lon: Longitude
|
|
33
|
+
days: Número de dias para análise (padrão 30)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
WeatherSummary com dados climáticos ou fallback
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Chave do cache
|
|
40
|
+
cache_key = f"weather_{lat}_{lon}_{days}"
|
|
41
|
+
|
|
42
|
+
# Verificar cache primeiro
|
|
43
|
+
cached = get_cache(cache_key)
|
|
44
|
+
if cached:
|
|
45
|
+
return WeatherSummary(**cached)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Calcular datas (últimos N dias)
|
|
49
|
+
end_date = datetime.now().date()
|
|
50
|
+
start_date = end_date - timedelta(days=days)
|
|
51
|
+
|
|
52
|
+
# Parâmetros da API Open-Meteo Archive
|
|
53
|
+
params = {
|
|
54
|
+
"latitude": str(lat),
|
|
55
|
+
"longitude": str(lon),
|
|
56
|
+
"start_date": start_date.strftime("%Y-%m-%d"),
|
|
57
|
+
"end_date": end_date.strftime("%Y-%m-%d"),
|
|
58
|
+
"daily": ",".join([
|
|
59
|
+
"temperature_2m_mean",
|
|
60
|
+
"temperature_2m_max",
|
|
61
|
+
"temperature_2m_min",
|
|
62
|
+
"precipitation_sum",
|
|
63
|
+
"et0_fao_evapotranspiration",
|
|
64
|
+
"relative_humidity_2m_mean",
|
|
65
|
+
"shortwave_radiation_sum"
|
|
66
|
+
]),
|
|
67
|
+
"timezone": "America/Sao_Paulo"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Construir URL
|
|
71
|
+
base_url = "https://archive-api.open-meteo.com/v1/archive"
|
|
72
|
+
query_string = urllib.parse.urlencode(params)
|
|
73
|
+
url = f"{base_url}?{query_string}"
|
|
74
|
+
|
|
75
|
+
# Fazer requisição
|
|
76
|
+
with urllib.request.urlopen(url, timeout=10) as response:
|
|
77
|
+
data = json.loads(response.read().decode())
|
|
78
|
+
|
|
79
|
+
daily = data.get("daily", {})
|
|
80
|
+
|
|
81
|
+
# Extrair dados
|
|
82
|
+
temps_mean = daily.get("temperature_2m_mean", [])
|
|
83
|
+
temps_max = daily.get("temperature_2m_max", [])
|
|
84
|
+
temps_min = daily.get("temperature_2m_min", [])
|
|
85
|
+
precipitation = daily.get("precipitation_sum", [])
|
|
86
|
+
evapotranspiration = daily.get("et0_fao_evapotranspiration", [])
|
|
87
|
+
humidity = daily.get("relative_humidity_2m_mean", [])
|
|
88
|
+
radiation = daily.get("shortwave_radiation_sum", [])
|
|
89
|
+
|
|
90
|
+
# Calcular médias (ignorando valores None)
|
|
91
|
+
def safe_mean(values):
|
|
92
|
+
valid_values = [v for v in values if v is not None]
|
|
93
|
+
return sum(valid_values) / len(valid_values) if valid_values else None
|
|
94
|
+
|
|
95
|
+
def safe_sum(values):
|
|
96
|
+
valid_values = [v for v in values if v is not None]
|
|
97
|
+
return sum(valid_values) if valid_values else None
|
|
98
|
+
|
|
99
|
+
temp_media = safe_mean(temps_mean)
|
|
100
|
+
temp_maxima = safe_mean(temps_max)
|
|
101
|
+
temp_minima = safe_mean(temps_min)
|
|
102
|
+
precip_total = safe_sum(precipitation)
|
|
103
|
+
evap_media = safe_mean(evapotranspiration)
|
|
104
|
+
umid_media = safe_mean(humidity)
|
|
105
|
+
rad_total = safe_sum(radiation)
|
|
106
|
+
|
|
107
|
+
# Estimar risco climático
|
|
108
|
+
risco = estimar_risco_climatico(temp_media, precip_total)
|
|
109
|
+
|
|
110
|
+
# Criar resultado
|
|
111
|
+
result = WeatherSummary(
|
|
112
|
+
source="open-meteo",
|
|
113
|
+
latitude=lat,
|
|
114
|
+
longitude=lon,
|
|
115
|
+
temperatura_media=round(temp_media, 1) if temp_media else None,
|
|
116
|
+
temperatura_maxima=round(temp_maxima, 1) if temp_maxima else None,
|
|
117
|
+
temperatura_minima=round(temp_minima, 1) if temp_minima else None,
|
|
118
|
+
precipitacao_total=round(precip_total, 1) if precip_total else None,
|
|
119
|
+
evapotranspiracao=round(evap_media, 2) if evap_media else None,
|
|
120
|
+
umidade_media=round(umid_media, 1) if umid_media else None,
|
|
121
|
+
radiacao_solar=round(rad_total, 1) if rad_total else None,
|
|
122
|
+
risco_climatico_estimado=risco,
|
|
123
|
+
fallback=False,
|
|
124
|
+
error=None
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Cachear por 1 hora (dados históricos mudam pouco)
|
|
128
|
+
set_cache(cache_key, result.to_dict(), ttl_seconds=3600)
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
# Fallback para dados simulados
|
|
134
|
+
return _get_fallback_weather(lat, lon, str(e))
|
|
135
|
+
|
|
136
|
+
def _get_fallback_weather(lat: float, lon: float, error: str) -> WeatherSummary:
|
|
137
|
+
"""Retorna dados climáticos simulados como fallback"""
|
|
138
|
+
|
|
139
|
+
# Dados simulados baseados na localização (heurística simples)
|
|
140
|
+
# Região Sudeste do Brasil como referência
|
|
141
|
+
temp_base = 22.0
|
|
142
|
+
if lat < -25: # Mais ao sul, mais frio
|
|
143
|
+
temp_base = 18.0
|
|
144
|
+
elif lat > -20: # Mais ao norte, mais quente
|
|
145
|
+
temp_base = 26.0
|
|
146
|
+
|
|
147
|
+
return WeatherSummary(
|
|
148
|
+
source="simulado",
|
|
149
|
+
latitude=lat,
|
|
150
|
+
longitude=lon,
|
|
151
|
+
temperatura_media=temp_base,
|
|
152
|
+
temperatura_maxima=temp_base + 8,
|
|
153
|
+
temperatura_minima=temp_base - 6,
|
|
154
|
+
precipitacao_total=120.0, # mm/mês típico
|
|
155
|
+
evapotranspiracao=4.5, # mm/dia típico
|
|
156
|
+
umidade_media=65.0, # % típico
|
|
157
|
+
radiacao_solar=180.0, # MJ/m² típico mensal
|
|
158
|
+
risco_climatico_estimado="medio",
|
|
159
|
+
fallback=True,
|
|
160
|
+
error=f"Open-Meteo indisponível: {error}"
|
|
161
|
+
)
|
package/dist/index.js
CHANGED
|
@@ -358,7 +358,7 @@ Correja os problemas acima:`);
|
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
// src/commands/serve.ts
|
|
361
|
-
import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
|
|
361
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3, openSync, closeSync } from "fs";
|
|
362
362
|
import { spawn } from "child_process";
|
|
363
363
|
async function serveOnCommand() {
|
|
364
364
|
console.log(`\uD83D\uDE80 Iniciando API local do AgroPlan AI...
|
|
@@ -421,6 +421,8 @@ async function serveOnCommand() {
|
|
|
421
421
|
return;
|
|
422
422
|
}
|
|
423
423
|
console.log("\uD83C\uDF10 Iniciando servidor uvicorn...");
|
|
424
|
+
const out = openSync(paths.logFile, "a");
|
|
425
|
+
const err = openSync(paths.logFile, "a");
|
|
424
426
|
const child = spawn(uvicornPath, [
|
|
425
427
|
"api:app",
|
|
426
428
|
"--host",
|
|
@@ -432,20 +434,16 @@ async function serveOnCommand() {
|
|
|
432
434
|
], {
|
|
433
435
|
cwd: paths.backend,
|
|
434
436
|
detached: true,
|
|
435
|
-
|
|
437
|
+
windowsHide: true,
|
|
438
|
+
stdio: ["ignore", out, err],
|
|
436
439
|
env: {
|
|
437
440
|
...process.env,
|
|
438
441
|
CORS_ORIGINS: "https://agroplan-ai.vercel.app,http://localhost:3000,http://127.0.0.1:3000"
|
|
439
442
|
}
|
|
440
443
|
});
|
|
441
444
|
savePid(child.pid);
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
logStream.write(data);
|
|
445
|
-
});
|
|
446
|
-
child.stderr?.on("data", (data) => {
|
|
447
|
-
logStream.write(data);
|
|
448
|
-
});
|
|
445
|
+
closeSync(out);
|
|
446
|
+
closeSync(err);
|
|
449
447
|
child.unref();
|
|
450
448
|
console.log("\u23F3 Aguardando inicializa\xE7\xE3o...");
|
|
451
449
|
let attempts = 0;
|
|
@@ -459,7 +457,8 @@ async function serveOnCommand() {
|
|
|
459
457
|
console.log(" URL: http://localhost:8000");
|
|
460
458
|
console.log(" Health: http://localhost:8000/health");
|
|
461
459
|
console.log(`
|
|
462
|
-
\
|
|
460
|
+
\uD83C\uDFAF API rodando em segundo plano - voc\xEA pode fechar este terminal!`);
|
|
461
|
+
console.log("\uD83D\uDCA1 Use 'agroplan serve off' para parar");
|
|
463
462
|
return;
|
|
464
463
|
}
|
|
465
464
|
attempts++;
|
|
@@ -780,7 +779,7 @@ var COMMANDS = {
|
|
|
780
779
|
open: "Abre o AgroPlan AI no navegador"
|
|
781
780
|
};
|
|
782
781
|
function showHelp() {
|
|
783
|
-
console.log("\uD83C\uDF31 AgroPlan AI - CLI Local v1.0.
|
|
782
|
+
console.log("\uD83C\uDF31 AgroPlan AI - CLI Local v1.0.8");
|
|
784
783
|
console.log(` Launcher para modo local acelerado
|
|
785
784
|
`);
|
|
786
785
|
console.log("\uD83D\uDCCB Comandos dispon\xEDveis:");
|