added decision engine

This commit is contained in:
2026-05-10 20:07:19 -04:00
parent 62cbea7141
commit 6948755c86
2 changed files with 333 additions and 433 deletions
+231
View File
@@ -0,0 +1,231 @@
#!/bin/bash
# ==============================================================================================
# ================================= DECISION ENGINE ============================================
# ==============================================================================================
# Central behavior-driven decision kernel used by media automation systems.
#
# This engine does NOT download media.
# This engine does NOT search indexers.
# This engine does NOT manage applications directly.
#
# Instead:
# It evaluates candidates.
# Scores them against ecosystem behavior.
# Applies adaptive filtering rules.
# Returns decisions to consumer scripts.
#
# ==============================================================================================
# ── DESIGN PHILOSOPHY ─────────────────────────────────────────────────────────────────────────
#
# The ecosystem is built around:
#
# Family-aware decisions
# Time-aware weighting
# Behavior-driven adaptation
# Domain-specific strictness
#
# Each media domain consumes the engine differently:
#
# Lidarr → highly selective, quality-first discovery
# Sonarr → balanced family-aware episodic intake
# Radarr → broader flexibility with intelligent filtering
#
# The engine itself remains domain-agnostic.
# Consumers define their own thresholds, weights, and strictness profiles.
#
# This separation prevents:
#
# Cross-domain bias pollution
# Unified-feed degeneration
# Overfitting to a single user's habits
# Low-quality recommendation drift over time
#
# Result:
#
# Music stays curated and intentional
# TV stays balanced across users
# Movies remain adaptive without chaos
#
# ==============================================================================================
# ── RESPONSIBILITIES ──────────────────────────────────────────────────────────────────────────
#
# The decision engine is responsible for:
#
# Candidate scoring
# User weighting
# Temporal decay
# Popularity normalization
# Duplicate prevention
# Strictness enforcement
# Threshold evaluation
# Final decision output
#
# The engine returns:
#
# ACCEPT
# REJECT
# SCORE
# REASON
#
# Consumer scripts decide what to do with the result.
#
# ==============================================================================================
# ── ECOSYSTEM ROLE ────────────────────────────────────────────────────────────────────────────
#
# Kernel Position:
#
# Kernel/
# ├── decision_engine.sh
# ├── transcoding_engine.sh
# ├── future_engine_modules...
#
# Shared reusable logic belongs in:
#
# common.sh
#
# Shared ecosystem configuration belongs in:
#
# master.conf
#
# Host-specific secrets/configuration belong in:
#
# master_host*.conf
#
# The kernel contains:
#
# Stateful logic
# Adaptive systems
# Scoring systems
# Cross-domain intelligence
#
# ==============================================================================================
# ── VERSION ───────────────────────────────────────────────────────────────────────────────────
#
# v1.0
# Initial decision kernel architecture
# Built first for Lidarr discovery orchestration
#
# ==============================================================================================
# ==============================================================================================
# ── SCORE CANDIDATE ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Calculates weighted score for a media candidate.
#
# Inputs:
# USER_SCORE
# POPULARITY_SCORE
# RECENCY_SCORE
# QUALITY_SCORE
#
# Output:
# TOTAL_SCORE
#
# Consumer scripts define actual weighting values.
score_candidate() {
local user_score="${1:-0}"
local popularity_score="${2:-0}"
local recency_score="${3:-0}"
local quality_score="${4:-0}"
TOTAL_SCORE=$(( \
user_score + \
popularity_score + \
recency_score + \
quality_score \
))
echo "$TOTAL_SCORE"
}
# ==============================================================================================
# ── THRESHOLD CHECK ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Determines if candidate passes scoring threshold.
#
# Usage:
# evaluate_threshold "$score" "$minimum"
evaluate_threshold() {
local score="$1"
local minimum="$2"
if (( score >= minimum )); then
return 0
fi
return 1
}
# ==============================================================================================
# ── TEMPORAL DECAY ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Reduces influence of old behavior over time.
#
# Prevents:
# Permanent genre lock-in
# Historical bias accumulation
# Dead-user dominance
#
# Usage:
# apply_temporal_decay current_score age_days
apply_temporal_decay() {
local score="$1"
local age_days="$2"
local decay=$(( age_days / 30 ))
local adjusted=$(( score - decay ))
(( adjusted < 0 )) && adjusted=0
echo "$adjusted"
}
# ==============================================================================================
# ── DUPLICATE PROTECTION ──────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Prevents repetitive acquisitions.
#
# Consumer defines:
# cooldown periods
# replay windows
# duplicate tolerance
#
# Returns:
# 0 = duplicate
# 1 = unique
is_duplicate_candidate() {
local candidate="$1"
local history_file="$2"
grep -qi "^${candidate}$" "$history_file" 2>/dev/null
}
# ==============================================================================================
# ── FINAL DECISION ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Produces final engine verdict.
#
# Outputs:
# ACCEPT
# REJECT
make_decision() {
local score="$1"
local threshold="$2"
if evaluate_threshold "$score" "$threshold"; then
echo "ACCEPT"
else
echo "REJECT"
fi
}
+102 -433
View File
@@ -1,444 +1,113 @@
#currently a consecpt
#!/bin/bash #!/bin/bash
# ============================================================================================== # ==============================================================================================
# ================================== DECISION ENGINE =========================================== # =============================== LIDARR DISCOVERY =============================================
# ============================================================================================== # ==============================================================================================
# Central behavioral decision engine for the media ecosystem. # Lidarr discovery orchestrator.
# #
# This engine transforms recent user activity into weighted decision output used by # Consumes:
# downstream automation systems such as Lidarr, Sonarr, Radarr, and future services. # Kernel/decision_engine.sh
# #
# The engine is: # Purpose:
# Time-aware — recent activity matters more than historical ownership # Discover high-quality artists/albums for acquisition using
# Behavior-driven — decisions are based on actual usage patterns # family-aware and behavior-driven scoring logic.
# Family-aware — balances influence across active users dynamically
# Domain-agnostic — policies can adapt behavior per media type
# Constraint-based — no single user can dominate system-wide discovery
# #
# ── CORE PHILOSOPHY ──────────────────────────────────────────────────────────────────────────── # This script is intentionally selective.
# #
# This is NOT: # Music discovery is treated differently than TV/movies:
# • a recommendation engine
# • a media AI
# • a centralized content manager
# #
# This IS: # Higher strictness
# • a decision layer # Stronger quality bias
# • a weighting system # Lower tolerance for trend chasing
# • a behavioral context engine # Longer behavioral memory
# #
# Recent activity defines current context.
#
# Discovery influence is earned through participation, not ownership.
# Users who actively engage with media contribute more strongly to discovery weighting,
# while configurable caps prevent any single user from overwhelming the ecosystem.
#
# The result is a continuously adapting media environment that reflects the current
# behavioral state of the household rather than static long-term bias.
#
# ── RESPONSIBILITIES ───────────────────────────────────────────────────────────────────────────
#
# • Ingest recent activity from media systems and APIs
# • Normalize activity across users and domains
# • Apply time-decay and recency weighting
# • Enforce per-user influence caps
# • Generate ranked discovery candidates
# • Return structured decision output to consumer scripts
#
# ── NON-RESPONSIBILITIES ───────────────────────────────────────────────────────────────────────
#
# The decision engine NEVER:
# • Executes downloads
# • Calls arr APIs directly
# • Starts/stops containers
# • Performs filesystem operations
# • Handles infrastructure orchestration
#
# Infrastructure execution belongs to:
# • adapter scripts
# • orchestrators
# • common.sh shared runtime functions
#
# ── ARCHITECTURE ROLE ─────────────────────────────────────────────────────────────────────────
#
# Ecosystem Layers:
#
# Config Layer
# master.conf + host configs
# ↓
# Runtime Layer
# common.sh
# ↓
# Decision Layer
# decision_engine.sh ← THIS FILE
# ↓
# Domain Adapters
# lidarr_discovery.sh
# sonarr_discovery.sh
# radarr_discovery.sh
# transcoding_manager.sh
#
# This separation keeps decision logic centralized while allowing execution systems
# to evolve independently.
#
# ── FUTURE EXPANSION ──────────────────────────────────────────────────────────────────────────
#
# Planned consumers:
# • Lidarr music discovery
# • Sonarr TV discovery balancing
# • Radarr movie discovery weighting
# • Transcoding priority orchestration
# • Queue scheduling systems
# • Resource-aware automation policies
#
# Shared decision primitives may eventually include:
# • Recency decay models
# • User weighting models
# • Fairness constraints
# • Diversity scoring
# • Load-sensitive prioritization
#
# ── DESIGN RULES ──────────────────────────────────────────────────────────────────────────────
#
# • Engines decide — adapters execute
# • Policies tune behavior — engines apply logic
# • Shared infrastructure belongs in common.sh
# • Domain logic belongs in policy modules
# • No infrastructure execution inside the engine
#
# ── VERSION ───────────────────────────────────────────────────────────────────────────────────
# v1.0 — Initial decision engine architecture
# ============================================================================================== # ==============================================================================================
import requests
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
# ========================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# CONFIG ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
# =========================================================
source "$ROOT_DIR/load_config.sh"
LASTFM_API_KEY = "YOUR_LASTFM_KEY" source "$ROOT_DIR/Kernel/decision_engine.sh"
LASTFM_USER = "YOUR_USERNAME"
# ==============================================================================================
EMBY_URL = "http://YOUR_EMBY:8096" # ── CONFIG ────────────────────────────────────────────────────────────────────────────────────
EMBY_API_KEY = "YOUR_EMBY_API_KEY" # ==============================================================================================
# influence constraints DISCOVERY_THRESHOLD=70
MAX_USER_WEIGHT = 0.35 # no single user > 35%
MIN_USER_ACTIVITY = 5 # ignore near-zero listeners # ==============================================================================================
# ── EXAMPLE CANDIDATE ─────────────────────────────────────────────────────────────────────────
# scoring thresholds # ==============================================================================================
CORE_THRESHOLD = 90 #
CONTEXT_THRESHOLD = 80 # Real implementation would pull:
#
FINAL_SELECTION_LIMIT = 3 # Last.fm
# Trakt-style behavior history
# Lidarr metadata
# ========================================================= # User weighting
# PLAYLIST CONTEXT (intentional listening structure) # Genre affinity
# ========================================================= # Temporal activity
#
PLAYLIST_CONTEXTS = [ # ==============================================================================================
{"name": "90s Alternative", "tags": ["alternative", "rock", "90s"], "weight": 1.3},
{"name": "Favorites Mix", "tags": ["all-time", "mixed"], "weight": 1.5}, ARTIST_NAME="Example Artist"
{"name": "ICP / Aggressive", "tags": ["hardcore", "rap", "aggressive"], "weight": 1.2},
{"name": "Country Party", "tags": ["country", "party"], "weight": 1.3}, USER_SCORE=35
{"name": "2000s Rock", "tags": ["rock", "2000s"], "weight": 1.25}, POPULARITY_SCORE=15
] RECENCY_SCORE=10
QUALITY_SCORE=20
# ========================================================= # ==============================================================================================
# DATA MODEL # ── SCORING ───────────────────────────────────────────────────────────────────────────────────
# ========================================================= # ==============================================================================================
@dataclass TOTAL_SCORE=$(score_candidate \
class ArtistCandidate: "$USER_SCORE" \
name: str "$POPULARITY_SCORE" \
score: float = 0.0 "$RECENCY_SCORE" \
tier: str = "rotation" # core | context | rotation "$QUALITY_SCORE"
)
# ========================================================= # ==============================================================================================
# EMBY: PLAYBACK ACTIVITY (TRUTH SOURCE) # ── DECISION ──────────────────────────────────────────────────────────────────────────────────
# ========================================================= # ==============================================================================================
def get_emby_recent_events(days=30): DECISION=$(make_decision "$TOTAL_SCORE" "$DISCOVERY_THRESHOLD")
"""
Pull recent playback activity from Emby. # ==============================================================================================
Each event should include: user + artist # ── OUTPUT ────────────────────────────────────────────────────────────────────────────────────
""" # ==============================================================================================
url = f"{EMBY_URL}/Users/{EMBY_API_KEY}/Items/Latest" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
headers = {"X-Emby-Token": EMBY_API_KEY} echo "🎵 Lidarr Discovery Candidate"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
try: echo "Artist: $ARTIST_NAME"
r = requests.get(url, headers=headers) echo "Score : $TOTAL_SCORE"
data = r.json() echo "Result: $DECISION"
except Exception: echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
return []
# ==============================================================================================
events = [] # ── ACTION ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
for item in data: #
if "ArtistName" in item and "UserId" in item: # Real implementation would:
events.append({ #
"user": item["UserId"], # Add artist to Lidarr
"artist": item["ArtistName"] # Queue search
}) # Log decision
# Record scoring metadata
return events # Update history state
#
# ==============================================================================================
# =========================================================
# ACTIVE USER DETECTION (TIME-BOUND CONTEXT) if [[ "$DECISION" == "ACCEPT" ]]; then
# =========================================================
echo "Adding artist to Lidarr..."
def get_active_users(events):
user_activity = defaultdict(int) # future:
# curl -X POST "$LIDARR_URL/api/v1/artist"
for e in events:
user_activity[e["user"]] += 1 else
# filter low activity users echo "Candidate rejected."
filtered = {
u: c for u, c in user_activity.items() fi
if c >= MIN_USER_ACTIVITY
}
if not filtered:
return {}
# normalize weights
max_count = max(filtered.values())
user_weights = {}
for u, c in filtered.items():
weight = c / max_count
user_weights[u] = min(weight, MAX_USER_WEIGHT)
return user_weights
# =========================================================
# LAST.FM SIGNALS (GLOBAL TASTE GRAPH)
# =========================================================
def lastfm_similar(artist):
url = "http://ws.audioscrobbler.com/2.0/"
params = {
"method": "artist.getsimilar",
"artist": artist,
"api_key": LASTFM_API_KEY,
"format": "json",
"limit": 20
}
try:
r = requests.get(url, params=params)
data = r.json()
except Exception:
return []
results = []
try:
for a in data["similarartists"]["artist"]:
results.append({
"name": a["name"],
"match": float(a["match"])
})
except Exception:
pass
return results
def lastfm_top_artists():
url = "http://ws.audioscrobbler.com/2.0/"
params = {
"method": "user.gettopartists",
"user": LASTFM_USER,
"api_key": LASTFM_API_KEY,
"format": "json",
"limit": 50
}
try:
r = requests.get(url, params=params)
data = r.json()
except Exception:
return []
results = []
try:
for a in data["topartists"]["artist"]:
results.append((a["name"], int(a["playcount"])))
except Exception:
pass
return results
# =========================================================
# CONTEXT VECTOR (PLAYLIST BLENDING)
# =========================================================
def build_context_vector():
ctx = defaultdict(float)
for p in PLAYLIST_CONTEXTS:
for tag in p["tags"]:
ctx[tag] += p["weight"]
return ctx
# =========================================================
# SCORING ENGINE (TIME-BOUND + MULTI-SIGNAL)
# =========================================================
def build_scores():
scores = defaultdict(float)
context_vector = build_context_vector()
# -----------------------------------------------------
# 1. EMBY: playback events (current taste context)
# -----------------------------------------------------
events = get_emby_recent_events()
active_users = get_active_users(events)
user_artist_counts = defaultdict(lambda: defaultdict(int))
for e in events:
if e["user"] in active_users:
user_artist_counts[e["user"]][e["artist"]] += 1
# weighted user contribution
for user, artists in user_artist_counts.items():
weight = active_users[user]
for artist, count in artists.items():
scores[artist] += count * 50 * weight
# -----------------------------------------------------
# 2. LAST.FM: long-term identity
# -----------------------------------------------------
top = lastfm_top_artists()
for artist, plays in top:
scores[artist] += min(plays * 0.25, 80)
# -----------------------------------------------------
# 3. GRAPH EXPANSION
# -----------------------------------------------------
seeds = list(scores.keys())[:15]
for seed in seeds:
for s in lastfm_similar(seed):
scores[s["name"]] += s["match"] * 60
# -----------------------------------------------------
# 4. CONTEXT BLENDING (playlist influence)
# -----------------------------------------------------
for artist in list(scores.keys()):
for tag, weight in context_vector.items():
if tag.lower() in artist.lower():
scores[artist] += weight * 8
return scores
# =========================================================
# TIER CLASSIFICATION
# =========================================================
def classify_tier(score):
if score >= CORE_THRESHOLD:
return "core"
elif score >= CONTEXT_THRESHOLD:
return "context"
return "rotation"
# =========================================================
# SURVIVAL FILTER
# =========================================================
def filter_candidates(scores):
candidates = []
for name, score in scores.items():
candidates.append(ArtistCandidate(
name=name,
score=score,
tier=classify_tier(score)
))
candidates = [c for c in candidates if c.score >= CONTEXT_THRESHOLD]
candidates.sort(key=lambda x: x.score, reverse=True)
return candidates
# =========================================================
# FINAL SELECTION (balanced survival)
# =========================================================
def select_final(candidates):
if not candidates:
return []
final = []
cores = [c for c in candidates if c.tier == "core"]
contexts = [c for c in candidates if c.tier == "context"]
final.extend(cores[:2])
final.extend(contexts[:1])
return final[:FINAL_SELECTION_LIMIT]
# =========================================================
# OUTPUT (LIDARR HOOK)
# =========================================================
def send_to_lidarr(artists):
for a in artists:
print(f"[{a.tier.upper()}] {a.name} ({a.score:.2f})")
# =========================================================
# MAIN CYCLE
# =========================================================
def run():
print("\n=== DISCOVERY CYCLE START ===")
print(f"Time: {datetime.now()}")
scores = build_scores()
candidates = filter_candidates(scores)
final = select_final(candidates)
if not final:
print("\nNo artists survived this cycle.")
return
print("\nSurvivors:")
for f in final:
print(f"- {f.name} [{f.tier}] ({f.score:.2f})")
send_to_lidarr(final)
print("\n=== CYCLE COMPLETE ===")
if __name__ == "__main__":
run()