File size: 6,542 Bytes
31fa536
 
 
 
 
 
 
 
 
 
 
 
 
 
2559985
fbe8f5e
31fa536
ac00fb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2559985
ac00fb3
 
 
 
 
 
2559985
 
 
 
ac00fb3
2559985
 
 
 
 
 
 
 
 
 
 
 
 
fbe8f5e
2559985
 
 
31fa536
 
 
 
 
 
 
 
 
2559985
ac00fb3
31fa536
 
 
 
 
 
ac00fb3
2559985
31fa536
 
ac00fb3
 
 
 
31fa536
2559985
 
31fa536
 
 
ac00fb3
31fa536
2559985
31fa536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""Step 5: write the blog post from the extracted source material.

Output is Markdown with:
  # H1 title, ## H2 sections, normal paragraphs, and
  [IMAGE: <scene description>] 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)]