jtlevine Claude Opus 4.7 (1M context) commited on
Commit
e85bfb6
·
unverified ·
1 Parent(s): 34d4290

Fix four correctness bugs surfaced in the post-MOS-rip diagnostic (#3)

Browse files

1. 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 CHANGED
@@ -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
- wholesale = float(raw["wholesale"])
553
- retail = float(raw["retail"])
 
 
 
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"],
src/optimizer.py CHANGED
@@ -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
- # No mandis found -- return empty recommendation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="No mandis in range",
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,
src/pipeline.py CHANGED
@@ -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
  })
src/recommendation_agent.py CHANGED
@@ -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 (e.g. start naturally, like \"{local_greeting_example}\"). "
 
 
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