Spaces:
Sleeping
Sleeping
Commit ·
31fa536
1
Parent(s): 1722c47
Add grounded blog-post generator pipeline
Browse filesDocker Space bundling self-hosted SearXNG + a Gradio app that turns
topic/keywords/brief into an illustrated .docx blog post:
search-term LLM -> SearXNG -> OpenPageRank ranking -> content extraction
-> blog writing -> FLUX.1-schnell images -> Qwen2.5-VL captions -> docx.
All paid inference is billed to the user's HF token entered in the UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .gitattributes +7 -0
- .gitignore +5 -0
- Dockerfile +48 -0
- README.md +48 -2
- app.py +113 -0
- pipeline/__init__.py +1 -0
- pipeline/cache.py +43 -0
- pipeline/captions.py +52 -0
- pipeline/config.py +54 -0
- pipeline/docx_builder.py +116 -0
- pipeline/extract.py +47 -0
- pipeline/images.py +56 -0
- pipeline/llm.py +53 -0
- pipeline/openpagerank.py +76 -0
- pipeline/orchestrator.py +100 -0
- pipeline/search_terms.py +70 -0
- pipeline/searxng_client.py +74 -0
- pipeline/writer.py +105 -0
- requirements.txt +8 -0
- searxng/settings.yml +34 -0
- start.sh +48 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
|
| 37 |
+
# Force LF for scripts/text so the Docker (Linux) build runs correctly
|
| 38 |
+
*.sh text eol=lf
|
| 39 |
+
*.py text eol=lf
|
| 40 |
+
*.yml text eol=lf
|
| 41 |
+
Dockerfile text eol=lf
|
| 42 |
+
start.sh text eol=lf
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.cache/
|
| 4 |
+
data/
|
| 5 |
+
*.docx
|
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Blog Post Generator — HF Docker Space
|
| 2 |
+
# Bundles a self-hosted SearXNG (internal, 127.0.0.1:8080) + a Gradio app (public, 7860).
|
| 3 |
+
FROM searxng/searxng:latest
|
| 4 |
+
|
| 5 |
+
USER root
|
| 6 |
+
|
| 7 |
+
# --- system deps for trafilatura/lxml/Pillow (Alpine base) ---
|
| 8 |
+
RUN apk add --no-cache \
|
| 9 |
+
bash \
|
| 10 |
+
libxml2 \
|
| 11 |
+
libxslt \
|
| 12 |
+
jpeg \
|
| 13 |
+
zlib \
|
| 14 |
+
build-base \
|
| 15 |
+
libxml2-dev \
|
| 16 |
+
libxslt-dev \
|
| 17 |
+
jpeg-dev \
|
| 18 |
+
zlib-dev
|
| 19 |
+
|
| 20 |
+
# SearXNG installs into this virtualenv; reuse it for the app so we get one Python.
|
| 21 |
+
ENV VENV=/usr/local/searxng/searx-pyenv
|
| 22 |
+
ENV PATH="$VENV/bin:$PATH"
|
| 23 |
+
ENV SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml
|
| 24 |
+
# HF persistent storage mounts at /data; config.py falls back if absent.
|
| 25 |
+
ENV DATA_DIR=/data
|
| 26 |
+
ENV SEARXNG_URL=http://127.0.0.1:8080
|
| 27 |
+
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
|
| 30 |
+
COPY requirements.txt .
|
| 31 |
+
RUN "$VENV/bin/pip" install --no-cache-dir -r requirements.txt
|
| 32 |
+
|
| 33 |
+
# App source
|
| 34 |
+
COPY app.py start.sh ./
|
| 35 |
+
COPY pipeline ./pipeline
|
| 36 |
+
COPY searxng/settings.yml /etc/searxng/settings.yml
|
| 37 |
+
|
| 38 |
+
# HF Spaces run the container as UID 1000 — make everything that gets written writable.
|
| 39 |
+
RUN chmod +x /app/start.sh \
|
| 40 |
+
&& mkdir -p /data /app/.cache \
|
| 41 |
+
&& chgrp -R 0 /app /etc/searxng /usr/local/searxng /data \
|
| 42 |
+
&& chmod -R g+rwX /app /etc/searxng /usr/local/searxng /data
|
| 43 |
+
|
| 44 |
+
EXPOSE 7860
|
| 45 |
+
|
| 46 |
+
USER 1000
|
| 47 |
+
|
| 48 |
+
ENTRYPOINT ["/app/start.sh"]
|
README.md
CHANGED
|
@@ -4,8 +4,54 @@ emoji: ⚡
|
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
short_description:
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Illustrated blog-post generator, exported as .docx
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Blog Post Generator
|
| 13 |
+
|
| 14 |
+
Turn a **topic + keywords + brief** into a fully-written, illustrated blog post exported
|
| 15 |
+
as a `.docx` file. The pipeline is *grounded*: instead of writing blind, it runs real web
|
| 16 |
+
search, ranks the sources by authority, and writes from the most authoritative pages.
|
| 17 |
+
|
| 18 |
+
## Pipeline
|
| 19 |
+
|
| 20 |
+
1. **Search-term reasoning** — an LLM turns your topic/keywords/brief into optimized queries.
|
| 21 |
+
2. **Web search** — a self-hosted [SearXNG](https://github.com/searxng/searxng) instance
|
| 22 |
+
(running inside this Space) returns the top ~25 result domains.
|
| 23 |
+
3. **Authority ranking** — [OpenPageRank](https://www.domcop.com/openpagerank/documentation)
|
| 24 |
+
scores those domains; the top 5 pages are selected.
|
| 25 |
+
4. **Content extraction** — main article text is extracted from those pages.
|
| 26 |
+
5. **Writing** — a strong LLM writes an SEO-aware post from that material, weaving your
|
| 27 |
+
keywords in and marking where images belong.
|
| 28 |
+
6. **Illustration** — image prompts are written by an LLM and rendered with
|
| 29 |
+
**FLUX.1 [schnell]**.
|
| 30 |
+
7. **Captioning** — a vision model captions each generated image.
|
| 31 |
+
8. **Export** — everything is assembled into a downloadable `.docx`.
|
| 32 |
+
|
| 33 |
+
## Billing
|
| 34 |
+
|
| 35 |
+
All *paid* AI calls (reasoning, writing, image generation, captioning) run through
|
| 36 |
+
**Hugging Face Inference Providers using the HF token you enter in the UI**, so they are
|
| 37 |
+
billed to **you**. Nothing is stored server-side.
|
| 38 |
+
|
| 39 |
+
Get a token at <https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained>
|
| 40 |
+
(needs *"Make calls to Inference Providers"* permission).
|
| 41 |
+
|
| 42 |
+
## Configuration (Space owner)
|
| 43 |
+
|
| 44 |
+
- **Secret `OPR_API_KEY`** — a free OpenPageRank API key
|
| 45 |
+
(<https://www.domcop.com/openpagerank/auth/signup>). Required for authority ranking.
|
| 46 |
+
- **Persistent storage** *(optional)* — if enabled, `/data` is used to cache search
|
| 47 |
+
results, scores, images and docx between runs. Without it the app still works; the cache
|
| 48 |
+
just lives in a local directory and is not persisted.
|
| 49 |
+
|
| 50 |
+
## Models (fixed defaults)
|
| 51 |
+
|
| 52 |
+
| Step | Model |
|
| 53 |
+
| --- | --- |
|
| 54 |
+
| Search terms & image prompts | `openai/gpt-oss-120b` |
|
| 55 |
+
| Blog writing | `deepseek-ai/DeepSeek-V3-0324` (fallback `Qwen/Qwen2.5-72B-Instruct`) |
|
| 56 |
+
| Image generation | `black-forest-labs/FLUX.1-schnell` |
|
| 57 |
+
| Image captioning | `Qwen/Qwen2.5-VL-72B-Instruct` |
|
app.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio UI for the Blog Post Generator HF Space.
|
| 2 |
+
|
| 3 |
+
Inputs: topic, primary/secondary keyword, brief, and the user's HF token (password).
|
| 4 |
+
All paid AI calls are billed to that token. Outputs a live status log, a Markdown
|
| 5 |
+
preview, an image gallery, and a downloadable .docx.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
import traceback
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
|
| 14 |
+
from pipeline import config, orchestrator
|
| 15 |
+
|
| 16 |
+
INTRO = """
|
| 17 |
+
# 📝 Blog Post Generator
|
| 18 |
+
Turn a topic + keywords + brief into a fully-written, **illustrated** blog post exported as `.docx`.
|
| 19 |
+
|
| 20 |
+
The pipeline: **search-term reasoning → self-hosted SearXNG web search → OpenPageRank
|
| 21 |
+
authority ranking → content extraction → LLM writing → FLUX.1-schnell images → vision
|
| 22 |
+
captioning → DOCX export.**
|
| 23 |
+
|
| 24 |
+
> All paid AI calls run through **Hugging Face Inference Providers using the token you
|
| 25 |
+
> enter below**, so they are billed to **you**. The token is used only for this run and
|
| 26 |
+
> is not stored.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _preview(markdown: str, images: list) -> str:
|
| 31 |
+
"""Replace [IMAGE:] markers with their caption text for a readable Markdown preview."""
|
| 32 |
+
imgs = [im for im in images if im.get("path")]
|
| 33 |
+
it = iter(imgs)
|
| 34 |
+
|
| 35 |
+
def repl(_m):
|
| 36 |
+
im = next(it, None)
|
| 37 |
+
cap = (im or {}).get("caption") or (im or {}).get("scene") or "image"
|
| 38 |
+
return f"\n> 🖼️ *{cap}*\n"
|
| 39 |
+
|
| 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 |
+
|
| 47 |
+
def status():
|
| 48 |
+
return "\n".join(f"- {ln}" for ln in log_lines)
|
| 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)
|
| 56 |
+
|
| 57 |
+
if result.get("images"):
|
| 58 |
+
gallery = [
|
| 59 |
+
(im["path"], im.get("caption") or im.get("scene", ""))
|
| 60 |
+
for im in result["images"]
|
| 61 |
+
if im.get("path")
|
| 62 |
+
]
|
| 63 |
+
if result.get("markdown"):
|
| 64 |
+
preview = _preview(result["markdown"], result.get("images", []))
|
| 65 |
+
if result.get("docx_path"):
|
| 66 |
+
docx_file = result["docx_path"]
|
| 67 |
+
|
| 68 |
+
yield status(), preview, gallery, docx_file
|
| 69 |
+
except Exception as e: # noqa: BLE001 - show the user a clean error
|
| 70 |
+
log_lines.append(f"❌ **Error:** {e}")
|
| 71 |
+
traceback.print_exc()
|
| 72 |
+
yield status(), preview, gallery, docx_file
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_ui() -> gr.Blocks:
|
| 76 |
+
with gr.Blocks(title="Blog Post Generator", theme=gr.themes.Soft()) as demo:
|
| 77 |
+
gr.Markdown(INTRO)
|
| 78 |
+
with gr.Row():
|
| 79 |
+
with gr.Column(scale=1):
|
| 80 |
+
topic = gr.Textbox(label="Topic", placeholder="e.g. Home composting for beginners")
|
| 81 |
+
primary = gr.Textbox(label="Primary keyword", placeholder="e.g. home composting")
|
| 82 |
+
secondary = gr.Textbox(label="Secondary keyword", placeholder="e.g. kitchen scraps")
|
| 83 |
+
brief = gr.Textbox(
|
| 84 |
+
label="Brief",
|
| 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",
|
| 91 |
+
placeholder="hf_...",
|
| 92 |
+
)
|
| 93 |
+
run_btn = gr.Button("Generate blog post", variant="primary")
|
| 94 |
+
with gr.Column(scale=2):
|
| 95 |
+
status_box = gr.Markdown(label="Progress")
|
| 96 |
+
docx_out = gr.File(label="Download .docx")
|
| 97 |
+
gallery = gr.Gallery(label="Generated images", columns=2, height=320)
|
| 98 |
+
preview = gr.Markdown(label="Preview")
|
| 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(
|
| 106 |
+
f"Models: writer `{config.MODEL_WRITER}` · reasoning `{config.MODEL_REASONING}` · "
|
| 107 |
+
f"images `{config.MODEL_IMAGE}` · vision `{config.MODEL_VISION}`."
|
| 108 |
+
)
|
| 109 |
+
return demo
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
build_ui().queue().launch(server_name="0.0.0.0", server_port=7860)
|
pipeline/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Blog Post Generator pipeline package."""
|
pipeline/cache.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny keyed cache under DATA_DIR for JSON-serializable pipeline artifacts.
|
| 2 |
+
|
| 3 |
+
Used to avoid re-running search / ranking / extraction for identical inputs. Image
|
| 4 |
+
and docx binaries are written directly under OUT_DIR by their own modules; this
|
| 5 |
+
handles the lightweight JSON steps.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
from . import config
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def run_key(*parts: str) -> str:
|
| 17 |
+
"""Stable short hash identifying a run from its inputs."""
|
| 18 |
+
joined = " ".join(p or "" for p in parts)
|
| 19 |
+
h = hashlib.sha256(joined.encode("utf-8"))
|
| 20 |
+
return h.hexdigest()[:16]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _path(key: str, name: str):
|
| 24 |
+
return config.CACHE_DIR / f"{key}.{name}.json"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get(key: str, name: str) -> Optional[Any]:
|
| 28 |
+
p = _path(key, name)
|
| 29 |
+
if p.exists():
|
| 30 |
+
try:
|
| 31 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 32 |
+
except Exception:
|
| 33 |
+
return None
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def put(key: str, name: str, value: Any) -> None:
|
| 38 |
+
p = _path(key, name)
|
| 39 |
+
try:
|
| 40 |
+
p.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 41 |
+
except Exception:
|
| 42 |
+
# Cache is best-effort; never fail the pipeline over it.
|
| 43 |
+
pass
|
pipeline/captions.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 7: caption each generated image with a vision-language model."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import mimetypes
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
from huggingface_hub import InferenceClient
|
| 10 |
+
|
| 11 |
+
from . import config
|
| 12 |
+
|
| 13 |
+
_CAPTION_PROMPT = (
|
| 14 |
+
"Write a concise, engaging one-sentence caption for this blog illustration. "
|
| 15 |
+
"Describe what is shown; do not start with 'This image' or 'A picture of'. "
|
| 16 |
+
"Return only the caption."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _data_uri(path: Path) -> str:
|
| 21 |
+
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
| 22 |
+
b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
|
| 23 |
+
return f"data:{mime};base64,{b64}"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def caption_images(client: InferenceClient, images: List[dict]) -> List[dict]:
|
| 27 |
+
"""Add a 'caption' key to each image dict that has a valid 'path'."""
|
| 28 |
+
for item in images:
|
| 29 |
+
path = item.get("path")
|
| 30 |
+
if not path or not Path(path).exists():
|
| 31 |
+
item["caption"] = ""
|
| 32 |
+
continue
|
| 33 |
+
try:
|
| 34 |
+
resp = client.chat.completions.create(
|
| 35 |
+
model=config.MODEL_VISION,
|
| 36 |
+
messages=[
|
| 37 |
+
{
|
| 38 |
+
"role": "user",
|
| 39 |
+
"content": [
|
| 40 |
+
{"type": "text", "text": _CAPTION_PROMPT},
|
| 41 |
+
{"type": "image_url", "image_url": {"url": _data_uri(Path(path))}},
|
| 42 |
+
],
|
| 43 |
+
}
|
| 44 |
+
],
|
| 45 |
+
max_tokens=80,
|
| 46 |
+
temperature=0.5,
|
| 47 |
+
)
|
| 48 |
+
item["caption"] = (resp.choices[0].message.content or "").strip().strip('"')
|
| 49 |
+
except Exception:
|
| 50 |
+
# fall back to the scene description if captioning fails
|
| 51 |
+
item["caption"] = item.get("scene", "")
|
| 52 |
+
return images
|
pipeline/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration: model ids, storage paths, and pipeline constants.
|
| 2 |
+
|
| 3 |
+
Everything tunable lives here so models/limits can be swapped in one place.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
# --- Models (all served via HF Inference Providers, billed to the user's token) ---
|
| 11 |
+
# Fast, capable general model used for reasoning-style tasks (search terms, image prompts).
|
| 12 |
+
MODEL_REASONING = os.environ.get("MODEL_REASONING", "openai/gpt-oss-120b")
|
| 13 |
+
# Strong long-form writer for the actual blog post, with a fallback if unavailable.
|
| 14 |
+
MODEL_WRITER = os.environ.get("MODEL_WRITER", "deepseek-ai/DeepSeek-V3-0324")
|
| 15 |
+
MODEL_WRITER_FALLBACK = os.environ.get("MODEL_WRITER_FALLBACK", "Qwen/Qwen2.5-72B-Instruct")
|
| 16 |
+
# Text-to-image model (as requested).
|
| 17 |
+
MODEL_IMAGE = os.environ.get("MODEL_IMAGE", "black-forest-labs/FLUX.1-schnell")
|
| 18 |
+
# Vision-language model for captioning generated images.
|
| 19 |
+
MODEL_VISION = os.environ.get("MODEL_VISION", "Qwen/Qwen2.5-VL-72B-Instruct")
|
| 20 |
+
|
| 21 |
+
# --- External services ---
|
| 22 |
+
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://127.0.0.1:8080")
|
| 23 |
+
OPR_API_KEY = os.environ.get("OPR_API_KEY", "")
|
| 24 |
+
OPR_ENDPOINT = "https://openpagerank.com/api/v1.0/getPageRank"
|
| 25 |
+
|
| 26 |
+
# --- Pipeline constants ---
|
| 27 |
+
N_SEARCH_TERMS = int(os.environ.get("N_SEARCH_TERMS", "5")) # queries generated by the LLM
|
| 28 |
+
TOP_N = int(os.environ.get("TOP_N", "25")) # results ranked by OpenPageRank
|
| 29 |
+
TOP_K = int(os.environ.get("TOP_K", "5")) # top pages used as source material
|
| 30 |
+
N_IMAGES = int(os.environ.get("N_IMAGES", "3")) # illustrations per post
|
| 31 |
+
SOURCE_CHAR_CAP = int(os.environ.get("SOURCE_CHAR_CAP", "4000")) # chars kept per source page
|
| 32 |
+
HTTP_TIMEOUT = int(os.environ.get("HTTP_TIMEOUT", "20")) # seconds for outbound HTTP
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _resolve_data_dir() -> Path:
|
| 36 |
+
"""Prefer HF persistent storage (/data); fall back to a local dir if not writable."""
|
| 37 |
+
candidate = Path(os.environ.get("DATA_DIR", "/data"))
|
| 38 |
+
try:
|
| 39 |
+
candidate.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
probe = candidate / ".write_test"
|
| 41 |
+
probe.write_text("ok", encoding="utf-8")
|
| 42 |
+
probe.unlink()
|
| 43 |
+
return candidate
|
| 44 |
+
except Exception:
|
| 45 |
+
fallback = Path(__file__).resolve().parent.parent / ".cache"
|
| 46 |
+
fallback.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
return fallback
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
DATA_DIR = _resolve_data_dir()
|
| 51 |
+
CACHE_DIR = DATA_DIR / "cache"
|
| 52 |
+
OUT_DIR = DATA_DIR / "out"
|
| 53 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
pipeline/docx_builder.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 8: assemble the Markdown post + images + captions into a .docx file."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
from docx import Document
|
| 9 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 10 |
+
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 |
+
|
| 17 |
+
def build_docx(markdown: str, images: List[dict], out_path: Path) -> Path:
|
| 18 |
+
"""Render `markdown` to a Word document, inserting `images` at [IMAGE:] markers in order."""
|
| 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):
|
| 28 |
+
for piece in _IMAGE_MARKER.split(block):
|
| 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:
|
| 37 |
+
_add_picture(doc, im)
|
| 38 |
+
|
| 39 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
doc.save(out_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] = []
|
| 47 |
+
for raw in md.split("\n"):
|
| 48 |
+
line = raw.rstrip()
|
| 49 |
+
if line.strip() == "":
|
| 50 |
+
continue
|
| 51 |
+
blocks.append(line)
|
| 52 |
+
return blocks
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _render_line(doc: Document, line: str) -> None:
|
| 56 |
+
stripped = line.strip()
|
| 57 |
+
if stripped.startswith("#"):
|
| 58 |
+
level = len(stripped) - len(stripped.lstrip("#"))
|
| 59 |
+
text = stripped[level:].strip()
|
| 60 |
+
doc.add_heading(text, level=min(max(level, 1), 4))
|
| 61 |
+
elif re.match(r"^[-*]\s+", stripped):
|
| 62 |
+
text = re.sub(r"^[-*]\s+", "", stripped)
|
| 63 |
+
p = doc.add_paragraph(style="List Bullet")
|
| 64 |
+
_add_runs(p, text)
|
| 65 |
+
elif re.match(r"^\d+\.\s+", stripped):
|
| 66 |
+
text = re.sub(r"^\d+\.\s+", "", stripped)
|
| 67 |
+
p = doc.add_paragraph(style="List Number")
|
| 68 |
+
_add_runs(p, text)
|
| 69 |
+
elif stripped.startswith(">"):
|
| 70 |
+
p = doc.add_paragraph(style="Intense Quote")
|
| 71 |
+
_add_runs(p, stripped.lstrip("> ").strip())
|
| 72 |
+
else:
|
| 73 |
+
p = doc.add_paragraph()
|
| 74 |
+
_add_runs(p, stripped)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _add_runs(paragraph, text: str) -> None:
|
| 78 |
+
"""Add text to a paragraph, honoring **bold**, *italic*, and `code`."""
|
| 79 |
+
for token in _INLINE.split(text):
|
| 80 |
+
if not token:
|
| 81 |
+
continue
|
| 82 |
+
if token.startswith("**") and token.endswith("**"):
|
| 83 |
+
paragraph.add_run(token[2:-2]).bold = True
|
| 84 |
+
elif token.startswith("*") and token.endswith("*"):
|
| 85 |
+
paragraph.add_run(token[1:-1]).italic = True
|
| 86 |
+
elif token.startswith("`") and token.endswith("`"):
|
| 87 |
+
run = paragraph.add_run(token[1:-1])
|
| 88 |
+
run.font.name = "Consolas"
|
| 89 |
+
else:
|
| 90 |
+
paragraph.add_run(token)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _insert_next_image(doc: Document, img_iter) -> None:
|
| 94 |
+
im = next(img_iter, None)
|
| 95 |
+
if im is not None:
|
| 96 |
+
_add_picture(doc, im)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _add_picture(doc: Document, im: dict) -> None:
|
| 100 |
+
path = im.get("path")
|
| 101 |
+
if not path or not Path(path).exists():
|
| 102 |
+
return
|
| 103 |
+
try:
|
| 104 |
+
p = doc.add_paragraph()
|
| 105 |
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 106 |
+
p.add_run().add_picture(str(path), width=_MAX_IMG_WIDTH)
|
| 107 |
+
except Exception:
|
| 108 |
+
return
|
| 109 |
+
caption = (im.get("caption") or "").strip()
|
| 110 |
+
if caption:
|
| 111 |
+
cap = doc.add_paragraph()
|
| 112 |
+
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 113 |
+
run = cap.add_run(caption)
|
| 114 |
+
run.italic = True
|
| 115 |
+
run.font.size = Pt(9)
|
| 116 |
+
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
pipeline/extract.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 4: fetch the top-ranked pages and extract clean article text."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
import trafilatura
|
| 7 |
+
|
| 8 |
+
from . import config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def extract_sources(results: List[dict]) -> List[dict]:
|
| 12 |
+
"""For each result, download + extract main text (capped). Skips pages that fail.
|
| 13 |
+
|
| 14 |
+
Adds 'text' to each result dict and returns only those with usable content.
|
| 15 |
+
"""
|
| 16 |
+
sources: List[dict] = []
|
| 17 |
+
for r in results:
|
| 18 |
+
url = r.get("url")
|
| 19 |
+
if not url:
|
| 20 |
+
continue
|
| 21 |
+
text = _extract_one(url)
|
| 22 |
+
if not text:
|
| 23 |
+
# fall back to the search snippet so the source still contributes something
|
| 24 |
+
text = (r.get("snippet") or "").strip()
|
| 25 |
+
if not text:
|
| 26 |
+
continue
|
| 27 |
+
r = dict(r)
|
| 28 |
+
r["text"] = text[: config.SOURCE_CHAR_CAP]
|
| 29 |
+
sources.append(r)
|
| 30 |
+
return sources
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _extract_one(url: str) -> str:
|
| 34 |
+
try:
|
| 35 |
+
downloaded = trafilatura.fetch_url(url)
|
| 36 |
+
if not downloaded:
|
| 37 |
+
return ""
|
| 38 |
+
text = trafilatura.extract(
|
| 39 |
+
downloaded,
|
| 40 |
+
include_comments=False,
|
| 41 |
+
include_tables=False,
|
| 42 |
+
no_fallback=False,
|
| 43 |
+
favor_precision=True,
|
| 44 |
+
)
|
| 45 |
+
return (text or "").strip()
|
| 46 |
+
except Exception:
|
| 47 |
+
return ""
|
pipeline/images.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 6: turn each [IMAGE: ...] marker into a FLUX prompt and render it."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from huggingface_hub import InferenceClient
|
| 8 |
+
|
| 9 |
+
from . import config, llm
|
| 10 |
+
|
| 11 |
+
_PROMPT_SYSTEM = (
|
| 12 |
+
"You are a prompt engineer for the FLUX text-to-image model. Given a short scene "
|
| 13 |
+
"description for a blog illustration and the article topic, write ONE vivid, concrete "
|
| 14 |
+
"image prompt (single line, <60 words). Describe subject, setting, composition, "
|
| 15 |
+
"lighting and style. Prefer clean, editorial, photographic or tasteful illustrative "
|
| 16 |
+
"styles suitable for a professional blog. No text/words in the image. Return only the prompt."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _flux_prompt(client: InferenceClient, topic: str, scene: str) -> str:
|
| 21 |
+
try:
|
| 22 |
+
p = llm.chat(
|
| 23 |
+
client,
|
| 24 |
+
config.MODEL_REASONING,
|
| 25 |
+
_PROMPT_SYSTEM,
|
| 26 |
+
f"Article topic: {topic}\nScene: {scene}\nWrite the FLUX prompt.",
|
| 27 |
+
max_tokens=150,
|
| 28 |
+
temperature=0.8,
|
| 29 |
+
)
|
| 30 |
+
p = p.strip().strip('"')
|
| 31 |
+
return p or scene
|
| 32 |
+
except Exception:
|
| 33 |
+
return f"{scene}, editorial photography, clean composition, natural lighting"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_images(
|
| 37 |
+
client: InferenceClient,
|
| 38 |
+
topic: str,
|
| 39 |
+
scenes: List[str],
|
| 40 |
+
run_dir: Path,
|
| 41 |
+
) -> List[dict]:
|
| 42 |
+
"""Render one image per scene. Returns [{scene, prompt, path|None, error?}]."""
|
| 43 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
out: List[dict] = []
|
| 45 |
+
for i, scene in enumerate(scenes):
|
| 46 |
+
prompt = _flux_prompt(client, topic, scene)
|
| 47 |
+
item = {"scene": scene, "prompt": prompt, "path": None}
|
| 48 |
+
try:
|
| 49 |
+
image = client.text_to_image(prompt=prompt, model=config.MODEL_IMAGE)
|
| 50 |
+
path = run_dir / f"image_{i + 1}.png"
|
| 51 |
+
image.save(path)
|
| 52 |
+
item["path"] = str(path)
|
| 53 |
+
except Exception as e: # noqa: BLE001 - one bad image shouldn't kill the run
|
| 54 |
+
item["error"] = str(e)
|
| 55 |
+
out.append(item)
|
| 56 |
+
return out
|
pipeline/llm.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared helpers for talking to HF Inference Providers with the user's token.
|
| 2 |
+
|
| 3 |
+
Every call here is billed to whoever owns `hf_token`. A single InferenceClient is
|
| 4 |
+
built per request in app.py and threaded through the pipeline.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
|
| 10 |
+
from huggingface_hub import InferenceClient
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def make_client(hf_token: str) -> InferenceClient:
|
| 14 |
+
"""Build an InferenceClient bound to the user's token (auto provider selection)."""
|
| 15 |
+
token = (hf_token or "").strip()
|
| 16 |
+
if not token:
|
| 17 |
+
raise ValueError("A Hugging Face token is required (paid calls are billed to it).")
|
| 18 |
+
return InferenceClient(token=token)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def chat(
|
| 22 |
+
client: InferenceClient,
|
| 23 |
+
model: str,
|
| 24 |
+
system: str,
|
| 25 |
+
user: str,
|
| 26 |
+
*,
|
| 27 |
+
max_tokens: int = 1024,
|
| 28 |
+
temperature: float = 0.7,
|
| 29 |
+
fallback_model: Optional[str] = None,
|
| 30 |
+
) -> str:
|
| 31 |
+
"""Run a chat completion and return the assistant text.
|
| 32 |
+
|
| 33 |
+
Falls back to `fallback_model` once if the primary model errors (e.g. no provider).
|
| 34 |
+
"""
|
| 35 |
+
messages = [
|
| 36 |
+
{"role": "system", "content": system},
|
| 37 |
+
{"role": "user", "content": user},
|
| 38 |
+
]
|
| 39 |
+
models: List[str] = [model] + ([fallback_model] if fallback_model else [])
|
| 40 |
+
last_err: Optional[Exception] = None
|
| 41 |
+
for m in models:
|
| 42 |
+
try:
|
| 43 |
+
resp = client.chat.completions.create(
|
| 44 |
+
model=m,
|
| 45 |
+
messages=messages,
|
| 46 |
+
max_tokens=max_tokens,
|
| 47 |
+
temperature=temperature,
|
| 48 |
+
)
|
| 49 |
+
return (resp.choices[0].message.content or "").strip()
|
| 50 |
+
except Exception as e: # noqa: BLE001 - surface a clean error after trying fallback
|
| 51 |
+
last_err = e
|
| 52 |
+
continue
|
| 53 |
+
raise RuntimeError(f"LLM call failed for {models}: {last_err}")
|
pipeline/openpagerank.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 3: rank the candidate domains by authority using OpenPageRank.
|
| 2 |
+
|
| 3 |
+
API: GET https://openpagerank.com/api/v1.0/getPageRank
|
| 4 |
+
Auth: header API-OPR: <key>
|
| 5 |
+
Params: repeated domains[]=<domain> (up to 100 per call)
|
| 6 |
+
Response: response[] with page_rank_decimal / page_rank_integer / rank per domain.
|
| 7 |
+
Docs: https://www.domcop.com/openpagerank/documentation
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Dict, List
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
from . import config
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _chunks(items: List[str], size: int = 100):
|
| 19 |
+
for i in range(0, len(items), size):
|
| 20 |
+
yield items[i : i + size]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def fetch_pageranks(domains: List[str]) -> Dict[str, float]:
|
| 24 |
+
"""Return {domain: page_rank_decimal}. Missing/unknown domains map to 0.0.
|
| 25 |
+
|
| 26 |
+
Raises RuntimeError if the API key is missing so the caller can surface it.
|
| 27 |
+
"""
|
| 28 |
+
if not config.OPR_API_KEY:
|
| 29 |
+
raise RuntimeError(
|
| 30 |
+
"OPR_API_KEY is not set. The Space owner must add an OpenPageRank API key "
|
| 31 |
+
"(free at domcop.com) as a Space secret."
|
| 32 |
+
)
|
| 33 |
+
scores: Dict[str, float] = {d: 0.0 for d in domains}
|
| 34 |
+
headers = {"API-OPR": config.OPR_API_KEY}
|
| 35 |
+
for chunk in _chunks(domains, 100):
|
| 36 |
+
params = [("domains[]", d) for d in chunk]
|
| 37 |
+
try:
|
| 38 |
+
r = requests.get(
|
| 39 |
+
config.OPR_ENDPOINT,
|
| 40 |
+
params=params,
|
| 41 |
+
headers=headers,
|
| 42 |
+
timeout=config.HTTP_TIMEOUT,
|
| 43 |
+
)
|
| 44 |
+
r.raise_for_status()
|
| 45 |
+
payload = r.json()
|
| 46 |
+
except Exception as e: # noqa: BLE001
|
| 47 |
+
raise RuntimeError(f"OpenPageRank request failed: {e}") from e
|
| 48 |
+
|
| 49 |
+
for item in payload.get("response", []) or []:
|
| 50 |
+
dom = (item.get("domain") or "").lower()
|
| 51 |
+
if dom not in scores:
|
| 52 |
+
continue
|
| 53 |
+
try:
|
| 54 |
+
scores[dom] = float(item.get("page_rank_decimal") or 0.0)
|
| 55 |
+
except (TypeError, ValueError):
|
| 56 |
+
scores[dom] = 0.0
|
| 57 |
+
return scores
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def rank_results(results: List[dict], top_k: int = config.TOP_K) -> List[dict]:
|
| 61 |
+
"""Attach OpenPageRank scores to SearXNG results and return the top-K by authority.
|
| 62 |
+
|
| 63 |
+
Falls back to the incoming (SearXNG) order if the API is unavailable.
|
| 64 |
+
"""
|
| 65 |
+
domains = [r["domain"] for r in results]
|
| 66 |
+
try:
|
| 67 |
+
scores = fetch_pageranks(domains)
|
| 68 |
+
for r in results:
|
| 69 |
+
r["page_rank"] = scores.get(r["domain"], 0.0)
|
| 70 |
+
ranked = sorted(results, key=lambda x: x.get("page_rank", 0.0), reverse=True)
|
| 71 |
+
except RuntimeError:
|
| 72 |
+
# Preserve SearXNG order; mark scores as unknown.
|
| 73 |
+
for r in results:
|
| 74 |
+
r.setdefault("page_rank", None)
|
| 75 |
+
ranked = results
|
| 76 |
+
return ranked[:top_k]
|
pipeline/orchestrator.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end orchestration of the blog-post pipeline.
|
| 2 |
+
|
| 3 |
+
Kept UI-agnostic: `run()` is a generator that yields (progress_fraction, message,
|
| 4 |
+
partial_result) tuples so any front-end can drive a progress bar. The final yield
|
| 5 |
+
carries the complete result dict.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Iterator, Tuple
|
| 11 |
+
|
| 12 |
+
from . import (
|
| 13 |
+
cache,
|
| 14 |
+
captions,
|
| 15 |
+
config,
|
| 16 |
+
extract,
|
| 17 |
+
images,
|
| 18 |
+
llm,
|
| 19 |
+
openpagerank,
|
| 20 |
+
search_terms,
|
| 21 |
+
searxng_client,
|
| 22 |
+
writer,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run(
|
| 27 |
+
hf_token: str,
|
| 28 |
+
topic: str,
|
| 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}
|
| 41 |
+
|
| 42 |
+
# 1) search terms
|
| 43 |
+
yield 0.05, "Generating search terms…", result
|
| 44 |
+
terms = cache.get(key, "terms") or search_terms.generate_search_terms(
|
| 45 |
+
client, topic, primary_keyword, secondary_keyword, brief
|
| 46 |
+
)
|
| 47 |
+
cache.put(key, "terms", terms)
|
| 48 |
+
result["terms"] = terms
|
| 49 |
+
|
| 50 |
+
# 2) SearXNG search
|
| 51 |
+
yield 0.15, f"Searching the web for {len(terms)} queries…", result
|
| 52 |
+
candidates = cache.get(key, "candidates") or searxng_client.search(terms, config.TOP_N)
|
| 53 |
+
if not candidates:
|
| 54 |
+
raise RuntimeError(
|
| 55 |
+
"No search results from SearXNG. Is the SearXNG service running "
|
| 56 |
+
"(check the Space logs)?"
|
| 57 |
+
)
|
| 58 |
+
cache.put(key, "candidates", candidates)
|
| 59 |
+
result["candidates"] = candidates
|
| 60 |
+
|
| 61 |
+
# 3) OpenPageRank authority ranking
|
| 62 |
+
yield 0.30, f"Ranking {len(candidates)} domains by OpenPageRank…", result
|
| 63 |
+
top = openpagerank.rank_results(candidates, config.TOP_K)
|
| 64 |
+
cache.put(key, "top", top)
|
| 65 |
+
result["top"] = top
|
| 66 |
+
|
| 67 |
+
# 4) extract source material
|
| 68 |
+
yield 0.40, f"Extracting content from top {len(top)} pages…", result
|
| 69 |
+
sources = extract.extract_sources(top)
|
| 70 |
+
if not sources:
|
| 71 |
+
raise RuntimeError("Could not extract readable content from the top pages.")
|
| 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
|
| 81 |
+
|
| 82 |
+
# 6) generate images
|
| 83 |
+
scenes = writer.parse_image_markers(markdown)
|
| 84 |
+
yield 0.70, f"Generating {len(scenes)} images with FLUX.1-schnell…", result
|
| 85 |
+
imgs = images.generate_images(client, topic, scenes, run_dir)
|
| 86 |
+
result["images"] = imgs
|
| 87 |
+
|
| 88 |
+
# 7) caption images
|
| 89 |
+
yield 0.85, "Captioning images…", result
|
| 90 |
+
imgs = captions.caption_images(client, imgs)
|
| 91 |
+
result["images"] = imgs
|
| 92 |
+
|
| 93 |
+
# 8) build docx
|
| 94 |
+
yield 0.95, "Building the .docx file…", result
|
| 95 |
+
from . import docx_builder
|
| 96 |
+
|
| 97 |
+
docx_path = docx_builder.build_docx(markdown, imgs, run_dir / "blog.docx")
|
| 98 |
+
result["docx_path"] = str(docx_path)
|
| 99 |
+
|
| 100 |
+
yield 1.0, "Done.", result
|
pipeline/search_terms.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 1: LLM turns the topic/keywords/brief into optimized web-search queries."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
from huggingface_hub import InferenceClient
|
| 9 |
+
|
| 10 |
+
from . import config, llm
|
| 11 |
+
|
| 12 |
+
_SYSTEM = (
|
| 13 |
+
"You are an SEO research assistant. Given a blog topic, its target keywords, and a "
|
| 14 |
+
"brief, produce a small set of high-signal web search queries that will surface the "
|
| 15 |
+
"most authoritative, information-rich sources to write the post from. Vary angle and "
|
| 16 |
+
"specificity. Return ONLY a JSON array of query strings, nothing else."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _fallback_terms(topic: str, primary: str, secondary: str) -> List[str]:
|
| 21 |
+
base = [t for t in [topic, primary, secondary] if t]
|
| 22 |
+
extra = [f"{topic} {primary}".strip(), f"{topic} guide", f"{topic} best practices"]
|
| 23 |
+
seen, out = set(), []
|
| 24 |
+
for t in base + extra:
|
| 25 |
+
t = t.strip()
|
| 26 |
+
if t and t.lower() not in seen:
|
| 27 |
+
seen.add(t.lower())
|
| 28 |
+
out.append(t)
|
| 29 |
+
return out[: config.N_SEARCH_TERMS]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def generate_search_terms(
|
| 33 |
+
client: InferenceClient,
|
| 34 |
+
topic: str,
|
| 35 |
+
primary_keyword: str,
|
| 36 |
+
secondary_keyword: str,
|
| 37 |
+
brief: str,
|
| 38 |
+
) -> List[str]:
|
| 39 |
+
user = (
|
| 40 |
+
f"Topic: {topic}\n"
|
| 41 |
+
f"Primary keyword: {primary_keyword}\n"
|
| 42 |
+
f"Secondary keyword: {secondary_keyword}\n"
|
| 43 |
+
f"Brief: {brief}\n\n"
|
| 44 |
+
f"Return {config.N_SEARCH_TERMS} search queries as a JSON array."
|
| 45 |
+
)
|
| 46 |
+
try:
|
| 47 |
+
raw = llm.chat(
|
| 48 |
+
client, config.MODEL_REASONING, _SYSTEM, user,
|
| 49 |
+
max_tokens=400, temperature=0.4,
|
| 50 |
+
)
|
| 51 |
+
terms = _parse_terms(raw)
|
| 52 |
+
if terms:
|
| 53 |
+
return terms[: config.N_SEARCH_TERMS]
|
| 54 |
+
except Exception:
|
| 55 |
+
pass
|
| 56 |
+
return _fallback_terms(topic, primary_keyword, secondary_keyword)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _parse_terms(raw: str) -> List[str]:
|
| 60 |
+
"""Extract a list of strings from a (possibly fenced) LLM response."""
|
| 61 |
+
match = re.search(r"\[.*\]", raw, re.DOTALL)
|
| 62 |
+
if match:
|
| 63 |
+
try:
|
| 64 |
+
data = json.loads(match.group(0))
|
| 65 |
+
return [str(t).strip() for t in data if str(t).strip()]
|
| 66 |
+
except Exception:
|
| 67 |
+
pass
|
| 68 |
+
# line-based fallback
|
| 69 |
+
lines = [re.sub(r'^[\s\-\*\d\.\)"]+', "", ln).strip().strip('"') for ln in raw.splitlines()]
|
| 70 |
+
return [ln for ln in lines if ln]
|
pipeline/searxng_client.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 2: query the in-container SearXNG JSON API and aggregate results by domain."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
from urllib.parse import urlparse
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import tldextract
|
| 9 |
+
|
| 10 |
+
from . import config
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def registrable_domain(url: str) -> str:
|
| 14 |
+
"""Return the registrable domain (e.g. 'blog.example.co.uk' -> 'example.co.uk')."""
|
| 15 |
+
ext = tldextract.extract(url)
|
| 16 |
+
if ext.domain and ext.suffix:
|
| 17 |
+
return f"{ext.domain}.{ext.suffix}".lower()
|
| 18 |
+
return (urlparse(url).netloc or url).lower()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _search_one(term: str) -> List[dict]:
|
| 22 |
+
params = {"q": term, "format": "json", "safesearch": "1", "language": "en"}
|
| 23 |
+
try:
|
| 24 |
+
r = requests.get(
|
| 25 |
+
f"{config.SEARXNG_URL}/search",
|
| 26 |
+
params=params,
|
| 27 |
+
timeout=config.HTTP_TIMEOUT,
|
| 28 |
+
headers={"User-Agent": "BlogPostGenerator/1.0"},
|
| 29 |
+
)
|
| 30 |
+
r.raise_for_status()
|
| 31 |
+
return r.json().get("results", []) or []
|
| 32 |
+
except Exception:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def search(terms: List[str], top_n: int = config.TOP_N) -> List[dict]:
|
| 37 |
+
"""Run all queries, aggregate scores per domain, and return the top-N domains.
|
| 38 |
+
|
| 39 |
+
Each returned item: {domain, url, title, snippet, score}. One representative URL
|
| 40 |
+
(the highest-scoring) is kept per registrable domain so OpenPageRank ranks domains.
|
| 41 |
+
"""
|
| 42 |
+
by_domain: Dict[str, dict] = {}
|
| 43 |
+
for term in terms:
|
| 44 |
+
for res in _search_one(term):
|
| 45 |
+
url = res.get("url")
|
| 46 |
+
if not url or not url.startswith("http"):
|
| 47 |
+
continue
|
| 48 |
+
dom = registrable_domain(url)
|
| 49 |
+
if not dom:
|
| 50 |
+
continue
|
| 51 |
+
score = float(res.get("score") or 0.0)
|
| 52 |
+
entry = by_domain.get(dom)
|
| 53 |
+
if entry is None:
|
| 54 |
+
by_domain[dom] = {
|
| 55 |
+
"domain": dom,
|
| 56 |
+
"url": url,
|
| 57 |
+
"title": res.get("title") or "",
|
| 58 |
+
"snippet": res.get("content") or "",
|
| 59 |
+
"score": score,
|
| 60 |
+
"_best": score, # highest single-result score seen for this domain
|
| 61 |
+
}
|
| 62 |
+
else:
|
| 63 |
+
entry["score"] += score
|
| 64 |
+
# keep the single best-scoring page as the domain's representative URL
|
| 65 |
+
if score > entry["_best"]:
|
| 66 |
+
entry["_best"] = score
|
| 67 |
+
entry["url"] = url
|
| 68 |
+
entry["title"] = res.get("title") or entry["title"]
|
| 69 |
+
entry["snippet"] = res.get("content") or entry["snippet"]
|
| 70 |
+
|
| 71 |
+
ranked = sorted(by_domain.values(), key=lambda x: x["score"], reverse=True)
|
| 72 |
+
for e in ranked:
|
| 73 |
+
e.pop("_best", None)
|
| 74 |
+
return ranked[:top_n]
|
pipeline/writer.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Step 5: write the blog post from the extracted source material.
|
| 2 |
+
|
| 3 |
+
Output is Markdown with:
|
| 4 |
+
# H1 title, ## H2 sections, normal paragraphs, and
|
| 5 |
+
[IMAGE: <scene description>] markers where illustrations belong.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from typing import List
|
| 11 |
+
|
| 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(
|
| 35 |
+
client: InferenceClient,
|
| 36 |
+
topic: str,
|
| 37 |
+
primary_keyword: str,
|
| 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 |
+
)
|
| 60 |
+
md = _strip_fences(md)
|
| 61 |
+
return _ensure_image_markers(md)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _format_sources(sources: List[dict]) -> str:
|
| 65 |
+
parts = []
|
| 66 |
+
for i, s in enumerate(sources, 1):
|
| 67 |
+
pr = s.get("page_rank")
|
| 68 |
+
pr_str = f"PR={pr}" if pr is not None else "PR=n/a"
|
| 69 |
+
parts.append(
|
| 70 |
+
f"[Source {i}] {s.get('title', '')} ({s.get('domain', '')}, {pr_str})\n"
|
| 71 |
+
f"URL: {s.get('url', '')}\n"
|
| 72 |
+
f"{s.get('text', '')}\n"
|
| 73 |
+
)
|
| 74 |
+
return "\n".join(parts)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _strip_fences(md: str) -> str:
|
| 78 |
+
md = md.strip()
|
| 79 |
+
if md.startswith("```"):
|
| 80 |
+
md = re.sub(r"^```[a-zA-Z]*\n", "", md)
|
| 81 |
+
md = re.sub(r"\n```$", "", md)
|
| 82 |
+
return md.strip()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _ensure_image_markers(md: str) -> str:
|
| 86 |
+
"""Guarantee at least one image marker so the illustration step has work to do."""
|
| 87 |
+
if re.search(r"\[IMAGE:", md):
|
| 88 |
+
return md
|
| 89 |
+
# inject one marker after the first paragraph following the H1
|
| 90 |
+
lines = md.splitlines()
|
| 91 |
+
out, injected = [], False
|
| 92 |
+
for idx, line in enumerate(lines):
|
| 93 |
+
out.append(line)
|
| 94 |
+
if not injected and idx > 0 and line.strip() == "" and lines[idx - 1].strip() and not lines[idx - 1].startswith("#"):
|
| 95 |
+
out.append("[IMAGE: a compelling hero image illustrating the topic]")
|
| 96 |
+
out.append("")
|
| 97 |
+
injected = True
|
| 98 |
+
if not injected:
|
| 99 |
+
out.append("\n[IMAGE: a compelling hero image illustrating the topic]")
|
| 100 |
+
return "\n".join(out)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def parse_image_markers(md: str) -> List[str]:
|
| 104 |
+
"""Return the scene descriptions from all [IMAGE: ...] markers, in order."""
|
| 105 |
+
return [m.strip() for m in re.findall(r"\[IMAGE:\s*(.+?)\]", md, re.DOTALL)]
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==4.44.1
|
| 2 |
+
huggingface_hub>=0.28.0
|
| 3 |
+
requests>=2.32.0
|
| 4 |
+
trafilatura>=1.12.0
|
| 5 |
+
python-docx>=1.1.2
|
| 6 |
+
Pillow>=10.4.0
|
| 7 |
+
PyYAML>=6.0.2
|
| 8 |
+
tldextract>=5.1.0
|
searxng/settings.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal SearXNG override — inherits all engine defaults, changes only what we need.
|
| 2 |
+
# See https://docs.searxng.org/admin/settings/index.html
|
| 3 |
+
use_default_settings: true
|
| 4 |
+
|
| 5 |
+
general:
|
| 6 |
+
instance_name: "Blog Post Generator Search"
|
| 7 |
+
# keep it internal; no public UI needed
|
| 8 |
+
enable_metrics: false
|
| 9 |
+
|
| 10 |
+
server:
|
| 11 |
+
# replaced at container start if left as the placeholder
|
| 12 |
+
secret_key: "ultrasecretkey_change_me"
|
| 13 |
+
bind_address: "127.0.0.1"
|
| 14 |
+
port: 8080
|
| 15 |
+
# single trusted caller (our own app on localhost) — no bot limiter
|
| 16 |
+
limiter: false
|
| 17 |
+
public_instance: false
|
| 18 |
+
image_proxy: false
|
| 19 |
+
|
| 20 |
+
search:
|
| 21 |
+
# JSON is required for our client; keep html for manual debugging
|
| 22 |
+
formats:
|
| 23 |
+
- html
|
| 24 |
+
- json
|
| 25 |
+
# be resilient: don't fail the whole query if one engine errors
|
| 26 |
+
autocomplete: ""
|
| 27 |
+
default_lang: "en"
|
| 28 |
+
|
| 29 |
+
ui:
|
| 30 |
+
static_use_hash: true
|
| 31 |
+
|
| 32 |
+
# Redis is not available in this single-container setup; SearXNG runs without it.
|
| 33 |
+
redis:
|
| 34 |
+
url: false
|
start.sh
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Launch SearXNG (internal) then the Gradio app (public). SearXNG runs in the
|
| 3 |
+
# background bound to 127.0.0.1:8080; the app talks to it over localhost.
|
| 4 |
+
set -euo pipefail
|
| 5 |
+
|
| 6 |
+
SEARXNG_HOST="127.0.0.1"
|
| 7 |
+
SEARXNG_PORT="8080"
|
| 8 |
+
|
| 9 |
+
# A settings.yml secret_key must be set. If the shipped one is still the
|
| 10 |
+
# placeholder, generate one at runtime (settings dir is made writable in the image).
|
| 11 |
+
SETTINGS="${SEARXNG_SETTINGS_PATH:-/etc/searxng/settings.yml}"
|
| 12 |
+
if grep -q 'ultrasecretkey_change_me' "$SETTINGS" 2>/dev/null; then
|
| 13 |
+
KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')"
|
| 14 |
+
# portable in-place edit (BusyBox sed supports -i)
|
| 15 |
+
sed -i "s/ultrasecretkey_change_me/${KEY}/" "$SETTINGS" || true
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
echo "[start] launching SearXNG on ${SEARXNG_HOST}:${SEARXNG_PORT} ..."
|
| 19 |
+
python -m searx.webapp &
|
| 20 |
+
SEARXNG_PID=$!
|
| 21 |
+
|
| 22 |
+
# Wait for SearXNG to answer before starting the app (max ~30s).
|
| 23 |
+
for i in $(seq 1 30); do
|
| 24 |
+
if python - <<'PY' 2>/dev/null; then
|
| 25 |
+
import socket, sys
|
| 26 |
+
s = socket.socket()
|
| 27 |
+
s.settimeout(1)
|
| 28 |
+
try:
|
| 29 |
+
s.connect(("127.0.0.1", 8080))
|
| 30 |
+
sys.exit(0)
|
| 31 |
+
except Exception:
|
| 32 |
+
sys.exit(1)
|
| 33 |
+
finally:
|
| 34 |
+
s.close()
|
| 35 |
+
PY
|
| 36 |
+
echo "[start] SearXNG is up."
|
| 37 |
+
break
|
| 38 |
+
fi
|
| 39 |
+
if ! kill -0 "$SEARXNG_PID" 2>/dev/null; then
|
| 40 |
+
echo "[start] SearXNG process exited early; continuing (search will be degraded)."
|
| 41 |
+
break
|
| 42 |
+
fi
|
| 43 |
+
echo "[start] waiting for SearXNG ($i/30) ..."
|
| 44 |
+
sleep 1
|
| 45 |
+
done
|
| 46 |
+
|
| 47 |
+
echo "[start] launching Gradio app on 0.0.0.0:7860 ..."
|
| 48 |
+
exec python /app/app.py
|