Add weather forecast and trim analysis context
This commit is contained in:
parent
9703cb473f
commit
f998b2a07d
3 changed files with 194 additions and 24 deletions
204
ha_observer.py
204
ha_observer.py
|
|
@ -38,12 +38,18 @@ SITE_URL = os.environ.get("SITE_URL", "http://localhost").rstrip("/")
|
|||
PROMPT_FILE = Path(os.environ.get("PROMPT_FILE", "./llm_instructions.md"))
|
||||
HISTORY_HOURS = int(os.environ.get("HISTORY_HOURS", "24"))
|
||||
MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20"))
|
||||
MAX_STATES_PER_SNAPSHOT = int(os.environ.get("MAX_STATES_PER_SNAPSHOT", "160"))
|
||||
MAX_HISTORY_ENTITIES_PER_SNAPSHOT = int(os.environ.get("MAX_HISTORY_ENTITIES_PER_SNAPSHOT", "70"))
|
||||
CALENDAR_LOOKAHEAD_DAYS = int(os.environ.get("CALENDAR_LOOKAHEAD_DAYS", "7"))
|
||||
MAX_CALENDAR_EVENTS_PER_CALENDAR = int(os.environ.get("MAX_CALENDAR_EVENTS_PER_CALENDAR", "8"))
|
||||
ANALYZE_SNAPSHOT_HOURS = int(os.environ.get("ANALYZE_SNAPSHOT_HOURS", "24"))
|
||||
ARTICLE_CONTEXT_DAYS = int(os.environ.get("ARTICLE_CONTEXT_DAYS", "7"))
|
||||
MAX_ANALYZE_CHARS = int(os.environ.get("MAX_ANALYZE_CHARS", "80000"))
|
||||
MAX_PREVIOUS_ARTICLE_CHARS = int(os.environ.get("MAX_PREVIOUS_ARTICLE_CHARS", "20000"))
|
||||
MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT = int(os.environ.get("MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT", "3000"))
|
||||
DISPLAY_TIMEZONE = os.environ.get("DISPLAY_TIMEZONE", "Europe/Copenhagen")
|
||||
WEATHER_LATITUDE = os.environ.get("WEATHER_LATITUDE", "").strip()
|
||||
WEATHER_LONGITUDE = os.environ.get("WEATHER_LONGITUDE", "").strip()
|
||||
KEEP_SNAPSHOT_DAYS = int(os.environ.get("KEEP_SNAPSHOT_DAYS", "14"))
|
||||
|
||||
# LLM_MODE: none | pi | ollama | openai
|
||||
|
|
@ -144,6 +150,130 @@ def ha_get(path: str, params: dict[str, str] | None = None) -> Any:
|
|||
return response.json()
|
||||
|
||||
|
||||
WEATHER_CODES = {
|
||||
0: "clear sky",
|
||||
1: "mainly clear",
|
||||
2: "partly cloudy",
|
||||
3: "overcast",
|
||||
45: "fog",
|
||||
48: "depositing rime fog",
|
||||
51: "light drizzle",
|
||||
53: "moderate drizzle",
|
||||
55: "dense drizzle",
|
||||
56: "light freezing drizzle",
|
||||
57: "dense freezing drizzle",
|
||||
61: "slight rain",
|
||||
63: "moderate rain",
|
||||
65: "heavy rain",
|
||||
66: "light freezing rain",
|
||||
67: "heavy freezing rain",
|
||||
71: "slight snow",
|
||||
73: "moderate snow",
|
||||
75: "heavy snow",
|
||||
77: "snow grains",
|
||||
80: "slight rain showers",
|
||||
81: "moderate rain showers",
|
||||
82: "violent rain showers",
|
||||
85: "slight snow showers",
|
||||
86: "heavy snow showers",
|
||||
95: "thunderstorm",
|
||||
96: "thunderstorm with slight hail",
|
||||
99: "thunderstorm with heavy hail",
|
||||
}
|
||||
|
||||
|
||||
def weather_code_text(value: Any) -> str:
|
||||
try:
|
||||
return WEATHER_CODES.get(int(value), f"weather code {value}")
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_weather_coordinates() -> tuple[float, float] | None:
|
||||
if WEATHER_LATITUDE and WEATHER_LONGITUDE:
|
||||
try:
|
||||
return float(WEATHER_LATITUDE), float(WEATHER_LONGITUDE)
|
||||
except ValueError:
|
||||
print("Ignoring invalid WEATHER_LATITUDE/WEATHER_LONGITUDE", file=sys.stderr)
|
||||
try:
|
||||
config = ha_get("/api/config")
|
||||
return float(config["latitude"]), float(config["longitude"])
|
||||
except Exception as exc:
|
||||
print(f"Skipping direct weather forecast; could not get coordinates: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_direct_weather_forecast() -> dict[str, Any]:
|
||||
"""Fetch current + today/tomorrow forecast from Open-Meteo. No API key required."""
|
||||
coords = get_weather_coordinates()
|
||||
if not coords:
|
||||
return {}
|
||||
latitude, longitude = coords
|
||||
params = {
|
||||
"latitude": f"{latitude:.5f}",
|
||||
"longitude": f"{longitude:.5f}",
|
||||
"timezone": DISPLAY_TIMEZONE,
|
||||
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,showers,snowfall,weather_code,cloud_cover,wind_speed_10m,wind_gusts_10m",
|
||||
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,rain_sum,showers_sum,snowfall_sum,precipitation_probability_max,wind_speed_10m_max,wind_gusts_10m_max,sunrise,sunset",
|
||||
"forecast_days": "2",
|
||||
}
|
||||
try:
|
||||
response = requests.get("https://api.open-meteo.com/v1/forecast", params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
print(f"Skipping direct weather forecast; Open-Meteo request failed: {exc}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
current = data.get("current") or {}
|
||||
daily = data.get("daily") or {}
|
||||
days: list[dict[str, Any]] = []
|
||||
dates = daily.get("time") or []
|
||||
for i, date_value in enumerate(dates[:2]):
|
||||
def daily_value(name: str) -> Any:
|
||||
values = daily.get(name) or []
|
||||
return values[i] if i < len(values) else None
|
||||
|
||||
days.append(
|
||||
{
|
||||
"date": date_value,
|
||||
"condition": weather_code_text(daily_value("weather_code")),
|
||||
"temperature_min_c": daily_value("temperature_2m_min"),
|
||||
"temperature_max_c": daily_value("temperature_2m_max"),
|
||||
"precipitation_mm": daily_value("precipitation_sum"),
|
||||
"rain_mm": daily_value("rain_sum"),
|
||||
"showers_mm": daily_value("showers_sum"),
|
||||
"snowfall_cm": daily_value("snowfall_sum"),
|
||||
"precipitation_probability_max_percent": daily_value("precipitation_probability_max"),
|
||||
"wind_speed_max_kmh": daily_value("wind_speed_10m_max"),
|
||||
"wind_gusts_max_kmh": daily_value("wind_gusts_10m_max"),
|
||||
"sunrise": daily_value("sunrise"),
|
||||
"sunset": daily_value("sunset"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"source": "Open-Meteo direct net forecast",
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"current": {
|
||||
"time": current.get("time"),
|
||||
"condition": weather_code_text(current.get("weather_code")),
|
||||
"temperature_c": current.get("temperature_2m"),
|
||||
"apparent_temperature_c": current.get("apparent_temperature"),
|
||||
"humidity_percent": current.get("relative_humidity_2m"),
|
||||
"precipitation_mm": current.get("precipitation"),
|
||||
"rain_mm": current.get("rain"),
|
||||
"showers_mm": current.get("showers"),
|
||||
"snowfall_cm": current.get("snowfall"),
|
||||
"cloud_cover_percent": current.get("cloud_cover"),
|
||||
"wind_speed_kmh": current.get("wind_speed_10m"),
|
||||
"wind_gusts_kmh": current.get("wind_gusts_10m"),
|
||||
},
|
||||
"days": days,
|
||||
}
|
||||
|
||||
|
||||
def is_relevant_entity(entity_id: str) -> bool:
|
||||
return entity_id not in EXCLUDED_ENTITIES and entity_id.split(".", 1)[0] in RELEVANT_DOMAINS
|
||||
|
||||
|
|
@ -278,20 +408,34 @@ def get_history(hours: int, entity_ids: list[str]) -> list[dict[str, Any]]:
|
|||
if len(set(x["state"] for x in compact)) > 1:
|
||||
changes.append({"entity_id": entity_id, "recent_states": compact})
|
||||
|
||||
changes = sorted(changes, key=lambda x: (-entity_importance(x.get("entity_id", "")), x.get("entity_id", "")))
|
||||
if MAX_HISTORY_ENTITIES_PER_SNAPSHOT > 0:
|
||||
changes = changes[:MAX_HISTORY_ENTITIES_PER_SNAPSHOT]
|
||||
return sorted(changes, key=lambda x: x["entity_id"])
|
||||
|
||||
|
||||
def make_snapshot() -> dict[str, Any]:
|
||||
states = get_states()
|
||||
all_states = get_states()
|
||||
states = sorted(
|
||||
all_states,
|
||||
key=lambda state: (-entity_importance(state.get("entity_id", ""), state.get("attributes", {})), state.get("entity_id", "")),
|
||||
)
|
||||
if MAX_STATES_PER_SNAPSHOT > 0:
|
||||
states = states[:MAX_STATES_PER_SNAPSHOT]
|
||||
entity_ids = [state["entity_id"] for state in states]
|
||||
calendar_entity_ids = [entity_id for entity_id in entity_ids if entity_id.startswith("calendar.")]
|
||||
return {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"history_hours": HISTORY_HOURS,
|
||||
"calendar_lookahead_days": CALENDAR_LOOKAHEAD_DAYS,
|
||||
"state_count_before_limit": len(all_states),
|
||||
"state_count_after_limit": len(states),
|
||||
"max_states_per_snapshot": MAX_STATES_PER_SNAPSHOT,
|
||||
"max_history_entities_per_snapshot": MAX_HISTORY_ENTITIES_PER_SNAPSHOT,
|
||||
"states": states,
|
||||
"history": get_history(HISTORY_HOURS, entity_ids),
|
||||
"calendar_events": get_calendar_events(calendar_entity_ids),
|
||||
"weather_forecast": get_direct_weather_forecast(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -392,6 +536,27 @@ def summarize_snapshot(snapshot: dict[str, Any]) -> str:
|
|||
value = f"{state.get('state')} {unit}".strip()
|
||||
score = entity_importance(state.get("entity_id", ""), attrs)
|
||||
lines.append(f"- importance={score} {name} ({state.get('entity_id')}): {value}; last_changed={display_time(state.get('last_changed'))}")
|
||||
forecast = snapshot.get("weather_forecast") or {}
|
||||
if forecast:
|
||||
lines.append("Direct weather forecast:")
|
||||
lines.append(f"- source={forecast.get('source')}; location={forecast.get('latitude')},{forecast.get('longitude')}")
|
||||
current = forecast.get("current") or {}
|
||||
if current:
|
||||
lines.append(
|
||||
"- current: "
|
||||
f"{current.get('condition')}; temp={current.get('temperature_c')}°C; "
|
||||
f"feels_like={current.get('apparent_temperature_c')}°C; humidity={current.get('humidity_percent')}%; "
|
||||
f"precipitation={current.get('precipitation_mm')}mm; cloud_cover={current.get('cloud_cover_percent')}%; "
|
||||
f"wind={current.get('wind_speed_kmh')}km/h; gusts={current.get('wind_gusts_kmh')}km/h"
|
||||
)
|
||||
for day in forecast.get("days", []):
|
||||
lines.append(
|
||||
f"- forecast {day.get('date')}: {day.get('condition')}; "
|
||||
f"temp={day.get('temperature_min_c')}–{day.get('temperature_max_c')}°C; "
|
||||
f"precipitation={day.get('precipitation_mm')}mm; probability={day.get('precipitation_probability_max_percent')}%; "
|
||||
f"wind_max={day.get('wind_speed_max_kmh')}km/h; gusts={day.get('wind_gusts_max_kmh')}km/h; "
|
||||
f"sunrise={day.get('sunrise')}; sunset={day.get('sunset')}"
|
||||
)
|
||||
lines.append("Upcoming calendar events:")
|
||||
for calendar in snapshot.get("calendar_events", []):
|
||||
lines.append(f"- {calendar.get('entity_id')}:")
|
||||
|
|
@ -457,8 +622,18 @@ def load_recent_article_context(days: int) -> str:
|
|||
print(f"Skipping unreadable previous report {path}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
conclusions = text.split("\n## Data bundle\n", 1)[0].strip()
|
||||
articles.append(f"PREVIOUS ARTICLE {path.name}:\n{conclusions[:8000]}")
|
||||
return "\n\n---\n\n".join(articles[-7:])
|
||||
articles.append(f"PREVIOUS ARTICLE {path.name}:\n{conclusions[:MAX_PREVIOUS_ARTICLE_CHARS_PER_REPORT]}")
|
||||
|
||||
selected: list[str] = []
|
||||
total = 0
|
||||
separator_len = len("\n\n---\n\n")
|
||||
for article in reversed(articles[-7:]):
|
||||
extra = len(article) + (separator_len if selected else 0)
|
||||
if selected and total + extra > MAX_PREVIOUS_ARTICLE_CHARS:
|
||||
break
|
||||
selected.append(article)
|
||||
total += extra
|
||||
return "\n\n---\n\n".join(reversed(selected))
|
||||
|
||||
|
||||
def analysis_prompt(input_summary: str, previous_articles: str = "") -> str:
|
||||
|
|
@ -743,7 +918,6 @@ BLOG_CSS = """
|
|||
article li { margin:.35rem 0; }
|
||||
article p, article li { font-size:1.04rem; color:#e6fbff; }
|
||||
article h1 { font-size:clamp(1.8rem,4vw,3.5rem); text-align:left; }
|
||||
.title-image { display:block; width:100%; height:auto; margin:0 0 1.4rem; border:1px solid #22d3ee66; box-shadow:0 0 28px #00d9ff22; }
|
||||
article h2 { margin-top:1.8rem; padding-top:1rem; border-top:1px solid #22d3ee33; }
|
||||
article h1 + p, article h2 + p, article h3 + p { margin-top:.3rem; }
|
||||
strong { color:#ffffff; font-weight:750; }
|
||||
|
|
@ -913,17 +1087,13 @@ def write_rss_feed() -> Path:
|
|||
description = article_text[:600]
|
||||
pub_dt = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
url = site_url(f"articles/{path.name}")
|
||||
image_path = WEB_DIR / "images" / path.name.replace(".html", ".svg")
|
||||
enclosure = ""
|
||||
if image_path.exists():
|
||||
enclosure = f'\n <enclosure url="{html.escape(site_url(f"images/{image_path.name}"))}" type="image/svg+xml" length="{image_path.stat().st_size}" />'
|
||||
items.append(f"""
|
||||
<item>
|
||||
<title>{html.escape(title)}</title>
|
||||
<link>{html.escape(url)}</link>
|
||||
<guid isPermaLink="true">{html.escape(url)}</guid>
|
||||
<pubDate>{format_datetime(pub_dt, usegmt=True)}</pubDate>
|
||||
<description>{html.escape(description)}</description>{enclosure}
|
||||
<description>{html.escape(description)}</description>
|
||||
</item>""")
|
||||
now = format_datetime(datetime.now(timezone.utc), usegmt=True)
|
||||
feed_url = site_url("rss.xml")
|
||||
|
|
@ -945,10 +1115,7 @@ def write_rss_feed() -> Path:
|
|||
return path
|
||||
|
||||
|
||||
def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str, image_href: str = "") -> str:
|
||||
image_meta = ""
|
||||
if image_href:
|
||||
image_meta = f'<meta property="og:image" content="{html.escape(site_url(image_href.lstrip("/")))}">\n'
|
||||
def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -956,7 +1123,7 @@ def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str,
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="canonical" href="{html.escape(site_url())}">
|
||||
{image_meta}<link rel="alternate" type="application/rss+xml" title="Smart Home Gossip Gazette RSS" href="{html.escape(site_url('rss.xml'))}">
|
||||
<link rel="alternate" type="application/rss+xml" title="Smart Home Gossip Gazette RSS" href="{html.escape(site_url('rss.xml'))}">
|
||||
<link rel="icon" href="{html.escape(site_href('favicon.svg'))}" type="image/svg+xml">
|
||||
<style>{BLOG_CSS}</style>
|
||||
</head>
|
||||
|
|
@ -991,13 +1158,8 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
article_name = f"{now_dt:%Y-%m-%d}.html"
|
||||
body = markdownish_to_html(conclusions)
|
||||
raw = html.escape(raw_summary[:60000])
|
||||
article_title, _ = clean_rss_text(f"<article>{body}</article>")
|
||||
title_image_path = write_title_image(article_name, article_title, now)
|
||||
title_image_href = site_href(f"images/{title_image_path.name}")
|
||||
title_image_html = f'<img class="title-image" src="{html.escape(title_image_href)}" alt="{html.escape(article_title)}">'
|
||||
article_content = f"""
|
||||
<article id="article" class="article post h-entry" itemscope itemtype="https://schema.org/Article">
|
||||
{title_image_html}
|
||||
<div class="entry-content post-content e-content" itemprop="articleBody">
|
||||
{body}
|
||||
</div>
|
||||
|
|
@ -1015,7 +1177,6 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
f"Daily home intelligence briefing · Generated {now}",
|
||||
article_content,
|
||||
article_links(),
|
||||
image_href=f"images/{title_image_path.name}",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
|
@ -1023,7 +1184,6 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
featured = f"""
|
||||
<article id="article" class="article post h-entry" itemscope itemtype="https://schema.org/Article">
|
||||
<p class="meta">Latest article · {html.escape(now)}</p>
|
||||
{title_image_html}
|
||||
<div class="entry-content post-content e-content" itemprop="articleBody">
|
||||
{body}
|
||||
</div>
|
||||
|
|
@ -1032,7 +1192,7 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:
|
|||
"""
|
||||
index_path = WEB_DIR / "index.html"
|
||||
index_path.write_text(
|
||||
blog_shell("Smart Home Gossip Gazette", "A daily blog of your Home Assistant household signals", featured, article_links(), image_href=f"images/{title_image_path.name}"),
|
||||
blog_shell("Smart Home Gossip Gazette", "A daily blog of your Home Assistant household signals", featured, article_links()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_favicon()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue