Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
.env
__pycache__/
*.py[cod]
*.log
100 changes: 69 additions & 31 deletions app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,50 +8,88 @@
app = Flask(__name__)


@app.get("/flight/<flight_number>/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)
Comment on lines 94 to +95
134 changes: 79 additions & 55 deletions services/classifier.py
Original file line number Diff line number Diff line change
@@ -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}
Loading