Triage the classification leaks the metadata rule says it cannot judge
The scan's own header calls reverse-anime leaks "genuine judgment calls" and reports them as a count. Seventeen of them concealed two live-action crime dramas filed under anime for as long as nobody read the list. The scan now persists that bucket as its own verdict, and the sweep asks the model to sort only those eleven — not the eleven hundred it already classifies correctly. Files findings for misfiled and uncertain only; donghua and anime-adjacent are answers, not problems, and filing them would rebuild the undifferentiated list this exists to break up. Moves nothing — --move stays a flag a human types.
This commit is contained in:
@@ -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"))"'
|
" [\(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
|
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 ━━━
|
# ━━━ Summary ━━━
|
||||||
# ==============================================================================================
|
# ==============================================================================================
|
||||||
|
|||||||
@@ -965,6 +965,141 @@ function vv_ai_watchdog_findings(?array $payload = null): array {
|
|||||||
return $found;
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Reaching the operator ────────────────────────────────────────────────────────────────────
|
||||||
// A finding nobody is told about is a finding nobody has. The card on the AI tab shows them, but
|
// 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
|
// 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_syslog_findings($since) as $c) $candidates[] = $c;
|
||||||
foreach (vv_ai_container_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;
|
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) {
|
foreach ($runs as $run) {
|
||||||
$lines = vv_ai_run_log_lines($run['log'], $run['start']);
|
$lines = vv_ai_run_log_lines($run['log'], $run['start']);
|
||||||
|
|||||||
Reference in New Issue
Block a user