Chair learning: specific reasons only, and nothing used until you confirm it
Paul, 5 November 2026: two Ring X rejections (UFC 332, The Arena) taught 'fewer Action titles for Ring X', a broad conclusion from the one genre they shared. Lessons now come from what decisions specifically share: a named editorial concept (MMA, boxing, professional wrestling, made-for-TV romance...) from two, or a genre that isn't one of the channel's own from three; a channel's defining genres never make a lesson, and a concept beats a genre for the same decisions. Every lesson is a proposal until confirmed on the Chair (confirm, dismiss, or stop using it); only confirmed lessons apply, and only while learning is on. Also a read-only check of the proposed Ring X correction (wrestling first, MMA events out); the channel's brief is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,57 +1,115 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The Director's Chair learning from your decisions (Paul, 5 November 2026): local, explainable and reversible. It reads the
|
||||
* decisions the Chair has kept and finds the patterns in them, each a lesson in plain words with the decisions behind it:
|
||||
* - a redirect: you put titles of a genre on another channel than the one suggested, at least [LESSON_MIN] times;
|
||||
* - a hold-back: you dropped titles of a genre suggested for a channel, at least [LESSON_MIN] times;
|
||||
* - agreement: how often you placed a title where the Chair suggested.
|
||||
* Lessons only change suggestions while learning is switched on ([ChairLearning.on], your switch on the Chair). Off, the Chair
|
||||
* suggests from the briefs alone and the lessons are just shown. Switching off undoes every effect at once; no decision is lost.
|
||||
* Learning never books, places or approves anything.
|
||||
* decisions the Chair has kept and looks for what they specifically share:
|
||||
* - an editorial concept named in their evidence ([EditorialConcepts]: MMA, boxing, professional wrestling, made-for-TV
|
||||
* romance...), from two decisions;
|
||||
* - otherwise a genre that isn't one of the channel's own defining genres, from three. A channel's own genres (Action on
|
||||
* Ring X) never make a lesson: they say what the channel is, not what you rejected.
|
||||
* Two kinds of lesson: a redirect (you keep putting such titles on another channel than the one suggested) and a hold-back
|
||||
* (you keep dropping such titles for a channel). Each comes with the decisions behind it. A lesson is only a proposal until
|
||||
* you confirm it on the Chair (Paul, 5 November 2026: "keep this as a proposed correction until verified"); only confirmed
|
||||
* lessons, and only while learning is on, change suggestions. Nothing here changes a channel's rules, books, places or
|
||||
* approves anything.
|
||||
*/
|
||||
internal const val LESSON_MIN = 2
|
||||
/** A genre-only pattern needs more decisions than a named concept: a genre is broad. */
|
||||
internal const val GENRE_LESSON_MIN = 3
|
||||
|
||||
internal class ChairLesson(val kind: Kind, val genre: String, val from: Int, val to: Int?, val count: Int, val titles: List<String>) {
|
||||
/** Editorial concepts a lesson can name, each read from a title's name, description, studios and genres. Specific beats broad. */
|
||||
internal val EditorialConcepts: List<Pair<String, Regex>> = listOf(
|
||||
"MMA" to Regex("\\b(UFC|MMA|mixed martial|cage (fight|bout)|octagon|Bellator|PFL|ONE Championship|Ultimate Fighting)", RegexOption.IGNORE_CASE),
|
||||
"boxing" to Regex("\\b(boxing|boxer|Matchroom|Top Rank|heavyweight (title|champion))", RegexOption.IGNORE_CASE),
|
||||
"professional wrestling" to Regex("\\b(wrestl|WWE|AEW|TNA|NJPW|ROH|Impact Wrestling|NXT|SmackDown|Stardom|WrestleMania)", RegexOption.IGNORE_CASE),
|
||||
"made-for-TV romance" to Regex("\\bTV Movie\\b.*\\bRomance\\b|\\bRomance\\b.*\\bTV Movie\\b|Hallmark|Lifetime|Great American", RegexOption.IGNORE_CASE),
|
||||
"reality competition" to Regex("\\bReality\\b.*\\b(Game Show|compete|competition|contestants)|\\b(Game Show|compete|competition|contestants)\\b.*\\bReality\\b", RegexOption.IGNORE_CASE),
|
||||
"home makeover" to Regex("\\b(renovation|makeover|Home and Garden|HGTV|designers?)\\b", RegexOption.IGNORE_CASE),
|
||||
"paranormal" to Regex("\\b(haunted|paranormal|ghosts?|poltergeist)\\b", RegexOption.IGNORE_CASE),
|
||||
"stand-up" to Regex("\\b(stand-up|standup|comedy special)\\b", RegexOption.IGNORE_CASE),
|
||||
"concert" to Regex("\\b(concert|live from|world tour)\\b", RegexOption.IGNORE_CASE))
|
||||
|
||||
/** What a title specifically is, for learning: the concepts its evidence names. */
|
||||
internal fun conceptsOf(e: TitleEvidence): Set<String> {
|
||||
val text = listOf(e.name, e.overview.orEmpty(), e.studios.orEmpty().joinToString(" "), e.genres.orEmpty().joinToString(" ")).joinToString(" | ")
|
||||
return EditorialConcepts.filter { (_, r) -> r.containsMatchIn(text) }.map { it.first }.toSet()
|
||||
}
|
||||
|
||||
/** The genres a lesson for [channel] may use: the title's own genres as the library gives them, less the channel's defining ones. */
|
||||
internal fun lessonGenres(e: TitleEvidence, channel: Int): Set<String> {
|
||||
val core = Briefs.firstOrNull { it.number == channel }?.let { it.genresAny + it.needAny }.orEmpty()
|
||||
return (e.genres.orEmpty().toSet() + e.genreSet).filter { g -> g !in core && (GenreSynonyms[g] ?: g) !in core }.toSet()
|
||||
}
|
||||
|
||||
internal class ChairLesson(val kind: Kind, val feature: String, val concept: Boolean, val from: Int, val to: Int?, val count: Int, val titles: List<String>) {
|
||||
enum class Kind { Redirect, HoldBack }
|
||||
/** Stable across reports, for confirming or dismissing it. */
|
||||
val key get() = "${kind.name}|$feature|$from|${to ?: ""}"
|
||||
fun text(): String {
|
||||
val fromName = Briefs.firstOrNull { it.number == from }?.name ?: "$from"
|
||||
val what = if (concept) "$feature titles" else "$feature titles (by genre)"
|
||||
return when (kind) {
|
||||
Kind.Redirect -> "$genre titles suggested for $fromName go on ${Briefs.firstOrNull { it.number == to }?.name ?: "$to"} ($count times: ${titles.take(3).joinToString(", ")})"
|
||||
Kind.HoldBack -> "Fewer $genre titles for $fromName: you dropped $count (${titles.take(3).joinToString(", ")})"
|
||||
Kind.Redirect -> "$what suggested for $fromName go on ${Briefs.firstOrNull { it.number == to }?.name ?: "$to"}: you placed ${titles.take(3).joinToString(", ")} there"
|
||||
Kind.HoldBack -> "Fewer $what for $fromName: you dropped ${titles.take(3).joinToString(", ")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class ChairLessons(val lessons: List<ChairLesson>, val decisions: Int, val agreed: Int, val changed: Int, val dropped: Int) {
|
||||
/** The lesson that applies to a title suggested for [channel] with [genres], redirects first. */
|
||||
fun redirectFor(channel: Int, genres: Set<String>) = lessons.firstOrNull { it.kind == ChairLesson.Kind.Redirect && it.from == channel && it.genre in genres }
|
||||
fun holdBackFor(channel: Int, genres: Set<String>) = lessons.firstOrNull { it.kind == ChairLesson.Kind.HoldBack && it.from == channel && it.genre in genres }
|
||||
private fun features(e: TitleEvidence, channel: Int) = conceptsOf(e) + lessonGenres(e, channel)
|
||||
/** The confirmed lesson that applies to [e] suggested for [channel]: concept lessons before genre ones. */
|
||||
fun redirectFor(channel: Int, e: TitleEvidence, confirmed: Set<String>) =
|
||||
lessons.firstOrNull { it.kind == ChairLesson.Kind.Redirect && it.key in confirmed && it.from == channel && it.feature in features(e, channel) }
|
||||
fun holdBackFor(channel: Int, e: TitleEvidence, confirmed: Set<String>) =
|
||||
lessons.firstOrNull { it.kind == ChairLesson.Kind.HoldBack && it.key in confirmed && it.from == channel && it.feature in features(e, channel) }
|
||||
fun summary(): String = if (decisions == 0) "No decisions yet." else
|
||||
"$decisions decisions: $agreed placed where suggested, $changed placed elsewhere, $dropped dropped."
|
||||
}
|
||||
|
||||
/** The lessons in [decisions], judged by each decided title's genres in [evidence]. Titles without evidence teach nothing. */
|
||||
/** The lessons in [decisions], judged by each decided title's evidence. Titles without evidence teach nothing. */
|
||||
internal fun chairLessons(decisions: List<ChairDecision>, evidence: Map<String, TitleEvidence>): ChairLessons {
|
||||
val judged = decisions.filter { !it.premiere && it.suggested != null }
|
||||
val redirects = HashMap<Triple<String, Int, Int>, MutableList<String>>(); val drops = HashMap<Pair<String, Int>, MutableList<String>>()
|
||||
// feature -> (is a concept) ; keyed with the channels involved, collecting the titles that share it.
|
||||
val redirects = HashMap<Triple<Pair<String, Boolean>, Int, Int>, MutableList<String>>(); val drops = HashMap<Pair<Pair<String, Boolean>, Int>, MutableList<String>>()
|
||||
for (d in judged) {
|
||||
val genres = evidence[d.id]?.genreSet.orEmpty()
|
||||
val e = evidence[d.id] ?: continue
|
||||
val suggested = d.suggested ?: continue
|
||||
if (d.placed && d.channel != null && d.channel != suggested) genres.forEach { redirects.getOrPut(Triple(it, suggested, d.channel)) { mutableListOf() } += d.title }
|
||||
if (!d.placed) genres.forEach { drops.getOrPut(it to suggested) { mutableListOf() } += d.title }
|
||||
val features = conceptsOf(e).map { it to true } + lessonGenres(e, suggested).map { it to false }
|
||||
if (d.placed && d.channel != null && d.channel != suggested) features.forEach { redirects.getOrPut(Triple(it, suggested, d.channel)) { mutableListOf() } += d.title }
|
||||
if (!d.placed) features.forEach { drops.getOrPut(it to suggested) { mutableListOf() } += d.title }
|
||||
}
|
||||
val lessons = redirects.filterValues { it.size >= LESSON_MIN }.map { (k, t) -> ChairLesson(ChairLesson.Kind.Redirect, k.first, k.second, k.third, t.size, t) } +
|
||||
drops.filterValues { it.size >= LESSON_MIN }.map { (k, t) -> ChairLesson(ChairLesson.Kind.HoldBack, k.first, k.second, null, t.size, t) }
|
||||
return ChairLessons(lessons.sortedByDescending { it.count }, judged.size, judged.count { it.placed && it.channel == it.suggested },
|
||||
judged.count { it.placed && it.channel != it.suggested }, judged.count { !it.placed })
|
||||
fun enough(feature: Pair<String, Boolean>, n: Int) = n >= if (feature.second) LESSON_MIN else GENRE_LESSON_MIN
|
||||
val found = redirects.filter { enough(it.key.first, it.value.size) }.map { (k, t) -> ChairLesson(ChairLesson.Kind.Redirect, k.first.first, k.first.second, k.second, k.third, t.size, t) } +
|
||||
drops.filter { enough(it.key.first, it.value.size) }.map { (k, t) -> ChairLesson(ChairLesson.Kind.HoldBack, k.first.first, k.first.second, k.second, null, t.size, t) }
|
||||
// The same decisions explained by a concept and by a genre: the concept is the more specific reason, so the genre one goes.
|
||||
val lessons = found.filter { l -> l.concept || found.none { o -> o.concept && o.kind == l.kind && o.from == l.from && o.to == l.to && o.titles.toSet().containsAll(l.titles) } }
|
||||
return ChairLessons(lessons.sortedWith(compareByDescending<ChairLesson> { it.concept }.thenByDescending { it.count }), judged.size,
|
||||
judged.count { it.placed && it.channel == it.suggested }, judged.count { it.placed && it.channel != it.suggested }, judged.count { !it.placed })
|
||||
}
|
||||
|
||||
/** Your learning switch on the Chair, kept on the box. Off unless you turn it on. */
|
||||
/**
|
||||
* Your learning settings on the Chair, kept on the box: the switch (off unless you turn it on), and the lessons you've confirmed
|
||||
* or dismissed. Only confirmed lessons are ever used.
|
||||
*/
|
||||
internal class LearningState(val on: Boolean = false, val confirmed: Set<String> = emptySet(), val dismissed: Set<String> = emptySet())
|
||||
|
||||
internal object ChairLearning {
|
||||
fun on(file: File): Boolean = runCatching { JSONObject(file.readText()).optBoolean("on") }.getOrDefault(false)
|
||||
fun set(file: File, on: Boolean) = runCatching { file.writeText(JSONObject().put("on", on).toString()) }
|
||||
fun load(file: File): LearningState = runCatching {
|
||||
val o = JSONObject(file.readText())
|
||||
fun set(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() }.orEmpty()
|
||||
LearningState(o.optBoolean("on"), set("confirmed"), set("dismissed"))
|
||||
}.getOrDefault(LearningState())
|
||||
fun on(file: File): Boolean = load(file).on
|
||||
private fun save(file: File, s: LearningState) = runCatching {
|
||||
file.writeText(JSONObject().put("on", s.on).put("confirmed", JSONArray(s.confirmed.toList())).put("dismissed", JSONArray(s.dismissed.toList())).toString())
|
||||
}
|
||||
fun set(file: File, on: Boolean) = load(file).let { save(file, LearningState(on, it.confirmed, it.dismissed)) }
|
||||
/** Confirms ([confirm] true) or dismisses a lesson by its key; either way it's no longer a proposal. */
|
||||
fun decide(file: File, key: String, confirm: Boolean) = load(file).let {
|
||||
save(file, LearningState(it.on, if (confirm) it.confirmed + key else it.confirmed - key, if (confirm) it.dismissed - key else it.dismissed + key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ internal class DeskPick(val title: String, val channel: String, val tag: String,
|
||||
internal class DeskAsk(val title: String, val first: String, val second: String, val id: String = "", val type: String = "", val year: Int? = null,
|
||||
val options: List<DeskOption> = emptyList(), val line: String = "", val firstRun: DeskOption? = null)
|
||||
|
||||
/** A lesson as the Chair shows it: proposed until you confirm or dismiss it. */
|
||||
internal class DeskLesson(val key: String, val text: String, val state: String)
|
||||
|
||||
/** One of this week's Orbit Premiere proposals: why, the doubts, its home and what happens after its window. Read only. */
|
||||
internal class DeskPremiere(val id: String, val title: String, val year: Int?, val priority: Double, val home: DeskOption?, val after: String, val remainsOnPremiere: Boolean,
|
||||
val reasons: List<String>, val cautions: List<String>) {
|
||||
@@ -83,14 +86,14 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca
|
||||
/** Premiere debuts you've approved, waiting for the booking workflow. */
|
||||
val premiereApproved: List<String> = emptyList(),
|
||||
/** What the Chair has learned from your decisions, in plain words, and whether it's allowed to use it. */
|
||||
val learnedSummary: String = "", val lessons: List<String> = emptyList(), val learningOn: Boolean = false) {
|
||||
val learnedSummary: String = "", val lessons: List<DeskLesson> = emptyList(), val learningOn: Boolean = false) {
|
||||
fun toJson(): JSONObject = JSONObject().put("newlyReleased", newlyReleased).put("newToLibrary", newToLibrary).put("scanned", scanned).put("unread", unread)
|
||||
.put("strong", strong).put("strongByChannel", JSONArray(strongByChannel.map { JSONObject().put("c", it.first).put("n", it.second) }))
|
||||
.put("picks", JSONArray(picks.map { JSONObject().put("t", it.title).put("c", it.channel).put("g", it.tag).put("l", it.line).item(it.id, it.type, it.year, it.options, it.firstRun) }))
|
||||
.put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second).put("l", it.line).item(it.id, it.type, it.year, it.options, it.firstRun) }))
|
||||
.put("collections", collections).put("franchises", franchises).put("catalogueComplete", catalogueComplete).put("computedMs", computedMs).put("tookMs", tookMs)
|
||||
.put("decided", decided).put("premieres", JSONArray(premieres.map { it.toJson() })).put("premiereQueued", premiereQueued).put("premiereApproved", JSONArray(premiereApproved))
|
||||
.put("learnedSummary", learnedSummary).put("lessons", JSONArray(lessons)).put("learningOn", learningOn)
|
||||
.put("learnedSummary", learnedSummary).put("lessons", JSONArray(lessons.map { JSONObject().put("k", it.key).put("t", it.text).put("s", it.state) })).put("learningOn", learningOn)
|
||||
companion object {
|
||||
fun of(o: JSONObject): DeskReport {
|
||||
fun list(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty()
|
||||
@@ -101,7 +104,7 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca
|
||||
o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs"), o.optInt("decided"),
|
||||
list("premieres").map(DeskPremiere::of), o.optInt("premiereQueued"),
|
||||
o.optJSONArray("premiereApproved")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty(),
|
||||
o.optString("learnedSummary"), o.optJSONArray("lessons")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty(), o.optBoolean("learningOn"))
|
||||
o.optString("learnedSummary"), list("lessons").map { DeskLesson(it.optString("k"), it.optString("t"), it.optString("s")) }, o.optBoolean("learningOn"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +197,7 @@ internal fun franchiseLinks(file: File, pools: Map<Int, List<String>>): Triple<F
|
||||
*/
|
||||
internal fun buildDeskReport(evidenceFile: File, collectionsFile: File, pools: Map<Int, List<String>>, released: List<Arrival>, carriedWorks: Set<String>,
|
||||
originals: Map<String, String>, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(),
|
||||
decided: Set<String> = emptySet(), premiere: PremiereInputs = PremiereInputs(), history: List<ChairDecision> = emptyList(), learning: Boolean = false): DeskReport =
|
||||
decided: Set<String> = emptySet(), premiere: PremiereInputs = PremiereInputs(), history: List<ChairDecision> = emptyList(), learning: LearningState = LearningState()): DeskReport =
|
||||
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided, premiere, history, learning).getValue(mode)
|
||||
|
||||
/** What Orbit Premiere's proposals are judged from: the catalogue's recent films, each film's Orbit status and this week's premiere decisions. */
|
||||
@@ -211,7 +214,7 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
|
||||
/** Titles you've placed or dropped on the Chair: never shown again, so the next ones take their place. */
|
||||
decided: Set<String> = emptySet(), premiere: PremiereInputs = PremiereInputs(),
|
||||
/** Your decisions, for what the Chair has learned ([chairLessons]); [learning] is your switch for letting it change suggestions. */
|
||||
history: List<ChairDecision> = emptyList(), learning: Boolean = false): Map<DirectorMode, DeskReport> {
|
||||
history: List<ChairDecision> = emptyList(), learning: LearningState = LearningState()): Map<DirectorMode, DeskReport> {
|
||||
val started = System.nanoTime()
|
||||
val evidence = if (evidenceFile.exists()) evidenceFile.readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() }
|
||||
.associate { it.optString("id") to TitleEvidence.of(it, originals[it.optString("id")]) } else emptyMap()
|
||||
@@ -245,8 +248,10 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
|
||||
fun recent(id: String) = premiered[id]?.takeIf { it.length == 10 }?.let { runCatching { LocalDate.parse(it) }.getOrNull() }?.let { !it.isBefore(today.minusDays(14)) } == true
|
||||
val lessons = chairLessons(history, evidence)
|
||||
// Learning on: titles like ones you keep dropping are held back, and a redirect you keep making becomes the suggestion.
|
||||
val heldBack = if (learning) placed.filter { lessons.holdBackFor(it.top.number, it.evidence.genreSet) != null }.map { it.evidence.id }.toSet() else emptySet()
|
||||
fun learned(p: Placement): ChairLesson? = if (learning) lessons.redirectFor(p.top.number, p.evidence.genreSet) else null
|
||||
// Only lessons you've confirmed, and only while learning is on.
|
||||
val confirmed = if (learning.on) learning.confirmed else emptySet()
|
||||
val heldBack = placed.filter { lessons.holdBackFor(it.top.number, it.evidence, confirmed) != null }.map { it.evidence.id }.toSet()
|
||||
fun learned(p: Placement): ChairLesson? = lessons.redirectFor(p.top.number, p.evidence, confirmed)
|
||||
fun choicesFor(p: Placement): List<DeskOption> {
|
||||
val all = channelChoices(p, text, links)
|
||||
val lesson = learned(p) ?: return all
|
||||
@@ -266,7 +271,8 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
|
||||
p.evidence.overview?.let { sentence(it) } ?: "No description in the library yet", p.evidence.id, p.evidence.type, p.evidence.year, choicesFor(p))
|
||||
}
|
||||
DeskReport(candidates.size, newToLibrary, read.size, candidates.size - read.size, byChannel, strong.size, picks, asks, collections, catalogueComplete, franchises, nowMs, tookMs, placed.count { it.evidence.id in decided }, premieres, premiereQueued,
|
||||
PremiereCalls.filter { it.premiere }.map { it.title }, lessons.summary(), lessons.lessons.map { it.text() }, learning)
|
||||
PremiereCalls.filter { it.premiere }.map { it.title }, lessons.summary(),
|
||||
lessons.lessons.filter { it.key !in learning.dismissed }.map { DeskLesson(it.key, it.text(), if (it.key in learning.confirmed) "confirmed" else "proposed") }, learning.on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +299,7 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us
|
||||
val newToLibrary = arrivals.count { nowMs - it.firstSeenMs < NEW_TO_LIBRARY_DAYS * 24 * 60 * 60_000L }
|
||||
val reports = buildDeskReports(evidence, collections, ids, released, carriedWorks, catalogue.originalTitles(released.map { it.id }), newToLibrary, catalogue.complete(), nowMs,
|
||||
decided = loadDecisions(decisions).map { it.id }.toSet(), premiere = premiereInputs(store, catalogue, userId, nowMs),
|
||||
history = loadDecisions(decisions), learning = ChairLearning.on(store.learningFile()))
|
||||
history = loadDecisions(decisions), learning = ChairLearning.load(store.learningFile()))
|
||||
runCatching { cache.writeText(JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) }
|
||||
val b = reports.getValue(DirectorMode.Balanced)
|
||||
android.util.Log.i("OrbitSchedule", "director newlyReleased=${b.newlyReleased} read=${b.scanned} strong=${b.strong} asks=${b.asks.size} collections=${b.collections} tookMs=${b.tookMs}")
|
||||
@@ -352,7 +358,9 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
|
||||
/** Premiere first, then [DeskOption] as its home; returns what to tell you (the debut, or why there's none). */
|
||||
premiere: suspend (ChairChoice, DeskOption) -> String = { _, _ -> "" },
|
||||
/** Your learning switch: saved, then the report is worked out again. */
|
||||
setLearning: suspend (Boolean) -> Unit = {}) {
|
||||
setLearning: suspend (Boolean) -> Unit = {},
|
||||
/** Confirms (true) or dismisses (false) a proposed lesson by its key. */
|
||||
decideLesson: suspend (String, Boolean) -> Unit = { _, _ -> }) {
|
||||
var mode by remember { mutableStateOf(DirectorMode.Balanced) }
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
var report by remember { mutableStateOf<DeskReport?>(null) }
|
||||
@@ -360,6 +368,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
|
||||
var decided by remember { mutableStateOf(emptySet<String>()) }
|
||||
var open by remember { mutableStateOf<ChairChoice?>(null) }
|
||||
var said by remember { mutableStateOf<String?>(null) }
|
||||
var lesson by remember { mutableStateOf<DeskLesson?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(mode, version) { withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() }?.let { report = it } }
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -430,13 +439,33 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("WHAT IT'S LEARNED", color = Color.White, fontFamily = Anton, fontSize = 13.sp)
|
||||
Text(r.learnedSummary.ifEmpty { "No decisions yet." }, color = Muted, fontSize = 10.sp)
|
||||
if (r.lessons.isEmpty()) Text("Nothing to change yet: a pattern needs two decisions that go the same way.", color = Muted, fontSize = 10.sp)
|
||||
r.lessons.take(3).forEach { Text("· $it", color = if (r.learningOn) ChairMint else Muted, fontSize = 10.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) }
|
||||
if (r.lessons.isEmpty()) Text("Nothing to change yet: a pattern needs decisions that share something specific.", color = Muted, fontSize = 10.sp)
|
||||
r.lessons.take(4).forEach { l ->
|
||||
ChairCard(if (l.state == "confirmed") "CONFIRMED" else "PROPOSED: PRESS TO CONFIRM OR DISMISS", l.text, null, if (l.state == "confirmed") "In use while learning is on" else "Not used until you confirm it",
|
||||
onClick = { lesson = l })
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(said ?: "Press a title to place it or drop it. A placed title joins that channel's list at its next top-up; nothing already booked changes. Worked out in ${r.tookMs} ms.",
|
||||
color = if (said != null) ChairMint else Muted, fontSize = 10.sp)
|
||||
}
|
||||
lesson?.let { l ->
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = { lesson = null }, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { first.requestFocus() } }
|
||||
Column(Modifier.width(560.dp).background(ChairNavy, RoundedCornerShape(10.dp)).border(2.dp, ChairGold, RoundedCornerShape(10.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (l.state == "confirmed") "CONFIRMED LESSON" else "PROPOSED LESSON", color = ChairGold, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(l.text, color = Color.White, fontSize = 15.sp)
|
||||
Text("Confirmed, it shapes suggestions while learning is on. It never changes a channel's rules or anything booked.", color = Muted, fontSize = 11.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (l.state != "confirmed") OrbitButton("Confirm", Modifier.focusRequester(first), onClick = { lesson = null; scope.launch { decideLesson(l.key, true); said = "Lesson confirmed."; version++ } })
|
||||
OrbitButton(if (l.state == "confirmed") "Stop using it" else "Dismiss", if (l.state == "confirmed") Modifier.focusRequester(first) else Modifier,
|
||||
onClick = { lesson = null; scope.launch { decideLesson(l.key, false); said = "Lesson dismissed: the Chair won't use it."; version++ } })
|
||||
OrbitButton("Not now", onClick = { lesson = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open?.let { choice ->
|
||||
ChairPopup(choice, onPlace = { option ->
|
||||
open = null; decided = decided + choice.id; place(choice, option)
|
||||
|
||||
@@ -83,7 +83,7 @@ internal val Briefs: List<Brief> = listOf(
|
||||
|
||||
private val KidsOk = setOf("G", "PG", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "U", "TV-Y7-FV", "E", "0+", "3+", "6+", "7+")
|
||||
private val Adult = setOf("R", "TV-MA", "NC-17", "18", "18+", "X", "NR18", "SG-R21", "SG-M18", "GB-18")
|
||||
private val GenreSynonyms = mapOf("Anime" to "Animation", "Martial Arts" to "Action", "Sci-Fi & Fantasy" to "Science Fiction", "Action & Adventure" to "Action",
|
||||
internal val GenreSynonyms = mapOf("Anime" to "Animation", "Martial Arts" to "Action", "Sci-Fi & Fantasy" to "Science Fiction", "Action & Adventure" to "Action",
|
||||
"Kids" to "Children", "Suspense" to "Thriller", "Sports" to "Sport")
|
||||
private val Uk = Regex("United Kingdom|England|Scotland|Wales|\\bUK\\b")
|
||||
private val UkStudio = Regex("\\bBBC\\b|\\bITV\\b|Channel 4|Film4|Sky\\b|Working Title|Ealing|Hammer|Aardman", RegexOption.IGNORE_CASE)
|
||||
|
||||
@@ -524,7 +524,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
if (debut == null) "${choice.title} wasn't placed: it has been booked on Orbit already, or there was no free Friday or Saturday night in the next three weeks."
|
||||
else "${choice.title} premieres on Orbit Premiere ${java.time.Instant.ofEpochMilli(debut).atZone(java.time.ZoneId.systemDefault()).let { "%s %d %s at %02d:%02d".format(it.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.dayOfMonth, it.month.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.hour, it.minute) }}, then joins ${home.name}."
|
||||
} },
|
||||
setLearning = { on -> withContext(Dispatchers.IO) { ChairLearning.set(scheduleStore.learningFile(), on) }; android.util.Log.i("OrbitSchedule", "director learning=$on") }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
|
||||
setLearning = { on -> withContext(Dispatchers.IO) { ChairLearning.set(scheduleStore.learningFile(), on) }; android.util.Log.i("OrbitSchedule", "director learning=$on") },
|
||||
decideLesson = { key, confirm -> withContext(Dispatchers.IO) { ChairLearning.decide(scheduleStore.learningFile(), key, confirm) }; android.util.Log.i("OrbitSchedule", "director lesson confirmed=$confirm") }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
|
||||
onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on ->
|
||||
val tots = scheduledChannels[TOTS_NUMBER]
|
||||
val active = session
|
||||
|
||||
@@ -6,40 +6,56 @@ import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/** The Chair learning from your decisions: patterns of two or more, explained, used only while learning is on. */
|
||||
/** The Chair learning from your decisions: specific shared reasons, never a channel's own genre, used only once you confirm them. */
|
||||
class ChairLearningTest {
|
||||
private fun ev(id: String, title: String, genres: List<String>) = TitleEvidence(id, "Movie", title, 2026, "A story.", genres, null, null, null, null)
|
||||
private fun ev(id: String, title: String, genres: List<String>, overview: String = "A story.", studios: List<String>? = null, type: String = "Movie") =
|
||||
TitleEvidence(id, type, title, 2026, overview, genres, studios, null, null, null)
|
||||
private fun placed(id: String, title: String, channel: Int, suggested: Int) = ChairDecision(id, title, "Movie", true, channel, suggested, 1)
|
||||
private fun dropped(id: String, title: String, suggested: Int) = ChairDecision(id, title, "Movie", false, null, suggested, 1)
|
||||
|
||||
@Test fun twoMatchingDecisionsMakeALessonOneDoesNot() {
|
||||
val evidence = mapOf("1" to ev("1", "Gore Night", listOf("Horror")), "2" to ev("2", "Splatter Party", listOf("Horror")), "3" to ev("3", "Wedding", listOf("Romance")),
|
||||
"4" to ev("4", "Big Day", listOf("Romance")), "5" to ev("5", "Lone Shark", listOf("Horror")))
|
||||
val lessons = chairLessons(listOf(placed("1", "Gore Night", 305, 304), placed("2", "Splatter Party", 305, 304), dropped("3", "Wedding", 307), dropped("4", "Big Day", 307),
|
||||
placed("5", "Lone Shark", 304, 304)), evidence)
|
||||
assertEquals(5, lessons.decisions); assertEquals(1, lessons.agreed); assertEquals(2, lessons.changed); assertEquals(2, lessons.dropped)
|
||||
assertEquals(305, lessons.redirectFor(304, setOf("Horror"))!!.to)
|
||||
assertNotNull(lessons.holdBackFor(307, setOf("Romance")))
|
||||
assertNull(lessons.redirectFor(304, setOf("Comedy")))
|
||||
assertTrue(lessons.lessons.first { it.kind == ChairLesson.Kind.Redirect }.text().contains("Gore Night")) // explained by its own decisions
|
||||
// One decision alone is no pattern.
|
||||
assertTrue(chairLessons(listOf(placed("1", "Gore Night", 305, 304)), evidence).lessons.isEmpty())
|
||||
// Paul's two Ring X rejections, as the library has them.
|
||||
private val ufc = ev("u", "UFC 332: Silva vs. Wang", listOf("Action", "Drama"), "UFC 332 was a mixed martial arts event produced by the Ultimate Fighting Championship.", listOf("Ultimate Fighting Championship"))
|
||||
private val arena = ev("a", "The Arena (2026)", listOf("Game Show", "Martial Arts", "Reality", "Sport"), "Fighters face brutal tests and cage bouts for a shot at a pro MMA contract.", listOf("Netflix"), "Series")
|
||||
|
||||
@Test fun twoMmaRejectionsTeachMmaNotAction() {
|
||||
val lessons = chairLessons(listOf(dropped("u", ufc.name, 401), dropped("a", arena.name, 401)), mapOf("u" to ufc, "a" to arena))
|
||||
val lesson = lessons.lessons.single()
|
||||
assertEquals("MMA", lesson.feature); assertTrue(lesson.concept); assertEquals(401, lesson.from)
|
||||
assertTrue(lessons.lessons.none { it.feature == "Action" }) // Ring X's own genre is never the lesson
|
||||
assertTrue(lesson.text().contains("UFC 332") && lesson.text().contains("The Arena"))
|
||||
// Confirmed, it holds back MMA for Ring X, and not professional wrestling, though both are Action.
|
||||
val wrestling = ev("w", "WWE Survivor Series 2026", listOf("Action", "Sport"), "WWE's annual pay-per-view wrestling event.")
|
||||
val mma = ev("m", "UFC 333", listOf("Action"), "A UFC mixed martial arts event.")
|
||||
assertNull(lessons.holdBackFor(401, mma, emptySet())) // a proposal does nothing
|
||||
assertNotNull(lessons.holdBackFor(401, mma, setOf(lesson.key)))
|
||||
assertNull(lessons.holdBackFor(401, wrestling, setOf(lesson.key)))
|
||||
}
|
||||
|
||||
@Test fun lessonsChangeSuggestionsOnlyWhileLearningIsOn() {
|
||||
val horror = { id: String, name: String -> JSONObject().put("id", id).put("type", "Movie").put("name", name).put("year", 2026)
|
||||
.put("overview", "A haunted house, a demon and a curse: terror for a family in the night.").put("genres", JSONArray(listOf("Horror"))).put("studios", JSONObject.NULL)
|
||||
@Test fun aBroadGenreNeedsThreeAndASpecificReasonWins() {
|
||||
val e = mapOf("1" to ev("1", "Wedding One", listOf("Romance", "Musical")), "2" to ev("2", "Wedding Two", listOf("Romance", "Musical")), "3" to ev("3", "Wedding Three", listOf("Romance", "Musical")))
|
||||
assertTrue(chairLessons(listOf(dropped("1", "Wedding One", 307), dropped("2", "Wedding Two", 307)), e).lessons.isEmpty()) // two by genre: not enough
|
||||
assertEquals("Musical", chairLessons(listOf(dropped("1", "Wedding One", 307), dropped("2", "Wedding Two", 307), dropped("3", "Wedding Three", 307)), e).lessons.single().feature)
|
||||
// Redirects work the same way: two horror-comedies with gore both moved to After Dark.
|
||||
val g = mapOf("4" to ev("4", "Gore Night", listOf("Horror", "Comedy"), "A splatter party."), "5" to ev("5", "Blood Bath", listOf("Horror", "Comedy"), "A gross-out splatter romp."))
|
||||
assertTrue(chairLessons(listOf(placed("4", "Gore Night", 305, 304), placed("5", "Blood Bath", 305, 304)), g).lessons.none { it.feature == "Horror" })
|
||||
}
|
||||
|
||||
@Test fun lessonsChangeSuggestionsOnlyOnceConfirmedAndLearningIsOn() {
|
||||
val line = { id: String, name: String, ov: String -> JSONObject().put("id", id).put("type", "Movie").put("name", name).put("year", 2026)
|
||||
.put("overview", ov).put("genres", JSONArray(listOf("Action", "Sport"))).put("studios", JSONObject.NULL)
|
||||
.put("rating", JSONObject.NULL).put("countries", JSONArray(listOf("United States"))).put("taglines", JSONObject.NULL) }
|
||||
val evidence = File.createTempFile("evidence", ".jsonl").apply { deleteOnExit(); writeText(listOf(horror("1", "Gore Night"), horror("2", "Splatter Party"), horror("9", "The Haunting"), horror("10", "The Curse")).joinToString("") { "$it\n" }) }
|
||||
val evidence = File.createTempFile("evidence", ".jsonl").apply { deleteOnExit(); writeText(listOf(line("1", "UFC 330", "A UFC mixed martial arts pay-per-view event with a title bout."),
|
||||
line("2", "UFC 331", "A UFC mixed martial arts pay-per-view event with a title bout."), line("9", "UFC 334", "A UFC mixed martial arts pay-per-view event with a title bout.")).joinToString("") { "$it\n" }) }
|
||||
val sets = File.createTempFile("collections", ".jsonl").apply { delete(); deleteOnExit() }
|
||||
val history = listOf(placed("1", "Gore Night", 305, 304), placed("2", "Splatter Party", 305, 304))
|
||||
fun report(learning: Boolean) = buildDeskReport(evidence, sets, emptyMap(), listOf(Arrival("9", "Movie", "The Haunting", 2026, "2026-10-30", "tmdb:9", 0)),
|
||||
emptySet(), emptyMap(), 1, true, DirectorMode.Balanced, 1_793_800_000_000, decided = setOf("1", "2"), history = history, learning = learning)
|
||||
val off = report(false); val on = report(true)
|
||||
assertEquals(1, off.lessons.size); assertFalse(off.learningOn)
|
||||
val offFirst = (off.picks.map { it.options } + off.asks.map { it.options }).single().first()
|
||||
val onFirst = (on.picks.map { it.options } + on.asks.map { it.options }).single().first()
|
||||
assertEquals(304, offFirst.number) // off: the brief's own suggestion
|
||||
assertEquals(305, onFirst.number); assertTrue(onFirst.reason.startsWith("learned from you"))
|
||||
val history = listOf(dropped("1", "UFC 330", 401), dropped("2", "UFC 331", 401))
|
||||
fun report(state: LearningState) = buildDeskReport(evidence, sets, emptyMap(), listOf(Arrival("9", "Movie", "UFC 334", 2026, "2026-10-30", "tmdb:9", 0)),
|
||||
emptySet(), emptyMap(), 1, true, DirectorMode.Balanced, 1_793_800_000_000, decided = setOf("1", "2"), history = history, learning = state)
|
||||
fun shown(r: DeskReport) = (r.picks.map { it.id } + r.asks.map { it.id }).contains("9")
|
||||
val proposed = report(LearningState(on = true))
|
||||
assertEquals("proposed", proposed.lessons.single().state); assertTrue(shown(proposed)) // on, but not confirmed: nothing changes
|
||||
val key = proposed.lessons.single().key
|
||||
assertFalse(shown(report(LearningState(on = true, confirmed = setOf(key))))) // confirmed and on: held back
|
||||
assertTrue(shown(report(LearningState(on = false, confirmed = setOf(key))))) // off undoes it
|
||||
assertTrue(report(LearningState(on = true, dismissed = setOf(key))).lessons.isEmpty()) // dismissed: gone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Read-only check of the proposed Ring X correction (Paul, 5 Nov 2026): professional wrestling first, MMA events no longer Ring X. Not applied.
|
||||
import sys,json,re
|
||||
S='/tmp/claude-1000/-home-pauljd-Projects/c539a8ea-4816-4f5b-8d2a-3efd663b12ac/scratchpad'
|
||||
sys.argv=['x',S+'/rx',S+'/fixture2']
|
||||
src=open('/home/pauljd/Documents/Codex/orbit-tv/docs/replays/stage-d-trial-7.py').read().split("if __name__=='__main__':")[0]
|
||||
g={}; exec(src,g)
|
||||
ev,place,B,W,score=g['ev'],g['place'],g['B'],g['W'],g['score']
|
||||
items=list(ev.values())
|
||||
before={r['id']:place(r) for r in items}
|
||||
PROMO=re.compile(r"WWE|World Wrestling|All Elite Wrestling|\bAEW\b|\bTNA\b|Total Nonstop|Impact Wrestling|New Japan|\bNJPW\b|Ring of Honor|\bROH\b|\bNWA\b|\bWCW\b|\bECW\b|Lucha|Stardom|Game Changer Wrestling",re.I)
|
||||
MMA=re.compile(r"\b(UFC|MMA|mixed martial|Bellator|PFL|Ultimate Fighting|cage (fight|bout)|octagon)",re.I)
|
||||
B['401']=dict(B['401'],themes=W('wrestl','WWE','AEW','TNA','NJPW','ROH','Impact Wrestling','NXT','SmackDown','WrestleMania','Royal Rumble','SummerSlam','Survivor Series','Money in the Bank','Bound for Glory','WrestleDream','title match','pay-per-view'),
|
||||
against=W('romance'))
|
||||
def score2(r,ch):
|
||||
text=' '.join([r.get('name') or '',r.get('overview') or '',' '.join(r.get('studios') or [])])
|
||||
if MMA.search(text) and not PROMO.search(text): return 0,['an MMA event: Ring X is professional wrestling, and no other channel takes combat sports events']
|
||||
s,e=g['_score'](r,ch)
|
||||
if ch=='401' and PROMO.search(' '.join(r.get('studios') or [])): s+=.15; e=e+['a verified wrestling promotion']
|
||||
return s,e
|
||||
g['_score']=score; g['score']=score2
|
||||
after={r['id']:place(r) for r in items}
|
||||
def rx(d,i): return d[i]['top']=='Ring X' and d[i]['verdict']=='suggested'
|
||||
lost=[(before[i]['title'],before[i]['s1'],after[i]['top'],after[i]['s1'],after[i]['verdict']) for i in before if rx(before,i) and not rx(after,i)]
|
||||
gained=[(after[i]['title'],after[i]['s1']) for i in after if rx(after,i) and not rx(before,i)]
|
||||
print('Ring X suggested before',sum(rx(before,i) for i in before),'after',sum(rx(after,i) for i in after))
|
||||
import collections
|
||||
print('LOST',len(lost),collections.Counter('UFC' in x[0] for x in lost)); [print(' ',x) for x in lost if 'UFC' not in x[0]]
|
||||
print('GAINED',gained)
|
||||
other=[(after[i]['title'],after[i]['top'],after[i]['s1']) for i in after if not rx(before,i) and before[i]['top']!=after[i]['top'] and after[i]['verdict']=='suggested']
|
||||
print('OTHER moves',other[:10])
|
||||
json.dump(dict(lost=lost,gained=gained),open('rx-result.json','w'))
|
||||
Reference in New Issue
Block a user