UPDATE
This commit is contained in:
+2
-2
@@ -130,9 +130,9 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None)
|
||||
|
||||
|
||||
def _format_farm_context(farm_uuid: str) -> str:
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if not farm_details:
|
||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any
|
||||
from django.apps import apps
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name
|
||||
from farm_data.services import (
|
||||
build_ai_farm_snapshot,
|
||||
clone_snapshot_as_runtime_plant,
|
||||
get_ai_snapshot_weather,
|
||||
get_farm_plant_snapshot_by_name,
|
||||
)
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -628,6 +633,7 @@ def get_fertilization_recommendation(
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
|
||||
|
||||
plant_config = apps.get_app_config("plant")
|
||||
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
|
||||
@@ -662,6 +668,7 @@ def get_fertilization_recommendation(
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
growth_stage=resolved_growth_stage,
|
||||
ai_snapshot=ai_snapshot,
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
@@ -727,6 +734,9 @@ def get_fertilization_recommendation(
|
||||
growth_stage=resolved_growth_stage,
|
||||
forecasts=forecasts,
|
||||
)
|
||||
result.setdefault("source_metadata", {})
|
||||
result["source_metadata"]["farm_metrics"] = (ai_snapshot or {}).get("source_metadata", {}).get("farm_metrics", {})
|
||||
result["source_metadata"]["weather"] = {"source": "center_location_forecast", "policy": "center_location_latest_forecast"}
|
||||
result = _validate_fertilization_response(result)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -124,7 +125,14 @@ class FertilizationPlanParserService:
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "fertilization_plan_parser",
|
||||
"endpoint_policy": "parser_first",
|
||||
}
|
||||
if farm_uuid:
|
||||
# Parser-first endpoint: farm context is optional enrichment only.
|
||||
structured_context["farm_context_source_metadata"] = {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"optional": True,
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
|
||||
+17
-10
@@ -11,7 +11,10 @@ from django.db import transaction
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import (
|
||||
build_ai_farm_snapshot,
|
||||
clone_snapshot_as_runtime_plant,
|
||||
get_ai_snapshot_metric,
|
||||
get_ai_snapshot_weather,
|
||||
get_farm_plant_snapshot_by_name,
|
||||
)
|
||||
from irrigation.evapotranspiration import (
|
||||
@@ -55,13 +58,15 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
|
||||
if sensor is None or not isinstance(sensor.sensor_payload, dict):
|
||||
return None
|
||||
for payload in sensor.sensor_payload.values():
|
||||
if isinstance(payload, dict) and payload.get(metric) is not None:
|
||||
return _safe_float(payload.get(metric), default=0.0)
|
||||
return None
|
||||
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||
value = get_ai_snapshot_metric(ai_snapshot, metric)
|
||||
return _safe_float(value, default=0.0) if value is not None else None
|
||||
|
||||
|
||||
|
||||
def _aggregated_metric_fallback(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||
"""Limited fallback for missing aggregated metrics only; raw payload is intentionally not consulted."""
|
||||
return _aggregated_metric(ai_snapshot, metric)
|
||||
|
||||
|
||||
def _coerce_list(value: Any) -> list[Any]:
|
||||
@@ -275,9 +280,9 @@ def _build_irrigation_ui_payload(
|
||||
crop_profile: dict[str, Any],
|
||||
active_kc: float,
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
sensor: SensorData | None,
|
||||
ai_snapshot: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
soil_moisture = _sensor_metric(sensor, "soil_moisture")
|
||||
soil_moisture = _aggregated_metric_fallback(ai_snapshot, "soil_moisture")
|
||||
plan = _normalize_plan(
|
||||
llm_result,
|
||||
optimizer_result,
|
||||
@@ -380,6 +385,7 @@ def get_irrigation_recommendation(
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
|
||||
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
|
||||
_persist_irrigation_method_on_farm(sensor, irrigation_method)
|
||||
|
||||
@@ -423,6 +429,7 @@ def get_irrigation_recommendation(
|
||||
if plant is not None and forecasts:
|
||||
optimized_result = _get_optimizer().optimize_irrigation(
|
||||
sensor=sensor,
|
||||
ai_snapshot=ai_snapshot,
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
daily_water_needs=daily_water_needs,
|
||||
@@ -518,7 +525,7 @@ def get_irrigation_recommendation(
|
||||
crop_profile,
|
||||
active_kc,
|
||||
irrigation_method,
|
||||
sensor,
|
||||
ai_snapshot,
|
||||
)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -120,7 +121,14 @@ class IrrigationPlanParserService:
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "irrigation_plan_parser",
|
||||
"endpoint_policy": "parser_first",
|
||||
}
|
||||
if farm_uuid:
|
||||
# Parser-first endpoint: farm context is optional enrichment only.
|
||||
structured_context["farm_context_source_metadata"] = {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"optional": True,
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_build_content_parts,
|
||||
@@ -106,7 +106,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
@@ -134,8 +134,8 @@ def _build_service_client(cfg: RAGConfig):
|
||||
|
||||
|
||||
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
|
||||
weather = farm_details.get("weather") or {}
|
||||
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
||||
weather = ((farm_details.get("weather") or {}).get("forecast") or {})
|
||||
soil = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
|
||||
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
|
||||
temp = _safe_float(weather.get("temperature_mean"), 24.0)
|
||||
rain = _safe_float(weather.get("precipitation"), 0.0)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -73,7 +73,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_metric, get_ai_snapshot_weather
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -71,7 +71,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
@@ -158,6 +158,16 @@ def get_water_need_prediction_insight(
|
||||
structured_context = {
|
||||
"farm_uuid": farm_uuid,
|
||||
"prediction_payload": prediction_payload,
|
||||
"source_metadata": {
|
||||
"agronomic_metrics": {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"policy": "cluster_block_farm_aggregated",
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
},
|
||||
}
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
@@ -208,4 +218,14 @@ def get_water_need_prediction_insight(
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
parsed["source_metadata"] = {
|
||||
"agronomic_metrics": {
|
||||
"source": "build_ai_farm_snapshot",
|
||||
"policy": "cluster_block_farm_aggregated",
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"policy": "center_location_latest_forecast",
|
||||
},
|
||||
}
|
||||
return parsed
|
||||
|
||||
@@ -69,3 +69,56 @@ class ChatContextTests(SimpleTestCase):
|
||||
|
||||
self.assertIn("[اطلاعات کامل مزرعه]", context)
|
||||
self.assertIn("soil_moisture", context)
|
||||
|
||||
|
||||
class CanonicalFarmContextTests(SimpleTestCase):
|
||||
@patch("rag.chat.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-123", "farm_metrics": {"resolved_metrics": {"soil_moisture": 41.0}}})
|
||||
def test_format_farm_context_uses_canonical_ai_snapshot(self, mock_snapshot):
|
||||
from rag.chat import _format_farm_context
|
||||
|
||||
context = _format_farm_context("farm-123")
|
||||
|
||||
self.assertIn("farm_metrics", context)
|
||||
self.assertIn("soil_moisture", context)
|
||||
mock_snapshot.assert_called_once_with("farm-123")
|
||||
|
||||
|
||||
class CanonicalUserSoilTextTests(SimpleTestCase):
|
||||
@patch(
|
||||
"rag.user_data.build_ai_farm_snapshot",
|
||||
return_value={
|
||||
"farm_uuid": "farm-123",
|
||||
"aggregation_policy": {
|
||||
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"weather": "center_location_latest_forecast",
|
||||
},
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 42.0, "nitrogen": 18.0}},
|
||||
"block_metrics": [
|
||||
{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 40.0}},
|
||||
{"block_code": "block-2", "resolved_metrics": {"soil_moisture": 44.0}},
|
||||
],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 39.0}},
|
||||
{"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 45.0}},
|
||||
],
|
||||
"source_metadata": {
|
||||
"farm_metrics": {
|
||||
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_build_user_soil_text_uses_only_canonical_snapshot(self, mock_snapshot):
|
||||
from rag.user_data import build_user_soil_text
|
||||
|
||||
text = build_user_soil_text("farm-123")
|
||||
|
||||
self.assertIn("سیاست تجمیع خاک", text)
|
||||
self.assertIn("خلاصه تجمیعشده مزرعه", text)
|
||||
self.assertIn("بلوک block-1", text)
|
||||
self.assertIn("زیر-بلوک cluster-a", text)
|
||||
self.assertIn("canonical_source: farmer_block_aggregated_snapshot", text)
|
||||
self.assertNotIn("sensor_payload", text)
|
||||
mock_snapshot.assert_called_once_with("farm-123")
|
||||
|
||||
@@ -86,3 +86,39 @@ class RAGFailureContractTests(SimpleTestCase):
|
||||
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
|
||||
|
||||
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
|
||||
|
||||
|
||||
@patch("rag.services.soil_anomaly.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"})
|
||||
def test_soil_anomaly_loads_canonical_snapshot(self, mock_snapshot):
|
||||
from rag.services.soil_anomaly import _load_farm_or_error
|
||||
|
||||
payload = _load_farm_or_error("farm-1")
|
||||
|
||||
self.assertEqual(payload["farm_uuid"], "farm-1")
|
||||
mock_snapshot.assert_called_once_with("farm-1")
|
||||
|
||||
@patch(
|
||||
"rag.services.pest_disease.build_ai_farm_snapshot",
|
||||
return_value={
|
||||
"farm_uuid": "farm-1",
|
||||
"weather": {"forecast": {"humidity_mean": 75.0, "temperature_mean": 31.0, "precipitation": 3.0}},
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 66.0, "electrical_conductivity": 2.8, "soil_ph": 7.9}},
|
||||
},
|
||||
)
|
||||
def test_pest_risk_context_reads_canonical_snapshot_shape(self, mock_snapshot):
|
||||
from rag.services.pest_disease import _build_risk_context, _load_farm_or_error
|
||||
|
||||
farm_details = _load_farm_or_error("farm-1")
|
||||
risk = _build_risk_context(farm_details, plant_name=None, growth_stage=None)
|
||||
|
||||
self.assertEqual(risk["overall_risk"], "high")
|
||||
self.assertIn("EC بالا", risk["key_drivers"])
|
||||
mock_snapshot.assert_called_once_with("farm-1")
|
||||
|
||||
@patch("rag.services.pest_disease.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"})
|
||||
def test_pest_detection_remains_image_first_with_optional_farm_context(self, mock_snapshot):
|
||||
with self.assertRaises(RAGServiceError) as exc_info:
|
||||
get_pest_disease_detection(farm_uuid="farm-1", images=[])
|
||||
|
||||
self.assertEqual(exc_info.exception.contract.error_code, "missing_images")
|
||||
mock_snapshot.assert_not_called()
|
||||
|
||||
@@ -385,3 +385,47 @@ class RecommendationServiceDefaultsTests(TestCase):
|
||||
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||
|
||||
|
||||
class RecommendationCanonicalSnapshotTests(TestCase):
|
||||
@patch("rag.services.irrigation.get_chat_client")
|
||||
@patch("rag.services.irrigation.build_rag_context", return_value="")
|
||||
@patch("rag.services.irrigation.build_ai_farm_snapshot")
|
||||
def test_irrigation_ui_payload_uses_aggregated_snapshot_metrics(self, mock_snapshot, _mock_context, mock_client):
|
||||
from rag.services.irrigation import _build_irrigation_ui_payload
|
||||
|
||||
mock_snapshot.return_value = {"farm_metrics": {"resolved_metrics": {"soil_moisture": 44.0}}}
|
||||
payload = _build_irrigation_ui_payload(
|
||||
llm_result={"plan": {}, "timeline": [], "sections": []},
|
||||
optimizer_result=None,
|
||||
daily_water_needs=[],
|
||||
crop_profile={},
|
||||
active_kc=0.9,
|
||||
irrigation_method=None,
|
||||
ai_snapshot=mock_snapshot.return_value,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["plan"]["moistureLevel"], 44)
|
||||
|
||||
@patch("rag.services.fertilization._get_optimizer")
|
||||
@patch("rag.services.fertilization.get_chat_client")
|
||||
@patch("rag.services.fertilization.build_rag_context", return_value="")
|
||||
@patch("rag.services.fertilization.build_ai_farm_snapshot")
|
||||
def test_fertilization_recommendation_includes_snapshot_provenance(self, mock_snapshot, _mock_context, mock_client, mock_optimizer):
|
||||
from rag.services.fertilization import get_fertilization_recommendation
|
||||
|
||||
client = mock_client.return_value
|
||||
client.chat.completions.create.return_value = type("Resp", (), {"choices": [type("Choice", (), {"message": type("Msg", (), {"content": '{"status": "success", "data": {}}'})()})()]})()
|
||||
mock_snapshot.return_value = {
|
||||
"source_metadata": {
|
||||
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
|
||||
}
|
||||
}
|
||||
mock_optimizer.return_value.optimize_fertilization.return_value = None
|
||||
|
||||
with patch("rag.services.fertilization.SensorData.objects.select_related") as mock_select:
|
||||
mock_select.return_value.prefetch_related.return_value.filter.return_value.first.return_value = None
|
||||
result = get_fertilization_recommendation(farm_uuid="farm-1")
|
||||
|
||||
self.assertEqual(result["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
self.assertEqual(result["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||
|
||||
+2
-2
@@ -132,7 +132,7 @@ class ChatView(APIView):
|
||||
],
|
||||
)
|
||||
def post(self, request: Request):
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from .config import load_rag_config
|
||||
|
||||
data = request.data if request.method == "POST" else request.query_params
|
||||
@@ -178,7 +178,7 @@ class ChatView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
cfg = load_rag_config()
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
if farm_details is None:
|
||||
return Response(
|
||||
{"code": 404, "msg": "farm پیدا نشد."},
|
||||
|
||||
Reference in New Issue
Block a user