Compare commits
12 Commits
ba56bc1ec1
...
6f19808f15
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f19808f15 | |||
| f1d7fcbcb7 | |||
| c3724a6761 | |||
| 74cc66eed3 | |||
| 353dc859d2 | |||
| 51b98c9399 | |||
| b4058f9f1f | |||
| 093e78a89e | |||
| bcba67a35f | |||
| 98a330c416 | |||
| af2050160e | |||
| 203d74f61d |
@@ -96,6 +96,10 @@ Open `http://localhost:5000`.
|
|||||||
- **Project Dashboard:** Create and monitor generation jobs from the browser.
|
- **Project Dashboard:** Create and monitor generation jobs from the browser.
|
||||||
- **Real-time Logs:** Console output is streamed to the browser and stored in the database.
|
- **Real-time Logs:** Console output is streamed to the browser and stored in the database.
|
||||||
- **Chapter Editor:** Edit chapters directly in the browser; manual edits are preserved across artifact regenerations and synced back to character/plot tracking state.
|
- **Chapter Editor:** Edit chapters directly in the browser; manual edits are preserved across artifact regenerations and synced back to character/plot tracking state.
|
||||||
|
- **Chapter Navigation:** Prev/Next buttons on every chapter card in the manuscript reader let you jump between chapters without scrolling.
|
||||||
|
- **Download Bible:** Download the project's `bible.json` directly from any run's detail page for offline review or cloning.
|
||||||
|
- **Run Tagging:** Label runs with comma-separated tags (e.g. `dark-ending`, `v2`, `favourite`) to organise and track experiments.
|
||||||
|
- **Run Deletion:** Delete completed or failed runs and their filesystem data from the run detail page.
|
||||||
- **Cover Regeneration:** Submit written feedback to regenerate the cover image iteratively.
|
- **Cover Regeneration:** Submit written feedback to regenerate the cover image iteratively.
|
||||||
- **Admin Panel:** Manage all users, view spend, and perform factory resets at `/admin`.
|
- **Admin Panel:** Manage all users, view spend, and perform factory resets at `/admin`.
|
||||||
- **Per-User API Keys:** Each user can supply their own Gemini API key; costs are tracked per account.
|
- **Per-User API Keys:** Each user can supply their own Gemini API key; costs are tracked per account.
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ model_logic = None
|
|||||||
model_writer = None
|
model_writer = None
|
||||||
model_artist = None
|
model_artist = None
|
||||||
model_image = None
|
model_image = None
|
||||||
logic_model_name = "models/gemini-1.5-pro"
|
logic_model_name = "models/gemini-1.5-flash"
|
||||||
writer_model_name = "models/gemini-1.5-flash"
|
writer_model_name = "models/gemini-1.5-flash"
|
||||||
artist_model_name = "models/gemini-1.5-flash"
|
artist_model_name = "models/gemini-1.5-flash"
|
||||||
|
pro_model_name = "models/gemini-2.0-pro-exp" # Best available Pro for critical rewrites (prefer free/exp)
|
||||||
image_model_name = None
|
image_model_name = None
|
||||||
image_model_source = "None"
|
image_model_source = "None"
|
||||||
|
|
||||||
|
|||||||
93
ai/setup.py
93
ai/setup.py
@@ -34,9 +34,10 @@ def get_optimal_model(base_type="pro"):
|
|||||||
|
|
||||||
def get_default_models():
|
def get_default_models():
|
||||||
return {
|
return {
|
||||||
"logic": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro for complex reasoning and JSON adherence.", "estimated_cost": "$0.00/1M (Experimental)"},
|
"logic": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro Exp (free) for cost-effective logic and JSON adherence.", "estimated_cost": "Free"},
|
||||||
"writer": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for fast, high-quality creative writing.", "estimated_cost": "$0.10/1M"},
|
"writer": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for fast, high-quality creative writing.", "estimated_cost": "$0.10/1M"},
|
||||||
"artist": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for visual prompt design.", "estimated_cost": "$0.10/1M"},
|
"artist": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for visual prompt design.", "estimated_cost": "$0.10/1M"},
|
||||||
|
"pro_rewrite": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro Exp (free) for critical chapter rewrites.", "estimated_cost": "Free"},
|
||||||
"ranking": []
|
"ranking": []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,37 +74,59 @@ def select_best_models(force_refresh=False):
|
|||||||
model = genai.GenerativeModel(bootstrapper)
|
model = genai.GenerativeModel(bootstrapper)
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
ROLE: AI Model Architect
|
ROLE: AI Model Architect
|
||||||
TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available.
|
TASK: Select the optimal Gemini models for a book-writing application.
|
||||||
|
PRIMARY OBJECTIVE: Keep total book generation cost under $2.00. Quality is secondary to this budget.
|
||||||
|
|
||||||
AVAILABLE_MODELS:
|
AVAILABLE_MODELS:
|
||||||
{json.dumps(compatible)}
|
{json.dumps(compatible)}
|
||||||
|
|
||||||
PRICING_CONTEXT (USD per 1M tokens, approximate):
|
PRICING_CONTEXT (USD per 1M tokens — use these to calculate actual book cost):
|
||||||
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing.
|
- FREE TIER: Any model with 'exp', 'beta', or 'preview' in name = $0.00. Always prefer these.
|
||||||
- Gemini 2.0 Flash: ~$0.10 Input / $0.40 Output. (Fast, cost-effective, excellent quality).
|
e.g. gemini-2.0-pro-exp = FREE, gemini-2.5-pro-preview = FREE.
|
||||||
- Gemini 2.0 Pro Exp: Free experimental tier with strong reasoning.
|
- gemini-2.5-flash / gemini-2.5-flash-preview: ~$0.075 Input / $0.30 Output.
|
||||||
- Gemini 1.5 Flash: ~$0.075 Input / $0.30 Output. (Legacy, still reliable).
|
- gemini-2.0-flash: ~$0.10 Input / $0.40 Output.
|
||||||
- Gemini 1.5 Pro: ~$1.25 Input / $5.00 Output. (Legacy, expensive).
|
- gemini-1.5-flash: ~$0.075 Input / $0.30 Output.
|
||||||
|
- gemini-2.5-pro (stable, non-preview): ~$1.25 Input / $10.00 Output. BUDGET BREAKER.
|
||||||
|
- gemini-1.5-pro (stable): ~$1.25 Input / $5.00 Output. BUDGET BREAKER.
|
||||||
|
|
||||||
CRITERIA:
|
BOOK TOKEN BUDGET (30-chapter novel — use this to calculate real cost before deciding):
|
||||||
- LOGIC: Needs complex reasoning, strict JSON adherence, plot consistency, and instruction following.
|
Logic role total: ~265,000 input tokens + ~55,000 output tokens
|
||||||
-> Prefer: Gemini 2.5 Pro > 2.0 Pro > 2.0 Flash > 1.5 Pro
|
(planning, state tracking, consistency checks, director treatments per chapter)
|
||||||
- WRITER: Needs creativity, prose quality, long-form text generation, and speed.
|
Writer role total: ~450,000 input tokens + ~135,000 output tokens
|
||||||
-> Prefer: Gemini 2.5 Flash/Pro > 2.0 Flash > 1.5 Flash (balance quality/cost)
|
(drafting, evaluation, refinement per chapter — 2 passes max)
|
||||||
- ARTIST: Needs rich visual description, prompt understanding for cover art design.
|
Artist role total: ~30,000 input tokens + ~8,000 output tokens
|
||||||
-> Prefer: Gemini 2.0 Flash > 1.5 Flash (speed and visual understanding)
|
(cover art prompt design, cover layout, blurb, image quality evaluation — text calls only)
|
||||||
|
|
||||||
CONSTRAINTS:
|
NOTE: Cover IMAGE generation uses the Imagen API (billed per image, not per token).
|
||||||
- Strongly prefer Gemini 2.x over 1.5 where available.
|
Imagen costs are fixed at ~$0.04/image × up to 3 attempts = ~$0.12 max. This is SEPARATE
|
||||||
- Avoid 'experimental' or 'preview' only if a stable 2.x version exists; otherwise experimental 2.x is fine.
|
from the text token budget below and cannot be reduced by model selection.
|
||||||
- 'thinking' models are too slow/expensive for Writer/Artist roles.
|
|
||||||
- Provide a ranking of ALL available models from best to worst overall.
|
COST FORMULA: cost = (input_tokens / 1,000,000 * input_price) + (output_tokens / 1,000,000 * output_price)
|
||||||
|
HARD BUDGET: Logic_cost + Writer_cost + Artist_cost (text only) must be < $1.85
|
||||||
|
(leaving $0.15 headroom for Imagen cover generation, total book target: $2.00).
|
||||||
|
|
||||||
|
SELECTION RULES (apply in order):
|
||||||
|
1. FREE FIRST: If a free/exp model exists (any tier, any quality), pick it for Logic. Cost = $0.
|
||||||
|
2. FLASH FOR WRITER: Flash is sufficient for fiction prose. Never pick a paid Pro for Writer.
|
||||||
|
3. CALCULATE: For non-free models, compute the actual book cost using the token budget above.
|
||||||
|
Reject any combination that exceeds $2.00 total.
|
||||||
|
4. QUALITY TIEBREAK: Among models with similar cost, prefer newer generation (2.x > 1.5).
|
||||||
|
5. NO THINKING MODELS: Too slow and expensive for any role.
|
||||||
|
|
||||||
|
ROLES:
|
||||||
|
- LOGIC: Planning, JSON adherence, plot consistency. Free/exp Pro ideal; Flash acceptable.
|
||||||
|
- WRITER: Creative prose, chapter drafting. Flash 2.x is sufficient — do NOT use paid Pro.
|
||||||
|
- ARTIST: Visual prompts for cover art. Cheapest capable Flash model.
|
||||||
|
- PRO_REWRITE: Emergency full-chapter rewrite (rare, ~1-2x per book). Best free/exp Pro available.
|
||||||
|
If no free Pro exists, use best Flash — do not use paid Pro even here.
|
||||||
|
|
||||||
OUTPUT_FORMAT (JSON only, no markdown):
|
OUTPUT_FORMAT (JSON only, no markdown):
|
||||||
{{
|
{{
|
||||||
"logic": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }},
|
"logic": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
|
||||||
"writer": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }},
|
"writer": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
|
||||||
"artist": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }},
|
"artist": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
|
||||||
|
"pro_rewrite": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
|
||||||
|
"total_estimated_book_cost": "$X.XX",
|
||||||
"ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ]
|
"ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ]
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
@@ -173,19 +196,27 @@ def init_models(force=False):
|
|||||||
if not force:
|
if not force:
|
||||||
missing_costs = False
|
missing_costs = False
|
||||||
for role in ['logic', 'writer', 'artist']:
|
for role in ['logic', 'writer', 'artist']:
|
||||||
if 'estimated_cost' not in selected_models.get(role, {}) or selected_models[role].get('estimated_cost') == 'N/A':
|
role_data = selected_models.get(role, {})
|
||||||
|
if 'estimated_cost' not in role_data or role_data.get('estimated_cost') == 'N/A':
|
||||||
missing_costs = True
|
missing_costs = True
|
||||||
|
if 'book_cost' not in role_data:
|
||||||
|
missing_costs = True
|
||||||
|
if 'total_estimated_book_cost' not in selected_models:
|
||||||
|
missing_costs = True
|
||||||
if missing_costs:
|
if missing_costs:
|
||||||
utils.log("SYSTEM", "⚠️ Missing cost info in cached models. Forcing refresh.")
|
utils.log("SYSTEM", "⚠️ Missing cost info in cached models. Forcing refresh.")
|
||||||
return init_models(force=True)
|
return init_models(force=True)
|
||||||
|
|
||||||
def get_model_details(role_data):
|
def get_model_details(role_data):
|
||||||
if isinstance(role_data, dict): return role_data.get('model'), role_data.get('estimated_cost', 'N/A')
|
if isinstance(role_data, dict):
|
||||||
return role_data, 'N/A'
|
return role_data.get('model'), role_data.get('estimated_cost', 'N/A'), role_data.get('book_cost', 'N/A')
|
||||||
|
return role_data, 'N/A', 'N/A'
|
||||||
|
|
||||||
logic_name, logic_cost = get_model_details(selected_models['logic'])
|
logic_name, logic_cost, logic_book = get_model_details(selected_models['logic'])
|
||||||
writer_name, writer_cost = get_model_details(selected_models['writer'])
|
writer_name, writer_cost, writer_book = get_model_details(selected_models['writer'])
|
||||||
artist_name, artist_cost = get_model_details(selected_models['artist'])
|
artist_name, artist_cost, artist_book = get_model_details(selected_models['artist'])
|
||||||
|
pro_name, pro_cost, _ = get_model_details(selected_models.get('pro_rewrite', {'model': 'models/gemini-2.0-pro-exp', 'estimated_cost': 'Free', 'book_cost': '$0.00'}))
|
||||||
|
total_book_cost = selected_models.get('total_estimated_book_cost', 'N/A')
|
||||||
|
|
||||||
logic_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
logic_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||||
writer_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
writer_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
||||||
@@ -194,8 +225,10 @@ def init_models(force=False):
|
|||||||
models.logic_model_name = logic_name
|
models.logic_model_name = logic_name
|
||||||
models.writer_model_name = writer_name
|
models.writer_model_name = writer_name
|
||||||
models.artist_model_name = artist_name
|
models.artist_model_name = artist_name
|
||||||
|
models.pro_model_name = pro_name
|
||||||
|
|
||||||
utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}) | Writer={writer_name} ({writer_cost}) | Artist={artist_name}")
|
utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}, {logic_book}/book) | Writer={writer_name} ({writer_cost}, {writer_book}/book) | Artist={artist_name} | Pro-Rewrite={pro_name} ({pro_cost})")
|
||||||
|
utils.log("SYSTEM", f"💰 Estimated book cost: {total_book_cost} text + ~$0.00-$0.12 Imagen cover (budget: $2.00 total)")
|
||||||
|
|
||||||
utils.update_pricing(logic_name, logic_cost)
|
utils.update_pricing(logic_name, logic_cost)
|
||||||
utils.update_pricing(writer_name, writer_cost)
|
utils.update_pricing(writer_name, writer_cost)
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ class BookWizard:
|
|||||||
while True:
|
while True:
|
||||||
self.clear()
|
self.clear()
|
||||||
personas = {}
|
personas = {}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
if os.path.exists(os.path.join(config.PERSONAS_DIR, "personas.json")):
|
||||||
try:
|
try:
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'r') as f: personas = json.load(f)
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]"))
|
console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]"))
|
||||||
@@ -120,7 +120,7 @@ class BookWizard:
|
|||||||
if sub == 2:
|
if sub == 2:
|
||||||
if Confirm.ask(f"Delete '{selected_key}'?", default=False):
|
if Confirm.ask(f"Delete '{selected_key}'?", default=False):
|
||||||
del personas[selected_key]
|
del personas[selected_key]
|
||||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'w') as f: json.dump(personas, f, indent=2)
|
||||||
continue
|
continue
|
||||||
elif sub == 3:
|
elif sub == 3:
|
||||||
continue
|
continue
|
||||||
@@ -145,7 +145,7 @@ class BookWizard:
|
|||||||
|
|
||||||
if Confirm.ask("Save Persona?", default=True):
|
if Confirm.ask("Save Persona?", default=True):
|
||||||
personas[selected_key] = details
|
personas[selected_key] = details
|
||||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'w') as f: json.dump(personas, f, indent=2)
|
||||||
|
|
||||||
def select_mode(self):
|
def select_mode(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -322,9 +322,9 @@ class BookWizard:
|
|||||||
console.print("\n[bold blue]Project Details[/bold blue]")
|
console.print("\n[bold blue]Project Details[/bold blue]")
|
||||||
|
|
||||||
personas = {}
|
personas = {}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
if os.path.exists(os.path.join(config.PERSONAS_DIR, "personas.json")):
|
||||||
try:
|
try:
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'r') as f: personas = json.load(f)
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
author_details = {}
|
author_details = {}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ if not API_KEY: raise ValueError("CRITICAL ERROR: GEMINI_API_KEY not found in en
|
|||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
||||||
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
|
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
|
||||||
PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json")
|
# PERSONAS_FILE is deprecated — persona data is now stored in the Persona DB table.
|
||||||
|
# PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json")
|
||||||
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
|
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
|
||||||
|
|
||||||
# --- ENSURE DIRECTORIES EXIST ---
|
# --- ENSURE DIRECTORIES EXIST ---
|
||||||
|
|||||||
@@ -129,11 +129,8 @@ def load_json(path):
|
|||||||
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
||||||
|
|
||||||
def create_default_personas():
|
def create_default_personas():
|
||||||
|
# Persona data is now stored in the Persona DB table; ensure the directory exists for sample files.
|
||||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||||
if not os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump({}, f, indent=2)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
def get_length_presets():
|
def get_length_presets():
|
||||||
presets = {}
|
presets = {}
|
||||||
|
|||||||
@@ -157,6 +157,21 @@ def harvest_metadata(bp, folder, full_manuscript):
|
|||||||
return bp
|
return bp
|
||||||
|
|
||||||
|
|
||||||
|
def get_chapter_neighbours(manuscript, current_num):
|
||||||
|
"""Return (prev_num, next_num) chapter numbers adjacent to current_num.
|
||||||
|
|
||||||
|
manuscript: list of chapter dicts each with a 'num' key.
|
||||||
|
Returns None for prev/next when at the boundary.
|
||||||
|
"""
|
||||||
|
nums = sorted({ch.get('num') for ch in manuscript if ch.get('num') is not None})
|
||||||
|
if current_num not in nums:
|
||||||
|
return None, None
|
||||||
|
idx = nums.index(current_num)
|
||||||
|
prev_num = nums[idx - 1] if idx > 0 else None
|
||||||
|
next_num = nums[idx + 1] if idx < len(nums) - 1 else None
|
||||||
|
return prev_num, next_num
|
||||||
|
|
||||||
|
|
||||||
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"""
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ def _empty_state():
|
|||||||
return {"active_threads": [], "immediate_handoff": "", "resolved_threads": [], "chapter": 0}
|
return {"active_threads": [], "immediate_handoff": "", "resolved_threads": [], "chapter": 0}
|
||||||
|
|
||||||
|
|
||||||
def load_story_state(folder):
|
def load_story_state(folder, project_id=None):
|
||||||
"""Load structured story state from story_state.json, or return empty state."""
|
"""Load structured story state from DB (if project_id given) or story_state.json fallback."""
|
||||||
|
if project_id is not None:
|
||||||
|
try:
|
||||||
|
from web.db import StoryState
|
||||||
|
record = StoryState.query.filter_by(project_id=project_id).first()
|
||||||
|
if record and record.state_json:
|
||||||
|
return json.loads(record.state_json) or _empty_state()
|
||||||
|
except Exception:
|
||||||
|
pass # Fall through to file-based load if DB unavailable (e.g. CLI context)
|
||||||
|
|
||||||
path = os.path.join(folder, "story_state.json")
|
path = os.path.join(folder, "story_state.json")
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return utils.load_json(path) or _empty_state()
|
return utils.load_json(path) or _empty_state()
|
||||||
return _empty_state()
|
return _empty_state()
|
||||||
|
|
||||||
|
|
||||||
def update_story_state(chapter_text, chapter_num, current_state, folder):
|
def update_story_state(chapter_text, chapter_num, current_state, folder, project_id=None):
|
||||||
"""Use model_logic to extract structured story threads from the new chapter
|
"""Use model_logic to extract structured story threads from the new chapter
|
||||||
and save the updated state to story_state.json. Returns the new state."""
|
and save the updated state to the StoryState DB table and/or story_state.json.
|
||||||
|
Returns the new state."""
|
||||||
utils.log("STATE", f"Updating story state after Ch {chapter_num}...")
|
utils.log("STATE", f"Updating story state after Ch {chapter_num}...")
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
ROLE: Story State Tracker
|
ROLE: Story State Tracker
|
||||||
@@ -54,9 +64,28 @@ def update_story_state(chapter_text, chapter_num, current_state, folder):
|
|||||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||||
new_state = json.loads(utils.clean_json(response.text))
|
new_state = json.loads(utils.clean_json(response.text))
|
||||||
new_state['chapter'] = chapter_num
|
new_state['chapter'] = chapter_num
|
||||||
|
|
||||||
|
# Write to DB if project_id is available
|
||||||
|
if project_id is not None:
|
||||||
|
try:
|
||||||
|
from web.db import db, StoryState
|
||||||
|
from datetime import datetime
|
||||||
|
record = StoryState.query.filter_by(project_id=project_id).first()
|
||||||
|
if record:
|
||||||
|
record.state_json = json.dumps(new_state)
|
||||||
|
record.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
record = StoryState(project_id=project_id, state_json=json.dumps(new_state))
|
||||||
|
db.session.add(record)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as db_err:
|
||||||
|
utils.log("STATE", f" -> DB write failed: {db_err}. Falling back to file.")
|
||||||
|
|
||||||
|
# Always write to file for backward compat with CLI
|
||||||
path = os.path.join(folder, "story_state.json")
|
path = os.path.join(folder, "story_state.json")
|
||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
json.dump(new_state, f, indent=2)
|
json.dump(new_state, f, indent=2)
|
||||||
|
|
||||||
utils.log("STATE", f" -> Story state saved. Active threads: {len(new_state.get('active_threads', []))}")
|
utils.log("STATE", f" -> Story state saved. Active threads: {len(new_state.get('active_threads', []))}")
|
||||||
return new_state
|
return new_state
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -157,10 +157,12 @@ def update_persona_sample(bp, folder):
|
|||||||
|
|
||||||
author_name = meta.get('author', 'Unknown Author')
|
author_name = meta.get('author', 'Unknown Author')
|
||||||
|
|
||||||
|
# Use a local file mirror for the engine context (runs outside Flask app context)
|
||||||
|
_personas_file = os.path.join(config.PERSONAS_DIR, "personas.json")
|
||||||
personas = {}
|
personas = {}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
if os.path.exists(_personas_file):
|
||||||
try:
|
try:
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
with open(_personas_file, 'r') as f: personas = json.load(f)
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
if author_name not in personas:
|
if author_name not in personas:
|
||||||
@@ -189,4 +191,4 @@ def update_persona_sample(bp, folder):
|
|||||||
if filename not in personas[author_name]['sample_files']:
|
if filename not in personas[author_name]['sample_files']:
|
||||||
personas[author_name]['sample_files'].append(filename)
|
personas[author_name]['sample_files'].append(filename)
|
||||||
|
|
||||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
with open(_personas_file, 'w') as f: json.dump(personas, f, indent=2)
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
|||||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||||
|
|
||||||
max_attempts = 5
|
max_attempts = 2
|
||||||
SCORE_AUTO_ACCEPT = 8
|
SCORE_AUTO_ACCEPT = 8
|
||||||
SCORE_PASSING = 7
|
SCORE_PASSING = 7
|
||||||
SCORE_REWRITE_THRESHOLD = 6
|
SCORE_REWRITE_THRESHOLD = 6
|
||||||
@@ -378,11 +378,15 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_pro = getattr(ai_models, 'pro_model_name', 'models/gemini-2.0-pro-exp')
|
||||||
|
ai_models.model_logic.update(_pro)
|
||||||
resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt)
|
resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt)
|
||||||
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
|
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
|
||||||
current_text = resp_rewrite.text
|
current_text = resp_rewrite.text
|
||||||
|
ai_models.model_logic.update(ai_models.logic_model_name)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
ai_models.model_logic.update(ai_models.logic_model_name)
|
||||||
utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.")
|
utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.")
|
||||||
|
|
||||||
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
||||||
|
|||||||
@@ -48,6 +48,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter Navigation Footer -->
|
||||||
|
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-2">
|
||||||
|
{% if not loop.first %}
|
||||||
|
{% set prev_ch = manuscript[loop.index0 - 1] %}
|
||||||
|
<a href="#ch-{{ prev_ch.num }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-up me-1"></i>Ch {{ prev_ch.num }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span></span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="#" class="btn btn-sm btn-link text-muted small py-0">Back to Top</a>
|
||||||
|
{% if not loop.last %}
|
||||||
|
{% set next_ch = manuscript[loop.index0 + 1] %}
|
||||||
|
<a href="#ch-{{ next_ch.num }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
Ch {{ next_ch.num }}<i class="fas fa-arrow-down ms-1"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small fst-italic">End of Book</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Rewrite Modal -->
|
<!-- Rewrite Modal -->
|
||||||
<div class="modal fade" id="rewriteModal{{ ch.num|string|replace(' ', '') }}" tabindex="-1">
|
<div class="modal fade" id="rewriteModal{{ ch.num|string|replace(' ', '') }}" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
@@ -10,10 +10,21 @@
|
|||||||
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
||||||
<i class="fas fa-scroll me-2"></i>Show Bible
|
<i class="fas fa-scroll me-2"></i>Show Bible
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{{ url_for('run.download_bible', id=run.id) }}" class="btn btn-outline-info me-2" title="Download the project bible (JSON) used for this run.">
|
||||||
|
<i class="fas fa-file-download me-2"></i>Download Bible
|
||||||
|
</a>
|
||||||
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
|
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
|
||||||
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
|
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
{% if run.status not in ['running', 'queued'] %}
|
||||||
|
<form action="{{ url_for('run.delete_run', id=run.id) }}" method="POST" class="d-inline ms-2"
|
||||||
|
onsubmit="return confirm('Delete Run #{{ run.id }} and all its files? This cannot be undone.');">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">
|
||||||
|
<i class="fas fa-trash me-2"></i>Delete Run
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary ms-2">Back to Project</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,6 +108,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="mb-3 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
{% if run.tags %}
|
||||||
|
{% for tag in run.tags.split(',') %}
|
||||||
|
<span class="badge bg-secondary fs-6">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small fst-italic">No tags</span>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#tagsForm">
|
||||||
|
<i class="fas fa-tag me-1"></i>Edit Tags
|
||||||
|
</button>
|
||||||
|
<div class="collapse w-100" id="tagsForm">
|
||||||
|
<form action="{{ url_for('run.set_tags', id=run.id) }}" method="POST" class="d-flex gap-2 mt-1">
|
||||||
|
<input type="text" name="tags" class="form-control form-control-sm"
|
||||||
|
value="{{ run.tags or '' }}"
|
||||||
|
placeholder="comma-separated tags, e.g. dark-ending, v2, favourite">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Status Bar -->
|
<!-- Status Bar -->
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ with app.app_context():
|
|||||||
_log("System: Added 'last_heartbeat' column to Run table.")
|
_log("System: Added 'last_heartbeat' column to Run table.")
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
# Migration: Add 'tags' column if missing
|
||||||
|
try:
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE run ADD COLUMN tags VARCHAR(300)"))
|
||||||
|
conn.commit()
|
||||||
|
_log("System: Added 'tags' column to Run table.")
|
||||||
|
except: pass
|
||||||
|
|
||||||
# Reset all non-terminal runs on startup (running, queued, interrupted)
|
# Reset all non-terminal runs on startup (running, queued, interrupted)
|
||||||
# The Huey consumer restarts with the app, so any in-flight tasks are gone.
|
# The Huey consumer restarts with the app, so any in-flight tasks are gone.
|
||||||
try:
|
try:
|
||||||
|
|||||||
15
web/db.py
15
web/db.py
@@ -35,6 +35,8 @@ class Run(db.Model):
|
|||||||
progress = db.Column(db.Integer, default=0)
|
progress = db.Column(db.Integer, default=0)
|
||||||
last_heartbeat = db.Column(db.DateTime, nullable=True)
|
last_heartbeat = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
tags = db.Column(db.String(300), nullable=True)
|
||||||
|
|
||||||
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
def duration(self):
|
def duration(self):
|
||||||
@@ -49,3 +51,16 @@ class LogEntry(db.Model):
|
|||||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
phase = db.Column(db.String(50))
|
phase = db.Column(db.String(50))
|
||||||
message = db.Column(db.Text)
|
message = db.Column(db.Text)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryState(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||||
|
state_json = db.Column(db.Text, nullable=True)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Persona(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||||
|
details_json = db.Column(db.Text, nullable=True)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||||
from flask_login import login_required, login_user, current_user
|
from flask_login import login_required, login_user, current_user
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from web.db import db, User, Project, Run
|
from web.db import db, User, Project, Run, Persona
|
||||||
from web.helpers import admin_required
|
from web.helpers import admin_required
|
||||||
from core import config, utils
|
from core import config, utils
|
||||||
from ai import models as ai_models
|
from ai import models as ai_models
|
||||||
@@ -83,10 +83,7 @@ def admin_factory_reset():
|
|||||||
except: pass
|
except: pass
|
||||||
db.session.delete(u)
|
db.session.delete(u)
|
||||||
|
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
Persona.query.delete()
|
||||||
try: os.remove(config.PERSONAS_FILE)
|
|
||||||
except: pass
|
|
||||||
utils.create_default_personas()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Factory Reset Complete. All other users and projects have been wiped.")
|
flash("Factory Reset Complete. All other users and projects have been wiped.")
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import os
|
|
||||||
import json
|
import json
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from core import config, utils
|
from core import utils
|
||||||
from ai import models as ai_models
|
from ai import models as ai_models
|
||||||
from ai import setup as ai_setup
|
from ai import setup as ai_setup
|
||||||
|
from web.db import db, Persona
|
||||||
|
|
||||||
persona_bp = Blueprint('persona', __name__)
|
persona_bp = Blueprint('persona', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_personas_dict():
|
||||||
|
"""Return all personas as a dict keyed by name, matching the old personas.json structure."""
|
||||||
|
records = Persona.query.all()
|
||||||
|
result = {}
|
||||||
|
for rec in records:
|
||||||
|
try:
|
||||||
|
details = json.loads(rec.details_json) if rec.details_json else {}
|
||||||
|
except Exception:
|
||||||
|
details = {}
|
||||||
|
result[rec.name] = details
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@persona_bp.route('/personas')
|
@persona_bp.route('/personas')
|
||||||
@login_required
|
@login_required
|
||||||
def list_personas():
|
def list_personas():
|
||||||
personas = {}
|
personas = _all_personas_dict()
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
return render_template('personas.html', personas=personas)
|
return render_template('personas.html', personas=personas)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,17 +38,16 @@ def new_persona():
|
|||||||
@persona_bp.route('/persona/<string:name>')
|
@persona_bp.route('/persona/<string:name>')
|
||||||
@login_required
|
@login_required
|
||||||
def edit_persona(name):
|
def edit_persona(name):
|
||||||
personas = {}
|
record = Persona.query.filter_by(name=name).first()
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
if not record:
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
persona = personas.get(name)
|
|
||||||
if not persona:
|
|
||||||
flash(f"Persona '{name}' not found.")
|
flash(f"Persona '{name}' not found.")
|
||||||
return redirect(url_for('persona.list_personas'))
|
return redirect(url_for('persona.list_personas'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
persona = json.loads(record.details_json) if record.details_json else {}
|
||||||
|
except Exception:
|
||||||
|
persona = {}
|
||||||
|
|
||||||
return render_template('persona_edit.html', persona=persona, name=name)
|
return render_template('persona_edit.html', persona=persona, name=name)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,16 +61,7 @@ def save_persona():
|
|||||||
flash("Persona name is required.")
|
flash("Persona name is required.")
|
||||||
return redirect(url_for('persona.list_personas'))
|
return redirect(url_for('persona.list_personas'))
|
||||||
|
|
||||||
personas = {}
|
persona_data = {
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
if old_name and old_name != name and old_name in personas:
|
|
||||||
del personas[old_name]
|
|
||||||
|
|
||||||
persona = {
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"bio": request.form.get('bio'),
|
"bio": request.form.get('bio'),
|
||||||
"age": request.form.get('age'),
|
"age": request.form.get('age'),
|
||||||
@@ -75,10 +74,21 @@ def save_persona():
|
|||||||
"style_inspirations": request.form.get('style_inspirations')
|
"style_inspirations": request.form.get('style_inspirations')
|
||||||
}
|
}
|
||||||
|
|
||||||
personas[name] = persona
|
# If name changed, remove old record
|
||||||
|
if old_name and old_name != name:
|
||||||
|
old_record = Persona.query.filter_by(name=old_name).first()
|
||||||
|
if old_record:
|
||||||
|
db.session.delete(old_record)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
record = Persona.query.filter_by(name=name).first()
|
||||||
|
if record:
|
||||||
|
record.details_json = json.dumps(persona_data)
|
||||||
|
else:
|
||||||
|
record = Persona(name=name, details_json=json.dumps(persona_data))
|
||||||
|
db.session.add(record)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
flash(f"Persona '{name}' saved.")
|
flash(f"Persona '{name}' saved.")
|
||||||
return redirect(url_for('persona.list_personas'))
|
return redirect(url_for('persona.list_personas'))
|
||||||
|
|
||||||
@@ -86,15 +96,10 @@ def save_persona():
|
|||||||
@persona_bp.route('/persona/delete/<string:name>', methods=['POST'])
|
@persona_bp.route('/persona/delete/<string:name>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_persona(name):
|
def delete_persona(name):
|
||||||
personas = {}
|
record = Persona.query.filter_by(name=name).first()
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
if record:
|
||||||
try:
|
db.session.delete(record)
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
db.session.commit()
|
||||||
except: pass
|
|
||||||
|
|
||||||
if name in personas:
|
|
||||||
del personas[name]
|
|
||||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
|
||||||
flash(f"Persona '{name}' deleted.")
|
flash(f"Persona '{name}' deleted.")
|
||||||
|
|
||||||
return redirect(url_for('persona.list_personas'))
|
return redirect(url_for('persona.list_personas'))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import shutil
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from web.db import db, Project, Run
|
from web.db import db, Project, Run, Persona
|
||||||
from web.helpers import is_project_locked
|
from web.helpers import is_project_locked
|
||||||
from core import config, utils
|
from core import config, utils
|
||||||
from ai import models as ai_models
|
from ai import models as ai_models
|
||||||
@@ -104,11 +104,7 @@ def project_setup_wizard():
|
|||||||
flash(f"AI Analysis failed — fill in the details manually. ({e})", "warning")
|
flash(f"AI Analysis failed — fill in the details manually. ({e})", "warning")
|
||||||
suggestions = _default_suggestions
|
suggestions = _default_suggestions
|
||||||
|
|
||||||
personas = {}
|
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
||||||
|
|
||||||
@@ -149,11 +145,7 @@ def project_setup_refine():
|
|||||||
flash(f"Refinement failed: {e}")
|
flash(f"Refinement failed: {e}")
|
||||||
return redirect(url_for('project.index'))
|
return redirect(url_for('project.index'))
|
||||||
|
|
||||||
personas = {}
|
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
||||||
|
|
||||||
@@ -329,11 +321,7 @@ def view_project(id):
|
|||||||
has_draft = os.path.exists(draft_path)
|
has_draft = os.path.exists(draft_path)
|
||||||
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
||||||
|
|
||||||
personas = {}
|
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
|
runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
|
||||||
latest_run = runs[0] if runs else None
|
latest_run = runs[0] if runs else None
|
||||||
@@ -730,11 +718,7 @@ def set_project_persona(id):
|
|||||||
bible = utils.load_json(bible_path)
|
bible = utils.load_json(bible_path)
|
||||||
|
|
||||||
if bible:
|
if bible:
|
||||||
personas = {}
|
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
|
||||||
try:
|
|
||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
if persona_name in personas:
|
if persona_name in personas:
|
||||||
bible['project_metadata']['author_details'] = personas[persona_name]
|
bible['project_metadata']['author_details'] = personas[persona_name]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
import markdown
|
import markdown
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
|
||||||
@@ -393,6 +394,67 @@ def revise_book(run_id, book_folder):
|
|||||||
return redirect(url_for('run.view_run', id=new_run.id))
|
return redirect(url_for('run.view_run', id=new_run.id))
|
||||||
|
|
||||||
|
|
||||||
|
@run_bp.route('/run/<int:id>/set_tags', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def set_tags(id):
|
||||||
|
run = db.session.get(Run, id)
|
||||||
|
if not run: return "Run not found", 404
|
||||||
|
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||||
|
|
||||||
|
raw = request.form.get('tags', '')
|
||||||
|
tags = [t.strip() for t in raw.split(',') if t.strip()]
|
||||||
|
run.tags = ','.join(dict.fromkeys(tags))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Tags updated.")
|
||||||
|
return redirect(url_for('run.view_run', id=id))
|
||||||
|
|
||||||
|
|
||||||
|
@run_bp.route('/run/<int:id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_run(id):
|
||||||
|
run = db.session.get(Run, id)
|
||||||
|
if not run: return "Run not found", 404
|
||||||
|
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||||
|
|
||||||
|
if run.status in ['running', 'queued']:
|
||||||
|
flash("Cannot delete an active run. Stop it first.")
|
||||||
|
return redirect(url_for('run.view_run', id=id))
|
||||||
|
|
||||||
|
project_id = run.project_id
|
||||||
|
|
||||||
|
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||||
|
if os.path.exists(run_dir):
|
||||||
|
shutil.rmtree(run_dir)
|
||||||
|
|
||||||
|
db.session.delete(run)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"Run #{id} deleted successfully.")
|
||||||
|
return redirect(url_for('project.view_project', id=project_id))
|
||||||
|
|
||||||
|
|
||||||
|
@run_bp.route('/run/<int:id>/download_bible')
|
||||||
|
@login_required
|
||||||
|
def download_bible(id):
|
||||||
|
run = db.session.get(Run, id)
|
||||||
|
if not run: return "Run not found", 404
|
||||||
|
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||||
|
|
||||||
|
bible_path = os.path.join(run.project.folder_path, "bible.json")
|
||||||
|
if not os.path.exists(bible_path):
|
||||||
|
return "Bible file not found", 404
|
||||||
|
|
||||||
|
safe_name = utils.sanitize_filename(run.project.name or "project")
|
||||||
|
download_name = f"bible_{safe_name}.json"
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.dirname(bible_path),
|
||||||
|
os.path.basename(bible_path),
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=download_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST'])
|
@run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def regenerate_artifacts(run_id):
|
def regenerate_artifacts(run_id):
|
||||||
|
|||||||
50
web/tasks.py
50
web/tasks.py
@@ -107,6 +107,56 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
|
|
||||||
_task_log(f"Task picked up by Huey worker. project_path={project_path}")
|
_task_log(f"Task picked up by Huey worker. project_path={project_path}")
|
||||||
|
|
||||||
|
# 0. Orphaned Job Guard — verify that all required resources exist before
|
||||||
|
# doing any work. If a run, project folder, or bible is missing, terminate
|
||||||
|
# silently and mark the run as failed to prevent data being written to the
|
||||||
|
# wrong book or project.
|
||||||
|
db_path_early = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(db_path_early, timeout=10) as _conn:
|
||||||
|
_row = _conn.execute("SELECT id FROM run WHERE id = ?", (run_id,)).fetchone()
|
||||||
|
if not _row:
|
||||||
|
_task_log(f"ABORT: Run #{run_id} no longer exists in DB. Terminating silently.")
|
||||||
|
return
|
||||||
|
except Exception as _e:
|
||||||
|
_task_log(f"WARNING: Could not verify run #{run_id} existence: {_e}")
|
||||||
|
|
||||||
|
if not os.path.isdir(project_path):
|
||||||
|
_task_log(f"ABORT: Project folder missing ({project_path}). Marking run #{run_id} as failed.")
|
||||||
|
try:
|
||||||
|
_robust_update_run_status(db_path_early, run_id, 'failed',
|
||||||
|
end_time=datetime.utcnow().isoformat())
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.isfile(bible_path):
|
||||||
|
_task_log(f"ABORT: Bible file missing ({bible_path}). Marking run #{run_id} as failed.")
|
||||||
|
try:
|
||||||
|
_robust_update_run_status(db_path_early, run_id, 'failed',
|
||||||
|
end_time=datetime.utcnow().isoformat())
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate that the bible has at least one book entry
|
||||||
|
try:
|
||||||
|
with open(bible_path, 'r', encoding='utf-8') as _bf:
|
||||||
|
_bible_check = json.load(_bf)
|
||||||
|
if not _bible_check.get('books'):
|
||||||
|
_task_log(f"ABORT: Bible has no books defined. Marking run #{run_id} as failed.")
|
||||||
|
try:
|
||||||
|
_robust_update_run_status(db_path_early, run_id, 'failed',
|
||||||
|
end_time=datetime.utcnow().isoformat())
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
except Exception as _e:
|
||||||
|
_task_log(f"ABORT: Could not parse bible ({bible_path}): {_e}. Marking run #{run_id} as failed.")
|
||||||
|
try:
|
||||||
|
_robust_update_run_status(db_path_early, run_id, 'failed',
|
||||||
|
end_time=datetime.utcnow().isoformat())
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
|
||||||
# 1. Setup Logging
|
# 1. Setup Logging
|
||||||
log_filename = f"system_log_{run_id}.txt"
|
log_filename = f"system_log_{run_id}.txt"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user