Publish blog-style daily articles
This commit is contained in:
parent
480e6d08ea
commit
b7e417636e
3 changed files with 149 additions and 48 deletions
188
ha_observer.py
188
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"""<!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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue