Adedoyinjames commited on
Commit
fe052d4
·
verified ·
1 Parent(s): fd1db35

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +542 -194
app.py CHANGED
@@ -1,19 +1,21 @@
1
- # app.py
2
  import os
3
  import io
4
  import json
 
 
5
  import datetime
6
  import requests
7
-
 
8
  from flask import Flask, request, jsonify, send_file, abort
9
  from flask_cors import CORS
10
  from reportlab.lib.pagesizes import A4
11
  from reportlab.lib.units import mm
12
- from reportlab.lib import styles
13
  from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
 
14
  from reportlab.lib.enums import TA_LEFT
15
- from reportlab.lib.styles import ParagraphStyle
16
- from reportlab.lib.colors import HexColor, black, white
17
 
18
  # Configuration
19
  HF_API_URL = "https://router.huggingface.co/v1/chat/completions"
@@ -23,247 +25,593 @@ MODEL = "deepseek-ai/DeepSeek-V3.2:novita"
23
  if not HF_TOKEN:
24
  raise RuntimeError("HF_TOKEN environment variable is required")
25
 
 
 
 
 
 
26
  SYSTEM_PROMPT = """
27
- You are EcoScan AI, a smart AI tool for environmental risk assessment in Nigeria.
28
  You analyze proposed project locations using satellite imagery and scientific data to assess risks in:
29
  - Soil Erosion (score 0-10, higher = higher risk)
30
  - Slope Stability (score 0-10, higher = higher risk)
31
  - Flooding (score 0-10, higher = higher risk)
32
  - Biodiversity (score 0-10, higher = higher impact/risk to biodiversity)
33
 
34
- For a given location in Nigeria, generate a realistic, concise assessment based on general knowledge of Nigerian geography, climate, and environmental data.
35
 
36
- **IMPORTANT**: Output ONLY a single valid JSON object and nothing else. The JSON object must follow exactly this structure (fields and nesting):
37
 
38
  {
39
- "erosion": {"score": float, "description": "brief explanation"},
40
- "slope_stability": {"score": float, "description": "brief explanation"},
41
- "flooding": {"score": float, "description": "brief explanation"},
42
- "biodiversity": {"score": float, "description": "brief explanation"},
43
- "overall_report": "A comprehensive paragraph summarizing the risks and mitigation recommendations."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  - Scores must be numeric (float) between 0.0 and 10.0 (one decimal place preferred).
47
- - Descriptions should be a single sentence (brief).
48
- - overall_report should be one paragraph (2-5 sentences) with specific mitigation recommendations.
49
- - Do not include any additional text, commentary, markdown, or trailing characters outside the JSON.
 
50
  """
51
 
52
  # Flask app
53
  app = Flask(__name__)
54
  CORS(app)
55
 
56
-
57
- def call_hf_chat(location_text: str, timeout: int = 30) -> str:
58
- """
59
- Call the Hugging Face chat completions endpoint and return the model's text content.
60
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  headers = {"Authorization": f"Bearer {HF_TOKEN}"}
 
 
 
 
 
 
 
 
62
  payload = {
63
  "model": MODEL,
64
  "messages": [
65
  {"role": "system", "content": SYSTEM_PROMPT},
66
- {
67
- "role": "user",
68
- "content": f"Assess the following location in Nigeria: {location_text}\nReturn output exactly in the required JSON format."
69
- }
70
  ],
71
- "max_tokens": 512,
72
- "temperature": 0.3,
73
  }
74
 
75
- r = requests.post(HF_API_URL, headers=headers, json=payload, timeout=timeout)
76
  try:
 
77
  r.raise_for_status()
78
- except requests.HTTPError as e:
79
- raise RuntimeError(f"HuggingFace API error: {e}, response: {r.text}")
80
-
81
- data = r.json()
82
- # Expecting structure similar to OpenAI-like chat completions:
83
- # {"choices":[{"message":{"role":"assistant","content":"{...}"}}], ...}
84
- try:
85
- content = data["choices"][0]["message"]["content"]
86
  except Exception as e:
87
- raise RuntimeError(f"Unexpected HF response structure: {e} | full response: {data}")
88
-
89
- return content
90
-
91
-
92
- def sanitize_and_parse_json(text: str) -> dict:
93
- """
94
- Try to parse model output as JSON. If model included noise, attempt to extract JSON substring.
95
- """
96
- # First try direct load
 
 
 
 
97
  try:
98
  return json.loads(text)
99
- except Exception:
100
- pass
101
-
102
- # Try to find the first '{' and last '}' and load substring
103
- first = text.find("{")
104
- last = text.rfind("}")
105
- if first != -1 and last != -1 and last > first:
106
- candidate = text[first : last + 1]
107
  try:
108
- return json.loads(candidate)
109
- except Exception:
110
- pass
111
-
112
- raise ValueError("Unable to parse JSON from model output")
113
-
114
-
115
- def format_scores_and_validate(d: dict) -> dict:
116
- """
117
- Ensure numeric scores, clamp to 0-10, round to 1 decimal, and provide minimal validation.
118
- """
119
- keys = ["erosion", "slope_stability", "flooding", "biodiversity", "overall_report"]
120
- for k in keys:
121
- if k not in d:
122
- raise ValueError(f"Missing required key in model output: {k}")
123
-
 
 
 
 
 
 
 
 
 
 
124
  for metric in ["erosion", "slope_stability", "flooding", "biodiversity"]:
125
- entry = d.get(metric)
126
- if not isinstance(entry, dict) or "score" not in entry or "description" not in entry:
127
- raise ValueError(f"Invalid structure for {metric}")
128
- # Try convert score to float
129
- try:
130
- score = float(entry["score"])
131
- except Exception:
132
- raise ValueError(f"Non-numeric score for {metric}")
133
- # clamp and round
134
- score = max(0.0, min(10.0, score))
135
- entry["score"] = round(score, 1)
136
- entry["description"] = str(entry["description"]).strip()
137
-
138
- # overall_report must be string
139
- d["overall_report"] = str(d["overall_report"]).strip()
140
- return d
141
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- def generate_pdf_bytes(location: str, assessment: dict) -> bytes:
144
- """
145
- Create a simple, clean PDF summary resembling the frontend layout.
146
- Returns PDF bytes.
147
- """
148
  buffer = io.BytesIO()
149
- doc = SimpleDocTemplate(buffer, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=18 * mm, bottomMargin=18 * mm)
 
 
150
  story = []
151
-
152
  # Styles
153
- stylesheet = styles.getSampleStyleSheet()
 
 
154
  title_style = ParagraphStyle(
155
- "Title",
156
- parent=stylesheet["Heading1"],
157
- alignment=TA_LEFT,
158
- fontSize=20,
159
- leading=24,
160
- spaceAfter=6,
161
  )
162
- meta_style = ParagraphStyle("Meta", parent=stylesheet["Normal"], fontSize=9, leading=11, textColor=HexColor("#666666"))
163
- heading_style = ParagraphStyle("Heading", parent=stylesheet["Heading2"], fontSize=14, leading=16, spaceBefore=10, spaceAfter=6)
164
- normal_style = ParagraphStyle("Normal", parent=stylesheet["BodyText"], fontSize=11, leading=14)
165
-
166
- # Header
167
- story.append(Paragraph("EcoScan AI — Environmental Risk Assessment", title_style))
168
- story.append(Paragraph(f"Location: {location}", meta_style))
169
- story.append(Paragraph(f"Generated: {datetime.datetime.utcnow().isoformat()} UTC", meta_style))
170
- story.append(Spacer(1, 8))
171
-
172
- # Risk analysis heading
173
- story.append(Paragraph("Risk Analysis (0 = low, 10 = high)", heading_style))
174
-
175
- # Table of metrics
176
- table_data = [["Metric", "Score", "Brief description"]]
177
- for metric_key, pretty in [
178
- ("erosion", "Soil Erosion"),
179
- ("slope_stability", "Slope Stability"),
180
- ("flooding", "Flooding"),
181
- ("biodiversity", "Biodiversity"),
182
- ]:
183
- entry = assessment[metric_key]
184
- table_data.append([pretty, f"{entry['score']:.1f}", entry["description"]])
185
-
186
- tbl = Table(table_data, colWidths=[60 * mm, 25 * mm, 90 * mm], hAlign="LEFT")
187
- tbl.setStyle(
188
- TableStyle(
189
- [
190
- ("BACKGROUND", (0, 0), (-1, 0), HexColor("#f0f6f2")),
191
- ("TEXTCOLOR", (0, 0), (-1, 0), black),
192
- ("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
193
- ("FONTSIZE", (0, 0), (-1, -1), 10),
194
- ("GRID", (0, 0), (-1, -1), 0.25, HexColor("#e0e0e0")),
195
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
196
- ("LEFTPADDING", (0, 0), (-1, -1), 6),
197
- ("RIGHTPADDING", (0, 0), (-1, -1), 6),
198
- ("TOPPADDING", (0, 0), (-1, -1), 4),
199
- ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
200
- ]
201
- )
202
  )
203
- story.append(tbl)
 
 
 
204
  story.append(Spacer(1, 12))
205
-
206
- # Mitigation / overall report
207
- story.append(Paragraph("Overall Assessment & Mitigation Recommendations", heading_style))
208
- story.append(Paragraph(assessment.get("overall_report", ""), normal_style))
209
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  doc.build(story)
211
  buffer.seek(0)
212
  return buffer.read()
213
 
214
-
215
  @app.route("/health", methods=["GET"])
216
  def health():
217
- return jsonify({"status": "ok"})
218
-
219
-
220
- @app.route("/assess", methods=["POST", "GET"])
221
- def assess():
222
- """
223
- POST JSON: { "location": "<location description or lat,lon>" }
224
- Query param (optional): ?download=1 to get PDF as attachment instead of raw JSON.
225
- GET with ?location=... also supported (returns JSON by default).
226
- """
227
- if request.method == "POST":
228
- body = request.get_json(silent=True)
229
- if not body or "location" not in body:
230
- return jsonify({"error": "POST body must be JSON with 'location' field"}), 400
231
- location = body["location"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  else:
233
- location = request.args.get("location", "")
234
- if not location:
235
- return jsonify({"error": "GET requires ?location=..."}), 400
236
-
237
- download = request.args.get("download", "0") in ("1", "true", "True", "yes")
238
-
239
- try:
240
- model_text = call_hf_chat(location)
241
- except Exception as e:
242
- return jsonify({"error": str(e)}), 502
243
-
244
- try:
245
- parsed = sanitize_and_parse_json(model_text)
246
- validated = format_scores_and_validate(parsed)
247
- except Exception as e:
248
- # Return raw model text for debugging (but still JSON-wrapped)
249
- return jsonify({"error": "Failed to parse/validate model output", "model_output": model_text, "details": str(e)}), 500
250
-
251
- if download:
252
  try:
253
- pdf_bytes = generate_pdf_bytes(location, validated)
254
- pdf_filename = f"ecoscan_assessment_{datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}.pdf"
255
- return send_file(
256
- io.BytesIO(pdf_bytes),
257
- mimetype="application/pdf",
258
- as_attachment=True,
259
- download_name=pdf_filename,
260
- )
261
  except Exception as e:
262
- return jsonify({"error": f"Failed to generate PDF: {e}"}), 500
263
-
264
- return jsonify(validated)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
  if __name__ == "__main__":
268
- # For local testing only. Production should use a WSGI server.
269
  app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
 
1
+ # app.py - Updated Backend with Job-based API and Coordinate Support
2
  import os
3
  import io
4
  import json
5
+ import uuid
6
+ import time
7
  import datetime
8
  import requests
9
+ import threading
10
+ from typing import Dict, Any, Optional
11
  from flask import Flask, request, jsonify, send_file, abort
12
  from flask_cors import CORS
13
  from reportlab.lib.pagesizes import A4
14
  from reportlab.lib.units import mm
 
15
  from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
16
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
17
  from reportlab.lib.enums import TA_LEFT
18
+ from reportlab.lib.colors import HexColor, black
 
19
 
20
  # Configuration
21
  HF_API_URL = "https://router.huggingface.co/v1/chat/completions"
 
25
  if not HF_TOKEN:
26
  raise RuntimeError("HF_TOKEN environment variable is required")
27
 
28
+ # In-memory job store (in production, use Redis or database)
29
+ jobs_store: Dict[str, Dict] = {}
30
+ jobs_lock = threading.Lock()
31
+
32
+ # Extended SYSTEM_PROMPT with coordinate support
33
  SYSTEM_PROMPT = """
34
+ You are EcoScan AI, a smart AI tool for environmental risk assessment in Nigeria and other locations.
35
  You analyze proposed project locations using satellite imagery and scientific data to assess risks in:
36
  - Soil Erosion (score 0-10, higher = higher risk)
37
  - Slope Stability (score 0-10, higher = higher risk)
38
  - Flooding (score 0-10, higher = higher risk)
39
  - Biodiversity (score 0-10, higher = higher impact/risk to biodiversity)
40
 
41
+ For a given location (place name or coordinates like "9.0820° N, 8.6753° E"), generate a realistic, detailed assessment based on geography, climate, and environmental data.
42
 
43
+ **IMPORTANT**: Output ONLY a single valid JSON object and nothing else. The JSON object must follow exactly this structure:
44
 
45
  {
46
+ "location": {
47
+ "name": "extracted location name",
48
+ "coordinates": "latitude, longitude if available",
49
+ "region": "geographic region"
50
+ },
51
+ "erosion": {
52
+ "score": float,
53
+ "description": "detailed explanation of erosion risk",
54
+ "factors": ["factor1", "factor2", "factor3"],
55
+ "mitigation": "recommended mitigation measures"
56
+ },
57
+ "slope_stability": {
58
+ "score": float,
59
+ "description": "detailed explanation of slope stability",
60
+ "factors": ["factor1", "factor2", "factor3"],
61
+ "mitigation": "recommended mitigation measures"
62
+ },
63
+ "flooding": {
64
+ "score": float,
65
+ "description": "detailed explanation of flooding risk",
66
+ "factors": ["factor1", "factor2", "factor3"],
67
+ "mitigation": "recommended mitigation measures"
68
+ },
69
+ "biodiversity": {
70
+ "score": float,
71
+ "description": "detailed explanation of biodiversity impact",
72
+ "factors": ["factor1", "factor2", "factor3"],
73
+ "mitigation": "recommended mitigation measures"
74
+ },
75
+ "overall_report": "A comprehensive 3-5 sentence paragraph summarizing all risks and specific mitigation recommendations.",
76
+ "assessment_date": "current date in YYYY-MM-DD format"
77
  }
78
 
79
  - Scores must be numeric (float) between 0.0 and 10.0 (one decimal place preferred).
80
+ - Descriptions should be 2-3 sentences with specific details.
81
+ - Provide 2-3 factors for each category.
82
+ - Mitigation should be practical, actionable recommendations.
83
+ - Do not include any additional text outside the JSON.
84
  """
85
 
86
  # Flask app
87
  app = Flask(__name__)
88
  CORS(app)
89
 
90
+ def extract_coordinates(location_text: str) -> Dict[str, Any]:
91
+ """Extract coordinates from location text if present."""
92
+ import re
93
+
94
+ # Pattern for coordinates like "9.0820° N, 8.6753° E" or "9.0820, 8.6753"
95
+ patterns = [
96
+ r'([-+]?\d+\.\d+)[°\s]*([NS]?)[,\s]*([-+]?\d+\.\d+)[°\s]*([EW]?)',
97
+ r'lat[:\s]*([-+]?\d+\.\d+)[,\s]*lon[:\s]*([-+]?\d+\.\d+)',
98
+ r'([-+]?\d+\.\d+)[,\s]+([-+]?\d+\.\d+)'
99
+ ]
100
+
101
+ for pattern in patterns:
102
+ match = re.search(pattern, location_text, re.IGNORECASE)
103
+ if match:
104
+ groups = match.groups()
105
+ if len(groups) >= 2:
106
+ lat = float(groups[0])
107
+ lon = float(groups[1])
108
+ # Handle N/S, E/W notation
109
+ if len(groups) > 2 and groups[2] == 'S':
110
+ lat = -lat
111
+ if len(groups) > 3 and groups[3] == 'W':
112
+ lon = -lon
113
+ return {
114
+ "latitude": lat,
115
+ "longitude": lon,
116
+ "coordinates": f"{lat:.4f}, {lon:.4f}"
117
+ }
118
+
119
+ return {"coordinates": None, "latitude": None, "longitude": None}
120
+
121
+ def call_hf_chat(location_text: str) -> str:
122
+ """Call the Hugging Face chat completions endpoint."""
123
  headers = {"Authorization": f"Bearer {HF_TOKEN}"}
124
+
125
+ # Add coordinate context if available
126
+ user_prompt = f"Assess the following location: {location_text}\n"
127
+ coords = extract_coordinates(location_text)
128
+ if coords["coordinates"]:
129
+ user_prompt += f"Coordinates: {coords['coordinates']}\n"
130
+ user_prompt += "Return output exactly in the required JSON format."
131
+
132
  payload = {
133
  "model": MODEL,
134
  "messages": [
135
  {"role": "system", "content": SYSTEM_PROMPT},
136
+ {"role": "user", "content": user_prompt}
 
 
 
137
  ],
138
+ "max_tokens": 1024,
139
+ "temperature": 0.2,
140
  }
141
 
 
142
  try:
143
+ r = requests.post(HF_API_URL, headers=headers, json=payload, timeout=45)
144
  r.raise_for_status()
145
+ data = r.json()
146
+ return data["choices"][0]["message"]["content"]
 
 
 
 
 
 
147
  except Exception as e:
148
+ raise RuntimeError(f"HuggingFace API error: {e}")
149
+
150
+ def sanitize_and_parse_json(text: str) -> Dict:
151
+ """Parse model output as JSON, handling noise."""
152
+ import re
153
+
154
+ # Clean the text
155
+ text = text.strip()
156
+
157
+ # Find JSON object
158
+ json_match = re.search(r'\{.*\}', text, re.DOTALL)
159
+ if json_match:
160
+ text = json_match.group(0)
161
+
162
  try:
163
  return json.loads(text)
164
+ except json.JSONDecodeError:
165
+ # Try to fix common issues
166
+ text = re.sub(r',\s*}', '}', text)
167
+ text = re.sub(r',\s*]', ']', text)
 
 
 
 
168
  try:
169
+ return json.loads(text)
170
+ except:
171
+ raise ValueError(f"Unable to parse JSON: {text[:200]}...")
172
+
173
+ def validate_and_enrich_assessment(assessment: Dict, location_text: str) -> Dict:
174
+ """Validate and enrich assessment with additional data."""
175
+ required_keys = ["erosion", "slope_stability", "flooding", "biodiversity", "overall_report"]
176
+
177
+ # Ensure all required keys exist
178
+ for key in required_keys:
179
+ if key not in assessment:
180
+ if key != "overall_report":
181
+ assessment[key] = {"score": 0.0, "description": "Data not available"}
182
+ else:
183
+ assessment[key] = "Assessment data not available."
184
+
185
+ # Add location information
186
+ if "location" not in assessment:
187
+ coords = extract_coordinates(location_text)
188
+ assessment["location"] = {
189
+ "name": location_text,
190
+ "coordinates": coords.get("coordinates"),
191
+ "region": "Unknown"
192
+ }
193
+
194
+ # Ensure scores are valid
195
  for metric in ["erosion", "slope_stability", "flooding", "biodiversity"]:
196
+ if metric in assessment:
197
+ entry = assessment[metric]
198
+ if isinstance(entry, dict):
199
+ # Ensure score is float between 0-10
200
+ if "score" in entry:
201
+ try:
202
+ score = float(entry["score"])
203
+ entry["score"] = max(0.0, min(10.0, round(score, 1)))
204
+ except:
205
+ entry["score"] = 5.0
206
+ else:
207
+ entry["score"] = 5.0
208
+
209
+ # Ensure description exists
210
+ if "description" not in entry:
211
+ entry["description"] = "Risk assessment data."
212
+
213
+ # Add factors if missing
214
+ if "factors" not in entry:
215
+ entry["factors"] = ["Soil type", "Vegetation cover", "Rainfall pattern"]
216
+
217
+ # Add mitigation if missing
218
+ if "mitigation" not in entry:
219
+ entry["mitigation"] = "Implement standard mitigation measures."
220
+
221
+ # Add assessment date
222
+ assessment["assessment_date"] = datetime.datetime.now().strftime("%Y-%m-%d")
223
+
224
+ return assessment
225
+
226
+ def process_assessment_job(job_id: str, location: str):
227
+ """Background job processor for assessments."""
228
+ with jobs_lock:
229
+ jobs_store[job_id]["status"] = "processing"
230
+ jobs_store[job_id]["progress"] = 0.1
231
+ jobs_store[job_id]["message"] = "Starting analysis..."
232
+
233
+ try:
234
+ # Step 1: Extract coordinates
235
+ with jobs_lock:
236
+ jobs_store[job_id]["progress"] = 0.2
237
+ jobs_store[job_id]["message"] = "Extracting location data..."
238
+
239
+ coords = extract_coordinates(location)
240
+
241
+ # Step 2: Call AI model
242
+ with jobs_lock:
243
+ jobs_store[job_id]["progress"] = 0.4
244
+ jobs_store[job_id]["message"] = "Analyzing environmental data..."
245
+
246
+ model_output = call_hf_chat(location)
247
+
248
+ # Step 3: Parse response
249
+ with jobs_lock:
250
+ jobs_store[job_id]["progress"] = 0.7
251
+ jobs_store[job_id]["message"] = "Processing assessment results..."
252
+
253
+ assessment = sanitize_and_parse_json(model_output)
254
+
255
+ # Step 4: Validate and enrich
256
+ assessment = validate_and_enrich_assessment(assessment, location)
257
+
258
+ # Add coordinate data
259
+ if coords["coordinates"]:
260
+ assessment["location"]["coordinates"] = coords["coordinates"]
261
+ assessment["location"]["latitude"] = coords["latitude"]
262
+ assessment["location"]["longitude"] = coords["longitude"]
263
+
264
+ # Step 5: Complete
265
+ with jobs_lock:
266
+ jobs_store[job_id]["status"] = "done"
267
+ jobs_store[job_id]["progress"] = 1.0
268
+ jobs_store[job_id]["message"] = "Assessment complete"
269
+ jobs_store[job_id]["result"] = assessment
270
+ jobs_store[job_id]["completed_at"] = time.time()
271
+
272
+ except Exception as e:
273
+ with jobs_lock:
274
+ jobs_store[job_id]["status"] = "error"
275
+ jobs_store[job_id]["message"] = str(e)
276
+ jobs_store[job_id]["error"] = str(e)
277
+
278
+ def process_pdf_job(job_id: str, location: str, assessment: Dict):
279
+ """Background job processor for PDF generation."""
280
+ with jobs_lock:
281
+ jobs_store[job_id]["progress"] = 0.3
282
+ jobs_store[job_id]["message"] = "Generating PDF report..."
283
+
284
+ try:
285
+ pdf_bytes = generate_pdf_bytes(location, assessment)
286
+
287
+ with jobs_lock:
288
+ jobs_store[job_id]["status"] = "done"
289
+ jobs_store[job_id]["progress"] = 1.0
290
+ jobs_store[job_id]["message"] = "PDF generated"
291
+ jobs_store[job_id]["result"] = pdf_bytes
292
+ jobs_store[job_id]["completed_at"] = time.time()
293
+
294
+ except Exception as e:
295
+ with jobs_lock:
296
+ jobs_store[job_id]["status"] = "error"
297
+ jobs_store[job_id]["message"] = str(e)
298
+ jobs_store[job_id]["error"] = str(e)
299
 
300
+ def generate_pdf_bytes(location: str, assessment: Dict) -> bytes:
301
+ """Create a professional PDF report."""
 
 
 
302
  buffer = io.BytesIO()
303
+ doc = SimpleDocTemplate(buffer, pagesize=A4,
304
+ leftMargin=20*mm, rightMargin=20*mm,
305
+ topMargin=20*mm, bottomMargin=20*mm)
306
  story = []
307
+
308
  # Styles
309
+ styles = getSampleStyleSheet()
310
+
311
+ # Title
312
  title_style = ParagraphStyle(
313
+ 'Title',
314
+ parent=styles['Heading1'],
315
+ fontSize=24,
316
+ textColor=HexColor('#16a34a'),
317
+ spaceAfter=6
 
318
  )
319
+
320
+ subtitle_style = ParagraphStyle(
321
+ 'Subtitle',
322
+ parent=styles['Heading2'],
323
+ fontSize=16,
324
+ textColor=HexColor('#4b5563'),
325
+ spaceAfter=24
326
+ )
327
+
328
+ heading_style = ParagraphStyle(
329
+ 'Heading',
330
+ parent=styles['Heading2'],
331
+ fontSize=14,
332
+ textColor=HexColor('#1f2937'),
333
+ spaceAfter=12
334
+ )
335
+
336
+ normal_style = ParagraphStyle(
337
+ 'Normal',
338
+ parent=styles['BodyText'],
339
+ fontSize=11,
340
+ leading=14,
341
+ spaceAfter=6
342
+ )
343
+
344
+ small_style = ParagraphStyle(
345
+ 'Small',
346
+ parent=styles['BodyText'],
347
+ fontSize=9,
348
+ textColor=HexColor('#6b7280'),
349
+ spaceAfter=2
 
 
 
 
 
 
 
 
 
350
  )
351
+
352
+ # Header
353
+ story.append(Paragraph("EcoScan AI", title_style))
354
+ story.append(Paragraph("Environmental Risk Assessment Report", subtitle_style))
355
  story.append(Spacer(1, 12))
356
+
357
+ # Location and date
358
+ location_text = f"<b>Location:</b> {location}"
359
+ if assessment.get('location', {}).get('coordinates'):
360
+ location_text += f" ({assessment['location']['coordinates']})"
361
+
362
+ story.append(Paragraph(location_text, normal_style))
363
+ story.append(Paragraph(f"<b>Assessment Date:</b> {assessment.get('assessment_date', 'N/A')}", normal_style))
364
+ story.append(Paragraph(f"<b>Report ID:</b> {str(uuid.uuid4())[:8]}", small_style))
365
+ story.append(Spacer(1, 24))
366
+
367
+ # Risk Scores Summary
368
+ story.append(Paragraph("Risk Analysis Summary", heading_style))
369
+
370
+ # Table data
371
+ table_data = [
372
+ ["Risk Factor", "Score (0-10)", "Description"]
373
+ ]
374
+
375
+ metrics = [
376
+ ("Erosion", assessment.get('erosion', {})),
377
+ ("Slope Stability", assessment.get('slope_stability', {})),
378
+ ("Flooding", assessment.get('flooding', {})),
379
+ ("Biodiversity", assessment.get('biodiversity', {}))
380
+ ]
381
+
382
+ for name, data in metrics:
383
+ score = data.get('score', 0)
384
+ desc = data.get('description', 'No description available')
385
+ # Truncate description for table
386
+ if len(desc) > 80:
387
+ desc = desc[:80] + "..."
388
+ table_data.append([name, f"{score:.1f}", desc])
389
+
390
+ # Create table
391
+ table = Table(table_data, colWidths=[60*mm, 30*mm, 80*mm])
392
+ table.setStyle(TableStyle([
393
+ ('BACKGROUND', (0, 0), (-1, 0), HexColor('#f0f9ff')),
394
+ ('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#1e40af')),
395
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
396
+ ('ALIGN', (1, 1), (1, -1), 'CENTER'),
397
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
398
+ ('FONTSIZE', (0, 0), (-1, -1), 10),
399
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
400
+ ('GRID', (0, 0), (-1, -1), 0.5, HexColor('#e5e7eb')),
401
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ffffff'), HexColor('#f9fafb')]),
402
+ ]))
403
+
404
+ story.append(table)
405
+ story.append(Spacer(1, 24))
406
+
407
+ # Detailed Assessment
408
+ story.append(Paragraph("Detailed Assessment", heading_style))
409
+
410
+ for name, data in metrics:
411
+ story.append(Paragraph(f"<b>{name}</b>", normal_style))
412
+ story.append(Paragraph(f"Score: {data.get('score', 0):.1f}/10", small_style))
413
+ story.append(Paragraph(data.get('description', ''), normal_style))
414
+
415
+ # Factors
416
+ factors = data.get('factors', [])
417
+ if factors:
418
+ factors_text = "<b>Key Factors:</b> " + ", ".join(factors)
419
+ story.append(Paragraph(factors_text, small_style))
420
+
421
+ # Mitigation
422
+ mitigation = data.get('mitigation', '')
423
+ if mitigation:
424
+ story.append(Paragraph(f"<b>Mitigation:</b> {mitigation}", small_style))
425
+
426
+ story.append(Spacer(1, 12))
427
+
428
+ # Overall Report
429
+ story.append(Paragraph("Overall Assessment & Recommendations", heading_style))
430
+ story.append(Paragraph(assessment.get('overall_report', ''), normal_style))
431
+ story.append(Spacer(1, 24))
432
+
433
+ # Footer
434
+ story.append(Paragraph("Generated by EcoScan AI Environmental Risk Assessment System",
435
+ ParagraphStyle('Footer', parent=styles['BodyText'], fontSize=8,
436
+ textColor=HexColor('#9ca3af'))))
437
+
438
+ # Build PDF
439
  doc.build(story)
440
  buffer.seek(0)
441
  return buffer.read()
442
 
443
+ # API Endpoints
444
  @app.route("/health", methods=["GET"])
445
  def health():
446
+ return jsonify({"status": "ok", "service": "EcoScan AI"})
447
+
448
+ @app.route("/assess_job", methods=["POST"])
449
+ def create_assessment_job():
450
+ """Create a new assessment job."""
451
+ data = request.get_json()
452
+ if not data or "location" not in data:
453
+ return jsonify({"error": "Missing 'location' in request body"}), 400
454
+
455
+ job_id = str(uuid.uuid4())
456
+
457
+ with jobs_lock:
458
+ jobs_store[job_id] = {
459
+ "job_id": job_id,
460
+ "type": "assessment",
461
+ "status": "pending",
462
+ "progress": 0.0,
463
+ "message": "Job created",
464
+ "location": data["location"],
465
+ "created_at": time.time(),
466
+ "result": None,
467
+ "error": None
468
+ }
469
+
470
+ # Start processing in background
471
+ thread = threading.Thread(target=process_assessment_job, args=(job_id, data["location"]))
472
+ thread.daemon = True
473
+ thread.start()
474
+
475
+ return jsonify({"job_id": job_id, "status": "pending"})
476
+
477
+ @app.route("/assess_job/status/<job_id>", methods=["GET"])
478
+ def get_assessment_status(job_id):
479
+ """Get status of an assessment job."""
480
+ with jobs_lock:
481
+ job = jobs_store.get(job_id)
482
+
483
+ if not job:
484
+ return jsonify({"error": "Job not found"}), 404
485
+
486
+ return jsonify({
487
+ "job_id": job_id,
488
+ "status": job["status"],
489
+ "progress": job["progress"],
490
+ "message": job["message"],
491
+ "error": job.get("error")
492
+ })
493
+
494
+ @app.route("/assess_job/result/<job_id>", methods=["GET"])
495
+ def get_assessment_result(job_id):
496
+ """Get result of a completed assessment job."""
497
+ with jobs_lock:
498
+ job = jobs_store.get(job_id)
499
+
500
+ if not job:
501
+ return jsonify({"error": "Job not found"}), 404
502
+
503
+ if job["status"] != "done":
504
+ return jsonify({"error": "Job not completed yet"}), 400
505
+
506
+ return jsonify(job["result"])
507
+
508
+ @app.route("/pdf_job", methods=["POST"])
509
+ def create_pdf_job():
510
+ """Create a new PDF generation job."""
511
+ data = request.get_json()
512
+ if not data or "location" not in data:
513
+ return jsonify({"error": "Missing 'location' in request body"}), 400
514
+
515
+ # First get or create assessment
516
+ location = data["location"]
517
+
518
+ # Check if we have assessment data in request
519
+ if "assessment" in data:
520
+ assessment = data["assessment"]
521
  else:
522
+ # Generate assessment first
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  try:
524
+ model_output = call_hf_chat(location)
525
+ assessment = sanitize_and_parse_json(model_output)
526
+ assessment = validate_and_enrich_assessment(assessment, location)
 
 
 
 
 
527
  except Exception as e:
528
+ return jsonify({"error": f"Failed to generate assessment: {str(e)}"}), 500
529
+
530
+ job_id = str(uuid.uuid4())
531
+
532
+ with jobs_lock:
533
+ jobs_store[job_id] = {
534
+ "job_id": job_id,
535
+ "type": "pdf",
536
+ "status": "pending",
537
+ "progress": 0.0,
538
+ "message": "Job created",
539
+ "location": location,
540
+ "created_at": time.time(),
541
+ "result": None,
542
+ "error": None
543
+ }
544
+
545
+ # Start processing in background
546
+ thread = threading.Thread(target=process_pdf_job, args=(job_id, location, assessment))
547
+ thread.daemon = True
548
+ thread.start()
549
+
550
+ return jsonify({"job_id": job_id, "status": "pending"})
551
+
552
+ @app.route("/pdf_job/status/<job_id>", methods=["GET"])
553
+ def get_pdf_status(job_id):
554
+ """Get status of a PDF job."""
555
+ with jobs_lock:
556
+ job = jobs_store.get(job_id)
557
+
558
+ if not job:
559
+ return jsonify({"error": "Job not found"}), 404
560
+
561
+ return jsonify({
562
+ "job_id": job_id,
563
+ "status": job["status"],
564
+ "progress": job["progress"],
565
+ "message": job["message"],
566
+ "error": job.get("error")
567
+ })
568
+
569
+ @app.route("/pdf_job/result/<job_id>", methods=["GET"])
570
+ def get_pdf_result(job_id):
571
+ """Get PDF result."""
572
+ with jobs_lock:
573
+ job = jobs_store.get(job_id)
574
+
575
+ if not job:
576
+ return jsonify({"error": "Job not found"}), 404
577
+
578
+ if job["status"] != "done":
579
+ return jsonify({"error": "Job not completed yet"}), 400
580
+
581
+ pdf_bytes = job["result"]
582
+
583
+ # Create response
584
+ buffer = io.BytesIO(pdf_bytes)
585
+ filename = f"EcoScan_Report_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
586
+
587
+ return send_file(
588
+ buffer,
589
+ mimetype='application/pdf',
590
+ as_attachment=True,
591
+ download_name=filename
592
+ )
593
 
594
+ # Clean up old jobs periodically
595
+ def cleanup_old_jobs():
596
+ """Remove jobs older than 1 hour."""
597
+ current_time = time.time()
598
+ with jobs_lock:
599
+ to_delete = []
600
+ for job_id, job in jobs_store.items():
601
+ if current_time - job.get("created_at", 0) > 3600: # 1 hour
602
+ to_delete.append(job_id)
603
+
604
+ for job_id in to_delete:
605
+ del jobs_store[job_id]
606
+
607
+ # Schedule cleanup every 30 minutes
608
+ import atexit
609
+ from apscheduler.schedulers.background import BackgroundScheduler
610
+
611
+ scheduler = BackgroundScheduler()
612
+ scheduler.add_job(func=cleanup_old_jobs, trigger="interval", minutes=30)
613
+ scheduler.start()
614
+ atexit.register(lambda: scheduler.shutdown())
615
 
616
  if __name__ == "__main__":
 
617
  app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))