Give AI_ASSIST_CLEANUP the consumer it has never had: it describes the shape of a classification and decides nothing, so switching it off changes no deletion

This commit is contained in:
Gmer4Lfe
2026-08-26 18:03:17 -04:00
parent 96d8a5e3f0
commit 1838eed855
3 changed files with 94 additions and 1 deletions
+44
View File
@@ -562,6 +562,50 @@ fi
# The ceiling is a per-run budget, not a veto. It still means what it always meant — no single run # The ceiling is a per-run budget, not a veto. It still means what it always meant — no single run
# removes more than RADARR_MAX_DELETE_GB — but a backlog larger than the ceiling now drains over # removes more than RADARR_MAX_DELETE_GB — but a backlog larger than the ceiling now drains over
# consecutive nights instead of failing the orchestrator forever on a queue it cannot clear. # consecutive nights instead of failing the orchestrator forever on a queue it cannot clear.
# ── AI note (AI_ASSIST_CLEANUP) ───────────────────────────────────────────────────────────────
# Describes the shape of what was classified. It decides nothing: the eligible set, the budget and
# the strikes are all settled above and none of them read this. Switch AI_ASSIST_CLEANUP off and
# the run removes exactly the same files — the log just loses a paragraph.
#
# ctime clustering is the signal worth surfacing. A normal upgrade cycle dribbles in over weeks; a
# lump sharing one narrow ctime window with mtimes spread across months is a bulk write-back, which
# is what a partnership merge against a partner holding older copies produces. That distinction
# took a person an evening on 2026-08-26 and is the whole reason this note exists.
if [[ "$ORPHAN_COUNT" -gt 0 ]] && [[ -s "$TO_DELETE_FILE" ]]; then
_ai_ev=$(awk -F'\t' '
{ n++; bytes += $1
c = int($2)
if (cmin == 0 || c < cmin) cmin = c
if (c > cmax) cmax = c
bucket[int(c / 21600)]++ }
END {
for (b in bucket) if (bucket[b] > top) { top = bucket[b] }
printf "files=%d bytes_gb=%.1f ctime_span_hours=%.1f largest_6h_ctime_bucket=%d\n",
n, bytes/1073741824, (cmax-cmin)/3600, top
}' "$TO_DELETE_FILE")
_ai_mt=$(cut -d"$(printf '\t')" -f3 "$TO_DELETE_FILE" | head -8 \
| while IFS= read -r p; do [[ -f "$p" ]] && \
printf '%s %s\n' "$(stat -c %y "$p" 2>/dev/null | cut -c1-7)" "$(basename "$p")"; done)
_ai_note=$(ai_assist_note AI_ASSIST_CLEANUP "You are looking at files an automated media-library cleanup has classified for deletion on an Unraid server. They are files on disk that the Radarr database no longer references.
EVIDENCE
$_ai_ev
sample (modification month, then path):
$_ai_mt
A normal quality-upgrade cycle produces orphans whose ctimes are spread out over weeks, because each upgrade happens on its own day. A bulk event - a sync or restore writing files back onto this host - produces orphans sharing one narrow ctime window while their modification times stay spread across months, because the copy preserves modification time but resets ctime.
In no more than three sentences, say which of those two this looks like and name the numbers above that support it. Do not recommend an action. Do not speculate beyond the evidence given.") || _ai_note=""
if [[ -n "$_ai_note" ]]; then
echo ""
echo "━━━ $ICON_GEAR AI note on this classification ━━━"
printf '%s\n' "$_ai_note"
fi
unset _ai_ev _ai_mt
fi
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt" BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then if [[ "$I_KNOW" == true ]]; then
+4 -1
View File
@@ -2044,7 +2044,10 @@
AI_ASSIST_REPORTS=false # tier 1 — digest / coffee report narration AI_ASSIST_REPORTS=false # tier 1 — digest / coffee report narration
AI_ASSIST_WATCHDOG=false # tier 2 — file a finding when a watchdog counter passes its limit (needs AI_REPAIR_ENABLED) AI_ASSIST_WATCHDOG=false # tier 2 — file a finding when a watchdog counter passes its limit (needs AI_REPAIR_ENABLED)
AI_ASSIST_DISCOVERY=false # tier 2 — discovery / classification judgement calls AI_ASSIST_DISCOVERY=false # tier 2 — discovery / classification judgement calls
AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage. Describes the shape of a
# classification in the log; decides nothing. Off = identical deletions.
AI_ASSIST_TIMEOUT=45 # seconds any single assist may take. An assist that can stall a
# nightly cleanup is not an assist — it is silently skipped past this.
AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance
# ━━━ AI Repair ━━━ # ━━━ AI Repair ━━━
+46
View File
@@ -2782,6 +2782,52 @@ apply_delete_budget() {
_BUDGET_DEFERRED_BYTES _BUDGET_DEFERRED_COUNT _BUDGET_STUCK <<< "$out" _BUDGET_DEFERRED_BYTES _BUDGET_DEFERRED_COUNT _BUDGET_STUCK <<< "$out"
} }
# Asks the local generation model to characterise a block of evidence, for the one line a human
# would otherwise have to derive by hand. Prints the note on stdout and returns 0; returns 1 and
# prints nothing whenever anything at all is missing, off, slow or malformed.
#
# The contract is that failure is indistinguishable from the feature being switched off, because
# every caller must behave identically either way. An assist that can block a nightly cleanup is
# not an assist — so this never retries, never blocks longer than its timeout, and never returns
# a partial answer for a caller to interpret.
#
# Usage: note=$(ai_assist_note AI_ASSIST_CLEANUP "$prompt") && echo "$note"
# $1 name of the AI_ASSIST_* flag governing this caller — passed by name, checked here, so a
# caller cannot accidentally run an assist its own toggle says is off
# $2 the prompt, evidence included
# $3 timeout in seconds (default AI_ASSIST_TIMEOUT, else 45)
ai_assist_note() {
local flag="$1" prompt="$2" ai_timeout="${3:-${AI_ASSIST_TIMEOUT:-45}}"
[[ "${AI_ENABLED:-false}" == "true" ]] || return 1
[[ "${!flag:-false}" == "true" ]] || return 1
[[ -n "${MY_ID:-}" ]] || return 1
command -v jq >/dev/null 2>&1 || return 1
local u_var="${MY_ID}_OLLAMA_URL" m_var="${MY_ID}_OLLAMA_MODEL"
local url="${!u_var:-}" model="${!m_var:-}"
[[ -n "$url" && -n "$model" ]] || return 1
local body out
# temperature 0: this is a description of evidence, and the same evidence should not produce a
# different characterisation on a rerun.
body=$(jq -nc --arg m "$model" --arg p "$prompt" \
'{model:$m, prompt:$p, stream:false, options:{temperature:0}}' 2>/dev/null) || return 1
out=$(timeout "$ai_timeout" curl -sf --max-time "$ai_timeout" \
-H 'Content-Type: application/json' -d "$body" \
"${url%/}/api/generate" 2>/dev/null | jq -r '.response // empty' 2>/dev/null)
# Reasoning models emit a <think> block before the answer. It is not the note — a log line a
# human is meant to skim cannot open with several hundred words of the model talking itself
# through the arithmetic. Strip to the last close tag; a response with no block is unchanged.
[[ "$out" == *"</think>"* ]] && out="${out##*</think>}"
out="${out#"${out%%[![:space:]]*}"}"
[[ -n "$out" ]] || return 1
printf '%s\n' "$out"
}
# True if filepath's extension (case-insensitive) matches one of the given extensions. # True if filepath's extension (case-insensitive) matches one of the given extensions.
# Usage: has_extension "$filepath" "${LIDARR_EXTENSIONS[@]}" # Usage: has_extension "$filepath" "${LIDARR_EXTENSIONS[@]}"
has_extension() { has_extension() {