rework: Build tab drafts a scene from your best-rated shortcuts; learns from rated scenes in projects
Drops the run log, edit-replay swap and Generate capture calls (core no longer depends on the feature). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
39876d871a
commit
e877ad08b7
@@ -39,7 +39,6 @@ app/playwright-report/
|
||||
/quarantine.json
|
||||
/shortcut_categories.json
|
||||
/shortcut_revolver.json
|
||||
/prompt_composer_memory.json
|
||||
|
||||
# Taste keys: what your ratings taught, per rental
|
||||
/taste/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Build: a scene drafted from your own shortcuts, those in scenes you liked first. Rating renders is what teaches it.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { compose } from "../../shell/habits.js";
|
||||
|
||||
export function build(app, own) {
|
||||
const p = app.project;
|
||||
let alive = true;
|
||||
own(() => { alive = false; });
|
||||
const idea = c.textarea.md({ label: "Idea", rows: 3, value: (p.selected && p.selected.text) || "", placeholder: "Optional: a few words or triggers to start from." });
|
||||
const draft = c.textarea.md({ label: "Draft", rows: 4, value: "", placeholder: "Press Build.", onCommit: () => preview() });
|
||||
const said = c.hint.default({ text: "" }), full = c.text.sm({ text: "" });
|
||||
|
||||
async function preview() { // what Generate would send for the draft, with one draw of each shortcut
|
||||
const prefix = (app.promptPrefix || []).flatMap((f) => { try { return f(p.selected); } catch { return []; } });
|
||||
try {
|
||||
const r = await app.api.expandPrompt({ text: draft.value, anchor: [...prefix, p.anchor].filter((x) => x && String(x).trim()).join(" "), postfix: p.postfix, postfix_enabled: p.postfixEnabled, variables: p.variables });
|
||||
if (alive) full.setText(r.text || "");
|
||||
} catch (err) { if (alive) full.setText(`Could not build the full prompt: ${err.message}`); }
|
||||
}
|
||||
async function go() {
|
||||
let library, stats;
|
||||
try { [library, stats] = await Promise.all([app.api.shortcuts().then((r) => r.shortcuts || []), app.api.suggestionStats()]); }
|
||||
catch (err) { return said.setText(err.message); }
|
||||
if (!alive) return;
|
||||
const { text, added } = compose(library, stats, idea.value);
|
||||
draft.setValue(text);
|
||||
said.setText(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.");
|
||||
preview();
|
||||
}
|
||||
const use = () => {
|
||||
if (!p.selected) return said.setText("Select a scene first.");
|
||||
p.setText(p.selectedId, draft.value);
|
||||
said.setText("Put in the selected scene. Edit > Undo takes it back.");
|
||||
};
|
||||
return c.region.stack({ gap: "sm", children: [
|
||||
c.hint.default({ text: "Drafts a scene from your shortcuts, favouring those in scenes you liked. Rate renders to teach it." }),
|
||||
idea,
|
||||
c.toolbar.default({ items: [c.button.sm({ label: "Build", tone: "primary", onClick: go })] }),
|
||||
draft, said,
|
||||
c.field.default({ label: "Full prompt", control: full }),
|
||||
c.toolbar.default({ items: [c.button.sm({ label: "Use in selected scene", tone: "neutral", onClick: use })] }),
|
||||
] });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
|
||||
let build;
|
||||
test.before(async () => { setupDom(); ({ build } = await import("./build.js")); });
|
||||
test.after(() => teardownDom());
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
test("Build drafts from the idea, shows the full prompt, and Use puts the draft in the selected scene", async () => {
|
||||
const scene = { id: "s1", text: "a fox" }, sent = [];
|
||||
const p = { selected: scene, selectedId: "s1", anchor: "", postfix: "", postfixEnabled: true, variables: [], setText: (id, t) => { scene.text = t; } };
|
||||
const api = {
|
||||
shortcuts: async () => ({ shortcuts: [{ name: "neon", triggers: ["neon"], replacements: ["neon lights"], category: "light" }] }),
|
||||
suggestionStats: async () => ({ scores: { neon: 2 } }),
|
||||
expandPrompt: async (body) => { sent.push(body); return { text: body.text.replace("neon", "neon lights") }; },
|
||||
};
|
||||
const page = build({ project: p, api }, () => {});
|
||||
document.body.append(page.node);
|
||||
assert.equal(page.node.querySelector('[aria-label="Idea"]').value, "a fox", "starts from the selected scene");
|
||||
[...page.node.querySelectorAll("button")].find((b) => b.textContent === "Build").click();
|
||||
await tick(); await tick();
|
||||
assert.equal(page.node.querySelector('[aria-label="Draft"]').value, "a fox neon");
|
||||
assert.match(page.node.textContent, /Added: neon/);
|
||||
assert.match(page.node.textContent, /a fox neon lights/);
|
||||
[...page.node.querySelectorAll("button")].find((b) => b.textContent === "Use in selected scene").click();
|
||||
assert.equal(scene.text, "a fox neon");
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { applyStory, clash, joinStory } from "./story.js";
|
||||
import { shortcuts } from "./shortcuts.js";
|
||||
import { cuts } from "./cuts.js";
|
||||
import { enhance } from "./enhance.js";
|
||||
import { promptBuilder } from "./prompt_builder.js";
|
||||
import { build } from "./build.js";
|
||||
import { chat, chatHook } from "./chat.js";
|
||||
import { files } from "./files.js";
|
||||
import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js";
|
||||
@@ -54,7 +54,7 @@ const story = (app, own) => {
|
||||
] });
|
||||
};
|
||||
|
||||
const sheets = { story, shortcuts, build: promptBuilder, cuts,
|
||||
const sheets = { story, shortcuts, build, cuts,
|
||||
enhance, chat, files };
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
|
||||
const requestBody = (app, text) => {
|
||||
const selected = app.project.selected;
|
||||
const prefix = (app.promptPrefix || []).flatMap((fn) => { try { return fn(selected); } catch { return []; } });
|
||||
return {
|
||||
text,
|
||||
anchor: [...prefix, app.project.anchor].filter((x) => x && String(x).trim()).join(" "),
|
||||
postfix: app.project.postfix,
|
||||
postfix_enabled: app.project.postfixEnabled,
|
||||
variables: app.project.variables,
|
||||
seed: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export function promptBuilder(app, own) {
|
||||
const page = c.region.stack({ gap: "sm" });
|
||||
const project = app.project;
|
||||
let enabled = false, analysis = null, base = "", draft = "", learned = false, ratingStats = {}, reviewNeeded = false, shotStructure = null;
|
||||
let busy = false, alive = true, builtBody = null, resultEdited = false;
|
||||
let targetProject = project.project && project.project.id, targetScene = project.selectedId;
|
||||
own(() => { alive = false; });
|
||||
|
||||
const source = c.textarea.md({ label: "Prompt idea", rows: 4,
|
||||
value: project.selected ? project.selected.text || "" : "",
|
||||
placeholder: "Describe the scene or start with a shortcut trigger.",
|
||||
onInput: () => { base = ""; draft = ""; learned = false; builtBody = null; resultEdited = false; } });
|
||||
const result = c.textarea.md({ label: "Review and edit the draft", rows: 7, value: "",
|
||||
placeholder: "Build a draft to start. You can edit it here before using or teaching it.",
|
||||
onInput: (value) => { draft = value; resultEdited = true; } });
|
||||
const note = c.hint.default({ text: "" });
|
||||
|
||||
function draw() {
|
||||
if (!alive) return;
|
||||
const info = analysis || {};
|
||||
const categories = (info.categories || []).slice(0, 5).map((x) => `${x.name} (${x.count})`).join(" · ") || "No shortcut categories yet";
|
||||
const terms = (info.words || []).slice(0, 8).map((x) => `${x.text} ×${x.count}`).join(" · ") || "No repeated terms yet";
|
||||
const positions = (info.positions || []).slice(0, 5).map((x) => `${x.text}: ${Object.entries(x).filter(([k]) => k !== "text").map(([k, v]) => `${k} ${v}`).join(", ")}`).join(" · ") || "No position patterns yet";
|
||||
const neighbors = (info.neighbors || []).slice(0, 5).map((x) => `${x.left} → ${x.right} (${x.count})`).join(" · ") || "No repeated neighbors yet";
|
||||
const phrases = (info.phrases || []).slice(0, 4).map((x) => `“${x.text}”`).join(" · ") || "No replacement phrases yet";
|
||||
const targetIsCurrent = () => project.project && project.project.id === targetProject && project.selectedId === targetScene;
|
||||
const loadScene = c.button.sm({ label: "Load selected scene", tone: "ghost", onClick: () => {
|
||||
targetProject = project.project && project.project.id; targetScene = project.selectedId;
|
||||
source.setValue(project.selected ? project.selected.text || "" : "");
|
||||
base = ""; draft = ""; learned = false; builtBody = null;
|
||||
resultEdited = false;
|
||||
result.setValue(""); note.setText("Loaded the currently selected scene.");
|
||||
} });
|
||||
const build = c.button.sm({ label: busy ? "Building…" : "Build draft", tone: "primary", disabled: busy,
|
||||
onClick: async () => {
|
||||
busy = true; draw();
|
||||
try {
|
||||
builtBody = requestBody(app, source.node.value);
|
||||
targetProject = project.project && project.project.id; targetScene = project.selectedId;
|
||||
const built = await app.api.promptBuilderDraft(builtBody);
|
||||
base = built.base; draft = built.draft; learned = built.learned;
|
||||
resultEdited = false;
|
||||
reviewNeeded = built.prompt_review_needed === true;
|
||||
shotStructure = built.shot_structure || null;
|
||||
result.setValue(draft);
|
||||
note.setText(reviewNeeded
|
||||
? `This reviewed version has ${built.prompt_votes.bad} dislikes and ${built.prompt_votes.good} likes. FunPack paused its automatic reuse; review and save a new edit if you want to teach a replacement.`
|
||||
: learned ? "Loaded your reviewed version for this exact expanded prompt." : "Started with your normal shortcut and variable expansion. Edit the draft, then choose Learn from this edit.");
|
||||
} catch (err) { note.setText(`Could not build a draft: ${err.message}`); }
|
||||
finally { busy = false; draw(); }
|
||||
} });
|
||||
const generate = c.button.sm({ label: busy ? "Learning…" : "Generate from memory", tone: "neutral", disabled: busy,
|
||||
onClick: async () => {
|
||||
busy = true; draw();
|
||||
try {
|
||||
const request = requestBody(app, source.node.value);
|
||||
const generated = await app.api.promptBuilderGenerate(request);
|
||||
if (!generated.generated) return note.setText(generated.why || "Could not generate a prompt from the current shortcut library.");
|
||||
builtBody = { ...request, text: generated.source_text };
|
||||
targetProject = project.project && project.project.id; targetScene = project.selectedId;
|
||||
base = generated.base; draft = generated.draft; learned = false;
|
||||
resultEdited = false;
|
||||
shotStructure = generated.shot_structure || null;
|
||||
result.setValue(draft);
|
||||
if (!source.node.value.trim()) source.setValue(generated.source_text);
|
||||
const basis = generated.training_runs
|
||||
? `used ${generated.training_runs} rated run${generated.training_runs === 1 ? "" : "s"}`
|
||||
: "started from the enabled shortcut library; ratings will personalize later drafts";
|
||||
note.setText(`Generated a prompt from your shortcuts (${basis}). ${generated.reasons.join("; ")}. Review it, then rate the render or teach your edits.`);
|
||||
} catch (err) { note.setText(`Could not generate a prompt: ${err.message}`); }
|
||||
finally { busy = false; draw(); }
|
||||
} });
|
||||
const use = c.button.sm({ label: "Use in selected scene", tone: "neutral", disabled: !base || !draft,
|
||||
onClick: () => {
|
||||
if (!targetIsCurrent()) return note.setText("The selected project or scene changed. Load the selected scene before applying this draft.");
|
||||
const selected = project.selected;
|
||||
if (!selected) return note.setText("Select a scene first.");
|
||||
const sceneText = !learned && !resultEdited && builtBody ? builtBody.text : draft;
|
||||
project.setText(selected.id, sceneText);
|
||||
source.setValue(sceneText); base = ""; learned = false; builtBody = null; resultEdited = false;
|
||||
note.setText("Draft copied into the selected scene. FunPack kept the source variables and shortcut triggers when the expanded draft was unchanged.");
|
||||
} });
|
||||
const learn = c.button.sm({ label: "Learn from this edit", tone: "neutral", disabled: !base || !draft || busy,
|
||||
onClick: async () => {
|
||||
try {
|
||||
if (!builtBody) return note.setText("Build a draft before teaching an edit.");
|
||||
const saved = await app.api.promptBuilderLearn({ ...builtBody, accepted: result.node.value });
|
||||
note.setText(`Saved ${saved.edit_count} edit${saved.edit_count === 1 ? "" : "s"}. The configured postfix (including sound instructions) is preserved unchanged. FunPack will reuse this review for the same expanded prompt when automatic use is enabled.`);
|
||||
} catch (err) { note.setText(`Could not save this review: ${err.message}`); }
|
||||
} });
|
||||
const toggle = c.toggle.default({ label: "Use learned drafts automatically for exact matches", checked: enabled,
|
||||
hint: "This changes the prompt sent to generation only when it exactly matches a prompt you reviewed.",
|
||||
onChange: async (value) => {
|
||||
try { const state = await app.api.promptBuilderEnabled(value); enabled = state.enabled; draw(); }
|
||||
catch (err) { note.setText(`Could not update this setting: ${err.message}`); }
|
||||
} });
|
||||
const clear = c.button.sm({ label: "Forget learned drafts…", tone: "ghost", disabled: !info.examples,
|
||||
onClick: async () => {
|
||||
if (!(await c.modal.dialogue({ title: "Forget learned drafts", message: "Delete the reviewed prompt pairs and turn automatic use off?", tone: "danger", confirmLabel: "Forget" }).result)) return;
|
||||
try { const state = await app.api.promptBuilderClear(); enabled = state.enabled; ratingStats = state; analysis = { ...info, examples: 0 }; note.setText("Learned prompt pairs deleted."); draw(); }
|
||||
catch (err) { note.setText(`Could not clear learned prompts: ${err.message}`); }
|
||||
} });
|
||||
page.set([
|
||||
c.label.section({ text: "Prompt builder" }),
|
||||
c.hint.default({ text: "Builds from your scene text, saved shortcuts and project variables. The postfix is protected when you teach an edit. Shot Camera runs later in the generation pipeline and may add cut times, views, camera moves and liked details to [Shot N] blocks; those actual choices are captured after the run." }),
|
||||
toggle,
|
||||
c.field.default({ label: "Shortcut profile", hint: `${info.shortcuts || 0} enabled shortcuts · ${info.spacy ? "spaCy phrase patterns available" : "word and category counts (spaCy model unavailable)"}`, control: c.text.sm({ text: `${categories}\nTerms: ${terms}\nPositions: ${positions}\nNeighbors: ${neighbors}\nCommon phrases: ${phrases}` }) }),
|
||||
c.toolbar.default({ items: [c.label.section({ text: "Prompt idea" })], trailing: [loadScene] }),
|
||||
source,
|
||||
c.toolbar.default({ items: [build, generate], trailing: [clear] }),
|
||||
shotStructure && shotStructure.shots ? c.hint.default({ text: `Shot structure in this draft: ${shotStructure.shots} [Shot N] block${shotStructure.shots === 1 ? "" : "s"}${shotStructure.cut_times ? ` · ${shotStructure.cut_times} existing cut time${shotStructure.cut_times === 1 ? "" : "s"}` : ""}. Shot Camera may add cuts or views during generation.` }) : null,
|
||||
result,
|
||||
c.toolbar.default({ items: [learn, use] }),
|
||||
note,
|
||||
c.hint.default({ text: `${info.examples || 0} reviewed edits and ${ratingStats.captured_prompts || 0} recent runs stored locally. Prompt feedback: ${ratingStats.rated_prompt_runs || 0} likes and ${ratingStats.disliked_prompt_runs || 0} prompt/composition dislikes. Shot Camera choices captured: ${ratingStats.shot_camera_runs || 0} runs, ${ratingStats.shot_cuts_added || 0} cuts, ${ratingStats.shot_views_added || 0} views, ${ratingStats.shot_camera_moves_added || 0} camera moves and ${ratingStats.shot_details_added || 0} details. Shortcut order and use are learned from rated runs; exact reviewed edits remain available for matching prompts.` }),
|
||||
]);
|
||||
}
|
||||
|
||||
Promise.all([app.api.promptBuilderStatus(), app.api.promptBuilderAnalysis()]).then(([state, stats]) => {
|
||||
enabled = state.enabled; ratingStats = state; analysis = stats; draw();
|
||||
}).catch((err) => { analysis = {}; note.setText(`Could not load the local prompt profile: ${err.message}`); draw(); });
|
||||
return page;
|
||||
}
|
||||
@@ -92,9 +92,7 @@ export default {
|
||||
if (group.every((s) => s.excluded) || !isGenerative(root)) continue; // all left out, or not made by the model
|
||||
// The unit is made once at the length of its clips together (each cut adds one shared frame).
|
||||
const frames = group.reduce((t, s) => t + effFrames(s, snap), 0) - (group.length - 1);
|
||||
const promptPrefix = (app.promptPrefix || []).flatMap((f) => { try { return f(root); } catch { return []; } });
|
||||
const authoredPrompt = [...promptPrefix, snap.anchor, root.text].filter((x) => x && String(x).trim()).join(" ");
|
||||
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, frames, hooks: app.inputHooks, ...(fixedSeed ? { seed: null } : {}), prefix: promptPrefix });
|
||||
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, frames, hooks: app.inputHooks, ...(fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root); } catch { return []; } }) });
|
||||
app.lastRun.typed = (root.text || "").trim(); // what a Chat comment made now would be about
|
||||
const seeded = rolesAt(frozen, "generation.seed")[0];
|
||||
if (seeded) app.lastRun.seed = (inputs[seeded.slot.id] || {})[seeded.role.input] ?? (seeded.slot.inputs || {})[seeded.role.input]; // shown in ⏱ Sampler, to keep a liked one
|
||||
@@ -118,19 +116,8 @@ export default {
|
||||
}
|
||||
if (stopped) g.cancel(); // Stop landed while this one was being queued
|
||||
if (!made) app.api.newTasteGeneration(g.run.state.promptId).catch(() => {}); // a run really started: the last run's unrated clips can no longer be paired with what it learned
|
||||
const promptInput = rolesAt(frozen, "generation.prompt")[0];
|
||||
const promptText = promptInput && (inputs[promptInput.slot.id] || {})[promptInput.role.input];
|
||||
if (g.run.state.promptId && typeof promptText === "string") {
|
||||
app.api.promptBuilderCapture(g.run.state.promptId, promptText, authoredPrompt).catch(() => {});
|
||||
}
|
||||
made += 1;
|
||||
const end = await done;
|
||||
// Shot Camera runs inside ComfyUI after the queue request. Refresh this
|
||||
// prompt's capture when it has finished so its actual cuts/views/moves
|
||||
// are attached before the user rates the result.
|
||||
if (g.run.state.promptId && typeof promptText === "string") {
|
||||
app.api.promptBuilderCapture(g.run.state.promptId, promptText, authoredPrompt).catch(() => {});
|
||||
}
|
||||
if (end === g.CANCELLED) break;
|
||||
const images = g.run.state.images;
|
||||
if (end !== g.DONE) { // ComfyUI's own words: which node, and why
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { analyze, byHabit, presentIn } from "./ideas.js";
|
||||
|
||||
const lib = [
|
||||
{ name: "rain", triggers: ["rain"], category: "weather" }, { name: "fog", triggers: ["fog"], category: "weather" },
|
||||
{ name: "dolly", triggers: ["dolly in"], category: "camera" }, { name: "off", triggers: ["zoom"], category: "camera", enabled: false }, { name: "x", triggers: [], category: "none" },
|
||||
];
|
||||
|
||||
test("a trigger counts only as a whole token", () => {
|
||||
assert.deepEqual(presentIn(lib, "a train in the rain").map((s) => s.name), ["rain"]);
|
||||
assert.deepEqual(presentIn(lib, "constraint").map((s) => s.name), []);
|
||||
assert.deepEqual(presentIn(lib, "a DOLLY IN shot").map((s) => s.name), ["dolly"]);
|
||||
});
|
||||
|
||||
test("a category is used when any of its triggers is in the text; disabled and trigger-less shortcuts are ignored", () => {
|
||||
const { used, missing } = analyze(lib, "rain");
|
||||
assert.deepEqual(used.map((g) => g.cat), ["weather"]);
|
||||
assert.deepEqual(missing.map((g) => g.cat), ["camera"]);
|
||||
});
|
||||
|
||||
test("habits: partners of what is here, strongest first, one-offs ignored, nothing already used", () => {
|
||||
const edges = [["rain", "dolly", 3], ["rain", "fog", 1], ["fog", "dolly", 2]];
|
||||
const got = byHabit(lib, edges, new Set(["rain"]), new Set(["rain"]), { both: true });
|
||||
assert.deepEqual(got.map((g) => [g.sc.name, g.n, g.with.name]), [["dolly", 3, "rain"]]);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { claimFor } from "../../composer/internals/zlayer.js";
|
||||
import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js";
|
||||
import { analyze, byHabit, presentIn, shuffled } from "./ideas.js";
|
||||
import { analyze, byHabit, presentIn, shuffled } from "../../shell/habits.js";
|
||||
|
||||
const BOXES = new Set(["Story", "Prompt"]);
|
||||
const first = (sc) => String((sc.triggers || [])[0] || "").trim();
|
||||
|
||||
@@ -49,14 +49,6 @@ export const api = {
|
||||
setRevolver: (enabled, random) => call("POST", "/api/shortcuts/revolver", { enabled, random }),
|
||||
enhancerDefaults: () => call("GET", "/api/m/conditioning_prompt_enhancer/defaults"),
|
||||
enhancerRuns: () => call("GET", "/api/m/conditioning_prompt_enhancer/runs"),
|
||||
promptBuilderStatus: () => call("GET", "/api/m/conditioning_prompt_composer/status"),
|
||||
promptBuilderAnalysis: () => call("GET", "/api/m/conditioning_prompt_composer/analysis"),
|
||||
promptBuilderDraft: (body) => call("POST", "/api/m/conditioning_prompt_composer/draft", body),
|
||||
promptBuilderGenerate: (body) => call("POST", "/api/m/conditioning_prompt_composer/generate", body),
|
||||
promptBuilderLearn: (body) => call("POST", "/api/m/conditioning_prompt_composer/learn", body),
|
||||
promptBuilderCapture: (promptId, text, sourceText = "") => call("POST", "/api/m/conditioning_prompt_composer/capture", { prompt_id: promptId, text, source_text: sourceText }),
|
||||
promptBuilderEnabled: (enabled) => call("POST", "/api/m/conditioning_prompt_composer/settings", { enabled }),
|
||||
promptBuilderClear: () => call("POST", "/api/m/conditioning_prompt_composer/clear", {}),
|
||||
rateTaste: (promptId, rating, axis) => call("POST", "/api/m/taste/rate", { prompt_id: promptId, rating, axis: axis || null }),
|
||||
newTasteGeneration: (promptId) => call("POST", "/api/m/taste/generation", promptId ? { prompt_id: promptId } : {}),
|
||||
shotMemory: () => call("GET", "/api/m/conditioning_shot_camera/memory"),
|
||||
|
||||
@@ -47,3 +47,25 @@ export const shuffled = (list, random = Math.random) => {
|
||||
for (let i = a.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
||||
return a;
|
||||
};
|
||||
|
||||
/** A scene from the person's shortcuts, the best-rated first: the idea's own triggers kept, plus up to `add` more, one per
|
||||
* category the text does not use yet, drawn at random by weight. 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 here = presentIn(library, idea), names = new Set(here.map((s) => s.name)), cats = new Set(here.map((s) => s.category || "other"));
|
||||
const scores = stats.scores || {}, counts = stats.counts || {}, 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)) + (counts[s.name] || 0) / 4 }));
|
||||
const added = [];
|
||||
while (added.length < add) {
|
||||
const open = pool.filter((x) => !cats.has(x.s.category || "other"));
|
||||
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(pick.s.category || "other");
|
||||
}
|
||||
const trigger = (s) => s.triggers.find((t) => String(t).trim()).trim();
|
||||
return { text: [String(idea || "").trim(), ...added.map(trigger)].filter(Boolean).join(" "), added };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { analyze, byHabit, compose, presentIn } from "../habits.js";
|
||||
|
||||
const lib = [
|
||||
{ name: "rain", triggers: ["rain"], category: "weather" }, { name: "fog", triggers: ["fog"], category: "weather" },
|
||||
{ name: "dolly", triggers: ["dolly in"], category: "camera" }, { name: "off", triggers: ["zoom"], category: "camera", enabled: false }, { name: "x", triggers: [], category: "none" },
|
||||
];
|
||||
|
||||
test("a trigger counts only as a whole token", () => {
|
||||
assert.deepEqual(presentIn(lib, "a train in the rain").map((s) => s.name), ["rain"]);
|
||||
assert.deepEqual(presentIn(lib, "constraint").map((s) => s.name), []);
|
||||
assert.deepEqual(presentIn(lib, "a DOLLY IN shot").map((s) => s.name), ["dolly"]);
|
||||
});
|
||||
|
||||
test("a category is used when any of its triggers is in the text; disabled and trigger-less shortcuts are ignored", () => {
|
||||
const { used, missing } = analyze(lib, "rain");
|
||||
assert.deepEqual(used.map((g) => g.cat), ["weather"]);
|
||||
assert.deepEqual(missing.map((g) => g.cat), ["camera"]);
|
||||
});
|
||||
|
||||
test("habits: partners of what is here, strongest first, one-offs ignored, nothing already used", () => {
|
||||
const edges = [["rain", "dolly", 3], ["rain", "fog", 1], ["fog", "dolly", 2]];
|
||||
const got = byHabit(lib, edges, new Set(["rain"]), new Set(["rain"]), { both: true });
|
||||
assert.deepEqual(got.map((g) => [g.sc.name, g.n, g.with.name]), [["dolly", 3, "rain"]]);
|
||||
});
|
||||
|
||||
const pool = [
|
||||
{ name: "rain", triggers: ["rain"], replacements: ["r"], category: "weather" }, { name: "fog", triggers: ["fog"], replacements: ["f"], category: "weather" },
|
||||
{ name: "dolly", triggers: ["dolly in"], replacements: ["d"], category: "camera" }, { name: "pan", triggers: ["pan"], replacements: ["p"], category: "camera" },
|
||||
{ name: "empty", triggers: ["nil"], replacements: [], category: "style" },
|
||||
];
|
||||
|
||||
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.deepEqual(added.map((s) => s.name), ["dolly"]);
|
||||
assert.equal(compose(pool, {}, "", { random: () => 0 }).added.length, 2, "weather + camera; the empty style is skipped");
|
||||
});
|
||||
|
||||
test("compose favours what was liked and never draws what was rated down, alone or beside the idea", () => {
|
||||
assert.equal(compose(pool, { scores: { fog: 5 } }, "", { random: () => 0.5, add: 1 }).added[0].name, "fog", "11 against 1, either order");
|
||||
assert.equal(compose(pool, { scores: { rain: -1 } }, "", { random: () => 0, add: 1 }).added[0].name, "fog");
|
||||
assert.deepEqual(compose(pool, { rated_pairs: [["dolly", "rain", -1]] }, "rain", { random: () => 0 }).added.map((s) => s.name), ["pan"]);
|
||||
assert.deepEqual(compose(pool, { scores: { dolly: -1, pan: -2 } }, "rain").added, [], "nothing left to add");
|
||||
});
|
||||
@@ -1079,13 +1079,6 @@ def register(routes, prefix=None):
|
||||
seed=seed if isinstance(seed, int) and not isinstance(seed, bool) else 0,
|
||||
commit=body.get("commit") is True,
|
||||
)
|
||||
# Optional prompt editors can apply a user's reviewed transformation
|
||||
# at this final boundary. The feature owns its memory; the core only
|
||||
# asks registered providers and leaves the ordinary prompt untouched
|
||||
# when none has a matching learned draft.
|
||||
transformed = modules().ask("prompt_transform", expanded)
|
||||
if isinstance(transformed, str):
|
||||
expanded = transformed
|
||||
return web.json_response({"text": expanded})
|
||||
|
||||
@routes.get(P + "/api/log/funpack")
|
||||
|
||||
+19
-2
@@ -1,5 +1,6 @@
|
||||
"""What a person's own prompting habits say about shortcuts: which ones land in
|
||||
the same scene (pairs) and which shot follows which (follows). Mined on demand
|
||||
the same scene (pairs), which shot follows which (follows), and how the scenes
|
||||
using them were rated (scores, rated_pairs: liked +1, disliked -1). Mined on demand
|
||||
by reading every saved project's scene text for triggers typed verbatim, so
|
||||
there is nothing learned to store and nothing to go stale. Keyed by shortcut
|
||||
name, which is what the picker identifies a shortcut by."""
|
||||
@@ -11,6 +12,14 @@ import re
|
||||
from . import projects, shortcuts
|
||||
|
||||
|
||||
def vote(label: str) -> int:
|
||||
"""A scene's saved rating as +1 / -1 / 0. A bad-image dislike blames the picture, not the prompt: 0."""
|
||||
v = str(label or "").removesuffix("|loved").strip()
|
||||
if v.isdigit():
|
||||
return 1 if int(v) >= 6 else -1
|
||||
return -1 if v == "Disliked: bad composition" else 0
|
||||
|
||||
|
||||
def stats() -> dict:
|
||||
pats = []
|
||||
for sc in shortcuts.listing():
|
||||
@@ -23,6 +32,8 @@ def stats() -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
pairs: dict[tuple, int] = {}
|
||||
follows: dict[tuple, int] = {}
|
||||
scores: dict[str, int] = {}
|
||||
rated_pairs: dict[tuple, int] = {}
|
||||
scanned = 0
|
||||
for meta in projects.listing():
|
||||
proj = projects.get(meta["id"])
|
||||
@@ -42,16 +53,22 @@ def stats() -> dict:
|
||||
continue
|
||||
present = {k for k, pat in pats if pat.search(text)}
|
||||
scanned += 1
|
||||
v = vote(sc.rating)
|
||||
for k in present:
|
||||
counts[k] = counts.get(k, 0) + 1
|
||||
if v:
|
||||
scores[k] = scores.get(k, 0) + v
|
||||
for a in present:
|
||||
for b in present:
|
||||
if a < b:
|
||||
pairs[(a, b)] = pairs.get((a, b), 0) + 1
|
||||
if v:
|
||||
rated_pairs[(a, b)] = rated_pairs.get((a, b), 0) + v
|
||||
for a in prev or ():
|
||||
for b in present:
|
||||
follows[(a, b)] = follows.get((a, b), 0) + 1
|
||||
prev = present
|
||||
return {"scenes": scanned, "counts": counts,
|
||||
"pairs": [[a, b, n] for (a, b), n in pairs.items()],
|
||||
"follows": [[a, b, n] for (a, b), n in follows.items()]}
|
||||
"follows": [[a, b, n] for (a, b), n in follows.items()],
|
||||
"scores": scores, "rated_pairs": [[a, b, n] for (a, b), n in rated_pairs.items()]}
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_pairs_follows_counts_come_from_typed_triggers_only():
|
||||
|
||||
|
||||
def test_no_projects_or_no_shortcuts_is_an_empty_answer():
|
||||
assert suggestions.stats() == {"scenes": 0, "counts": {}, "pairs": [], "follows": []}
|
||||
assert suggestions.stats() == {"scenes": 0, "counts": {}, "pairs": [], "follows": [], "scores": {}, "rated_pairs": []}
|
||||
|
||||
|
||||
def test_excluded_scenes_and_sub_clips_are_skipped_and_follows_use_timeline_order():
|
||||
@@ -41,3 +41,16 @@ def test_excluded_scenes_and_sub_clips_are_skipped_and_follows_use_timeline_orde
|
||||
s = suggestions.stats()
|
||||
assert s["scenes"] == 2 and s["counts"] == {"rain": 1, "neon": 1}
|
||||
assert s["follows"] == [["neon", "rain", 1]]
|
||||
|
||||
|
||||
def test_ratings_score_shortcuts_and_pairs_and_a_bad_image_blames_nothing():
|
||||
for n in ("rain", "neon", "fox"):
|
||||
shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]})
|
||||
projects.save(projects.Project(name="x", scenes=[
|
||||
projects.Scene(text="rain neon", rating="10"), projects.Scene(text="rain neon", rating="10|loved"),
|
||||
projects.Scene(text="neon fox", rating="Disliked: bad composition"),
|
||||
projects.Scene(text="fox", rating="Disliked: bad image"), projects.Scene(text="fox", rating="")]))
|
||||
s = suggestions.stats()
|
||||
assert s["scores"] == {"rain": 2, "neon": 1, "fox": -1}
|
||||
assert sorted(map(tuple, s["rated_pairs"])) == [("fox", "neon", -1), ("neon", "rain", 2)]
|
||||
assert [suggestions.vote(x) for x in ("1", "6", "5", "Disliked: bad image", "odd")] == [-1, 1, -1, 0, 0]
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Local prompt drafting from saved shortcuts and the user's reviewed edits."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from . import engine
|
||||
|
||||
ID = "conditioning_prompt_composer"
|
||||
TITLE = "Prompt composer"
|
||||
STAGE = "conditioning"
|
||||
CATEGORY = "conditioning"
|
||||
STATUS = "experimental"
|
||||
|
||||
|
||||
def routes(table, base, web):
|
||||
@table.get(base + "/status")
|
||||
async def _status(_req):
|
||||
return web.json_response(engine.status())
|
||||
|
||||
@table.get(base + "/analysis")
|
||||
async def _analysis(_req):
|
||||
return web.json_response(await asyncio.to_thread(engine.analysis))
|
||||
|
||||
@table.post(base + "/draft")
|
||||
async def _draft(req):
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return web.json_response({"why": "that is not JSON"}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"why": "send a prompt object"}, status=400)
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(engine.draft, body))
|
||||
except Exception as exc: # noqa: BLE001 -- keep an optional feature from taking down Composer
|
||||
return web.json_response({"why": str(exc)}, status=400)
|
||||
|
||||
@table.post(base + "/generate")
|
||||
async def _generate(req):
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return web.json_response({"why": "that is not JSON"}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"why": "send a prompt object"}, status=400)
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(engine.generate, body))
|
||||
except Exception as exc: # noqa: BLE001 -- the prompt feature cannot stop the Composer
|
||||
return web.json_response({"why": str(exc)}, status=400)
|
||||
|
||||
@table.post(base + "/learn")
|
||||
async def _learn(req):
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return web.json_response({"why": "that is not JSON"}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"why": "send a prompt object"}, status=400)
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(engine.learn, body))
|
||||
except (ValueError, OSError) as exc:
|
||||
return web.json_response({"why": str(exc)}, status=400)
|
||||
|
||||
@table.post(base + "/capture")
|
||||
async def _capture(req):
|
||||
try:
|
||||
body = await req.json()
|
||||
return web.json_response(await asyncio.to_thread(
|
||||
engine.capture, body.get("prompt_id"), body.get("text"), body.get("source_text")))
|
||||
except (ValueError, AttributeError, OSError) as exc:
|
||||
return web.json_response({"why": str(exc)}, status=400)
|
||||
|
||||
@table.post(base + "/settings")
|
||||
async def _settings(req):
|
||||
try:
|
||||
body = await req.json()
|
||||
return web.json_response(engine.set_enabled(body.get("enabled")))
|
||||
except (ValueError, AttributeError, OSError) as exc:
|
||||
return web.json_response({"why": str(exc)}, status=400)
|
||||
|
||||
@table.post(base + "/clear")
|
||||
async def _clear(_req):
|
||||
try:
|
||||
return web.json_response(engine.clear())
|
||||
except OSError as exc:
|
||||
return web.json_response({"why": str(exc)}, status=500)
|
||||
|
||||
|
||||
def prompt_transform(prompt):
|
||||
return engine.transform(prompt)
|
||||
|
||||
|
||||
def on_rating(prompt_id, rating, axis=None):
|
||||
return engine.rate(prompt_id, rating, axis)
|
||||
|
||||
|
||||
PROVIDES = {"routes": routes, "prompt_transform": prompt_transform, "on_rating": on_rating}
|
||||
@@ -1,508 +0,0 @@
|
||||
"""Local, reviewed prompt drafts built from shortcuts and exact user edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections import Counter
|
||||
|
||||
from ....core import config, prompt_build, shortcuts
|
||||
|
||||
_LOCK = threading.RLock()
|
||||
_MAX_EXAMPLES = 300
|
||||
_MAX_TEXT = 32768
|
||||
_MAX_CAPTURES = 500
|
||||
_STOP = {"a", "an", "and", "as", "at", "by", "for", "from", "in", "into", "is", "of", "on", "or", "the", "to", "with"}
|
||||
_WORD = re.compile(r"[\w'-]+", re.UNICODE)
|
||||
_EDIT_TOKEN = re.compile(r"[\w'-]+|[^\w\s]", re.UNICODE)
|
||||
_SHOT = re.compile(r"\[Shot\s+\d+\]", re.IGNORECASE)
|
||||
_CUT_TIME = re.compile(r"\bAt\s+\d{2}:\d{2}(?:\.\d+)?", re.IGNORECASE)
|
||||
_nlp = None
|
||||
_nlp_tried = False
|
||||
|
||||
|
||||
def _path():
|
||||
return config.ROOT / "prompt_composer_memory.json"
|
||||
|
||||
|
||||
def _read():
|
||||
try:
|
||||
value = json.loads(_path().read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
value = {}
|
||||
if not isinstance(value, dict):
|
||||
value = {}
|
||||
examples = value.get("examples")
|
||||
if isinstance(examples, list):
|
||||
for row in examples[-_MAX_EXAMPLES:]:
|
||||
if isinstance(row, dict) and not isinstance(row.get("prompt_ratings"), dict):
|
||||
old = row.get("composition_ratings")
|
||||
row["prompt_ratings"] = {key: "bad" for key, vote in old.items()
|
||||
if vote == "bad"} if isinstance(old, dict) else {}
|
||||
return {"enabled": value.get("enabled") is True,
|
||||
"examples": examples[-_MAX_EXAMPLES:] if isinstance(examples, list) else [],
|
||||
"captures": value.get("captures", [])[-_MAX_CAPTURES:] if isinstance(value.get("captures"), list) else []}
|
||||
|
||||
|
||||
def _write(data):
|
||||
path = _path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _key(text):
|
||||
return hashlib.sha256(str(text or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _nlp_model():
|
||||
global _nlp, _nlp_tried
|
||||
if not _nlp_tried:
|
||||
_nlp_tried = True
|
||||
try:
|
||||
import spacy
|
||||
_nlp = spacy.load("en_core_web_sm", disable=["ner"])
|
||||
except Exception: # noqa: BLE001 -- analysis still works without spaCy/model
|
||||
_nlp = None
|
||||
return _nlp
|
||||
|
||||
|
||||
def _body_text(body):
|
||||
body = body if isinstance(body, dict) else {}
|
||||
return prompt_build.build(
|
||||
str(body.get("text") or "")[:_MAX_TEXT],
|
||||
anchor=str(body.get("anchor") or "")[:_MAX_TEXT],
|
||||
postfix=str(body.get("postfix") or "")[:_MAX_TEXT],
|
||||
postfix_enabled=body.get("postfix_enabled") is not False,
|
||||
variables=body.get("variables") if isinstance(body.get("variables"), list) else None,
|
||||
seed=body.get("seed") if isinstance(body.get("seed"), int) and not isinstance(body.get("seed"), bool) else 1,
|
||||
)
|
||||
|
||||
|
||||
def _postfix_text(body):
|
||||
"""Resolve the project's tail by itself so learned edits can never rewrite it."""
|
||||
body = body if isinstance(body, dict) else {}
|
||||
if body.get("postfix_enabled") is False:
|
||||
return ""
|
||||
return prompt_build.build(
|
||||
"", postfix=str(body.get("postfix") or "")[:_MAX_TEXT],
|
||||
variables=body.get("variables") if isinstance(body.get("variables"), list) else None,
|
||||
seed=body.get("seed") if isinstance(body.get("seed"), int) and not isinstance(body.get("seed"), bool) else 1,
|
||||
)
|
||||
|
||||
|
||||
def _remove_current_anchor(source, body):
|
||||
anchor = " ".join(str(body.get("anchor") or "").split())
|
||||
source = str(source or "").strip()
|
||||
if anchor and source.startswith(anchor) and (len(source) == len(anchor) or source[len(anchor)].isspace()):
|
||||
return source[len(anchor):].strip()
|
||||
return source
|
||||
|
||||
|
||||
def _preserve_postfix(accepted, postfix):
|
||||
"""Keep a resolved sound/postfix variable byte-for-byte at the end of a review."""
|
||||
if not postfix or accepted.endswith(postfix):
|
||||
return accepted
|
||||
# If the user removed only the end of the tail, replace that partial remnant
|
||||
# instead of duplicating it. The complete configured tail is always restored.
|
||||
overlap = min(len(postfix), len(accepted))
|
||||
while overlap and not accepted.endswith(postfix[:overlap]):
|
||||
overlap -= 1
|
||||
if overlap:
|
||||
accepted = accepted[:-overlap]
|
||||
return accepted.rstrip() + (" " if accepted.rstrip() else "") + postfix
|
||||
|
||||
|
||||
def _shot_structure(text):
|
||||
text = str(text or "")
|
||||
markers = list(_SHOT.finditer(text))
|
||||
return {"shots": len(markers), "cut_times": len(_CUT_TIME.findall(text)),
|
||||
"shot_camera_ready": bool(markers)}
|
||||
|
||||
|
||||
def _shot_camera_run(prompt_id):
|
||||
try:
|
||||
from ..shot_camera import memory as shot_memory
|
||||
run = shot_memory.run_for_prompt(prompt_id)
|
||||
except Exception: # noqa: BLE001 -- optional module and old memory files are non-fatal
|
||||
return None
|
||||
if not isinstance(run, dict):
|
||||
return None
|
||||
arms = run.get("arms") if isinstance(run.get("arms"), list) else []
|
||||
views = run.get("views") if isinstance(run.get("views"), list) else []
|
||||
details = run.get("details") if isinstance(run.get("details"), list) else []
|
||||
return {"views": [str(row.get("view")) for row in views if isinstance(row, dict) and row.get("view")],
|
||||
"cuts_added": sum(str(arm) == "split:yes" for arm in arms),
|
||||
"camera_moves_added": sum(str(arm) == "move:yes" for arm in arms),
|
||||
"details_added": [str(row.get("phrase")) for row in details if isinstance(row, dict) and row.get("phrase")],
|
||||
"final_text": run.get("text") if isinstance(run.get("text"), str) else None}
|
||||
|
||||
|
||||
def _shortcut_units(text, items=None):
|
||||
"""Find the user's actual shortcut triggers in a source prompt, preserving order."""
|
||||
items = items if items is not None else [s for s in shortcuts.listing() if s.enabled]
|
||||
candidates = sorted(((trigger, item.name) for item in items
|
||||
for trigger in item.triggers if trigger), key=lambda row: -len(row[0]))
|
||||
if not candidates:
|
||||
return []
|
||||
pattern = re.compile(r"(?<!\w)(?:" + "|".join(re.escape(trigger) for trigger, _name in candidates) + r")(?!\w)", re.IGNORECASE)
|
||||
names = {trigger.casefold(): name for trigger, name in candidates}
|
||||
return [names.get(match.group(0).casefold(), match.group(0)) for match in pattern.finditer(str(text or ""))]
|
||||
|
||||
|
||||
def _shortcut_trigger(name, items):
|
||||
for item in items:
|
||||
if item.name.casefold() == str(name).casefold() and item.triggers:
|
||||
return item.triggers[0]
|
||||
return str(name)
|
||||
|
||||
|
||||
def _training_counts(data):
|
||||
"""Rating-weighted shortcut and ordering counts from recorded user runs."""
|
||||
items = [s for s in shortcuts.listing() if s.enabled]
|
||||
use = Counter()
|
||||
transitions = Counter()
|
||||
neutral = Counter()
|
||||
for row in data.get("captures", []):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
units = _shortcut_units(row.get("source_text"), items)
|
||||
rating = row.get("rating")
|
||||
weight = 2 if rating == "good" else -2 if rating == "bad" else 0
|
||||
for unit in set(units):
|
||||
if weight > 0:
|
||||
use[(unit.casefold(), "good")] += weight
|
||||
elif weight < 0:
|
||||
use[(unit.casefold(), "bad")] += -weight
|
||||
else:
|
||||
neutral[unit.casefold()] += 1
|
||||
for left, right in zip(units, units[1:]):
|
||||
if weight > 0:
|
||||
transitions[(left.casefold(), right.casefold(), "good")] += weight
|
||||
elif weight < 0:
|
||||
transitions[(left.casefold(), right.casefold(), "bad")] += -weight
|
||||
return use, transitions, neutral
|
||||
|
||||
|
||||
def generate(body):
|
||||
"""Build a prompt with rated shortcut order and replacement relevance; no language model."""
|
||||
body = body if isinstance(body, dict) else {}
|
||||
idea = str(body.get("text") or "")[:_MAX_TEXT].strip()
|
||||
items = [s for s in shortcuts.listing() if s.enabled and s.replacements and s.triggers]
|
||||
if not items:
|
||||
return {"why": "There are no enabled shortcuts with replacements yet.", "draft": _body_text(body), "generated": False}
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
usage, transitions, neutral = _training_counts(data)
|
||||
units = _shortcut_units(idea, items)
|
||||
idea_words = {word.casefold() for word in _WORD.findall(idea) if word.casefold() not in _STOP}
|
||||
chosen = []
|
||||
reasons = []
|
||||
if units:
|
||||
chosen.extend(units)
|
||||
elif not idea:
|
||||
# With no seed idea, reuse the strongest liked source composition as the
|
||||
# starting grammar. It remains the user's macro prompt, including $vars.
|
||||
rated = [r for r in data.get("captures", []) if isinstance(r, dict) and r.get("rating") == "good" and r.get("source_text")]
|
||||
if rated:
|
||||
frequency = Counter(r.get("source_text") for r in rated)
|
||||
last_seen = {r.get("source_text"): i for i, r in enumerate(rated)}
|
||||
source = max(frequency, key=lambda value: (frequency[value], len(_shortcut_units(value, items)), last_seen[value]))
|
||||
idea = _remove_current_anchor(str(source or "")[:_MAX_TEXT], body)
|
||||
chosen.extend(_shortcut_units(idea, items))
|
||||
units = _shortcut_units(idea, items)
|
||||
reasons.append("started from a liked prompt structure")
|
||||
else:
|
||||
remembered = [r for r in data.get("captures", []) if isinstance(r, dict) and r.get("source_text")]
|
||||
if remembered:
|
||||
idea = _remove_current_anchor(str(remembered[-1].get("source_text") or "")[:_MAX_TEXT], body)
|
||||
chosen.extend(_shortcut_units(idea, items))
|
||||
units = _shortcut_units(idea, items)
|
||||
reasons.append("started from a recent prompt structure while ratings are still sparse")
|
||||
if not chosen and idea_words:
|
||||
candidates = []
|
||||
for item in items:
|
||||
replacement_words = {word.casefold() for phrase in item.replacements
|
||||
for word in _WORD.findall(phrase) if word.casefold() not in _STOP}
|
||||
overlap = len(idea_words & replacement_words) / max(1, len(idea_words | replacement_words) ** 0.5)
|
||||
good = usage[(item.name.casefold(), "good")]
|
||||
bad = usage[(item.name.casefold(), "bad")]
|
||||
score = overlap + 0.08 * (good - bad) + 0.015 * neutral[item.name.casefold()]
|
||||
if overlap > 0:
|
||||
candidates.append((score, item))
|
||||
candidates.sort(key=lambda row: (-row[0], row[1].name.casefold()))
|
||||
for _score, item in candidates[:2]:
|
||||
chosen.append(item.name)
|
||||
if candidates:
|
||||
reasons.append("matched shortcut actions to the idea and rating history")
|
||||
if chosen:
|
||||
last = chosen[-1].casefold()
|
||||
next_candidates = []
|
||||
for item in items:
|
||||
name = item.name.casefold()
|
||||
if name in {x.casefold() for x in chosen}:
|
||||
continue
|
||||
good = transitions[(last, name, "good")]
|
||||
bad = transitions[(last, name, "bad")]
|
||||
score = good - bad
|
||||
if good or bad:
|
||||
next_candidates.append((score, good, item))
|
||||
next_candidates.sort(key=lambda row: (-row[0], -row[1], row[2].name.casefold()))
|
||||
if next_candidates and next_candidates[0][0] > 0 and len(chosen) < 3:
|
||||
chosen.append(next_candidates[0][2].name)
|
||||
reasons.append("continued a shortcut order that received positive ratings")
|
||||
if not idea:
|
||||
# A cold start still gives a useful, editable draft from the available
|
||||
# library; rated runs will gradually replace this inventory-based pick.
|
||||
item = max(items, key=lambda row: (sum(len(x) for x in row.replacements), row.name.casefold()))
|
||||
chosen = [item.name]
|
||||
idea = _shortcut_trigger(item.name, items)
|
||||
units = list(chosen)
|
||||
reasons.append("cold start: selected from the enabled shortcut library")
|
||||
elif chosen:
|
||||
additions = chosen[len(units):]
|
||||
if additions:
|
||||
triggers = [_shortcut_trigger(name, items) for name in additions]
|
||||
idea = " ".join(part for part in (idea, " ".join(triggers)) if part).strip()
|
||||
base = _body_text({**body, "text": idea})
|
||||
return {"base": base, "draft": base, "generated": True, "source_text": idea,
|
||||
"shortcuts": chosen, "reasons": reasons,
|
||||
"training_runs": sum(isinstance(r, dict) and r.get("rating") in ("good", "bad") for r in data.get("captures", [])),
|
||||
"shot_structure": _shot_structure(base),
|
||||
"postfix_preserved": bool(_postfix_text(body))}
|
||||
|
||||
|
||||
def _learned(base, data=None):
|
||||
data = data or _read()
|
||||
key = _key(base)
|
||||
for row in reversed(data["examples"]):
|
||||
if isinstance(row, dict) and row.get("key") == key:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_votes(row):
|
||||
ratings = row.get("prompt_ratings", {}) if isinstance(row, dict) else {}
|
||||
if not isinstance(ratings, dict):
|
||||
return {"good": 0, "bad": 0}
|
||||
return {"good": sum(value == "good" for value in ratings.values()),
|
||||
"bad": sum(value == "bad" for value in ratings.values())}
|
||||
|
||||
|
||||
def _needs_review(row):
|
||||
votes = _prompt_votes(row)
|
||||
return votes["bad"] - votes["good"] >= 2
|
||||
|
||||
|
||||
def transform(prompt):
|
||||
"""Optional core hook: replay a reviewed edit only for the exact same prompt."""
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
if not data["enabled"]:
|
||||
return None
|
||||
row = _learned(prompt, data)
|
||||
# A single dislike can be seed luck. Repeated negative prompt feedback
|
||||
# stops automatic replay until the user reviews and saves a new edit.
|
||||
return (_preserve_postfix(row.get("accepted", prompt), row.get("postfix", ""))
|
||||
if row and not _needs_review(row) else None)
|
||||
|
||||
|
||||
def draft(body):
|
||||
base = _body_text(body)
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
row = _learned(base, data)
|
||||
votes = _prompt_votes(row)
|
||||
review_needed = row is not None and _needs_review(row)
|
||||
use_learned = data["enabled"] and row is not None and not review_needed
|
||||
accepted = _preserve_postfix(row.get("accepted", base), _postfix_text(body)) if use_learned else base
|
||||
return {"base": base, "draft": accepted,
|
||||
"learned": use_learned, "enabled": data["enabled"],
|
||||
"edits": row.get("edits", []) if use_learned else [],
|
||||
"prompt_votes": votes,
|
||||
"prompt_review_needed": review_needed,
|
||||
"shot_structure": _shot_structure(base),
|
||||
"postfix_preserved": bool(_postfix_text(body))}
|
||||
|
||||
|
||||
def _edit_summary(before, after):
|
||||
a, b = _EDIT_TOKEN.findall(before), _EDIT_TOKEN.findall(after)
|
||||
matcher = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
|
||||
out = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag != "equal":
|
||||
out.append({"change": tag, "before": " ".join(a[i1:i2]),
|
||||
"after": " ".join(b[j1:j2]), "position": i1})
|
||||
return out[:100]
|
||||
|
||||
|
||||
def learn(body):
|
||||
body = body if isinstance(body, dict) else {}
|
||||
accepted = body.get("accepted")
|
||||
if not isinstance(accepted, str) or not accepted.strip():
|
||||
raise ValueError("The reviewed prompt cannot be empty.")
|
||||
accepted = accepted[:_MAX_TEXT]
|
||||
base = _body_text(body)
|
||||
if not base.strip():
|
||||
raise ValueError("Enter a prompt idea before saving a learned edit.")
|
||||
accepted = _preserve_postfix(accepted, _postfix_text(body))
|
||||
row = {"key": _key(base), "base": base, "accepted": accepted,
|
||||
"postfix": _postfix_text(body),
|
||||
"edits": _edit_summary(base, accepted), "prompt_ratings": {}}
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
data["examples"] = [r for r in data["examples"]
|
||||
if not isinstance(r, dict) or r.get("key") != row["key"]]
|
||||
data["examples"].append(row)
|
||||
data["examples"] = data["examples"][-_MAX_EXAMPLES:]
|
||||
_write(data)
|
||||
return {"learned": True, "edit_count": len(row["edits"]),
|
||||
"examples": len(data["examples"]), "enabled": data["enabled"]}
|
||||
|
||||
|
||||
def capture(prompt_id, prompt_text, source_text=None):
|
||||
"""Keep the exact prompt sent to ComfyUI until its render is rated."""
|
||||
if not isinstance(prompt_id, str) or not prompt_id:
|
||||
return {"captured": False}
|
||||
prompt_text = str(prompt_text or "")[:_MAX_TEXT]
|
||||
if not prompt_text.strip():
|
||||
return {"captured": False}
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
example = next((r for r in reversed(data["examples"])
|
||||
if isinstance(r, dict) and r.get("accepted") == prompt_text), None)
|
||||
camera = _shot_camera_run(prompt_id)
|
||||
record = {"prompt_id": prompt_id, "text": prompt_text,
|
||||
"source_text": str(source_text or "")[:_MAX_TEXT],
|
||||
"rating": None,
|
||||
"example_key": example.get("key") if example else None,
|
||||
"example_text": example.get("accepted") if example else None,
|
||||
"shot_structure": _shot_structure(prompt_text),
|
||||
"shot_camera": {key: value for key, value in (camera or {}).items() if key != "final_text"}}
|
||||
data["captures"] = [r for r in data["captures"]
|
||||
if not isinstance(r, dict) or r.get("prompt_id") != prompt_id]
|
||||
data["captures"].append(record)
|
||||
data["captures"] = data["captures"][-_MAX_CAPTURES:]
|
||||
_write(data)
|
||||
return {"captured": True, "linked": True,
|
||||
"shot_structure": record["shot_structure"],
|
||||
"shot_camera": record["shot_camera"]}
|
||||
|
||||
|
||||
def rate(prompt_id, rating, axis=None):
|
||||
"""Attach prompt-relevant ratings to the reviewed prompt that ran."""
|
||||
if not isinstance(prompt_id, str) or not prompt_id:
|
||||
return {"linked": False}
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
capture_row = next((r for r in reversed(data["captures"])
|
||||
if isinstance(r, dict) and r.get("prompt_id") == prompt_id), None)
|
||||
if not capture_row:
|
||||
return {"linked": False}
|
||||
value = ("good" if rating == "liked" else
|
||||
"bad" if rating == "disliked" and axis != "image" else None)
|
||||
capture_row["rating"] = value
|
||||
example = next((r for r in data["examples"]
|
||||
if isinstance(r, dict) and r.get("key") == capture_row.get("example_key")
|
||||
and r.get("accepted") == capture_row.get("example_text")), None)
|
||||
if example:
|
||||
ratings = example.setdefault("prompt_ratings", {})
|
||||
else:
|
||||
ratings = None
|
||||
if ratings is not None and value == "good":
|
||||
ratings[prompt_id] = "good"
|
||||
elif ratings is not None and value == "bad":
|
||||
# A general dislike may include bad composition. An explicit bad
|
||||
# image is excluded because it does not identify a prompt problem.
|
||||
ratings[prompt_id] = "bad"
|
||||
elif ratings is not None:
|
||||
# Clearing or an image-only dislike removes this run's prompt vote.
|
||||
ratings.pop(prompt_id, None)
|
||||
_write(data)
|
||||
return {"linked": True, "prompt_votes": _prompt_votes(example) if example else {"good": 0, "bad": 0},
|
||||
"prompt_review_needed": _needs_review(example) if example else False,
|
||||
"shot_camera": capture_row.get("shot_camera", {})}
|
||||
|
||||
|
||||
def set_enabled(enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError("enabled must be true or false")
|
||||
with _LOCK:
|
||||
data = _read()
|
||||
data["enabled"] = enabled
|
||||
_write(data)
|
||||
return status(data)
|
||||
|
||||
|
||||
def clear():
|
||||
with _LOCK:
|
||||
data = {"enabled": False, "examples": [], "captures": []}
|
||||
_write(data)
|
||||
return status(data)
|
||||
|
||||
|
||||
def status(data=None):
|
||||
data = data or _read()
|
||||
return {"enabled": data["enabled"], "examples": len(data["examples"]),
|
||||
"captured_prompts": len(data["captures"]),
|
||||
"prompt_likes": sum(_prompt_votes(row)["good"] for row in data["examples"] if isinstance(row, dict)),
|
||||
"prompt_dislikes": sum(_prompt_votes(row)["bad"] for row in data["examples"] if isinstance(row, dict)),
|
||||
"prompt_review_needed": sum(_needs_review(row) for row in data["examples"] if isinstance(row, dict)),
|
||||
"shot_camera_runs": sum(bool(row.get("shot_camera")) for row in data["captures"] if isinstance(row, dict)),
|
||||
"shot_views_added": sum(len(row.get("shot_camera", {}).get("views", [])) for row in data["captures"] if isinstance(row, dict)),
|
||||
"shot_cuts_added": sum(int(row.get("shot_camera", {}).get("cuts_added", 0)) for row in data["captures"] if isinstance(row, dict)),
|
||||
"shot_camera_moves_added": sum(int(row.get("shot_camera", {}).get("camera_moves_added", 0)) for row in data["captures"] if isinstance(row, dict)),
|
||||
"shot_details_added": sum(len(row.get("shot_camera", {}).get("details_added", [])) for row in data["captures"] if isinstance(row, dict)),
|
||||
"rated_prompt_runs": sum(row.get("rating") == "good" for row in data["captures"] if isinstance(row, dict)),
|
||||
"disliked_prompt_runs": sum(row.get("rating") == "bad" for row in data["captures"] if isinstance(row, dict))}
|
||||
|
||||
|
||||
def analysis():
|
||||
items = [s for s in shortcuts.listing() if s.enabled and s.replacements]
|
||||
categories, phrases, words, patterns, positions, neighbors = Counter(), Counter(), Counter(), Counter(), {}, Counter()
|
||||
phrase_rows = []
|
||||
for item in items:
|
||||
category = " / ".join(x for x in (item.category, item.sub_category) if x) or "Uncategorised"
|
||||
categories[category] += 1
|
||||
for replacement in item.replacements:
|
||||
clean = " ".join(replacement.split())
|
||||
if not clean:
|
||||
continue
|
||||
phrases[clean.casefold()] += 1
|
||||
phrase_rows.append((clean, category))
|
||||
tokens = [x.casefold() for x in _WORD.findall(clean) if x.casefold() not in _STOP]
|
||||
words.update(tokens)
|
||||
for i, token in enumerate(tokens):
|
||||
bucket = "single" if len(tokens) == 1 else "start" if i == 0 else "end" if i == len(tokens) - 1 else "middle"
|
||||
positions.setdefault(token, Counter())[bucket] += 1
|
||||
if i:
|
||||
neighbors[(tokens[i - 1], token)] += 1
|
||||
nlp = _nlp_model()
|
||||
if nlp is not None:
|
||||
pattern = " ".join(t.pos_ or "X" for t in nlp(clean) if not t.is_punct)
|
||||
if pattern:
|
||||
patterns[pattern] += 1
|
||||
top_phrases = []
|
||||
seen = set()
|
||||
for phrase, category in sorted(phrase_rows, key=lambda r: (-phrases[r[0].casefold()], r[0].casefold())):
|
||||
key = phrase.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
top_phrases.append({"text": phrase, "category": category, "count": phrases[key]})
|
||||
if len(top_phrases) == 12:
|
||||
break
|
||||
with _LOCK:
|
||||
state = status()
|
||||
return {"shortcuts": len(items), "categories": [{"name": k, "count": v} for k, v in categories.most_common(12)],
|
||||
"phrases": top_phrases, "words": [{"text": k, "count": v} for k, v in words.most_common(16)],
|
||||
"positions": [{"text": word, **counts} for word, counts in sorted(
|
||||
positions.items(), key=lambda item: (-sum(item[1].values()), item[0]))[:8]],
|
||||
"neighbors": [{"left": left, "right": right, "count": count}
|
||||
for (left, right), count in neighbors.most_common(10)],
|
||||
"patterns": [{"pattern": k, "count": v} for k, v in patterns.most_common(10)],
|
||||
"spacy": _nlp is not None, **state}
|
||||
@@ -378,15 +378,6 @@ def record_run(prompt_id, chose):
|
||||
_save(data)
|
||||
|
||||
|
||||
@_locked
|
||||
def run_for_prompt(prompt_id):
|
||||
"""Return the shot-camera choices made for one queued prompt, if they are ready."""
|
||||
if not isinstance(prompt_id, str) or not prompt_id:
|
||||
return None
|
||||
run = (_read().get("runs") or {}).get(prompt_id)
|
||||
return dict(run) if isinstance(run, dict) else None
|
||||
|
||||
|
||||
def _sign(rating, axis):
|
||||
"""+1 liked; -1 disliked for the camera; 0 for no rating, or a dislike that blames the picture alone."""
|
||||
return 1 if rating == "liked" else -1 if rating == "disliked" and axis != "image" else 0
|
||||
|
||||
Reference in New Issue
Block a user