vivekchakraverty Claude Opus 4.8 commited on
Commit
01abd01
·
1 Parent(s): 01fb241

Replace keyword density with primary/secondary keyword placement + AEO structure

Browse files

- UI: primary keyword + secondary keywords (comma-separated) instead of density inputs
- LLM prompt: use primary ~3x in body and place it in title/slug/meta/first-100-words of
intro and 1-2 H2 headings; each secondary keyword once
- AEO best practices (from GameMaster citation guide): answer-first "Quick answer" block,
descriptive title + meta description + URL slug, H2 step headings, FAQ section, and a
sources/last-updated trust footer
- docx_builder renders the new structure; tutorial JSON gains slug/meta_description/answer/faqs
- Add count_keyword() and surface the primary-keyword count in the run status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +4 -1
  2. app.py +34 -31
  3. pipeline/docx_builder.py +55 -14
  4. pipeline/tutorial.py +100 -34
README.md CHANGED
@@ -29,7 +29,10 @@ on that topic.
29
  5. **Candidate frames** are extracted densely from the video, then the **video is
30
  auto-deleted** — only small JPEGs and the transcript remain.
31
  6. **Tutorial text** — `deepseek-ai/DeepSeek-V3` (HF Inference Providers, billed to your
32
- token) turns the transcript into structured steps.
 
 
 
33
  7. **Screenshot selection** — a weighted indicator blends each step's LLM-suggested
34
  timestamp with Whisper's actual speech timing.
35
  8. **Captions** — a vision model (default `Qwen/Qwen2.5-VL-72B-Instruct`, billed to your
 
29
  5. **Candidate frames** are extracted densely from the video, then the **video is
30
  auto-deleted** — only small JPEGs and the transcript remain.
31
  6. **Tutorial text** — `deepseek-ai/DeepSeek-V3` (HF Inference Providers, billed to your
32
+ token) turns the transcript into an **answer-engine-optimized** post: an answer-first
33
+ paragraph, H2 step headings, an FAQ, a meta description, a URL slug, and a
34
+ last-updated/source citation. Optional **primary/secondary keyword** placement (primary
35
+ ~3× + in title/slug/meta/intro/H2; each secondary once).
36
  7. **Screenshot selection** — a weighted indicator blends each step's LLM-suggested
37
  timestamp with Whisper's actual speech timing.
38
  8. **Captions** — a vision model (default `Qwen/Qwen2.5-VL-72B-Instruct`, billed to your
app.py CHANGED
@@ -65,23 +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 _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] = []
@@ -142,11 +143,16 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
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,21 +207,18 @@ def build_ui():
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():
@@ -239,7 +242,7 @@ def build_ui():
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
 
65
  return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:60] or "tutorial"
66
 
67
 
68
+ def _collect_keywords(primary_kw, secondary_kw) -> dict:
69
+ """Build ``{"primary": str, "secondary": [str, ...]}`` from the two keyword inputs.
70
+
71
+ Secondary keywords are comma-separated. Duplicates and the primary are removed.
72
+ """
73
+ primary = (primary_kw or "").strip()
74
+ secondary = []
75
+ seen = {primary.lower()}
76
+ for part in (secondary_kw or "").split(","):
77
+ kw = part.strip()
78
+ if kw and kw.lower() not in seen:
79
+ seen.add(kw.lower())
80
+ secondary.append(kw)
81
+ return {"primary": primary, "secondary": secondary}
82
 
83
 
84
  def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
85
+ max_minutes, max_shots, primary_kw, secondary_kw,
86
  progress=gr.Progress()):
87
  """Generator that yields (status_md, ranking_df, transcript, docx_file)."""
88
  log: list[str] = []
 
143
 
144
  # 6. Tutorial text -----------------------------------------------------------
145
  progress(0.72, desc="Writing tutorial")
146
+ keywords = _collect_keywords(primary_kw, secondary_kw)
147
+ kw_note = f" primary: '{keywords['primary']}'" if keywords["primary"] else ""
148
+ if keywords["secondary"]:
149
+ kw_note += f" • secondary: {', '.join(keywords['secondary'])}"
150
  yield status(f"🤖 Generating tutorial with `{llm_model}`{kw_note}…"), ranking, gr.update(value=transcript), gr.update()
151
  tut = tutorial_mod.generate_tutorial(transcript, hf_token.strip(), llm_model, keywords)
152
+ if keywords["primary"]:
153
+ n = tutorial_mod.count_keyword(tut, keywords["primary"])
154
+ yield (status(f"🔑 Primary keyword '{keywords['primary']}' appears {n}× in the post."),
155
+ ranking, gr.update(value=transcript), gr.update())
156
 
157
  # 7. Weighted screenshot selection ------------------------------------------
158
  selected = frames_mod.select_screenshots(
 
207
  vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
208
  label="Vision model (captions)", allow_custom_value=True)
209
 
210
+ with gr.Accordion("SEO / AEO keywords (optional)", open=False):
211
  gr.Markdown(
212
+ "The **primary keyword** is used naturally ~3× in the body and placed in "
213
+ "the title, URL slug, meta description, the first 100 words, and one or "
214
+ "two H2 headings. Each **secondary keyword** is used once. The post also "
215
+ "follows answer-engine best practices (direct answer up top, FAQ, "
216
+ "last-updated date, source citation)."
217
  )
218
+ primary_kw = gr.Textbox(label="Primary keyword",
219
+ placeholder="e.g. godot ai plugin")
220
+ secondary_kw = gr.Textbox(label="Secondary keywords (comma-separated)",
221
+ placeholder="e.g. gdscript assistant, ai game tools")
 
 
 
 
 
222
 
223
  with gr.Accordion("Advanced settings", open=False):
224
  with gr.Row():
 
242
  run_btn.click(
243
  run_pipeline,
244
  inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
245
+ max_minutes, max_shots, primary_kw, secondary_kw],
246
  outputs=[status_md, ranking_df, transcript_box, docx_file],
247
  )
248
  return demo
pipeline/docx_builder.py CHANGED
@@ -1,12 +1,22 @@
1
- """Stage 9: assemble the .docx tutorial (text + captioned screenshots)."""
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
 
4
  import os
5
 
6
  from docx import Document
7
  from docx.enum.text import WD_ALIGN_PARAGRAPH
8
  from docx.shared import Inches, Pt, RGBColor
9
 
 
 
 
10
 
11
  def _fmt_ts(seconds: float) -> str:
12
  m, s = divmod(int(seconds), 60)
@@ -14,27 +24,46 @@ def _fmt_ts(seconds: float) -> str:
14
  return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:d}:{s:02d}"
15
 
16
 
 
 
 
 
 
 
 
 
 
17
  def build_docx(tutorial: dict, selected: dict[int, dict], captions: dict[int, str],
18
  out_path: str, source_url: str = "") -> str:
19
  """Write the tutorial to ``out_path`` and return it.
20
 
21
- ``tutorial`` = {title, intro, steps}; ``selected`` = {step_idx: {time, path}};
22
- ``captions`` = {step_idx: caption}.
23
  """
 
24
  doc = Document()
25
  doc.add_heading(tutorial["title"], level=0)
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  if tutorial.get("intro"):
28
  doc.add_paragraph(tutorial["intro"])
29
 
30
- if source_url:
31
- src = doc.add_paragraph()
32
- run = src.add_run(f"Source video: {source_url}")
33
- run.italic = True
34
- run.font.size = Pt(9)
35
- run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
36
-
37
- for i, step in enumerate(tutorial["steps"], start=0):
38
  doc.add_heading(f"{i + 1}. {step['heading']}", level=1)
39
  if step.get("body"):
40
  doc.add_paragraph(step["body"])
@@ -47,14 +76,26 @@ def build_docx(tutorial: dict, selected: dict[int, dict], captions: dict[int, st
47
 
48
  cap_par = doc.add_paragraph()
49
  cap_par.alignment = WD_ALIGN_PARAGRAPH.CENTER
50
- caption = captions.get(i, "")
51
- cap_run = cap_par.add_run(f"{caption} ")
52
  cap_run.italic = True
53
  cap_run.font.size = Pt(9)
54
  ts_run = cap_par.add_run(f"(@ {_fmt_ts(sel['time'])})")
55
  ts_run.italic = True
56
  ts_run.font.size = Pt(9)
57
- ts_run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
60
  doc.save(out_path)
 
1
+ """Stage 9: assemble the .docx tutorial (text + captioned screenshots).
2
+
3
+ Layout follows answer-engine best practices: H1 title, an SEO metadata line
4
+ (slug / meta description / last-updated), an answer-first "Quick answer" block, the
5
+ intro, H2 step sections with captioned screenshots, an FAQ section, and a sources/
6
+ trust footer citing the source video.
7
+ """
8
  from __future__ import annotations
9
 
10
+ import datetime
11
  import os
12
 
13
  from docx import Document
14
  from docx.enum.text import WD_ALIGN_PARAGRAPH
15
  from docx.shared import Inches, Pt, RGBColor
16
 
17
+ _GREY = RGBColor(0x66, 0x66, 0x66)
18
+ _LIGHT = RGBColor(0x88, 0x88, 0x88)
19
+
20
 
21
  def _fmt_ts(seconds: float) -> str:
22
  m, s = divmod(int(seconds), 60)
 
24
  return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:d}:{s:02d}"
25
 
26
 
27
+ def _grey_line(doc, text: str, size: int = 9, color: RGBColor = _GREY):
28
+ p = doc.add_paragraph()
29
+ run = p.add_run(text)
30
+ run.italic = True
31
+ run.font.size = Pt(size)
32
+ run.font.color.rgb = color
33
+ return p
34
+
35
+
36
  def build_docx(tutorial: dict, selected: dict[int, dict], captions: dict[int, str],
37
  out_path: str, source_url: str = "") -> str:
38
  """Write the tutorial to ``out_path`` and return it.
39
 
40
+ ``tutorial`` = {title, slug, meta_description, answer, intro, steps, faqs};
41
+ ``selected`` = {step_idx: {time, path}}; ``captions`` = {step_idx: caption}.
42
  """
43
+ today = datetime.date.today().isoformat()
44
  doc = Document()
45
  doc.add_heading(tutorial["title"], level=0)
46
 
47
+ # SEO metadata line (slug + last-updated) and meta description.
48
+ meta_bits = []
49
+ if tutorial.get("slug"):
50
+ meta_bits.append(f"Slug: /{tutorial['slug']}/")
51
+ meta_bits.append(f"Last updated: {today}")
52
+ _grey_line(doc, " • ".join(meta_bits))
53
+ if tutorial.get("meta_description"):
54
+ _grey_line(doc, f"Meta description: {tutorial['meta_description']}")
55
+
56
+ # Answer-first block (quotable by AI assistants).
57
+ if tutorial.get("answer"):
58
+ doc.add_heading("Quick answer", level=2)
59
+ ans = doc.add_paragraph()
60
+ ans.add_run(tutorial["answer"]).bold = True
61
+
62
  if tutorial.get("intro"):
63
  doc.add_paragraph(tutorial["intro"])
64
 
65
+ # Step sections.
66
+ for i, step in enumerate(tutorial["steps"]):
 
 
 
 
 
 
67
  doc.add_heading(f"{i + 1}. {step['heading']}", level=1)
68
  if step.get("body"):
69
  doc.add_paragraph(step["body"])
 
76
 
77
  cap_par = doc.add_paragraph()
78
  cap_par.alignment = WD_ALIGN_PARAGRAPH.CENTER
79
+ cap_run = cap_par.add_run(f"{captions.get(i, '')} ")
 
80
  cap_run.italic = True
81
  cap_run.font.size = Pt(9)
82
  ts_run = cap_par.add_run(f"(@ {_fmt_ts(sel['time'])})")
83
  ts_run.italic = True
84
  ts_run.font.size = Pt(9)
85
+ ts_run.font.color.rgb = _LIGHT
86
+
87
+ # FAQ section (visible Q&A — AEO/FAQPage-friendly).
88
+ if tutorial.get("faqs"):
89
+ doc.add_heading("Frequently asked questions", level=1)
90
+ for f in tutorial["faqs"]:
91
+ doc.add_heading(f["q"], level=2)
92
+ doc.add_paragraph(f["a"])
93
+
94
+ # Sources / trust footer (E-E-A-T: citation + freshness).
95
+ doc.add_heading("Sources", level=1)
96
+ if source_url:
97
+ _grey_line(doc, f"Adapted from the source video: {source_url}", color=_GREY)
98
+ _grey_line(doc, f"Generated and last updated: {today}.", color=_GREY)
99
 
100
  os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
101
  doc.save(out_path)
pipeline/tutorial.py CHANGED
@@ -1,8 +1,13 @@
1
- """Stage 7: transform the timestamped transcript into a structured tutorial.
2
 
3
  Uses an HF Inference Providers chat model (default DeepSeek-V3) billed to the user's
4
- token. The model is asked for strict JSON so the downstream weighted indicator and the
5
- .docx builder have stable fields to work with.
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
@@ -18,32 +23,44 @@ DEFAULT_LLM = "deepseek-ai/DeepSeek-V3"
18
  MAX_TRANSCRIPT_CHARS = 24000
19
 
20
  _SYSTEM = (
21
- "You are a technical writer. You convert a timestamped video transcript into a clear, "
22
- "step-by-step written tutorial. You ALWAYS respond with a single JSON object and no "
23
- "prose outside it."
 
24
  )
25
 
26
  _INSTRUCTIONS = """\
27
- Turn the transcript below into a tutorial. Return ONLY a JSON object with this schema:
28
 
29
  {
30
- "title": "string - concise tutorial title",
31
- "intro": "string - 2-4 sentence overview",
 
 
 
 
32
  "steps": [
33
  {
34
- "heading": "string - short step title",
35
- "body": "string - 1-3 paragraphs explaining this step in your own words",
36
  "quote": "string - a short, near-verbatim snippet (<=120 chars) copied from the "
37
  "transcript line this step is based on, used to locate the moment",
38
  "t_llm": number, // best timestamp IN SECONDS for an illustrative screenshot
39
  "importance": number // 0..1, how worth screenshotting this step is
40
  }
 
 
 
 
41
  ]
42
  }
43
 
44
  Rules:
45
  - 4 to 10 steps. Keep quotes copied from the transcript so they can be matched back.
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}
@@ -55,27 +72,40 @@ Transcript (each line is "[mm:ss] text"):
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()
@@ -111,19 +141,55 @@ def _normalize(data: dict) -> dict:
111
  })
112
  if not steps:
113
  raise RuntimeError("The LLM returned no usable steps.")
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  return {
115
- "title": str(data.get("title", "Tutorial")).strip() or "Tutorial",
116
- "intro": str(data.get("intro", "")).strip(),
 
 
 
117
  "steps": steps,
 
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).")
 
1
+ """Stage 7: transform the timestamped transcript into a structured, AEO-friendly tutorial.
2
 
3
  Uses an HF Inference Providers chat model (default DeepSeek-V3) billed to the user's
4
+ token. The model returns strict JSON so the downstream weighted indicator and the .docx
5
+ builder have stable fields to work with.
6
+
7
+ The output follows answer-engine-optimization (AEO) / AI-citation best practices: an
8
+ answer-first paragraph near the top, descriptive title + meta description + URL slug,
9
+ clear H2 step headings, and an FAQ section. SEO keyword placement is explicit (see
10
+ ``_keyword_block``).
11
  """
12
  from __future__ import annotations
13
 
 
23
  MAX_TRANSCRIPT_CHARS = 24000
24
 
25
  _SYSTEM = (
26
+ "You are a technical writer who optimizes for answer engines (ChatGPT, Claude, "
27
+ "Gemini, Perplexity, Google AI). You convert a timestamped video transcript into a "
28
+ "clear, citable, step-by-step written tutorial. You ALWAYS respond with a single JSON "
29
+ "object and no prose outside it."
30
  )
31
 
32
  _INSTRUCTIONS = """\
33
+ Turn the transcript below into a tutorial blog post. Return ONLY a JSON object with this schema:
34
 
35
  {
36
+ "title": "string - H1 / title tag; concise and descriptive",
37
+ "slug": "string - lowercase, hyphenated URL slug",
38
+ "meta_description": "string - ~150-160 chars, compelling, search-friendly",
39
+ "answer": "string - 1-3 sentence DIRECT answer/definition of the topic, placed first so "
40
+ "AI assistants can quote it on its own",
41
+ "intro": "string - 2-4 sentence overview that builds on the answer",
42
  "steps": [
43
  {
44
+ "heading": "string - short, descriptive H2 step title",
45
+ "body": "string - 1-3 paragraphs in your own words; specific and verifiable",
46
  "quote": "string - a short, near-verbatim snippet (<=120 chars) copied from the "
47
  "transcript line this step is based on, used to locate the moment",
48
  "t_llm": number, // best timestamp IN SECONDS for an illustrative screenshot
49
  "importance": number // 0..1, how worth screenshotting this step is
50
  }
51
+ ],
52
+ "faqs": [
53
+ { "q": "string - a natural question a user might ask an AI assistant",
54
+ "a": "string - a concise, factual 1-3 sentence answer" }
55
  ]
56
  }
57
 
58
  Rules:
59
  - 4 to 10 steps. Keep quotes copied from the transcript so they can be matched back.
60
  - t_llm must be within the transcript's time range.
61
+ - Provide 3-5 FAQs phrased as real questions ("Can ...?", "How do I ...?", "What is ...?").
62
+ - Be specific and factual (citation-worthy); avoid marketing fluff. Note prerequisites or
63
+ limitations where relevant.
64
  - Output valid JSON only. No markdown, no comments in the actual output.
65
 
66
  {keyword_block}
 
72
  """
73
 
74
 
75
+ def _keyword_block(keywords: dict | None) -> str:
76
+ """Render the SEO/keyword placement guidance injected into the prompt.
77
+
78
+ ``keywords`` = ``{"primary": str, "secondary": [str, ...]}``.
79
+ """
80
+ keywords = keywords or {}
81
+ primary = (keywords.get("primary") or "").strip()
82
+ secondary = [s for s in (keywords.get("secondary") or []) if s.strip()]
83
+ if not primary and not secondary:
84
  return "SEO keywords: none specified."
85
+
86
+ lines = ["SEO / keyword requirements (keep everything natural - never keyword-stuff):"]
87
+ if primary:
88
+ lines += [
89
+ f'- PRIMARY keyword: "{primary}".',
90
+ f' * Use "{primary}" naturally about 3 times in the body text.',
91
+ f' * Also place "{primary}" in: the title (H1), the URL slug, the meta '
92
+ f'description, within the FIRST 100 words of the intro, and in one or two '
93
+ f'H2 step headings where it fits naturally.',
94
+ ]
95
+ if secondary:
96
+ joined = ", ".join(f'"{s}"' for s in secondary)
97
+ lines.append(
98
+ f"- SECONDARY keywords ({joined}): use each one EXACTLY once, naturally, "
99
+ "somewhere in the body."
100
+ )
101
  return "\n".join(lines)
102
 
103
 
104
+ def _slugify(text: str) -> str:
105
+ text = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
106
+ return text[:80] or "tutorial"
107
+
108
+
109
  def _extract_json(text: str) -> dict:
110
  """Parse the model output into a dict, tolerating code fences / stray prose."""
111
  text = text.strip()
 
141
  })
142
  if not steps:
143
  raise RuntimeError("The LLM returned no usable steps.")
144
+
145
+ faqs = []
146
+ for f in data.get("faqs", []) or []:
147
+ q = str(f.get("q", "")).strip()
148
+ a = str(f.get("a", "")).strip()
149
+ if q and a:
150
+ faqs.append({"q": q, "a": a})
151
+
152
+ title = str(data.get("title", "Tutorial")).strip() or "Tutorial"
153
+ intro = str(data.get("intro", "")).strip()
154
+ answer = str(data.get("answer", "")).strip()
155
+ meta = str(data.get("meta_description", "")).strip() or (answer or intro)[:160]
156
+ slug = _slugify(str(data.get("slug", "")).strip() or title)
157
  return {
158
+ "title": title,
159
+ "slug": slug,
160
+ "meta_description": meta,
161
+ "answer": answer,
162
+ "intro": intro,
163
  "steps": steps,
164
+ "faqs": faqs,
165
  }
166
 
167
 
168
+ def count_keyword(tutorial: dict, keyword: str) -> int:
169
+ """Count case-insensitive whole-keyword occurrences across all post text.
170
+
171
+ Used only for transparency in the UI status — placement isn't hard-enforced.
172
+ """
173
+ keyword = (keyword or "").strip()
174
+ if not keyword:
175
+ return 0
176
+ parts = [tutorial.get("title", ""), tutorial.get("slug", "").replace("-", " "),
177
+ tutorial.get("meta_description", ""), tutorial.get("answer", ""),
178
+ tutorial.get("intro", "")]
179
+ for s in tutorial.get("steps", []):
180
+ parts += [s.get("heading", ""), s.get("body", "")]
181
+ for f in tutorial.get("faqs", []):
182
+ parts += [f.get("q", ""), f.get("a", "")]
183
+ blob = "\n".join(parts).lower()
184
+ return len(re.findall(re.escape(keyword.lower()), blob))
185
+
186
+
187
  def generate_tutorial(transcript: str, hf_token: str, model: str = DEFAULT_LLM,
188
+ keywords: dict | None = None) -> dict:
189
+ """Call the chat model and return a normalized tutorial dict.
190
 
191
+ Returned keys: ``title, slug, meta_description, answer, intro, steps, faqs``.
192
+ ``keywords`` is ``{"primary": str, "secondary": [str, ...]}`` for SEO placement.
193
  """
194
  if not hf_token:
195
  raise ValueError("An HF token is required for the tutorial LLM (billed to your key).")