- story/style_persona.py: Expanded default ai_isms list with 20+ modern AI phrases (delved, mined, neon-lit, bustling, a wave of, etched in, etc.) and added filter_words (wondered, seemed, appeared, watched, observed, sensed) - story/editor.py: Stricter evaluate_chapter_quality rubric — added DEEP_POV_ENFORCEMENT block with automatic fail conditions for filter word density and summary mode; strengthened criterion 5 scoring thresholds - story/writer.py: Added get_genre_instructions() helper with genre-specific mandates for Thriller, Romance, Fantasy, Sci-Fi, Horror, Historical, and General Fiction; added DEEP_POV_MANDATE block banning summary mode and filter words; expanded AVOID AI-ISMS banned phrase list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
19 KiB
Python
406 lines
19 KiB
Python
import json
|
|
import os
|
|
from core import utils
|
|
from ai import models as ai_models
|
|
from story.style_persona import get_style_guidelines
|
|
|
|
|
|
def evaluate_chapter_quality(text, chapter_title, genre, model, folder):
|
|
guidelines = get_style_guidelines()
|
|
ai_isms = "', '".join(guidelines['ai_isms'])
|
|
fw_examples = ", ".join([f"'He {w}'" for w in guidelines['filter_words'][:5]])
|
|
|
|
word_count = len(text.split()) if text else 0
|
|
min_sugg = max(3, int(word_count / 500))
|
|
max_sugg = min_sugg + 2
|
|
suggestion_range = f"{min_sugg}-{max_sugg}"
|
|
|
|
prompt = f"""
|
|
ROLE: Senior Literary Editor
|
|
TASK: Critique chapter draft. Apply STRICT scoring — do not inflate scores.
|
|
|
|
METADATA:
|
|
- TITLE: {chapter_title}
|
|
- GENRE: {genre}
|
|
|
|
PROHIBITED_PATTERNS:
|
|
- AI_ISMS: {ai_isms}
|
|
- FILTER_WORDS: {fw_examples} — these are telling words that distance the reader from the scene.
|
|
- CLICHES: White Room, As You Know Bob, Summary Mode, Anachronisms.
|
|
- SYNTAX: Repetitive structure, Passive Voice, Adverb Reliance.
|
|
|
|
DEEP_POV_ENFORCEMENT (AUTOMATIC FAIL CONDITIONS):
|
|
- FILTER_WORD_DENSITY: Scan the entire text for filter words (felt, saw, heard, realized, decided, noticed, knew, thought, wondered, seemed, appeared, watched, observed, sensed). If these words appear more than once per 120 words on average, criterion 5 MUST score 1-4 and the overall score CANNOT exceed 5.
|
|
- SUMMARY_MODE: If any passage narrates events in summary rather than dramatizing them in real-time scene (e.g., "Over the next hour, they discussed...", "He had spent years..."), flag it. Summary mode in a scene that should be dramatized drops criterion 2 to 1-3 and the overall score CANNOT exceed 6.
|
|
- TELLING_EMOTIONS: Phrases like "She felt sad," "He was angry," "She was nervous" — labeling emotions instead of showing them through physical action — are automatic criterion 5 failures. Each instance must be called out.
|
|
|
|
QUALITY_RUBRIC (1-10):
|
|
1. ENGAGEMENT & TENSION: Does the story grip the reader from the first line? Is there conflict or tension in every scene?
|
|
2. SCENE EXECUTION: Is the middle of the chapter fully fleshed out? Does it avoid "sagging" or summarizing key moments? (Automatic 1-3 if summary mode detected.)
|
|
3. VOICE & TONE: Is the narrative voice distinct? Does it match the genre?
|
|
4. SENSORY IMMERSION: Does the text use sensory details effectively without being overwhelming?
|
|
5. SHOW, DON'T TELL / DEEP POV: STRICT ENFORCEMENT. Emotions must be rendered through physical reactions, micro-behaviours, and subtext — NOT named or labelled. Score 1-4 if filter word density is high. Score 1-2 if the chapter names emotions directly ("she felt," "he was angry") more than 3 times. Score 7-10 ONLY if the reader experiences the POV character's state without being told what it is.
|
|
6. CHARACTER AGENCY: Do characters drive the plot through active choices?
|
|
7. PACING: Does the chapter feel rushed? Does the ending land with impact, or does it cut off too abruptly?
|
|
8. GENRE APPROPRIATENESS: Are introductions of characters, places, items, or actions consistent with the {genre} conventions?
|
|
9. DIALOGUE AUTHENTICITY: Do characters sound distinct? Is there subtext? Avoids "on-the-nose" dialogue.
|
|
10. PLOT RELEVANCE: Does the chapter advance the plot or character arcs significantly? Avoids filler.
|
|
11. STAGING & FLOW: Do characters enter/exit physically? Do paragraphs transition logically (Action -> Reaction)?
|
|
12. PROSE DYNAMICS: Is there sentence variety? Avoids purple prose, adjective stacking, and excessive modification.
|
|
13. CLARITY & READABILITY: Is the text easy to follow? Are sentences clear and concise?
|
|
|
|
SCORING_SCALE:
|
|
- 10 (Masterpiece): Flawless, impactful, ready for print.
|
|
- 9 (Bestseller): Exceptional quality, minor style tweaks only.
|
|
- 7-8 (Professional): Good draft, solid structure, needs editing.
|
|
- 6 (Passable): Average, has issues with pacing or voice. Needs heavy refinement.
|
|
- 1-5 (Fail): Structural flaws, summary mode detected, heavy filter word reliance, or incoherent. Needs full rewrite.
|
|
- IMPORTANT: A score of 7+ CANNOT be awarded if filter word density is high or if any emotion is directly named/labelled.
|
|
|
|
OUTPUT_FORMAT (JSON):
|
|
{{
|
|
"score": int,
|
|
"critique": "Detailed analysis of flaws, citing specific examples from the text.",
|
|
"actionable_feedback": "List of {suggestion_range} specific, ruthless instructions for the rewrite (e.g. 'Expand the middle dialogue', 'Add sensory details about the rain', 'Dramatize the argument instead of summarizing it')."
|
|
}}
|
|
"""
|
|
try:
|
|
response = model.generate_content([prompt, utils.truncate_to_tokens(text, 7500)])
|
|
model_name = getattr(model, 'name', ai_models.logic_model_name)
|
|
utils.log_usage(folder, model_name, response.usage_metadata)
|
|
data = json.loads(utils.clean_json(response.text))
|
|
|
|
critique_text = data.get('critique', 'No critique provided.')
|
|
if data.get('actionable_feedback'):
|
|
critique_text += "\n\nREQUIRED FIXES:\n" + str(data.get('actionable_feedback'))
|
|
|
|
return data.get('score', 0), critique_text
|
|
except Exception as e:
|
|
return 0, f"Evaluation error: {str(e)}"
|
|
|
|
|
|
def check_pacing(bp, summary, last_chapter_text, last_chapter_data, remaining_chapters, folder):
|
|
utils.log("ARCHITECT", "Checking pacing and structure health...")
|
|
|
|
if not remaining_chapters:
|
|
return None
|
|
|
|
meta = bp.get('book_metadata', {})
|
|
|
|
prompt = f"""
|
|
ROLE: Structural Editor
|
|
TASK: Analyze pacing.
|
|
|
|
CONTEXT:
|
|
- PREVIOUS_SUMMARY: {utils.truncate_to_tokens(summary, 1000)}
|
|
- CURRENT_CHAPTER: {utils.truncate_to_tokens(last_chapter_text, 800)}
|
|
- UPCOMING: {json.dumps([c['title'] for c in remaining_chapters[:3]])}
|
|
- REMAINING_COUNT: {len(remaining_chapters)}
|
|
|
|
LOGIC:
|
|
- IF skipped major beats -> ADD_BRIDGE
|
|
- IF covered next chapter's beats -> CUT_NEXT
|
|
- ELSE -> OK
|
|
|
|
OUTPUT_FORMAT (JSON):
|
|
{{
|
|
"status": "ok" or "add_bridge" or "cut_next",
|
|
"reason": "Explanation...",
|
|
"new_chapter": {{ "title": "...", "beats": ["..."], "pov_character": "..." }} (Required if add_bridge)
|
|
}}
|
|
"""
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
return json.loads(utils.clean_json(response.text))
|
|
except Exception as e:
|
|
utils.log("ARCHITECT", f"Pacing check failed: {e}")
|
|
return None
|
|
|
|
|
|
def analyze_consistency(bp, manuscript, folder):
|
|
utils.log("EDITOR", "Analyzing manuscript for continuity errors...")
|
|
|
|
if not manuscript: return {"issues": ["No manuscript found."], "score": 0}
|
|
if not bp: return {"issues": ["No blueprint found."], "score": 0}
|
|
|
|
chapter_summaries = []
|
|
for ch in manuscript:
|
|
text = ch.get('content', '')
|
|
excerpt = text[:1000] + "\n...\n" + text[-1000:] if len(text) > 2000 else text
|
|
chapter_summaries.append(f"Ch {ch.get('num')}: {excerpt}")
|
|
|
|
context = "\n".join(chapter_summaries)
|
|
|
|
prompt = f"""
|
|
ROLE: Continuity Editor
|
|
TASK: Analyze book summary for plot holes.
|
|
|
|
INPUT_DATA:
|
|
- CHARACTERS: {json.dumps(bp.get('characters', []))}
|
|
- SUMMARIES:
|
|
{context}
|
|
|
|
OUTPUT_FORMAT (JSON): {{ "issues": ["Issue 1", "Issue 2"], "score": 8, "summary": "Brief overall assessment." }}
|
|
"""
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
return json.loads(utils.clean_json(response.text))
|
|
except Exception as e:
|
|
return {"issues": [f"Analysis failed: {e}"], "score": 0, "summary": "Error during analysis."}
|
|
|
|
|
|
def rewrite_chapter_content(bp, manuscript, chapter_num, instruction, folder):
|
|
utils.log("WRITER", f"Rewriting Ch {chapter_num} with instruction: {instruction}")
|
|
|
|
target_chap = next((c for c in manuscript if str(c.get('num')) == str(chapter_num)), None)
|
|
if not target_chap: return None
|
|
|
|
prev_text = ""
|
|
prev_chap = None
|
|
if isinstance(chapter_num, int):
|
|
prev_chap = next((c for c in manuscript if c['num'] == chapter_num - 1), None)
|
|
elif str(chapter_num).lower() == "epilogue":
|
|
numbered_chaps = [c for c in manuscript if isinstance(c['num'], int)]
|
|
if numbered_chaps:
|
|
prev_chap = max(numbered_chaps, key=lambda x: x['num'])
|
|
|
|
if prev_chap:
|
|
prev_text = prev_chap.get('content', '')[-3000:]
|
|
|
|
meta = bp.get('book_metadata', {})
|
|
|
|
ad = meta.get('author_details', {})
|
|
if not ad and 'author_bio' in meta:
|
|
persona_info = meta['author_bio']
|
|
else:
|
|
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
|
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
|
|
|
char_visuals = ""
|
|
from core import config
|
|
tracking_path = os.path.join(folder, "tracking_characters.json")
|
|
if os.path.exists(tracking_path):
|
|
try:
|
|
tracking_chars = utils.load_json(tracking_path)
|
|
if tracking_chars:
|
|
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
|
|
for name, data in tracking_chars.items():
|
|
desc = ", ".join(data.get('descriptors', []))
|
|
speech = data.get('speech_style', 'Unknown')
|
|
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n"
|
|
except: pass
|
|
|
|
guidelines = get_style_guidelines()
|
|
fw_list = '", "'.join(guidelines['filter_words'])
|
|
|
|
prompt = f"""
|
|
You are an expert fiction writing AI. Your task is to rewrite a specific chapter based on a user directive.
|
|
|
|
INPUT DATA:
|
|
- TITLE: {meta.get('title')}
|
|
- GENRE: {meta.get('genre')}
|
|
- TONE: {meta.get('style', {}).get('tone')}
|
|
- AUTHOR_VOICE: {persona_info}
|
|
- PREVIOUS_CONTEXT: {prev_text}
|
|
- CURRENT_DRAFT: {target_chap.get('content', '')[:5000]}
|
|
- CHARACTERS: {json.dumps(bp.get('characters', []))}
|
|
{char_visuals}
|
|
|
|
PRIMARY DIRECTIVE (USER INSTRUCTION):
|
|
{instruction}
|
|
|
|
EXECUTION RULES:
|
|
1. CONTINUITY: The new text must flow logically from PREVIOUS_CONTEXT.
|
|
2. ADHERENCE: The PRIMARY DIRECTIVE overrides any conflicting details in CURRENT_DRAFT.
|
|
3. VOICE: Strictly emulate the AUTHOR_VOICE.
|
|
4. GENRE: Enforce {meta.get('genre')} conventions. No anachronisms.
|
|
5. LOGIC: Enforce strict causality (Action -> Reaction). No teleporting characters.
|
|
|
|
PROSE OPTIMIZATION RULES (STRICT ENFORCEMENT):
|
|
- FILTER_REMOVAL: Scan for words [{fw_list}]. If found, rewrite the sentence to remove the filter and describe the sensation directly.
|
|
- SENTENCE_VARIETY: Penalize consecutive sentences starting with the same pronoun or article. Vary structure.
|
|
- SHOW_DONT_TELL: Convert internal summaries of emotion into physical actions or subtextual dialogue.
|
|
- ACTIVE_VOICE: Convert passive voice ("was [verb]ed") to active voice.
|
|
- SENSORY_ANCHORING: The first paragraph must establish the setting using at least one non-visual sense (smell, sound, touch).
|
|
- SUBTEXT: Dialogue must imply meaning rather than stating it outright.
|
|
|
|
RETURN JSON:
|
|
{{
|
|
"content": "The full chapter text in Markdown...",
|
|
"summary": "A concise summary of the chapter's events and ending state (for continuity checks)."
|
|
}}
|
|
"""
|
|
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
try:
|
|
data = json.loads(utils.clean_json(response.text))
|
|
return data.get('content'), data.get('summary')
|
|
except:
|
|
return response.text, None
|
|
except Exception as e:
|
|
utils.log("WRITER", f"Rewrite failed: {e}")
|
|
return None, None
|
|
|
|
|
|
def check_and_propagate(bp, manuscript, changed_chap_num, folder, change_summary=None):
|
|
utils.log("WRITER", f"Checking ripple effects from Ch {changed_chap_num}...")
|
|
|
|
changed_chap = next((c for c in manuscript if c['num'] == changed_chap_num), None)
|
|
if not changed_chap: return None
|
|
|
|
if change_summary:
|
|
current_context = change_summary
|
|
else:
|
|
change_summary_prompt = f"""
|
|
ROLE: Summarizer
|
|
TASK: Summarize the key events and ending state of this chapter for continuity tracking.
|
|
|
|
TEXT:
|
|
{utils.truncate_to_tokens(changed_chap.get('content', ''), 2500)}
|
|
|
|
FOCUS:
|
|
- Major plot points.
|
|
- Character status changes (injuries, items acquired, location changes).
|
|
- New information revealed.
|
|
|
|
OUTPUT: Concise text summary.
|
|
"""
|
|
try:
|
|
resp = ai_models.model_writer.generate_content(change_summary_prompt)
|
|
utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata)
|
|
current_context = resp.text
|
|
except:
|
|
current_context = changed_chap.get('content', '')[-2000:]
|
|
|
|
original_change_context = current_context
|
|
sorted_ms = sorted(manuscript, key=utils.chapter_sort_key)
|
|
start_index = -1
|
|
for i, c in enumerate(sorted_ms):
|
|
if str(c['num']) == str(changed_chap_num):
|
|
start_index = i
|
|
break
|
|
|
|
if start_index == -1 or start_index == len(sorted_ms) - 1:
|
|
return None
|
|
|
|
changes_made = False
|
|
consecutive_no_changes = 0
|
|
potential_impact_chapters = []
|
|
|
|
for i in range(start_index + 1, len(sorted_ms)):
|
|
target_chap = sorted_ms[i]
|
|
|
|
if consecutive_no_changes >= 2:
|
|
if target_chap['num'] not in potential_impact_chapters:
|
|
future_flags = [n for n in potential_impact_chapters if isinstance(n, int) and isinstance(target_chap['num'], int) and n > target_chap['num']]
|
|
|
|
if not future_flags:
|
|
remaining_chaps = sorted_ms[i:]
|
|
if not remaining_chaps: break
|
|
|
|
utils.log("WRITER", " -> Short-term ripple dissipated. Scanning remaining chapters for long-range impacts...")
|
|
|
|
chapter_summaries = []
|
|
for rc in remaining_chaps:
|
|
text = rc.get('content', '')
|
|
excerpt = text[:500] + "\n...\n" + text[-500:] if len(text) > 1000 else text
|
|
chapter_summaries.append(f"Ch {rc['num']}: {excerpt}")
|
|
|
|
scan_prompt = f"""
|
|
ROLE: Continuity Scanner
|
|
TASK: Identify chapters impacted by a change.
|
|
|
|
CHANGE_CONTEXT:
|
|
{original_change_context}
|
|
|
|
CHAPTER_SUMMARIES:
|
|
{json.dumps(chapter_summaries)}
|
|
|
|
CRITERIA: Identify later chapters that mention items, characters, or locations involved in the Change Context.
|
|
|
|
OUTPUT_FORMAT (JSON): [Chapter_Number_Int, ...]
|
|
"""
|
|
|
|
try:
|
|
resp = ai_models.model_logic.generate_content(scan_prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
|
|
potential_impact_chapters = json.loads(utils.clean_json(resp.text))
|
|
if not isinstance(potential_impact_chapters, list): potential_impact_chapters = []
|
|
potential_impact_chapters = [int(x) for x in potential_impact_chapters if str(x).isdigit()]
|
|
except Exception as e:
|
|
utils.log("WRITER", f" -> Scan failed: {e}. Stopping.")
|
|
break
|
|
|
|
if not potential_impact_chapters:
|
|
utils.log("WRITER", " -> No long-range impacts detected. Stopping.")
|
|
break
|
|
else:
|
|
utils.log("WRITER", f" -> Detected potential impact in chapters: {potential_impact_chapters}")
|
|
|
|
if isinstance(target_chap['num'], int) and target_chap['num'] not in potential_impact_chapters:
|
|
utils.log("WRITER", f" -> Skipping Ch {target_chap['num']} (Not flagged).")
|
|
continue
|
|
|
|
utils.log("WRITER", f" -> Checking Ch {target_chap['num']} for continuity...")
|
|
|
|
chap_word_count = len(target_chap.get('content', '').split())
|
|
prompt = f"""
|
|
ROLE: Continuity Checker
|
|
TASK: Determine if a chapter contradicts a story change. If it does, rewrite it to fix the contradiction.
|
|
|
|
CHANGED_CHAPTER: {changed_chap_num}
|
|
CHANGE_SUMMARY: {current_context}
|
|
|
|
CHAPTER_TO_CHECK (Ch {target_chap['num']}):
|
|
{utils.truncate_to_tokens(target_chap['content'], 3000)}
|
|
|
|
DECISION_LOGIC:
|
|
- If the chapter directly contradicts the change (references dead characters, items that no longer exist, events that didn't happen), status = REWRITE.
|
|
- If the chapter is consistent or only tangentially related, status = NO_CHANGE.
|
|
- Be conservative — only rewrite if there is a genuine contradiction.
|
|
|
|
REWRITE_RULES (apply only if REWRITE):
|
|
- Fix the specific contradiction. Preserve all other content.
|
|
- The rewritten chapter MUST be approximately {chap_word_count} words (same length as original).
|
|
- Include the chapter header formatted as Markdown H1.
|
|
- Do not add new plot points not in the original.
|
|
|
|
OUTPUT_FORMAT (JSON):
|
|
{{
|
|
"status": "NO_CHANGE" or "REWRITE",
|
|
"reason": "Brief explanation of the contradiction or why it's consistent",
|
|
"content": "Full Markdown rewritten chapter (ONLY if status is REWRITE, otherwise null)"
|
|
}}
|
|
"""
|
|
|
|
try:
|
|
response = ai_models.model_writer.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
|
data = json.loads(utils.clean_json(response.text))
|
|
|
|
if data.get('status') == 'NO_CHANGE':
|
|
utils.log("WRITER", f" -> Ch {target_chap['num']} is consistent.")
|
|
current_context = f"Ch {target_chap['num']} Summary: " + target_chap.get('content', '')[-2000:]
|
|
consecutive_no_changes += 1
|
|
elif data.get('status') == 'REWRITE' and data.get('content'):
|
|
new_text = data.get('content')
|
|
if new_text:
|
|
utils.log("WRITER", f" -> Rewriting Ch {target_chap['num']} to fix continuity.")
|
|
target_chap['content'] = new_text
|
|
changes_made = True
|
|
current_context = f"Ch {target_chap['num']} Summary: " + new_text[-2000:]
|
|
consecutive_no_changes = 0
|
|
|
|
try:
|
|
with open(os.path.join(folder, "manuscript.json"), 'w') as f: json.dump(manuscript, f, indent=2)
|
|
except: pass
|
|
|
|
except Exception as e:
|
|
utils.log("WRITER", f" -> Check failed: {e}")
|
|
|
|
return manuscript if changes_made else None
|