Spaces:
Sleeping
Sleeping
Commit ·
01fb241
1
Parent(s): f6a6455
Fix Gradio 6 Textbox error and add SEO keyword-density controls
Browse files- Remove show_copy_button (unsupported by Textbox in Gradio 6.x) that crashed startup
- Add three keyword + density% inputs; instruct the tutorial LLM to weave them in at the
target keyword density, leaning toward under-use rather than keyword stuffing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +41 -5
- pipeline/tutorial.py +33 -3
app.py
CHANGED
|
@@ -65,8 +65,24 @@ def _safe_name(text: str) -> str:
|
|
| 65 |
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:60] or "tutorial"
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
| 69 |
-
max_minutes, max_shots,
|
|
|
|
| 70 |
"""Generator that yields (status_md, ranking_df, transcript, docx_file)."""
|
| 71 |
log: list[str] = []
|
| 72 |
|
|
@@ -126,8 +142,11 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
|
| 126 |
|
| 127 |
# 6. Tutorial text -----------------------------------------------------------
|
| 128 |
progress(0.72, desc="Writing tutorial")
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
# 7. Weighted screenshot selection ------------------------------------------
|
| 133 |
selected = frames_mod.select_screenshots(
|
|
@@ -182,6 +201,22 @@ def build_ui():
|
|
| 182 |
vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
|
| 183 |
label="Vision model (captions)", allow_custom_value=True)
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
with gr.Accordion("Advanced settings", open=False):
|
| 186 |
with gr.Row():
|
| 187 |
w_llm = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Weight: LLM timestamp")
|
|
@@ -198,12 +233,13 @@ def build_ui():
|
|
| 198 |
headers=["#", "Title", "Positive", "Comments", "Note", "URL"],
|
| 199 |
label="Sentiment ranking", interactive=False, wrap=True,
|
| 200 |
)
|
| 201 |
-
transcript_box = gr.Textbox(label="Transcript preview", lines=10, max_lines=20
|
| 202 |
docx_file = gr.File(label="Download tutorial (.docx)")
|
| 203 |
|
| 204 |
run_btn.click(
|
| 205 |
run_pipeline,
|
| 206 |
-
inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
|
|
|
| 207 |
outputs=[status_md, ranking_df, transcript_box, docx_file],
|
| 208 |
)
|
| 209 |
return demo
|
|
|
|
| 65 |
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:60] or "tutorial"
|
| 66 |
|
| 67 |
|
| 68 |
+
def _collect_keywords(kw1, d1, kw2, d2, kw3, d3) -> list[dict]:
|
| 69 |
+
"""Build the [{keyword, density}] list from the three UI keyword/density pairs."""
|
| 70 |
+
keywords = []
|
| 71 |
+
for kw, dens in ((kw1, d1), (kw2, d2), (kw3, d3)):
|
| 72 |
+
kw = (kw or "").strip()
|
| 73 |
+
if not kw:
|
| 74 |
+
continue
|
| 75 |
+
try:
|
| 76 |
+
dval = float(dens or 0)
|
| 77 |
+
except (TypeError, ValueError):
|
| 78 |
+
dval = 0.0
|
| 79 |
+
keywords.append({"keyword": kw, "density": max(0.0, dval)})
|
| 80 |
+
return keywords
|
| 81 |
+
|
| 82 |
+
|
| 83 |
def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
| 84 |
+
max_minutes, max_shots, kw1, d1, kw2, d2, kw3, d3,
|
| 85 |
+
progress=gr.Progress()):
|
| 86 |
"""Generator that yields (status_md, ranking_df, transcript, docx_file)."""
|
| 87 |
log: list[str] = []
|
| 88 |
|
|
|
|
| 142 |
|
| 143 |
# 6. Tutorial text -----------------------------------------------------------
|
| 144 |
progress(0.72, desc="Writing tutorial")
|
| 145 |
+
keywords = _collect_keywords(kw1, d1, kw2, d2, kw3, d3)
|
| 146 |
+
kw_note = (" with keywords " + ", ".join(f"{k['keyword']} (~{k['density']:.1f}%)"
|
| 147 |
+
for k in keywords)) if keywords else ""
|
| 148 |
+
yield status(f"🤖 Generating tutorial with `{llm_model}`{kw_note}…"), ranking, gr.update(value=transcript), gr.update()
|
| 149 |
+
tut = tutorial_mod.generate_tutorial(transcript, hf_token.strip(), llm_model, keywords)
|
| 150 |
|
| 151 |
# 7. Weighted screenshot selection ------------------------------------------
|
| 152 |
selected = frames_mod.select_screenshots(
|
|
|
|
| 201 |
vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
|
| 202 |
label="Vision model (captions)", allow_custom_value=True)
|
| 203 |
|
| 204 |
+
with gr.Accordion("SEO keywords (optional)", open=False):
|
| 205 |
+
gr.Markdown(
|
| 206 |
+
"Weave up to three keywords into the tutorial at a target **keyword "
|
| 207 |
+
"density** (% of total words). The writer aims for these but stays "
|
| 208 |
+
"natural — it may use a keyword somewhat less often than requested."
|
| 209 |
+
)
|
| 210 |
+
with gr.Row():
|
| 211 |
+
kw1 = gr.Textbox(label="Keyword 1", scale=3)
|
| 212 |
+
d1 = gr.Number(label="Density % 1", value=0, minimum=0, maximum=100, scale=1)
|
| 213 |
+
with gr.Row():
|
| 214 |
+
kw2 = gr.Textbox(label="Keyword 2", scale=3)
|
| 215 |
+
d2 = gr.Number(label="Density % 2", value=0, minimum=0, maximum=100, scale=1)
|
| 216 |
+
with gr.Row():
|
| 217 |
+
kw3 = gr.Textbox(label="Keyword 3", scale=3)
|
| 218 |
+
d3 = gr.Number(label="Density % 3", value=0, minimum=0, maximum=100, scale=1)
|
| 219 |
+
|
| 220 |
with gr.Accordion("Advanced settings", open=False):
|
| 221 |
with gr.Row():
|
| 222 |
w_llm = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Weight: LLM timestamp")
|
|
|
|
| 233 |
headers=["#", "Title", "Positive", "Comments", "Note", "URL"],
|
| 234 |
label="Sentiment ranking", interactive=False, wrap=True,
|
| 235 |
)
|
| 236 |
+
transcript_box = gr.Textbox(label="Transcript preview", lines=10, max_lines=20)
|
| 237 |
docx_file = gr.File(label="Download tutorial (.docx)")
|
| 238 |
|
| 239 |
run_btn.click(
|
| 240 |
run_pipeline,
|
| 241 |
+
inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
| 242 |
+
max_minutes, max_shots, kw1, d1, kw2, d2, kw3, d3],
|
| 243 |
outputs=[status_md, ranking_df, transcript_box, docx_file],
|
| 244 |
)
|
| 245 |
return demo
|
pipeline/tutorial.py
CHANGED
|
@@ -46,6 +46,8 @@ Rules:
|
|
| 46 |
- t_llm must be within the transcript's time range.
|
| 47 |
- Output valid JSON only. No markdown, no comments in the actual output.
|
| 48 |
|
|
|
|
|
|
|
| 49 |
Transcript (each line is "[mm:ss] text"):
|
| 50 |
---
|
| 51 |
{transcript}
|
|
@@ -53,6 +55,27 @@ Transcript (each line is "[mm:ss] text"):
|
|
| 53 |
"""
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
def _extract_json(text: str) -> dict:
|
| 57 |
"""Parse the model output into a dict, tolerating code fences / stray prose."""
|
| 58 |
text = text.strip()
|
|
@@ -95,15 +118,22 @@ def _normalize(data: dict) -> dict:
|
|
| 95 |
}
|
| 96 |
|
| 97 |
|
| 98 |
-
def generate_tutorial(transcript: str, hf_token: str, model: str = DEFAULT_LLM
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if not hf_token:
|
| 101 |
raise ValueError("An HF token is required for the tutorial LLM (billed to your key).")
|
| 102 |
|
| 103 |
truncated = transcript[:MAX_TRANSCRIPT_CHARS]
|
| 104 |
if len(transcript) > MAX_TRANSCRIPT_CHARS:
|
| 105 |
truncated += "\n[... transcript truncated for length ...]"
|
| 106 |
-
prompt =
|
|
|
|
|
|
|
| 107 |
|
| 108 |
client = InferenceClient(token=hf_token)
|
| 109 |
try:
|
|
|
|
| 46 |
- t_llm must be within the transcript's time range.
|
| 47 |
- Output valid JSON only. No markdown, no comments in the actual output.
|
| 48 |
|
| 49 |
+
{keyword_block}
|
| 50 |
+
|
| 51 |
Transcript (each line is "[mm:ss] text"):
|
| 52 |
---
|
| 53 |
{transcript}
|
|
|
|
| 55 |
"""
|
| 56 |
|
| 57 |
|
| 58 |
+
def _keyword_block(keywords: list[dict] | None) -> str:
|
| 59 |
+
"""Render the SEO-keyword guidance injected into the prompt."""
|
| 60 |
+
keywords = [k for k in (keywords or []) if k.get("keyword")]
|
| 61 |
+
if not keywords:
|
| 62 |
+
return "SEO keywords: none specified."
|
| 63 |
+
lines = [
|
| 64 |
+
"SEO keywords: naturally weave the following keywords into the title, intro and "
|
| 65 |
+
"step bodies, aiming for roughly the target keyword density (share of total "
|
| 66 |
+
"words). Treat each target as an upper guide: keep the writing natural and "
|
| 67 |
+
"readable, and prefer using a keyword somewhat LESS often than the target over "
|
| 68 |
+
"forcing awkward, repetitive phrasing (no keyword stuffing):",
|
| 69 |
+
]
|
| 70 |
+
for k in keywords:
|
| 71 |
+
try:
|
| 72 |
+
dens = float(k.get("density", 0) or 0)
|
| 73 |
+
except (TypeError, ValueError):
|
| 74 |
+
dens = 0.0
|
| 75 |
+
lines.append(f' - "{k["keyword"]}": target ~{dens:.1f}% of total words')
|
| 76 |
+
return "\n".join(lines)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
def _extract_json(text: str) -> dict:
|
| 80 |
"""Parse the model output into a dict, tolerating code fences / stray prose."""
|
| 81 |
text = text.strip()
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
|
| 121 |
+
def generate_tutorial(transcript: str, hf_token: str, model: str = DEFAULT_LLM,
|
| 122 |
+
keywords: list[dict] | None = None) -> dict:
|
| 123 |
+
"""Call the chat model and return a normalized ``{title, intro, steps}`` dict.
|
| 124 |
+
|
| 125 |
+
``keywords`` is an optional list of ``{"keyword", "density"}`` SEO targets the model
|
| 126 |
+
is asked to weave in at roughly the given keyword density.
|
| 127 |
+
"""
|
| 128 |
if not hf_token:
|
| 129 |
raise ValueError("An HF token is required for the tutorial LLM (billed to your key).")
|
| 130 |
|
| 131 |
truncated = transcript[:MAX_TRANSCRIPT_CHARS]
|
| 132 |
if len(transcript) > MAX_TRANSCRIPT_CHARS:
|
| 133 |
truncated += "\n[... transcript truncated for length ...]"
|
| 134 |
+
prompt = (_INSTRUCTIONS
|
| 135 |
+
.replace("{keyword_block}", _keyword_block(keywords))
|
| 136 |
+
.replace("{transcript}", truncated))
|
| 137 |
|
| 138 |
client = InferenceClient(token=hf_token)
|
| 139 |
try:
|