Add advisory mode for kids LLM review, show reviewer notes on jobs, allow sun-kissed

This commit is contained in:
Justin Oros
2026-09-17 22:58:33 -07:00
parent 9205e4614b
commit 10e49dd39e
2 changed files with 96 additions and 22 deletions
+88 -16
View File
@@ -44,6 +44,7 @@ SCOPES = [
MASK = "********"
MIN_FREE_GB = 2
LATIN_LANGS = {"en", "es", "fr", "de", "it", "pt", "nl", "sv", "no", "da", "fi", "pl", "cs", "ro", "hu", "tr", "id", "ms", "vi", "tl"}
REVIEW_MODES = ("advisory", "block", "off")
GLOBAL_DEFAULTS = {
"active_profile": "default",
@@ -55,7 +56,7 @@ GLOBAL_DEFAULTS = {
"vision_model": "llama3.2-vision",
"ollama_timeout": 300,
"gemini_api_key": "",
"veo_model": "veo-3.0-fast-generate-001",
"veo_model": "veo-3.1-fast-generate-preview",
"aspect_ratio": "9:16",
"target_seconds": 60,
"clip_seconds": 8,
@@ -67,6 +68,7 @@ PROFILE_DEFAULTS = {
"channel_niche": "",
"made_for_kids": False,
"kids_manual_review": True,
"kids_llm_review": "advisory",
"upload_category_id": "24",
"privacy_status": "private",
"trending_query": "",
@@ -90,6 +92,16 @@ Include a gentle lesson or positive message such as kindness, sharing, curiosity
Visuals are bright, colorful, calm, friendly animation in safe, cheerful settings, with no flashing or strobing light.
"""
REVIEW_RULES = {
"violence": "violence, weapons, injuries, blood, death, or dangerous acts a child could copy",
"scary": "scary, creepy, or disturbing imagery or events",
"humans": "realistic humans or real children as characters",
"adult": "romance, kissing, alcohol, tobacco, drugs, or other adult themes",
"commercial": "brand names, real products, toys for sale, or product placement",
"contact": "asking viewers to comment, like, subscribe, share personal information, visit a website, or leave YouTube",
"negative": "bullying, meanness, or a message that teaches bad behavior",
}
KIDS_VEO_SUFFIX = (
" Child-friendly cartoon animation with cute non-human characters, soft bright colors, calm pacing, "
"safe cheerful setting, nothing scary or dangerous, no realistic people, no logos, no flashing lights."
@@ -102,7 +114,7 @@ Return JSON only: {"safe": true or false, "reason": "short explanation"}"""
KIDS_BANNED = re.compile(
r"\b(kill\w*|blood\w*|bleed\w*|guns?|knife|knives|swords?|weapons?|bombs?|murder\w*|dead|die|dies|dying|death|"
r"scary|spooky|terrif\w*|horror|creepy|nightmare\w*|ghost\w*|haunt\w*|zombie\w*|demon\w*|devil\w*|bhoot|"
r"sexy|kiss\w*|beer|wine|drunk|cigar\w*|smok\w*|drugs?|vape\w*|hate)\b",
r"sexy|(?<!sun-)(?<!sun )kiss\w*|beer|wine|drunk|cigar\w*|smok\w*|drugs?|vape\w*|hate)\b",
re.I,
)
KIDS_CONTACT = re.compile(
@@ -591,6 +603,17 @@ Return JSON only: {{"tags": ["15 to 25 tags, most specific first"]}}"""
return []
def normalize_text(t):
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", str(t).lower())).strip()
def script_text(script):
parts = [script["title"], script["description"], script["style"]]
for sc in script["scenes"]:
parts += [sc["visual"], sc["narration"]]
return " ".join(parts)
def kids_rule_issues(script):
issues = []
texts = [script["title"], script["description"], script["style"]]
@@ -610,31 +633,62 @@ def kids_rule_issues(script):
def kids_llm_review(s, script):
scenes = "\n".join(f"{i}. Visual: {sc['visual']}\n Narration: {sc['narration']}" for i, sc in enumerate(script["scenes"], 1))
prompt = f"""You are a strict compliance reviewer for YouTube videos that are made for kids, applying COPPA and YouTube's quality principles for kids content.
Check this Short against every rule below. Reject it if any rule is broken or anything could upset, endanger, or mislead a young child.
rules = "\n".join(f"{k}: {v}" for k, v in REVIEW_RULES.items())
prompt = f"""You are a careful reviewer for a YouTube channel of cartoon stories for young children.
Check the script below against each rule. A rule is only broken if specific words in the script clearly break it.
Do not judge capitalization, punctuation, target age, art style, or anything not listed in the rules. Those are checked elsewhere.
Rules (id: what is not allowed):
{rules}
{KIDS_RULES}
Title: {script['title']}
Description: {script['description']}
Visual style: {script['style']}
Scenes:
{scenes}
Return JSON only: {{"pass": true or false, "issues": ["each specific problem"]}}"""
For every broken rule, copy the exact words from the script that break it.
Return JSON only: {{"violations": [{{"rule": "rule id", "quote": "exact words copied from the script", "reason": "short explanation"}}]}}
Return {{"violations": []}} if no rule is broken."""
try:
data = ollama_json(s, prompt, temperature=0.1, num_predict=1024)
except Cancelled:
raise
except Exception as e:
return False, [f"compliance reviewer error: {e}"]
issues = [str(i) for i in data.get("issues", []) if str(i).strip()] if isinstance(data.get("issues"), list) else []
return truthy(data.get("pass")), issues
return [f"compliance reviewer error: {e}"], 0
raw = data.get("violations", [])
if not isinstance(raw, list):
return ["compliance reviewer returned an invalid response"], 0
haystack = normalize_text(script_text(script))
verified, ignored = [], 0
for v in raw:
if not isinstance(v, dict):
ignored += 1
continue
rule = str(v.get("rule", "")).strip().lower()
quote = normalize_text(v.get("quote", ""))
if rule not in REVIEW_RULES or len(quote) < 3 or quote not in haystack:
ignored += 1
logger.debug("Ignored unverified reviewer finding: %s", v)
continue
verified.append(f"{rule}: '{v.get('quote')}' ({v.get('reason', '')})")
return verified, ignored
def review_mode(s):
mode = s["kids_llm_review"] if s["kids_llm_review"] in REVIEW_MODES else "advisory"
if mode == "advisory" and not s["kids_manual_review"]:
return "block"
return mode
def produce_script(s, src):
kids = bool(s["made_for_kids"])
feedback = []
attempts = 4 if kids else 1
mode = review_mode(s)
if kids and mode != s["kids_llm_review"]:
logger.info("LLM review switched to block mode because manual review is off")
for attempt in range(1, attempts + 1):
check()
set_stage("writing script")
@@ -651,12 +705,24 @@ def produce_script(s, src):
logger.info("Tags: %s", ", ".join(tags))
set_stage("kids compliance review")
issues = kids_rule_issues(script)
ok, llm_issues = kids_llm_review(s, script)
if not ok:
issues += llm_issues or ["reviewer rejected the script without details"]
notes, ignored = ([], 0) if mode == "off" else kids_llm_review(s, script)
if ignored:
logger.info("Ignored %d reviewer findings that did not quote the script or match a rule", ignored)
if mode == "block":
issues += notes
if not issues:
logger.info("Kids compliance review passed on attempt %d", attempt)
return script, tags, {"kids_checks": True, "script_review": "passed", "attempts": attempt, "dropped_tags": dropped}
if notes:
logger.warning("Reviewer notes (advisory, check during manual review): %s", "; ".join(notes))
logger.info("Kids compliance review passed on attempt %d (LLM review: %s)", attempt, mode)
return script, tags, {
"kids_checks": True,
"script_review": "passed",
"attempts": attempt,
"dropped_tags": dropped,
"reviewer_mode": mode,
"reviewer_notes": notes,
"reviewer_ignored_findings": ignored,
}
logger.warning("Kids compliance review failed (attempt %d/%d): %s", attempt, attempts, "; ".join(issues))
feedback = issues
raise RuntimeError(f"Script failed kids compliance review after {attempts} attempts")
@@ -784,7 +850,11 @@ def run_job(s):
prepare_storage(s)
logger.info("Profile: %s | Niche: %s | Made for kids: %s | Upload category: %s", s["name"], s["channel_niche"] or "none", kids, s["upload_category_id"])
if kids:
logger.info("Kids compliance active: script rules, word filters, LLM review, frame vision checks, made-for-kids flag%s", ", manual review hold" if s["kids_manual_review"] else "")
logger.info(
"Kids compliance active: script rules, word filters, LLM review (%s), frame vision checks, made-for-kids flag%s",
review_mode(s),
", manual review hold" if s["kids_manual_review"] else "",
)
set_stage("fetching trending videos")
trending = fetch_trending(s)
state = load_state()
@@ -822,7 +892,7 @@ def run_job(s):
finalized = False
try:
script, tags, compliance = produce_script(s, src)
update_history(job_id, title=script["title"])
update_history(job_id, title=script["title"], notes="; ".join(compliance.get("reviewer_notes", []))[:400])
write_json(job_dir / "script.json", {"profile": s["name"], "source": src, "script": script, "tags": tags})
client = genai.Client(api_key=s["gemini_api_key"])
orientation = "Vertical" if s["aspect_ratio"] == "9:16" else "Widescreen"
@@ -992,6 +1062,8 @@ async def post_settings(request: Request):
p[k] = coerce(PROFILE_DEFAULTS, k, v)
g["storage_dir"] = g["storage_dir"].strip().strip('"') or "videos"
p["source_language"] = p["source_language"].lower()[:5]
if p["kids_llm_review"] not in REVIEW_MODES:
p["kids_llm_review"] = "advisory"
root = validate_storage(g["storage_dir"])
except (TypeError, ValueError) as e:
return JSONResponse({"detail": str(e)}, status_code=400)
+8 -6
View File
@@ -38,6 +38,7 @@ input,select{width:100%;padding:8px;background:#0f1115;border:1px solid var(--bo
.grid h3{grid-column:1/-1;margin:10px 0 0;font-size:14px;color:var(--text);border-bottom:1px solid var(--border);padding-bottom:6px}
.grid .wide{grid-column:1/-1}
.hint{font-size:11px;color:var(--muted);margin-top:3px;word-break:break-all}
.hint.notes{color:#ffb74d;word-break:normal}
a{color:#64b5f6}
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#394150}
.pill.published{background:#1b5e20}.pill.failed{background:#b71c1c}.pill.running{background:#e65100}.pill.review{background:#6a1b9a}
@@ -105,6 +106,7 @@ const FIELDS=[
["channel_niche","Channel niche","wide","Steers every script, e.g. 60 second animated movies for kids ages 3 to 7","p"],
["made_for_kids","Made for kids","checkbox","Flags uploads as made for kids and enforces child-safe rules, word filters, LLM compliance review, and frame vision checks","p"],
["kids_manual_review","Hold kids videos for manual review","checkbox","Forces made-for-kids uploads to private so you can watch them before publishing","p"],
["kids_llm_review","LLM script review","select:advisory=Advisory (show notes, you review)|block=Block (reject flagged scripts)|off=Off","Advisory only applies while manual review is on, otherwise it blocks. Word filters and vision checks always block","p"],
["upload_category_id","Upload category","select:"+CATS,"","p"],
["privacy_status","Upload privacy","select:private|unlisted|public","","p"],
["trending_query","Trending search query","text","Blank uses the general trending chart. e.g. kids animated story","p"],
@@ -125,10 +127,10 @@ const FIELDS=[
["vision_model","Ollama vision model","text","Required for made-for-kids profiles. Run: ollama pull llama3.2-vision","g"],
["ollama_timeout","Ollama timeout (seconds)","number","Max wait per Ollama request before retrying","g"],
["gemini_api_key","Gemini API key (Veo)","password","Billing must be enabled","g"],
["veo_model","Veo model","text","Update if Google renames or retires the model","g"],
["veo_model","Veo model","text","e.g. veo-3.1-fast-generate-preview. Update if Google renames or retires the model","g"],
["aspect_ratio","Aspect ratio","select:9:16|16:9","","g"],
["target_seconds","Target length (seconds)","number","Split into clips of the length below","g"],
["clip_seconds","Seconds per clip","number","Veo 3 clips are 8 seconds","g"],
["clip_seconds","Seconds per clip","number","Veo clips are 8 seconds","g"],
["error_cooldown_minutes","Wait after error (minutes)","number","","g"]
];
const $=s=>document.querySelector(s);
@@ -155,9 +157,9 @@ const d=document.createElement("div");if(type==="wide")d.className="wide";
d.innerHTML=`<label>${label}</label>${input}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`;g.appendChild(d)}
syncKidsFields()}
function syncKidsFields(){
const kids=$("#f-made_for_kids");const review=$("#f-kids_manual_review");
if(!kids||!review)return;
review.disabled=!kids.checked;review.closest("div").style.opacity=kids.checked?1:.5;
const kids=$("#f-made_for_kids");
if(!kids)return;
for(const id of ["#f-kids_manual_review","#f-kids_llm_review"]){const el=$(id);if(!el)continue;el.disabled=!kids.checked;el.closest("div").style.opacity=kids.checked?1:.5}
kids.onchange=syncKidsFields}
async function saveSettings(){
const body={global:{},profile:{}};
@@ -176,7 +178,7 @@ $("#st-free").style.color=s.storage_free_gb!==null&&s.storage_free_gb<10?"#ffb74
$("#st-after").textContent=s.delete_after_publish?"Delete files":"Move to published";
$("#kids-badge").style.display=s.made_for_kids?"inline-block":"none";
$("#go").disabled=s.running;$("#stop").disabled=!s.running;
$("#jobs").innerHTML="<tr><th>Started</th><th>Profile</th><th>Source</th><th>Title</th><th>Status</th><th>Video</th></tr>"+s.history.map(h=>`<tr><td>${esc(h.started)}</td><td>${esc(h.profile)}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td>${esc(h.title)}</td><td><span class="pill ${esc(h.status)}">${h.status==="review"?"private, needs review":esc(h.status)}</span>${h.error?`<div class="hint">${esc(h.error)}</div>`:""}${h.location?`<div class="hint">${esc(h.location)}</div>`:""}</td><td>${h.youtube_id?`<a target="_blank" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">studio</a> <a target="_blank" href="https://youtube.com/shorts/${esc(h.youtube_id)}">open</a>`:""}</td></tr>`).join("")
$("#jobs").innerHTML="<tr><th>Started</th><th>Profile</th><th>Source</th><th>Title</th><th>Status</th><th>Video</th></tr>"+s.history.map(h=>`<tr><td>${esc(h.started)}</td><td>${esc(h.profile)}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td>${esc(h.title)}</td><td><span class="pill ${esc(h.status)}">${h.status==="review"?"private, needs review":esc(h.status)}</span>${h.notes?`<div class="hint notes">Reviewer notes: ${esc(h.notes)}</div>`:""}${h.error?`<div class="hint">${esc(h.error)}</div>`:""}${h.location?`<div class="hint">${esc(h.location)}</div>`:""}</td><td>${h.youtube_id?`<a target="_blank" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">studio</a> <a target="_blank" href="https://youtube.com/shorts/${esc(h.youtube_id)}">open</a>`:""}</td></tr>`).join("")
}catch(e){}}
let since=0;
async function pollLogs(){