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
ANALYZE_SNAPSHOT_HOURS="24"
ARTICLE_CONTEXT_DAYS="7"
# Domains to include
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 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`
## Setup
@ -102,12 +102,14 @@ Run the 05:00-style analysis/publishing step:
./run_ha_observer.sh analyze
```
Open the page served by nginx:
Open the blog served by nginx:
```text
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.
## Install cron jobs
@ -133,7 +135,7 @@ Manual crontab equivalent:
```text
/home/hbrain/haobserver/data/ 30-minute JSON snapshots
/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
/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"))
MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20"))
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"))
# 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()
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_block = ""
if extra_instructions:
@ -234,21 +253,30 @@ def analysis_prompt(input_summary: str) -> str:
ADDITIONAL OWNER INSTRUCTIONS FROM {PROMPT_FILE}:
{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,
but remain factual and privacy-aware. Include:
return f"""You are writing today's Home Assistant smart-home blog article for the owner.
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
- What seemed to happen at home
- What seemed to happen at home today
- 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
- Anomalies, risks, or privacy/security concerns
- Suggested Home Assistant automations or fixes
Distinguish strong evidence from guesses. Do not invent facts not supported by the data.
{extra_block}
DATA:
{extra_block}{previous_block}
TODAY'S DATA:
{input_summary}
"""
@ -296,10 +324,10 @@ def call_pi(prompt: str) -> str:
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":
return "AI analysis disabled. Set LLM_MODE=ollama or LLM_MODE=openai in .env. The raccoon analyst is asleep. 🦝💤"
prompt = analysis_prompt(input_summary)
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, previous_articles)
if LLM_MODE == "ollama":
return call_ollama(prompt)
if LLM_MODE == "openai":
@ -319,52 +347,121 @@ def markdownish_to_html(text: str) -> str:
return safe
def publish_webpage(conclusions: str, raw_summary: str) -> Path:
WEB_DIR.mkdir(parents=True, exist_ok=True)
now = datetime.now().strftime("%Y-%m-%d %H:%M")
body = markdownish_to_html(conclusions)
raw = html.escape(raw_summary[:60000])
page = f"""<!doctype html>
BLOG_CSS = """
:root { color-scheme: dark; }
body { margin:0; font-family: Georgia, 'Times New Roman', serif; background:#101018; color:#eeeef6; line-height:1.65; }
header { border-bottom:1px solid #303044; background:linear-gradient(135deg,#18182a,#25193a); }
.wrap { max-width:980px; margin:0 auto; padding:1.5rem; }
.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">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="1800">
<title>Smart Home Gossip Gazette</title>
<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>
<title>{html.escape(title)}</title>
<style>{BLOG_CSS}</style>
</head>
<body>
<header>
<div class="mascot">🦝🏠</div>
<h1>Smart Home Gossip Gazette</h1>
<p>Fresh 5AM nonsense-powered intelligence briefing · Generated {html.escape(now)}</p>
<div class="wrap masthead">
<div class="kicker">🦝 Smart Home Gossip Gazette</div>
<h1>{html.escape(title)}</h1>
<p class="meta">{html.escape(subtitle)}</p>
</div>
</header>
<main>
<section class="card">{body}</section>
<details>
<summary>Raw data bundle shown to the AI goblin</summary>
<pre>{raw}</pre>
</details>
<main class="wrap layout">
<section>{main_content}</section>
<aside>
<h2>Article archive</h2>
<ul class="archive">{archive}</ul>
</aside>
</main>
<footer>Generated by Home Assistant Observer · Served locally by nginx</footer>
</body>
</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:
@ -390,7 +487,8 @@ def cmd_analyze() -> int:
if not snapshots:
raise RuntimeError(f"No snapshots found in {DATA_DIR}; run collect first")
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)
html_path = publish_webpage(conclusions, summary)
print(f"Wrote report: {md_path}")