Skip to content

Commit 9b23056

Browse files
authored
Merge pull request #572 from fireform-core/564-feat-enrich-ai-input-with-open-source-weather-apis
2 parents da5da4f + c9d8b46 commit 9b23056

15 files changed

Lines changed: 473 additions & 16 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ fireform: build up
6363
@echo "Run 'make logs' to view live logs, 'make down' to stop."
6464

6565
build:
66-
@$(COMPOSE) build --progress=quiet
66+
@$(COMPOSE) build
6767

6868
up:
6969
@$(COMPOSE) up -d

app/api/router.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@
1111

1212
from app.api.routes import forms, templates
1313
from app.api.v1.router import v1_router
14+
from app.api.routes import forms, templates, weather, zipcode
1415

1516
api_router = APIRouter()
1617
api_router.include_router(templates.router)
1718
api_router.include_router(forms.router)
1819
api_router.include_router(v1_router)
20+
21+
api_router.include_router(weather.router)
22+
api_router.include_router(zipcode.router)

app/api/routes/weather.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from fastapi import APIRouter
2+
3+
from app.core.errors.base import AppError
4+
from app.services.controller import Controller
5+
6+
router = APIRouter(prefix="/weather", tags=["weather"])
7+
8+
ALL_HOURLY_FIELDS = [
9+
"temperature_2m",
10+
"relative_humidity_2m",
11+
"rain",
12+
"apparent_temperature",
13+
"precipitation_probability",
14+
"precipitation",
15+
"wind_direction_10m",
16+
"wind_speed_10m",
17+
"soil_temperature_0cm",
18+
"uv_index",
19+
]
20+
21+
22+
@router.get("/forecast")
23+
def get_weather_forecast(
24+
latitude: float,
25+
longitude: float,
26+
start_date: str,
27+
end_date: str,
28+
fields: str | None = None,
29+
):
30+
"""
31+
Fetch hourly weather forecast data for the given coordinates and date range.
32+
33+
Coordinates must be supplied as **decimal degrees** — not degree/minute/second notation. E.g. -122.4194, 37.7749.
34+
Dates must use the **YYYY-MM-DD** format (ISO 8601).
35+
"""
36+
controller = Controller()
37+
try:
38+
requested = [f.strip() for f in fields.split(",") if f.strip()] if fields else []
39+
weather_data = controller.get_weather(latitude, longitude, start_date, end_date, hourly_fields=requested)
40+
return weather_data
41+
except Exception as e:
42+
raise AppError(str(e), status_code=500)

app/api/routes/zipcode.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from fastapi import APIRouter
2+
3+
from app.core.errors.base import AppError
4+
from app.services.controller import Controller
5+
6+
router = APIRouter(prefix="/zipcode", tags=["zipcode"])
7+
8+
9+
@router.get("/lookup-address")
10+
def lookup_address(address: str):
11+
"""
12+
Resolve a free-form address to one or more geographic locations including
13+
postal code, latitude, longitude, place name, state, county, and country.
14+
15+
Returns a list of matching results ordered by relevance (most relevant first).
16+
Each result is guaranteed to include `latitude` and `longitude`; `postal_code`
17+
is present when the geocoding service can determine it for the given address.
18+
19+
Example:
20+
address = "1600 Amphitheatre Parkway, Mountain View, CA, US"
21+
"""
22+
controller = Controller()
23+
try:
24+
return controller.lookup_address(address)
25+
except TimeoutError as e:
26+
raise AppError(str(e), status_code=504)
27+
except Exception as e:
28+
raise AppError(str(e), status_code=500)

app/services/controller.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
from app.services.file_manipulator import FileManipulator
2+
from app.services.external_apis_coordinator import ExternalAPIsCoordinator
3+
24

35
class Controller:
46
def __init__(self):
57
self.file_manipulator = FileManipulator()
8+
self.external_apis_coordinator = ExternalAPIsCoordinator()
69

710
def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: str = None):
811
return self.file_manipulator.fill_form(user_input, fields, pdf_form_path, model=model)
912

1013
def prepare_fillable(self, pdf_path: str):
11-
return self.file_manipulator.prepare_fillable(pdf_path)
14+
return self.file_manipulator.prepare_fillable(pdf_path)
15+
16+
def get_weather(self, latitude: float, longitude: float, start_date: str, end_date: str, hourly_fields: list = None):
17+
return self.external_apis_coordinator.get_weather(latitude, longitude, start_date, end_date, hourly_fields)
18+
19+
def lookup_address(self, address: str) -> list[dict]:
20+
return self.external_apis_coordinator.lookup_address(address)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import openmeteo_requests
2+
3+
import pandas as pd
4+
import requests_cache
5+
from retry_requests import retry
6+
7+
# All supported hourly fields exposed by this service
8+
ALL_HOURLY_FIELDS = [
9+
"temperature_2m",
10+
"relative_humidity_2m",
11+
"rain",
12+
"apparent_temperature",
13+
"precipitation_probability",
14+
"precipitation",
15+
"wind_direction_10m",
16+
"wind_speed_10m",
17+
"soil_temperature_0cm",
18+
"uv_index",
19+
]
20+
21+
class WeatherAPI:
22+
def __init__(self):
23+
pass
24+
25+
def get_weather(self, latitude: float, longitude: float, start_date: str, end_date: str, hourly_fields: list[str] | None = None):
26+
"""
27+
Get weather data for a given latitude and longitude.
28+
29+
Args:
30+
latitude (float): The latitude of the location.
31+
longitude (float): The longitude of the location.
32+
hourly_fields (list[str] | None): Subset of hourly variables to request.
33+
Defaults to ALL_HOURLY_FIELDS when None or empty.
34+
35+
Returns:
36+
dict: A dictionary containing the weather data.
37+
"""
38+
requested = [f for f in (hourly_fields or []) if f in ALL_HOURLY_FIELDS]
39+
if not requested:
40+
requested = list(ALL_HOURLY_FIELDS)
41+
42+
# Setup the Open-Meteo API client with cache and retry on error
43+
cache_session = requests_cache.CachedSession('.cache', expire_after=3600)
44+
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
45+
openmeteo = openmeteo_requests.Client(session=retry_session)
46+
47+
url = "https://api.open-meteo.com/v1/forecast"
48+
params = {
49+
"latitude": latitude,
50+
"longitude": longitude,
51+
"hourly": requested,
52+
"start_date": start_date,
53+
"end_date": end_date,
54+
}
55+
responses = openmeteo.weather_api(url, params=params)
56+
57+
response = responses[0]
58+
59+
hourly = response.Hourly()
60+
61+
hourly_data: dict = {
62+
"date": pd.date_range(
63+
start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
64+
end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
65+
freq=pd.Timedelta(seconds=hourly.Interval()),
66+
inclusive="left",
67+
)
68+
}
69+
70+
for i, field in enumerate(requested):
71+
hourly_data[field] = hourly.Variables(i).ValuesAsNumpy().tolist()
72+
73+
# Convert pandas Timestamp objects to ISO strings for JSON serialization
74+
hourly_data["date"] = [d.isoformat() for d in hourly_data["date"]]
75+
76+
return {
77+
"latitude": response.Latitude(),
78+
"longitude": response.Longitude(),
79+
"elevation": response.Elevation(),
80+
"timezone": response.Timezone(),
81+
"timezone_abbreviation": response.TimezoneAbbreviation(),
82+
"utc_offset_seconds": response.UtcOffsetSeconds(),
83+
"hourly": hourly_data,
84+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from geopy.geocoders import Nominatim
2+
from geopy.exc import GeocoderTimedOut, GeocoderServiceError
3+
4+
from app.core.logging import get_logger
5+
6+
logger = get_logger(__name__)
7+
8+
_GEOLOCATOR = Nominatim(user_agent="fireform", timeout=10)
9+
10+
11+
class ZipCodeAPI:
12+
def __init__(self):
13+
pass
14+
15+
def lookup_address(self, address: str) -> list[dict]:
16+
"""
17+
Look up an address string via OpenStreetMap Nominatim and return a list
18+
of matching locations with their postal codes, coordinates, and names.
19+
20+
Args:
21+
address: Full address string, e.g. "1600 Amphitheatre Parkway, Mountain View, CA, US"
22+
23+
Returns:
24+
A list of dicts, each containing:
25+
- postal_code (str | None)
26+
- place_name (str)
27+
- latitude (float)
28+
- longitude (float)
29+
- display_name (str)
30+
- country (str | None)
31+
- state (str | None)
32+
- county (str | None)
33+
"""
34+
try:
35+
locations = _GEOLOCATOR.geocode(
36+
address,
37+
exactly_one=False,
38+
addressdetails=True,
39+
limit=10,
40+
)
41+
except GeocoderTimedOut:
42+
logger.error("Nominatim geocoding timed out for address: %s", address)
43+
raise TimeoutError(f"Geocoding service timed out for address: '{address}'")
44+
except GeocoderServiceError as exc:
45+
logger.error("Nominatim geocoding service error: %s", exc)
46+
raise RuntimeError(f"Geocoding service error: {exc}")
47+
48+
if not locations:
49+
logger.info("No results found for address: %s", address)
50+
return []
51+
52+
results = []
53+
for loc in locations:
54+
addr = loc.raw.get("address", {})
55+
results.append({
56+
"postal_code": addr.get("postcode"),
57+
"place_name": addr.get("city")
58+
or addr.get("town")
59+
or addr.get("village")
60+
or addr.get("hamlet")
61+
or addr.get("municipality")
62+
or "",
63+
"latitude": loc.latitude,
64+
"longitude": loc.longitude,
65+
"display_name": loc.address,
66+
"country": addr.get("country"),
67+
"state": addr.get("state"),
68+
"county": addr.get("county"),
69+
})
70+
logger.debug("Geocoded result: %s", results[-1])
71+
72+
return results
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from app.services.external_apis.zipcode_api import ZipCodeAPI
2+
from app.services.external_apis.weather_api import WeatherAPI
3+
4+
class ExternalAPIsCoordinator:
5+
def __init__(self):
6+
self.weather_api = WeatherAPI()
7+
self.zipcode_api = ZipCodeAPI()
8+
9+
def get_weather(self, latitude: float, longitude: float, start_date: str, end_date: str, hourly_fields: list[str] | None = None):
10+
return self.weather_api.get_weather(latitude, longitude, start_date, end_date, hourly_fields)
11+
12+
def lookup_address(self, address: str) -> list[dict]:
13+
return self.zipcode_api.lookup_address(address)

app/services/file_manipulator.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import os
22
from app.services.filler import Filler
33
from app.services.llm import LLM
4+
from app.core.logging import get_logger
5+
6+
logger = get_logger(__name__)
47

58

69
class FileManipulator:
@@ -39,25 +42,23 @@ def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: st
3942
It receives the raw data, runs the PDF filling logic,
4043
and returns the path to the newly created file.
4144
"""
42-
print("[1] Received request from frontend.")
43-
print(f"[2] PDF template path: {pdf_form_path}")
45+
logger.info("[1] Received request from frontend.")
46+
logger.info("[2] PDF template path: %s", pdf_form_path)
4447

4548
if not os.path.exists(pdf_form_path):
4649
raise FileNotFoundError(f"PDF template not found at {pdf_form_path}")
4750

48-
print("[3] Starting extraction and PDF filling process...")
51+
logger.info("[3] Starting extraction and PDF filling process...")
4952
try:
5053
self.llm._target_fields = fields
5154
self.llm._transcript_text = user_input
5255
self.llm._model = model
5356
output_name = self.filler.fill_form(pdf_form=pdf_form_path, llm=self.llm)
5457

55-
print("\n----------------------------------")
56-
print("✅ Process Complete.")
57-
print(f"Output saved to: {output_name}")
58+
logger.info("Process complete. Output saved to: %s", output_name)
5859

5960
return output_name
6061

6162
except Exception as e:
62-
print(f"An error occurred during PDF generation: {e}")
63+
logger.error("An error occurred during PDF generation: %s", e)
6364
raise e

app/services/llm.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from requests.exceptions import Timeout, RequestException
55

66
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL
7+
from app.core.logging import get_logger
8+
9+
logger = get_logger(__name__)
710

811

912
class LLM:
@@ -51,9 +54,9 @@ def main_loop(self):
5154
json_data = response.json()
5255
break
5356
except Timeout:
54-
print(f"[LOG]: Ollama request timed out (attempt {attempt+1}) for field '{field}'. Retrying...")
57+
logger.warning("Ollama request timed out (attempt %d) for field '%s'. Retrying...", attempt + 1, field)
5558
except RequestException as e:
56-
print(f"[LOG]: Ollama request failed: {e}")
59+
logger.error("Ollama request failed: %s", e)
5760
except requests.exceptions.ConnectionError:
5861
raise ConnectionError(
5962
f"Could not connect to Ollama at {ollama_url}. "
@@ -67,12 +70,9 @@ def main_loop(self):
6770
else:
6871
parsed_response = json_data["response"]
6972
self.add_response_to_json(field, parsed_response)
70-
print(f"[{i}/{total_fields}] Extracted data for field '{field}' successfully.")
73+
logger.info("[%d/%d] Extracted data for field '%s' successfully.", i, total_fields, field)
7174

72-
print("----------------------------------")
73-
print("\t[LOG] Resulting JSON created from the input text:")
74-
print(json.dumps(self._json, indent=2))
75-
print("--------- extracted data ---------")
75+
logger.info("Resulting JSON created from the input text:\n%s", json.dumps(self._json, indent=2))
7676

7777
return self
7878

0 commit comments

Comments
 (0)