Raw data bundle shown to the AI goblin
+{raw}
+ diff --git a/.env.example b/.env.example index bb1534f..f4b012a 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/README.md b/README.md index 46e7a39..f6273af 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/ha_observer.py b/ha_observer.py index c04b559..a64b9b3 100755 --- a/ha_observer.py +++ b/ha_observer.py @@ -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""" +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 "
Fresh 5AM nonsense-powered intelligence briefing ยท Generated {html.escape(now)}
+{raw}
- {raw}
+