This commit is contained in:
2026-05-13 16:45:54 +03:30
parent 948c062b93
commit 46fe62fa04
96 changed files with 3834 additions and 155 deletions
+2 -2
View File
@@ -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 نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
+11 -1
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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,
+4 -4
View File
@@ -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)
+2 -2
View File
@@ -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",
+22 -2
View File
@@ -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
+53
View File
@@ -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")
+36
View File
@@ -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()
+44
View File
@@ -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
View File
@@ -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 پیدا نشد."},