diff --git a/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt b/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt index fb2916b..22f2802 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt @@ -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) { +/** Editorial concepts a lesson can name, each read from a title's name, description, studios and genres. Specific beats broad. */ +internal val EditorialConcepts: List> = 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 { + 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 { + 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) { 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, 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) = lessons.firstOrNull { it.kind == ChairLesson.Kind.Redirect && it.from == channel && it.genre in genres } - fun holdBackFor(channel: Int, genres: Set) = 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) = + 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) = + 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, evidence: Map): ChairLessons { val judged = decisions.filter { !it.premiere && it.suggested != null } - val redirects = HashMap, MutableList>(); val drops = HashMap, MutableList>() + // feature -> (is a concept) ; keyed with the channels involved, collecting the titles that share it. + val redirects = HashMap, Int, Int>, MutableList>(); val drops = HashMap, Int>, MutableList>() 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, 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 { 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 = emptySet(), val dismissed: Set = 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)) + } } diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt index 2924ce3..6b1290d 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -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 = 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, val cautions: List) { @@ -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 = 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 = emptyList(), val learningOn: Boolean = false) { + val learnedSummary: String = "", val lessons: List = 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>): Triple>, released: List, carriedWorks: Set, originals: Map, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), - decided: Set = emptySet(), premiere: PremiereInputs = PremiereInputs(), history: List = emptyList(), learning: Boolean = false): DeskReport = + decided: Set = emptySet(), premiere: PremiereInputs = PremiereInputs(), history: List = 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 = emptySet(), premiere: PremiereInputs = PremiereInputs(), /** Your decisions, for what the Chair has learned ([chairLessons]); [learning] is your switch for letting it change suggestions. */ - history: List = emptyList(), learning: Boolean = false): Map { + history: List = emptyList(), learning: LearningState = LearningState()): Map { 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 { 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(null) } @@ -360,6 +368,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ var decided by remember { mutableStateOf(emptySet()) } var open by remember { mutableStateOf(null) } var said by remember { mutableStateOf(null) } + var lesson by remember { mutableStateOf(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) diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt index 15dcd37..48ba75a 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt @@ -83,7 +83,7 @@ internal val Briefs: List = 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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 5a3ed35..1a1199e 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 diff --git a/android/app/src/test/java/tv/orbit/classic/ChairLearningTest.kt b/android/app/src/test/java/tv/orbit/classic/ChairLearningTest.kt index f6b9644..601262b 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChairLearningTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChairLearningTest.kt @@ -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) = TitleEvidence(id, "Movie", title, 2026, "A story.", genres, null, null, null, null) + private fun ev(id: String, title: String, genres: List, overview: String = "A story.", studios: List? = 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 } } diff --git a/docs/replays/ring-x-wrestling-correction.py b/docs/replays/ring-x-wrestling-correction.py new file mode 100644 index 0000000..61d5d1d --- /dev/null +++ b/docs/replays/ring-x-wrestling-correction.py @@ -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'))