vivekchakraverty Claude Opus 4.8 commited on
Commit
2559985
·
1 Parent(s): d3ee9ee

Add target word count control and AEO citation guidelines

Browse files

- UI: new "Target word count" number box, threaded through generate() ->
orchestrator.run() -> writer.write_post(); output tokens scale to the target.
- Writing prompt now enforces AI-citation (AEO) best practices distilled from the
GameMaster citation best-practices doc: direct answer up top, question-style
headings, specific/verifiable claims, a comparison/feature table, an FAQ, cited
sources, stated limitations, freshness and descriptive anchors.
- docx_builder: render Markdown pipe tables as real Word tables (bold header row,
separator row dropped) so the required tables export correctly.

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

app.py CHANGED
@@ -40,7 +40,7 @@ def _preview(markdown: str, images: list) -> str:
40
  return re.sub(r"\[IMAGE:\s*.+?\]", repl, markdown, flags=re.DOTALL)
41
 
42
 
43
- def generate(topic, primary, secondary, brief, hf_token, progress=gr.Progress()):
44
  log_lines = []
45
  gallery, docx_file, preview = [], None, ""
46
 
@@ -49,7 +49,7 @@ def generate(topic, primary, secondary, brief, hf_token, progress=gr.Progress())
49
 
50
  try:
51
  for frac, message, result in orchestrator.run(
52
- hf_token, topic, primary, secondary, brief
53
  ):
54
  progress(frac, desc=message)
55
  log_lines.append(message)
@@ -85,6 +85,14 @@ def build_ui() -> gr.Blocks:
85
  lines=5,
86
  placeholder="Audience, angle, tone, must-cover points…",
87
  )
 
 
 
 
 
 
 
 
88
  hf_token = gr.Textbox(
89
  label="Hugging Face token (billed to you)",
90
  type="password",
@@ -99,7 +107,7 @@ def build_ui() -> gr.Blocks:
99
 
100
  run_btn.click(
101
  fn=generate,
102
- inputs=[topic, primary, secondary, brief, hf_token],
103
  outputs=[status_box, preview, gallery, docx_out],
104
  )
105
  gr.Markdown(
 
40
  return re.sub(r"\[IMAGE:\s*.+?\]", repl, markdown, flags=re.DOTALL)
41
 
42
 
43
+ def generate(topic, primary, secondary, brief, target_wordcount, hf_token, progress=gr.Progress()):
44
  log_lines = []
45
  gallery, docx_file, preview = [], None, ""
46
 
 
49
 
50
  try:
51
  for frac, message, result in orchestrator.run(
52
+ hf_token, topic, primary, secondary, brief, target_wordcount
53
  ):
54
  progress(frac, desc=message)
55
  log_lines.append(message)
 
85
  lines=5,
86
  placeholder="Audience, angle, tone, must-cover points…",
87
  )
88
+ target_wc = gr.Number(
89
+ label="Target word count",
90
+ value=config.DEFAULT_WORD_COUNT,
91
+ minimum=300,
92
+ maximum=5000,
93
+ step=100,
94
+ precision=0,
95
+ )
96
  hf_token = gr.Textbox(
97
  label="Hugging Face token (billed to you)",
98
  type="password",
 
107
 
108
  run_btn.click(
109
  fn=generate,
110
+ inputs=[topic, primary, secondary, brief, target_wc, hf_token],
111
  outputs=[status_box, preview, gallery, docx_out],
112
  )
113
  gr.Markdown(
pipeline/aeo_guidelines.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AEO / AI-chatbot-citation writing guidelines injected into the blog-writing prompt.
2
+
3
+ Distilled from the writing-relevant parts of
4
+ "Best Practices for Being Listed and Cited in AI Chatbot Queries"
5
+ (V:\\GameMaster\\Marketing\\AEO\\ai_chatbot_citation_best_practices_gamemaster.docx).
6
+
7
+ Only the guidance that applies to authoring a single article is included here — site-wide
8
+ technical items (robots.txt, sitemaps, schema/JSON-LD, llms.txt) are out of scope for a
9
+ generated blog post and are intentionally omitted.
10
+ """
11
+
12
+ # Kept as a single prompt-ready block. Referenced by writer.write_post().
13
+ AEO_GUIDELINES = """\
14
+ Write the article to be easily discovered, quoted and CITED by AI answer engines
15
+ (ChatGPT, Claude, Gemini, Perplexity, Google AI Overviews). Follow these rules:
16
+
17
+ Core formula: be specific + be useful + show original proof + be quotable.
18
+
19
+ 1. Direct answer first: open with a one-sentence, self-contained definition/answer to the
20
+ article's core question, then a short direct-answer paragraph — all within the first
21
+ ~100 words. A reader (or model) should get the answer without scrolling.
22
+ 2. Answer-friendly structure: use clear, descriptive H2/H3 headings; where natural, phrase
23
+ headings as the questions users actually ask. Each section should directly answer its
24
+ heading. Keep paragraphs short (2-4 sentences) and use bullet lists so points are easy
25
+ to extract and quote.
26
+ 3. Be specific, not generic: prefer concrete, verifiable, extractable statements over vague
27
+ marketing claims. (Weak: "the best tool for everyone." Strong: name the exact features,
28
+ numbers, versions, steps or limitations.) Every claim should be something a model could
29
+ quote and attribute.
30
+ 4. Include at least one comparison or feature table (Markdown table syntax) when the topic
31
+ supports it — comparisons, feature/module matrices, specs, pros/cons or step summaries.
32
+ 5. Include a short FAQ section near the end: 3-6 real questions a user might ask an AI,
33
+ each with a concise, direct 1-3 sentence answer (great for citation).
34
+ 6. Trust / E-E-A-T: cite reliable primary sources by name inline where you make factual
35
+ claims, and add a brief "Sources" list at the end referencing them. Show experience and
36
+ state limitations or caveats honestly (what does NOT work, what needs manual review).
37
+ 7. Freshness: include a "Last updated" line (use the current month/year) near the top or end.
38
+ 8. Descriptive link text: when you reference a concept, use strong descriptive anchor
39
+ phrasing — never "click here", "read more", "this article" or "learn more".
40
+ 9. Self-contained: put the important details in plain text prose; do not defer key facts to
41
+ the images. Images illustrate; the text must stand alone.
42
+ """
pipeline/config.py CHANGED
@@ -36,6 +36,7 @@ N_SEARCH_TERMS = int(os.environ.get("N_SEARCH_TERMS", "5")) # queries generate
36
  TOP_N = int(os.environ.get("TOP_N", "25")) # results ranked by OpenPageRank
37
  TOP_K = int(os.environ.get("TOP_K", "5")) # top pages used as source material
38
  N_IMAGES = int(os.environ.get("N_IMAGES", "3")) # illustrations per post
 
39
  SOURCE_CHAR_CAP = int(os.environ.get("SOURCE_CHAR_CAP", "4000")) # chars kept per source page
40
  HTTP_TIMEOUT = int(os.environ.get("HTTP_TIMEOUT", "20")) # seconds for outbound HTTP
41
 
 
36
  TOP_N = int(os.environ.get("TOP_N", "25")) # results ranked by OpenPageRank
37
  TOP_K = int(os.environ.get("TOP_K", "5")) # top pages used as source material
38
  N_IMAGES = int(os.environ.get("N_IMAGES", "3")) # illustrations per post
39
+ DEFAULT_WORD_COUNT = int(os.environ.get("DEFAULT_WORD_COUNT", "1200")) # target post length
40
  SOURCE_CHAR_CAP = int(os.environ.get("SOURCE_CHAR_CAP", "4000")) # chars kept per source page
41
  HTTP_TIMEOUT = int(os.environ.get("HTTP_TIMEOUT", "20")) # seconds for outbound HTTP
42
 
pipeline/docx_builder.py CHANGED
@@ -11,6 +11,7 @@ from docx.shared import Inches, Pt, RGBColor
11
 
12
  _IMAGE_MARKER = re.compile(r"\[IMAGE:\s*.+?\]", re.DOTALL)
13
  _INLINE = re.compile(r"(\*\*.+?\*\*|\*.+?\*|`.+?`)")
 
14
  _MAX_IMG_WIDTH = Inches(6.0)
15
 
16
 
@@ -19,9 +20,25 @@ def build_docx(markdown: str, images: List[dict], out_path: Path) -> Path:
19
  doc = Document()
20
  img_iter = iter([im for im in images if im.get("path")])
21
 
22
- for block in _split_blocks(markdown):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  if _IMAGE_MARKER.fullmatch(block.strip()):
24
  _insert_next_image(doc, img_iter)
 
25
  continue
26
  # a block may still contain an inline marker mixed with text
27
  if _IMAGE_MARKER.search(block):
@@ -29,8 +46,11 @@ def build_docx(markdown: str, images: List[dict], out_path: Path) -> Path:
29
  if piece.strip():
30
  _render_line(doc, piece.strip())
31
  _insert_next_image(doc, img_iter)
 
32
  continue
 
33
  _render_line(doc, block)
 
34
 
35
  # any leftover images that never got placed → append at the end
36
  for im in img_iter:
@@ -41,6 +61,47 @@ def build_docx(markdown: str, images: List[dict], out_path: Path) -> Path:
41
  return out_path
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  def _split_blocks(md: str) -> List[str]:
45
  """Split into logical blocks (headings/paragraphs/markers), preserving order."""
46
  blocks: List[str] = []
 
11
 
12
  _IMAGE_MARKER = re.compile(r"\[IMAGE:\s*.+?\]", re.DOTALL)
13
  _INLINE = re.compile(r"(\*\*.+?\*\*|\*.+?\*|`.+?`)")
14
+ _SEP_CELL = re.compile(r"^:?-{2,}:?$")
15
  _MAX_IMG_WIDTH = Inches(6.0)
16
 
17
 
 
20
  doc = Document()
21
  img_iter = iter([im for im in images if im.get("path")])
22
 
23
+ blocks = _split_blocks(markdown)
24
+ i, n = 0, len(blocks)
25
+ while i < n:
26
+ block = blocks[i]
27
+
28
+ # Markdown pipe table: a header row followed by a separator row (| --- | --- |).
29
+ if _is_table_row(block) and i + 1 < n and _is_separator_row(blocks[i + 1]):
30
+ j = i + 2
31
+ rows = [block]
32
+ while j < n and _is_table_row(blocks[j]):
33
+ rows.append(blocks[j])
34
+ j += 1
35
+ _add_table(doc, rows)
36
+ i = j
37
+ continue
38
+
39
  if _IMAGE_MARKER.fullmatch(block.strip()):
40
  _insert_next_image(doc, img_iter)
41
+ i += 1
42
  continue
43
  # a block may still contain an inline marker mixed with text
44
  if _IMAGE_MARKER.search(block):
 
46
  if piece.strip():
47
  _render_line(doc, piece.strip())
48
  _insert_next_image(doc, img_iter)
49
+ i += 1
50
  continue
51
+
52
  _render_line(doc, block)
53
+ i += 1
54
 
55
  # any leftover images that never got placed → append at the end
56
  for im in img_iter:
 
61
  return out_path
62
 
63
 
64
+ def _is_table_row(line: str) -> bool:
65
+ s = line.strip()
66
+ return "|" in s and s.count("|") >= 2
67
+
68
+
69
+ def _is_separator_row(line: str) -> bool:
70
+ cells = _parse_row(line)
71
+ return bool(cells) and all(_SEP_CELL.match(c.replace(" ", "")) for c in cells)
72
+
73
+
74
+ def _parse_row(line: str) -> List[str]:
75
+ s = line.strip()
76
+ if s.startswith("|"):
77
+ s = s[1:]
78
+ if s.endswith("|"):
79
+ s = s[:-1]
80
+ return [c.strip() for c in s.split("|")]
81
+
82
+
83
+ def _add_table(doc: Document, rows: List[str]) -> None:
84
+ header = _parse_row(rows[0])
85
+ body = [_parse_row(r) for r in rows[1:]]
86
+ ncols = max([len(header)] + [len(r) for r in body]) if (header or body) else 0
87
+ if ncols == 0:
88
+ return
89
+ table = doc.add_table(rows=1, cols=ncols)
90
+ try:
91
+ table.style = "Table Grid"
92
+ except Exception:
93
+ pass
94
+ # header (cells start empty, so add_run yields runs[0])
95
+ for c in range(ncols):
96
+ run = table.rows[0].cells[c].paragraphs[0].add_run(header[c] if c < len(header) else "")
97
+ run.bold = True
98
+ # body
99
+ for r in body:
100
+ cells = table.add_row().cells
101
+ for c in range(ncols):
102
+ _add_runs(cells[c].paragraphs[0], r[c] if c < len(r) else "")
103
+
104
+
105
  def _split_blocks(md: str) -> List[str]:
106
  """Split into logical blocks (headings/paragraphs/markers), preserving order."""
107
  blocks: List[str] = []
pipeline/orchestrator.py CHANGED
@@ -29,12 +29,19 @@ def run(
29
  primary_keyword: str,
30
  secondary_keyword: str,
31
  brief: str,
 
32
  ) -> Iterator[Tuple[float, str, dict]]:
33
  if not topic.strip():
34
  raise ValueError("Please enter a topic.")
35
  client = llm.make_client(hf_token) # raises if token missing → billed to user
36
 
37
- key = cache.run_key(topic, primary_keyword, secondary_keyword, brief)
 
 
 
 
 
 
38
  run_dir = config.OUT_DIR / key
39
  run_dir.mkdir(parents=True, exist_ok=True)
40
  result: dict = {"key": key}
@@ -72,9 +79,10 @@ def run(
72
  result["sources"] = [{k: v for k, v in s.items() if k != "text"} for s in sources]
73
 
74
  # 5) write the post
75
- yield 0.55, "Writing the blog post…", result
76
  markdown = writer.write_post(
77
- client, topic, primary_keyword, secondary_keyword, brief, sources
 
78
  )
79
  (run_dir / "post.md").write_text(markdown, encoding="utf-8")
80
  result["markdown"] = markdown
 
29
  primary_keyword: str,
30
  secondary_keyword: str,
31
  brief: str,
32
+ target_wordcount: int = config.DEFAULT_WORD_COUNT,
33
  ) -> Iterator[Tuple[float, str, dict]]:
34
  if not topic.strip():
35
  raise ValueError("Please enter a topic.")
36
  client = llm.make_client(hf_token) # raises if token missing → billed to user
37
 
38
+ try:
39
+ target_wordcount = int(target_wordcount)
40
+ except (TypeError, ValueError):
41
+ target_wordcount = config.DEFAULT_WORD_COUNT
42
+ target_wordcount = max(300, min(target_wordcount, 5000))
43
+
44
+ key = cache.run_key(topic, primary_keyword, secondary_keyword, brief, str(target_wordcount))
45
  run_dir = config.OUT_DIR / key
46
  run_dir.mkdir(parents=True, exist_ok=True)
47
  result: dict = {"key": key}
 
79
  result["sources"] = [{k: v for k, v in s.items() if k != "text"} for s in sources]
80
 
81
  # 5) write the post
82
+ yield 0.55, f"Writing the ~{target_wordcount}-word blog post…", result
83
  markdown = writer.write_post(
84
+ client, topic, primary_keyword, secondary_keyword, brief, sources,
85
+ target_wordcount=target_wordcount,
86
  )
87
  (run_dir / "post.md").write_text(markdown, encoding="utf-8")
88
  result["markdown"] = markdown
pipeline/writer.py CHANGED
@@ -12,23 +12,30 @@ from typing import List
12
  from huggingface_hub import InferenceClient
13
 
14
  from . import config, llm
 
15
 
16
- _SYSTEM = (
17
- "You are an expert blog writer and SEO editor. Using ONLY the supplied source "
18
- "material as factual grounding, write an original, engaging, well-structured blog "
19
- "post. Do not copy sentences verbatim from the sources; synthesize in your own voice.\n\n"
20
- "Requirements:\n"
21
- "- Start with a single '# ' H1 title that includes the primary keyword.\n"
22
- "- Use the primary keyword naturally in the first 100 words and in at least one '## ' heading.\n"
23
- "- Weave the secondary keyword in naturally 1-3 times.\n"
24
- "- Use '## ' subheadings to organize sections; write substantive paragraphs.\n"
25
- "- Honor the brief's angle, audience, and tone.\n"
26
- f"- Insert exactly {config.N_IMAGES} image markers of the form "
27
- "'[IMAGE: a vivid visual scene to illustrate this section]' at natural points "
28
- "(never two in a row, not in the title).\n"
29
- "- End with a short conclusion.\n"
30
- "Return Markdown only no preamble, no code fences."
31
- )
 
 
 
 
 
 
32
 
33
 
34
  def write_post(
@@ -38,22 +45,28 @@ def write_post(
38
  secondary_keyword: str,
39
  brief: str,
40
  sources: List[dict],
 
41
  ) -> str:
42
  source_block = _format_sources(sources)
43
  user = (
44
  f"Topic: {topic}\n"
45
  f"Primary keyword: {primary_keyword}\n"
46
  f"Secondary keyword: {secondary_keyword}\n"
 
47
  f"Brief: {brief}\n\n"
48
  f"SOURCE MATERIAL (ranked by domain authority):\n{source_block}\n\n"
49
- "Write the full blog post in Markdown now."
 
 
50
  )
 
 
51
  md = llm.chat(
52
  client,
53
  config.MODEL_WRITER,
54
- _SYSTEM,
55
  user,
56
- max_tokens=3500,
57
  temperature=0.7,
58
  fallback_model=config.MODEL_WRITER_FALLBACK,
59
  )
 
12
  from huggingface_hub import InferenceClient
13
 
14
  from . import config, llm
15
+ from .aeo_guidelines import AEO_GUIDELINES
16
 
17
+
18
+ def _build_system(target_wordcount: int) -> str:
19
+ return (
20
+ "You are an expert blog writer and SEO/AEO editor. Using ONLY the supplied source "
21
+ "material as factual grounding, write an original, engaging, well-structured blog "
22
+ "post. Do not copy sentences verbatim from the sources; synthesize in your own voice.\n\n"
23
+ "Structural requirements:\n"
24
+ "- Start with a single '# ' H1 title that includes the primary keyword.\n"
25
+ "- Use the primary keyword naturally in the first 100 words and in at least one '## ' heading.\n"
26
+ "- Weave the secondary keyword in naturally 1-3 times.\n"
27
+ "- Use '## ' subheadings to organize sections; write substantive paragraphs.\n"
28
+ "- Honor the brief's angle, audience, and tone.\n"
29
+ f"- Target length: about {target_wordcount} words (stay within roughly ±10%).\n"
30
+ f"- Insert exactly {config.N_IMAGES} image markers of the form "
31
+ "'[IMAGE: a vivid visual scene to illustrate this section]' at natural points "
32
+ "(never two in a row, not in the title).\n"
33
+ "- End with a short conclusion.\n\n"
34
+ "AI-citation (AEO) requirements:\n"
35
+ f"{AEO_GUIDELINES}\n"
36
+ "Return Markdown only — no preamble, no code fences. Use Markdown pipe tables for "
37
+ "any tables."
38
+ )
39
 
40
 
41
  def write_post(
 
45
  secondary_keyword: str,
46
  brief: str,
47
  sources: List[dict],
48
+ target_wordcount: int = config.DEFAULT_WORD_COUNT,
49
  ) -> str:
50
  source_block = _format_sources(sources)
51
  user = (
52
  f"Topic: {topic}\n"
53
  f"Primary keyword: {primary_keyword}\n"
54
  f"Secondary keyword: {secondary_keyword}\n"
55
+ f"Target word count: {target_wordcount}\n"
56
  f"Brief: {brief}\n\n"
57
  f"SOURCE MATERIAL (ranked by domain authority):\n{source_block}\n\n"
58
+ f"Write the full ~{target_wordcount}-word blog post in Markdown now, following the "
59
+ "AI-citation guidelines (direct answer up top, question-style headings, a comparison/"
60
+ "feature table, an FAQ, cited sources, stated limitations)."
61
  )
62
+ # Allow enough output tokens for the requested length (~1.6 tokens/word + overhead).
63
+ max_tokens = max(1500, min(int(target_wordcount * 2) + 600, 8000))
64
  md = llm.chat(
65
  client,
66
  config.MODEL_WRITER,
67
+ _build_system(target_wordcount),
68
  user,
69
+ max_tokens=max_tokens,
70
  temperature=0.7,
71
  fallback_model=config.MODEL_WRITER_FALLBACK,
72
  )