Publish blog-style daily articles

This commit is contained in:
hbrain 2026-05-16 08:50:49 +00:00
parent 480e6d08ea
commit b7e417636e
3 changed files with 149 additions and 48 deletions

View file

@ -20,6 +20,7 @@ KEEP_SNAPSHOT_DAYS="14"
# At 05:00, analyze snapshots from roughly this many hours # At 05:00, analyze snapshots from roughly this many hours
ANALYZE_SNAPSHOT_HOURS="24" ANALYZE_SNAPSHOT_HOURS="24"
ARTICLE_CONTEXT_DAYS="7"
# Domains to include # Domains to include
RELEVANT_DOMAINS="sensor,binary_sensor,person,device_tracker,climate,light,switch,lock,cover,alarm_control_panel,media_player,calendar,weather" RELEVANT_DOMAINS="sensor,binary_sensor,person,device_tracker,climate,light,switch,lock,cover,alarm_control_panel,media_player,calendar,weather"

View file

@ -4,7 +4,7 @@ Cron-friendly Home Assistant observer:
- every 30 minutes: collect compact Home Assistant snapshots into `./data` - every 30 minutes: collect compact Home Assistant snapshots into `./data`
- every day at 05:00: send the last day of snapshots to AI - every day at 05:00: send the last day of snapshots to AI
- publish a funny local webpage at `./web/index.html` - publish a funny local blog at `./web/index.html` with daily article archive links
- save Markdown AI reports in `./reports` - save Markdown AI reports in `./reports`
## Setup ## Setup
@ -102,12 +102,14 @@ Run the 05:00-style analysis/publishing step:
./run_ha_observer.sh analyze ./run_ha_observer.sh analyze
``` ```
Open the page served by nginx: Open the blog served by nginx:
```text ```text
http://localhost/haobserver/ http://localhost/haobserver/
``` ```
Daily articles are written under `web/articles/YYYY-MM-DD.html`, and `index.html` links to the archive. New articles include context from previous reports from the last `ARTICLE_CONTEXT_DAYS` days.
This uses a symlink from `/var/www/html/haobserver` to the project's `./web` directory. This uses a symlink from `/var/www/html/haobserver` to the project's `./web` directory.
## Install cron jobs ## Install cron jobs
@ -133,7 +135,7 @@ Manual crontab equivalent:
```text ```text
/home/hbrain/haobserver/data/ 30-minute JSON snapshots /home/hbrain/haobserver/data/ 30-minute JSON snapshots
/home/hbrain/haobserver/reports/ daily Markdown AI reports /home/hbrain/haobserver/reports/ daily Markdown AI reports
/home/hbrain/haobserver/web/ local funny webpage, index.html /home/hbrain/haobserver/web/ local funny blog, index.html and articles/*.html
/var/www/html/haobserver symlink to web/ for nginx /var/www/html/haobserver symlink to web/ for nginx
/home/hbrain/haobserver/cron.log cron logs /home/hbrain/haobserver/cron.log cron logs
``` ```

View file

@ -34,6 +34,7 @@ PROMPT_FILE = Path(os.environ.get("PROMPT_FILE", "./llm_instructions.md"))
HISTORY_HOURS = int(os.environ.get("HISTORY_HOURS", "24")) HISTORY_HOURS = int(os.environ.get("HISTORY_HOURS", "24"))
MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20")) MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20"))
ANALYZE_SNAPSHOT_HOURS = int(os.environ.get("ANALYZE_SNAPSHOT_HOURS", "24")) ANALYZE_SNAPSHOT_HOURS = int(os.environ.get("ANALYZE_SNAPSHOT_HOURS", "24"))
ARTICLE_CONTEXT_DAYS = int(os.environ.get("ARTICLE_CONTEXT_DAYS", "7"))
KEEP_SNAPSHOT_DAYS = int(os.environ.get("KEEP_SNAPSHOT_DAYS", "14")) KEEP_SNAPSHOT_DAYS = int(os.environ.get("KEEP_SNAPSHOT_DAYS", "14"))
# LLM_MODE: none | pi | ollama | openai # LLM_MODE: none | pi | ollama | openai
@ -225,7 +226,25 @@ def read_extra_llm_instructions() -> str:
return PROMPT_FILE.read_text(encoding="utf-8").strip() return PROMPT_FILE.read_text(encoding="utf-8").strip()
def analysis_prompt(input_summary: str) -> str: def load_recent_article_context(days: int) -> str:
if days <= 0 or not REPORT_DIR.exists():
return ""
cutoff = datetime.now() - timedelta(days=days)
articles: list[str] = []
for path in sorted(REPORT_DIR.glob("daily-ai-analysis-*.md")):
if datetime.fromtimestamp(path.stat().st_mtime) < cutoff:
continue
try:
text = path.read_text(encoding="utf-8")
except Exception as exc:
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:])
def analysis_prompt(input_summary: str, previous_articles: str = "") -> str:
extra_instructions = read_extra_llm_instructions() extra_instructions = read_extra_llm_instructions()
extra_block = "" extra_block = ""
if extra_instructions: if extra_instructions:
@ -234,21 +253,30 @@ def analysis_prompt(input_summary: str) -> str:
ADDITIONAL OWNER INSTRUCTIONS FROM {PROMPT_FILE}: ADDITIONAL OWNER INSTRUCTIONS FROM {PROMPT_FILE}:
{extra_instructions} {extra_instructions}
""" """
previous_block = ""
if previous_articles:
previous_block = f"""
return f"""You are analyzing a day of Home Assistant smart-home data for the owner. PREVIOUS ARTICLES FROM THE LAST {ARTICLE_CONTEXT_DAYS} DAYS FOR CONTEXT:
Use these only for trend/context awareness. Do not claim something happened today unless today's data supports it.
{previous_articles}
"""
Write a funny but useful morning briefing. Use light humor, emojis, and playful headings, return f"""You are writing today's Home Assistant smart-home blog article for the owner.
but remain factual and privacy-aware. Include:
Write a funny but useful morning briefing in a blog/article style. Use light humor, emojis,
and playful headings, but remain factual and privacy-aware. Include:
- A short comedy headline for the day - A short comedy headline for the day
- What seemed to happen at home - What seemed to happen at home today
- Behavioral patterns that can reasonably be inferred - Behavioral patterns that can reasonably be inferred
- Notable trends compared with recent previous articles, if supported
- What a nosy raccoon/hacker could figure out about the resident - What a nosy raccoon/hacker could figure out about the resident
- Anomalies, risks, or privacy/security concerns - Anomalies, risks, or privacy/security concerns
- Suggested Home Assistant automations or fixes - Suggested Home Assistant automations or fixes
Distinguish strong evidence from guesses. Do not invent facts not supported by the data. Distinguish strong evidence from guesses. Do not invent facts not supported by the data.
{extra_block} {extra_block}{previous_block}
DATA: TODAY'S DATA:
{input_summary} {input_summary}
""" """
@ -296,10 +324,10 @@ def call_pi(prompt: str) -> str:
return result.stdout.strip() return result.stdout.strip()
def get_llm_conclusions(input_summary: str) -> str: def get_llm_conclusions(input_summary: str, previous_articles: str = "") -> str:
if LLM_MODE == "none": if LLM_MODE == "none":
return "AI analysis disabled. Set LLM_MODE=ollama or LLM_MODE=openai in .env. The raccoon analyst is asleep. 🦝💤" return "AI analysis disabled. Set LLM_MODE=pi, LLM_MODE=ollama, or LLM_MODE=openai in .env. The raccoon analyst is asleep. 🦝💤"
prompt = analysis_prompt(input_summary) prompt = analysis_prompt(input_summary, previous_articles)
if LLM_MODE == "ollama": if LLM_MODE == "ollama":
return call_ollama(prompt) return call_ollama(prompt)
if LLM_MODE == "openai": if LLM_MODE == "openai":
@ -319,52 +347,121 @@ def markdownish_to_html(text: str) -> str:
return safe return safe
def publish_webpage(conclusions: str, raw_summary: str) -> Path: BLOG_CSS = """
WEB_DIR.mkdir(parents=True, exist_ok=True) :root { color-scheme: dark; }
now = datetime.now().strftime("%Y-%m-%d %H:%M") body { margin:0; font-family: Georgia, 'Times New Roman', serif; background:#101018; color:#eeeef6; line-height:1.65; }
body = markdownish_to_html(conclusions) header { border-bottom:1px solid #303044; background:linear-gradient(135deg,#18182a,#25193a); }
raw = html.escape(raw_summary[:60000]) .wrap { max-width:980px; margin:0 auto; padding:1.5rem; }
page = f"""<!doctype html> .masthead { padding:2.4rem 1.5rem; }
.kicker { color:#fbbf24; text-transform:uppercase; letter-spacing:.14em; font:700 .78rem system-ui,sans-serif; }
h1 { margin:.2rem 0; font-size:clamp(2.2rem,6vw,4.8rem); line-height:1; }
h2,h3 { color:#fde68a; line-height:1.2; }
article { background:#181827; border:1px solid #33334a; border-radius:22px; padding:clamp(1rem,3vw,2rem); box-shadow:0 18px 45px #0007; }
article p, article li { font-size:1.05rem; }
.layout { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:1.25rem; align-items:start; }
aside { background:#171724; border:1px solid #303044; border-radius:18px; padding:1rem; position:sticky; top:1rem; }
.archive { list-style:none; margin:0; padding:0; }
.archive li { border-bottom:1px solid #2b2b3f; padding:.55rem 0; }
.archive li:last-child { border-bottom:0; }
a { color:#93c5fd; text-decoration:none; }
a:hover { text-decoration:underline; }
.meta { color:#b7b7c8; font: .95rem system-ui,sans-serif; }
details { margin-top:1.5rem; }
pre { white-space:pre-wrap; background:#0b0b11; color:#d1d5db; padding:1rem; border-radius:12px; overflow:auto; font-size:.85rem; }
footer { color:#9999aa; text-align:center; padding:2rem; font: .9rem system-ui,sans-serif; }
@media (max-width:800px) { .layout { grid-template-columns:1fr; } aside { position:static; } }
"""
def article_links() -> str:
articles_dir = WEB_DIR / "articles"
if not articles_dir.exists():
return "<li>No articles yet. The raccoon newsroom is warming up.</li>"
links = []
for path in sorted(articles_dir.glob("*.html"), reverse=True):
label = path.stem
try:
label = datetime.strptime(path.stem, "%Y-%m-%d").strftime("%A, %B %-d, %Y")
except ValueError:
pass
links.append(f'<li><a href="articles/{html.escape(path.name)}">{html.escape(label)}</a></li>')
return "\n".join(links) or "<li>No articles yet. The raccoon newsroom is warming up.</li>"
def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str, article_href_prefix: str = "") -> str:
archive = archive_links.replace('href="articles/', f'href="{article_href_prefix}articles/')
return f"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="1800"> <title>{html.escape(title)}</title>
<title>Smart Home Gossip Gazette</title> <style>{BLOG_CSS}</style>
<style>
body {{ margin:0; font-family: system-ui, sans-serif; background: #151522; color:#f7f7fb; }}
header {{ padding: 2rem; background: linear-gradient(135deg,#7c3aed,#db2777,#f59e0b); color:white; }}
main {{ max-width: 950px; margin: 0 auto; padding: 2rem; }}
.card {{ background:#222238; border:1px solid #39395b; border-radius:20px; padding:1.4rem; box-shadow:0 12px 30px #0006; }}
h1 {{ margin:0; font-size: clamp(2rem, 5vw, 4rem); }}
h2,h3 {{ color:#fde68a; }}
li {{ margin:.35rem 0; }}
.mascot {{ font-size:3rem; float:right; animation: wiggle 2s infinite; }}
details {{ margin-top: 2rem; }}
pre {{ white-space: pre-wrap; background:#0f0f18; color:#d1d5db; padding:1rem; border-radius:12px; overflow:auto; }}
a {{ color:#93c5fd; }}
@keyframes wiggle {{ 0%,100% {{ transform: rotate(-3deg); }} 50% {{ transform: rotate(3deg); }} }}
</style>
</head> </head>
<body> <body>
<header> <header>
<div class="mascot">🦝🏠</div> <div class="wrap masthead">
<h1>Smart Home Gossip Gazette</h1> <div class="kicker">🦝 Smart Home Gossip Gazette</div>
<p>Fresh 5AM nonsense-powered intelligence briefing · Generated {html.escape(now)}</p> <h1>{html.escape(title)}</h1>
<p class="meta">{html.escape(subtitle)}</p>
</div>
</header> </header>
<main> <main class="wrap layout">
<section class="card">{body}</section> <section>{main_content}</section>
<details> <aside>
<summary>Raw data bundle shown to the AI goblin</summary> <h2>Article archive</h2>
<pre>{raw}</pre> <ul class="archive">{archive}</ul>
</details> </aside>
</main> </main>
<footer>Generated by Home Assistant Observer · Served locally by nginx</footer>
</body> </body>
</html> </html>
""" """
path = WEB_DIR / "index.html"
path.write_text(page, encoding="utf-8")
return path def publish_webpage(conclusions: str, raw_summary: str) -> Path:
WEB_DIR.mkdir(parents=True, exist_ok=True)
articles_dir = WEB_DIR / "articles"
articles_dir.mkdir(parents=True, exist_ok=True)
now_dt = datetime.now()
now = now_dt.strftime("%Y-%m-%d %H:%M")
article_name = f"{now_dt:%Y-%m-%d}.html"
body = markdownish_to_html(conclusions)
raw = html.escape(raw_summary[:60000])
article_content = f"""
<article>
{body}
<details>
<summary>Raw data bundle shown to the AI goblin</summary>
<pre>{raw}</pre>
</details>
</article>
"""
article_path = articles_dir / article_name
article_path.write_text(
blog_shell(
"Smart Home Gossip Gazette",
f"Daily home intelligence briefing · Generated {now}",
article_content,
article_links(),
article_href_prefix="../",
),
encoding="utf-8",
)
featured = f"""
<article>
<p class="meta">Latest article · {html.escape(now)}</p>
{body}
<p><a href="articles/{html.escape(article_name)}">Permanent link for this article </a></p>
</article>
"""
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()),
encoding="utf-8",
)
return article_path
def write_markdown_report(summary: str, conclusions: str) -> Path: def write_markdown_report(summary: str, conclusions: str) -> Path:
@ -390,7 +487,8 @@ def cmd_analyze() -> int:
if not snapshots: if not snapshots:
raise RuntimeError(f"No snapshots found in {DATA_DIR}; run collect first") raise RuntimeError(f"No snapshots found in {DATA_DIR}; run collect first")
summary = build_daily_summary(snapshots) summary = build_daily_summary(snapshots)
conclusions = get_llm_conclusions(summary) previous_articles = load_recent_article_context(ARTICLE_CONTEXT_DAYS)
conclusions = get_llm_conclusions(summary, previous_articles)
md_path = write_markdown_report(summary, conclusions) md_path = write_markdown_report(summary, conclusions)
html_path = publish_webpage(conclusions, summary) html_path = publish_webpage(conclusions, summary)
print(f"Wrote report: {md_path}") print(f"Wrote report: {md_path}")