[#PlayWithAI] [Ultra-Lightweight x Super-Genius] NVIDIA's latest 4B model 'Nemotron 3 Nano 4B' is incredible! The future of automated AI novel generation showcased by the Mamba2 x Transformer hybrid
Hello everyone! For those of you who love gadgets, the latest tech, and AI, aren't you feeling dizzy from the speed at which LLMs (Large Language Models) are evolving lately? "ChatGPT and Claude are amazing, but the API costs are a concern..." "I want a model that runs smoothly on my local PC (edge environment) and is still smart!" A monstrously lightweight model that grants those selfish wishes has arrived from NVIDIA. Its name is 'Nemotron 3 Nano 4B'!
In this article, I will thoroughly explain the greatness of this highly anticipated new model. Furthermore, in the latter half of the article, I will talk about a super romantic way to use it: "using this model to fully automatically generate horror sci-fi novels and automatically post them to WordPress" for fun! First of all, what kind of model is 'Nemotron 3 Nano 4B'? In a word, it is "an AI that is super compact but has an adult's brain." It is a model that takes the original 'NVIDIA Nemotron Nano 9B v2'—which was already an excellent 9 billion parameter model—and compresses it down to 4 billion (4B) parameters. You might think, "If the number of parameters decreases, won't it become stupid?" but this is where NVIDIA's technical prowess comes into play.
Let's take a detailed look at the specs! 💡 Main Specs List Item Details Developer NVIDIA Parameter Count 4 Billion (4B) Architecture Mamba2 x Transformer Hybrid Structure Maximum Context Length 256K (approx. 250,000 tokens) Supported Languages English (*Japanese can also be used effectively depending on prompt engineering, translation, or external integration!) Key Features Reasoning Toggle, High Agent Functionality This is crazy! 3 Innovative Highlights Why is this model causing such a stir? The secret lies in its architecture and unique features.
1. The super weapon that is the 'Mamba2 x Transformer' hybrid. Conventional LLMs are often built using only the 'Transformer' mechanism, which has the weakness that as the text gets longer, the operation becomes heavier and consumes more PC memory (VRAM). That's where the next-generation high-speed architecture 'Mamba2' comes in. 'Nemotron 3 Nano 4B' takes the best of both Mamba2 and Transformer. This allows for surprisingly light and high-quality inference even in 'edge environments' like smartphones, small local PCs, and IoT devices.
2. Amazing '256K (250,000 tokens)' long context! Despite its mini 4B size, it can handle a massive text of 256K tokens at once. 250,000 tokens is a level where a short novel can easily fit in its entirety. Because it never forgets past context, you can dramatically reduce mistakes common in AI novels, such as "the setting of a character who appeared earlier contradicts later on." 3. Boost intelligence with the 'Reasoning Toggle'! This might be the most interesting feature. This model is equipped with a switch (toggle) that allows you to toggle whether or not to output intermediate thought processes (traces of reasoning). ON: The AI thinks carefully behind the scenes, saying "Hmm, first I'll think this way, then I'll assemble this..." before giving an answer, so the accuracy of complex plots and foreshadowing recovery skyrockets. OFF: It outputs conclusions and text immediately, making chat responses lightning fast. The ability to switch the AI's 'brain gear' according to the application is the greatest strength of this model. The ultimate in romance!
'Horror Sci-Fi Novel' automated generation x WordPress automated posting system Now, from here on, I will introduce a super creative way to play that utilizes 120% of the potential of this 'Nemotron 3 Nano 4B'.
Actually, I am enjoying building a system that "fully automatically generates horror sci-fi novels and automatically posts them to my own WordPress site" using this model! This is so fun that I look forward to waking up every morning. Why is 'Nemotron 3 Nano 4B' suitable for novel generation? The horror sci-fi genre is a very difficult genre for AI, requiring "meticulous scientific settings (sci-fi)" and "psychological descriptions and plot consistency that gradually incite fear (horror)."
This is where the '256K long context' and 'Reasoning Toggle' I introduced earlier come into play. Build a plot with Reasoning ON: If you instruct it, "In the closed space of a spaceship, unknown nanomachines are eroding the crew's minds. I want the ending to be that the AI itself was the source of the infection. Sprinkle 3 pieces of foreshadowing along the way," thanks to the reasoning toggle, the AI will carefully assemble a plot without logical flaws on its own. Long stories don't waver with 256K context maintenance: Even as you write Chapter 1, Chapter 2..., it won't forget the initial settings or eerie foreshadowing. A compelling novel is completed where the character's tone and the spaceship's structural settings are consistent to the end. Overall picture of the automated posting system (image) The flow of the entire system is like this. The strength of the 4B model is that it can run in local environments or low-cost servers.
[Nemotron 3 Nano 4B]
│ (Turn Reasoning ON to generate a detailed horror sci-fi novel)
▼
[API / Python script]
│ (Translate/format to Japanese as needed, generate eye-catching image prompts)
▼
[WordPress API]
│ (Automatically draft as a new article, or post publicly!)
▼
[New horror is uploaded to my blog every night]
* This model natively supports English, but by asking it to "output in Japanese" in the prompt, or by passing the generated beautiful English novel through another translation API, or by combining it with DeepL, etc., an incredibly serious and profound overseas translated sci-fi horror-style blog will be born. This matches the atmosphere and is the best. The built-in 'edge agent function' shines. Nemotron 3 Nano 4B has native tool use capabilities, meaning it has the aptitude as an 'AI agent' from the start. By utilizing this, it is not just a dream to create an interactive automated blog that "judges and categorizes WordPress categories by itself" or "automatically reads reader comments and reflects the next story development (reader voting results, etc.) in the novel." Because it is a model designed for game NPCs and local assistants, its affinity for integration with external systems (WordPress) is extremely high. Summary: Experience the new era of edge AI! NVIDIA's 'Nemotron 3 Nano 4B' has completely overturned the common sense that "lightweight model = mediocre brain." Ultra-lightweight and ultra-high-speed operation by Mamba2, 256K context that can handle long novels with ease, a reasoning toggle that becomes smart when it counts, and high agent capabilities that connect to systems. All these features are condensed into a compact size.
Whether you enjoy 'automated entertainment where you have AI write novels and automatically post them to WordPress' like I do, or try creating your own voice assistant or game NPC in a local environment, it is a partner that will infinitely shape a creator's delusions. Why don't you all try building your own AI system using this amazing 4B model? The world of sci-fi is already right in front of you!
The site is below.
I have released a Python sample script.
[Caution] Please use it only as a reference. At your own risk. I cannot answer questions. I was able to automatically post to WordPress like this. Parts that cannot be disclosed have been changed.
-- coding: utf-8 --
"""
story_autoposter_sample.py
WordPress Auto-Poster for AI-Generated Short Fiction
(LM Studio / OpenAI-compatible API)
■ Overview
A sample script that uses a local LLM (LM Studio) to automatically generate one short story every day and post it to WordPress.
■ Setup
pip install openai requests python-dotenv
Create a .env file in the same directory and enter the configuration values
(Refer to .env.example)Launch LM Studio and load the model
python story_autoposter_sample.py
■ .env configuration example (.env.example)
WP_BASE=https://your-site.example.com
WP_USER=your_wp_username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
LMSTUDIO_BASE_URL=http://localhost:1234/v1
LMSTUDIO_MODEL=your-model-name
POST_STATUS=draft
SERIES_NAME=your series name
SITE_BRAND=your site name
AUTHOR_LABEL=your author name
TARGET_CHARS_MIN=1800
TARGET_CHARS_MAX=2200
COVER_IMAGE_PATH=cover.png
■ License
MIT License — Feel free to modify and redistribute.
"""
import os
import re
import json
import html
import time
import base64
import random
import mimetypes
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional
import requests
from dotenv import dotenv_values
from openai import OpenAI
=========================================================
Base Paths
=========================================================
BASE_DIR = Path(file).resolve().parent
ENV_PATH = BASE_DIR / ".env"
RECENT_JSON_PATH = BASE_DIR / "recent_posts.json" # Post history file
LAST_OUTPUT_PATH = BASE_DIR / "last_story_output.txt" # Log of most recent generation
=========================================================
Load environment variables
=========================================================
ENV_MAP = dotenv_values(ENV_PATH, encoding="utf-8") if ENV_PATH.exists() else {}
def env_str(key: str, default: str = "") -> str:
raw = ENV_MAP.get(key)
if raw is None or str(raw).strip() == "":
raw = os.environ.get(key, default)
return str(raw).strip().strip('"').strip("'")
def env_int(key: str, default: int) -> int:
try:
return int(env_str(key, str(default)))
except Exception:
return default
def env_bool(key: str, default: bool = False) -> bool:
v = env_str(key, str(default)).strip().lower()
return v in ("1", "true", "yes", "on", "y")
=========================================================
Configuration
=========================================================
WP_BASE = env_str("WP_BASE", "").rstrip("/")
WP_POSTS = f"{WP_BASE}/wp-json/wp/v2/posts" if WP_BASE else ""
WP_MEDIA = f"{WP_BASE}/wp-json/wp/v2/media" if WP_BASE else ""
WP_USER = env_str("WP_USER", "")
Use WordPress application password with whitespace removed
WP_PASS = re.sub(r"\s+", "", env_str("WP_APP_PASSWORD", ""))
LM Studio (OpenAI compatible API) connection destination
LMSTUDIO_BASE_URL = env_str("LMSTUDIO_BASE_URL", "http://localhost:1234/v1")
LMSTUDIO_API_KEY = env_str("LMSTUDIO_API_KEY", "lm-studio") # Any string is fine for local use
LMSTUDIO_MODEL = env_str("LMSTUDIO_MODEL", "local-model")
Post settings
POST_STATUS = env_str("POST_STATUS", "draft") # "draft" or "publish"
SERIES_NAME = env_str("SERIES_NAME", "AI Creative Short Story Series") # Series name displayed on the site
SITE_BRAND = env_str("SITE_BRAND", "Your Site Name") # Site brand name
AUTHOR_LABEL = env_str("AUTHOR_LABEL", "AI Creative BOT") # Author display name
Target range for generated character count
TARGET_CHARS_MIN = env_int("TARGET_CHARS_MIN", 1800)
TARGET_CHARS_MAX = env_int("TARGET_CHARS_MAX", 2200)
Number of history items to keep
RECENT_KEEP = env_int("RECENT_KEEP", 30)
Whether to display episode numbers
USE_SERIES_NUMBER = env_bool("USE_SERIES_NUMBER", True)
Whether to display fiction notice
SHOW_FICTION_NOTICE = env_bool("SHOW_FICTION_NOTICE", True)
Path to eye-catching image (optional)
COVER_IMAGE_PATH_RAW = env_str("COVER_IMAGE_PATH", "cover.png")
if COVER_IMAGE_PATH_RAW:
_tmp = Path(COVER_IMAGE_PATH_RAW)
COVER_IMAGE_PATH = _tmp if _tmp.is_absolute() else (BASE_DIR / _tmp)
else:
COVER_IMAGE_PATH = None
=========================================================
Initialize LLM client
=========================================================
client = OpenAI(
base_url=LMSTUDIO_BASE_URL,
api_key=LMSTUDIO_API_KEY,
)
=========================================================
Utility functions
=========================================================
def clean_text(s: Any) -> str:
"""Normalize text"""
if s is None:
return ""
s = str(s).replace("\r\n", "\n").replace("\r", "\n")
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\n{3,}", "\n\n", s)
return s.strip()
def now_local() -> datetime:
return datetime.now()
def today_str(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d")
def jp_date_str(dt: datetime) -> str:
return f"{dt.year} year {dt.month} month {dt.day} day"
def normalize_title(s: str) -> str:
"""Normalize title for duplicate checking"""
s = clean_text(s).lower()
s = re.sub(r"[^\w一-龠ぁ-んァ-ヶ]+", "", s)
return s
def safe_json_loads(s: str) -> Optional[Dict[str, Any]]:
"""Safely extract JSON from LLM output"""
try:
data = json.loads(s)
if isinstance(data, dict):
return data
except Exception:
pass
m = re.search(r"{.*}", s, re.S)
if m:
try:
data = json.loads(m.group(0))
if isinstance(data, dict):
return data
except Exception:
return None
return None
def text_length_ja(s: str) -> int:
"""Return Japanese character count excluding line breaks"""
return len(clean_text(s).replace("\n", ""))
=========================================================
Startup diagnostics
=========================================================
def print_diag():
print("=== Startup diagnostics ===")
print("BASE_DIR =", BASE_DIR)
print("ENV_PATH =", ENV_PATH)
print("ENV_EXISTS =", ENV_PATH.exists())
print("WP_USER loaded =", bool(WP_USER))
print("WP_PASS loaded =", bool(WP_PASS))
print("WP_BASE =", WP_BASE)
print("POST_STATUS =", POST_STATUS)
print("LMSTUDIO_BASE_URL =", LMSTUDIO_BASE_URL)
print("LMSTUDIO_MODEL =", LMSTUDIO_MODEL)
print("SERIES_NAME =", SERIES_NAME)
print("TARGET_CHARS =", TARGET_CHARS_MIN, "~", TARGET_CHARS_MAX)
print("================")
def ensure_required():
"""Check for existence of required settings. Raise ValueError if missing"""
problems = []
if not ENV_PATH.exists():
problems.append(".env does not exist")
if not WP_BASE:
problems.append("WP_BASE is empty")
if not WP_USER:
problems.append("WP_USER is empty")
if not WP_PASS:
problems.append("WP_APP_PASSWORD is empty")
if not LMSTUDIO_BASE_URL:
problems.append("LMSTUDIO_BASE_URL is empty")
if not LMSTUDIO_MODEL:
problems.append("LMSTUDIO_MODEL is empty")
if problems:
raise ValueError("Configuration error: " + " / ".join(problems))
def wp_auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Generate Basic authentication headers for WordPress REST API"""
token = base64.b64encode(f"{WP_USER}:{WP_PASS}".encode("utf-8")).decode("utf-8")
headers = {
"Authorization": f"Basic {token}",
"User-Agent": "StoryAutoposter-Sample/1.0",
}
if extra:
headers.update(extra)
return headers
=========================================================
Recent posts history
=========================================================
def load_recent_posts() -> List[Dict[str, Any]]:
if not RECENT_JSON_PATH.exists():
return []
try:
data = json.loads(RECENT_JSON_PATH.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
except Exception:
pass
return []
def save_recent_posts(items: List[Dict[str, Any]]) -> None:
items = items[:RECENT_KEEP]
RECENT_JSON_PATH.write_text(
json.dumps(items, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def recent_titles(items: List[Dict[str, Any]]) -> List[str]:
return [clean_text(x.get("title", "")) for x in items if clean_text(x.get("title", ""))]
def title_looks_duplicate(new_title: str, titles: List[str]) -> bool:
"""Check if the new title is too similar to existing titles"""
nn = normalize_title(new_title)
if not nn:
return False
for old in titles:
oo = normalize_title(old)
if not oo:
continue
if nn == oo:
return True
if len(nn) >= 8 and (nn in oo or oo in nn):
return True
return False
=========================================================
Daily rotation plan
=========================================================
Set genres and theme hints for each day of the week (0=Mon to 6=Sun).
Feel free to customize.
DAILY_PLANS: Dict[int, Dict] = {
0: {
"genre": "SF",
"theme_hints": [
"Quantum observation and personality replication",
"Abnormal records from a lunar base",
"A city where AI has become an oracle",
"Memories altered after UFO contact",
"A room whose existence changes when observed",
],
},
1: {
"genre": "Horror",
"theme_hints": [
"Disappearance cases and forbidden areas",
"A train station platform that only opens at night",
"Another personality left behind in the mirror",
"Red records of a quarantined ward",
"Surveillance footage that no one should be watching",
],
},
2: {
"genre": "Fantasy",
"theme_hints": [
"Voices calling from ancient ruins",
"Forbidden books and royal bloodlines",
"Temples floating in the sky",
"Machine gods remaining in a ruined city",
"A sanctuary beyond a sealed map",
],
},
3: {
"genre": "SF",
"theme_hints": [
"Unknown particles leaked from an experimental facility",
"Time-reversal area",
"Observation equipment that learned human emotions",
"Voices of the dead arriving via quantum communication",
"Mission to recover wreckage of an alien civilization",
],
},
4: {
"genre": "Horror",
"theme_hints": [
"A forest where UMA legends remain",
"A village in sea fog and vanished residents",
"Storage media found under an altar",
"Footsteps increasing in the school building at midnight",
"The fate of a family that failed a ritual",
],
},
5: {
"genre": "Fantasy",
"theme_hints": [
"Contracts with myth-level entities",
"Doomsday prophecies and star gates",
"The King's engine sleeping in a ruined city",
"Winged, grotesque pilgrims",
"An execution city modeled after paradise",
],
},
6: {
"genre": "SF",
"theme_hints": [
"An imperial agency that contains abnormal phenomena",
"Observation logs sent from the future",
"The boundary between human modification and holiness",
"Abnormality rulings by AI inquisitors",
"Orders arriving from outside the universe",
],
},
}
def choose_today_plan(dt: datetime) -> Dict[str, str]:
"""Select genre/theme based on today's day of the week (same day will have the same theme)"""
weekday = dt.weekday()
plan = DAILY_PLANS.get(weekday, DAILY_PLANS[0])
rng = random.Random(dt.strftime("%Y-%m-%d"))
return {
"genre": plan["genre"],
"theme_hint": rng.choice(plan["theme_hints"]),
}
=========================================================
LLM call wrapper
=========================================================
def call_llm(
user_prompt: str,
system_prompt: str = "",
temperature: float = 0.8,
max_tokens: int = 2200,
) -> str:
"""Send a request to LM Studio (OpenAI compatible API) and return text"""
messages = []
if system_prompt.strip():
messages.append({"role": "system", "content": system_prompt.strip()})
messages.append({"role": "user", "content": user_prompt.strip()})
resp = client.chat.completions.create(
model=LMSTUDIO_MODEL,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
content = resp.choices[0].message.content or ""
return clean_text(content)def test_llm():
"""LLM connection verification test"""
print("[TEST] LLM unit test started")
try:
msg = call_llm(
user_prompt="Please reply only with 'Startup normal'.",
system_prompt="You are an assistant that replies concisely.",
temperature=0,
max_tokens=20,
)
print("[TEST] LLM response =", repr(msg))
except Exception as e:
print("[TEST] LLM error =", repr(e))
raise
=========================================================
Blueprint generation
=========================================================
BLUEPRINT_SYSTEM_PROMPT = """
You are a short story planner dedicated to creative AI.
You specialize in designing short stories that are intellectual, unsettling, aesthetic, and leave a strong lingering impression.
Strictly adhere to the output rules (JSON only, no code blocks, no preamble).
""".strip()
def build_blueprint_prompt(
genre: str,
theme_hint: str,
dt: datetime,
recent_titles_list: List[str],
) -> str:
recent_txt = json.dumps(recent_titles_list[-15:], ensure_ascii=False)
return f"""
You are an AI that designs Japanese short stories.
Please output a blueprint for a self-contained short story in JSON format.
Output must be JSON only. No code blocks. No preamble.
Requirements:
Completely original
Genre is {genre}
Theme hint is '{theme_hint}'
Must include at least one element of 'abnormal phenomenon', 'incomprehensible', 'inexplicable', 'human madness', or 'contact with the unknown'
Worldview must be either unsettling, aesthetic, mysterious, or intellectual
Structure should be easy to expand later into a text of approximately {TARGET_CHARS_MIN} to {TARGET_CHARS_MAX} characters
No citations of real-world news
Must not overlap with recently used titles or subject matter
Leave the reader with a sense of lingering emotion, anxiety, dread, or awe
Input:
input_genre = {genre}
input_theme_hint = {theme_hint}
input_date = {jp_date_str(dt)}
recent_titles = {recent_txt}
Output format:
{{
"title": "Short and powerful Japanese title",
"genre": "{genre}",
"theme": "Subject",
"setting": "Setting",
"protagonist": "Protagonist",
"hook": "Abnormal event at the beginning",
"outline_3beats": [
"Introduction",
"Midpoint reversal",
"Conclusion"
],
"ending_type": "Lingering emotion / Reversal / Despair / Awakening / Cycle",
"tags": ["Tag1", "Tag2", "Tag3", "Tag4"]
}}
""".strip()
def generate_blueprint(
genre: str,
theme_hint: str,
dt: datetime,
recent_items: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Generate a blueprint using an LLM (max 3 retries)"""
titles = recent_titles(recent_items)
for attempt in range(3):
prompt = build_blueprint_prompt(genre, theme_hint, dt, titles)
txt = call_llm(
user_prompt=prompt,
system_prompt=BLUEPRINT_SYSTEM_PROMPT,
temperature=0.9,
max_tokens=900,
)
data = safe_json_loads(txt)
if not data:
continue
title = clean_text(data.get("title", ""))
if not title or title_looks_duplicate(title, titles):
continue
# フィールドの補正・正規化
data["title"] = title
data["genre"] = clean_text(data.get("genre", genre)) or genre
data["theme"] = clean_text(data.get("theme", theme_hint)) or theme_hint
data["setting"] = clean_text(data.get("setting", ""))
data["protagonist"] = clean_text(data.get("protagonist", ""))
data["hook"] = clean_text(data.get("hook", ""))
beats = data.get("outline_3beats", [])
if not isinstance(beats, list):
beats = []
beats = [clean_text(x) for x in beats if clean_text(x)]
while len(beats) < 3:
beats.append(["導入", "中盤の反転", "結末"][len(beats)])
data["outline_3beats"] = beats[:3]
data["ending_type"] = clean_text(data.get("ending_type", "余韻")) or "余韻"
tags = data.get("tags", [])
if not isinstance(tags, list):
tags = []
tags = [clean_text(x) for x in tags if clean_text(x)]
if not tags:
tags = [genre, "異常現象", "創作短編", theme_hint]
data["tags"] = tags[:6]
return data
# 3回失敗した場合のフォールバック設計図
return {
"title": f"{theme_hint}の記録",
"genre": genre,
"theme": theme_hint,
"setting": "異常現象を観測する閉鎖環境",
"protagonist": "ある異常に巻き込まれた観測者",
"hook": "誰も知らないはずの異常記録が主人公に届く",
"outline_3beats": [
"奇妙な記録との遭遇",
"観測のたびに現実が変質する",
"最後に主人公自身が記録の一部になる",
],
"ending_type": "余韻",
"tags": [genre, "異常現象", "創作短編", "不可解"],
}=========================================================
Story text generation
=========================================================
STORY_SYSTEM_PROMPT = """
You are a commercial Japanese short story writer.
Do not rely on explanatory prose; let the reader experience the story through scenes, sounds, atmosphere, actions, and a sense of unease.
Avoid cheap template expressions and maintain an unsettling and intellectual reading experience.
""".strip()
def build_story_prompt(blueprint: Dict[str, Any]) -> str:
bp = json.dumps(blueprint, ensure_ascii=False, indent=2)
return f"""
You are a Japanese commercial short story writer.
Based on the following blueprint, please write a short story of approximately {TARGET_CHARS_MIN} to {TARGET_CHARS_MAX} characters.
Output only the body text. No code blocks. No explanations. No headings.
Mandatory requirements:
Write in Japanese
Complete in one chapter
Present a strong anomaly in the first 3 to 5 lines
Do not write like a news article
No bullet points or chapter divisions
Do not cite real news
Establish it as a completely original work
Emphasize scene description, psychological portrayal, sense of unease, and lingering resonance
Avoid explicit sexual descriptions
Keep cruel expressions literary and suggestive
Leave a strong lingering impression in the final 1 to 2 sentences
Blueprint:
{bp}
""".strip()
def generate_story_text(blueprint: Dict[str, Any]) -> str:
prompt = build_story_prompt(blueprint)
story = call_llm(
user_prompt=prompt,
system_prompt=STORY_SYSTEM_PROMPT,
temperature=0.95,
max_tokens=2600,
)
return clean_text(story)
def adjust_story_length(story: str, blueprint: Dict[str, Any]) -> str:
"""Adjust by regenerating if the character count is outside the target range"""
n = text_length_ja(story)
if TARGET_CHARS_MIN <= n <= TARGET_CHARS_MAX:
return story
direction = (
"短いので描写と余韻を補って増やしてください"
if n < TARGET_CHARS_MIN
else "長いので密度を保って削ってください"
)
prompt = f"""Please revise the following short story.
The current character count is approximately {n} characters.
{direction}
The target is approximately {TARGET_CHARS_MIN} to {TARGET_CHARS_MAX} characters.
Rules:
Output only the revised body text
Do not write titles or explanations
Do not change the core of the story
Maintain the dignity of the writing style
Strengthen the lingering effect of the ending
Blueprint:
{json.dumps(blueprint, ensure_ascii=False, indent=2)}
Body:
{story}
""".strip()
fixed = call_llm(
user_prompt=prompt,
system_prompt=STORY_SYSTEM_PROMPT,
temperature=0.7,
max_tokens=2600,
)
fixed = clean_text(fixed)
return fixed if fixed else story=========================================================
Post title & excerpt generation
=========================================================
def generate_post_title(blueprint: Dict[str, Any], story: str, series_no: int) -> str:
default_title = clean_text(blueprint.get("title", "Creative Short Story"))
prefix = f"Episode {series_no}: " if USE_SERIES_NUMBER else ""
prompt = f"""
Please create exactly one WordPress post title for the following short story.
Output only the title text. No explanations.
Conditions:
Japanese
Approximately 20 to 36 characters
Strong but not vulgar
Evoke a sense of either disturbance, intellect, or aestheticism
Make people want to click
Words like 'anomaly', 'record', 'report', 'observation', 'blockade', 'disappearance', 'reverberation', and 'forbidden zone' may be used appropriately
If there is a series number, attach it to the beginning as is
Series prefix:
{prefix}
Blueprint title:
{default_title}
Body:
{story[:1400]}
""".strip()
title = call_llm(
user_prompt=prompt,
system_prompt="あなたは日本語の優れた編集者です。",
temperature=0.8,
max_tokens=80,
)
title = clean_text(title).replace("「", "").replace("」", "")
if not title:
title = prefix + default_title
if prefix and not title.startswith(prefix):
title = prefix + title
return title[:60]def generate_excerpt(post_title: str, blueprint: Dict[str, Any], story: str) -> str:
prompt = f"""
Please create exactly one excerpt for the following short story.
Output only the body text. No explanations allowed.
Conditions:
Japanese
90 to 140 characters
Do not include too many spoilers
Evoke a sense of unease, abnormality, and intellectual curiosity
Natural text that can be used as a WordPress excerpt
Title:
{post_title}
Blueprint:
{json.dumps(blueprint, ensure_ascii=False)}
Body:
{story[:1600]}
""".strip()
excerpt = call_llm(
user_prompt=prompt,
system_prompt="あなたは日本語の編集者です。",
temperature=0.7,
max_tokens=140,
)
excerpt = clean_text(excerpt)
if not excerpt:
excerpt = f"{post_title} —— {clean_text(blueprint.get('hook', '説明不能な異常から始まる短編。'))}"
return excerpt[:180]=========================================================
HTML formatting
=========================================================
def split_into_paragraphs(text: str) -> List[str]:
"""Split text into a list of paragraphs"""
text = clean_text(text)
blocks = [b.strip() for b in re.split(r"\n\s*\n", text) if b.strip()]
if len(blocks) >= 2:
return blocks
# 改行が少ない場合は3文ごとに段落化
sents = re.split(r"(?<=[。!?])", text)
buf, out, count = "", [], 0
for s in sents:
s = s.strip()
if not s:
continue
buf += s
count += 1
if count >= 3:
out.append(buf.strip())
buf, count = "", 0
if buf.strip():
out.append(buf.strip())
return out if out else [text]def story_text_to_html(text: str) -> str:
"""Convert body text to HTML <p> tags"""
paras = split_into_paragraphs(text)
return "\n".join(
f'<p style="margin:0 0 1.35em; line-height:2.05; font-size:17px; color:#f0f0f0;">'
f'{html.escape(p)}</p>'
for p in paras
)
def build_post_html(
post_title: str,
excerpt: str,
story: str,
blueprint: Dict[str, Any],
dt: datetime,
series_no: int,
) -> str:
"""Assemble the entire HTML to be posted to WordPress"""
genre = html.escape(clean_text(blueprint.get("genre", "")))
theme = html.escape(clean_text(blueprint.get("theme", "")))
setting = html.escape(clean_text(blueprint.get("setting", "")))
protagonist = html.escape(clean_text(blueprint.get("protagonist", "")))
tags = [html.escape(clean_text(x)) for x in blueprint.get("tags", []) if clean_text(x)]
tag_line = " / ".join(tags[:6]) if tags else "Creative Short Story / Abnormal Phenomenon"
fiction_notice = ""
if SHOW_FICTION_NOTICE:
fiction_notice = """
<div style="margin:0 0 22px; padding:12px 14px; border:1px solid #4b3d00;
background:#1b1606; color:#f9d978; border-radius:10px; font-size:14px;">
※ 本記事はAIによる完全創作の短編小説です。実在の事件・人物・報道を
そのまま記述したものではありません。
</div>
"""
return f"""<div style="max-width:860px; margin:0 auto; background:linear-gradient(180deg,#121212 0%,#1a1a1a 100%);
color:#f3f3f3; padding:28px 22px; border-radius:18px; border:1px solid #2c2c2c ;
box-shadow:0 18px 48px rgba(0,0,0,.32);">
<div style="display:inline-block; padding:6px 12px; background:#5b0000; color:#fff1c9;
border-radius:999px; font-size:12px; letter-spacing:.08em; margin-bottom:14px;">
{html.escape(SERIES_NAME)}
</div>
<h1 style="margin:0 0 14px; font-size:34px; line-height:1.35; color:#ffd36a;">
{html.escape(post_title)}
</h1>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px;">
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{genre}</span>
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{theme}</span>
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{html.escape(jp_date_str(dt))}</span>
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{html.escape(AUTHOR_LABEL)}</span>
</div>
<div style="margin:0 0 20px; padding:16px 18px;
background:linear-gradient(90deg,#241a00,#111111);
border-left:4px solid #d4a62a ; border-radius:12px;">
<div style="font-size:13px; color:#e7c65f; margin-bottom:6px;">Introduction</div>
<div style="font-size:16px; line-height:1.9; color:#fafafa;">
{html.escape(excerpt)}
</div>
</div>
{fiction_notice}
<div style="margin:0 0 22px; padding:16px 18px; background:#171717;
border:1px solid #2d2d2d ; border-radius:12px;">
<div style="font-size:14px; color:#e9c868; margin-bottom:10px;">Observation Notes</div>
<div style="font-size:14px; line-height:1.9; color:#d8d8d8;">
<strong>Setting:</strong> {setting}<br>
<strong>Protagonist:</strong> {protagonist}<br>
<strong>Tags:</strong> {tag_line}
</div>
</div>
<hr style="border:none; border-top:1px solid #2a2a2a ; margin:28px 0;">
<div style="margin:0 0 8px; font-size:14px; color:#d6b657;">Body</div>
<div>{story_text_to_html(story)}</div>
<hr style="border:none; border-top:1px solid #2a2a2a ; margin:34px 0 22px;">
<div style="padding:16px 18px; background:#141414; border:1px solid #292929 ; border-radius:12px;">
<div style="font-size:14px; color:#e6c25b; margin-bottom:8px;">Series Information</div>
<div style="font-size:14px; color:#d4d4d4; line-height:1.9;">
Creative short story archive of {html.escape(SITE_BRAND)}.
I will record and post one anomalous tale daily, teetering on the boundaries of sci-fi, horror, and fantasy.
{f"This record number is episode {series_no}." if USE_SERIES_NUMBER else ""}
</div>
</div>
</div>
""".strip()
=========================================================
WordPress publishing
=========================================================
def upload_featured_image(image_path: Optional[Path]) -> Optional[int]:
"""Upload featured image to WordPress media library"""
if not image_path:
print("[INFO] Skipping image upload because COVER_IMAGE_PATH is not specified")
return None
if not image_path.exists():
print(f"[INFO] Skipping because image not found: {image_path}")
return None
mime = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
filename = image_path.name
headers = wp_auth_headers({
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": mime,
})
with open(image_path, "rb") as f:
data = f.read()
res = requests.post(WP_MEDIA, headers=headers, data=data, timeout=60)
if res.status_code not in (200, 201):
print("[WARN] 画像アップロード失敗:", res.status_code, res.text[:300])
return None
try:
media_id = res.json().get("id")
print("[INFO] 画像アップロード成功 media_id =", media_id)
return media_id
except Exception:
print("[WARN] 画像アップロード応答のJSON解析に失敗")
return Nonedef create_wp_post(
title: str,
content_html: str,
excerpt: str,
featured_media_id: Optional[int] = None,
) -> Dict[str, Any]:
"""Create a post via WordPress REST API"""
payload: Dict[str, Any] = {
"title": title,
"content": content_html,
"excerpt": excerpt,
"status": POST_STATUS,
}
if featured_media_id:
payload["featured_media"] = featured_media_id
headers = wp_auth_headers({"Content-Type": "application/json; charset=utf-8"})
res = requests.post(WP_POSTS, headers=headers, json=payload, timeout=60)
if res.status_code not in (200, 201):
raise RuntimeError(f"WordPress 投稿失敗: {res.status_code} / {res.text[:500]}")
return res.json()=========================================================
Main entry point
=========================================================
def main():
print_diag()
ensure_required()
test_llm()
dt = now_local()
plan = choose_today_plan(dt)
genre = plan["genre"]
theme_hint = plan["theme_hint"]
recent_items = load_recent_posts()
series_no = len(recent_items) + 1
print(f"[PLAN] genre={genre} / theme_hint={theme_hint}")
print(f"[PLAN] recent_count={len(recent_items)} / series_no={series_no}")
# Step 1: 設計図生成
blueprint = generate_blueprint(genre, theme_hint, dt, recent_items)
print("[BLUEPRINT]", json.dumps(blueprint, ensure_ascii=False))
# Step 2: 本文生成 → 文字数調整
story = generate_story_text(blueprint)
story = adjust_story_length(story, blueprint)
story = clean_text(story)
print(f"[STORY] 文字数={text_length_ja(story)}")
# Step 3: タイトル・抜粋生成
post_title = generate_post_title(blueprint, story, series_no)
excerpt = generate_excerpt(post_title, blueprint, story)
# Step 4: 重複タイトル補正
if title_looks_duplicate(post_title, recent_titles(recent_items)):
post_title = f"{post_title}・別観測"
print("[INFO] タイトルが近いため補正:", post_title)
# Step 5: HTML組み立て
content_html = build_post_html(
post_title=post_title,
excerpt=excerpt,
story=story,
blueprint=blueprint,
dt=dt,
series_no=series_no,
)
# Step 6: 生成ログ保存
LAST_OUTPUT_PATH.write_text(
f"TITLE: {post_title}\n\n"
f"EXCERPT:\n{excerpt}\n\n"
f"BLUEPRINT:\n{json.dumps(blueprint, ensure_ascii=False, indent=2)}\n\n"
f"STORY:\n{story}\n",
encoding="utf-8",
)
# Step 7: 画像アップロード(任意)
featured_media_id = upload_featured_image(COVER_IMAGE_PATH)
# Step 8: WordPress 投稿
result = create_wp_post(post_title, content_html, excerpt, featured_media_id)
post_id = result.get("id")
post_url = result.get("link")
post_status = result.get("status")
print("=== 投稿成功 ===")
print("ID =", post_id)
print("TITLE =", post_title)
print("URL =", post_url)
print("STATUS =", post_status)
# Step 9: 履歴保存
recent_items.insert(0, {
"date": today_str(dt),
"title": post_title,
"genre": blueprint.get("genre", genre),
"theme": blueprint.get("theme", theme_hint),
"tags": blueprint.get("tags", []),
"wp_post_id": post_id,
"wp_url": post_url,
"status": post_status,
})
save_recent_posts(recent_items)
print("[INFO] recent_posts.json 更新完了")if name == "main":
main()
============================================================
.env.example — story_autoposter_sample.py configuration file
Copy this file to .env and update each value.
============================================================
---- WordPress Connection Settings ----
WP_BASE=https://your-site.example.com
WP_USER=your_wp_username
Application Password (issued via WordPress Admin > Users > Profile)
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
---- LM Studio Connection Settings ----
LMSTUDIO_BASE_URL=http://localhost:1234/v1
LMSTUDIO_API_KEY=lm-studio
LMSTUDIO_MODEL=your-model-name
---- Post Settings ----
draft or publish (immediate publication)
POST_STATUS=draft
SERIES_NAME=AI Creative Short Story Series
SITE_BRAND=Your Site Name
AUTHOR_LABEL=AI Creative BOT
---- Generated Character Count ----
TARGET_CHARS_MIN=1800
TARGET_CHARS_MAX=2200
---- Others ----
RECENT_KEEP=30
USE_SERIES_NUMBER=True
SHOW_FICTION_NOTICE=True
Eyecatch image (optional)
COVER_IMAGE_PATH=cover.png
