Add pi analysis mode and HA history filtering

This commit is contained in:
hbrain 2026-05-16 08:43:13 +00:00
parent 325917c09b
commit ba667b9e2d
3 changed files with 86 additions and 23 deletions

View file

@ -28,10 +28,16 @@ RELEVANT_DOMAINS="sensor,binary_sensor,person,device_tracker,climate,light,switc
# EXCLUDED_ENTITIES="device_tracker.my_phone,camera.front_door" # EXCLUDED_ENTITIES="device_tracker.my_phone,camera.front_door"
EXCLUDED_ENTITIES="" EXCLUDED_ENTITIES=""
# AI backend for the 05:00 analysis: none, ollama, or openai # AI backend for the 05:00 analysis: none, pi, ollama, or openai
# none publishes a page, but without real AI conclusions. # none publishes a page, but without real AI conclusions.
# pi uses your logged-in pi subscription via `pi -p`.
LLM_MODE="none" LLM_MODE="none"
# For pi subscription mode. Run `pi /login` interactively once first.
PI_BIN="/usr/local/bin/pi"
PI_MODEL=""
PI_TIMEOUT="600"
# For local Ollama, recommended for privacy # For local Ollama, recommended for privacy
OLLAMA_URL="http://localhost:11434" OLLAMA_URL="http://localhost:11434"
OLLAMA_MODEL="llama3.1" OLLAMA_MODEL="llama3.1"

View file

@ -34,6 +34,23 @@ Profile → Security → Long-lived access tokens
## AI mode for the 05:00 report ## AI mode for the 05:00 report
Use your logged-in pi subscription:
```bash
pi
/login
```
Then set:
```bash
LLM_MODE="pi"
PI_BIN="/usr/local/bin/pi"
PI_MODEL=""
```
`PI_MODEL` is optional; leave it empty to use pi's current/default model.
Local Ollama is recommended for privacy: Local Ollama is recommended for privacy:
```bash ```bash

View file

@ -16,6 +16,7 @@ import html
import json import json
import os import os
import re import re
import subprocess
import sys import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@ -35,12 +36,15 @@ 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"))
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 | ollama | openai # LLM_MODE: none | pi | ollama | openai
LLM_MODE = os.environ.get("LLM_MODE", "none").lower() LLM_MODE = os.environ.get("LLM_MODE", "none").lower()
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
PI_BIN = os.environ.get("PI_BIN", "pi")
PI_MODEL = os.environ.get("PI_MODEL", "")
PI_TIMEOUT = int(os.environ.get("PI_TIMEOUT", "600"))
RELEVANT_DOMAINS = set( RELEVANT_DOMAINS = set(
x.strip() x.strip()
@ -81,10 +85,14 @@ def require_config(for_ai: bool = False) -> None:
raise ConfigError("LLM_MODE=openai but OPENAI_API_KEY is not set") raise ConfigError("LLM_MODE=openai but OPENAI_API_KEY is not set")
def ha_get(path: str) -> Any: def ha_get(path: str, params: dict[str, str] | None = None) -> Any:
headers = {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
response = requests.get(f"{HA_URL}{path}", headers=headers, timeout=60) response = requests.get(f"{HA_URL}{path}", headers=headers, params=params, timeout=60)
try:
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text.strip()
raise requests.HTTPError(f"{exc}; response={detail[:500]}", response=response) from exc
return response.json() return response.json()
@ -115,11 +123,20 @@ def get_states() -> list[dict[str, Any]]:
return sorted(useful, key=lambda x: x["entity_id"]) return sorted(useful, key=lambda x: x["entity_id"])
def get_history(hours: int) -> list[dict[str, Any]]: def get_history(hours: int, entity_ids: list[str]) -> list[dict[str, Any]]:
start = datetime.now(timezone.utc) - timedelta(hours=hours) start = datetime.now(timezone.utc) - timedelta(hours=hours)
data = ha_get(f"/api/history/period/{start.isoformat()}?minimal_response")
changes: list[dict[str, Any]] = [] changes: list[dict[str, Any]] = []
# Recent Home Assistant versions/configurations require filter_entity_id for
# the history endpoint. Query in chunks to avoid an overlong URL.
chunk_size = 50
for i in range(0, len(entity_ids), chunk_size):
chunk = entity_ids[i : i + chunk_size]
data = ha_get(
f"/api/history/period/{start.isoformat(timespec='seconds')}",
params={"filter_entity_id": ",".join(chunk), "minimal_response": ""},
)
for entity_history in data: for entity_history in data:
if not entity_history: if not entity_history:
continue continue
@ -139,11 +156,13 @@ def get_history(hours: int) -> list[dict[str, Any]]:
def make_snapshot() -> dict[str, Any]: def make_snapshot() -> dict[str, Any]:
states = get_states()
entity_ids = [state["entity_id"] for state in states]
return { return {
"generated_at": datetime.now().isoformat(timespec="seconds"), "generated_at": datetime.now().isoformat(timespec="seconds"),
"history_hours": HISTORY_HOURS, "history_hours": HISTORY_HOURS,
"states": get_states(), "states": states,
"history": get_history(HISTORY_HOURS), "history": get_history(HISTORY_HOURS, entity_ids),
} }
@ -258,6 +277,25 @@ def call_openai(prompt: str) -> str:
return response.json()["choices"][0]["message"]["content"].strip() return response.json()["choices"][0]["message"]["content"].strip()
def call_pi(prompt: str) -> str:
cmd = [PI_BIN, "--no-tools"]
if PI_MODEL:
cmd.extend(["--model", PI_MODEL])
cmd.extend(["-p", "Analyze the Home Assistant data from stdin and write the requested briefing."])
result = subprocess.run(
cmd,
input=prompt,
text=True,
capture_output=True,
timeout=PI_TIMEOUT,
check=False,
)
if result.returncode != 0:
stderr = result.stderr.strip()
raise RuntimeError(f"pi exited with status {result.returncode}: {stderr[-1000:]}")
return result.stdout.strip()
def get_llm_conclusions(input_summary: str) -> str: def get_llm_conclusions(input_summary: 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=ollama or LLM_MODE=openai in .env. The raccoon analyst is asleep. 🦝💤"
@ -266,7 +304,9 @@ def get_llm_conclusions(input_summary: str) -> str:
return call_ollama(prompt) return call_ollama(prompt)
if LLM_MODE == "openai": if LLM_MODE == "openai":
return call_openai(prompt) return call_openai(prompt)
return f"Unknown LLM_MODE={LLM_MODE!r}. Use none, ollama, or openai." if LLM_MODE == "pi":
return call_pi(prompt)
return f"Unknown LLM_MODE={LLM_MODE!r}. Use none, pi, ollama, or openai."
def markdownish_to_html(text: str) -> str: def markdownish_to_html(text: str) -> str: