vivekchakraverty Claude Opus 4.8 commited on
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>

Files changed (2) hide show
  1. app.py +41 -5
  2. 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, progress=gr.Progress()):
 
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
- yield status(f"🤖 Generating tutorial with `{llm_model}`…"), ranking, gr.update(value=transcript), gr.update()
130
- tut = tutorial_mod.generate_tutorial(transcript, hf_token.strip(), llm_model)
 
 
 
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, show_copy_button=True)
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, max_minutes, max_shots],
 
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) -> dict:
99
- """Call the chat model and return a normalized ``{title, intro, steps}`` dict."""
 
 
 
 
 
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 = _INSTRUCTIONS.replace("{transcript}", truncated)
 
 
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: