diff --git a/Arrs_Stack/sonarr_classification_scan.sh b/Arrs_Stack/sonarr_classification_scan.sh index d78f031..5f1e77a 100755 --- a/Arrs_Stack/sonarr_classification_scan.sh +++ b/Arrs_Stack/sonarr_classification_scan.sh @@ -319,6 +319,31 @@ if [[ "$ENABLE_LOGGING" == true ]]; then " [\(if .forward_anime_miss then "FORWARD-ANIME" elif .forward_kids_miss then "FORWARD-KIDS" elif .reverse_anime_leak then "REVERSE-ANIME" elif .reverse_kids_leak then "REVERSE-KIDS" else "?" end)] \(.title) (root: \(.rootFolderPath), network: \(.network // "n/a"), cert: \(.certification // "n/a"))"' fi +# ── The reverse leaks, written down ─────────────────────────────────────────────────────────── +# This block's own header says it does not act on REVERSE-ANIME leaks because they are genuine +# judgement calls. That is right, and it is also why they are the one result worth persisting: +# every other bucket either self-resolves or is acted on by --move, while these accumulate as a +# number in a summary nobody can do anything with. Seventeen of them hid two live-action dramas +# filed under anime for as long as the count stayed a count. +# +# Written as the script's own verdict so anything reading it — the triage that reads this next — +# inherits the classification rather than computing a second opinion from the same metadata. +# Report-only: this records what was found, it does not change what happens to any of it. +if [[ -n "${STATE_DIR:-}" ]] && [[ "$DRY_RUN" != true ]]; then + _review_file="$STATE_DIR/arr_classification_review.json" + echo "$RESULTS" | jq -c --arg host "$MY_ID" --argjson ts "$(date +%s)" ' + { host: $host, ts: $ts, arr: "sonarr", + reverse_anime: [ .[] | select(.reverse_anime_leak) | + { title, root: .rootFolderPath, network: (.network // ""), cert: (.certification // ""), + lang: (.originalLanguage.name // .originalLanguage // ""), id: .id } ], + reverse_kids: [ .[] | select(.reverse_kids_leak) | + { title, root: .rootFolderPath, network: (.network // ""), cert: (.certification // "") } ] } + ' > "$_review_file" 2>/dev/null \ + && log "$ICON_GEAR Review list written — $REVERSE_ANIME_COUNT anime leak(s) for triage" \ + || warn "Could not write $_review_file — triage will have nothing to read" + unset _review_file +fi + # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index 4523a2a..aad9961 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -965,6 +965,141 @@ function vv_ai_watchdog_findings(?array $payload = null): array { return $found; } +// ── Classification triage ───────────────────────────────────────────────────────────────────── +// The one place in the media stack where a model beats the rule it is helping. +// +// sonarr_classification_scan.sh decides anime/kids/regular from metadata — genre, certification, +// network, original language — and that works: forward misses are zero. What it cannot decide is +// the reverse direction, which its own header calls "genuine judgment calls". A series sitting in +// the anime root with no anime signal is either misfiled or a deliberate choice, and no metadata +// field distinguishes those. So it reports a count, every night, and a count is not actionable: +// seventeen of them concealed two live-action crime dramas filed under anime for as long as +// nobody read the list. +// +// This asks only about that bucket — eleven titles, not the eleven hundred the scan handles — and +// only to sort them. It moves nothing. --move stays a flag a human types, because relocating media +// is the one action here that is tedious to undo and impossible to notice going wrong. +// +// Reads the scan's own verdict from the review file rather than re-deriving it. A second opinion +// computed from the same metadata would be the same answer with extra steps, and would drift from +// the script the first time either changed. +const VV_AI_TRIAGE_BUCKETS = ['misfiled', 'donghua', 'anime_adjacent', 'uncertain']; + +function vv_ai_assist_discovery_enabled(): bool { + if (!vv_ai_repair_enabled()) return false; + return strtolower(trim((string)(vv_conf_vars()['AI_ASSIST_DISCOVERY'] ?? 'false'))) === 'true'; +} + +function vv_ai_classification_review(): array { + $raw = @file_get_contents(STATE_DIR . '/arr_classification_review.json'); + if ($raw === false) return []; + $d = json_decode($raw, true); + return is_array($d) ? $d : []; +} + +// One call for the whole list, not one per title. Eleven separate requests would spend eleven +// model loads on a question that fits in a paragraph, and the model reads the set better than the +// items — "these six are all Chinese streaming platforms" is a judgement about the group. +function vv_ai_triage_classification(array $items, ?callable $ask = null): array { + if (!$items) return []; + + $lines = []; + foreach ($items as $i => $x) { + $lines[] = sprintf('%d. %s — network: %s, certification: %s', $i + 1, + $x['title'] ?? '?', $x['network'] ?: 'unknown', $x['cert'] ?: 'unknown'); + } + + $prompt = "These television series are filed in a library folder reserved for anime. A metadata " + . "rule could not confirm any of them as anime, so each is either misfiled or a deliberate " + . "choice by the library's owner.\n\n" + . "Sort every one into exactly one bucket:\n" + . " misfiled — not animation at all, or animation with no plausible claim to the " + . "anime shelf. Live action belongs here.\n" + . " donghua — Chinese or Korean animation.\n" + . " anime_adjacent — Japanese-produced, or Western animation made in an anime style, where " + . "shelving it as anime is defensible.\n" + . " uncertain — you do not recognise it well enough to say.\n\n" + . "Answer with one line per series, exactly: NUMBER|BUCKET|a short reason.\n" + . "Use uncertain rather than guessing. Add nothing else.\n\n" + . implode("\n", $lines); + + $reply = $ask ? $ask($prompt) : vv_ai_ask_model($prompt); + if ($reply === null) return []; + + $out = []; + foreach (explode("\n", $reply) as $line) { + if (!preg_match('/^\s*(\d+)\s*\|\s*([a-z_]+)\s*\|\s*(.+)$/i', trim($line), $m)) continue; + $idx = (int)$m[1] - 1; + $b = strtolower(trim($m[2])); + if (!isset($items[$idx]) || !in_array($b, VV_AI_TRIAGE_BUCKETS, true)) continue; + $out[] = $items[$idx] + ['bucket' => $b, 'why' => mb_substr(trim($m[3]), 0, 160)]; + } + return $out; +} + +// Deliberately not streaming and deliberately not the chat worker's path: this is one bounded +// question with no conversation, no history and no capability grants, asked from a sweep that must +// exit 0 whatever happens. A failure returns null and the caller files nothing. +function vv_ai_ask_model(string $prompt, int $timeout = 120): ?string { + $cfg = vv_ai_config(); + if (empty($cfg['enabled']) || empty($cfg['url'])) return null; + + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode([ + 'model' => $cfg['model'], + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'stream' => false, + 'think' => false, + 'options' => ['num_ctx' => 8192, 'temperature' => 0], + ]), + 'timeout' => $timeout, + 'ignore_errors' => true, + ]]); + $raw = @file_get_contents(rtrim($cfg['url'], '/') . '/api/chat', false, $ctx); + if ($raw === false) return null; + $d = json_decode($raw, true); + return $d['message']['content'] ?? null; +} + +// Findings, one per title that is not defensible where it sits. donghua and anime_adjacent are +// answers, not problems — filing them would recreate the undifferentiated list this exists to +// break up. uncertain is filed, because "the model could not tell either" is worth knowing and is +// the honest outcome for an obscure title. +function vv_ai_classification_findings(): array { + if (!vv_ai_assist_discovery_enabled()) return []; + + $rev = vv_ai_classification_review(); + $items = $rev['reverse_anime'] ?? []; + if (!$items) return []; + + $found = []; + foreach (vv_ai_triage_classification($items) as $r) { + if (!in_array($r['bucket'], ['misfiled', 'uncertain'], true)) continue; + $title = (string)($r['title'] ?? '?'); + $found[] = [ + 'kind' => 'watchdog_strike', + 'subject' => $title, + 'pin' => 'cls:' . $r['bucket'] . '|' . $title, + 'ref' => 'classification', + 'conf_key' => '', + 'conf_file' => '', + 'sys_level' => $r['bucket'] === 'misfiled' ? 'warn' : 'info', + 'observed' => $r['bucket'], + 'evidence' => sprintf('%s sits in %s. The metadata rule found no anime signal, and ' + . 'the triage calls it %s — %s. Network %s, certification %s. ' + . 'Nothing has been moved.', + $title, $r['root'] ?? 'the anime root', $r['bucket'], + $r['why'] ?? 'no reason given', + $r['network'] ?: 'unknown', $r['cert'] ?: 'unknown'), + 'source_log' => 'Arrs_Stack/sonarr_classification_scan', + 'state' => 'needs_operator', + ]; + } + return $found; +} + // ── Reaching the operator ──────────────────────────────────────────────────────────────────── // A finding nobody is told about is a finding nobody has. The card on the AI tab shows them, but // only to someone who opens the tab, and the point of this subsystem is that it works while @@ -1166,6 +1301,10 @@ function vv_ai_repair_sweep(bool $dryRun = false): array { foreach (vv_ai_syslog_findings($since) as $c) $candidates[] = $c; foreach (vv_ai_container_findings($since) as $c) $candidates[] = $c; foreach (vv_ai_watchdog_findings() as $c) $candidates[] = $c; + // The only source here that asks the model anything. Gated separately on AI_ASSIST_DISCOVERY, + // and it does nothing at all unless the classification scan has left a review file — so on a + // host that never runs that scan this costs one failed file read. + foreach (vv_ai_classification_findings() as $c) $candidates[] = $c; foreach ($runs as $run) { $lines = vv_ai_run_log_lines($run['log'], $run['start']);