"""Step 5: write the blog post from the extracted source material. Output is Markdown with: # H1 title, ## H2 sections, normal paragraphs, and [IMAGE: ] markers where illustrations belong. """ from __future__ import annotations import re from typing import List from huggingface_hub import InferenceClient from . import config, llm from .aeo_guidelines import AEO_GUIDELINES from .factual_accuracy import FACTUAL_ACCURACY_GUIDELINES # Content-goal directives shape the post's stance and tone. One is selected in the UI. GOAL_GUIDANCE = { "Informational": ( "Content goal: INFORMATIONAL. Prioritize clear, objective, comprehensive " "explanation. Teach the reader, define terms, stay neutral and factual, and favor " "accuracy and completeness over opinion or salesmanship." ), "Persuasive": ( "Content goal: PERSUASIVE. Build a convincing case for a clear position or action. " "Lead with benefits, address likely objections and counterpoints, support claims " "with concrete evidence and examples, and close with a motivating call to action — " "without overstating or making unsupported claims." ), "Authoritative": ( "Content goal: AUTHORITATIVE. Write as the definitive reference on the topic. Be " "precise and thorough, back every key claim with specific evidence, data or primary " "sources, cover edge cases and limitations, and use a confident expert tone that " "earns citation and trust." ), "Thought Leadership": ( "Content goal: THOUGHT LEADERSHIP. Offer an original, forward-looking perspective. " "Frame the topic within broader industry trends, share a distinctive point of view " "or informed prediction, challenge conventional assumptions where warranted, and " "support opinions with clear reasoning and evidence." ), } DEFAULT_GOAL = "Informational" def _goal_directive(content_goal: str) -> str: return GOAL_GUIDANCE.get(content_goal, GOAL_GUIDANCE[DEFAULT_GOAL]) def _build_system(target_wordcount: int, content_goal: str) -> str: return ( "You are an expert blog writer and SEO/AEO editor. Using ONLY the supplied source " "material as factual grounding, write an original, engaging, well-structured blog " "post. Do not copy sentences verbatim from the sources; synthesize in your own voice.\n\n" f"{_goal_directive(content_goal)}\n\n" "Structural requirements:\n" "- Start with a single '# ' H1 title that includes the primary keyword.\n" "- Use the primary keyword naturally in the first 100 words and in at least one '## ' heading.\n" "- Weave the secondary keyword in naturally 1-3 times.\n" "- Use '## ' subheadings to organize sections; write substantive paragraphs.\n" "- Honor the brief's angle, audience, and tone.\n" f"- Target length: about {target_wordcount} words (stay within roughly ±10%).\n" f"- Insert exactly {config.N_IMAGES} image markers of the form " "'[IMAGE: a vivid visual scene to illustrate this section]' at natural points " "(never two in a row, not in the title).\n" "- End with a short conclusion.\n\n" "AI-citation (AEO) requirements:\n" f"{AEO_GUIDELINES}\n" f"{FACTUAL_ACCURACY_GUIDELINES}\n" "Return Markdown only — no preamble, no code fences. Use Markdown pipe tables for " "any tables." ) def write_post( client: InferenceClient, topic: str, primary_keyword: str, secondary_keyword: str, brief: str, sources: List[dict], target_wordcount: int = config.DEFAULT_WORD_COUNT, content_goal: str = DEFAULT_GOAL, ) -> str: source_block = _format_sources(sources) user = ( f"Topic: {topic}\n" f"Primary keyword: {primary_keyword}\n" f"Secondary keyword: {secondary_keyword}\n" f"Content goal: {content_goal}\n" f"Target word count: {target_wordcount}\n" f"Brief: {brief}\n\n" f"SOURCE MATERIAL (ranked by domain authority):\n{source_block}\n\n" f"Write the full ~{target_wordcount}-word blog post in Markdown now, matching the " f"'{content_goal}' content goal and following the AI-citation guidelines (direct " "answer up top, question-style headings, a comparison/feature table, an FAQ, cited " "sources, stated limitations)." ) # Allow enough output tokens for the requested length (~1.6 tokens/word + overhead). max_tokens = max(1500, min(int(target_wordcount * 2) + 600, 8000)) md = llm.chat( client, config.MODEL_WRITER, _build_system(target_wordcount, content_goal), user, max_tokens=max_tokens, temperature=0.7, fallback_model=config.MODEL_WRITER_FALLBACK, ) md = _strip_fences(md) return _ensure_image_markers(md) def _format_sources(sources: List[dict]) -> str: parts = [] for i, s in enumerate(sources, 1): pr = s.get("page_rank") pr_str = f"PR={pr}" if pr is not None else "PR=n/a" parts.append( f"[Source {i}] {s.get('title', '')} ({s.get('domain', '')}, {pr_str})\n" f"URL: {s.get('url', '')}\n" f"{s.get('text', '')}\n" ) return "\n".join(parts) def _strip_fences(md: str) -> str: md = md.strip() if md.startswith("```"): md = re.sub(r"^```[a-zA-Z]*\n", "", md) md = re.sub(r"\n```$", "", md) return md.strip() def _ensure_image_markers(md: str) -> str: """Guarantee at least one image marker so the illustration step has work to do.""" if re.search(r"\[IMAGE:", md): return md # inject one marker after the first paragraph following the H1 lines = md.splitlines() out, injected = [], False for idx, line in enumerate(lines): out.append(line) if not injected and idx > 0 and line.strip() == "" and lines[idx - 1].strip() and not lines[idx - 1].startswith("#"): out.append("[IMAGE: a compelling hero image illustrating the topic]") out.append("") injected = True if not injected: out.append("\n[IMAGE: a compelling hero image illustrating the topic]") return "\n".join(out) def parse_image_markers(md: str) -> List[str]: """Return the scene descriptions from all [IMAGE: ...] markers, in order.""" return [m.strip() for m in re.findall(r"\[IMAGE:\s*(.+?)\]", md, re.DOTALL)]