Fix four correctness bugs surfaced in the post-MOS-rip diagnostic (#3)
Browse files1. Unit mismatch: KAMIS publishes KES per kg; pipeline expected per quintal
Result: SMS showed "Net: KES 14/qtl" for a maize farmer because the
optimizer subtracted per-quintal transport costs from a per-kg price.
Fix: normalize to KES/quintal at both KAMIS PriceRecord creation sites
(demo CSV reader and live API) via a named KG_TO_QUINTAL=100 constant.
2. CI bounds missing for 14d/30d horizons
Neon's price_forecasts rows had ci_lower/ci_upper NULL for 14d and 30d.
Root cause: pipeline.py:1075-1088 only copied ci_lower_7d/ci_upper_7d
into the dict sent to save_pipeline_run; the other four CI fields from
the PriceForecast dataclass were dropped. Fix: include all six CI keys.
3. Swahili SMS leaked the example greeting's name
FMR-K0003's SMS started "Habari Wanjiku, Owino Muriuki,". The translation
prompt said "start naturally, like 'Habari Wanjiku...'" — Claude copied
the example name verbatim before the real farmer's name. Fix: rewrite
the prompt to instruct preserving the English source's farmer name
exactly; remove the now-unused local_greeting_example config key.
4. Optimizer conflated three empty-result cases as "No mandis in range"
FMR-K0003's bean farmer got "No mandis in range" even though all 11
Kenya markets trade beans — the real issue was that KAMIS had no bean
prices in today's ingest. Fix: distinguish
- "{commodity} not traded at tracked markets" (config gap)
- "No recent price data for {commodity}" (ingest gap)
- "No markets in range" (true distance constraint)
32 tests pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/ingestion/kamis.py +17 -5
- src/optimizer.py +19 -2
- src/pipeline.py +4 -0
- src/recommendation_agent.py +3 -3
|
@@ -44,6 +44,12 @@ log = logging.getLogger(__name__)
|
|
| 44 |
|
| 45 |
# --- KAMIS portal configuration -----------------------------------------
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
PORTAL_BASE_URL = "https://kamis.kilimo.go.ke/site/market_search"
|
| 48 |
|
| 49 |
KAMIS_ATTRIBUTION = (
|
|
@@ -463,13 +469,16 @@ def _load_demo_snapshot(
|
|
| 463 |
min_price = _safe_float(row.get("Min Price")) or modal
|
| 464 |
max_price = _safe_float(row.get("Max Price")) or modal
|
| 465 |
arrivals = _safe_float(row.get("Arrivals (Tonnes)")) or 0.0
|
|
|
|
|
|
|
|
|
|
| 466 |
results[_market_id(market_obj)].append(PriceRecord(
|
| 467 |
mandi_id=_market_id(market_obj),
|
| 468 |
commodity_id=commodity["id"],
|
| 469 |
date=price_date.strftime("%Y-%m-%d"),
|
| 470 |
-
min_price_rs=min_price,
|
| 471 |
-
max_price_rs=max_price,
|
| 472 |
-
modal_price_rs=modal,
|
| 473 |
arrivals_tonnes=arrivals,
|
| 474 |
source="kamis",
|
| 475 |
freshness_hours=24.0,
|
|
@@ -549,8 +558,11 @@ def _to_price_record(
|
|
| 549 |
and retail as `max_price_rs`. `arrivals_tonnes` stays 0 because
|
| 550 |
KAMIS's "volume" field is an unconfirmed unit.
|
| 551 |
"""
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
| 554 |
return PriceRecord(
|
| 555 |
mandi_id=_market_id(market),
|
| 556 |
commodity_id=commodity["id"],
|
|
|
|
| 44 |
|
| 45 |
# --- KAMIS portal configuration -----------------------------------------
|
| 46 |
|
| 47 |
+
# KAMIS publishes prices in KES per kilogram. The rest of the MI pipeline
|
| 48 |
+
# (storage costs, transport costs, mandi fees, forecast training data) uses
|
| 49 |
+
# KES per quintal (100 kg) — matching the Indian Agmarknet convention. All
|
| 50 |
+
# PriceRecord emits in this module apply this conversion.
|
| 51 |
+
KG_TO_QUINTAL = 100
|
| 52 |
+
|
| 53 |
PORTAL_BASE_URL = "https://kamis.kilimo.go.ke/site/market_search"
|
| 54 |
|
| 55 |
KAMIS_ATTRIBUTION = (
|
|
|
|
| 469 |
min_price = _safe_float(row.get("Min Price")) or modal
|
| 470 |
max_price = _safe_float(row.get("Max Price")) or modal
|
| 471 |
arrivals = _safe_float(row.get("Arrivals (Tonnes)")) or 0.0
|
| 472 |
+
# KAMIS publishes KES per kg; the rest of the pipeline expects
|
| 473 |
+
# per-quintal (100 kg) — matching Agmarknet's convention and the
|
| 474 |
+
# transport/storage/fee constants in optimizer.py. Normalize here.
|
| 475 |
results[_market_id(market_obj)].append(PriceRecord(
|
| 476 |
mandi_id=_market_id(market_obj),
|
| 477 |
commodity_id=commodity["id"],
|
| 478 |
date=price_date.strftime("%Y-%m-%d"),
|
| 479 |
+
min_price_rs=min_price * KG_TO_QUINTAL,
|
| 480 |
+
max_price_rs=max_price * KG_TO_QUINTAL,
|
| 481 |
+
modal_price_rs=modal * KG_TO_QUINTAL,
|
| 482 |
arrivals_tonnes=arrivals,
|
| 483 |
source="kamis",
|
| 484 |
freshness_hours=24.0,
|
|
|
|
| 558 |
and retail as `max_price_rs`. `arrivals_tonnes` stays 0 because
|
| 559 |
KAMIS's "volume" field is an unconfirmed unit.
|
| 560 |
"""
|
| 561 |
+
# KAMIS publishes KES per kg; the rest of the pipeline expects
|
| 562 |
+
# per-quintal (100 kg) — matching Agmarknet's convention and the
|
| 563 |
+
# transport/storage/fee constants in optimizer.py. Normalize here.
|
| 564 |
+
wholesale = float(raw["wholesale"]) * KG_TO_QUINTAL
|
| 565 |
+
retail = float(raw["retail"]) * KG_TO_QUINTAL
|
| 566 |
return PriceRecord(
|
| 567 |
mandi_id=_market_id(market),
|
| 568 |
commodity_id=commodity["id"],
|
|
@@ -201,7 +201,24 @@ def optimize_sell(
|
|
| 201 |
all_options.sort(key=lambda o: o.net_price_rs, reverse=True)
|
| 202 |
|
| 203 |
if not all_options:
|
| 204 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
return SellRecommendation(
|
| 206 |
commodity_id=commodity_id,
|
| 207 |
commodity_name=commodity_name,
|
|
@@ -209,7 +226,7 @@ def optimize_sell(
|
|
| 209 |
farmer_lat=farmer_lat,
|
| 210 |
farmer_lon=farmer_lon,
|
| 211 |
best_option=SellOption(
|
| 212 |
-
mandi_id="", mandi_name=
|
| 213 |
commodity_id=commodity_id, sell_timing="now",
|
| 214 |
market_price_rs=0, transport_cost_rs=0, storage_loss_rs=0,
|
| 215 |
storage_cost_rs=0, mandi_fee_rs=0, net_price_rs=0,
|
|
|
|
| 201 |
all_options.sort(key=lambda o: o.net_price_rs, reverse=True)
|
| 202 |
|
| 203 |
if not all_options:
|
| 204 |
+
# Distinguish between the three distinct empty-result cases so the
|
| 205 |
+
# farmer-facing message is honest:
|
| 206 |
+
# 1. Commodity not traded at any tracked market (config gap)
|
| 207 |
+
# 2. Commodity traded somewhere, but no recent price data (ingest
|
| 208 |
+
# gap — e.g. KAMIS had no bean prices today)
|
| 209 |
+
# 3. Data exists but everything is beyond max_distance_km
|
| 210 |
+
mandis_trading = [m for m in MANDIS if commodity_id in m.commodities_traded]
|
| 211 |
+
mandis_with_prices = [
|
| 212 |
+
m for m in mandis_trading
|
| 213 |
+
if reconciled_prices.get(m.mandi_id, {}).get(commodity_id, {}).get("price_rs", 0) > 0
|
| 214 |
+
]
|
| 215 |
+
if not mandis_trading:
|
| 216 |
+
fallback_label = f"{commodity_name} not traded at tracked markets"
|
| 217 |
+
elif not mandis_with_prices:
|
| 218 |
+
fallback_label = f"No recent price data for {commodity_name}"
|
| 219 |
+
else:
|
| 220 |
+
fallback_label = "No markets in range"
|
| 221 |
+
|
| 222 |
return SellRecommendation(
|
| 223 |
commodity_id=commodity_id,
|
| 224 |
commodity_name=commodity_name,
|
|
|
|
| 226 |
farmer_lat=farmer_lat,
|
| 227 |
farmer_lon=farmer_lon,
|
| 228 |
best_option=SellOption(
|
| 229 |
+
mandi_id="", mandi_name=fallback_label,
|
| 230 |
commodity_id=commodity_id, sell_timing="now",
|
| 231 |
market_price_rs=0, transport_cost_rs=0, storage_loss_rs=0,
|
| 232 |
storage_cost_rs=0, mandi_fee_rs=0, net_price_rs=0,
|
|
@@ -1083,6 +1083,10 @@ class MarketIntelligencePipeline:
|
|
| 1083 |
"price_30d": fc.price_30d,
|
| 1084 |
"ci_lower_7d": fc.ci_lower_7d,
|
| 1085 |
"ci_upper_7d": fc.ci_upper_7d,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1086 |
"direction": fc.direction,
|
| 1087 |
"confidence": fc.confidence,
|
| 1088 |
})
|
|
|
|
| 1083 |
"price_30d": fc.price_30d,
|
| 1084 |
"ci_lower_7d": fc.ci_lower_7d,
|
| 1085 |
"ci_upper_7d": fc.ci_upper_7d,
|
| 1086 |
+
"ci_lower_14d": fc.ci_lower_14d,
|
| 1087 |
+
"ci_upper_14d": fc.ci_upper_14d,
|
| 1088 |
+
"ci_lower_30d": fc.ci_lower_30d,
|
| 1089 |
+
"ci_upper_30d": fc.ci_upper_30d,
|
| 1090 |
"direction": fc.direction,
|
| 1091 |
"confidence": fc.confidence,
|
| 1092 |
})
|
|
@@ -38,7 +38,6 @@ REGION_CONFIG: dict[str, dict[str, str]] = {
|
|
| 38 |
"market_type": "mandi",
|
| 39 |
"local_language_name": "Tamil",
|
| 40 |
"local_language_code": "ta",
|
| 41 |
-
"local_greeting_example": "வணக்கம் Lakshmi...",
|
| 42 |
"typical_crops": "paddy, cotton, turmeric, groundnut",
|
| 43 |
"phone_country_code": "+91",
|
| 44 |
},
|
|
@@ -50,7 +49,6 @@ REGION_CONFIG: dict[str, dict[str, str]] = {
|
|
| 50 |
"market_type": "market",
|
| 51 |
"local_language_name": "Swahili",
|
| 52 |
"local_language_code": "sw",
|
| 53 |
-
"local_greeting_example": "Habari Wanjiku...",
|
| 54 |
"typical_crops": "dry maize, beans, Irish potatoes, green grams",
|
| 55 |
"phone_country_code": "+254",
|
| 56 |
},
|
|
@@ -181,7 +179,9 @@ _TRANSLATION_PROMPT_TEMPLATE = (
|
|
| 181 |
"Translate the following agricultural sell recommendation into {local_language_name}. "
|
| 182 |
"Keep all numbers, {market_type} names, and {currency_symbol} amounts as-is. "
|
| 183 |
"Use simple, conversational {local_language_name} that a rural farmer would "
|
| 184 |
-
"understand
|
|
|
|
|
|
|
| 185 |
"Do not add any preamble -- just output the {local_language_name} text.\n\n"
|
| 186 |
)
|
| 187 |
|
|
|
|
| 38 |
"market_type": "mandi",
|
| 39 |
"local_language_name": "Tamil",
|
| 40 |
"local_language_code": "ta",
|
|
|
|
| 41 |
"typical_crops": "paddy, cotton, turmeric, groundnut",
|
| 42 |
"phone_country_code": "+91",
|
| 43 |
},
|
|
|
|
| 49 |
"market_type": "market",
|
| 50 |
"local_language_name": "Swahili",
|
| 51 |
"local_language_code": "sw",
|
|
|
|
| 52 |
"typical_crops": "dry maize, beans, Irish potatoes, green grams",
|
| 53 |
"phone_country_code": "+254",
|
| 54 |
},
|
|
|
|
| 179 |
"Translate the following agricultural sell recommendation into {local_language_name}. "
|
| 180 |
"Keep all numbers, {market_type} names, and {currency_symbol} amounts as-is. "
|
| 181 |
"Use simple, conversational {local_language_name} that a rural farmer would "
|
| 182 |
+
"understand. If the English text opens with a greeting and a farmer's name, "
|
| 183 |
+
"translate the greeting naturally but keep the farmer's name exactly as "
|
| 184 |
+
"written in the English source — do NOT substitute any other name. "
|
| 185 |
"Do not add any preamble -- just output the {local_language_name} text.\n\n"
|
| 186 |
)
|
| 187 |
|