From 7917e4070e709ab7dabbe5cdc485f56229761855 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:46:19 +0300 Subject: [PATCH] fix: ratings keep teaching after a regenerate, a cleared text or a left-out clip; Build never assembles a rated-down shortcut or pair Flush waits for a save already on its way; a field removed by a redraw no longer commits on blur. Co-Authored-By: Claude Opus 5.5 --- app/ui/composer/elements/input.js | 5 ++-- .../composer/tests/elements_behaviour.test.js | 8 +++++ app/ui/features/composer/build.js | 3 +- app/ui/features/composer/build.test.js | 8 ++--- app/ui/shell/habits.js | 28 +++++++++-------- app/ui/shell/project.js | 2 +- app/ui/shell/tests/habits.test.js | 14 ++++++++- core/shortcuts.py | 8 +++-- core/suggestions.py | 30 ++++++++++++------- core/tests/test_suggestions.py | 21 +++++++++++++ 10 files changed, 92 insertions(+), 35 deletions(-) diff --git a/app/ui/composer/elements/input.js b/app/ui/composer/elements/input.js index 47cb8e6c..b9a161e4 100644 --- a/app/ui/composer/elements/input.js +++ b/app/ui/composer/elements/input.js @@ -13,7 +13,8 @@ function wire(node, { onInput, onCommit }) { if (onCommit) { let last = node.dataset.committed = node.value; // region.stack carries an edit not committed yet across a redraw const commit = () => { if (node.value !== last) { last = node.dataset.committed = node.value; onCommit(node.value); } }; - node.addEventListener("blur", commit); + // Not from a field being removed (a redraw): committing mid-redraw would redraw inside it. region.stack carries the edit. + node.addEventListener("blur", () => { if (node.isConnected) commit(); }); // And `change`, which is what autofill, a password manager and a script // setting the value all fire without ever taking focus. commit() only acts // on a real difference, so the pair costs nothing when both arrive. @@ -94,7 +95,7 @@ define("number", "md", ({ value = 0, min, max, step = 1, precision, unit, snap, if (skipped) { skipped = false; commit(); } }; input.addEventListener("change", () => { if (held) skipped = true; else commit(); }); - input.addEventListener("blur", () => { held = skipped = false; commit(); }); // also ends a hold whose release never came + input.addEventListener("blur", () => { held = skipped = false; if (input.isConnected) commit(); }); // ends a lost hold; not from a removal (see wire) input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.repeat && /^(ArrowUp|ArrowDown|PageUp|PageDown)$/.test(e.key)) held = true; diff --git a/app/ui/composer/tests/elements_behaviour.test.js b/app/ui/composer/tests/elements_behaviour.test.js index a673f556..5319c3d0 100644 --- a/app/ui/composer/tests/elements_behaviour.test.js +++ b/app/ui/composer/tests/elements_behaviour.test.js @@ -723,3 +723,11 @@ test("Enter is a newline in a textarea, not a commit", () => { fire(box.node, "blur"); assert.deepEqual(committed, ["first line"], "blur is what commits a textarea"); }); + +test("a field blurred as a redraw removes it saves nothing then; the redrawn field carries the edit", () => { + const seen = []; + const n = mount(composer.number.md({ value: 3, onChange: (v) => seen.push(v) })); + const input = n.node.querySelector("input") || n.node; + input.value = "9"; input.remove(); fire(input, "blur"); + assert.deepEqual(seen, []); +}); diff --git a/app/ui/features/composer/build.js b/app/ui/features/composer/build.js index 06557e35..bf3672eb 100644 --- a/app/ui/features/composer/build.js +++ b/app/ui/features/composer/build.js @@ -29,12 +29,13 @@ export function build(app, own) { const go = async () => { const sc = root(); if (!sc || !isGenerative(sc)) return said.setText(sc ? "This clip is an imported video: it has no prompt." : "Select a scene first."); + const words = idea.value; // as it reads at the click: the selection may change while this works button.setBusy(true); try { await p.flush(); // a rating or edit made a moment ago is in the projects the ratings are read from const [library, stats] = await Promise.all([app.api.shortcuts().then((r) => r.shortcuts || []), app.api.suggestionStats()]); if (!alive) return; - const { text, added } = compose(library, stats, idea.value); + const { text, added } = compose(library, stats, words); target = sc.id; draft.setValue(text); said.setText(`For scene ${number(sc)}. ${added.length ? `Added: ${added.map((s) => s.name).join(", ")}` : library.length ? "Nothing to add: every category is used, or what is left was rated down." : "No shortcuts yet: add some in the Shortcuts tab."}`); diff --git a/app/ui/features/composer/build.test.js b/app/ui/features/composer/build.test.js index 4b955d11..b01c9f5c 100644 --- a/app/ui/features/composer/build.test.js +++ b/app/ui/features/composer/build.test.js @@ -28,11 +28,11 @@ test("Build drafts from the selected scene, shows the full prompt, and Use puts const { page, q, press, writes } = rig(); assert.equal(q("Idea").value, "a fox"); press("Build"); await tick(); await tick(); - assert.equal(q("Draft").value, "a fox neon"); + assert.equal(q("Draft").value, "a fox, neon"); assert.match(page.node.textContent, /For scene 1\. Added: neon/); - assert.match(page.node.textContent, /a fox neon lights/); + assert.match(page.node.textContent, /a fox, neon lights/); press("Use in scene"); - assert.deepEqual(writes, [["a", "a fox neon"]]); + assert.deepEqual(writes, [["a", "a fox, neon"]]); }); test("Use before Build, or with an emptied draft, never wipes the scene", async () => { @@ -57,7 +57,7 @@ test("the idea follows the selection until typed in; the draft goes to the scene select("a"); assert.equal(q("Idea").value, "a fox", "another scene starts the idea again"); press("Use in scene"); - assert.deepEqual(writes, [["b", "my words neon"]], "the scene it was built for, not the one selected since"); + assert.deepEqual(writes, [["b", "my words, neon"]], "the scene it was built for, not the one selected since"); }); test("an imported video clip is refused: it has no prompt", async () => { diff --git a/app/ui/shell/habits.js b/app/ui/shell/habits.js index 09c139ea..a022290c 100644 --- a/app/ui/shell/habits.js +++ b/app/ui/shell/habits.js @@ -51,21 +51,23 @@ export const shuffled = (list, random = Math.random) => { * A shortcut rated down overall, or rated down alongside what is already here, is never drawn. `stats` is * /suggestion_stats. -> { text, added: [shortcut] } */ export function compose(library, stats = {}, idea = "", { add = 3, random = Math.random } = {}) { - const cat = (s) => s.category || `\0${s.name}`; - const here = presentIn(library, idea), names = new Set(here.map((s) => s.name)), cats = new Set(here.map(cat)); - const scores = stats.scores || {}, near = new Map(); - for (const [a, b, n] of stats.rated_pairs || []) for (const [x, y] of [[a, b], [b, a]]) if (names.has(x)) near.set(y, (near.get(y) || 0) + n); - const pool = usable(library).filter((s) => !names.has(s.name) && (s.replacements || []).length && (scores[s.name] || 0) >= 0 && (near.get(s.name) || 0) >= 0) - .map((s) => ({ s, w: 1 + 2 * ((scores[s.name] || 0) + (near.get(s.name) || 0)) })); - const added = []; + const cat = (s) => s.category || `\0${s.name}`, key = (t) => String(t).trim().toLowerCase().replace(/\s+/g, " "); + const owners = new Map(); // a trigger two shortcuts share fires whichever the expander meets first: never offered + for (const s of usable(library)) for (const t of s.triggers) if (key(t)) owners.set(key(t), (owners.get(key(t)) || 0) + 1); + const trigger = (s) => s.triggers.find((t) => key(t) && owners.get(key(t)) === 1); + const here = presentIn(library, idea), cats = new Set(here.map(cat)), taken = new Set(here.map((s) => s.name)); + const scores = stats.scores || {}, near = new Map(), added = []; + const meet = (name) => { for (const [a, b, n] of stats.rated_pairs || []) for (const [x, y] of [[a, b], [b, a]]) if (x === name) near.set(y, (near.get(y) || 0) + n); }; + here.forEach((s) => meet(s.name)); + const pool = usable(library).filter((s) => (s.replacements || []).some((r) => String(r).trim()) && (scores[s.name] || 0) >= 0 && trigger(s)); while (added.length < add) { - const open = pool.filter((x) => !cats.has(cat(x.s))); + const open = pool.filter((s) => !taken.has(s.name) && !cats.has(cat(s)) && (near.get(s.name) || 0) >= 0) // nor rated down beside one already in + .map((s) => ({ s, w: 1 + 2 * ((scores[s.name] || 0) + (near.get(s.name) || 0)) })); if (!open.length) break; let r = random() * open.reduce((t, x) => t + x.w, 0); - const pick = open.find((x) => (r -= x.w) < 0) || open[open.length - 1]; - added.push(pick.s); - cats.add(cat(pick.s)); + const { s } = open.find((x) => (r -= x.w) < 0) || open[open.length - 1]; + added.push(s); taken.add(s.name); cats.add(cat(s)); meet(s.name); } - const trigger = (s) => s.triggers.find((t) => String(t).trim()).trim(); - return { text: [String(idea || "").trim(), ...added.map(trigger)].filter(Boolean).join(" "), added }; + // Commas between: two triggers side by side must not read as a third ("golden" + "hour" = "golden hour"). + return { text: [String(idea || "").trim(), ...added.map((s) => trigger(s).trim())].filter(Boolean).join(", "), added }; } diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js index 384cd4b5..ba16e6dd 100644 --- a/app/ui/shell/project.js +++ b/app/ui/shell/project.js @@ -162,7 +162,7 @@ export function createProject({ onChange, onError, onOpen, keepConsistent, befor async function flush(target = project) { if (timer) { clearTimeout(timer); timer = null; } - if (!target || !dirty) return; + if (!target || !dirty) return saving; // nothing new to write, but one on its way is waited for // One PUT at a time. Two overlapping writes of a whole project can land in // either order, and the loser is a version of the project the user has // already moved past. diff --git a/app/ui/shell/tests/habits.test.js b/app/ui/shell/tests/habits.test.js index c44224ff..a70a2ffd 100644 --- a/app/ui/shell/tests/habits.test.js +++ b/app/ui/shell/tests/habits.test.js @@ -33,7 +33,7 @@ const pool = [ test("compose keeps the idea, adds one shortcut per unused category, never one with nothing to put in", () => { const { text, added } = compose(pool, {}, "a fox in the rain", { random: () => 0 }); - assert.equal(text, "a fox in the rain dolly in"); + assert.equal(text, "a fox in the rain, dolly in"); assert.deepEqual(added.map((s) => s.name), ["dolly"]); assert.equal(compose(pool, {}, "", { random: () => 0 }).added.length, 2, "weather + camera; the empty style is skipped"); }); @@ -58,3 +58,15 @@ test("a trigger is matched as the expander matches it", () => { assert.deepEqual(presentIn(lib, "GOLDEN\n hour").map((s) => s.name), ["gh"]); assert.deepEqual(presentIn(lib, "(fox)").map((s) => s.name), ["fox"]); }); + +test("compose never builds what was rated down: not by two triggers side by side, not as a pair it makes itself, not via a shared trigger", () => { + const lib = [{ name: "G", triggers: ["golden"], replacements: ["g"], category: "a" }, { name: "H", triggers: ["hour"], replacements: ["h"], category: "b" }, + { name: "GH", triggers: ["golden hour"], replacements: ["gh"], category: "c" }]; + const { text } = compose(lib, { scores: { GH: -3 } }, "", { random: () => 0, add: 2 }); + assert.doesNotMatch(text, /golden\s+hour/i, "joined with a comma, so the rated-down one cannot fire"); + const pq = [{ name: "P", triggers: ["p"], replacements: ["p"], category: "x" }, { name: "Q", triggers: ["q"], replacements: ["q"], category: "y" }]; + assert.equal(compose(pq, { rated_pairs: [["P", "Q", -4]] }, "").added.length, 1, "the second would make the disliked pair"); + const twins = [{ name: "bad", triggers: ["x"], replacements: ["b"], category: "u" }, { name: "good", triggers: ["x"], replacements: ["g"], category: "v" }]; + assert.deepEqual(compose(twins, { scores: { bad: -1 } }, "").added, [], "its trigger would fire the other one"); + assert.deepEqual(compose([{ name: "cut", triggers: ["cut"], replacements: [""], category: "w" }], {}, "").added, [], "removes itself: adds nothing"); +}); diff --git a/core/shortcuts.py b/core/shortcuts.py index 13a7a939..ea154aa3 100644 --- a/core/shortcuts.py +++ b/core/shortcuts.py @@ -387,13 +387,17 @@ def matcher(items=None): compiled = [re.compile(pattern, re.IGNORECASE) for _, pattern, _, _ in candidates] needs = [set(_TOKEN.findall(trigger.lower())) for trigger, *_rest in candidates] by_first: dict[str, list[int]] = {} + always = [] # a trigger lower() reshapes ("İ" -> "i̇") matches case-blind in ways tokens cannot tell for i, (trigger, *_rest) in enumerate(candidates): - by_first.setdefault(_TOKEN.findall(trigger.lower())[0], []).append(i) + if len(trigger.lower()) != len(trigger): + always.append(i) + else: + by_first.setdefault(_TOKEN.findall(trigger.lower())[0], []).append(i) def fired(text) -> set: text = str(text or "") tokens = set(_TOKEN.findall(text.lower())) - near = [i for t in tokens for i in by_first.get(t, ()) if needs[i] <= tokens] + near = [i for t in tokens for i in by_first.get(t, ()) if needs[i] <= tokens] + always # As the expander's one pattern does: the leftmost match wins, the longest trigger (lowest i) first at a tie, # and the search goes on after it. out, pos = set(), 0 diff --git a/core/suggestions.py b/core/suggestions.py index 1de7fa49..e2fbe325 100644 --- a/core/suggestions.py +++ b/core/suggestions.py @@ -36,27 +36,35 @@ def stats() -> dict: order = [i for i in proj.timeline_order if i in by_id] or [sc.id for sc in proj.scenes] for sid in order: sc = by_id[sid] - # Editorial cuts share the root's text: only the root owns the prompt. - if sc.excluded or (sc.gen_unit_id and sc.cut_offset_frames): + # Editorial cuts share the root's text and rating: only the root owns them. + if sc.gen_unit_id and sc.cut_offset_frames: continue - if not (sc.text or "").strip(): + # Every rating counts for the text it was given to (older ones carry none: the text as it is now): the + # clip's own, and each earlier take's -- regenerating moves a rating there. A clip left out still was rated. + now = proj.scene_renders.get(sc.id) if isinstance(proj.scene_renders.get(sc.id), dict) else {} + on_clip = (lambda t: t.get("promptId") == now.get("promptId")) if now.get("promptId") else \ + (lambda t: (t.get("media") or {}).get("filename") == (now.get("media") or {}).get("filename")) + takes = [(t.get("rating"), t.get("rated_text")) for t in proj.scene_variants.get(sc.id) or [] + if isinstance(t, dict) and isinstance(t.get("rated_text"), str) and not (now and on_clip(t))] + for label, text in [(sc.rating, sc.rated_text or sc.text), *takes]: + v = vote(label) + rated = fired(text) if v and str(text or "").strip() else () + for k in rated: + scores[k] = scores.get(k, 0) + v + for b in rated: + if k < b: + rated_pairs[(k, b)] = rated_pairs.get((k, b), 0) + v + # Habits: what the cut uses now. + if sc.excluded or not (sc.text or "").strip(): continue present = fired(sc.text) scanned += 1 - v = vote(sc.rating) - # A rating is about the text it was given to (older ratings carry none: the text as it is now). - rated = fired(sc.rated_text) if v and sc.rated_text.strip() else present for k in present: counts[k] = counts.get(k, 0) + 1 for a in present: for b in present: if a < b: pairs[(a, b)] = pairs.get((a, b), 0) + 1 - for k in rated if v else (): - scores[k] = scores.get(k, 0) + v - for b in rated: - if k < b: - rated_pairs[(k, b)] = rated_pairs.get((k, b), 0) + v for a in prev or (): for b in present: follows[(a, b)] = follows.get((a, b), 0) + 1 diff --git a/core/tests/test_suggestions.py b/core/tests/test_suggestions.py index 5cd8bcb9..af0bba20 100644 --- a/core/tests/test_suggestions.py +++ b/core/tests/test_suggestions.py @@ -75,3 +75,24 @@ def test_the_miner_finds_exactly_what_the_expander_replaces(): text = " ".join(rng.choice(words) for _ in range(12)) out = shortcuts.expand(text, shortcuts=items, seed=1) assert fired(text) == {f"n{i}" for i in range(len(items)) if f"<{i}>" in out}, text + + +def test_ratings_outlive_a_regenerate_a_cleared_text_and_a_left_out_clip(): + for n in ("rain", "neon", "fox"): + shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]}) + shown = {"media": {"filename": "3.mp4"}, "promptId": "p3"} + projects.save(projects.Project(name="x", scenes=[ + projects.Scene(id="a", text="fox", rating="", rated_text=""), + projects.Scene(id="b", text="", rating="10", rated_text="neon"), + projects.Scene(id="c", text="fox", rating="10", rated_text="fox", excluded=True)], + scene_renders={"a": shown}, + scene_variants={"a": [{"media": {"filename": "1.mp4"}, "promptId": "p1", "rating": "1", "rated_text": "rain"}, + {**shown, "rating": "10", "rated_text": "fox"}]})) # the take on the clip: its head speaks for it + assert suggestions.stats()["scores"] == {"rain": -1, "neon": 1, "fox": 1} + + +def test_a_trigger_lower_reshapes_is_still_found_as_the_expander_finds_it(): + items = [shortcuts.Shortcut(name="big", triggers=["İstanbul"], replacements=[""]), + shortcuts.Shortcut(name="small", triggers=["istanbul"], replacements=[""])] + out = shortcuts.expand("istanbul", shortcuts=items, seed=1) + assert shortcuts.matcher(items)("istanbul") == {"big" if "" in out else "small"}