climaiq-kisan / climaiq_report_pdf.py
krishy
Deploy ClimaIQ Kisan app, model weights, and dataset CSV
fa08517
Raw
History Blame Contribute Delete
8.29 kB
"""
ClimaIQ — Loan officer assessment PDF (A4, branded header, Unicode via Noto fonts).
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import urllib.request
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from fpdf import FPDF
PRIMARY = (46, 125, 50)
PRIMARY_DARK = (27, 94, 32)
INK = (31, 42, 32)
MUTED = (90, 109, 102)
NOTO_SANS_URL = (
"https://raw.githubusercontent.com/googlefonts/noto-fonts/main/hinted/ttf/"
"NotoSans/NotoSans-Regular.ttf"
)
NOTO_DEVA_URL = (
"https://raw.githubusercontent.com/googlefonts/noto-fonts/main/hinted/ttf/"
"NotoSansDevanagari/NotoSansDevanagari-Regular.ttf"
)
def _font_cache_dir() -> str:
d = os.path.join(tempfile.gettempdir(), "climaiq_fonts")
os.makedirs(d, exist_ok=True)
return d
def _ensure_font(url: str, filename: str) -> Optional[str]:
path = os.path.join(_font_cache_dir(), filename)
if os.path.isfile(path) and os.path.getsize(path) > 10000:
return path
try:
req = urllib.request.Request(url, headers={"User-Agent": "ClimaIQ-Kisan/1.0"})
with urllib.request.urlopen(req, timeout=45) as resp:
data = resp.read()
with open(path, "wb") as f:
f.write(data)
return path if os.path.isfile(path) and os.path.getsize(path) > 10000 else None
except Exception:
return None
def _font_path_for_language(language: str) -> Optional[str]:
if language == "Hindi":
return _ensure_font(NOTO_DEVA_URL, "NotoSansDevanagari-Regular.ttf")
return _ensure_font(NOTO_SANS_URL, "NotoSans-Regular.ttf")
def split_report_sections(text: str) -> List[Tuple[Optional[str], str]]:
"""
Split officer report into (numbered heading or None, body) tuples.
Headings are lines like '1. Assessment Summary' at the start of a block.
"""
text = (text or "").strip()
if not text:
return []
matches = list(re.finditer(r"(?m)^\d+\.\s+[^\n]+", text))
if not matches:
return [(None, text)]
out: List[Tuple[Optional[str], str]] = []
if matches[0].start() > 0:
pre = text[: matches[0].start()].strip()
if pre:
out.append((None, pre))
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
chunk = text[start:end].strip()
first_nl = chunk.find("\n")
if first_nl == -1:
out.append((chunk.strip(), ""))
else:
title = chunk[:first_nl].strip()
body = chunk[first_nl + 1 :].strip()
out.append((title, body))
return out
def _ascii_fallback(s: str) -> str:
return "".join(c if ord(c) < 128 else "?" for c in str(s))
def build_officer_assessment_pdf(
report_text: str,
farmer_data: Dict,
result: Dict,
language: str,
) -> bytes:
font_path = _font_path_for_language(language)
use_core = font_path is None
pdf = FPDF(orientation="P", unit="mm", format="A4")
pdf.set_auto_page_break(auto=True, margin=18)
pdf.set_margins(18, 18, 18)
pdf.add_page()
if font_path and not use_core:
try:
pdf.add_font("ClimaIQ", "", font_path)
pdf.set_font("ClimaIQ", "", 11)
except Exception:
use_core = True
if use_core:
pdf.set_font("Helvetica", "", 10)
def set_size(sz: float) -> None:
if use_core:
pdf.set_font("Helvetica", "", sz)
else:
pdf.set_font("ClimaIQ", "", sz)
def cell_txt(w: float, h: float, txt: str, **kwargs) -> None:
t = _ascii_fallback(txt) if use_core else str(txt)
pdf.cell(w, h, t[:500], **kwargs)
def multi_txt(w: float, h: float, txt: str) -> None:
t = _ascii_fallback(txt) if use_core else str(txt)
pdf.multi_cell(w, h, t)
# Banner
pdf.set_fill_color(*PRIMARY)
pdf.rect(0, 0, 210, 16, "F")
pdf.set_text_color(255, 255, 255)
set_size(13)
pdf.set_xy(18, 5)
title = "Loan assessment report" if language != "Hindi" else "ऋण मूल्यांकन रिपोर्ट"
cell_txt(0, 8, title, ln=True)
pdf.set_text_color(*INK)
# Meta
pdf.set_xy(18, 22)
set_size(9)
pdf.set_text_color(*MUTED)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lang_lbl = "Language" if language != "Hindi" else "भाषा"
pdf.cell(95, 5, f"{lang_lbl}: {language}" if use_core else f"{lang_lbl}: {language}")
pdf.cell(0, 5, ts, ln=True)
pdf.set_text_color(*INK)
pdf.ln(3)
# Scorecard title
set_size(10)
pdf.set_text_color(*PRIMARY_DARK)
sc_title = "ClimaIQ scorecard" if language != "Hindi" else "ClimaIQ स्कोरकार्ड"
cell_txt(0, 6, sc_title, ln=True)
pdf.set_text_color(*INK)
pdf.ln(1)
col_w = (210 - 36) / 2
pdf.set_fill_color(245, 248, 243)
set_size(9)
if language == "Hindi":
rows = [
("क्रेडिट स्कोर", f"{result['credit_score']} / 850"),
("जोखिम श्रेणी", str(result["risk_band"])),
("डिफ़ॉल्ट संभावना", f"{result['default_probability']}%"),
("सुझाई कार्रवाई", str(result["recommended_action"])[:110]),
("फसल", str(farmer_data.get("crop_type", ""))),
("राज्य", str(farmer_data.get("state", ""))),
("ऋण (लाख ₹)", str(farmer_data.get("loan_amount_lakhs", ""))),
("बारिश कमी %", str(abs(float(farmer_data.get("rainfall_deficit_pct", 0))))),
("SPI", str(farmer_data.get("spi", ""))),
]
else:
rows = [
("Credit score", f"{result['credit_score']} / 850"),
("Risk band", str(result["risk_band"])),
("Default probability", f"{result['default_probability']}%"),
("Recommended action", str(result["recommended_action"])[:110]),
("Crop", str(farmer_data.get("crop_type", ""))),
("State", str(farmer_data.get("state", ""))),
("Loan (Rs lakhs)", str(farmer_data.get("loan_amount_lakhs", ""))),
("Rain deficit %", str(abs(float(farmer_data.get("rainfall_deficit_pct", 0))))),
("SPI", str(farmer_data.get("spi", ""))),
]
for a, b in rows:
pdf.set_x(18)
cell_txt(col_w - 2, 7, a, border="B", fill=True)
cell_txt(col_w + 2, 7, b, border="B", ln=True, fill=True)
pdf.ln(4)
# Narrative
set_size(10)
pdf.set_text_color(*PRIMARY)
narr_h = "AI narrative" if language != "Hindi" else "AI विवरण"
cell_txt(0, 6, narr_h, ln=True)
pdf.set_text_color(*INK)
pdf.ln(2)
usable_w = 210 - 36
for head, body in split_report_sections(report_text):
if head:
set_size(10.5)
pdf.set_x(18)
multi_txt(usable_w, 5.5, head)
pdf.ln(1)
if body:
set_size(10)
pdf.set_x(18)
multi_txt(usable_w, 5.2, body)
pdf.ln(3)
# Footer
pdf.set_y(-24)
pdf.set_draw_color(200, 210, 200)
yf = pdf.get_y()
pdf.line(18, yf, 192, yf)
pdf.ln(2)
set_size(8)
pdf.set_text_color(*MUTED)
foot = (
"ClimaIQ Kisan — climate-adjusted agricultural credit intelligence. "
"For decision support only; not a substitute for policy and compliance review."
)
if language == "Hindi":
foot = (
"ClimaIQ Kisan — कृषि ऋण हेतु जलवायु-समायोजित विश्लेषण। "
"केवल निर्णय सहायता; नीति व अनुपालन का स्थान नहीं लेता।"
)
pdf.set_x(18)
multi_txt(usable_w, 4, foot)
out = pdf.output(dest="S")
if isinstance(out, str):
return out.encode("latin-1", errors="replace")
return bytes(out)
def pdf_to_bytesio(report_text: str, farmer_data: Dict, result: Dict, language: str) -> io.BytesIO:
return io.BytesIO(build_officer_assessment_pdf(report_text, farmer_data, result, language))