File size: 26,146 Bytes
41366c7 5c1cd6f 41366c7 febe111 8a1097a febe111 41366c7 5c1cd6f 41366c7 5c1cd6f 41366c7 5c1cd6f 41366c7 5c1cd6f 41366c7 5c1cd6f e85bfb6 5c1cd6f e85bfb6 5c1cd6f 41366c7 5c1cd6f febe111 5c1cd6f febe111 8a1097a febe111 8a1097a 5c1cd6f febe111 5c1cd6f 8a1097a 5c1cd6f 8a1097a 5c1cd6f 8a1097a 5c1cd6f 8a1097a 5c1cd6f 8a1097a febe111 8a1097a febe111 8a1097a febe111 8a1097a 5c1cd6f 8a1097a 5c1cd6f 8a1097a 41366c7 5c1cd6f 8a1097a 5c1cd6f 41366c7 5c1cd6f 41366c7 5c1cd6f 41366c7 5c1cd6f 41366c7 5c1cd6f 81bdb2f 7afee5d 41366c7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 | """
Sell Optimizer -- computes when and where to sell for maximum net returns.
For each (mandi, timing) combination, calculates:
net_price = market_price - transport_cost - storage_loss - mandi_fees
Recommends the optimal combination across space (which mandi) and time
(sell now vs wait 7/14/30 days).
"""
from dataclasses import dataclass, field
from typing import Literal, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from src.dpi.models import FarmerProfile, KCCRepaymentStatus
# KCC utilization thresholds used to classify headroom as risk vs strength.
# 85% is the standard concern threshold lenders use when assessing how much
# additional credit a farmer can absorb; below 40% with meaningful headroom
# is treated as a positive signal.
KCC_UTILIZATION_HIGH_PCT = 85.0
KCC_UTILIZATION_SAFE_PCT = 40.0
KCC_MIN_HEADROOM_FOR_STRENGTH_RS = 20_000.0
from config import (
COMMODITY_MAP,
MANDI_MAP,
MANDIS,
MIN_TRANSPORT_COST_RS,
POST_HARVEST_LOSS,
TRANSPORT_COST_RS_PER_QUINTAL_PER_KM,
MANDI_FEE_PCT,
Mandi,
)
from src.geo import haversine_km
@dataclass
class SellOption:
"""A single sell option: specific mandi + timing."""
mandi_id: str
mandi_name: str
commodity_id: str
sell_timing: str # "now", "7d", "14d", "30d"
market_price_rs: float
transport_cost_rs: float
storage_loss_rs: float
storage_cost_rs: float
mandi_fee_rs: float
net_price_rs: float
distance_km: float
drive_time_min: float
confidence: float
price_source: str # "current" or "forecasted"
@dataclass
class SellRecommendation:
"""Complete sell recommendation for a farmer."""
commodity_id: str
commodity_name: str
quantity_quintals: float
farmer_lat: float
farmer_lon: float
best_option: SellOption
all_options: list[SellOption]
potential_gain_rs: float
recommendation_text: str
def _estimate_drive_time_min(distance_km: float) -> float:
"""Estimate drive time in minutes (avg 30 km/h for rural Tamil Nadu)."""
return (distance_km / 30) * 60
def optimize_sell(
farmer_lat: float,
farmer_lon: float,
commodity_id: str,
quantity_quintals: float,
reconciled_prices: dict[str, dict],
forecasted_prices: dict[str, dict] | None = None,
max_distance_km: float = 60.0,
storage_cost_rs_per_quintal_per_month: float = 20.0,
) -> SellRecommendation:
"""Compute optimal sell strategy across mandis and time horizons.
Parameters
----------
farmer_lat, farmer_lon : float
Farmer's location.
commodity_id : str
Commodity to sell.
quantity_quintals : float
Amount to sell.
reconciled_prices : dict
Mandi_id -> {commodity_id: {price_rs, ...}} -- current reconciled prices.
forecasted_prices : dict, optional
Mandi_id -> {commodity_id: {price_7d, price_14d, price_30d, ...}}.
max_distance_km : float
Maximum travel distance to consider.
storage_cost_rs_per_quintal_per_month : float
Warehouse storage rental cost if applicable.
Returns
-------
SellRecommendation
Best option with all alternatives ranked.
"""
commodity = COMMODITY_MAP.get(commodity_id, {})
commodity_name = commodity.get("name", commodity_id)
loss_data = POST_HARVEST_LOSS.get(commodity_id, {})
storage_loss_pct_month = loss_data.get("storage_per_month", 2.5)
if forecasted_prices is None:
forecasted_prices = {}
all_options: list[SellOption] = []
nearest_now_price = 0.0 # for potential gain calculation
for mandi in MANDIS:
if commodity_id not in mandi.commodities_traded:
continue
distance = haversine_km(farmer_lat, farmer_lon, mandi.latitude, mandi.longitude)
if distance > max_distance_km:
continue
drive_time = _estimate_drive_time_min(distance)
transport_cost = max(
MIN_TRANSPORT_COST_RS,
distance * TRANSPORT_COST_RS_PER_QUINTAL_PER_KM,
)
# Current price
mandi_prices = reconciled_prices.get(mandi.mandi_id, {})
commodity_price_data = mandi_prices.get(commodity_id, {})
current_price = commodity_price_data.get("price_rs", 0)
if current_price <= 0:
continue
# Track nearest mandi for potential gain
if nearest_now_price == 0.0:
nearest_now_price = current_price - transport_cost - (current_price * MANDI_FEE_PCT / 100)
# Time horizons
timings = [
("now", current_price, 0, "current"),
]
# Add forecasted prices
mandi_forecasts = forecasted_prices.get(mandi.mandi_id, {})
commodity_forecast = mandi_forecasts.get(commodity_id, {})
for label, key, months in [
("7d", "price_7d", 7 / 30),
("14d", "price_14d", 14 / 30),
("30d", "price_30d", 30 / 30),
]:
forecast_price = commodity_forecast.get(key, 0)
if forecast_price > 0:
timings.append((label, forecast_price, months, "forecasted"))
for timing_label, market_price, storage_months, price_source in timings:
# Storage loss (% of value per month of storage)
storage_loss_value = market_price * (storage_loss_pct_month / 100) * storage_months
storage_cost = storage_cost_rs_per_quintal_per_month * storage_months
# Mandi fee
mandi_fee = market_price * (MANDI_FEE_PCT / 100)
# Net price per quintal
net_price = market_price - transport_cost - storage_loss_value - storage_cost - mandi_fee
# Confidence decreases with forecast horizon
if price_source == "current":
confidence = commodity_price_data.get("confidence", 0.85)
else:
horizon_days = {"7d": 7, "14d": 14, "30d": 30}.get(timing_label, 7)
confidence = max(0.40, 0.85 - horizon_days * 0.01)
all_options.append(SellOption(
mandi_id=mandi.mandi_id,
mandi_name=mandi.name,
commodity_id=commodity_id,
sell_timing=timing_label,
market_price_rs=round(market_price, 0),
transport_cost_rs=round(transport_cost, 0),
storage_loss_rs=round(storage_loss_value, 0),
storage_cost_rs=round(storage_cost, 0),
mandi_fee_rs=round(mandi_fee, 0),
net_price_rs=round(net_price, 0),
distance_km=round(distance, 1),
drive_time_min=round(drive_time, 0),
confidence=round(confidence, 2),
price_source=price_source,
))
# Sort by net price descending
all_options.sort(key=lambda o: o.net_price_rs, reverse=True)
if not all_options:
# Distinguish between the three distinct empty-result cases so the
# farmer-facing message is honest:
# 1. Commodity not traded at any tracked market (config gap)
# 2. Commodity traded somewhere, but no recent price data (ingest
# gap β e.g. KAMIS had no bean prices today)
# 3. Data exists but everything is beyond max_distance_km
mandis_trading = [m for m in MANDIS if commodity_id in m.commodities_traded]
mandis_with_prices = [
m for m in mandis_trading
if reconciled_prices.get(m.mandi_id, {}).get(commodity_id, {}).get("price_rs", 0) > 0
]
if not mandis_trading:
fallback_label = f"{commodity_name} not traded at tracked markets"
elif not mandis_with_prices:
fallback_label = f"No recent price data for {commodity_name}"
else:
fallback_label = "No markets in range"
return SellRecommendation(
commodity_id=commodity_id,
commodity_name=commodity_name,
quantity_quintals=quantity_quintals,
farmer_lat=farmer_lat,
farmer_lon=farmer_lon,
best_option=SellOption(
mandi_id="", mandi_name=fallback_label,
commodity_id=commodity_id, sell_timing="now",
market_price_rs=0, transport_cost_rs=0, storage_loss_rs=0,
storage_cost_rs=0, mandi_fee_rs=0, net_price_rs=0,
distance_km=0, drive_time_min=0, confidence=0, price_source="none",
),
all_options=[],
potential_gain_rs=0,
recommendation_text="No mandis trading this commodity within travel distance.",
)
best = all_options[0]
# Nearest mandi selling now (for comparison)
now_options = [o for o in all_options if o.sell_timing == "now"]
if now_options:
nearest_now = min(now_options, key=lambda o: o.distance_km)
nearest_now_price = nearest_now.net_price_rs
potential_gain = (best.net_price_rs - nearest_now_price) * quantity_quintals
# Generate recommendation text
rec_text = _generate_recommendation_text(
best, all_options, commodity_name, quantity_quintals, potential_gain, nearest_now_price,
)
return SellRecommendation(
commodity_id=commodity_id,
commodity_name=commodity_name,
quantity_quintals=quantity_quintals,
farmer_lat=farmer_lat,
farmer_lon=farmer_lon,
best_option=best,
all_options=all_options[:15], # top 15 options
potential_gain_rs=round(potential_gain, 0),
recommendation_text=rec_text,
)
def _generate_recommendation_text(
best: SellOption,
all_options: list[SellOption],
commodity_name: str,
quantity: float,
potential_gain: float,
nearest_now_price: float,
) -> str:
"""Generate plain-language sell recommendation."""
parts = []
if best.sell_timing == "now":
parts.append(
f"Sell {commodity_name} NOW at {best.mandi_name} ({best.distance_km:.0f} km). "
f"Market price: Rs {best.market_price_rs:,.0f}/quintal. "
f"After transport (Rs {best.transport_cost_rs:,.0f}) and fees "
f"(Rs {best.mandi_fee_rs:,.0f}), net: Rs {best.net_price_rs:,.0f}/quintal."
)
else:
parts.append(
f"WAIT {best.sell_timing} and sell at {best.mandi_name} ({best.distance_km:.0f} km). "
f"Forecasted price: Rs {best.market_price_rs:,.0f}/quintal. "
f"After transport, storage loss (Rs {best.storage_loss_rs:,.0f}), and fees, "
f"net: Rs {best.net_price_rs:,.0f}/quintal."
)
if potential_gain > 0:
parts.append(
f"Potential gain over nearest mandi: Rs {potential_gain:,.0f} "
f"on {quantity:.0f} quintals."
)
# Compare best "sell now" vs best "wait"
now_options = [o for o in all_options if o.sell_timing == "now"]
wait_options = [o for o in all_options if o.sell_timing != "now"]
if now_options and wait_options:
best_now = now_options[0]
best_wait = wait_options[0]
if best_wait.net_price_rs > best_now.net_price_rs:
gain_per_quintal = best_wait.net_price_rs - best_now.net_price_rs
parts.append(
f"Waiting advantage: Rs {gain_per_quintal:,.0f}/quintal gain by selling "
f"{best_wait.sell_timing} at {best_wait.mandi_name} vs selling now at "
f"{best_now.mandi_name}."
)
else:
parts.append("Prices are expected to decline -- sell sooner rather than later.")
if best.confidence < 0.6:
parts.append("Note: forecast confidence is moderate. Monitor prices daily.")
return " ".join(parts)
# ---------------------------------------------------------------------------
# Credit readiness assessment β same data, farmer-facing view
# ---------------------------------------------------------------------------
@dataclass
class CreditReadiness:
"""Farmer-facing credit readiness assessment derived from sell optimization."""
readiness: Literal["strong", "moderate", "not_yet"]
expected_revenue_rs: float
min_revenue_rs: float
max_advisable_input_loan_rs: float
revenue_confidence: float
loan_to_revenue_pct: float # if farmer were to borrow max_advisable
strengths: list[str]
risks: list[str]
advice_en: str
advice_ta: str # Tamil placeholder
# DPI-aware fields β populated when a dpi_profile is passed in. Zeros
# + dpi_checked=False means the assessment never saw DPI data, so the
# dashboard can distinguish "no DPI" from "checked DPI, zero KCC".
kcc_limit_rs: float = 0.0
kcc_outstanding_rs: float = 0.0
kcc_repayment_status: str = "" # "" when no profile; otherwise KCCRepaymentStatus
dpi_checked: bool = False
@property
def kcc_headroom_rs(self) -> float:
return max(0.0, self.kcc_limit_rs - self.kcc_outstanding_rs)
def assess_credit_readiness(
rec: SellRecommendation,
has_storage: bool = True,
typical_input_loan_rs: float | None = None,
dpi_profile: Optional["FarmerProfile"] = None, # type: ignore[name-defined]
) -> CreditReadiness:
"""Assess whether a farmer should seek credit, based on her sell optimization.
This is advice FOR THE FARMER, not a score for a lender.
When `dpi_profile` is provided, the assessment uses the farmer's real
Kisan Credit Card state (limit, outstanding, repayment status) and
their registered land records to sharpen the recommendation:
- Max advisable loan is capped at the KCC headroom, not just at
40% of projected revenue.
- KCC near-limit utilization downgrades the classification.
- Overdue or defaulted KCC status forces "not_yet".
- Registered land acreage that doesn't support the claimed yield
shows up as a risk.
When `dpi_profile` is None, the function falls back to the prior
rule-based behavior β pure projection from sell data.
"""
if not rec.all_options or rec.best_option.net_price_rs <= 0:
return CreditReadiness(
readiness="not_yet",
expected_revenue_rs=0,
min_revenue_rs=0,
max_advisable_input_loan_rs=0,
revenue_confidence=0,
loan_to_revenue_pct=0,
strengths=[],
risks=["No market data available to estimate harvest revenue"],
advice_en="We don't have enough market data to assess your credit readiness right now. Check back after prices are available.",
advice_ta="",
dpi_checked=dpi_profile is not None,
)
best = rec.best_option
quantity = rec.quantity_quintals
# Revenue scenarios
expected_revenue = best.net_price_rs * quantity
now_options = [o for o in rec.all_options if o.sell_timing == "now"]
worst_net = min(o.net_price_rs for o in rec.all_options) if rec.all_options else best.net_price_rs
min_revenue = worst_net * quantity
# Conservative baseline: advisable loan is up to 40% of expected revenue.
# When DPI is available, cap at the real KCC headroom instead β this is
# the central upgrade the DPI integration delivers.
projected_max = expected_revenue * 0.40
if dpi_profile is not None and dpi_profile.kcc is not None:
kcc_headroom = dpi_profile.kcc.headroom
max_advisable = min(projected_max, kcc_headroom)
else:
max_advisable = projected_max
# If typical input loan provided, use it for comparison
if typical_input_loan_rs is None:
# Rough estimate: Rs 15,000-20,000 per hectare for most TN crops
# Use quantity as proxy (1 quintal β 0.03-0.05 ha depending on yield)
typical_input_loan_rs = min(max_advisable, 25_000)
loan_to_revenue = (typical_input_loan_rs / expected_revenue * 100) if expected_revenue > 0 else 999
# Strengths and risks
strengths = []
risks = []
if best.confidence >= 0.75:
strengths.append("Strong price forecast confidence")
elif best.confidence < 0.55:
risks.append("Price forecast has low confidence β actual revenue may differ")
if has_storage:
strengths.append("Storage available β you can wait for better prices if needed")
else:
risks.append("No storage β you must sell quickly, limiting price flexibility")
if expected_revenue > typical_input_loan_rs * 3:
strengths.append(f"Expected revenue (Rs {expected_revenue:,.0f}) is well above typical input costs")
elif expected_revenue < typical_input_loan_rs * 1.5:
risks.append("Expected revenue is close to input costs β tight margins if prices drop")
if rec.potential_gain_rs > 0:
strengths.append(f"Agent found Rs {rec.potential_gain_rs:,.0f} more value by optimizing where and when you sell")
price_spread = best.net_price_rs - worst_net if worst_net > 0 else 0
if price_spread > best.net_price_rs * 0.15:
risks.append("Large price variation across markets β revenue depends on selling at the right time and place")
if len(now_options) < 2:
risks.append("Few markets trading your commodity nearby")
# ββ DPI-derived strengths and risks ββββββββββββββββββββββββββββββββββ
# Land-area vs claimed-yield cross-checks are deliberately omitted: they
# require a commodity-specific yield table (rice 40 q/ha, banana 300 q/ha,
# pulses 7 q/ha) which would duplicate data from the DPI simulator. The
# value of the DPI integration lives in the KCC state below β that's
# where real lender decisions actually get made.
kcc_force_not_yet = False
kcc_limit = 0.0
kcc_outstanding = 0.0
kcc_headroom = 0.0
kcc_repayment = ""
if dpi_profile is not None:
# Commodity-vs-registration check: the rec's commodity should match
# something on the farmer's registered crops. Mismatch is low-stakes
# (crop rotations happen) but worth surfacing.
if dpi_profile.land_records and rec.commodity_id not in dpi_profile.primary_crops:
risks.append(
f"{rec.commodity_name} is not on your registered crop list β "
f"verify your land record before applying for input credit"
)
if dpi_profile.kcc is not None:
kcc_limit = dpi_profile.kcc.credit_limit
kcc_outstanding = dpi_profile.kcc.outstanding
kcc_headroom = dpi_profile.kcc.headroom
kcc_repayment = dpi_profile.kcc.repayment_status
util_pct = dpi_profile.kcc.utilization_pct
if kcc_repayment == "defaulted":
risks.append(
f"KCC shows a prior default β most lenders will not extend "
f"new credit until it's resolved"
)
kcc_force_not_yet = True
elif kcc_repayment == "overdue":
risks.append(
f"KCC is overdue on your last payment β clear it before seeking new credit"
)
elif util_pct >= KCC_UTILIZATION_HIGH_PCT:
risks.append(
f"KCC is {util_pct:.0f}% used (Rs {kcc_outstanding:,.0f} outstanding) β "
f"very little headroom for additional borrowing"
)
elif util_pct <= KCC_UTILIZATION_SAFE_PCT and kcc_headroom > KCC_MIN_HEADROOM_FOR_STRENGTH_RS:
strengths.append(
f"KCC has Rs {kcc_headroom:,.0f} headroom (card only {util_pct:.0f}% used)"
)
if kcc_repayment == "current" and util_pct < KCC_UTILIZATION_HIGH_PCT:
strengths.append("KCC payments are current β you're in good standing with your card")
else:
risks.append(
"No Kisan Credit Card on file β consider enrolling at your PACS "
"for subsidized input credit"
)
# Readiness determination
if kcc_force_not_yet:
readiness = "not_yet"
elif len(risks) == 0 and expected_revenue > typical_input_loan_rs * 2:
readiness = "strong"
elif len(risks) <= 1 and expected_revenue > typical_input_loan_rs * 1.5:
readiness = "moderate"
else:
readiness = "not_yet"
# Generate farmer-facing advice
advice_en = _credit_advice_en(readiness, expected_revenue, min_revenue, max_advisable, loan_to_revenue, strengths, risks, rec)
advice_ta = "" # Tamil via Claude in recommendation agent
return CreditReadiness(
readiness=readiness,
expected_revenue_rs=round(expected_revenue, 0),
min_revenue_rs=round(min_revenue, 0),
max_advisable_input_loan_rs=round(max_advisable, 0),
revenue_confidence=best.confidence,
loan_to_revenue_pct=round(loan_to_revenue, 1),
strengths=strengths,
risks=risks,
advice_en=advice_en,
advice_ta=advice_ta,
kcc_limit_rs=round(kcc_limit, 0),
kcc_outstanding_rs=round(kcc_outstanding, 0),
kcc_repayment_status=kcc_repayment,
dpi_checked=dpi_profile is not None,
)
def _credit_advice_en(
readiness: str,
expected: float,
minimum: float,
max_advisable: float,
loan_pct: float,
strengths: list[str],
risks: list[str],
rec: SellRecommendation,
) -> str:
"""Generate plain-language credit readiness advice for the farmer."""
commodity = rec.commodity_name
quantity = rec.quantity_quintals
best = rec.best_option
if readiness == "strong":
return (
f"Your {commodity} harvest ({quantity:.0f} quintals) is expected to earn "
f"Rs {expected:,.0f} at {best.mandi_name}. "
f"An input loan of up to Rs {max_advisable:,.0f} looks manageable β "
f"that's {loan_pct:.0f}% of your expected revenue. "
f"Consider applying through your local SACCO, FPO, or mobile platform."
)
elif readiness == "moderate":
return (
f"Your {commodity} harvest ({quantity:.0f} quintals) should earn around "
f"Rs {expected:,.0f}, but {'prices are uncertain' if any('confidence' in r.lower() for r in risks) else 'margins are tight'}. "
f"A smaller input loan β up to Rs {max_advisable:,.0f} β could work, "
f"but keep the amount conservative. "
f"{'Consider crop insurance to protect against downside.' if not any('storage' in s.lower() for s in strengths) else 'Your storage gives you flexibility to wait for better prices.'}"
)
else:
main_risk = risks[0] if risks else "uncertain revenue"
return (
f"Based on current market conditions, applying for a large input loan "
f"carries risk: {main_risk.lower()}. "
f"Your expected revenue is Rs {expected:,.0f}, with a worst case of Rs {minimum:,.0f}. "
f"Consider starting with a very small amount, or waiting until after harvest "
f"when you have cash in hand."
)
def credit_readiness_to_dict(cr: CreditReadiness) -> dict:
"""Convert CreditReadiness to a JSON-serializable dict."""
return {
"readiness": cr.readiness,
"expected_revenue_rs": cr.expected_revenue_rs,
"min_revenue_rs": cr.min_revenue_rs,
"max_advisable_input_loan_rs": cr.max_advisable_input_loan_rs,
"revenue_confidence": cr.revenue_confidence,
"loan_to_revenue_pct": cr.loan_to_revenue_pct,
"strengths": cr.strengths,
"risks": cr.risks,
"advice_en": cr.advice_en,
"advice_ta": cr.advice_ta,
"kcc_limit_rs": cr.kcc_limit_rs,
"kcc_outstanding_rs": cr.kcc_outstanding_rs,
"kcc_headroom_rs": cr.kcc_headroom_rs,
"kcc_repayment_status": cr.kcc_repayment_status,
"dpi_checked": cr.dpi_checked,
}
def recommendation_to_dict(rec: SellRecommendation) -> dict:
"""Convert a SellRecommendation to a JSON-serializable dict."""
return {
"commodity_id": rec.commodity_id,
"commodity_name": rec.commodity_name,
"quantity_quintals": rec.quantity_quintals,
"farmer_lat": rec.farmer_lat,
"farmer_lon": rec.farmer_lon,
"best_option": {
"mandi_id": rec.best_option.mandi_id,
"mandi_name": rec.best_option.mandi_name,
"sell_timing": rec.best_option.sell_timing,
"market_price_rs": rec.best_option.market_price_rs,
"transport_cost_rs": rec.best_option.transport_cost_rs,
"storage_loss_rs": rec.best_option.storage_loss_rs,
"mandi_fee_rs": rec.best_option.mandi_fee_rs,
"net_price_rs": rec.best_option.net_price_rs,
"distance_km": rec.best_option.distance_km,
"confidence": rec.best_option.confidence,
"price_source": rec.best_option.price_source,
},
"all_options": [
{
"mandi_id": o.mandi_id,
"mandi_name": o.mandi_name,
"sell_timing": o.sell_timing,
"market_price_rs": o.market_price_rs,
"transport_cost_rs": o.transport_cost_rs,
"storage_loss_rs": o.storage_loss_rs,
"mandi_fee_rs": o.mandi_fee_rs,
"net_price_rs": o.net_price_rs,
"distance_km": o.distance_km,
"confidence": o.confidence,
"price_source": o.price_source,
}
for o in rec.all_options
],
"potential_gain_rs": rec.potential_gain_rs,
"recommendation_text": rec.recommendation_text,
"farmer_id": "",
"farmer_name": "",
# Local-language translation + ISO 639-1 code. Populated by the
# RECOMMEND step (recommendation_agent) after this dict is built;
# this is just the default shape so downstream consumers see a
# stable key. "ta" = Tamil (India), "sw" = Swahili (Kenya).
"recommendation_local": "",
"local_language_code": "",
}
|