Fixes for site
This commit is contained in:
13
README.md
13
README.md
@@ -29,10 +29,12 @@ This is the best way to run the Web Dashboard on a server using Portainer.
|
|||||||
|
|
||||||
### 1. Git Setup
|
### 1. Git Setup
|
||||||
1. Create a new Git repository (GitHub/GitLab).
|
1. Create a new Git repository (GitHub/GitLab).
|
||||||
|
- For local servers like Gitea, your URL will be something like `http://10.0.0.102:3000/thethreemagi/bookapp.git`.
|
||||||
2. Push this project code to the repository.
|
2. Push this project code to the repository.
|
||||||
- **IMPORTANT:** Ensure `.env`, `token.json`, `credentials.json`, and the `data/` folder are in your `.gitignore`. Do **not** commit secrets to the repo.
|
- **IMPORTANT:** Ensure `.env`, `token.json`, `credentials.json`, and the `data/` folder are in your `.gitignore`. Do **not** commit secrets to the repo.
|
||||||
|
|
||||||
### 2. Server Preparation (One-Time Setup)
|
### 2. Server Preparation (One-Time Setup)
|
||||||
|
|
||||||
Since secrets and database files shouldn't be in Git, you need to place them on your server manually.
|
Since secrets and database files shouldn't be in Git, you need to place them on your server manually.
|
||||||
|
|
||||||
1. **Authenticate Locally:** Run the app on your PC first (`python wizard.py`) to generate the `token.json` file (Google Login).
|
1. **Authenticate Locally:** Run the app on your PC first (`python wizard.py`) to generate the `token.json` file (Google Login).
|
||||||
@@ -49,14 +51,19 @@ Since secrets and database files shouldn't be in Git, you need to place them on
|
|||||||
1. Log in to **Portainer**.
|
1. Log in to **Portainer**.
|
||||||
2. Go to **Stacks** > **Add stack**.
|
2. Go to **Stacks** > **Add stack**.
|
||||||
3. Select **Repository**.
|
3. Select **Repository**.
|
||||||
- **Repository URL:** `<your-git-repo-url>`
|
- **Repository URL:** `http://10.0.0.102:3000/thethreemagi/bookapp.git`
|
||||||
- **Compose path:** `docker-compose.yml`
|
- **Compose path:** `docker-compose.yml`
|
||||||
4. Under **Environment variables**, add the following:
|
4. **Enable Authentication:** Since your Gitea repository is private, you will need to provide credentials.
|
||||||
|
- Toggle on **Authentication**.
|
||||||
|
- **Username:** Your Gitea username (`thethreemagi`).
|
||||||
|
- **Password:** Use a **Personal Access Token** from Gitea, not your actual password.
|
||||||
|
- In Gitea, go to your **User Settings > Applications** and generate a new token with repository read access.
|
||||||
|
5. Under **Environment variables**, add the following:
|
||||||
- `HOST_PATH`: `/opt/bookapp` (The folder you created in Step 2)
|
- `HOST_PATH`: `/opt/bookapp` (The folder you created in Step 2)
|
||||||
- `GEMINI_API_KEY`: `<your-api-key>`
|
- `GEMINI_API_KEY`: `<your-api-key>`
|
||||||
- `ADMIN_PASSWORD`: `<secure-password-for-web-ui>`
|
- `ADMIN_PASSWORD`: `<secure-password-for-web-ui>`
|
||||||
- `FLASK_SECRET_KEY`: `<random-string>`
|
- `FLASK_SECRET_KEY`: `<random-string>`
|
||||||
5. Click **Deploy the stack**.
|
6. Click **Deploy the stack**.
|
||||||
|
|
||||||
Portainer will pull the code from Git, build the image, and mount the secrets/data from your server folder.
|
Portainer will pull the code from Git, build the image, and mount the secrets/data from your server folder.
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,19 @@ def init_models(force=False):
|
|||||||
|
|
||||||
img_source = "Gemini API" if model_image else "None"
|
img_source = "Gemini API" if model_image else "None"
|
||||||
|
|
||||||
|
# Auto-detect GCP Project from credentials if not set (Fix for Image Model)
|
||||||
|
if HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS):
|
||||||
|
try:
|
||||||
|
with open(config.GOOGLE_CREDS, 'r') as f:
|
||||||
|
cdata = json.load(f)
|
||||||
|
# Check common OAuth structures
|
||||||
|
for k in ['installed', 'web']:
|
||||||
|
if k in cdata and 'project_id' in cdata[k]:
|
||||||
|
config.GCP_PROJECT = cdata[k]['project_id']
|
||||||
|
utils.log("SYSTEM", f"Auto-detected GCP Project ID: {config.GCP_PROJECT}")
|
||||||
|
break
|
||||||
|
except: pass
|
||||||
|
|
||||||
if HAS_VERTEX and config.GCP_PROJECT:
|
if HAS_VERTEX and config.GCP_PROJECT:
|
||||||
creds = None
|
creds = None
|
||||||
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account
|
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account
|
||||||
|
|||||||
150
modules/story.py
150
modules/story.py
@@ -6,6 +6,71 @@ import config
|
|||||||
from modules import ai
|
from modules import ai
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
def get_style_guidelines():
|
||||||
|
defaults = {
|
||||||
|
"ai_isms": [
|
||||||
|
'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement',
|
||||||
|
'palpable tension', 'a sense of', 'suddenly', 'in that moment',
|
||||||
|
'symphony of', 'dance of', 'azure', 'cerulean'
|
||||||
|
],
|
||||||
|
"filter_words": [
|
||||||
|
'felt', 'saw', 'heard', 'realized', 'decided', 'noticed', 'knew', 'thought'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
user_data = utils.load_json(path)
|
||||||
|
if user_data:
|
||||||
|
if 'ai_isms' in user_data: defaults['ai_isms'] = user_data['ai_isms']
|
||||||
|
if 'filter_words' in user_data: defaults['filter_words'] = user_data['filter_words']
|
||||||
|
except: pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(path, 'w') as f: json.dump(defaults, f, indent=2)
|
||||||
|
except: pass
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def refresh_style_guidelines(model, folder=None):
|
||||||
|
utils.log("SYSTEM", "Refreshing Style Guidelines via AI...")
|
||||||
|
current = get_style_guidelines()
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
Act as a Literary Editor. Update our 'Banned Words' lists for AI writing.
|
||||||
|
|
||||||
|
CURRENT AI-ISMS (Cliches to avoid):
|
||||||
|
{json.dumps(current.get('ai_isms', []))}
|
||||||
|
|
||||||
|
CURRENT FILTER WORDS (Distancing language):
|
||||||
|
{json.dumps(current.get('filter_words', []))}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
1. Review the lists. Remove any that are too common/safe (false positives).
|
||||||
|
2. Add new common AI tropes (e.g. 'neon-lit', 'bustling', 'a sense of', 'mined', 'delved').
|
||||||
|
3. Ensure the list is robust but not paralyzing.
|
||||||
|
|
||||||
|
RETURN JSON: {{ "ai_isms": [strings], "filter_words": [strings] }}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = model.generate_content(prompt)
|
||||||
|
if folder: utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||||
|
new_data = json.loads(utils.clean_json(response.text))
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if 'ai_isms' in new_data and 'filter_words' in new_data:
|
||||||
|
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
|
||||||
|
with open(path, 'w') as f: json.dump(new_data, f, indent=2)
|
||||||
|
utils.log("SYSTEM", "Style Guidelines updated.")
|
||||||
|
return new_data
|
||||||
|
except Exception as e:
|
||||||
|
utils.log("SYSTEM", f"Failed to refresh guidelines: {e}")
|
||||||
|
return current
|
||||||
|
|
||||||
|
def filter_characters(chars):
|
||||||
|
"""Removes placeholder characters generated by AI."""
|
||||||
|
blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character']
|
||||||
|
return [c for c in chars if c.get('name') and c.get('name').lower().strip() not in blacklist]
|
||||||
|
|
||||||
def enrich(bp, folder, context=""):
|
def enrich(bp, folder, context=""):
|
||||||
utils.log("ENRICHER", "Fleshing out details from description...")
|
utils.log("ENRICHER", "Fleshing out details from description...")
|
||||||
|
|
||||||
@@ -36,7 +101,7 @@ def enrich(bp, folder, context=""):
|
|||||||
RETURN JSON in this EXACT format:
|
RETURN JSON in this EXACT format:
|
||||||
{{
|
{{
|
||||||
"book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }},
|
"book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }},
|
||||||
"characters": [ {{ "name": "Name", "role": "Role", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
"characters": [ {{ "name": "John Doe", "role": "Protagonist", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
||||||
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
@@ -72,6 +137,11 @@ def enrich(bp, folder, context=""):
|
|||||||
|
|
||||||
if 'characters' not in bp or not bp['characters']:
|
if 'characters' not in bp or not bp['characters']:
|
||||||
bp['characters'] = ai_data.get('characters', [])
|
bp['characters'] = ai_data.get('characters', [])
|
||||||
|
|
||||||
|
# Filter out default names
|
||||||
|
if 'characters' in bp:
|
||||||
|
bp['characters'] = filter_characters(bp['characters'])
|
||||||
|
|
||||||
if 'plot_beats' not in bp or not bp['plot_beats']:
|
if 'plot_beats' not in bp or not bp['plot_beats']:
|
||||||
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
||||||
|
|
||||||
@@ -281,26 +351,45 @@ def update_tracking(folder, chapter_num, chapter_text, current_tracking):
|
|||||||
return current_tracking
|
return current_tracking
|
||||||
|
|
||||||
def evaluate_chapter_quality(text, chapter_title, model, folder):
|
def evaluate_chapter_quality(text, chapter_title, 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]])
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
Analyze this book chapter text.
|
Act as a World-Class Literary Editor (e.g., Maxwell Perkins). Analyze this chapter draft with extreme scrutiny.
|
||||||
CHAPTER TITLE: {chapter_title}
|
CHAPTER TITLE: {chapter_title}
|
||||||
|
|
||||||
|
STRICT PROHIBITIONS (Automatic deduction):
|
||||||
|
- "AI-isms": '{ai_isms}'.
|
||||||
|
- Filter Words: {fw_examples}, etc. (Show the sensation/action, don't state the internal process).
|
||||||
|
- Stilted Dialogue: Characters speaking in perfect paragraphs without interruptions, slang, or subtext.
|
||||||
|
- White Room Syndrome: Dialogue occurring in a void without interaction with the setting/props.
|
||||||
|
- "As You Know, Bob": Characters explaining things to each other that they both already know.
|
||||||
|
|
||||||
CRITERIA:
|
CRITERIA:
|
||||||
1. ORGANIC FEEL: Does it sound like a human wrote it? Are "AI-isms" (e.g. 'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement') absent?
|
1. VOICE & TONE: Is the narrative voice distinct, or generic? Does it match the genre?
|
||||||
2. ENGAGEMENT: Is it interesting? Does it hook the reader?
|
2. SHOW, DON'T TELL: Are emotions demonstrated through action/viscera, or summarized?
|
||||||
3. REPETITION: Is sentence structure varied? Are words repeated unnecessarily?
|
3. PACING: Does the scene drag? Is there conflict in every beat?
|
||||||
4. PROGRESSION: Does the story move forward, or is it spinning its wheels?
|
4. CHARACTER AGENCY: Do characters make choices, or do things just happen to them?
|
||||||
|
|
||||||
Rate on a scale of 1-10.
|
Rate on a scale of 1-10. (Be harsh. 10 is Pulitzer level. 6 is average. Anything below 8 needs work).
|
||||||
Provide a concise critique focusing on the biggest flaw.
|
|
||||||
|
|
||||||
Return JSON: {{'score': int, 'critique': 'string'}}
|
Return JSON: {{
|
||||||
|
'score': int,
|
||||||
|
'critique': 'Detailed analysis of flaws, citing specific examples from the text.',
|
||||||
|
'actionable_feedback': 'List of 3-5 specific, ruthless instructions for the rewrite (e.g. "Cut the first 3 paragraphs", "Make the dialogue in the middle argument more aggressive", "Describe the smell of the room").'
|
||||||
|
}}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = model.generate_content([prompt, text[:30000]])
|
response = model.generate_content([prompt, text[:30000]])
|
||||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||||
data = json.loads(utils.clean_json(response.text))
|
data = json.loads(utils.clean_json(response.text))
|
||||||
return data.get('score', 0), data.get('critique', 'No critique provided.')
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
return 0, f"Evaluation error: {str(e)}"
|
return 0, f"Evaluation error: {str(e)}"
|
||||||
|
|
||||||
@@ -336,7 +425,7 @@ def refine_persona(bp, text, folder):
|
|||||||
current_bio = ad.get('bio', 'Standard style.')
|
current_bio = ad.get('bio', 'Standard style.')
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
Analyze this text sample from the book.
|
Act as a Literary Stylist. Analyze this text sample from the book.
|
||||||
|
|
||||||
TEXT:
|
TEXT:
|
||||||
{text[:3000]}
|
{text[:3000]}
|
||||||
@@ -449,7 +538,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
|
|||||||
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state.
|
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state.
|
||||||
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't summarize feelings; show the physical manifestation of them.
|
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't summarize feelings; show the physical manifestation of them.
|
||||||
- SENSORY DETAILS: Use specific, grounding sensory details (smell, touch, sound) rather than generic descriptions.
|
- SENSORY DETAILS: Use specific, grounding sensory details (smell, touch, sound) rather than generic descriptions.
|
||||||
- AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to').
|
- AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to', 'tapestry of', 'azure', 'cerulean').
|
||||||
- MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them.
|
- MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them.
|
||||||
- CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers.
|
- CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers.
|
||||||
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
|
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
|
||||||
@@ -477,14 +566,17 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
|
|||||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||||
|
|
||||||
# Refinement Loop
|
# Refinement Loop
|
||||||
max_attempts = 3
|
max_attempts = 9
|
||||||
best_score = 0
|
best_score = 0
|
||||||
best_text = current_text
|
best_text = current_text
|
||||||
|
past_critiques = []
|
||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
|
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
|
||||||
score, critique = evaluate_chapter_quality(current_text, chap['title'], ai.model_logic, folder)
|
score, critique = evaluate_chapter_quality(current_text, chap['title'], ai.model_logic, folder)
|
||||||
|
|
||||||
|
past_critiques.append(f"Attempt {attempt}: {critique}")
|
||||||
|
|
||||||
if "Evaluation error" in critique:
|
if "Evaluation error" in critique:
|
||||||
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
|
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
|
||||||
if best_score == 0: best_text = current_text
|
if best_score == 0: best_text = current_text
|
||||||
@@ -505,17 +597,27 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
|
|||||||
return best_text
|
return best_text
|
||||||
|
|
||||||
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
||||||
|
|
||||||
|
guidelines = get_style_guidelines()
|
||||||
|
fw_list = '", "'.join(guidelines['filter_words'])
|
||||||
|
|
||||||
|
history_str = "\n".join(past_critiques)
|
||||||
|
|
||||||
refine_prompt = f"""
|
refine_prompt = f"""
|
||||||
Act as a Senior Editor. Rewrite this chapter to fix the issues identified below.
|
Act as a Senior Editor. Rewrite this chapter to fix the issues identified below.
|
||||||
|
|
||||||
CRITIQUE TO ADDRESS:
|
CRITIQUE TO ADDRESS (MANDATORY):
|
||||||
{critique}
|
{critique}
|
||||||
|
|
||||||
ADDITIONAL OBJECTIVES:
|
PREVIOUS CRITIQUES (Reference):
|
||||||
1. NATURAL FLOW: Fix stilted phrasing. Ensure the prose flows naturally for the genre ({meta.get('genre', 'Fiction')}) and tone ({style.get('tone', 'Standard')}).
|
{history_str}
|
||||||
2. HUMANIZATION: Remove robotic phrasing. Ensure dialogue has subtext, interruptions, and distinct voices. Remove "AI-isms" (e.g. 'testament to', 'tapestry of', 'symphony of').
|
|
||||||
3. SENTENCE VARIETY: Check for and fix repetitive sentence starts or uniform sentence lengths. The prose should have a dynamic rhythm.
|
STYLIZED REWRITE INSTRUCTIONS:
|
||||||
4. CONTINUITY: Ensure consistency with the Story So Far.
|
1. REMOVE FILTER WORDS: Delete "{fw_list}". Describe the image, sensation, or sound directly.
|
||||||
|
2. VARY SENTENCE STRUCTURE: Do not start consecutive sentences with "He", "She", or "The". Use introductory clauses and varying lengths.
|
||||||
|
3. SUBTEXT: Ensure dialogue implies meaning rather than stating it outright. People rarely say exactly what they mean.
|
||||||
|
4. GENRE CONSISTENCY: Ensure the tone matches {meta.get('genre', 'Fiction')}.
|
||||||
|
5. SETTING INTERACTION: Ensure characters interact with their environment (props, weather, lighting) during dialogue.
|
||||||
|
|
||||||
STORY SO FAR:
|
STORY SO FAR:
|
||||||
{prev_sum}
|
{prev_sum}
|
||||||
@@ -545,8 +647,10 @@ def harvest_metadata(bp, folder, full_manuscript):
|
|||||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||||
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
|
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
|
||||||
if new_chars:
|
if new_chars:
|
||||||
utils.log("HARVESTER", f"Found {len(new_chars)} new chars.")
|
valid_chars = filter_characters(new_chars)
|
||||||
bp['characters'].extend(new_chars)
|
if valid_chars:
|
||||||
|
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
|
||||||
|
bp['characters'].extend(valid_chars)
|
||||||
except: pass
|
except: pass
|
||||||
return bp
|
return bp
|
||||||
|
|
||||||
@@ -609,11 +713,13 @@ def update_persona_sample(bp, folder):
|
|||||||
def refine_bible(bible, instruction, folder):
|
def refine_bible(bible, instruction, folder):
|
||||||
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
|
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
Act as a Book Editor.
|
Act as a Senior Developmental Editor.
|
||||||
CURRENT JSON: {json.dumps(bible)}
|
CURRENT JSON: {json.dumps(bible)}
|
||||||
USER INSTRUCTION: {instruction}
|
USER INSTRUCTION: {instruction}
|
||||||
|
|
||||||
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
||||||
|
Ensure character motivations remain consistent and plot holes are avoided.
|
||||||
|
|
||||||
RETURN ONLY THE JSON.
|
RETURN ONLY THE JSON.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ def view_project(id):
|
|||||||
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
|
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
|
||||||
|
|
||||||
artifacts = []
|
artifacts = []
|
||||||
|
cover_image = None
|
||||||
generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str}
|
generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str}
|
||||||
|
|
||||||
# Scan ALL completed runs to find the latest status of each book
|
# Scan ALL completed runs to find the latest status of each book
|
||||||
@@ -409,13 +410,28 @@ def view_project(id):
|
|||||||
b_num = int(parts[1])
|
b_num = int(parts[1])
|
||||||
# Only add if we haven't found a newer version (runs are ordered desc)
|
# Only add if we haven't found a newer version (runs are ordered desc)
|
||||||
if b_num not in generated_books:
|
if b_num not in generated_books:
|
||||||
generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d}
|
# Find artifacts for direct download link
|
||||||
|
book_path = os.path.join(run_dir, d)
|
||||||
|
epub_file = next((f for f in os.listdir(book_path) if f.endswith('.epub')), None)
|
||||||
|
docx_file = next((f for f in os.listdir(book_path) if f.endswith('.docx')), None)
|
||||||
|
|
||||||
|
generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d, 'epub': os.path.join(d, epub_file).replace("\\", "/") if epub_file else None, 'docx': os.path.join(d, docx_file).replace("\\", "/") if docx_file else None}
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
# Collect Artifacts from Latest Run
|
# Collect Artifacts from Latest Run
|
||||||
if latest_run:
|
if latest_run:
|
||||||
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}")
|
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}")
|
||||||
if os.path.exists(run_dir):
|
if os.path.exists(run_dir):
|
||||||
|
# Find Cover Image (Root or First Book)
|
||||||
|
if os.path.exists(os.path.join(run_dir, "cover.png")):
|
||||||
|
cover_image = "cover.png"
|
||||||
|
else:
|
||||||
|
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
||||||
|
for d in subdirs:
|
||||||
|
if os.path.exists(os.path.join(run_dir, d, "cover.png")):
|
||||||
|
cover_image = os.path.join(d, "cover.png").replace("\\", "/")
|
||||||
|
break
|
||||||
|
|
||||||
for root, dirs, files in os.walk(run_dir):
|
for root, dirs, files in os.walk(run_dir):
|
||||||
for f in files:
|
for f in files:
|
||||||
if f.lower().endswith(('.epub', '.docx')):
|
if f.lower().endswith(('.epub', '.docx')):
|
||||||
@@ -426,7 +442,7 @@ def view_project(id):
|
|||||||
'type': f.split('.')[-1].upper()
|
'type': f.split('.')[-1].upper()
|
||||||
})
|
})
|
||||||
|
|
||||||
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, personas=personas, generated_books=generated_books, other_projects=other_projects)
|
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects)
|
||||||
|
|
||||||
@app.route('/project/<int:id>/run', methods=['POST'])
|
@app.route('/project/<int:id>/run', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -718,18 +734,31 @@ def view_run(id):
|
|||||||
# Fetch Artifacts for Display
|
# Fetch Artifacts for Display
|
||||||
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
|
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
|
||||||
|
|
||||||
# Detect Book Subfolder (Series Support)
|
# Detect Books in Run (Series Support)
|
||||||
book_dir = run_dir
|
books_data = []
|
||||||
if os.path.exists(run_dir):
|
if os.path.exists(run_dir):
|
||||||
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
||||||
if subdirs: book_dir = os.path.join(run_dir, subdirs[0])
|
if not subdirs: subdirs = ["."] # Handle legacy/flat runs
|
||||||
|
|
||||||
blurb_content = ""
|
for d in subdirs:
|
||||||
blurb_path = os.path.join(book_dir, "blurb.txt")
|
b_path = os.path.join(run_dir, d)
|
||||||
if os.path.exists(blurb_path):
|
b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''}
|
||||||
with open(blurb_path, 'r', encoding='utf-8', errors='ignore') as f: blurb_content = f.read()
|
|
||||||
|
|
||||||
has_cover = os.path.exists(os.path.join(book_dir, "cover.png"))
|
# Artifacts
|
||||||
|
for f in os.listdir(b_path):
|
||||||
|
if f.lower().endswith(('.epub', '.docx')):
|
||||||
|
b_info['artifacts'].append({'name': f, 'path': os.path.join(d, f).replace("\\", "/")})
|
||||||
|
|
||||||
|
# Cover
|
||||||
|
if os.path.exists(os.path.join(b_path, "cover.png")):
|
||||||
|
b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/")
|
||||||
|
|
||||||
|
# Blurb
|
||||||
|
blurb_p = os.path.join(b_path, "blurb.txt")
|
||||||
|
if os.path.exists(blurb_p):
|
||||||
|
with open(blurb_p, 'r', encoding='utf-8', errors='ignore') as f: b_info['blurb'] = f.read()
|
||||||
|
|
||||||
|
books_data.append(b_info)
|
||||||
|
|
||||||
# Load Bible Data for Dropdown
|
# Load Bible Data for Dropdown
|
||||||
bible_path = os.path.join(run.project.folder_path, "bible.json")
|
bible_path = os.path.join(run.project.folder_path, "bible.json")
|
||||||
@@ -737,6 +766,8 @@ def view_run(id):
|
|||||||
|
|
||||||
# Load Tracking Data for Run Details
|
# Load Tracking Data for Run Details
|
||||||
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
||||||
|
# We load tracking from the first book found to populate the general stats
|
||||||
|
book_dir = os.path.join(run_dir, books_data[0]['folder']) if books_data else run_dir
|
||||||
if os.path.exists(book_dir):
|
if os.path.exists(book_dir):
|
||||||
t_ev = os.path.join(book_dir, "tracking_events.json")
|
t_ev = os.path.join(book_dir, "tracking_events.json")
|
||||||
t_ch = os.path.join(book_dir, "tracking_characters.json")
|
t_ch = os.path.join(book_dir, "tracking_characters.json")
|
||||||
@@ -748,7 +779,7 @@ def view_run(id):
|
|||||||
if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
|
if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
|
||||||
|
|
||||||
# Use dedicated run details template
|
# Use dedicated run details template
|
||||||
return render_template('run_details.html', run=run, log_content=log_content, blurb=blurb_content, has_cover=has_cover, bible=bible_data, tracking=tracking)
|
return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking)
|
||||||
|
|
||||||
@app.route('/run/<int:id>/status')
|
@app.route('/run/<int:id>/status')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -817,7 +848,12 @@ def optimize_models():
|
|||||||
# Force refresh via AI module (safely handles failures)
|
# Force refresh via AI module (safely handles failures)
|
||||||
try:
|
try:
|
||||||
ai.init_models(force=True) # Force re-initialization and API scan
|
ai.init_models(force=True) # Force re-initialization and API scan
|
||||||
flash("AI Models refreshed and optimized.")
|
|
||||||
|
# Refresh Style Guidelines
|
||||||
|
if ai.model_logic:
|
||||||
|
story.refresh_style_guidelines(ai.model_logic)
|
||||||
|
|
||||||
|
flash("AI Models refreshed and Style Guidelines updated.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Error refreshing models: {e}")
|
flash(f"Error refreshing models: {e}")
|
||||||
|
|
||||||
@@ -1080,6 +1116,29 @@ def admin_spend_report():
|
|||||||
|
|
||||||
return render_template('admin_spend.html', report=report, days=days, total=total_period_spend)
|
return render_template('admin_spend.html', report=report, days=days, total=total_period_spend)
|
||||||
|
|
||||||
|
@app.route('/admin/style', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def admin_style_guidelines():
|
||||||
|
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
ai_isms_raw = request.form.get('ai_isms', '')
|
||||||
|
filter_words_raw = request.form.get('filter_words', '')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"ai_isms": [x.strip() for x in ai_isms_raw.split('\n') if x.strip()],
|
||||||
|
"filter_words": [x.strip() for x in filter_words_raw.split('\n') if x.strip()]
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(path, 'w') as f: json.dump(data, f, indent=2)
|
||||||
|
flash("Style Guidelines updated successfully.")
|
||||||
|
return redirect(url_for('admin_style_guidelines'))
|
||||||
|
|
||||||
|
# Load current (creates defaults if missing)
|
||||||
|
data = story.get_style_guidelines()
|
||||||
|
return render_template('admin_style.html', data=data)
|
||||||
|
|
||||||
@app.route('/admin/impersonate/<int:user_id>')
|
@app.route('/admin/impersonate/<int:user_id>')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -61,6 +61,16 @@
|
|||||||
|
|
||||||
<!-- System Stats & Reset -->
|
<!-- System Stats & Reset -->
|
||||||
<div class="col-md-6 mb-4">
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-sliders-h me-2"></i>Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Manage global AI writing rules and banned words.</p>
|
||||||
|
<a href="{{ url_for('admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>
|
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>
|
||||||
|
|||||||
48
templates/admin_style.html
Normal file
48
templates/admin_style.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="fas fa-spell-check me-2 text-primary"></i>Style Guidelines</h2>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
These lists are used by the <strong>Editor Persona</strong> to critique chapters and by the <strong>Writer</strong> to refine text.
|
||||||
|
The AI will be penalized for using words in these lists.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold text-danger">
|
||||||
|
<i class="fas fa-ban me-2"></i>Banned "AI-isms" & Clichés
|
||||||
|
</label>
|
||||||
|
<div class="form-text mb-2">Common tropes that make text sound robotic (e.g., "testament to", "tapestry"). One per line.</div>
|
||||||
|
<textarea name="ai_isms" class="form-control font-monospace" rows="10">{{ data.ai_isms|join('\n') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold text-warning">
|
||||||
|
<i class="fas fa-filter me-2"></i>Filter Words
|
||||||
|
</label>
|
||||||
|
<div class="form-text mb-2">Words that create distance between the reader and the POV (e.g., "felt", "saw", "realized"). One per line.</div>
|
||||||
|
<textarea name="filter_words" class="form-control font-monospace" rows="6">{{ data.filter_words|join('\n') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Guidelines
|
||||||
|
</button>
|
||||||
|
<button type="submit" formaction="{{ url_for('optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
|
||||||
|
<i class="fas fa-magic me-2"></i>Auto-Refresh with AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -197,7 +197,16 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<span class="badge bg-light text-dark border">Book {{ book.book_number }}</span>
|
<span class="badge bg-light text-dark border">Book {{ book.book_number }}</span>
|
||||||
{% if generated_books.get(book.book_number) %}
|
{% if generated_books.get(book.book_number) %}
|
||||||
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Generated</span>
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-success dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-check me-1"></i>Generated
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% set gb = generated_books.get(book.book_number) %}
|
||||||
|
{% if gb.epub %}<li><a class="dropdown-item" href="/project/{{ gb.run_id }}/download?file={{ gb.epub }}"><i class="fas fa-file-epub me-2"></i>Download EPUB</a></li>{% endif %}
|
||||||
|
{% if gb.docx %}<li><a class="dropdown-item" href="/project/{{ gb.run_id }}/download?file={{ gb.docx }}"><i class="fas fa-file-word me-2"></i>Download DOCX</a></li>{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Planned</span>
|
<span class="badge bg-secondary">Planned</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -109,54 +109,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<!-- Generated Books in this Run -->
|
||||||
<!-- Left Column: Cover Art -->
|
{% for book in books %}
|
||||||
<div class="col-md-4 mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0"><i class="fas fa-image me-2"></i>Cover Art</h5>
|
<h5 class="mb-0"><i class="fas fa-book me-2"></i>{{ book.folder }}</h5>
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
{% if has_cover %}
|
|
||||||
<img src="{{ url_for('download_artifact', run_id=run.id, file='cover.png') }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover">
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-secondary py-5">
|
|
||||||
<i class="fas fa-image fa-3x mb-3"></i><br>
|
|
||||||
No cover generated yet.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST">
|
|
||||||
<label class="form-label text-start w-100 small fw-bold">Regenerate Art & Files</label>
|
|
||||||
<textarea name="feedback" class="form-control mb-2" rows="2" placeholder="Feedback (e.g. 'Make the font larger', 'Use a darker theme')..."></textarea>
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
|
||||||
<i class="fas fa-sync me-2"></i>Regenerate
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Blurb & Stats -->
|
|
||||||
<div class="col-md-8 mb-4">
|
|
||||||
<!-- Blurb -->
|
|
||||||
<div class="card shadow-sm mb-4">
|
|
||||||
<div class="card-header bg-light">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-align-left me-2"></i>Blurb</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if blurb %}
|
<div class="row">
|
||||||
<p class="card-text" style="white-space: pre-wrap;">{{ blurb }}</p>
|
<!-- Left Column: Cover Art -->
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="text-center">
|
||||||
|
{% if book.cover %}
|
||||||
|
<img src="{{ url_for('download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted fst-italic">Blurb not generated yet.</p>
|
<div class="alert alert-secondary py-5">
|
||||||
|
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if loop.first %}
|
||||||
|
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
|
||||||
|
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
|
||||||
|
<i class="fas fa-sync me-2"></i>Regenerate All
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Right Column: Blurb -->
|
||||||
<div class="row mb-4">
|
<div class="col-md-8">
|
||||||
|
<h6 class="fw-bold">Back Cover Blurb</h6>
|
||||||
|
<div class="p-3 bg-light rounded mb-3">
|
||||||
|
{% if book.blurb %}
|
||||||
|
<p class="mb-0" style="white-space: pre-wrap;">{{ book.blurb }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted fst-italic mb-0">No blurb generated.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-bold">Artifacts</h6>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for art in book.artifacts %}
|
||||||
|
<a href="{{ url_for('download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="fas fa-download me-1"></i> {{ art.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">No files found.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="card shadow-sm text-center">
|
<div class="card shadow-sm text-center">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -529,6 +529,8 @@ class BookWizard:
|
|||||||
if new_data:
|
if new_data:
|
||||||
if 'characters' in new_data:
|
if 'characters' in new_data:
|
||||||
self.data['characters'] = new_data['characters']
|
self.data['characters'] = new_data['characters']
|
||||||
|
# Filter defaults
|
||||||
|
self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']]
|
||||||
|
|
||||||
if 'books' in new_data:
|
if 'books' in new_data:
|
||||||
# Merge book data carefully
|
# Merge book data carefully
|
||||||
|
|||||||
Reference in New Issue
Block a user