diff --git a/.gitignore b/.gitignore index f86ec77..863f92f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ .env -__pycache__/ *.py[cod] *.log diff --git a/app.py b/app.py index 6d65f39..62205a0 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, jsonify +from flask import Flask, render_template, request from requests.exceptions import RequestException from services.flight import get_destination_from_flight @@ -8,50 +8,88 @@ app = Flask(__name__) -@app.get("/flight//news") -def flight_news(flight_number): +@app.get("/") +def index(): + return render_template("index.html") + + +@app.post("/results") +def results(): + flight_number = request.form.get("flight_number", "").strip() + + if not flight_number: + return render_template("index.html", error="Please enter a flight number.") + try: destination = get_destination_from_flight(flight_number) except RequestException: - return ( - jsonify( - { - "success": False, - "stage": "flight", - "reason": "flight_api_unavailable", - } - ), - 502, + return render_template( + "results.html", + success=False, + flight_number=flight_number, + stage="flight", + reason="flight_api_unavailable", ) if not destination.get("success"): - return jsonify({"success": False, "stage": "flight", **destination}), 404 + return render_template( + "results.html", + success=False, + flight_number=flight_number, + stage="flight", + reason=destination.get("reason"), + ) try: headlines = get_headlines_for_location(destination["lat"], destination["lon"]) except RequestException: - return ( - jsonify( - { - "success": False, - "stage": "news", - "reason": "news_api_unavailable", - } - ), - 502, + return render_template( + "results.html", + success=False, + flight_number=flight_number, + stage="news", + reason="news_api_unavailable", ) if not headlines.get("success"): - return jsonify({"success": False, "stage": "news", **headlines}), 502 - - return jsonify( - { - "success": True, - "destination": destination, - "articles": headlines["articles"], - } + return render_template( + "results.html", + success=False, + flight_number=flight_number, + stage="news", + reason=headlines.get("reason"), + ) + + try: + from services.classifier import classify_articles + + classified = classify_articles(headlines["articles"], destination["city"]) + except Exception: + return render_template( + "results.html", + success=False, + flight_number=flight_number, + stage="news", + reason="classifier_unavailable", + ) + + if not classified.get("success"): + return render_template( + "results.html", + success=False, + flight_number=flight_number, + stage="news", + reason=classified.get("reason"), + ) + + return render_template( + "results.html", + success=True, + flight_number=flight_number, + destination=destination, + articles=classified["articles"], ) if __name__ == "__main__": - app.run(debug=True) + app.run(host="0.0.0.0", port=5001, debug=True) diff --git a/services/classifier.py b/services/classifier.py index 444e724..7649d1e 100644 --- a/services/classifier.py +++ b/services/classifier.py @@ -1,55 +1,79 @@ -from dotenv import load_dotenv -from google import genai -from google.genai import types -import json - -load_dotenv() - -client = genai.Client() - - -def classify_articles(articles, city): - article_list = "\n\n".join( - f"[{i}] Title: {a['title']}\nText: {a['text'][:500]}" - for i, a in enumerate(articles) - ) - prompt = f"""You are sorting news articles for a travel app. A user is flying to {city}. - Below is a numbered list of articles. For each one, decide: - - 1. is_local: true if the article is genuinely ABOUT {city} (local event, local story, something specific to that place) — false if it merely mentions {city} in passing while being a national/global/unrelated story. - 2. category: one of "concern", "local interest", "weather", "general update" — only if is_local is true. - 3. exclude: true if the article contains extremely graphic violence (informative news about violence is allowed), is highly politically inflammatory, or otherwise inappropriate for a general travel-news feed — regardless of is_local. - - Articles: - {article_list} - - Return your answer as a JSON list, one object per article, in the same order, with fields: index, is_local, category, exclude.""" - - response = client.models.generate_content( - model="gemini-3.5-flash", - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - ), - ) - - judgements = json.loads(response.text) - - results = [] - - for judgement in judgements: - if not judgement["is_local"] or judgement["exclude"]: - continue - article = articles[judgement["index"]] - results.append( - { - "title": article["title"], - "url": article["url"], - "text": article["text"], - "publish_date": article["publish_date"], - "category": judgement["category"], - } - ) - if not results: - return {"success": False, "reason": "no_local_articles"} - return {"success": True, "articles": results} +from dotenv import load_dotenv +from google import genai +from google.genai import types +import json +import time + +load_dotenv() + +client = genai.Client() + +MAX_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 2 + + +def classify_articles(articles, city): + article_list = "\n\n".join( + f"[{i}] Title: {a['title']}\nText: {a['text'][:500]}" + for i, a in enumerate(articles) + ) + prompt = f"""You are sorting news articles for a travel app. A user is flying to {city}. + Below is a numbered list of articles. For each one, decide: + + 1. is_local: true if the article is genuinely ABOUT {city} (local event, local story, something specific to that place) — false if it merely mentions {city} in passing while being a national/global/unrelated story. + 2. category: one of "concern", "local interest", "weather", "general update" — only if is_local is true. + 3. exclude: true if the article contains extremely graphic violence (informative news about violence is allowed), is highly politically inflammatory, or otherwise inappropriate for a general travel-news feed — regardless of is_local. + + Articles: + {article_list} + + Return your answer as a JSON list, one object per article, in the same order, with fields: index, is_local, category, exclude.""" + + response = None + last_error = None + + for attempt in range(1, MAX_ATTEMPTS + 1): + try: + response = client.models.generate_content( + model="gemini-3.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + ), + ) + break # got a response, stop retrying + except Exception as e: + last_error = e + print( + f"gemini request failed (attempt {attempt}/{MAX_ATTEMPTS}): {e!r}" + ) + if attempt < MAX_ATTEMPTS: + time.sleep(RETRY_DELAY_SECONDS) + + if response is None: + # Every attempt failed (e.g. persistent 503 overload). Re-raise so + # app.py's existing try/except around classify_articles catches it + # and renders the normal "classifier_unavailable" error page + # instead of crashing. + raise last_error + + judgements = json.loads(response.text) + + results = [] + + for judgement in judgements: + if not judgement["is_local"] or judgement["exclude"]: + continue + article = articles[judgement["index"]] + results.append( + { + "title": article["title"], + "url": article["url"], + "text": article["text"], + "publish_date": article["publish_date"], + "category": judgement["category"], + } + ) + if not results: + return {"success": False, "reason": "no_local_articles"} + return {"success": True, "articles": results} diff --git a/services/flight.py b/services/flight.py index 2b94160..d555fc0 100644 --- a/services/flight.py +++ b/services/flight.py @@ -1,84 +1,88 @@ -import requests -import os -from dotenv import load_dotenv -import json - -path = os.path.join(os.path.dirname(__file__), "data", "airports.json") -with open(path, "r", encoding="utf-8") as f: - airports = json.load(f) - -airports_by_iata = { - entry["iata"]: entry for entry in airports.values() if entry["iata"] -} - -load_dotenv() - -BASE_URL = "https://api.aviationstack.com/v1" -TOKEN = os.getenv("AVIATIONSTACK_API_KEY") - -""" - -get_destination_from_flight returns a dictionary - -Success: -{ - "success": True, - "city": , - "state": , - "country": , - "lat": , - "lon": - "flight_status": "active" -} - -Failure: -{ - "success": False, - "reason" : ("no_active_flight" or "airport_not_in_database") -} - -""" - - -def get_destination_from_flight(flight_number) -> dict: - - flight_number = flight_number.strip().upper() - - response = requests.get( - f"{BASE_URL}/flights?access_key={TOKEN}&flight_iata={flight_number}" - ) - data = response.json() - - active_flight = None - - for elem in data["data"]: - if elem["flight_status"] == "active": - active_flight = elem - break - - if active_flight is None: - return {"success": False, "reason": "no_active_flight"} - - else: - - airport = active_flight["arrival"]["iata"] - airport_info = airports_by_iata.get(airport) - - if airport_info is None: - return {"success": False, "reason": "airport_not_in_database"} - - city = airport_info["city"] - state = airport_info["state"] - country = airport_info["country"] - lat = airport_info["lat"] - lon = airport_info["lon"] - - return { - "success": True, - "city": city, - "state": state, - "country": country, - "lat": lat, - "lon": lon, - "flight_status": "active", - } +import requests +import os +from dotenv import load_dotenv +import json + +path = os.path.join(os.path.dirname(__file__), "data", "airports.json") +with open(path, "r", encoding="utf-8") as f: + airports = json.load(f) + +airports_by_iata = { + entry["iata"]: entry for entry in airports.values() if entry["iata"] +} + +load_dotenv() + +BASE_URL = "https://api.aviationstack.com/v1" +TOKEN = os.getenv("AVIATIONSTACK_API_KEY") + +""" + +get_destination_from_flight returns a dictionary + +Success: +{ + "success": True, + "city": , + "state": , + "country": , + "lat": , + "lon": + "flight_status": "active" +} + +Failure: +{ + "success": False, + "reason" : ("no_active_flight", "airport_not_in_database", or "flight_api_error") +} + +""" + + +def get_destination_from_flight(flight_number) -> dict: + + flight_number = flight_number.strip().upper() + + response = requests.get( + f"{BASE_URL}/flights?access_key={TOKEN}&flight_iata={flight_number}" + ) + data = response.json() + + if "data" not in data: + print(f"aviationstack error response for {flight_number}: {data}") + return {"success": False, "reason": "flight_api_error"} + + active_flight = None + + for elem in data["data"]: + if elem["flight_status"] == "active": + active_flight = elem + break + + if active_flight is None: + return {"success": False, "reason": "no_active_flight"} + + else: + + airport = active_flight["arrival"]["iata"] + airport_info = airports_by_iata.get(airport) + + if airport_info is None: + return {"success": False, "reason": "airport_not_in_database"} + + city = airport_info["city"] + state = airport_info["state"] + country = airport_info["country"] + lat = airport_info["lat"] + lon = airport_info["lon"] + + return { + "success": True, + "city": city, + "state": state, + "country": country, + "lat": lat, + "lon": lon, + "flight_status": "active", + } diff --git a/services/news.py b/services/news.py index 8c6e27c..6cf3d6e 100644 --- a/services/news.py +++ b/services/news.py @@ -1,39 +1,62 @@ -import requests -import os -from dotenv import load_dotenv - -load_dotenv() - -BASE_URL = "https://api.worldnewsapi.com" -TOKEN = os.getenv("WORLDNEWS_API_KEY") - - -def get_headlines_for_location(lat, lon): - location_filter = f"{lat},{lon},50" - params = { - "api-key": TOKEN, - "location-filter": location_filter, - "language": "en", - "number": 10, - } - - response = requests.get(f"{BASE_URL}/search-news", params=params) - data = response.json() - - if data.get("status") == "failure": - return {"success": False, "reason": "api_error"} - - articles = [] - - for result in data["news"]: - articles.append( - { - "title": result["title"], - "text": result["text"], - "url": result["url"], - "publish_date": result["publish_date"], - } - ) - if not articles: - return {"success": False, "reason": "no_articles_found"} - return {"success": True, "articles": articles} +import requests +import os +import time +from dotenv import load_dotenv + +load_dotenv() + +BASE_URL = "https://api.worldnewsapi.com" +TOKEN = os.getenv("WORLDNEWS_API_KEY") + +MAX_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 1 + + +def get_headlines_for_location(lat, lon): + location_filter = f"{lat},{lon},50" + params = { + "api-key": TOKEN, + "location-filter": location_filter, + "language": "en", + "number": 10, + } + + data = None + + for attempt in range(1, MAX_ATTEMPTS + 1): + try: + response = requests.get(f"{BASE_URL}/search-news", params=params) + data = response.json() + break # got valid JSON back, stop retrying + except (requests.exceptions.RequestException, ValueError) as e: + # ValueError covers json.JSONDecodeError, in case a non-JSON + # requests version doesn't classify it under RequestException. + print( + f"worldnewsapi request failed (attempt {attempt}/{MAX_ATTEMPTS}): {e!r}" + ) + if attempt < MAX_ATTEMPTS: + time.sleep(RETRY_DELAY_SECONDS) + + if data is None: + # Every attempt failed to return usable JSON (network error, empty + # body, non-JSON error page, etc.). Fail gracefully instead of + # raising, same pattern as services/flight.py. + return {"success": False, "reason": "news_api_unavailable"} + + if data.get("status") == "failure": + return {"success": False, "reason": "api_error"} + + articles = [] + + for result in data["news"]: + articles.append( + { + "title": result["title"], + "text": result["text"], + "url": result["url"], + "publish_date": result["publish_date"], + } + ) + if not articles: + return {"success": False, "reason": "no_articles_found"} + return {"success": True, "articles": articles} diff --git a/static/style.css b/static/style.css index dcbbd49..e001eb0 100644 --- a/static/style.css +++ b/static/style.css @@ -7,10 +7,24 @@ --text-secondary: #5b6b7c; --card-bg: #ffffff; --radius: 10px; + + /* Category tile palette (results page) */ + --tile-weather-bg: #eaf3fc; + --tile-weather-fg: #0674c8; + --tile-concern-bg: #fdecec; + --tile-concern-fg: #b3261e; + --tile-local-interest-bg: #f4ecfb; + --tile-local-interest-fg: #7c3aed; + --tile-general-update-bg: #e8f7f0; + --tile-general-update-fg: #0f9d63; + + --status-bg: #e8f7f0; + --status-fg: #0f9d63; } @import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@1,500;1,600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,700;1,400&display=swap'); * { box-sizing: border-box; @@ -56,6 +70,18 @@ a { color: var(--accent-dark); } +.topbar-link { + font-family: 'DM Sans', sans-serif; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + text-decoration: none; +} + +.topbar-link:hover { + color: var(--text-primary); +} + /* ---------- Cards ---------- */ .card { @@ -139,4 +165,299 @@ a { padding: 12px 14px; font-size: 14px; margin-bottom: 16px; -} \ No newline at end of file + font-family: 'DM Sans', sans-serif; +} + +/* ---------- Results page: flight card ---------- */ + +.flight-card { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 40px; + font-family: 'DM Sans', sans-serif; +} + +.flight-card-route { + display: flex; + align-items: center; + gap: 20px; +} + +.endpoint-label { + margin: 0 0 2px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.endpoint-value { + margin: 0; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.endpoint-sub { + margin: 2px 0 0; + font-size: 13px; + color: var(--text-secondary); +} + +.route-arrow { + display: flex; + align-items: center; + gap: 8px; +} + +.route-line { + width: 28px; + height: 1px; + background: var(--border-color); +} + +.route-plane { + width: 30px; + height: 30px; + border-radius: 999px; + background: var(--tile-weather-bg); + color: var(--accent-dark); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.route-plane svg { + width: 14px; + height: 14px; +} + +.status-pill { + display: flex; + align-items: center; + gap: 8px; + background: var(--status-bg); + color: var(--status-fg); + border: 1px solid #bfe8d4; + padding: 9px 18px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--status-fg); +} + +/* ---------- Results page: section heading ---------- */ + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; +} + +.section-heading h2 { + font-family: 'Newsreader', serif; + font-style: italic; + font-weight: 500; + font-size: 28px; + margin: 0; +} + +.section-heading em { + font-style: italic; + color: var(--accent-dark); +} + +.meta-note { + font-family: 'Space Mono', monospace; + font-size: 12px; + color: var(--text-secondary); +} + +/* ---------- Results page: filter chips ---------- */ + +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 24px; +} + +.chip { + font-family: 'DM Sans', sans-serif; + font-size: 13px; + font-weight: 600; + padding: 7px 16px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.chip:hover { + border-color: var(--accent-light); + color: var(--text-primary); +} + +.chip.active { + background: var(--accent-dark); + border-color: var(--accent-dark); + color: white; +} + +/* ---------- Results page: news list ---------- */ + +.news-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.news-card { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius); + text-decoration: none; + color: inherit; + font-family: 'DM Sans', sans-serif; + transition: border-color 0.15s ease; +} + +.news-card:hover { + border-color: var(--accent-light); +} + +.icon-tile { + width: 38px; + height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-tile svg { + width: 18px; + height: 18px; +} + +.tile-weather { background: var(--tile-weather-bg); color: var(--tile-weather-fg); } +.tile-concern { background: var(--tile-concern-bg); color: var(--tile-concern-fg); } +.tile-local-interest { background: var(--tile-local-interest-bg); color: var(--tile-local-interest-fg); } +.tile-general-update { background: var(--tile-general-update-bg); color: var(--tile-general-update-fg); } + +.news-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.news-title { + font-size: 15px; + font-weight: 700; + line-height: 1.4; + margin-bottom: 4px; + color: var(--text-primary); +} + +.news-card:hover .news-title { + color: var(--accent-dark); +} + +.news-summary { + font-size: 13.5px; + line-height: 1.5; + color: var(--text-secondary); + margin-bottom: 10px; +} + +.news-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.news-category { + font-weight: 600; + color: var(--text-primary); +} + +.news-meta .dot, +.news-date { + color: var(--text-secondary); +} + +.chevron { + flex-shrink: 0; + color: var(--border-color); + margin-top: 2px; +} + +.chevron svg { + width: 15px; + height: 15px; +} + +.news-card:hover .chevron { + color: var(--accent-light); +} + +.empty-state { + text-align: center; + color: var(--text-secondary); + font-family: 'DM Sans', sans-serif; + font-size: 14px; + padding: 40px 24px; +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 560px) { + .flight-card { + flex-direction: column; + align-items: flex-start; + } + + .flight-card-route { + gap: 12px; + } + + .endpoint-value { + font-size: 19px; + } + + .route-line { + width: 16px; + } + + .status-pill { + align-self: stretch; + justify-content: center; + } + + .news-card { + padding: 14px; + } +} diff --git a/templates/results.html b/templates/results.html index e69de29..f3ab421 100644 --- a/templates/results.html +++ b/templates/results.html @@ -0,0 +1,153 @@ + + + + + + + Results – FlightScope + + + + + + + + {% macro category_icon(slug) %} + {% if slug == 'weather' %} + + {% elif slug == 'concern' %} + + {% elif slug == 'local-interest' %} + + {% else %} + + {% endif %} + {% endmacro %} + + {% if not success %} + +
+

We hit a snag

+
+ +
+
+ {% if stage == 'flight' %} + {% if reason == 'no_active_flight' %} + We couldn't find an active flight with number {{ flight_number }}. Double-check the number and try again. + {% elif reason == 'airport_not_in_database' %} + We found flight {{ flight_number }}, but we don't have destination data for its arrival airport yet. + {% else %} + We're having trouble reaching flight data right now. Please try again shortly. + {% endif %} + {% elif stage == 'news' %} + {% if reason == 'no_local_articles' or reason == 'no_articles_found' %} + We found your flight, but couldn't find any local stories for your destination yet. + {% else %} + We found your flight, but couldn't load local news right now. Please try again shortly. + {% endif %} + {% else %} + Something unexpected happened. Please try again. + {% endif %} +
+ Try another flight +
+ + {% else %} + +
+
+
+

Flight

+

{{ flight_number|upper }}

+
+ +
+ + + + + +
+ +
+

Arriving in

+

{{ destination.city }}

+

{% if destination.state %}{{ destination.state }}, {% endif %}{{ destination.country }}

+
+
+ +
+ + {{ destination.flight_status|capitalize }} +
+
+ + {% set categories = articles | map(attribute='category') | unique | list %} + +
+

What's happening in {{ destination.city }}

+ {{ articles|length }} {{ 'story' if articles|length == 1 else 'stories' }} found +
+ +
+ + {% for cat in categories %} + + {% endfor %} +
+ + + + {% if not articles %} +
No local stories for {{ destination.city }} right now. Check back closer to your flight.
+ {% endif %} + + {% endif %} + + + + + +