Compare commits
79 Commits
403d015dbe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f869700070 | |||
| d2c65f010a | |||
| dc39930da4 | |||
| ff5093a5f9 | |||
| 3a42d1a339 | |||
| 4f2449f79b | |||
| 2100ca2312 | |||
| 6684ec2bf5 | |||
| f740174257 | |||
| d77ceb376d | |||
| 3ba648ac5f | |||
| 6f19808f15 | |||
| f1d7fcbcb7 | |||
| c3724a6761 | |||
| 74cc66eed3 | |||
| 353dc859d2 | |||
| 51b98c9399 | |||
| b4058f9f1f | |||
| 093e78a89e | |||
| bcba67a35f | |||
| 98a330c416 | |||
| af2050160e | |||
| 203d74f61d | |||
| ba56bc1ec1 | |||
| 81340a18ea | |||
| 97efd51fd5 | |||
| 4e39e18dfe | |||
| 87f24d2bd8 | |||
| 493435e43c | |||
| 0d4b9b761b | |||
| a324355cdf | |||
| 1f01fedf00 | |||
| c2d6936aa5 | |||
| a24d2809f3 | |||
| 1f799227d9 | |||
| 85f1290f02 | |||
| d75186cb29 | |||
| 83a6a4315b | |||
| 2db7a35a66 | |||
| b1bce1eb55 | |||
| b37c503da4 | |||
| a08af59164 | |||
| 41f5719974 | |||
| 0667c31413 | |||
| f71a04c03c | |||
| fd4ce634d4 | |||
| 28a1308fbc | |||
| db70ad81f7 | |||
| f04a241936 | |||
| d797278413 | |||
| 583fc6f8d7 | |||
| 81353cf071 | |||
| f7099cc3e4 | |||
| edabc4d4fa | |||
| 958a6d0ea0 | |||
| 1964c9c2a5 | |||
| 2a9a605800 | |||
| 5e0def99c1 | |||
| 442406628a | |||
| 0ce071a5f0 | |||
| 7fdc2ea3de | |||
| 848d187f4b | |||
| 7e5dbe6f00 | |||
| e6110a6a54 | |||
|
|
92336e4f29 | ||
|
|
1cd62a75c9 | ||
|
|
346dbe3f64 | ||
|
|
dbc5878fe2 | ||
|
|
fdad92047b | ||
|
|
bfb694eabe | ||
|
|
df7cee9524 | ||
|
|
3a80307cc2 | ||
| ca221f0fb3 | |||
| 48dca539cd | |||
| 16db4e7d24 | |||
| 786d0bad6d | |||
| 9f8f094564 | |||
| 6e7ff0ae1d | |||
| c2e7ed01b4 |
25
.gitignore
vendored
25
.gitignore
vendored
@@ -6,3 +6,28 @@ run_*/
|
||||
data/
|
||||
token.json
|
||||
credentials.json
|
||||
|
||||
# AI Blueprint and Context Files
|
||||
ai_blueprint.md
|
||||
plans/
|
||||
|
||||
# Claude / Anthropic Artifacts
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
claude.json
|
||||
|
||||
# Gemini / Google Artifacts
|
||||
.gemini/
|
||||
gemini_history.json
|
||||
|
||||
# AI Coding Assistant Directories (Roo Code, Cline, Cursor, Windsurf)
|
||||
.roo/
|
||||
.cline/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
.cascade/
|
||||
|
||||
# AI Generated Index and Memory Cache Files
|
||||
*.aiindex
|
||||
ai_workspace_index.json
|
||||
12
Dockerfile
12
Dockerfile
@@ -3,20 +3,22 @@ FROM python:3.11-slim
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies required for Pillow (image processing)
|
||||
# Install system dependencies required for Pillow (image processing) and fonts
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
libpng-dev \
|
||||
libfreetype6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements files
|
||||
COPY requirements.txt .
|
||||
COPY modules/requirements_web.txt ./modules/
|
||||
COPY web/requirements_web.txt ./web/
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir -r modules/requirements_web.txt
|
||||
RUN pip install --no-cache-dir -r web/requirements_web.txt
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
@@ -24,4 +26,6 @@ COPY . .
|
||||
# Set Python path and run
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 5000
|
||||
CMD ["python", "-m", "modules.web_app"]
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/login')" || exit 1
|
||||
CMD ["python", "-m", "web.app"]
|
||||
421
README.md
421
README.md
@@ -1,141 +1,350 @@
|
||||
# 📚 BookApp: AI-Powered Series Engine
|
||||
# BookApp: AI-Powered Series Engine
|
||||
|
||||
An automated pipeline for planning, drafting, and publishing novels using Google Gemini.
|
||||
An automated pipeline for planning, drafting, and publishing novels using Google Gemini. Supports both a browser-based Web UI and an interactive CLI Wizard.
|
||||
|
||||
## 🚀 Quick Start
|
||||
1. **Install:** `pip install -r requirements.txt`
|
||||
2. **Web Dependencies:** `pip install -r modules/requirements_web.txt`
|
||||
3. **Setup:** Add your API key to `.env`.
|
||||
4. **Launch Dashboard:** `python -m modules.web_app`
|
||||
5. **Open Browser:** Go to `http://localhost:5000` to create projects, manage personas, and generate books.
|
||||
## Quick Start
|
||||
|
||||
### Alternative: CLI Mode
|
||||
If you prefer the command line:
|
||||
1. Run `python wizard.py` to create or edit your book settings interactively.
|
||||
2. Run `python main.py <path_to_bible.json>` to generate the book(s).
|
||||
1. **Install core dependencies:** `pip install -r requirements.txt`
|
||||
2. **Install web dependencies:** `pip install -r web/requirements_web.txt`
|
||||
3. **Configure:** Copy `.env.example` to `.env` and add your `GEMINI_API_KEY`.
|
||||
4. **Launch:** `python -m web.app`
|
||||
5. **Open:** `http://localhost:5000`
|
||||
|
||||
## 🛡️ Admin Access
|
||||
The application includes a protected Admin Dashboard at `/admin` for managing users and performing factory resets. Access is password-protected and restricted to users with the Admin role.
|
||||
### CLI Mode (No Browser)
|
||||
|
||||
1. **Register:** Create a normal account via the Web UI (`/register`).
|
||||
2. **Promote:** Run the included script to promote your user to Admin:
|
||||
```bash
|
||||
python make_admin.py <your_username>
|
||||
python -m cli.wizard
|
||||
```
|
||||
3. **Access:** Log in and click the "Admin" link in the navigation bar.
|
||||
|
||||
## <20> Docker Setup (Recommended for Raspberry Pi)
|
||||
This is the best way to run the Web Dashboard on a server using Portainer.
|
||||
The wizard guides you through creating or loading a project, defining characters and plot beats, and launching a generation run directly from the terminal. It auto-detects incomplete runs and offers to resume them.
|
||||
|
||||
## Admin Access
|
||||
|
||||
The `/admin` panel allows managing users and performing factory resets. It is restricted to accounts with the Admin role.
|
||||
|
||||
**Via environment variables (recommended for Docker):** Set `ADMIN_USERNAME` and `ADMIN_PASSWORD` — the account is auto-created on startup.
|
||||
|
||||
**Via manual promotion:** Register a normal account, then set `is_admin = 1` in the database.
|
||||
|
||||
## Docker Setup (Recommended for Servers)
|
||||
|
||||
### 1. Git Setup
|
||||
1. Create a new Git repository (GitHub/GitLab).
|
||||
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.
|
||||
|
||||
### 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.
|
||||
Push this project to a Git repository (GitHub, GitLab, or a self-hosted Gitea). Ensure `.env`, `token.json`, `credentials.json`, and `data/` are in `.gitignore`.
|
||||
|
||||
1. **Authenticate Locally:** Run the app on your PC first (`python wizard.py`) to generate the `token.json` file (Google Login).
|
||||
2. **SSH into your server** and create a folder for your app data:
|
||||
### 2. Server Preparation (One-Time)
|
||||
|
||||
Place secrets on the server manually — they must not be in Git.
|
||||
|
||||
1. Run `python -m cli.wizard` locally to generate `token.json` (Google OAuth).
|
||||
2. SSH into your server and create a data folder:
|
||||
```bash
|
||||
mkdir -p /opt/bookapp # Or any other path you prefer
|
||||
mkdir -p /opt/bookapp
|
||||
```
|
||||
3. **Upload Files:** Use WinSCP or SCP to upload these two files from your PC to the folder you just created (e.g., `/opt/bookapp`):
|
||||
- `token.json` (Generated in step 1)
|
||||
- `credentials.json` (Your Google Cloud OAuth file)
|
||||
The `data` subfolder, which stores your database and projects, will be created automatically by Docker when the container starts.
|
||||
3. Upload `token.json` and `credentials.json` to `/opt/bookapp`. The `data/` subfolder is created automatically on first run.
|
||||
|
||||
### 3. Portainer Stack Setup
|
||||
1. Log in to **Portainer**.
|
||||
2. Go to **Stacks** > **Add stack**.
|
||||
3. Select **Repository**.
|
||||
- **Repository URL:** `<your-git-repo-url>`
|
||||
- **Compose path:** `docker-compose.yml`
|
||||
4. Under **Environment variables**, add the following:
|
||||
- `HOST_PATH`: `/opt/bookapp` (The folder you created in Step 2)
|
||||
- `GEMINI_API_KEY`: `<your-api-key>`
|
||||
- `ADMIN_PASSWORD`: `<secure-password-for-web-ui>`
|
||||
- `FLASK_SECRET_KEY`: `<random-string>`
|
||||
|
||||
1. Go to **Stacks** > **Add stack** > **Repository**.
|
||||
2. Set **Repository URL** and **Compose path** (`docker-compose.yml`).
|
||||
3. Enable **Authentication** and supply a Gitea Personal Access Token if your repo is private.
|
||||
4. Add **Environment variables**:
|
||||
|
||||
| Variable | Description |
|
||||
| :--- | :--- |
|
||||
| `HOST_PATH` | Server folder for persistent data (e.g., `/opt/bookapp`) |
|
||||
| `GEMINI_API_KEY` | Your Google Gemini API key (**required**) |
|
||||
| `ADMIN_USERNAME` | Admin account username |
|
||||
| `ADMIN_PASSWORD` | Admin account password |
|
||||
| `FLASK_SECRET_KEY` | Random string for session encryption |
|
||||
| `FLASK_DEBUG` | `False` in production |
|
||||
| `GCP_PROJECT` | Google Cloud Project ID (required for Imagen / Vertex AI) |
|
||||
| `GCP_LOCATION` | GCP region (default: `us-central1`) |
|
||||
| `MODEL_LOGIC` | Override the reasoning model (e.g., `models/gemini-1.5-pro-latest`) |
|
||||
| `MODEL_WRITER` | Override the writing model |
|
||||
| `MODEL_ARTIST` | Override the visual-prompt model |
|
||||
| `MODEL_IMAGE` | Override the image generation model |
|
||||
|
||||
5. Click **Deploy the stack**.
|
||||
|
||||
Portainer will pull the code from Git, build the image, and mount the secrets/data from your server folder.
|
||||
|
||||
### 4. Updating the App
|
||||
To update the code:
|
||||
1. Run the app **on your PC** first (using `python wizard.py` or `main.py`).
|
||||
2. Push changes to Git.
|
||||
3. In Portainer, go to your Stack.
|
||||
4. Click **Editor** > **Pull and redeploy**.
|
||||
|
||||
### 📂 How to Manage Files (Input/Output)
|
||||
The Docker setup uses a **Volume** to map the container's internal `/app/data` folder to a folder on your server. This path is defined by the `HOST_PATH` variable you set in Portainer.
|
||||
1. Make changes locally and push to Git.
|
||||
2. In Portainer: Stack > **Editor** > **Pull and redeploy**.
|
||||
|
||||
- **To Add Personas/Fonts:** On your server, place files into the `${HOST_PATH}/data/personas/` or `${HOST_PATH}/data/fonts/` folders. The app will see them immediately.
|
||||
- **To Download Books:** You can download generated EPUBs directly from the Web Dashboard.
|
||||
- **To Backup:** Just create a backup of the entire `${HOST_PATH}` directory on your server. It contains the database, all projects, and generated books.
|
||||
### Managing Files
|
||||
|
||||
## 🐍 Native Web Setup (Alternative)
|
||||
If you prefer to run the web app without Docker:
|
||||
The Docker volume maps `/app/data` in the container to `HOST_PATH` on your server.
|
||||
|
||||
- **Add personas/fonts:** Drop files into `${HOST_PATH}/data/personas/` or `${HOST_PATH}/data/fonts/`.
|
||||
- **Download books:** Use the Web Dashboard download links.
|
||||
- **Backup:** Archive the entire `${HOST_PATH}` directory.
|
||||
|
||||
## Native Setup (No Docker)
|
||||
|
||||
1. **Install Web Dependencies:**
|
||||
```bash
|
||||
pip install -r modules/requirements_web.txt
|
||||
pip install -r requirements.txt
|
||||
pip install -r web/requirements_web.txt
|
||||
python -m web.app
|
||||
```
|
||||
2. **Start the App:**
|
||||
```bash
|
||||
python -m modules.web_app
|
||||
|
||||
Open `http://localhost:5000`.
|
||||
|
||||
## Features
|
||||
|
||||
### Web UI (`web/`)
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Cost-Effective by Design
|
||||
|
||||
This engine was built with the goal of producing high-quality fiction at the lowest possible cost. This is achieved through several architectural optimizations:
|
||||
|
||||
* **Tiered AI Models**: The system uses cheaper, faster models (like Gemini Pro) for structural and analytical tasks—planning the plot, scoring chapter quality, and ensuring consistency. The more powerful and expensive creative models are reserved for the actual writing process.
|
||||
* **Intelligent Context Management**: To minimize the number of tokens sent to the AI, the system is very selective about the data it includes in each request. For example, when writing a chapter, it only injects data for the characters who are currently in the scene, rather than the entire cast.
|
||||
* **Adaptive Workflows**: The engine avoids unnecessary work. If a user provides a detailed outline for a chapter, the system skips the AI step that would normally expand on a basic idea, saving both time and money. It also adjusts its quality standards based on the chapter's importance, spending more effort on a climactic scene than on a simple transition.
|
||||
* **Caching**: The system caches the results of deterministic AI tasks. If it needs to perform the same analysis twice, it reuses the original result instead of making a new API call.
|
||||
|
||||
### CLI Wizard (`cli/`)
|
||||
- **Interactive Setup:** Menu-driven interface (via Rich) for creating projects, managing personas, and defining characters and plot beats.
|
||||
- **Smart Resume:** Detects in-progress runs via lock files and prompts to resume.
|
||||
- **Interactive Mode:** Optionally review and approve/reject each chapter before generation continues.
|
||||
- **Stop Signal:** Create a `.stop` file in the run directory to gracefully abort a long run without corrupting state.
|
||||
|
||||
### Story Generation (`story/`)
|
||||
- **Adaptive Structure:** Chooses a narrative framework (Hero's Journey, Three-Act, Single Scene, etc.) based on the selected length preset and expands it through multiple depth levels.
|
||||
- **Dynamic Pacing:** Monitors story progress during writing and inserts bridge chapters to slow a rushing plot or removes redundant ones detected mid-stream — without restarting.
|
||||
- **Series Continuity:** When generating Book 2+, carries forward character visual tracking, established relationships, plot threads, and a cumulative "Story So Far" summary.
|
||||
- **Persona Refinement Loop:** Every 5 chapters, analyzes actual written text to refine the author persona model, maintaining stylistic consistency throughout the book.
|
||||
- **Persona Cache:** The author persona (including writing sample files) is loaded once at the start of the writing phase and reused for every chapter, eliminating redundant file I/O. The cache is refreshed whenever the persona is refined.
|
||||
- **Outline Validation Gate (`planner.py`):** Before the writing phase begins, a Logic-model pass checks the chapter plan for missing required beats, character continuity issues, pacing imbalances, and POV logic errors. Issues are logged as warnings so the writer can review them before generation begins.
|
||||
- **Adaptive Scoring Thresholds (`writer.py`):** Quality passing thresholds scale with chapter position — setup chapters use a lower bar (6.5) to avoid over-spending refinement tokens on early exposition, while climax chapters use a stricter bar (7.5) to ensure the most important scenes receive maximum effort.
|
||||
- **Adaptive Refinement Attempts (`writer.py`):** Climax and resolution chapters (position ≥ 75% through the book) receive up to 3 refinement attempts; earlier chapters keep 2. This concentrates quality effort on the scenes readers remember most.
|
||||
- **Stricter Polish Pass (`writer.py`):** The filter-word threshold for skipping the two-pass polish has been tightened from 1-per-83-words to 1-per-125-words, so more borderline drafts are cleaned before evaluation.
|
||||
- **Smart Beat Expansion Skip (`writer.py`):** If a chapter's scene beats are already detailed (>100 words total), the Director's Treatment expansion step is skipped, saving ~5K tokens per chapter.
|
||||
- **Consistency Checker (`editor.py`):** Scores chapters on 13 rubrics (engagement, voice, sensory detail, scene execution, dialogue, pacing, staging, prose dynamics, clarity, etc.) and flags AI-isms ("tapestry", "palpable tension") and weak filter verbs ("felt", "realized"). Chapter evaluation now uses head+tail sampling (`keep_head=True`) ensuring the evaluator sees the chapter opening (hooks, sensory anchoring) as well as the ending — long chapters no longer receive scores based only on their tail.
|
||||
- **Rewrite Model Upgrade (`editor.py`):** Manual chapter rewrites and user-triggered edits now use `model_writer` (the creative writing model) instead of `model_logic`, producing significantly better prose quality on rewritten content.
|
||||
- **Improved Consistency Sampling (`editor.py`):** The mid-generation consistency analysis now samples head + middle + tail of each chapter (instead of head + tail only), giving the continuity LLM a complete picture of each chapter's events for more accurate contradiction detection.
|
||||
- **Larger Persona Validation Sample (`style_persona.py`):** The persona validation test passage has been increased from 200 words to 400 words, giving the scorer enough material to reliably assess sentence rhythm, filter-word habits, and deep POV quality before accepting a persona.
|
||||
- **Dynamic Character Injection (`writer.py`):** Only injects characters explicitly named in the chapter's `scene_beats` plus the POV character into the writer prompt. Eliminates token waste from unused characters and reduces hallucinated appearances.
|
||||
- **Smart Context Tail (`writer.py`):** Extracts the final ~1,000 tokens of the previous chapter (the actual ending) rather than blindly truncating from the front. Ensures the hand-off point — where characters are standing and what was last said — is always preserved.
|
||||
- **Stateful Scene Tracking (`bible_tracker.py`):** After each chapter, the tracker records each character's `current_location`, `time_of_day`, and `held_items` in addition to appearance and events. This scene state is injected into subsequent chapter prompts so the writer knows exactly where characters are, what time it is, and what they're carrying.
|
||||
|
||||
### Marketing Assets (`marketing/`)
|
||||
- **Cover Art:** Generates a visual prompt from book themes and tracking data, then calls Imagen (Gemini or Vertex AI) to produce the cover. Evaluates image quality with multimodal AI critique before accepting.
|
||||
- **Back-Cover Blurb:** Writes 150–200 word marketing copy in a 4-part structure (Hook, Stakes, Tension, Close) with genre-specific tone (thriller=urgent, romance=emotional, etc.).
|
||||
|
||||
### Export (`export/`)
|
||||
- **EPUB:** eBook file with cover image, chapter structure, and formatted text (bold, italics, headers). Ready for Kindle / Apple Books.
|
||||
- **DOCX:** Word document for manual editing.
|
||||
|
||||
### AI Infrastructure (`ai/`)
|
||||
- **Resilient Model Wrapper:** Wraps every Gemini API call with up to 3 retries and exponential backoff, handles quota errors and rate limits, and can switch to an alternative model mid-stream.
|
||||
- **Auto Model Selection:** On startup, a bootstrapper model queries the Gemini API and selects the optimal models for Logic, Writer, Artist, and Image roles. Selection is cached for 24 hours. The selection algorithm now prioritizes quality — free/preview/exp models are preferred by capability (Pro > Flash, 2.5 > 2.0 > 1.5) rather than by cost alone.
|
||||
- **Vertex AI Support:** If `GCP_PROJECT` is set and OAuth credentials are present, initializes Vertex AI automatically for Imagen image generation.
|
||||
- **Payload Guardrails:** Every generation call estimates the prompt token count before dispatch. If the payload exceeds 30,000 tokens, a warning is logged so runaway context injection is surfaced immediately.
|
||||
|
||||
### AI Context Optimization (`core/utils.py`)
|
||||
- **System Status Model Optimization (`templates/system_status.html`, `web/routes/admin.py`):** Refreshing models operates via an async fetch request, preventing page freezes during the re-evaluation of available models.
|
||||
- **Context Truncation:** `truncate_to_tokens(text, max_tokens)` enforces hard caps on large context variables — previous chapter text, story summaries, and character data — before they are injected into prompts, preventing token overflows on large manuscripts.
|
||||
- **AI Response Cache:** An in-memory cache (`_AI_CACHE`) keyed by MD5 hash of inputs prevents redundant API calls for deterministic tasks such as persona analysis. Results are reused for identical inputs within the same session.
|
||||
|
||||
### Cost Tracking
|
||||
Every AI call logs input/output token counts and estimated USD cost (using cached pricing per model). Cumulative project cost is stored in the database and displayed per user and per run.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
BookApp/
|
||||
├── ai/ # Gemini/Vertex AI authentication and resilient model wrapper
|
||||
│ ├── models.py # ResilientModel class with retry logic
|
||||
│ └── setup.py # Model initialization and auto-selection
|
||||
├── cli/ # Terminal interface and generation orchestrator
|
||||
│ ├── engine.py # Full generation pipeline (plan → write → export)
|
||||
│ └── wizard.py # Interactive menu-driven setup wizard
|
||||
├── core/ # Central configuration and shared utilities
|
||||
│ ├── config.py # Environment variable loading, presets, AI safety settings
|
||||
│ └── utils.py # Logging, JSON cleaning, usage tracking, filename utils
|
||||
├── export/ # Manuscript compilation
|
||||
│ └── exporter.py # EPUB and DOCX generation
|
||||
├── marketing/ # Post-generation asset creation
|
||||
│ ├── assets.py # Orchestrates cover + blurb creation
|
||||
│ ├── blurb.py # Back-cover marketing copy generation
|
||||
│ ├── cover.py # Cover art generation and iterative refinement
|
||||
│ └── fonts.py # Google Fonts downloader/cache
|
||||
├── story/ # Core creative AI pipeline
|
||||
│ ├── bible_tracker.py # Character state and plot event tracking
|
||||
│ ├── editor.py # Chapter quality scoring and AI-ism detection
|
||||
│ ├── planner.py # Story structure and chapter plan generation
|
||||
│ ├── style_persona.py # Author persona creation and refinement
|
||||
│ └── writer.py # Chapter-by-chapter writing with persona/context injection
|
||||
├── templates/ # Jinja2 HTML templates for the web application
|
||||
├── web/ # Flask web application
|
||||
│ ├── app.py # App factory, blueprint registration, admin auto-creation
|
||||
│ ├── db.py # SQLAlchemy models: User, Project, Run, LogEntry
|
||||
│ ├── helpers.py # admin_required decorator, project lock check, CSRF utils
|
||||
│ ├── tasks.py # Huey background task queue (generate, rewrite, regenerate)
|
||||
│ ├── requirements_web.txt
|
||||
│ └── routes/
|
||||
│ ├── admin.py # User management and factory reset
|
||||
│ ├── auth.py # Login, register, session management
|
||||
│ ├── persona.py # Author persona CRUD and sample file upload
|
||||
│ ├── project.py # Project creation wizard and job queuing
|
||||
│ └── run.py # Run status, logs, downloads, chapter editing, cover regen
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── requirements.txt # Core AI/generation dependencies
|
||||
└── README.md
|
||||
```
|
||||
3. **Access:** Open `http://localhost:5000` in a browser.
|
||||
|
||||
## <EFBFBD>️ Features
|
||||
- **Interactive Wizard:** Create new books, series, or sequels. Edit existing blueprints with natural language commands.
|
||||
- **Modular Architecture:** Logic is split into specialized modules for easier maintenance and upgrades.
|
||||
- **Smart Resume:** If a run crashes, simply run the script again. It detects progress and asks to resume.
|
||||
- **Marketing Assets:** Automatically generates a blurb, back cover text, and a cover image.
|
||||
- **Rich Text:** Generates EPUBs with proper formatting (Bold, Italics, Headers).
|
||||
- **Dynamic Structure:** Automatically adapts the plot structure (e.g., "Hero's Journey" vs "Single Scene") based on the book length.
|
||||
- **Series Support:** Automatically carries context, characters, and plot threads from Book 1 to Book 2, etc.
|
||||
## Environment Variables
|
||||
|
||||
## 📂 Project Structure
|
||||
All variables are loaded from a `.env` file in the project root (never commit this file).
|
||||
|
||||
### Core Files
|
||||
- **`wizard.py`**: The interactive command-line interface for creating projects, managing personas, and editing the "Book Bible".
|
||||
- **`main.py`**: The execution engine. It reads the Bible JSON and orchestrates the generation process using the modules.
|
||||
- **`config.py`**: Central configuration for API keys, file paths, and model settings.
|
||||
- **`utils.py`**: Shared utility functions for logging, JSON handling, and file I/O.
|
||||
| Variable | Required | Description |
|
||||
| :--- | :---: | :--- |
|
||||
| `GEMINI_API_KEY` | Yes | Google Gemini API key |
|
||||
| `FLASK_SECRET_KEY` | No | Session encryption key (default: insecure dev value — change in production) |
|
||||
| `ADMIN_USERNAME` | No | Auto-creates an admin account on startup |
|
||||
| `ADMIN_PASSWORD` | No | Password for the auto-created admin account |
|
||||
| `GCP_PROJECT` | No | Google Cloud Project ID (enables Vertex AI / Imagen) |
|
||||
| `GCP_LOCATION` | No | GCP region (default: `us-central1`) |
|
||||
| `GOOGLE_APPLICATION_CREDENTIALS` | No | Path to OAuth2 credentials JSON for Vertex AI |
|
||||
| `MODEL_LOGIC` | No | Override the reasoning model |
|
||||
| `MODEL_WRITER` | No | Override the creative writing model |
|
||||
| `MODEL_ARTIST` | No | Override the visual-prompt model |
|
||||
| `MODEL_IMAGE` | No | Override the image generation model |
|
||||
| `FLASK_DEBUG` | No | Enable Flask debug mode (`True`/`False`) |
|
||||
|
||||
### Modules (`/modules`)
|
||||
- **`ai.py`**: Handles authentication and connection to Google Gemini and Vertex AI.
|
||||
- **`story.py`**: Contains the creative logic: enriching ideas, planning structure, and writing chapters.
|
||||
- **`marketing.py`**: Generates cover art prompts, images, and blurbs.
|
||||
- **`export.py`**: Compiles the final manuscript into DOCX and EPUB formats.
|
||||
## Length Presets
|
||||
|
||||
### Data Folders
|
||||
- **`data/projects/`**: Stores your book projects.
|
||||
- **`data/personas/`**: Stores author personas and writing samples.
|
||||
- **`data/fonts/`**: Caches downloaded fonts for cover art.
|
||||
The **Length** setting controls structural complexity, not just word count. It determines the narrative framework, chapter count, and the number of depth-expansion passes the planner performs.
|
||||
|
||||
## <20> Length Settings Explained
|
||||
The **Length Settings** control not just the word count, but the **structural complexity** of the story.
|
||||
| Preset | Approx Words | Chapters | Depth | Description |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Flash Fiction** | 500 – 1.5k | 1 | 1 | A single scene or moment. |
|
||||
| **Short Story** | 5k – 10k | 5 | 1 | One conflict, few characters. |
|
||||
| **Novella** | 20k – 40k | 15 | 2 | Developed plot, A & B stories. |
|
||||
| **Novel** | 60k – 80k | 30 | 3 | Deep subplots, slower pacing. |
|
||||
| **Epic** | 100k+ | 50 | 4 | Massive scope, world-building focus. |
|
||||
|
||||
| Type | Approx Words | Chapters | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Flash Fiction** | 500 - 1.5k | 1 | A single scene or moment. |
|
||||
| **Short Story** | 5k - 10k | 5 | One conflict, few characters. |
|
||||
| **Novella** | 20k - 40k | 15 | Developed plot, A & B stories. |
|
||||
| **Novel** | 60k - 80k | 30 | Deep subplots, slow pacing. |
|
||||
| **Epic** | 100k+ | 50 | Massive scope, world-building focus. |
|
||||
> **Note:** This engine is designed for **linear fiction**. Branching narratives ("Choose Your Own Adventure") are not currently supported.
|
||||
|
||||
> **Note:** This engine is designed for **linear fiction**. It does not currently support branching narratives like "Choose Your Own Adventure" books.
|
||||
## Data Structure & File Dictionary
|
||||
|
||||
## 📂 Output Folder Structure
|
||||
- **Project_Name/**: A folder created based on your book or series title.
|
||||
- **bible.json**: The master plan containing characters, settings, and plot outlines for the series.
|
||||
- **runs/**: Contains generation attempts.
|
||||
- **bible/**:
|
||||
- **run_#/**: Each generation attempt gets its own numbered folder.
|
||||
- **Book_1_Title/**: Specific folder for the generated book.
|
||||
- **final_blueprint.json**: The final plan used for this run.
|
||||
- **manuscript.json**: The raw text data.
|
||||
- **Book_Title.epub**: The final generated ebook.
|
||||
- **cover.png**: The AI-designed cover art.
|
||||
All data is stored in `data/`, making backup and migration simple.
|
||||
|
||||
### Folder Hierarchy
|
||||
|
||||
```text
|
||||
data/
|
||||
├── users/
|
||||
│ └── {user_id}/
|
||||
│ └── {Project_Name}/
|
||||
│ ├── bible.json # Project source of truth
|
||||
│ └── runs/
|
||||
│ └── run_{id}/
|
||||
│ ├── web_console.log
|
||||
│ └── Book_{N}_{Title}/
|
||||
│ ├── manuscript.json
|
||||
│ ├── tracking_events.json
|
||||
│ ├── tracking_characters.json
|
||||
│ ├── chapters.json
|
||||
│ ├── events.json
|
||||
│ ├── final_blueprint.json
|
||||
│ ├── usage_log.json
|
||||
│ ├── cover_art_prompt.txt
|
||||
│ ├── {Title}.epub
|
||||
│ └── {Title}.docx
|
||||
├── personas/
|
||||
│ └── personas.json
|
||||
├── fonts/ # Cached Google Fonts
|
||||
└── style_guidelines.json # Global AI writing rules
|
||||
```
|
||||
|
||||
### File Dictionary
|
||||
|
||||
| File | Scope | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `bible.json` | Project | Master plan: series title, author metadata, character list, and high-level plot outline for every book. |
|
||||
| `manuscript.json` | Book | Every written chapter in order. Used to resume generation if interrupted. |
|
||||
| `events.json` | Book | Structural outline (e.g., Hero's Journey beats) produced by the planner. |
|
||||
| `chapters.json` | Book | Detailed writing plan: title, POV character, pacing, estimated word count per chapter. |
|
||||
| `tracking_events.json` | Book | Cumulative plot summary and chronological event log for continuity. |
|
||||
| `tracking_characters.json` | Book | Current state of every character (appearance, clothing, location, injuries, speech patterns). |
|
||||
| `final_blueprint.json` | Book | Post-generation metadata snapshot: captures new characters and plot points invented during writing. |
|
||||
| `usage_log.json` | Book | AI token counts and estimated USD cost per call, per book. |
|
||||
| `cover_art_prompt.txt` | Book | Exact prompt submitted to Imagen / Vertex AI for cover generation. |
|
||||
| `{Title}.epub` | Book | Compiled eBook, ready for Kindle / Apple Books. |
|
||||
| `{Title}.docx` | Book | Compiled Word document for manual editing. |
|
||||
|
||||
## JSON Data Schemas
|
||||
|
||||
### `bible.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"project_metadata": {
|
||||
"title": "Series Title",
|
||||
"author": "Author Name",
|
||||
"genre": "Sci-Fi",
|
||||
"is_series": true,
|
||||
"style": {
|
||||
"tone": "Dark",
|
||||
"pov_style": "Third Person Limited"
|
||||
}
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"name": "Jane Doe",
|
||||
"role": "Protagonist",
|
||||
"description": "Physical and personality details..."
|
||||
}
|
||||
],
|
||||
"books": [
|
||||
{
|
||||
"book_number": 1,
|
||||
"title": "Book One Title",
|
||||
"manual_instruction": "High-level plot summary...",
|
||||
"plot_beats": ["Beat 1", "Beat 2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `manuscript.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"num": 1,
|
||||
"title": "Chapter Title",
|
||||
"pov_character": "Jane Doe",
|
||||
"content": "# Chapter 1\n\nThe raw markdown text of the chapter..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `tracking_characters.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"Jane Doe": {
|
||||
"descriptors": ["Blue eyes", "Tall"],
|
||||
"likes_dislikes": ["Loves coffee"],
|
||||
"last_worn": "Red dress (Ch 4)",
|
||||
"major_events": ["Injured leg in Ch 2"],
|
||||
"current_location": "The King's Throne Room",
|
||||
"time_of_day": "Late afternoon",
|
||||
"held_items": ["Iron sword", "Stolen ledger"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# BookApp Modules
|
||||
0
ai/__init__.py
Normal file
0
ai/__init__.py
Normal file
89
ai/models.py
Normal file
89
ai/models.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
import google.generativeai as genai
|
||||
from core import utils
|
||||
|
||||
# Suppress Vertex AI warnings
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
|
||||
|
||||
try:
|
||||
import vertexai
|
||||
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
|
||||
HAS_VERTEX = True
|
||||
except ImportError:
|
||||
HAS_VERTEX = False
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
HAS_OAUTH = True
|
||||
except ImportError:
|
||||
HAS_OAUTH = False
|
||||
|
||||
model_logic = None
|
||||
model_writer = None
|
||||
model_artist = None
|
||||
model_image = None
|
||||
logic_model_name = "models/gemini-1.5-flash"
|
||||
writer_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_source = "None"
|
||||
|
||||
|
||||
class ResilientModel:
|
||||
def __init__(self, name, safety_settings, role):
|
||||
self.name = name
|
||||
self.safety_settings = safety_settings
|
||||
self.role = role
|
||||
self.model = genai.GenerativeModel(name, safety_settings=safety_settings)
|
||||
|
||||
def update(self, name):
|
||||
self.name = name
|
||||
self.model = genai.GenerativeModel(name, safety_settings=self.safety_settings)
|
||||
|
||||
_TOKEN_WARN_LIMIT = 30_000
|
||||
|
||||
# Timeout in seconds for all generate_content calls (prevents indefinite hangs)
|
||||
_GENERATION_TIMEOUT = 180
|
||||
|
||||
def generate_content(self, *args, **kwargs):
|
||||
# Estimate payload size and warn if it exceeds the safe limit
|
||||
if args:
|
||||
payload = args[0]
|
||||
if isinstance(payload, str):
|
||||
est = utils.estimate_tokens(payload)
|
||||
elif isinstance(payload, list):
|
||||
est = sum(utils.estimate_tokens(p) if isinstance(p, str) else 0 for p in payload)
|
||||
else:
|
||||
est = 0
|
||||
if est > self._TOKEN_WARN_LIMIT:
|
||||
utils.log("SYSTEM", f"⚠️ Payload warning: ~{est:,} tokens for {self.role} ({self.name}). Consider reducing context.")
|
||||
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
base_delay = 5
|
||||
|
||||
# Inject timeout into request_options without overwriting caller-supplied values
|
||||
rq_opts = kwargs.pop("request_options", {}) or {}
|
||||
if isinstance(rq_opts, dict):
|
||||
rq_opts.setdefault("timeout", self._GENERATION_TIMEOUT)
|
||||
|
||||
while True:
|
||||
try:
|
||||
return self.model.generate_content(*args, **kwargs, request_options=rq_opts)
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
is_timeout = "timeout" in err_str or "deadline" in err_str or "timed out" in err_str
|
||||
is_retryable = is_timeout or "429" in err_str or "quota" in err_str or "500" in err_str or "503" in err_str or "504" in err_str or "internal error" in err_str
|
||||
if is_retryable and retries < max_retries:
|
||||
delay = base_delay * (2 ** retries)
|
||||
utils.log("SYSTEM", f"⚠️ {'Timeout' if is_timeout else 'API error'} on {self.role} ({self.name}). Retrying in {delay}s... ({retries + 1}/{max_retries})")
|
||||
time.sleep(delay)
|
||||
retries += 1
|
||||
continue
|
||||
raise e
|
||||
342
ai/setup.py
Normal file
342
ai/setup.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
import threading
|
||||
import google.generativeai as genai
|
||||
from core import config, utils
|
||||
from ai import models
|
||||
|
||||
|
||||
_LIST_MODELS_TIMEOUT = {"timeout": 30}
|
||||
|
||||
|
||||
def get_optimal_model(base_type="pro"):
|
||||
try:
|
||||
available = [m for m in genai.list_models(request_options=_LIST_MODELS_TIMEOUT) if 'generateContent' in m.supported_generation_methods]
|
||||
candidates = [m.name for m in available if base_type in m.name]
|
||||
if not candidates: return f"models/gemini-1.5-{base_type}"
|
||||
|
||||
def score(n):
|
||||
gen_bonus = 0
|
||||
if "2.5" in n: gen_bonus = 300
|
||||
elif "2.0" in n: gen_bonus = 200
|
||||
elif "2." in n: gen_bonus = 150
|
||||
if "exp" in n or "beta" in n or "preview" in n: return gen_bonus + 0
|
||||
if "latest" in n: return gen_bonus + 50
|
||||
return gen_bonus + 100
|
||||
|
||||
return sorted(candidates, key=score, reverse=True)[0]
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}")
|
||||
return f"models/gemini-1.5-{base_type}"
|
||||
|
||||
|
||||
def get_default_models():
|
||||
return {
|
||||
"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", "book_cost": "$0.00"},
|
||||
"writer": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for fast, high-quality creative writing.", "estimated_cost": "$0.10/1M", "book_cost": "$0.10"},
|
||||
"artist": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for visual prompt design.", "estimated_cost": "$0.10/1M", "book_cost": "$0.01"},
|
||||
"pro_rewrite": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro Exp (free) for critical chapter rewrites.", "estimated_cost": "Free", "book_cost": "$0.00"},
|
||||
"total_estimated_book_cost": "$0.11",
|
||||
"ranking": []
|
||||
}
|
||||
|
||||
|
||||
def select_best_models(force_refresh=False):
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
cached_models = None
|
||||
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f:
|
||||
cached = json.load(f)
|
||||
cached_models = cached.get('models', {})
|
||||
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
|
||||
m = cached_models
|
||||
if isinstance(m.get('logic'), dict) and 'reason' in m['logic']:
|
||||
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
|
||||
return m
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
|
||||
|
||||
try:
|
||||
utils.log("SYSTEM", "Refreshing AI model list from API...")
|
||||
all_models = list(genai.list_models(request_options=_LIST_MODELS_TIMEOUT))
|
||||
raw_model_names = [m.name for m in all_models]
|
||||
utils.log("SYSTEM", f"Found {len(all_models)} raw models from Google API.")
|
||||
|
||||
compatible = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()]
|
||||
utils.log("SYSTEM", f"Identified {len(compatible)} compatible Gemini models: {compatible}")
|
||||
|
||||
bootstrapper = get_optimal_model("flash")
|
||||
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
|
||||
|
||||
model = genai.GenerativeModel(bootstrapper)
|
||||
prompt = f"""
|
||||
ROLE: AI Model Architect
|
||||
TASK: Select the optimal Gemini models for a book-writing application.
|
||||
PRIMARY OBJECTIVE: Maximize book quality. Free/preview/exp models are $0.00 — use the BEST quality free model available for every role. Only fall back to paid Flash when no free alternative exists, and only if it fits within the budget cap.
|
||||
|
||||
AVAILABLE_MODELS:
|
||||
{json.dumps(compatible)}
|
||||
|
||||
PRICING_CONTEXT (USD per 1M tokens — use these to calculate actual book cost):
|
||||
- FREE TIER: Any model with 'exp', 'beta', or 'preview' in name = $0.00. Always prefer these.
|
||||
e.g. gemini-2.0-pro-exp = FREE, gemini-2.5-pro-preview = FREE, gemini-2.5-flash-preview = FREE.
|
||||
- gemini-2.5-flash / gemini-2.5-flash-preview: ~$0.075 Input / $0.30 Output.
|
||||
- gemini-2.0-flash: ~$0.10 Input / $0.40 Output.
|
||||
- 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.
|
||||
|
||||
BOOK TOKEN BUDGET (30-chapter novel — use this to calculate real cost before deciding):
|
||||
Logic role total: ~265,000 input tokens + ~55,000 output tokens
|
||||
(planning, state tracking, consistency checks, director treatments, chapter evaluation per chapter)
|
||||
Writer role total: ~450,000 input tokens + ~135,000 output tokens
|
||||
(drafting, refinement per chapter — 3 passes max)
|
||||
Artist role total: ~30,000 input tokens + ~8,000 output tokens
|
||||
(cover art prompt design, cover layout, blurb, image quality evaluation — text calls only)
|
||||
|
||||
NOTE: Cover IMAGE generation uses the Imagen API (billed per image, not per token).
|
||||
Imagen costs are fixed at ~$0.04/image × up to 3 attempts = ~$0.12 max. This is SEPARATE
|
||||
from the text token budget below and cannot be reduced by model selection.
|
||||
|
||||
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/PREVIEW ALWAYS WINS: Always pick the highest-quality free/exp/preview model for each role.
|
||||
Free models cost $0 regardless of tier — a free Pro beats a paid Flash every time.
|
||||
2. QUALITY FOR WRITER: The Writer role produces all fiction prose. Prefer the best free Flash or
|
||||
free Pro variant available. If no free model exists for Writer, use the cheapest paid Flash
|
||||
that keeps the total budget under $1.85. Never use a paid stable 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 identical cost (e.g. both free), prefer the highest
|
||||
generation and capability: Pro > Flash, 2.5 > 2.0 > 1.5, stable > exp only if cost equal.
|
||||
5. NO THINKING MODELS: Too slow and expensive for any role.
|
||||
|
||||
ROLES:
|
||||
- LOGIC: Planning, JSON adherence, plot consistency, AND chapter quality evaluation. Best free/exp Pro is ideal; free Flash preview acceptable if no free Pro exists.
|
||||
- WRITER: Creative prose, chapter drafting and refinement. Best available free Flash or free Pro variant. Never use a paid stable Pro.
|
||||
- ARTIST: Visual prompts for cover art. Cheapest capable Flash model (free preferred).
|
||||
- PRO_REWRITE: Emergency full-chapter rewrite (rare, ~1-2x per book). Best free/exp Pro available.
|
||||
If no free Pro exists, use best free Flash preview — do not use paid models here.
|
||||
|
||||
OUTPUT_FORMAT (JSON only, no markdown):
|
||||
{{
|
||||
"logic": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
|
||||
"writer": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
|
||||
"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" }} ]
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
selection = json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Model selection generation failed (Safety/Format): {e}")
|
||||
raise e
|
||||
|
||||
if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR)
|
||||
with open(cache_path, 'w') as f:
|
||||
json.dump({
|
||||
"timestamp": int(time.time()),
|
||||
"models": selection,
|
||||
"available_at_time": compatible,
|
||||
"raw_models": raw_model_names
|
||||
}, f, indent=2)
|
||||
return selection
|
||||
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"AI Model Selection failed: {e}.")
|
||||
|
||||
if cached_models:
|
||||
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
|
||||
return cached_models
|
||||
|
||||
utils.log("SYSTEM", "Falling back to heuristics.")
|
||||
fallback = get_default_models()
|
||||
|
||||
try:
|
||||
with open(cache_path, 'w') as f:
|
||||
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
|
||||
except: pass
|
||||
return fallback
|
||||
|
||||
|
||||
def init_models(force=False):
|
||||
global_vars = models.__dict__
|
||||
if global_vars.get('model_logic') and not force: return
|
||||
|
||||
genai.configure(api_key=config.API_KEY)
|
||||
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
skip_validation = False
|
||||
if not force and os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f: cached = json.load(f)
|
||||
if time.time() - cached.get('timestamp', 0) < 86400: skip_validation = True
|
||||
except: pass
|
||||
|
||||
if not skip_validation:
|
||||
utils.log("SYSTEM", "Validating credentials...")
|
||||
try:
|
||||
list(genai.list_models(page_size=1, request_options=_LIST_MODELS_TIMEOUT))
|
||||
utils.log("SYSTEM", "✅ Gemini API Key is valid.")
|
||||
except Exception as e:
|
||||
if os.path.exists(cache_path):
|
||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
|
||||
else:
|
||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.")
|
||||
|
||||
utils.log("SYSTEM", "Selecting optimal models via AI...")
|
||||
selected_models = select_best_models(force_refresh=force)
|
||||
|
||||
if not force:
|
||||
missing_costs = False
|
||||
for role in ['logic', 'writer', 'artist']:
|
||||
role_data = selected_models.get(role, {})
|
||||
if 'estimated_cost' not in role_data or role_data.get('estimated_cost') == 'N/A':
|
||||
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:
|
||||
utils.log("SYSTEM", "⚠️ Missing cost info in cached models. Forcing refresh.")
|
||||
return init_models(force=True)
|
||||
|
||||
def get_model_details(role_data):
|
||||
if isinstance(role_data, dict):
|
||||
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, logic_book = get_model_details(selected_models['logic'])
|
||||
writer_name, writer_cost, writer_book = get_model_details(selected_models['writer'])
|
||||
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
|
||||
writer_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
||||
artist_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
|
||||
|
||||
models.logic_model_name = logic_name
|
||||
models.writer_model_name = writer_name
|
||||
models.artist_model_name = artist_name
|
||||
models.pro_model_name = pro_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(writer_name, writer_cost)
|
||||
utils.update_pricing(artist_name, artist_cost)
|
||||
|
||||
if models.model_logic is None:
|
||||
models.model_logic = models.ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic")
|
||||
models.model_writer = models.ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer")
|
||||
models.model_artist = models.ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
|
||||
else:
|
||||
models.model_logic.update(logic_name)
|
||||
models.model_writer.update(writer_name)
|
||||
models.model_artist.update(artist_name)
|
||||
|
||||
models.model_image = None
|
||||
models.image_model_name = None
|
||||
models.image_model_source = "None"
|
||||
|
||||
hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO"
|
||||
|
||||
if hasattr(genai, 'ImageGenerationModel'):
|
||||
candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
models.model_image = genai.ImageGenerationModel(candidate)
|
||||
models.image_model_name = candidate
|
||||
models.image_model_source = "Gemini API"
|
||||
utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Auto-detect GCP Project
|
||||
if models.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)
|
||||
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 models.HAS_VERTEX and config.GCP_PROJECT:
|
||||
creds = None
|
||||
if models.HAS_OAUTH:
|
||||
gac = config.GOOGLE_CREDS
|
||||
if gac and os.path.exists(gac):
|
||||
try:
|
||||
with open(gac, 'r') as f: data = json.load(f)
|
||||
if 'installed' in data or 'web' in data:
|
||||
if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
|
||||
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
|
||||
|
||||
token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json')
|
||||
SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
|
||||
if os.path.exists(token_path):
|
||||
creds = models.Credentials.from_authorized_user_file(token_path, SCOPES)
|
||||
|
||||
_is_headless = threading.current_thread() is not threading.main_thread()
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(models.Request())
|
||||
except Exception:
|
||||
if _is_headless:
|
||||
utils.log("SYSTEM", "⚠️ Token refresh failed and cannot re-authenticate in a background/headless thread. Vertex AI will use ADC or be unavailable.")
|
||||
creds = None
|
||||
else:
|
||||
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...")
|
||||
flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
else:
|
||||
if _is_headless:
|
||||
utils.log("SYSTEM", "⚠️ OAuth Client ID requires browser login but running in headless/background mode. Skipping interactive auth. Use a Service Account key for Vertex AI in background tasks.")
|
||||
creds = None
|
||||
else:
|
||||
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...")
|
||||
flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
if creds:
|
||||
with open(token_path, 'w') as token: token.write(creds.to_json())
|
||||
|
||||
utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}")
|
||||
|
||||
import vertexai as _vertexai
|
||||
_vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
|
||||
utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
|
||||
|
||||
vertex_candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
|
||||
for candidate in vertex_candidates:
|
||||
try:
|
||||
models.model_image = models.VertexImageModel.from_pretrained(candidate)
|
||||
models.image_model_name = candidate
|
||||
models.image_model_source = "Vertex AI"
|
||||
utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
utils.log("SYSTEM", f"Image Generation Provider: {models.image_model_source} ({models.image_model_name or 'unavailable'})")
|
||||
194
ai_blueprint_v2.md
Normal file
194
ai_blueprint_v2.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# AI-Powered Book Generation: Optimized Architecture v2.0
|
||||
|
||||
**Date:** 2026-02-22
|
||||
**Status:** Defined — fulfills Action Plan Steps 5, 6, and 7 from `ai_blueprint.md`
|
||||
**Based on:** Current state analysis, alternatives analysis, and experiment design in `docs/`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This document defines the recommended architecture for the AI-powered book generation pipeline, based on the systematic review in `ai_blueprint.md`. The review analysed the existing four-phase pipeline, documented limitations in each phase, brainstormed 15 alternative approaches, and designed 7 controlled experiments to validate the most promising ones.
|
||||
|
||||
**Key finding:** The current system is already well-optimised for quality. The primary gains available are:
|
||||
1. **Reducing unnecessary token spend** on infrastructure (persona I/O, redundant beat expansion)
|
||||
2. **Improving front-loaded quality gates** (outline validation, persona validation)
|
||||
3. **Adaptive quality thresholds** to concentrate resources where they matter most
|
||||
|
||||
Several improvements from the analysis have been implemented in v2.0 (Phase 3 of this review). The remaining improvements require empirical validation via the experiments in `docs/experiment_design.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
### Current State → v2.0 Changes
|
||||
|
||||
| Component | Previous Behaviour | v2.0 Behaviour | Status |
|
||||
|-----------|-------------------|----------------|--------|
|
||||
| **Persona loading** | Re-read sample files from disk on every chapter | Loaded once per book run, cached in memory, rebuilt after each `refine_persona()` call | ✅ Implemented |
|
||||
| **Beat expansion** | Always expand beats to Director's Treatment | Skip expansion if beats already exceed 100 words total | ✅ Implemented |
|
||||
| **Outline validation** | No pre-generation quality gate | `validate_outline()` runs after chapter planning; logs issues before writing begins | ✅ Implemented |
|
||||
| **Scoring thresholds** | Fixed 7.0 passing threshold for all chapters | Adaptive: 6.5 for setup chapters → 7.5 for climax chapters (linear scale by position) | ✅ Implemented |
|
||||
| **Enrich validation** | Silent failure if enrichment returns missing fields | Explicit warnings logged for missing `title` or `genre` | ✅ Implemented |
|
||||
| **Persona validation** | Single-pass creation, no quality check | `validate_persona()` generates ~200-word sample; scored 1–10; regenerated up to 3× if < 7 | ✅ Implemented |
|
||||
| **Batched evaluation** | Per-chapter evaluation (20K tokens/call) | Experiment 4 (future) — batch 5 chapters per evaluation call | 🧪 Experiment Pending |
|
||||
| **Mid-gen consistency** | Post-generation consistency check only | `analyze_consistency()` called every 10 chapters inside writing loop; issues logged | ✅ Implemented |
|
||||
| **Two-pass drafting** | Single draft + iterative refinement | Rough Flash draft + Pro polish pass before evaluation; max_attempts reduced 3 → 2 | ✅ Implemented |
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase-by-Phase v2.0 Architecture
|
||||
|
||||
### Phase 1: Foundation & Ideation
|
||||
|
||||
**Implemented Changes:**
|
||||
- `enrich()` now logs explicit warnings if `book_metadata.title` or `book_metadata.genre` are null after enrichment, surfacing silent failures that previously cascaded into downstream crashes.
|
||||
|
||||
**Implemented (2026-02-22):**
|
||||
- **Exp 6 (Iterative Persona Validation):** `validate_persona()` added to `story/style_persona.py`. Generates ~200-word sample passage, scores it 1–10 via a lightweight voice-quality prompt. Accepted if ≥ 7. `cli/engine.py` retries `create_initial_persona()` up to 3× until score passes. Expected: -20% Phase 3 voice-drift rewrites.
|
||||
|
||||
**Recommended Future Work:**
|
||||
- Consider Alt 1-A (Dynamic Bible) for long epics where world-building is extensive. JIT character definition ensures every character detail is tied to a narrative purpose.
|
||||
- Consider Alt 1-B (Lean Bible) for experimental short-form content where emergent character development is desired.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Structuring & Outlining
|
||||
|
||||
**Implemented Changes:**
|
||||
- `validate_outline(events, chapters, bp, folder)` added to `story/planner.py`. Called after `create_chapter_plan()` in `cli/engine.py`. Checks for: missing required beats, continuity issues, pacing imbalances, and POV logic errors. Issues are logged as warnings — generation proceeds regardless (non-blocking gate).
|
||||
|
||||
**Pending Experiments:**
|
||||
- **Alt 2-A (Single-pass Outline):** Combine sequential `expand()` calls into one multi-step prompt. Saves ~60K tokens for a novel run. Low risk. Implement and test on novella-length stories first.
|
||||
|
||||
**Recommended Future Work:**
|
||||
- For the Lean Bible (Alt 1-B) variant, redesign `plan_structure()` to allow on-demand character enrichment as new characters appear in events.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Writing Engine
|
||||
|
||||
**Implemented Changes:**
|
||||
1. **`build_persona_info(bp)` function** extracted from `write_chapter()`. Contains all persona string building logic including disk reads. Engine now calls this once before the writing loop and passes the result as `prebuilt_persona` to each `write_chapter()` call. Rebuilt after each `refine_persona()` call.
|
||||
|
||||
2. **Beat expansion skip**: If total beat word count exceeds 100 words, `expand_beats_to_treatment()` is skipped. Expected savings: ~5K tokens × ~30% of chapters.
|
||||
|
||||
3. **Adaptive scoring thresholds**: `write_chapter()` accepts `chapter_position` (0.0–1.0). `SCORE_PASSING` scales from 6.5 (setup) to 7.5 (climax). Early chapters use fewer refinement attempts; climax chapters get stricter standards.
|
||||
|
||||
4. **`chapter_position` threading**: `cli/engine.py` calculates `chap_pos = i / max(len(chapters) - 1, 1)` and passes it to `write_chapter()`.
|
||||
|
||||
**Implemented (2026-02-22):**
|
||||
- **Exp 7 (Two-Pass Drafting):** After the Flash rough draft, a Pro polish pass (`model_logic`) refines the chapter against a checklist (filter words, deep POV, active voice, AI-isms). `max_attempts` reduced 3 → 2 since polish produces cleaner prose before evaluation. Expected: +0.3 HQS with fewer rewrite cycles.
|
||||
|
||||
**Pending Experiments:**
|
||||
- **Exp 3 (Pre-score Beats):** Score each chapter's beat list for "writability" before drafting. Flag high-risk chapters for additional attempts upfront.
|
||||
|
||||
**Recommended Future Work:**
|
||||
- Alt 2-C (Dynamic Personas): Once experiments validate basic optimisations, consider adapting persona sub-styles for action vs. introspection scenes.
|
||||
- Increase `SCORE_AUTO_ACCEPT` from 8.0 to 8.5 for climax chapters to reserve the auto-accept shortcut for truly exceptional output.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Review & Refinement
|
||||
|
||||
**No new implementations in v2.0** (Phase 4 is already highly optimised for quality).
|
||||
|
||||
**Implemented:**
|
||||
- **Exp 4 (Adaptive Thresholds):** Already implemented. Gather data on refinement call reduction.
|
||||
- **Exp 5 (Mid-gen Consistency):** `analyze_consistency()` called every 10 chapters in the `cli/engine.py` writing loop. Issues logged as `⚠️` warnings. Low cost (free on Pro-Exp). Expected: -30% post-gen CER.
|
||||
|
||||
**Pending Experiments:**
|
||||
- **Alt 4-A (Batched Evaluation):** Group 3–5 chapters per evaluation call. Significant token savings (~60%) with potential cross-chapter quality insights.
|
||||
|
||||
**Recommended Future Work:**
|
||||
- Alt 4-D (Editor Bot Specialisation): Implement fast regex-based checks for filter-word density and summary-mode detection before invoking the full LLM evaluator. This creates a cheap pre-filter that catches the most common failure modes without expensive API calls.
|
||||
|
||||
---
|
||||
|
||||
## 4. Expected Outcomes of v2.0 Implementations
|
||||
|
||||
### Token Savings (30-Chapter Novel)
|
||||
|
||||
| Change | Estimated Saving | Confidence |
|
||||
|--------|-----------------|------------|
|
||||
| Persona cache | ~90K tokens | High |
|
||||
| Beat expansion skip (30% of chapters) | ~45K tokens | High |
|
||||
| Adaptive thresholds (15% fewer setup refinements) | ~100K tokens | Medium |
|
||||
| Outline validation (prevents ~2 rewrites) | ~50K tokens | Medium |
|
||||
| **Total** | **~285K tokens (~8% of full book cost)** | — |
|
||||
|
||||
### Quality Impact
|
||||
|
||||
- Climax chapters: expected improvement in average evaluation score (+0.3–0.5 points) due to stricter SCORE_PASSING thresholds
|
||||
- Early setup chapters: expected slight reduction in revision loop overhead with no noticeable reader-facing quality decrease
|
||||
- Continuity errors: expected reduction from outline validation catching issues pre-generation
|
||||
|
||||
---
|
||||
|
||||
## 5. Experiment Roadmap
|
||||
|
||||
Execute experiments in this order (see `docs/experiment_design.md` for full specifications):
|
||||
|
||||
| Priority | Experiment | Effort | Expected Value |
|
||||
|----------|-----------|--------|----------------|
|
||||
| 1 | Exp 1: Persona Caching | ✅ Done | Token savings confirmed |
|
||||
| 2 | Exp 2: Beat Expansion Skip | ✅ Done | Token savings confirmed |
|
||||
| 3 | Exp 4: Adaptive Thresholds | ✅ Done | Quality + savings |
|
||||
| 4 | Exp 3: Outline Validation | ✅ Done | Quality gate |
|
||||
| 5 | Exp 6: Persona Validation | ✅ Done | -20% voice-drift rewrites |
|
||||
| 6 | Exp 5: Mid-gen Consistency | ✅ Done | -30% post-gen CER |
|
||||
| 7 | Exp 4: Batched Evaluation | Medium | -60% eval tokens |
|
||||
| 8 | Exp 7: Two-Pass Drafting | ✅ Done | +0.3 HQS |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cost Projections
|
||||
|
||||
### v2.0 Baseline (30-Chapter Novel, Quality-First Models)
|
||||
|
||||
| Phase | v1.0 Cost | v2.0 Cost | Saving |
|
||||
|-------|----------|----------|--------|
|
||||
| Phase 1: Ideation | FREE | FREE | — |
|
||||
| Phase 2: Outline | FREE | FREE | — |
|
||||
| Phase 3: Writing (text) | ~$0.18 | ~$0.16 | ~$0.02 |
|
||||
| Phase 4: Review | FREE | FREE | — |
|
||||
| Imagen Cover | ~$0.12 | ~$0.12 | — |
|
||||
| **Total** | **~$0.30** | **~$0.28** | **~7%** |
|
||||
|
||||
*Using Pro-Exp for all Logic tasks. Text savings primarily from persona cache + beat expansion skip.*
|
||||
|
||||
### With Future Experiment Wins (Conservative Estimate)
|
||||
|
||||
If Exp 5, 6, 7 succeed and are implemented:
|
||||
- Estimated additional token saving: ~400K tokens (~$0.04)
|
||||
- **Projected total: ~$0.24/book (text + cover)**
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Principles Revalidated
|
||||
|
||||
This review reconfirms the principles from `ai_blueprint.md`:
|
||||
|
||||
| Principle | Status | Evidence |
|
||||
|-----------|--------|---------|
|
||||
| **Quality First, then Cost** | ✅ Confirmed | Adaptive thresholds concentrate refinement resources on climax chapters, not cut them |
|
||||
| **Modularity and Flexibility** | ✅ Confirmed | `build_persona_info()` extraction enables future caching strategies |
|
||||
| **Data-Driven Decisions** | 🔄 In Progress | Experiment framework defined; gathering empirical data next |
|
||||
| **Minimize Rework** | ✅ Improved | Outline validation gate prevents rework from catching issues pre-generation |
|
||||
| **High-Quality Assurance** | ✅ Confirmed | 13-rubric evaluator with auto-fail conditions remains the quality backbone |
|
||||
| **Holistic Approach** | ✅ Confirmed | All four phases analysed; changes propagated across the full pipeline |
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Modified in v2.0
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `story/planner.py` | Added enrichment field validation; added `validate_outline()` function |
|
||||
| `story/writer.py` | Added `build_persona_info()`; `write_chapter()` accepts `prebuilt_persona` + `chapter_position`; beat expansion skip; adaptive scoring; **Exp 7: two-pass Pro polish before evaluation; `max_attempts` 3 → 2** |
|
||||
| `story/style_persona.py` | **Exp 6: Added `validate_persona()` — generates ~200-word sample, scores voice quality, rejects if < 7/10** |
|
||||
| `cli/engine.py` | Imported `build_persona_info`; persona cached before writing loop; rebuilt after `refine_persona()`; outline validation gate; `chapter_position` passed to `write_chapter()`; **Exp 6: persona retries up to 3× until validation passes; Exp 5: `analyze_consistency()` every 10 chapters** |
|
||||
| `docs/current_state_analysis.md` | New: Phase mapping with cost analysis |
|
||||
| `docs/alternatives_analysis.md` | New: 15 alternative approaches with hypotheses |
|
||||
| `docs/experiment_design.md` | New: 7 controlled A/B experiment specifications |
|
||||
| `ai_blueprint_v2.md` | This document |
|
||||
0
cli/__init__.py
Normal file
0
cli/__init__.py
Normal file
502
cli/engine.py
Normal file
502
cli/engine.py
Normal file
@@ -0,0 +1,502 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
import shutil
|
||||
from rich.prompt import Confirm
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import planner, writer as story_writer, editor as story_editor
|
||||
from story import style_persona, bible_tracker, state as story_state
|
||||
from story.writer import build_persona_info
|
||||
from marketing import assets as marketing_assets
|
||||
from export import exporter
|
||||
|
||||
|
||||
def process_book(bp, folder, context="", resume=False, interactive=False):
|
||||
# Create lock file to indicate active processing
|
||||
lock_path = os.path.join(folder, ".in_progress")
|
||||
with open(lock_path, "w") as f: f.write("running")
|
||||
|
||||
total_start = time.time()
|
||||
|
||||
try:
|
||||
# 1. Check completion
|
||||
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
|
||||
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
return
|
||||
|
||||
# 2. Load or Create Blueprint
|
||||
bp_path = os.path.join(folder, "blueprint_initial.json")
|
||||
t_step = time.time()
|
||||
utils.update_progress(5)
|
||||
utils.log("SYSTEM", "--- Phase: Blueprint ---")
|
||||
try:
|
||||
if resume and os.path.exists(bp_path):
|
||||
utils.log("RESUME", "Loading existing blueprint...")
|
||||
saved_bp = utils.load_json(bp_path)
|
||||
if saved_bp:
|
||||
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
|
||||
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
|
||||
if k in bp['book_metadata']:
|
||||
saved_bp['book_metadata'][k] = bp['book_metadata'][k]
|
||||
if 'series_metadata' in bp:
|
||||
saved_bp['series_metadata'] = bp['series_metadata']
|
||||
bp = saved_bp
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
else:
|
||||
bp = planner.enrich(bp, folder, context)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Ensure Persona Exists (Auto-create + Exp 6: Validate before accepting)
|
||||
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
|
||||
max_persona_attempts = 3
|
||||
for persona_attempt in range(1, max_persona_attempts + 1):
|
||||
candidate_persona = style_persona.create_initial_persona(bp, folder)
|
||||
is_valid, p_score = style_persona.validate_persona(bp, candidate_persona, folder)
|
||||
if is_valid or persona_attempt == max_persona_attempts:
|
||||
if not is_valid:
|
||||
utils.log("SYSTEM", f" ⚠️ Persona accepted after {max_persona_attempts} attempts despite low score ({p_score}/10). Voice drift risk elevated.")
|
||||
bp['book_metadata']['author_details'] = candidate_persona
|
||||
break
|
||||
utils.log("SYSTEM", f" -> Persona attempt {persona_attempt}/{max_persona_attempts} scored {p_score}/10. Regenerating...")
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
except Exception as _e:
|
||||
utils.log("ERROR", f"Blueprint phase failed: {type(_e).__name__}: {_e}")
|
||||
raise
|
||||
|
||||
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 3. Events (Plan & Expand)
|
||||
events_path = os.path.join(folder, "events.json")
|
||||
t_step = time.time()
|
||||
utils.update_progress(10)
|
||||
utils.log("SYSTEM", "--- Phase: Story Structure & Events ---")
|
||||
try:
|
||||
if resume and os.path.exists(events_path):
|
||||
utils.log("RESUME", "Loading existing events...")
|
||||
events = utils.load_json(events_path)
|
||||
else:
|
||||
events = planner.plan_structure(bp, folder)
|
||||
depth = bp['length_settings']['depth']
|
||||
target_chaps = bp['length_settings']['chapters']
|
||||
for d in range(1, depth+1):
|
||||
utils.log("SYSTEM", f" Expanding story structure depth {d}/{depth}...")
|
||||
events = planner.expand(events, d, target_chaps, bp, folder)
|
||||
time.sleep(1)
|
||||
with open(events_path, "w") as f: json.dump(events, f, indent=2)
|
||||
except Exception as _e:
|
||||
utils.log("ERROR", f"Events/Structure phase failed: {type(_e).__name__}: {_e}")
|
||||
raise
|
||||
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 4. Chapter Plan
|
||||
chapters_path = os.path.join(folder, "chapters.json")
|
||||
t_step = time.time()
|
||||
utils.update_progress(15)
|
||||
utils.log("SYSTEM", "--- Phase: Chapter Planning ---")
|
||||
try:
|
||||
if resume and os.path.exists(chapters_path):
|
||||
utils.log("RESUME", "Loading existing chapter plan...")
|
||||
chapters = utils.load_json(chapters_path)
|
||||
else:
|
||||
chapters = planner.create_chapter_plan(events, bp, folder)
|
||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||
except Exception as _e:
|
||||
utils.log("ERROR", f"Chapter planning phase failed: {type(_e).__name__}: {_e}")
|
||||
raise
|
||||
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 4b. Outline Validation Gate (Alt 2-B: pre-generation quality check)
|
||||
if chapters and not resume:
|
||||
try:
|
||||
planner.validate_outline(events, chapters, bp, folder)
|
||||
except Exception as _e:
|
||||
utils.log("ARCHITECT", f"Outline validation skipped: {_e}")
|
||||
|
||||
# 5. Writing Loop
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
loaded_ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else []
|
||||
ms = loaded_ms if loaded_ms is not None else []
|
||||
|
||||
# Load Tracking
|
||||
events_track_path = os.path.join(folder, "tracking_events.json")
|
||||
chars_track_path = os.path.join(folder, "tracking_characters.json")
|
||||
warn_track_path = os.path.join(folder, "tracking_warnings.json")
|
||||
lore_track_path = os.path.join(folder, "tracking_lore.json")
|
||||
|
||||
tracking = {"events": [], "characters": {}, "content_warnings": [], "lore": {}}
|
||||
if resume:
|
||||
if os.path.exists(events_track_path):
|
||||
tracking['events'] = utils.load_json(events_track_path)
|
||||
if os.path.exists(chars_track_path):
|
||||
tracking['characters'] = utils.load_json(chars_track_path)
|
||||
if os.path.exists(warn_track_path):
|
||||
tracking['content_warnings'] = utils.load_json(warn_track_path)
|
||||
if os.path.exists(lore_track_path):
|
||||
tracking['lore'] = utils.load_json(lore_track_path) or {}
|
||||
|
||||
# Load structured story state
|
||||
current_story_state = story_state.load_story_state(folder)
|
||||
|
||||
summary = "The story begins."
|
||||
if ms:
|
||||
utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
|
||||
try:
|
||||
selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms
|
||||
combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected])
|
||||
resp_sum = ai_models.model_writer.generate_content(f"""
|
||||
ROLE: Series Historian
|
||||
TASK: Create a cumulative 'Story So Far' summary.
|
||||
INPUT_TEXT:
|
||||
{combined_text}
|
||||
INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information.
|
||||
OUTPUT: Summary text.
|
||||
""")
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
|
||||
summary = resp_sum.text
|
||||
except: summary = "The story continues."
|
||||
|
||||
utils.log("SYSTEM", f"--- Phase: Writing ({len(chapters)} chapters planned) ---")
|
||||
t_step = time.time()
|
||||
session_chapters = 0
|
||||
session_time = 0
|
||||
|
||||
# Pre-load persona once for the entire writing phase (Alt 3-D: persona cache)
|
||||
# Rebuilt after each refine_persona() call to pick up bio updates.
|
||||
cached_persona = build_persona_info(bp)
|
||||
|
||||
i = len(ms)
|
||||
while i < len(chapters):
|
||||
ch_start = time.time()
|
||||
ch = chapters[i]
|
||||
|
||||
# Check for stop signal from Web UI
|
||||
run_dir = os.path.dirname(folder)
|
||||
if os.path.exists(os.path.join(run_dir, ".stop")):
|
||||
utils.log("SYSTEM", "Stop signal detected. Aborting generation.")
|
||||
break
|
||||
|
||||
# Robust Resume: Check if this specific chapter number is already in the manuscript
|
||||
if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Progress Banner
|
||||
utils.update_progress(15 + int((i / len(chapters)) * 75))
|
||||
utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}")
|
||||
|
||||
prev_content = ms[-1]['content'] if ms else None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Build context: use structured state if available, fall back to summary blob
|
||||
structured_ctx = story_state.format_for_prompt(current_story_state, ch.get('beats', []))
|
||||
if structured_ctx:
|
||||
summary_ctx = structured_ctx
|
||||
else:
|
||||
summary_ctx = summary[-8000:] if len(summary) > 8000 else summary
|
||||
next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else ""
|
||||
chap_pos = i / max(len(chapters) - 1, 1) if len(chapters) > 1 else 0.5
|
||||
txt = story_writer.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint, prebuilt_persona=cached_persona, chapter_position=chap_pos)
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Chapter generation failed: {e}")
|
||||
if interactive:
|
||||
if Confirm.ask("Generation failed (quality/error). Retry?", default=True):
|
||||
continue
|
||||
raise e
|
||||
|
||||
if interactive:
|
||||
print(f"\n--- Chapter {ch['chapter_number']} Preview ---\n{txt[:800]}...\n-------------------------------")
|
||||
if Confirm.ask(f"Accept Chapter {ch['chapter_number']}?", default=True):
|
||||
break
|
||||
else:
|
||||
utils.log("SYSTEM", "Regenerating chapter...")
|
||||
else:
|
||||
break
|
||||
|
||||
# Refine Persona to match the actual output (every 5 chapters)
|
||||
if (i == 0 or i % 5 == 0) and txt:
|
||||
pov_char = ch.get('pov_character')
|
||||
bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder, pov_character=pov_char)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
cached_persona = build_persona_info(bp) # Rebuild cache with updated bio
|
||||
|
||||
# Look ahead for context
|
||||
next_info = ""
|
||||
if i + 1 < len(chapters):
|
||||
next_ch = chapters[i+1]
|
||||
next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}"
|
||||
|
||||
try:
|
||||
update_prompt = f"""
|
||||
ROLE: Series Historian
|
||||
TASK: Update the 'Story So Far' summary to include the events of this new chapter.
|
||||
|
||||
INPUT_DATA:
|
||||
- CURRENT_SUMMARY:
|
||||
{summary}
|
||||
- NEW_CHAPTER_TEXT:
|
||||
{txt}
|
||||
- UPCOMING_CONTEXT_HINT: {next_info}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. STYLE: Dense, factual, chronological bullet points. Avoid narrative prose.
|
||||
2. CUMULATIVE: Do NOT remove old events. Append and integrate new information.
|
||||
3. TRACKING: Explicitly note who met whom, who knows what, and current locations.
|
||||
4. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved.
|
||||
|
||||
OUTPUT: Updated summary text.
|
||||
"""
|
||||
resp_sum = ai_models.model_writer.generate_content(update_prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
|
||||
summary = resp_sum.text
|
||||
except:
|
||||
try:
|
||||
resp_fallback = ai_models.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.")
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_fallback.usage_metadata)
|
||||
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
|
||||
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
|
||||
|
||||
ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt})
|
||||
|
||||
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
|
||||
utils.send_heartbeat() # Signal that the task is still alive
|
||||
|
||||
# Update Tracking
|
||||
tracking = bible_tracker.update_tracking(folder, ch['chapter_number'], txt, tracking)
|
||||
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
|
||||
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
||||
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
||||
|
||||
# Update Lore Index (Item 8: RAG-Lite) — every 3 chapters (lore is stable after ch 1-3)
|
||||
if i == 0 or i % 3 == 0:
|
||||
tracking['lore'] = bible_tracker.update_lore_index(folder, txt, tracking.get('lore', {}))
|
||||
with open(lore_track_path, "w") as f: json.dump(tracking['lore'], f, indent=2)
|
||||
|
||||
# Persist dynamic tracking changes back to the bible (Step 1: Bible-Tracking Merge)
|
||||
bp = bible_tracker.merge_tracking_to_bible(bp, tracking)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Update Structured Story State (Item 9: Thread Tracking)
|
||||
current_story_state = story_state.update_story_state(txt, ch['chapter_number'], current_story_state, folder)
|
||||
|
||||
# Exp 5: Mid-gen Consistency Snapshot (every 10 chapters)
|
||||
# Sample: first 2 + last 8 chapters to keep token cost bounded regardless of book length
|
||||
if len(ms) > 0 and len(ms) % 10 == 0:
|
||||
utils.log("EDITOR", f"--- Mid-gen consistency check after chapter {ch['chapter_number']} ({len(ms)} written) ---")
|
||||
try:
|
||||
ms_sample = (ms[:2] + ms[-8:]) if len(ms) > 10 else ms
|
||||
consistency = story_editor.analyze_consistency(bp, ms_sample, folder)
|
||||
issues = consistency.get('issues', [])
|
||||
if issues:
|
||||
for issue in issues:
|
||||
utils.log("EDITOR", f" ⚠️ {issue}")
|
||||
c_score = consistency.get('score', 'N/A')
|
||||
c_summary = consistency.get('summary', '')
|
||||
utils.log("EDITOR", f" Consistency score: {c_score}/10 — {c_summary}")
|
||||
except Exception as _ce:
|
||||
utils.log("EDITOR", f" Mid-gen consistency check failed (non-blocking): {_ce}")
|
||||
|
||||
# Dynamic Pacing Check (every other chapter)
|
||||
remaining = chapters[i+1:]
|
||||
if remaining and len(remaining) >= 2 and i % 2 == 1:
|
||||
pacing = story_editor.check_pacing(bp, summary, txt, ch, remaining, folder)
|
||||
if pacing and pacing.get('status') == 'add_bridge':
|
||||
new_data = pacing.get('new_chapter', {})
|
||||
if chapters:
|
||||
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
|
||||
else:
|
||||
avg_words = 1500
|
||||
new_ch = {
|
||||
"chapter_number": ch['chapter_number'] + 1,
|
||||
"title": new_data.get('title', 'Bridge Chapter'),
|
||||
"pov_character": new_data.get('pov_character', ch.get('pov_character')),
|
||||
"pacing": "Slow",
|
||||
"estimated_words": avg_words,
|
||||
"beats": new_data.get('beats', [])
|
||||
}
|
||||
chapters.insert(i+1, new_ch)
|
||||
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
|
||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||
utils.log("ARCHITECT", f" -> Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.")
|
||||
|
||||
elif pacing and pacing.get('status') == 'cut_next':
|
||||
removed = chapters.pop(i+1)
|
||||
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
|
||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||
utils.log("ARCHITECT", f" -> Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
|
||||
elif pacing:
|
||||
utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}")
|
||||
|
||||
# Increment loop
|
||||
i += 1
|
||||
|
||||
duration = time.time() - ch_start
|
||||
session_chapters += 1
|
||||
session_time += duration
|
||||
avg_time = session_time / session_chapters
|
||||
eta = avg_time * (len(chapters) - (i + 1))
|
||||
|
||||
prog = 15 + int((i / len(chapters)) * 75)
|
||||
utils.update_progress(prog)
|
||||
|
||||
word_count = len(txt.split()) if txt else 0
|
||||
utils.log("TIMING", f" -> Ch {ch['chapter_number']} done in {duration:.1f}s | {word_count:,} words | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s")
|
||||
|
||||
utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s")
|
||||
|
||||
# Post-Processing
|
||||
t_step = time.time()
|
||||
utils.log("SYSTEM", "--- Phase: Post-Processing (Harvest, Cover, Export) ---")
|
||||
try:
|
||||
utils.update_progress(92)
|
||||
utils.log("SYSTEM", " Harvesting metadata from manuscript...")
|
||||
bp = bible_tracker.harvest_metadata(bp, folder, ms)
|
||||
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
utils.update_progress(95)
|
||||
utils.log("SYSTEM", " Generating cover and marketing assets...")
|
||||
marketing_assets.create_marketing_assets(bp, folder, tracking, interactive=interactive)
|
||||
|
||||
utils.log("SYSTEM", " Updating author persona sample...")
|
||||
style_persona.update_persona_sample(bp, folder)
|
||||
|
||||
utils.update_progress(98)
|
||||
utils.log("SYSTEM", " Compiling final export files...")
|
||||
exporter.compile_files(bp, ms, folder)
|
||||
except Exception as _e:
|
||||
utils.log("ERROR", f"Post-processing phase failed: {type(_e).__name__}: {_e}")
|
||||
raise
|
||||
|
||||
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
|
||||
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
|
||||
|
||||
finally:
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
|
||||
|
||||
def run_generation(target=None, specific_run_id=None, interactive=False):
|
||||
utils.log("SYSTEM", "=== run_generation: Initialising AI models ===")
|
||||
ai_setup.init_models()
|
||||
|
||||
if not target: target = config.DEFAULT_BLUEPRINT
|
||||
data = utils.load_json(target)
|
||||
|
||||
if not data:
|
||||
utils.log("ERROR", f"Could not load bible/target: {target}")
|
||||
return
|
||||
|
||||
utils.log("SYSTEM", f"=== Starting Series Generation: {data.get('project_metadata', {}).get('title', 'Untitled')} ===")
|
||||
|
||||
project_dir = os.path.dirname(os.path.abspath(target))
|
||||
runs_base = os.path.join(project_dir, "runs")
|
||||
|
||||
run_dir = None
|
||||
resume_mode = False
|
||||
|
||||
if specific_run_id:
|
||||
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
|
||||
if not os.path.exists(run_dir): os.makedirs(run_dir)
|
||||
resume_mode = True
|
||||
else:
|
||||
latest_run = utils.get_latest_run_folder(runs_base)
|
||||
if latest_run:
|
||||
has_lock = False
|
||||
for root, dirs, files in os.walk(latest_run):
|
||||
if ".in_progress" in files:
|
||||
has_lock = True
|
||||
break
|
||||
|
||||
if has_lock:
|
||||
if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True):
|
||||
run_dir = latest_run
|
||||
resume_mode = True
|
||||
elif Confirm.ask(f"Delete artifacts in '{os.path.basename(latest_run)}' and start over?", default=False):
|
||||
shutil.rmtree(latest_run)
|
||||
os.makedirs(latest_run)
|
||||
run_dir = latest_run
|
||||
|
||||
if not run_dir: run_dir = utils.get_run_folder(runs_base)
|
||||
utils.log("SYSTEM", f"Run Directory: {run_dir}")
|
||||
|
||||
previous_context = ""
|
||||
|
||||
for i, book in enumerate(data['books']):
|
||||
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
|
||||
|
||||
if os.path.exists(os.path.join(run_dir, ".stop")):
|
||||
utils.log("SYSTEM", "Stop signal detected. Aborting series generation.")
|
||||
break
|
||||
|
||||
meta = data['project_metadata']
|
||||
bp = {
|
||||
"book_metadata": {
|
||||
"title": book.get('title'),
|
||||
"filename": book.get('filename'),
|
||||
"author": meta.get('author'),
|
||||
"genre": meta.get('genre'),
|
||||
"target_audience": meta.get('target_audience'),
|
||||
"style": meta.get('style', {}),
|
||||
"author_details": meta.get('author_details', {}),
|
||||
"author_bio": meta.get('author_bio', ''),
|
||||
},
|
||||
"length_settings": meta.get('length_settings', {}),
|
||||
"characters": data.get('characters', []),
|
||||
"manual_instruction": book.get('manual_instruction', ''),
|
||||
"plot_beats": book.get('plot_beats', []),
|
||||
"series_metadata": {
|
||||
"is_series": meta.get('is_series', False),
|
||||
"series_title": meta.get('title', ''),
|
||||
"book_number": book.get('book_number', i+1),
|
||||
"total_books": len(data['books'])
|
||||
}
|
||||
}
|
||||
|
||||
safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}"))
|
||||
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
|
||||
os.makedirs(book_folder, exist_ok=True)
|
||||
|
||||
utils.log("SYSTEM", f"--- Starting process_book for '{book.get('title')}' in {book_folder} ---")
|
||||
try:
|
||||
process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive)
|
||||
except Exception as _e:
|
||||
utils.log("ERROR", f"process_book failed for Book {book.get('book_number')}: {type(_e).__name__}: {_e}")
|
||||
raise
|
||||
utils.log("SYSTEM", f"--- Finished process_book for '{book.get('title')}' ---")
|
||||
|
||||
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
|
||||
if os.path.exists(final_bp_path):
|
||||
final_bp = utils.load_json(final_bp_path)
|
||||
|
||||
new_chars = final_bp.get('characters', [])
|
||||
|
||||
if os.path.exists(target):
|
||||
current_bible = utils.load_json(target)
|
||||
|
||||
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
|
||||
for char in new_chars:
|
||||
if char['name'].lower() not in existing_names:
|
||||
current_bible['characters'].append(char)
|
||||
|
||||
for b in current_bible.get('books', []):
|
||||
if b.get('book_number') == book.get('book_number'):
|
||||
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
|
||||
b['plot_beats'] = final_bp.get('plot_beats', b.get('plot_beats'))
|
||||
b['manual_instruction'] = final_bp.get('manual_instruction', b.get('manual_instruction'))
|
||||
break
|
||||
|
||||
with open(target, 'w') as f: json.dump(current_bible, f, indent=2)
|
||||
utils.log("SERIES", "Updated World Bible with new characters and plot data.")
|
||||
|
||||
last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book."
|
||||
previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}"
|
||||
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
run_generation(target_arg, interactive=True)
|
||||
@@ -1,32 +1,28 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import config
|
||||
import google.generativeai as genai
|
||||
from flask import Flask
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, IntPrompt, Confirm
|
||||
from rich.table import Table
|
||||
from modules import ai, utils
|
||||
from modules.web_db import db, User, Project
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from web.db import db, User, Project
|
||||
from marketing import cover as marketing_cover
|
||||
from export import exporter
|
||||
from cli.engine import run_generation
|
||||
|
||||
console = Console()
|
||||
genai.configure(api_key=config.API_KEY)
|
||||
|
||||
# Validate Key on Launch
|
||||
try:
|
||||
list(genai.list_models(page_size=1))
|
||||
ai_setup.init_models()
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]❌ CRITICAL: Gemini API Key check failed.[/bold red]")
|
||||
console.print(f"[bold red]CRITICAL: AI Model Initialization failed.[/bold red]")
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
console.print("Please check your .env file and ensure GEMINI_API_KEY is correct.")
|
||||
Prompt.ask("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
|
||||
logic_name = ai.get_optimal_model("pro") if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||
model = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
|
||||
# --- DB SETUP FOR WIZARD ---
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
|
||||
@@ -37,6 +33,7 @@ db.init_app(app)
|
||||
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
|
||||
|
||||
class BookWizard:
|
||||
def __init__(self):
|
||||
self.project_name = "New_Project"
|
||||
@@ -50,11 +47,10 @@ class BookWizard:
|
||||
utils.create_default_personas()
|
||||
|
||||
def _get_or_create_wizard_user(self):
|
||||
# Find or create a default user for CLI operations
|
||||
wizard_user = User.query.filter_by(username="wizard").first()
|
||||
if not wizard_user:
|
||||
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
|
||||
wizard_user = User(username="wizard", password="!", is_admin=True) # Password not used for CLI
|
||||
wizard_user = User(username="wizard", password="!", is_admin=True)
|
||||
db.session.add(wizard_user)
|
||||
db.session.commit()
|
||||
return wizard_user
|
||||
@@ -64,7 +60,7 @@ class BookWizard:
|
||||
def ask_gemini_json(self, prompt):
|
||||
text = None
|
||||
try:
|
||||
response = model.generate_content(prompt + "\nReturn ONLY valid JSON.")
|
||||
response = ai_models.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.")
|
||||
text = utils.clean_json(response.text)
|
||||
return json.loads(text)
|
||||
except Exception as e:
|
||||
@@ -74,7 +70,7 @@ class BookWizard:
|
||||
|
||||
def ask_gemini_text(self, prompt):
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
console.print(f"[red]AI Error: {e}[/red]")
|
||||
@@ -84,12 +80,12 @@ class BookWizard:
|
||||
while True:
|
||||
self.clear()
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
if os.path.exists(os.path.join(config.PERSONAS_DIR, "personas.json")):
|
||||
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
|
||||
|
||||
console.print(Panel("[bold cyan]🎭 Manage Author Personas[/bold cyan]"))
|
||||
console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]"))
|
||||
options = list(personas.keys())
|
||||
|
||||
for i, name in enumerate(options):
|
||||
@@ -108,11 +104,9 @@ class BookWizard:
|
||||
details = {}
|
||||
|
||||
if choice == len(options) + 1:
|
||||
# Create
|
||||
console.print("[yellow]Define New Persona[/yellow]")
|
||||
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
|
||||
else:
|
||||
# Edit/Delete Menu for specific persona
|
||||
selected_key = options[choice-1]
|
||||
details = personas[selected_key]
|
||||
if isinstance(details, str): details = {"bio": details}
|
||||
@@ -126,12 +120,11 @@ class BookWizard:
|
||||
if sub == 2:
|
||||
if Confirm.ask(f"Delete '{selected_key}'?", default=False):
|
||||
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
|
||||
elif sub == 3:
|
||||
continue
|
||||
|
||||
# Edit Fields
|
||||
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
|
||||
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
|
||||
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
|
||||
@@ -140,7 +133,6 @@ class BookWizard:
|
||||
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
|
||||
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
|
||||
|
||||
# Samples
|
||||
console.print("\n[bold]Style Samples[/bold]")
|
||||
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
|
||||
|
||||
@@ -153,12 +145,12 @@ class BookWizard:
|
||||
|
||||
if Confirm.ask("Save Persona?", default=True):
|
||||
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):
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel("[bold blue]🧙♂️ BookApp Setup Wizard[/bold blue]"))
|
||||
console.print(Panel("[bold blue]BookApp Setup Wizard[/bold blue]"))
|
||||
console.print("1. Create New Project")
|
||||
console.print("2. Open Existing Project")
|
||||
console.print("3. Manage Author Personas")
|
||||
@@ -173,16 +165,14 @@ class BookWizard:
|
||||
self.user = self._get_or_create_wizard_user()
|
||||
if self.open_existing_project(): return True
|
||||
elif choice == 3:
|
||||
# Personas don't need a user context
|
||||
self.manage_personas()
|
||||
else:
|
||||
return False
|
||||
|
||||
def create_new_project(self):
|
||||
self.clear()
|
||||
console.print(Panel("[bold green]🆕 New Project Setup[/bold green]"))
|
||||
console.print(Panel("[bold green]New Project Setup[/bold green]"))
|
||||
|
||||
# 1. Ask for Concept first to guide defaults
|
||||
console.print("Tell me about your story idea (or leave empty to start from scratch).")
|
||||
concept = Prompt.ask("Story Concept")
|
||||
|
||||
@@ -190,37 +180,41 @@ class BookWizard:
|
||||
if concept:
|
||||
with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"):
|
||||
prompt = f"""
|
||||
Analyze this story concept and suggest metadata for a book or series.
|
||||
ROLE: Publishing Analyst
|
||||
TASK: Suggest metadata for a story concept.
|
||||
|
||||
CONCEPT: {concept}
|
||||
|
||||
RETURN JSON with these keys:
|
||||
- title: Suggested book title
|
||||
- genre: Genre
|
||||
- target_audience: e.g. Adult, YA
|
||||
- tone: e.g. Dark, Whimsical
|
||||
- length_category: One of ["00", "0", "01", "1", "2", "2b", "3", "4", "5"] based on likely depth.
|
||||
- estimated_chapters: int (suggested chapter count)
|
||||
- estimated_word_count: string (e.g. "75,000")
|
||||
- include_prologue: boolean
|
||||
- include_epilogue: boolean
|
||||
- tropes: list of strings
|
||||
- pov_style: e.g. First Person
|
||||
- time_period: e.g. Modern
|
||||
- spice: e.g. Standard, Explicit
|
||||
- violence: e.g. None, Graphic
|
||||
- is_series: boolean
|
||||
- series_title: string (if series)
|
||||
- narrative_tense: e.g. Past, Present
|
||||
- language_style: e.g. Standard, Flowery
|
||||
- dialogue_style: e.g. Witty, Formal
|
||||
- page_orientation: Portrait, Landscape, or Square
|
||||
- formatting_rules: list of strings
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"title": "String",
|
||||
"genre": "String",
|
||||
"target_audience": "String",
|
||||
"tone": "String",
|
||||
"length_category": "String (Select code: '01'=Chapter Book, '1'=Flash Fiction, '2'=Short Story, '2b'=Young Adult, '3'=Novella, '4'=Novel, '5'=Epic)",
|
||||
"estimated_chapters": Int,
|
||||
"estimated_word_count": "String (e.g. '75,000')",
|
||||
"include_prologue": Bool,
|
||||
"include_epilogue": Bool,
|
||||
"tropes": ["String"],
|
||||
"pov_style": "String",
|
||||
"time_period": "String",
|
||||
"spice": "String",
|
||||
"violence": "String",
|
||||
"is_series": Bool,
|
||||
"series_title": "String",
|
||||
"narrative_tense": "String",
|
||||
"language_style": "String",
|
||||
"dialogue_style": "String",
|
||||
"page_orientation": "Portrait|Landscape|Square",
|
||||
"formatting_rules": ["String (e.g. 'Chapter Headers: Number + Title')"]
|
||||
}}
|
||||
"""
|
||||
suggestions = self.ask_gemini_json(prompt)
|
||||
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel("[bold green]🤖 AI Suggestions[/bold green]"))
|
||||
console.print(Panel("[bold green]AI Suggestions[/bold green]"))
|
||||
|
||||
grid = Table.grid(padding=(0, 2))
|
||||
grid.add_column(style="bold cyan")
|
||||
@@ -241,7 +235,6 @@ class BookWizard:
|
||||
grid.add_row("Length:", len_label)
|
||||
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
|
||||
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
|
||||
|
||||
grid.add_row("Tropes:", get_str('tropes'))
|
||||
grid.add_row("POV:", get_str('pov_style'))
|
||||
grid.add_row("Time:", get_str('time_period'))
|
||||
@@ -263,15 +256,18 @@ class BookWizard:
|
||||
instruction = Prompt.ask("Instruction (e.g. 'Make it darker', 'Change genre to Sci-Fi')")
|
||||
with console.status("[bold yellow]Refining suggestions...[/bold yellow]"):
|
||||
refine_prompt = f"""
|
||||
Update these project suggestions based on the user instruction.
|
||||
CURRENT JSON: {json.dumps(suggestions)}
|
||||
INSTRUCTION: {instruction}
|
||||
RETURN ONLY VALID JSON with the same keys.
|
||||
ROLE: Publishing Analyst
|
||||
TASK: Refine project metadata based on user instruction.
|
||||
|
||||
INPUT_DATA:
|
||||
- CURRENT_JSON: {json.dumps(suggestions)}
|
||||
- INSTRUCTION: {instruction}
|
||||
|
||||
OUTPUT_FORMAT (JSON): Same structure as input. Ensure length_category matches word count.
|
||||
"""
|
||||
new_sugg = self.ask_gemini_json(refine_prompt)
|
||||
if new_sugg: suggestions = new_sugg
|
||||
|
||||
# 2. Select Type (with AI default)
|
||||
default_type = "2" if suggestions.get('is_series') else "1"
|
||||
|
||||
console.print("1. Standalone Book")
|
||||
@@ -287,14 +283,13 @@ class BookWizard:
|
||||
return True
|
||||
|
||||
def open_existing_project(self):
|
||||
# Query projects from the database for the wizard user
|
||||
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
|
||||
if not projects:
|
||||
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
|
||||
Prompt.ask("Press Enter to continue...")
|
||||
return False
|
||||
|
||||
console.print("\n[bold cyan]📂 Select Project[/bold cyan]")
|
||||
console.print("\n[bold cyan]Select Project[/bold cyan]")
|
||||
for i, p in enumerate(projects):
|
||||
console.print(f"[{i+1}] {p.name}")
|
||||
|
||||
@@ -324,13 +319,12 @@ class BookWizard:
|
||||
|
||||
def configure_details(self, suggestions=None, concept="", is_series=False):
|
||||
if suggestions is None: suggestions = {}
|
||||
console.print("\n[bold blue]📝 Project Details[/bold blue]")
|
||||
console.print("\n[bold blue]Project Details[/bold blue]")
|
||||
|
||||
# Simplified Persona Selection (Skip creation)
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
if os.path.exists(os.path.join(config.PERSONAS_DIR, "personas.json")):
|
||||
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
|
||||
|
||||
author_details = {}
|
||||
@@ -361,10 +355,8 @@ class BookWizard:
|
||||
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
|
||||
|
||||
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
|
||||
# Create a copy so we don't modify the global definition
|
||||
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
|
||||
|
||||
# AI Defaults
|
||||
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
|
||||
def_words = suggestions.get('estimated_word_count', settings['words'])
|
||||
def_prologue = suggestions.get('include_prologue', False)
|
||||
@@ -375,8 +367,7 @@ class BookWizard:
|
||||
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
|
||||
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
|
||||
|
||||
# --- GENRE STANDARD CHECK ---
|
||||
# Parse current word count selection
|
||||
# Genre Standard Check
|
||||
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
|
||||
avg_words = 0
|
||||
if '-' in w_str:
|
||||
@@ -387,7 +378,6 @@ class BookWizard:
|
||||
try: avg_words = int(w_str.replace('k', '000'))
|
||||
except: pass
|
||||
|
||||
# Define rough standards
|
||||
std_target = 0
|
||||
g_lower = genre.lower()
|
||||
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
|
||||
@@ -396,9 +386,8 @@ class BookWizard:
|
||||
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
|
||||
|
||||
if std_target > 0 and avg_words > 0:
|
||||
# If difference is > 25%, warn user
|
||||
if abs(std_target - avg_words) / std_target > 0.25:
|
||||
console.print(f"\n[bold yellow]⚠️ Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
|
||||
console.print(f"\n[bold yellow]Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
|
||||
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
|
||||
settings['words'] = f"{std_target:,}"
|
||||
|
||||
@@ -408,15 +397,11 @@ class BookWizard:
|
||||
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
|
||||
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
|
||||
|
||||
# TITLE
|
||||
# If series, this is Series Title. If book, Book Title.
|
||||
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
|
||||
|
||||
# PROJECT NAME
|
||||
default_proj = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_") if title else "New_Project"
|
||||
default_proj = utils.sanitize_filename(title) if title else "New_Project"
|
||||
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
|
||||
|
||||
# Create Project in DB and set path
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
|
||||
if not os.path.exists(user_dir): os.makedirs(user_dir)
|
||||
|
||||
@@ -432,12 +417,10 @@ class BookWizard:
|
||||
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
|
||||
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
|
||||
|
||||
# POV SETTINGS
|
||||
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
|
||||
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
|
||||
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
|
||||
|
||||
# ADVANCED STYLE
|
||||
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
|
||||
|
||||
console.print("\n[bold]Content Guidelines[/bold]")
|
||||
@@ -450,7 +433,6 @@ class BookWizard:
|
||||
console.print("\n[bold]Formatting & World Rules[/bold]")
|
||||
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
|
||||
|
||||
# Visuals
|
||||
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
|
||||
|
||||
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
|
||||
@@ -458,7 +440,6 @@ class BookWizard:
|
||||
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
|
||||
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
|
||||
|
||||
# Update book_metadata with new fields
|
||||
style_data = {
|
||||
"tone": tone, "tropes": sel_tropes,
|
||||
"pov_style": pov_style, "pov_characters": pov_chars,
|
||||
@@ -480,7 +461,6 @@ class BookWizard:
|
||||
"style": style_data
|
||||
}
|
||||
|
||||
# Initialize Books List
|
||||
self.data['books'] = []
|
||||
if is_series:
|
||||
count = IntPrompt.ask("How many books in the series?", default=3)
|
||||
@@ -500,28 +480,25 @@ class BookWizard:
|
||||
})
|
||||
|
||||
def enrich_blueprint(self):
|
||||
console.print("\n[bold yellow]✨ Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
|
||||
console.print("\n[bold yellow]Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
|
||||
|
||||
prompt = f"""
|
||||
You are a Creative Director.
|
||||
Create a comprehensive Book Bible for the following project.
|
||||
ROLE: Creative Director
|
||||
TASK: Create a comprehensive Book Bible.
|
||||
|
||||
PROJECT METADATA: {json.dumps(self.data['project_metadata'])}
|
||||
EXISTING BOOKS STRUCTURE: {json.dumps(self.data['books'])}
|
||||
INPUT_DATA:
|
||||
- METADATA: {json.dumps(self.data['project_metadata'])}
|
||||
- BOOKS: {json.dumps(self.data['books'])}
|
||||
|
||||
TASK:
|
||||
1. Create a list of Main Characters (Global for the project).
|
||||
2. For EACH book in the 'books' list:
|
||||
- Generate a catchy Title (if not provided).
|
||||
- Write a 'manual_instruction' (Plot Summary).
|
||||
- Generate 'plot_beats' (10-15 chronological beats).
|
||||
INSTRUCTIONS:
|
||||
1. Create Main Characters.
|
||||
2. For EACH book: Generate Title, Plot Summary (manual_instruction), and 10-15 Plot Beats.
|
||||
|
||||
RETURN JSON in standard Bible format:
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"characters": [ {{ "name": "...", "role": "...", "description": "..." }} ],
|
||||
"characters": [ {{ "name": "String", "role": "String", "description": "String" }} ],
|
||||
"books": [
|
||||
{{ "book_number": 1, "title": "...", "manual_instruction": "...", "plot_beats": ["...", "..."] }},
|
||||
...
|
||||
{{ "book_number": Int, "title": "String", "manual_instruction": "String", "plot_beats": ["String"] }}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
@@ -529,9 +506,9 @@ class BookWizard:
|
||||
if new_data:
|
||||
if 'characters' in new_data:
|
||||
self.data['characters'] = new_data['characters']
|
||||
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:
|
||||
# Merge book data carefully
|
||||
ai_books = {b.get('book_number'): b for b in new_data['books']}
|
||||
for i, book in enumerate(self.data['books']):
|
||||
b_num = book.get('book_number', i+1)
|
||||
@@ -548,7 +525,6 @@ class BookWizard:
|
||||
length = meta.get('length_settings', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
# Metadata Grid
|
||||
grid = Table.grid(padding=(0, 2))
|
||||
grid.add_column(style="bold cyan")
|
||||
grid.add_column()
|
||||
@@ -558,37 +534,25 @@ class BookWizard:
|
||||
grid.add_row("Genre:", meta.get('genre', 'N/A'))
|
||||
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
|
||||
|
||||
# Dynamic Style Display
|
||||
# Define explicit order for common fields
|
||||
ordered_keys = [
|
||||
"tone", "pov_style", "pov_characters",
|
||||
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
|
||||
"tropes"
|
||||
]
|
||||
|
||||
defaults = {
|
||||
"tone": "Balanced",
|
||||
"pov_style": "Third Person Limited",
|
||||
"tense": "Past",
|
||||
"spice": "Standard",
|
||||
"violence": "Standard",
|
||||
"language": "Standard",
|
||||
"dialogue_style": "Standard",
|
||||
"time_period": "Modern",
|
||||
"page_orientation": "Portrait"
|
||||
"tone": "Balanced", "pov_style": "Third Person Limited", "tense": "Past",
|
||||
"spice": "Standard", "violence": "Standard", "language": "Standard",
|
||||
"dialogue_style": "Standard", "time_period": "Modern", "page_orientation": "Portrait"
|
||||
}
|
||||
|
||||
# 1. Show ordered keys first
|
||||
for k in ordered_keys:
|
||||
val = style.get(k)
|
||||
if val in [None, "", "N/A"]:
|
||||
val = defaults.get(k, 'N/A')
|
||||
|
||||
if isinstance(val, list): val = ", ".join(val)
|
||||
if isinstance(val, bool): val = "Yes" if val else "No"
|
||||
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
|
||||
|
||||
# 2. Show remaining keys
|
||||
for k, v in style.items():
|
||||
if k not in ordered_keys and k != 'formatting_rules':
|
||||
val = ", ".join(v) if isinstance(v, list) else str(v)
|
||||
@@ -602,29 +566,25 @@ class BookWizard:
|
||||
grid.add_row("Length:", len_str)
|
||||
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
|
||||
|
||||
console.print(Panel(grid, title="[bold blue]📖 Project Metadata[/bold blue]", expand=False))
|
||||
console.print(Panel(grid, title="[bold blue]Project Metadata[/bold blue]", expand=False))
|
||||
|
||||
# Formatting Rules Table
|
||||
fmt_rules = style.get('formatting_rules', [])
|
||||
if fmt_rules:
|
||||
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
|
||||
for i, r in enumerate(fmt_rules):
|
||||
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
|
||||
console.print(Panel(fmt_table, title="[bold blue]🎨 Formatting[/bold blue]"))
|
||||
console.print(Panel(fmt_table, title="[bold blue]Formatting[/bold blue]"))
|
||||
|
||||
# Characters Table
|
||||
char_table = Table(title="👥 Characters", show_header=True, header_style="bold magenta", expand=True)
|
||||
char_table = Table(title="Characters", show_header=True, header_style="bold magenta", expand=True)
|
||||
char_table.add_column("Name", style="green")
|
||||
char_table.add_column("Role")
|
||||
char_table.add_column("Description")
|
||||
for c in data.get('characters', []):
|
||||
# Removed truncation to show full description
|
||||
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
|
||||
console.print(char_table)
|
||||
|
||||
# Books List
|
||||
for book in data.get('books', []):
|
||||
console.print(f"\n[bold cyan]📘 Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
|
||||
console.print(f"\n[bold cyan]Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
|
||||
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
|
||||
|
||||
beats = book.get('plot_beats', [])
|
||||
@@ -637,26 +597,27 @@ class BookWizard:
|
||||
def refine_blueprint(self, title="Refine Blueprint"):
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]🔧 {title}[/bold blue]"))
|
||||
console.print(Panel(f"[bold blue]{title}[/bold blue]"))
|
||||
self.display_summary(self.data)
|
||||
console.print("\n[dim](Full JSON loaded)[/dim]")
|
||||
|
||||
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
|
||||
if change.lower() == 'done': break
|
||||
|
||||
# Inner loop for refinement
|
||||
current_data = self.data
|
||||
instruction = change
|
||||
|
||||
while True:
|
||||
with console.status("[bold green]AI is updating blueprint...[/bold green]"):
|
||||
prompt = f"""
|
||||
Act as a Book Editor.
|
||||
CURRENT JSON: {json.dumps(current_data)}
|
||||
USER INSTRUCTION: {instruction}
|
||||
ROLE: Senior Editor
|
||||
TASK: Update the Bible JSON based on instruction.
|
||||
|
||||
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
||||
RETURN ONLY THE JSON.
|
||||
INPUT_DATA:
|
||||
- CURRENT_JSON: {json.dumps(current_data)}
|
||||
- INSTRUCTION: {instruction}
|
||||
|
||||
OUTPUT_FORMAT (JSON): The full updated JSON object.
|
||||
"""
|
||||
new_data = self.ask_gemini_json(prompt)
|
||||
|
||||
@@ -665,7 +626,7 @@ class BookWizard:
|
||||
break
|
||||
|
||||
self.clear()
|
||||
console.print(Panel("[bold blue]👀 Review AI Changes[/bold blue]"))
|
||||
console.print(Panel("[bold blue]Review AI Changes[/bold blue]"))
|
||||
self.display_summary(new_data)
|
||||
|
||||
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
|
||||
@@ -687,15 +648,14 @@ class BookWizard:
|
||||
filename = os.path.join(self.project_path, "bible.json")
|
||||
|
||||
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
|
||||
console.print(Panel(f"[bold green]✅ Bible saved to: {filename}[/bold green]"))
|
||||
console.print(Panel(f"[bold green]Bible saved to: {filename}[/bold green]"))
|
||||
return filename
|
||||
|
||||
def manage_runs(self, job_filename):
|
||||
job_name = os.path.splitext(job_filename)[0]
|
||||
runs_dir = os.path.join(self.project_path, "runs", job_name)
|
||||
def manage_runs(self):
|
||||
runs_dir = os.path.join(self.project_path, "runs")
|
||||
|
||||
if not os.path.exists(runs_dir):
|
||||
console.print("[red]No runs found for this job.[/red]")
|
||||
console.print("[red]No runs found for this project.[/red]")
|
||||
Prompt.ask("Press Enter...")
|
||||
return
|
||||
|
||||
@@ -708,7 +668,7 @@ class BookWizard:
|
||||
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]Runs for: {job_name}[/bold blue]"))
|
||||
console.print(Panel(f"[bold blue]Runs for: {self.project_name}[/bold blue]"))
|
||||
for i, r in enumerate(runs):
|
||||
console.print(f"[{i+1}] {r}")
|
||||
console.print(f"[{len(runs)+1}] Back")
|
||||
@@ -720,7 +680,6 @@ class BookWizard:
|
||||
|
||||
selected_run = runs[choice-1]
|
||||
run_path = os.path.join(runs_dir, selected_run)
|
||||
|
||||
self.manage_specific_run(run_path)
|
||||
|
||||
def manage_specific_run(self, run_path):
|
||||
@@ -728,7 +687,6 @@ class BookWizard:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
|
||||
|
||||
# Detect sub-books (Series Run)
|
||||
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
|
||||
|
||||
if subdirs:
|
||||
@@ -755,10 +713,6 @@ class BookWizard:
|
||||
break
|
||||
elif choice == idx_exit:
|
||||
sys.exit()
|
||||
else:
|
||||
# Legacy or Flat Run
|
||||
self.manage_single_book_folder(run_path)
|
||||
break
|
||||
|
||||
def manage_single_book_folder(self, folder_path):
|
||||
while True:
|
||||
@@ -771,7 +725,6 @@ class BookWizard:
|
||||
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
|
||||
|
||||
if choice == 1:
|
||||
import main
|
||||
bp_path = os.path.join(folder_path, "final_blueprint.json")
|
||||
ms_path = os.path.join(folder_path, "manuscript.json")
|
||||
|
||||
@@ -780,7 +733,6 @@ class BookWizard:
|
||||
with open(bp_path, 'r') as f: bp = json.load(f)
|
||||
with open(ms_path, 'r') as f: ms = json.load(f)
|
||||
|
||||
# Check/Generate Tracking
|
||||
events_path = os.path.join(folder_path, "tracking_events.json")
|
||||
chars_path = os.path.join(folder_path, "tracking_characters.json")
|
||||
tracking = {"events": [], "characters": {}}
|
||||
@@ -788,10 +740,9 @@ class BookWizard:
|
||||
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
|
||||
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
|
||||
|
||||
main.ai.init_models()
|
||||
ai_setup.init_models()
|
||||
|
||||
if not tracking['events'] and not tracking['characters']:
|
||||
# Fallback: Use Blueprint data
|
||||
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
|
||||
tracking['events'] = bp.get('plot_beats', [])
|
||||
tracking['characters'] = {}
|
||||
@@ -805,8 +756,8 @@ class BookWizard:
|
||||
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
|
||||
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
|
||||
|
||||
main.marketing.generate_cover(bp, folder_path, tracking)
|
||||
main.export.compile_files(bp, ms, folder_path)
|
||||
marketing_cover.generate_cover(bp, folder_path, tracking)
|
||||
exporter.compile_files(bp, ms, folder_path)
|
||||
console.print("[green]Cover updated and EPUB recompiled![/green]")
|
||||
Prompt.ask("Press Enter...")
|
||||
else:
|
||||
@@ -823,6 +774,7 @@ class BookWizard:
|
||||
else:
|
||||
os.system(f"open '{path}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
w = BookWizard()
|
||||
with app.app_context():
|
||||
@@ -830,7 +782,7 @@ if __name__ == "__main__":
|
||||
if w.select_mode():
|
||||
while True:
|
||||
w.clear()
|
||||
console.print(Panel(f"[bold blue]📂 Project: {w.project_name}[/bold blue]"))
|
||||
console.print(Panel(f"[bold blue]Project: {w.project_name}[/bold blue]"))
|
||||
console.print("1. Edit Bible")
|
||||
console.print("2. Run Book Generation")
|
||||
console.print("3. Manage Runs")
|
||||
@@ -845,14 +797,10 @@ if __name__ == "__main__":
|
||||
elif choice == 2:
|
||||
if w.load_bible():
|
||||
bible_path = os.path.join(w.project_path, "bible.json")
|
||||
import main
|
||||
main.run_generation(bible_path)
|
||||
run_generation(bible_path, interactive=True)
|
||||
Prompt.ask("\nGeneration complete. Press Enter...")
|
||||
elif choice == 3:
|
||||
# Manage runs for the bible
|
||||
w.manage_runs("bible.json")
|
||||
w.manage_runs()
|
||||
else:
|
||||
break
|
||||
else:
|
||||
pass
|
||||
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Ensure .env is loaded from the script's directory (VS Code fix)
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
# __file__ is core/config.py; app root is one level up
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE_DIR = os.path.dirname(_HERE)
|
||||
|
||||
# Ensure .env is loaded from the app root
|
||||
load_dotenv(os.path.join(BASE_DIR, ".env"))
|
||||
|
||||
def get_clean_env(key, default=None):
|
||||
val = os.getenv(key, default)
|
||||
@@ -14,6 +18,7 @@ GCP_LOCATION = get_clean_env("GCP_LOCATION", "us-central1")
|
||||
MODEL_LOGIC_HINT = get_clean_env("MODEL_LOGIC", "AUTO")
|
||||
MODEL_WRITER_HINT = get_clean_env("MODEL_WRITER", "AUTO")
|
||||
MODEL_ARTIST_HINT = get_clean_env("MODEL_ARTIST", "AUTO")
|
||||
MODEL_IMAGE_HINT = get_clean_env("MODEL_IMAGE", "AUTO")
|
||||
DEFAULT_BLUEPRINT = "book_def.json"
|
||||
|
||||
# --- SECURITY & ADMIN ---
|
||||
@@ -21,33 +26,33 @@ FLASK_SECRET = get_clean_env("FLASK_SECRET_KEY", "dev-secret-key-change-this")
|
||||
ADMIN_USER = get_clean_env("ADMIN_USERNAME")
|
||||
ADMIN_PASSWORD = get_clean_env("ADMIN_PASSWORD")
|
||||
|
||||
if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
|
||||
if FLASK_SECRET == "dev-secret-key-change-this":
|
||||
print("WARNING: Using default FLASK_SECRET_KEY. This is insecure for production.")
|
||||
|
||||
if not API_KEY: raise ValueError("CRITICAL ERROR: GEMINI_API_KEY not found in environment or .env file.")
|
||||
|
||||
# --- DATA DIRECTORIES ---
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
||||
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")
|
||||
|
||||
# --- ENSURE DIRECTORIES EXIST ---
|
||||
# Critical: Create data folders immediately to prevent DB initialization errors
|
||||
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
|
||||
if not os.path.exists(d): os.makedirs(d, exist_ok=True)
|
||||
|
||||
# --- AUTHENTICATION ---
|
||||
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
if GOOGLE_CREDS:
|
||||
# Resolve to absolute path relative to this config file if not absolute
|
||||
if not os.path.isabs(GOOGLE_CREDS):
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS)
|
||||
GOOGLE_CREDS = os.path.join(BASE_DIR, GOOGLE_CREDS)
|
||||
|
||||
if os.path.exists(GOOGLE_CREDS):
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS
|
||||
else:
|
||||
print(f"⚠️ Warning: GOOGLE_APPLICATION_CREDENTIALS file not found at: {GOOGLE_CREDS}")
|
||||
print(f"Warning: GOOGLE_APPLICATION_CREDENTIALS file not found at: {GOOGLE_CREDS}")
|
||||
|
||||
# --- DEFINITIONS ---
|
||||
LENGTH_DEFINITIONS = {
|
||||
@@ -59,3 +64,6 @@ LENGTH_DEFINITIONS = {
|
||||
"4": {"label": "Novel", "words": "60,000 - 80,000", "chapters": 30, "depth": 3},
|
||||
"5": {"label": "Epic", "words": "100,000+", "chapters": 50, "depth": 4}
|
||||
}
|
||||
|
||||
# --- SYSTEM ---
|
||||
VERSION = "3.1"
|
||||
294
core/utils.py
Normal file
294
core/utils.py
Normal file
@@ -0,0 +1,294 @@
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import time
|
||||
import hashlib
|
||||
from core import config
|
||||
import threading
|
||||
import re
|
||||
|
||||
SAFETY_SETTINGS = [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||||
]
|
||||
|
||||
# Thread-local storage for logging context
|
||||
_log_context = threading.local()
|
||||
|
||||
# Cache for dynamic pricing from AI model selection
|
||||
PRICING_CACHE = {}
|
||||
|
||||
# --- Token Estimation & Truncation Utilities ---
|
||||
|
||||
def estimate_tokens(text):
|
||||
"""Estimate token count using a 3.5-chars-per-token heuristic (more accurate than /4)."""
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, int(len(text) / 3.5))
|
||||
|
||||
def truncate_to_tokens(text, max_tokens, keep_head=False):
|
||||
"""Truncate text to approximately max_tokens.
|
||||
|
||||
keep_head=False (default): keep the most recent (tail) content — good for 'story so far'.
|
||||
keep_head=True: keep first third + last two thirds — good for context that needs both
|
||||
the opening framing and the most recent events.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
max_chars = int(max_tokens * 3.5)
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
if keep_head:
|
||||
head_chars = max_chars // 3
|
||||
tail_chars = max_chars - head_chars
|
||||
return text[:head_chars] + "\n[...]\n" + text[-tail_chars:]
|
||||
return text[-max_chars:]
|
||||
|
||||
# --- In-Memory AI Response Cache ---
|
||||
|
||||
_AI_CACHE = {}
|
||||
|
||||
def get_ai_cache(key):
|
||||
"""Retrieve a cached AI response by key. Returns None if not cached."""
|
||||
return _AI_CACHE.get(key)
|
||||
|
||||
def set_ai_cache(key, value):
|
||||
"""Store an AI response in the in-memory cache keyed by a hash string."""
|
||||
_AI_CACHE[key] = value
|
||||
|
||||
def make_cache_key(prefix, *parts):
|
||||
"""Build a stable MD5 cache key from a prefix and variable string parts."""
|
||||
raw = "|".join(str(p) for p in parts)
|
||||
return f"{prefix}:{hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()}"
|
||||
|
||||
def set_log_file(filepath):
|
||||
_log_context.log_file = filepath
|
||||
|
||||
def set_log_callback(callback):
|
||||
_log_context.callback = callback
|
||||
|
||||
def set_progress_callback(callback):
|
||||
_log_context.progress_callback = callback
|
||||
|
||||
def set_heartbeat_callback(callback):
|
||||
_log_context.heartbeat_callback = callback
|
||||
|
||||
def update_progress(percent):
|
||||
if getattr(_log_context, 'progress_callback', None):
|
||||
try: _log_context.progress_callback(percent)
|
||||
except: pass
|
||||
|
||||
def send_heartbeat():
|
||||
if getattr(_log_context, 'heartbeat_callback', None):
|
||||
try: _log_context.heartbeat_callback()
|
||||
except: pass
|
||||
|
||||
def clean_json(text):
|
||||
text = text.replace("```json", "").replace("```", "").strip()
|
||||
start_obj = text.find('{')
|
||||
start_arr = text.find('[')
|
||||
if start_obj == -1 and start_arr == -1: return text
|
||||
if start_obj != -1 and (start_arr == -1 or start_obj < start_arr):
|
||||
return text[start_obj:text.rfind('}')+1]
|
||||
else:
|
||||
return text[start_arr:text.rfind(']')+1]
|
||||
|
||||
def sanitize_filename(name):
|
||||
if not name: return "Untitled"
|
||||
safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_")
|
||||
return safe if safe else "Untitled"
|
||||
|
||||
def chapter_sort_key(ch):
|
||||
num = ch.get('num', 0)
|
||||
if isinstance(num, int): return num
|
||||
if isinstance(num, str) and num.isdigit(): return int(num)
|
||||
s = str(num).lower().strip()
|
||||
if 'prologue' in s: return -1
|
||||
if 'epilogue' in s: return 9999
|
||||
return 999
|
||||
|
||||
def get_sorted_book_folders(run_dir):
|
||||
if not os.path.exists(run_dir): return []
|
||||
subdirs = [d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]
|
||||
def sort_key(d):
|
||||
parts = d.split('_')
|
||||
if len(parts) > 1 and parts[1].isdigit(): return int(parts[1])
|
||||
return 0
|
||||
return sorted(subdirs, key=sort_key)
|
||||
|
||||
def log_banner(phase, title):
|
||||
log(phase, f"{'─' * 18} {title} {'─' * 18}")
|
||||
|
||||
def log(phase, msg):
|
||||
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
line = f"[{timestamp}] {phase:<15} | {msg}"
|
||||
print(line)
|
||||
|
||||
if getattr(_log_context, 'log_file', None):
|
||||
with open(_log_context.log_file, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
if getattr(_log_context, 'callback', None):
|
||||
try: _log_context.callback(phase, msg)
|
||||
except: pass
|
||||
|
||||
def load_json(path):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError, ValueError) as e:
|
||||
log("SYSTEM", f"⚠️ Failed to load JSON from {path}: {e}")
|
||||
return None
|
||||
|
||||
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)
|
||||
|
||||
def get_length_presets():
|
||||
presets = {}
|
||||
for k, v in config.LENGTH_DEFINITIONS.items():
|
||||
presets[v['label']] = v
|
||||
return presets
|
||||
|
||||
def log_image_attempt(folder, img_type, prompt, filename, status, error=None, score=None, critique=None):
|
||||
log_path = os.path.join(folder, "image_log.json")
|
||||
entry = {
|
||||
"timestamp": int(time.time()),
|
||||
"type": img_type,
|
||||
"prompt": prompt,
|
||||
"filename": filename,
|
||||
"status": status,
|
||||
"error": str(error) if error else None,
|
||||
"score": score,
|
||||
"critique": critique
|
||||
}
|
||||
data = []
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = [] # Corrupted log — start fresh rather than crash
|
||||
data.append(entry)
|
||||
with open(log_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def get_run_folder(base_name):
|
||||
if not os.path.exists(base_name): os.makedirs(base_name)
|
||||
runs = [d for d in os.listdir(base_name) if d.startswith("run_")]
|
||||
next_num = max([int(r.split("_")[1]) for r in runs if r.split("_")[1].isdigit()] + [0]) + 1
|
||||
folder = os.path.join(base_name, f"run_{next_num}")
|
||||
os.makedirs(folder)
|
||||
return folder
|
||||
|
||||
def get_latest_run_folder(base_name):
|
||||
if not os.path.exists(base_name): return None
|
||||
runs = [d for d in os.listdir(base_name) if d.startswith("run_")]
|
||||
if not runs: return None
|
||||
runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
return os.path.join(base_name, runs[-1])
|
||||
|
||||
def update_pricing(model_name, cost_str):
|
||||
if not model_name or not cost_str or cost_str == 'N/A': return
|
||||
try:
|
||||
in_cost = 0.0
|
||||
out_cost = 0.0
|
||||
prices = re.findall(r'(?:\$|USD)\s*([0-9]+\.?[0-9]*)', cost_str, re.IGNORECASE)
|
||||
if len(prices) >= 2:
|
||||
in_cost = float(prices[0])
|
||||
out_cost = float(prices[1])
|
||||
elif len(prices) == 1:
|
||||
in_cost = float(prices[0])
|
||||
out_cost = in_cost * 3
|
||||
if in_cost > 0:
|
||||
PRICING_CACHE[model_name] = {"input": in_cost, "output": out_cost}
|
||||
except:
|
||||
pass
|
||||
|
||||
def calculate_cost(model_label, input_tokens, output_tokens, image_count=0):
|
||||
cost = 0.0
|
||||
m = model_label.lower()
|
||||
|
||||
if model_label in PRICING_CACHE:
|
||||
rates = PRICING_CACHE[model_label]
|
||||
cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output'])
|
||||
elif 'imagen' in m or image_count > 0:
|
||||
cost = (image_count * 0.04)
|
||||
else:
|
||||
if 'flash' in m:
|
||||
cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30)
|
||||
elif 'pro' in m or 'logic' in m:
|
||||
cost = (input_tokens / 1_000_000 * 3.50) + (output_tokens / 1_000_000 * 10.50)
|
||||
|
||||
return round(cost, 6)
|
||||
|
||||
def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
||||
if not folder or not os.path.exists(folder): return
|
||||
|
||||
log_path = os.path.join(folder, "usage_log.json")
|
||||
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
|
||||
if usage_metadata:
|
||||
try:
|
||||
input_tokens = usage_metadata.prompt_token_count or 0
|
||||
output_tokens = usage_metadata.candidates_token_count or 0
|
||||
except AttributeError:
|
||||
pass # usage_metadata shape varies by model; tokens stay 0
|
||||
|
||||
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
|
||||
|
||||
entry = {
|
||||
"timestamp": int(time.time()),
|
||||
"date": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"model": model_label,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"images": image_count,
|
||||
"cost": round(cost, 6)
|
||||
}
|
||||
|
||||
data = {"log": [], "totals": {"input_tokens": 0, "output_tokens": 0, "images": 0, "est_cost_usd": 0.0}}
|
||||
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
loaded = json.load(open(log_path, 'r'))
|
||||
if isinstance(loaded, list): data["log"] = loaded
|
||||
elif isinstance(loaded, dict): data = loaded
|
||||
except: pass
|
||||
|
||||
data["log"].append(entry)
|
||||
|
||||
t_in = sum(x.get('input_tokens', 0) for x in data["log"])
|
||||
t_out = sum(x.get('output_tokens', 0) for x in data["log"])
|
||||
t_img = sum(x.get('images', 0) for x in data["log"])
|
||||
|
||||
total_cost = 0.0
|
||||
for x in data["log"]:
|
||||
if 'cost' in x:
|
||||
total_cost += x['cost']
|
||||
else:
|
||||
c = 0.0
|
||||
mx = x.get('model', '').lower()
|
||||
ix = x.get('input_tokens', 0)
|
||||
ox = x.get('output_tokens', 0)
|
||||
imgx = x.get('images', 0)
|
||||
|
||||
if 'flash' in mx: c = (ix / 1_000_000 * 0.075) + (ox / 1_000_000 * 0.30)
|
||||
elif 'pro' in mx or 'logic' in mx: c = (ix / 1_000_000 * 3.50) + (ox / 1_000_000 * 10.50)
|
||||
elif 'imagen' in mx or imgx > 0: c = (imgx * 0.04)
|
||||
total_cost += c
|
||||
|
||||
data["totals"] = {
|
||||
"input_tokens": t_in,
|
||||
"output_tokens": t_out,
|
||||
"images": t_img,
|
||||
"est_cost_usd": round(total_cost, 4)
|
||||
}
|
||||
|
||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
||||
@@ -18,16 +18,33 @@ services:
|
||||
# --- DEVELOPMENT (Code Sync) ---
|
||||
# UNCOMMENT these lines only if you are developing and want to see changes instantly.
|
||||
# For production/deployment, keep them commented out so the container uses the built image code.
|
||||
# - ./modules:/app/modules
|
||||
# - ./core:/app/core
|
||||
# - ./ai:/app/ai
|
||||
# - ./story:/app/story
|
||||
# - ./marketing:/app/marketing
|
||||
# - ./export:/app/export
|
||||
# - ./web:/app/web
|
||||
# - ./cli:/app/cli
|
||||
# - ./templates:/app/templates
|
||||
# - ./main.py:/app/main.py
|
||||
# - ./wizard.py:/app/wizard.py
|
||||
# - ./config.py:/app/config.py
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- PYTHONIOENCODING=utf-8
|
||||
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json
|
||||
- PYTHONPATH=/app
|
||||
- FLASK_SECRET_KEY=change_this_to_a_random_string
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=change_me_in_portainer
|
||||
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-change_this_to_a_random_string}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-change_me_in_portainer}
|
||||
- FLASK_DEBUG=${FLASK_DEBUG:-False}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
- GCP_PROJECT=${GCP_PROJECT:-}
|
||||
- GCP_LOCATION=${GCP_LOCATION:-us-central1}
|
||||
- MODEL_LOGIC=${MODEL_LOGIC:-AUTO}
|
||||
- MODEL_WRITER=${MODEL_WRITER:-AUTO}
|
||||
- MODEL_ARTIST=${MODEL_ARTIST:-AUTO}
|
||||
- MODEL_IMAGE=${MODEL_IMAGE:-AUTO}
|
||||
# Keep Docker logs bounded so they don't fill the Pi's SD card.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
264
docs/alternatives_analysis.md
Normal file
264
docs/alternatives_analysis.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Alternatives Analysis: Hypotheses for Each Phase
|
||||
|
||||
**Date:** 2026-02-22
|
||||
**Status:** Completed — fulfills Action Plan Step 2
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
For each phase, we present the current approach, document credible alternatives, and state a testable hypothesis about cost and quality impact. Each alternative is rated for implementation complexity and expected payoff.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation & Ideation
|
||||
|
||||
### Current Approach
|
||||
A single Logic-model call expands a minimal user prompt into `book_metadata`, `characters`, and `plot_beats`. The author persona is created in a separate single-pass call.
|
||||
|
||||
---
|
||||
|
||||
### Alt 1-A: Dynamic Bible (Just-In-Time Generation)
|
||||
|
||||
**Description:** Instead of creating the full bible upfront, generate only world rules and core character archetypes at start. Flesh out secondary characters and specific locations only when the planner references them during outlining.
|
||||
|
||||
**Mechanism:**
|
||||
1. Upfront: title, genre, tone, 1–2 core characters, 3 immutable world rules
|
||||
2. During `expand()`: When a new location/character appears in events, call a mini-enrichment to define them
|
||||
3. Benefits: Only define what's actually used; no wasted detail on characters who don't appear
|
||||
|
||||
**Hypothesis:** Dynamic bible reduces Phase 1 token cost by ~30% and improves character coherence because every detail is tied to a specific narrative purpose. May increase Phase 2 cost by ~15% due to incremental enrichment calls.
|
||||
|
||||
**Complexity:** Medium — requires refactoring `planner.py` to support on-demand enrichment
|
||||
|
||||
**Risk:** New characters generated mid-outline might not be coherent with established world
|
||||
|
||||
---
|
||||
|
||||
### Alt 1-B: Lean Bible (Rules + Emergence)
|
||||
|
||||
**Description:** Define only immutable "physics" of the world (e.g., "no magic exists", "set in 1920s London") and let all characters and plot details emerge from the writing process. Only characters explicitly named by the user are pre-defined.
|
||||
|
||||
**Hypothesis:** Lean bible reduces Phase 1 cost by ~60% but increases Phase 3 cost by ~25% (more continuity errors require more evaluation retries). Net effect depends on how many characters the user pre-defines.
|
||||
|
||||
**Complexity:** Low — strip `enrich()` down to essentials
|
||||
|
||||
**Risk:** Characters might be inconsistent across chapters without a shared bible anchor
|
||||
|
||||
---
|
||||
|
||||
### Alt 1-C: Iterative Persona Validation
|
||||
|
||||
**Description:** After `create_initial_persona()`, immediately generate a 200-word sample passage in that persona's voice and evaluate it with the editor. Only accept the persona if the sample scores ≥ 7/10.
|
||||
|
||||
**Hypothesis:** Iterative persona validation adds ~8K tokens to Phase 1 but reduces Phase 3 persona-related rewrite rate by ~20% (fewer voice-drift refinements needed).
|
||||
|
||||
**Complexity:** Low — add one evaluation call after persona creation
|
||||
|
||||
**Risk:** Minimal — only adds cost if persona is rejected
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Structuring & Outlining
|
||||
|
||||
### Current Approach
|
||||
Sequential depth-expansion passes convert plot beats into a chapter plan. Each `expand()` call is unaware of the final desired state, so multiple passes are needed.
|
||||
|
||||
---
|
||||
|
||||
### Alt 2-A: Single-Pass Hierarchical Outline
|
||||
|
||||
**Description:** Replace sequential `expand()` calls with a single multi-step prompt that builds the outline in one shot — specifying the desired depth level in the instructions. The model produces both high-level events and chapter-level detail simultaneously.
|
||||
|
||||
**Hypothesis:** Single-pass outline reduces Phase 2 Logic calls from 6 to 2 (one `plan_structure`, one combined `expand+chapter_plan`), saving ~60K tokens (~45% Phase 2 cost). Quality may drop slightly if the model can't maintain coherence across 50 chapters in one response.
|
||||
|
||||
**Complexity:** Low — prompt rewrite; no code structure change
|
||||
|
||||
**Risk:** Large single-response JSON might fail or be truncated by model. Novel (30 chapters) is manageable; Epic (50 chapters) is borderline.
|
||||
|
||||
---
|
||||
|
||||
### Alt 2-B: Outline Validation Gate
|
||||
|
||||
**Description:** After `create_chapter_plan()`, run a validation call that checks the outline for: (a) missing required plot beats, (b) character deaths/revivals, (c) pacing imbalances, (d) POV distribution. Block writing phase until outline passes validation.
|
||||
|
||||
**Hypothesis:** Pre-generation outline validation (1 Logic call, ~15K tokens, FREE on Pro-Exp) prevents ~3–5 expensive rewrite cycles during Phase 3, saving 75K–125K Writer tokens (~$0.05–$0.10 per book).
|
||||
|
||||
**Complexity:** Low — add `validate_outline()` function, call it before Phase 3 begins
|
||||
|
||||
**Risk:** Validation might be overly strict and reject valid creative choices
|
||||
|
||||
---
|
||||
|
||||
### Alt 2-C: Dynamic Personas (Mood/POV Adaptation)
|
||||
|
||||
**Description:** Instead of a single author persona, create sub-personas for different scene types: (a) action sequences, (b) introspection/emotion, (c) dialogue-heavy scenes. The writer prompt selects the appropriate sub-persona based on chapter pacing.
|
||||
|
||||
**Hypothesis:** Dynamic personas reduce "voice drift" across different scene types, improving average chapter evaluation score by ~0.3 points. Cost increases by ~12K tokens/book for the additional persona generation calls.
|
||||
|
||||
**Complexity:** Medium — requires sub-persona generation, storage, and selection logic in `write_chapter()`
|
||||
|
||||
**Risk:** Sub-personas might be inconsistent with each other if not carefully designed
|
||||
|
||||
---
|
||||
|
||||
### Alt 2-D: Specialized Chapter Templates
|
||||
|
||||
**Description:** Create genre-specific "chapter templates" for common patterns: opening chapters, mid-point reversals, climax chapters, denouements. The planner selects the appropriate template when assigning structure, reducing the amount of creative work needed per chapter.
|
||||
|
||||
**Hypothesis:** Chapter templates reduce Phase 3 beat expansion cost by ~40% (pre-structured templates need less expansion) and reduce rewrite rate by ~15% (templates encode known-good patterns).
|
||||
|
||||
**Complexity:** Medium — requires template library and selection logic
|
||||
|
||||
**Risk:** Templates might make books feel formulaic
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The Writing Engine
|
||||
|
||||
### Current Approach
|
||||
Single-model drafting with up to 3 attempts. Low-scoring drafts trigger full rewrites using the Pro model. Evaluation happens after each draft.
|
||||
|
||||
---
|
||||
|
||||
### Alt 3-A: Two-Pass Drafting (Cheap Draft + Expensive Polish)
|
||||
|
||||
**Description:** Use the cheapest available Flash model for a rough first draft (focused on getting beats covered and word count right), then use the Pro model to polish prose quality. Skip the evaluation + rewrite loop entirely.
|
||||
|
||||
**Hypothesis:** Two-pass drafting reduces average chapter evaluation score variance (fewer very-low scores), but might be slower because every chapter gets polished regardless of quality. Net cost impact uncertain — depends on Flash vs Pro price differential. At current pricing (Flash free on Pro-Exp), this is equivalent to the current approach.
|
||||
|
||||
**Complexity:** Low — add a "polish" pass after initial draft in `write_chapter()`
|
||||
|
||||
**Risk:** Polish pass might not improve chapters that have structural problems (wrong beats covered)
|
||||
|
||||
---
|
||||
|
||||
### Alt 3-B: Adaptive Scoring Thresholds
|
||||
|
||||
**Description:** Use different scoring thresholds based on chapter position and importance:
|
||||
- Setup chapters (1–20% of book): SCORE_PASSING = 6.5 (accept imperfect early work)
|
||||
- Midpoint + rising action (20–70%): SCORE_PASSING = 7.0 (current standard)
|
||||
- Climax + resolution (70–100%): SCORE_PASSING = 7.5 (stricter standards for crucial chapters)
|
||||
|
||||
**Hypothesis:** Adaptive thresholds reduce refinement calls on setup chapters by ~25% while improving quality of climax chapters. Net token saving ~100K per book (~$0.02) with no quality loss on high-stakes scenes.
|
||||
|
||||
**Complexity:** Very low — change 2 constants in `write_chapter()` to be position-aware
|
||||
|
||||
**Risk:** Lower-quality setup chapters might affect reader engagement in early pages
|
||||
|
||||
---
|
||||
|
||||
### Alt 3-C: Pre-Scoring Outline Beats
|
||||
|
||||
**Description:** Before writing any chapter, use the Logic model to score each chapter's beat list for "writability" — the likelihood that the beats will produce a high-quality first draft. Flag chapters scoring below 6/10 as "high-risk" and assign them extra write attempts upfront.
|
||||
|
||||
**Hypothesis:** Pre-scoring beats adds ~5K tokens per book but reduces full-rewrite incidents by ~30% (the most expensive outcome). Expected saving: 30% × 15 rewrites × 50K tokens = ~225K tokens (~$0.05).
|
||||
|
||||
**Complexity:** Low — add `score_beats_writability()` call before Phase 3 loop
|
||||
|
||||
**Risk:** Pre-scoring accuracy might be low; Logic model can't fully predict quality from beats alone
|
||||
|
||||
---
|
||||
|
||||
### Alt 3-D: Persona Caching (Immediate Win)
|
||||
|
||||
**Description:** Load the author persona (bio, sample text, sample files) once per book run rather than re-reading from disk for each chapter. Store in memory and pass to `write_chapter()` as a pre-built string.
|
||||
|
||||
**Hypothesis:** Persona caching reduces per-chapter I/O overhead and eliminates redundant file reads. No quality impact. Saves ~90K tokens per book (3K tokens × 30 chapters from persona sample files).
|
||||
|
||||
**Complexity:** Very low — refactor engine.py to load persona once and pass it
|
||||
|
||||
**Risk:** None
|
||||
|
||||
---
|
||||
|
||||
### Alt 3-E: Skip Beat Expansion for Detailed Beats
|
||||
|
||||
**Description:** If a chapter's beats already exceed 100 words each, skip `expand_beats_to_treatment()`. The existing beats are detailed enough to guide the writer.
|
||||
|
||||
**Hypothesis:** ~30% of chapters have detailed beats. Skipping expansion saves 5K tokens × 30% × 30 chapters = ~45K tokens. Quality impact negligible for already-detailed beats.
|
||||
|
||||
**Complexity:** Very low — add word-count check before calling `expand_beats_to_treatment()`
|
||||
|
||||
**Risk:** None for already-detailed beats; risk only if threshold is set too low
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Review & Refinement
|
||||
|
||||
### Current Approach
|
||||
Per-chapter evaluation with 13 rubrics. Post-generation consistency check. Dynamic pacing interventions. User-triggered ripple propagation.
|
||||
|
||||
---
|
||||
|
||||
### Alt 4-A: Batched Chapter Evaluation
|
||||
|
||||
**Description:** Instead of evaluating each chapter individually (~20K tokens/eval), batch 3–5 chapters per evaluation call. The evaluator assesses them together and can identify cross-chapter issues (pacing, voice consistency) that per-chapter evaluation misses.
|
||||
|
||||
**Hypothesis:** Batched evaluation reduces evaluation token cost by ~60% (from 600K to 240K tokens) while improving cross-chapter quality detection. Risk: individual chapter scores may be less granular.
|
||||
|
||||
**Complexity:** Medium — refactor `evaluate_chapter_quality()` to accept chapter arrays
|
||||
|
||||
**Risk:** Batched scoring might be less precise per-chapter; harder to pinpoint which chapter needs rewriting
|
||||
|
||||
---
|
||||
|
||||
### Alt 4-B: Mid-Generation Consistency Snapshots
|
||||
|
||||
**Description:** Run `analyze_consistency()` every 10 chapters (not just post-generation). If contradictions are found, pause writing and resolve them before proceeding.
|
||||
|
||||
**Hypothesis:** Mid-generation consistency checks add ~3 Logic calls per 30-chapter book (~75K tokens, FREE) but reduce post-generation ripple propagation cost by ~50% by catching issues early.
|
||||
|
||||
**Complexity:** Low — add consistency snapshot call to engine.py loop
|
||||
|
||||
**Risk:** Consistency check might generate false positives that stall generation
|
||||
|
||||
---
|
||||
|
||||
### Alt 4-C: Semantic Ripple Detection
|
||||
|
||||
**Description:** Replace LLM-based ripple detection in `check_and_propagate()` with an embedding-similarity approach. When Chapter N is edited, compute semantic similarity between Chapter N's content and all downstream chapters. Only rewrite chapters above a similarity threshold.
|
||||
|
||||
**Hypothesis:** Semantic ripple detection reduces per-ripple token cost from ~15K (LLM scan) to ~2K (embedding query) — 87% reduction. Accuracy comparable to LLM for direct references; may miss indirect narrative impacts.
|
||||
|
||||
**Complexity:** High — requires adding `sentence-transformers` or Gemini embedding API dependency
|
||||
|
||||
**Risk:** Embedding similarity doesn't capture narrative causality (e.g., a character dying affects later chapters even if the death isn't mentioned verbatim)
|
||||
|
||||
---
|
||||
|
||||
### Alt 4-D: Editor Bot Specialization
|
||||
|
||||
**Description:** Create specialized sub-evaluators for specific failure modes:
|
||||
- `check_filter_words()` — fast regex-based scan (no LLM needed)
|
||||
- `check_summary_mode()` — detect scene-skipping patterns
|
||||
- `check_voice_consistency()` — compare chapter voice against persona sample
|
||||
- `check_plot_adherence()` — verify beats were covered
|
||||
|
||||
Run cheap checks first; only invoke full 13-rubric LLM evaluation if fast checks pass.
|
||||
|
||||
**Hypothesis:** Specialized editor bots reduce evaluation cost by ~40% (many chapters fail fast checks and don't need full LLM eval). Quality detection equal or better because fast checks are more precise for rule violations.
|
||||
|
||||
**Complexity:** Medium — implement regex-based fast checks; modify evaluation pipeline
|
||||
|
||||
**Risk:** Fast checks might have false positives that reject good chapters prematurely
|
||||
|
||||
---
|
||||
|
||||
## Summary: Hypotheses Ranked by Expected Value
|
||||
|
||||
| Alt | Phase | Expected Token Saving | Quality Impact | Complexity |
|
||||
|-----|-------|----------------------|----------------|------------|
|
||||
| 3-D (Persona Cache) | 3 | ~90K | None | Very Low |
|
||||
| 3-E (Skip Beat Expansion) | 3 | ~45K | None | Very Low |
|
||||
| 2-B (Outline Validation) | 2 | Prevents ~100K rewrites | Positive | Low |
|
||||
| 3-B (Adaptive Thresholds) | 3 | ~100K | Positive | Very Low |
|
||||
| 1-C (Persona Validation) | 1 | ~60K (prevented rewrites) | Positive | Low |
|
||||
| 4-B (Mid-gen Consistency) | 4 | ~75K (prevented rewrites) | Positive | Low |
|
||||
| 3-C (Pre-score Beats) | 3 | ~225K | Positive | Low |
|
||||
| 4-A (Batch Evaluation) | 4 | ~360K | Neutral/Positive | Medium |
|
||||
| 2-A (Single-pass Outline) | 2 | ~60K | Neutral | Low |
|
||||
| 3-B (Two-Pass Drafting) | 3 | Neutral | Potentially Positive | Low |
|
||||
| 4-D (Editor Bots) | 4 | ~240K | Positive | Medium |
|
||||
| 2-C (Dynamic Personas) | 2 | -12K (slight increase) | Positive | Medium |
|
||||
| 4-C (Semantic Ripple) | 4 | ~200K | Neutral | High |
|
||||
238
docs/current_state_analysis.md
Normal file
238
docs/current_state_analysis.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Current State Analysis: BookApp AI Pipeline
|
||||
|
||||
**Date:** 2026-02-22
|
||||
**Scope:** Mapping existing codebase to the four phases defined in `ai_blueprint.md`
|
||||
**Status:** Completed — fulfills Action Plan Step 1
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
BookApp is an AI-powered novel generation engine using Google Gemini. The pipeline is structured into four phases that map directly to the review framework in `ai_blueprint.md`. This document catalogues the current implementation, identifies efficiency metrics, and surfaces limitations in each phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation & Ideation ("The Seed")
|
||||
|
||||
**Primary File:** `story/planner.py` (lines 1–86)
|
||||
**Supporting:** `story/style_persona.py` (lines 81–104), `core/config.py`
|
||||
|
||||
### What Happens
|
||||
|
||||
1. User provides a minimal `manual_instruction` (can be a single sentence).
|
||||
2. `enrich(bp, folder, context)` calls the Logic model to expand this into:
|
||||
- `book_metadata`: title, genre, tone, time period, structure type, formatting rules, content warnings
|
||||
- `characters`: 2–8 named characters with roles and descriptions
|
||||
- `plot_beats`: 5–7 concrete narrative beats
|
||||
3. If the project is part of a series, context from previous books is injected.
|
||||
4. `create_initial_persona()` generates a fictional author persona (name, bio, age, gender).
|
||||
|
||||
### Costs (Per Book)
|
||||
|
||||
| Task | Model | Input Tokens | Output Tokens | Cost (Pro-Exp) |
|
||||
|------|-------|-------------|---------------|----------------|
|
||||
| `enrich()` | Logic | ~10K | ~3K | FREE |
|
||||
| `create_initial_persona()` | Logic | ~5.5K | ~1.5K | FREE |
|
||||
| **Phase 1 Total** | — | ~15.5K | ~4.5K | **FREE** |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
| ID | Issue | Impact |
|
||||
|----|-------|--------|
|
||||
| P1-L1 | `enrich()` silently returns original BP on exception (line 84) | Invalid enrichment passes downstream without warning |
|
||||
| P1-L2 | `filter_characters()` blacklists keywords like "TBD", "protagonist" — can cull valid names | Characters named "The Protagonist" are silently dropped |
|
||||
| P1-L3 | Single-pass persona creation — no quality check on output | Generic personas produce poor voice throughout book |
|
||||
| P1-L4 | No validation that required `book_metadata` fields are non-null | Downstream crashes when title/genre are missing |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Structuring & Outlining
|
||||
|
||||
**Primary File:** `story/planner.py` (lines 89–290)
|
||||
**Supporting:** `story/style_persona.py`
|
||||
|
||||
### What Happens
|
||||
|
||||
1. `plan_structure(bp, folder)` maps plot beats to a structural framework (Hero's Journey, Three-Act, etc.) and produces ~10–15 events.
|
||||
2. `expand(events, pass_num, ...)` iteratively enriches the outline. Called `depth` times (1–4 based on length preset). Each pass targets chapter count × 1.5 events as ceiling.
|
||||
3. `create_chapter_plan(events, bp, folder)` converts events into concrete chapter objects with POV, pacing, and estimated word count.
|
||||
4. `get_style_guidelines()` loads or refreshes the AI-ism blacklist and filter-word list.
|
||||
|
||||
### Depth Strategy
|
||||
|
||||
| Preset | Depth | Expand Calls | Approx Events |
|
||||
|--------|-------|-------------|---------------|
|
||||
| Flash Fiction | 1 | 1 | 1 |
|
||||
| Short Story | 1 | 1 | 5 |
|
||||
| Novella | 2 | 2 | 15 |
|
||||
| Novel | 3 | 3 | 30 |
|
||||
| Epic | 4 | 4 | 50 |
|
||||
|
||||
### Costs (30-Chapter Novel)
|
||||
|
||||
| Task | Calls | Input Tokens | Cost (Pro-Exp) |
|
||||
|------|-------|-------------|----------------|
|
||||
| `plan_structure` | 1 | ~15K | FREE |
|
||||
| `expand` × 3 | 3 | ~12K each | FREE |
|
||||
| `create_chapter_plan` | 1 | ~14K | FREE |
|
||||
| `get_style_guidelines` | 1 | ~8K | FREE |
|
||||
| **Phase 2 Total** | 6 | ~73K | **FREE** |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
| ID | Issue | Impact |
|
||||
|----|-------|--------|
|
||||
| P2-L1 | Sequential `expand()` calls — each call unaware of final state | Redundant inter-call work; could be one multi-step prompt |
|
||||
| P2-L2 | No continuity validation on outline — character deaths/revivals not detected | Plot holes remain until expensive Phase 3 rewrite |
|
||||
| P2-L3 | Static chapter plan — cannot adapt if early chapters reveal pacing problem | Dynamic interventions in Phase 4 are costly workarounds |
|
||||
| P2-L4 | POV assignment is AI-generated, not validated against narrative logic | Wrong POV on key scenes; caught only during editing |
|
||||
| P2-L5 | Word count estimates are rough (~±30% actual variance) | Writer overshoots/undershoots target; word count normalization fails |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The Writing Engine (Drafting)
|
||||
|
||||
**Primary File:** `story/writer.py`
|
||||
**Orchestrated by:** `cli/engine.py`
|
||||
|
||||
### What Happens
|
||||
|
||||
For each chapter:
|
||||
1. `expand_beats_to_treatment()` — Logic model expands sparse beats into a "Director's Treatment" (staging, sensory anchors, emotional arc, subtext).
|
||||
2. `write_chapter()` constructs a ~310-line prompt injecting:
|
||||
- Author persona (bio, sample text, sample files from disk)
|
||||
- Filtered characters (only those named in beats + POV character)
|
||||
- Character tracking state (location, clothing, held items)
|
||||
- Lore context (relevant locations/items from tracking)
|
||||
- Style guidelines + genre-specific mandates
|
||||
- Smart context tail: last ~1000 tokens of previous chapter
|
||||
- Director's Treatment
|
||||
3. Writer model generates first draft.
|
||||
4. Logic model evaluates on 13 rubrics (1–10 scale). Automatic fail conditions apply for filter-word density, summary mode, and labeled emotions.
|
||||
5. Iterative quality loop (up to 3 attempts):
|
||||
- Score ≥ 8.0 → Auto-accept
|
||||
- Score ≥ 7.0 → Accept after max attempts
|
||||
- Score < 7.0 → Refinement pass (Writer model)
|
||||
- Score < 6.0 → Full rewrite (Pro model)
|
||||
6. Every 5 chapters: `refine_persona()` updates author bio based on actual written text.
|
||||
|
||||
### Key Innovations
|
||||
|
||||
- **Dynamic Character Injection:** Only injects characters named in chapter beats (saves ~5K tokens/chapter).
|
||||
- **Smart Context Tail:** Takes last ~1000 tokens of previous chapter (not first 1000) — preserves handoff point.
|
||||
- **Auto Model Escalation:** Low-scoring drafts trigger switch to Pro model for full rewrite.
|
||||
|
||||
### Costs (30-Chapter Novel, Mixed Model Strategy)
|
||||
|
||||
| Task | Calls | Input Tokens | Output Tokens | Cost Estimate |
|
||||
|------|-------|-------------|---------------|---------------|
|
||||
| `expand_beats_to_treatment` × 30 | 30 | ~5K | ~2K | FREE (Logic) |
|
||||
| `write_chapter` draft × 30 | 30 | ~25K | ~3.5K | ~$0.087 (Writer) |
|
||||
| Evaluation × 30 | 30 | ~20K | ~1.5K | FREE (Logic) |
|
||||
| Refinement passes × 15 (est.) | 15 | ~20K | ~3K | ~$0.090 (Writer) |
|
||||
| `refine_persona` × 6 | 6 | ~6K | ~1.5K | FREE (Logic) |
|
||||
| **Phase 3 Total** | ~111 | ~1.9M | ~310K | **~$0.18** |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
| ID | Issue | Impact |
|
||||
|----|-------|--------|
|
||||
| P3-L1 | Persona files re-read from disk on every chapter | I/O overhead; persona doesn't change between reads |
|
||||
| P3-L2 | Beat expansion called even when beats are already detailed (>100 words) | Wastes ~5K tokens/chapter on ~30% of chapters |
|
||||
| P3-L3 | Full rewrite triggered at score < 6.0 — discards entire draft | If draft scores 5.9, all 25K output tokens wasted |
|
||||
| P3-L4 | No priority weighting for climax chapters | Ch 28 (climax) uses same resources/attempts as Ch 3 (setup) |
|
||||
| P3-L5 | Previous chapter context hard-capped at 1000 tokens | For long chapters, might miss setup context from earlier pages |
|
||||
| P3-L6 | Scoring thresholds fixed regardless of book position | Strict standards in early chapters = expensive refinement for setup scenes |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Review & Refinement (Editing)
|
||||
|
||||
**Primary Files:** `story/editor.py`, `story/bible_tracker.py`
|
||||
**Orchestrated by:** `cli/engine.py`
|
||||
|
||||
### What Happens
|
||||
|
||||
**During writing loop (every chapter):**
|
||||
- `update_tracking()` refreshes character state (location, clothing, held items, speech style, events).
|
||||
- `update_lore_index()` extracts canonical descriptions of locations and items.
|
||||
|
||||
**Every 2 chapters:**
|
||||
- `check_pacing()` detects if story is rushing or repeating beats; triggers ADD_BRIDGE or CUT_NEXT interventions.
|
||||
|
||||
**After writing completes:**
|
||||
- `analyze_consistency()` scans entire manuscript for plot holes and contradictions.
|
||||
- `harvest_metadata()` extracts newly invented characters not in the original bible.
|
||||
- `check_and_propagate()` cascades chapter edits forward through the manuscript.
|
||||
|
||||
### 13 Evaluation Rubrics
|
||||
|
||||
1. Engagement & tension
|
||||
2. Scene execution (no summaries)
|
||||
3. Voice & tone
|
||||
4. Sensory immersion
|
||||
5. Show, Don't Tell / Deep POV (**auto-fail trigger**)
|
||||
6. Character agency
|
||||
7. Pacing
|
||||
8. Genre appropriateness
|
||||
9. Dialogue authenticity
|
||||
10. Plot relevance
|
||||
11. Staging & flow
|
||||
12. Prose dynamics (sentence variety)
|
||||
13. Clarity & readability
|
||||
|
||||
**Automatic fail conditions:** filter-word density > 1/120 words → cap at 5; summary mode detected → cap at 6; >3 labeled emotions → cap at 5.
|
||||
|
||||
### Costs (30-Chapter Novel)
|
||||
|
||||
| Task | Calls | Input Tokens | Cost (Pro-Exp) |
|
||||
|------|-------|-------------|----------------|
|
||||
| `update_tracking` × 30 | 30 | ~18K | FREE |
|
||||
| `update_lore_index` × 30 | 30 | ~15K | FREE |
|
||||
| `check_pacing` × 15 | 15 | ~18K | FREE |
|
||||
| `analyze_consistency` | 1 | ~25K | FREE |
|
||||
| `harvest_metadata` | 1 | ~25K | FREE |
|
||||
| **Phase 4 Total** | 77 | ~1.34M | **FREE** |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
| ID | Issue | Impact |
|
||||
|----|-------|--------|
|
||||
| P4-L1 | Consistency check is post-generation only | Plot holes caught too late to cheaply fix |
|
||||
| P4-L2 | Ripple propagation (`check_and_propagate`) has no cost ceiling | A single user edit in Ch 5 can trigger 100K+ tokens of cascading rewrites |
|
||||
| P4-L3 | `rewrite_chapter_content()` uses Logic model instead of Writer model | Less creative rewrite output — Logic model optimizes reasoning, not prose |
|
||||
| P4-L4 | `check_pacing()` sampling only looks at recent chapters, not cumulative arc | Slow-building issues across 10+ chapters not detected until critical |
|
||||
| P4-L5 | No quality metric for the evaluator itself | Can't confirm if 13-rubric scores are calibrated correctly |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Phase Summary
|
||||
|
||||
### Total Costs (30-Chapter Novel)
|
||||
|
||||
| Phase | Token Budget | Cost Estimate |
|
||||
|-------|-------------|---------------|
|
||||
| Phase 1: Ideation | ~20K | FREE |
|
||||
| Phase 2: Outline | ~73K | FREE |
|
||||
| Phase 3: Writing | ~2.2M | ~$0.18 |
|
||||
| Phase 4: Review | ~1.34M | FREE |
|
||||
| Imagen Cover (3 images) | — | ~$0.12 |
|
||||
| **Total** | **~3.63M** | **~$0.30** |
|
||||
|
||||
*Assumes quality-first model selection (Pro-Exp for Logic, Flash for Writer)*
|
||||
|
||||
### Efficiency Frontier
|
||||
|
||||
- **Best case** (all chapters pass first attempt): ~$0.18 text + $0.04 cover = ~$0.22
|
||||
- **Worst case** (30% rewrite rate with Pro escalations): ~$0.45 text + $0.12 cover = ~$0.57
|
||||
- **Budget per blueprint goal:** $2.00 total — current system is 15–29% of budget
|
||||
|
||||
### Top 5 Immediate Optimization Opportunities
|
||||
|
||||
| Priority | ID | Change | Savings |
|
||||
|----------|----|--------|---------|
|
||||
| 1 | P3-L1 | Cache persona per book (not per chapter) | ~90K tokens |
|
||||
| 2 | P3-L2 | Skip beat expansion for detailed beats | ~45K tokens |
|
||||
| 3 | P2-L2 | Add pre-generation outline validation | Prevent expensive rewrites |
|
||||
| 4 | P1-L1 | Fix silent failure in `enrich()` | Prevent silent corrupt state |
|
||||
| 5 | P3-L6 | Adaptive scoring thresholds by chapter position | ~15% fewer refinement passes |
|
||||
290
docs/experiment_design.md
Normal file
290
docs/experiment_design.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Experiment Design: A/B Tests for BookApp Optimization
|
||||
|
||||
**Date:** 2026-02-22
|
||||
**Status:** Completed — fulfills Action Plan Step 3
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
All experiments follow a controlled A/B design. We hold all variables constant except the single variable under test. Success is measured against three primary metrics:
|
||||
|
||||
- **Cost per chapter (CPC):** Total token cost / number of chapters written
|
||||
- **Human Quality Score (HQS):** 1–10 score from a human reviewer blind to which variant generated the chapter
|
||||
- **Continuity Error Rate (CER):** Number of plot/character contradictions per 10 chapters (lower is better)
|
||||
|
||||
Each experiment runs on the same 3 prompts (one each of short story, novella, and novel length). Results are averaged across all 3.
|
||||
|
||||
**Baseline:** Current production configuration as of 2026-02-22.
|
||||
|
||||
---
|
||||
|
||||
## Experiment 1: Persona Caching
|
||||
|
||||
**Alt Reference:** Alt 3-D
|
||||
**Hypothesis:** Caching persona per book reduces I/O overhead with no quality impact.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| Persona loading | Re-read from disk each chapter | Load once per book run, pass as argument |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- Token count per chapter (to verify savings)
|
||||
- Wall-clock generation time per book
|
||||
- Chapter quality scores (should be identical)
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- Token reduction ≥ 2,000 tokens/chapter on books with sample files
|
||||
- HQS difference < 0.1 between A and B (no quality impact)
|
||||
- Zero new errors introduced
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Modify `cli/engine.py`: call `style_persona.load_persona_data()` once before chapter loop
|
||||
- Modify `story/writer.py`: accept optional `persona_info` parameter, skip disk reads if provided
|
||||
- Estimated implementation: 30 minutes
|
||||
|
||||
---
|
||||
|
||||
## Experiment 2: Skip Beat Expansion for Detailed Beats
|
||||
|
||||
**Alt Reference:** Alt 3-E
|
||||
**Hypothesis:** Skipping `expand_beats_to_treatment()` when beats exceed 100 words saves tokens with no quality loss.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| Beat expansion | Always called | Skipped if total beats > 100 words |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- Percentage of chapters that skip expansion (expected: ~30%)
|
||||
- Token savings per book
|
||||
- HQS for chapters that skip vs. chapters that don't skip
|
||||
- Rate of beat-coverage failures (chapters that miss a required beat)
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- ≥ 25% of chapters skip expansion (validating hypothesis)
|
||||
- HQS difference < 0.2 between chapters that skip and those that don't
|
||||
- Beat-coverage failure rate unchanged
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Modify `story/writer.py` `write_chapter()`: add `if sum(len(b) for b in beats) > 100` guard before calling expansion
|
||||
- Estimated implementation: 15 minutes
|
||||
|
||||
---
|
||||
|
||||
## Experiment 3: Outline Validation Gate
|
||||
|
||||
**Alt Reference:** Alt 2-B
|
||||
**Hypothesis:** Pre-generation outline validation prevents costly Phase 3 rewrites by catching plot holes at the outline stage.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| Outline validation | None | Run `validate_outline()` after `create_chapter_plan()`; block if critical issues found |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- Number of critical outline issues flagged per run
|
||||
- Rewrite rate during Phase 3 (did validation prevent rewrites?)
|
||||
- Phase 3 token cost difference (A vs B)
|
||||
- CER difference (did validation reduce continuity errors?)
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- Validation blocks at least 1 critical issue per 3 runs
|
||||
- Phase 3 rewrite rate drops ≥ 15% when validation is active
|
||||
- CER improves ≥ 0.5 per 10 chapters
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Add `validate_outline(events, chapters, bp, folder)` to `story/planner.py`
|
||||
- Prompt: "Review this chapter plan for: (1) missing required plot beats, (2) character deaths/revivals without explanation, (3) severe pacing imbalances, (4) POV character inconsistency. Return: {issues: [...], severity: 'critical'|'warning'|'ok'}"
|
||||
- Modify `cli/engine.py`: call `validate_outline()` and log issues before Phase 3 begins
|
||||
- Estimated implementation: 2 hours
|
||||
|
||||
---
|
||||
|
||||
## Experiment 4: Adaptive Scoring Thresholds
|
||||
|
||||
**Alt Reference:** Alt 3-B
|
||||
**Hypothesis:** Lowering SCORE_PASSING for early setup chapters reduces refinement cost while maintaining quality on high-stakes scenes.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| SCORE_AUTO_ACCEPT | 8.0 (all chapters) | 8.0 (all chapters) |
|
||||
| SCORE_PASSING | 7.0 (all chapters) | 6.5 (ch 1–20%), 7.0 (ch 20–70%), 7.5 (ch 70–100%) |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- Refinement pass count per chapter position bucket
|
||||
- HQS per chapter position bucket (A vs B)
|
||||
- CPC for each bucket
|
||||
- Overall HQS for full book (A vs B)
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- Setup chapters (1–20%): ≥ 20% fewer refinement passes in B
|
||||
- Climax chapters (70–100%): HQS improvement ≥ 0.3 in B
|
||||
- Full book HQS unchanged or improved
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Modify `story/writer.py` `write_chapter()`: accept `chapter_position` (0.0–1.0 float)
|
||||
- Compute adaptive threshold: `passing = 6.5 + position * 1.0` (linear scaling)
|
||||
- Modify `cli/engine.py`: pass `chapter_num / total_chapters` to `write_chapter()`
|
||||
- Estimated implementation: 1 hour
|
||||
|
||||
---
|
||||
|
||||
## Experiment 5: Mid-Generation Consistency Snapshots
|
||||
|
||||
**Alt Reference:** Alt 4-B
|
||||
**Hypothesis:** Running `analyze_consistency()` every 10 chapters reduces post-generation CER without significant cost increase.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| Consistency check | Post-generation only | Every 10 chapters + post-generation |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- CER post-generation (A vs B)
|
||||
- Number of issues caught mid-generation vs post-generation
|
||||
- Token cost difference (mid-gen checks add ~25K × N/10 tokens)
|
||||
- Generation time difference
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- Post-generation CER drops ≥ 30% in B
|
||||
- Issues caught mid-generation prevent at least 1 expensive post-gen ripple propagation per run
|
||||
- Additional cost ≤ $0.01 per book (all free on Pro-Exp)
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Modify `cli/engine.py`: every 10 chapters, call `analyze_consistency()` on written chapters so far
|
||||
- If issues found: log warning and optionally pause for user review
|
||||
- Estimated implementation: 1 hour
|
||||
|
||||
---
|
||||
|
||||
## Experiment 6: Iterative Persona Validation
|
||||
|
||||
**Alt Reference:** Alt 1-C
|
||||
**Hypothesis:** Validating the initial persona with a sample passage reduces voice-drift rewrites in Phase 3.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| Persona creation | Single-pass, no validation | Generate persona → generate 200-word sample → evaluate → accept if ≥ 7/10, else regenerate (max 3 attempts) |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- Initial persona acceptance rate (how often does first-pass persona pass the check?)
|
||||
- Phase 3 persona-related rewrite rate (rewrites where critique mentions "voice inconsistency" or "doesn't match persona")
|
||||
- HQS for first 5 chapters (voice is most important early on)
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- Phase 3 persona-related rewrite rate drops ≥ 20% in B
|
||||
- HQS for first 5 chapters improves ≥ 0.2
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Modify `story/style_persona.py`: after `create_initial_persona()`, call a new `validate_persona()` function
|
||||
- `validate_persona()` generates 200-word sample, evaluates with `evaluate_chapter_quality()` (light version)
|
||||
- Estimated implementation: 2 hours
|
||||
|
||||
---
|
||||
|
||||
## Experiment 7: Two-Pass Drafting (Draft + Polish)
|
||||
|
||||
**Alt Reference:** Alt 3-A
|
||||
**Hypothesis:** A cheap rough draft followed by a polished revision produces better quality than iterative retrying.
|
||||
|
||||
### Setup
|
||||
|
||||
| Parameter | Control (A) | Treatment (B) |
|
||||
|-----------|-------------|---------------|
|
||||
| Drafting strategy | Single draft → evaluate → retry | Rough draft (Flash) → polish (Pro) → evaluate → accept if ≥ 7.0 (max 1 retry) |
|
||||
| Max retry attempts | 3 | 1 (after polish) |
|
||||
| Everything else | Identical | Identical |
|
||||
|
||||
### Metrics to Measure
|
||||
|
||||
- CPC (A vs B)
|
||||
- HQS (A vs B)
|
||||
- Rate of chapters needing retry (A vs B)
|
||||
- Total generation time per book
|
||||
|
||||
### Success Criterion
|
||||
|
||||
- HQS improvement ≥ 0.3 in B with no cost increase
|
||||
- OR: CPC reduction ≥ 20% in B with no HQS decrease
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Modify `story/writer.py` `write_chapter()`: add polish pass using Pro model after initial draft
|
||||
- Reduce max_attempts to 1 for final retry (after polish)
|
||||
- This requires Pro model to be available (handled by auto-selection)
|
||||
|
||||
---
|
||||
|
||||
## Experiment Execution Order
|
||||
|
||||
Run experiments in this order to minimize dependency conflicts:
|
||||
|
||||
1. **Exp 1** (Persona Caching) — independent, 30 min, no risk
|
||||
2. **Exp 2** (Skip Beat Expansion) — independent, 15 min, no risk
|
||||
3. **Exp 4** (Adaptive Thresholds) — independent, 1 hr, low risk
|
||||
4. **Exp 3** (Outline Validation) — independent, 2 hrs, low risk
|
||||
5. **Exp 6** (Persona Validation) — independent, 2 hrs, low risk
|
||||
6. **Exp 5** (Mid-gen Consistency) — requires stable Phase 3, 1 hr, low risk
|
||||
7. **Exp 7** (Two-Pass Drafting) — highest risk, run last; 3 hrs, medium risk
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics Definitions
|
||||
|
||||
### Cost per Chapter (CPC)
|
||||
|
||||
```
|
||||
CPC = (total_input_tokens × input_price + total_output_tokens × output_price) / num_chapters
|
||||
```
|
||||
|
||||
Measure in both USD and token-count to separate model-price effects from efficiency effects.
|
||||
|
||||
### Human Quality Score (HQS)
|
||||
|
||||
Blind evaluation by a human reviewer:
|
||||
1. Read 3 chapters from treatment A and 3 from treatment B (same book premise)
|
||||
2. Score each on: prose quality (1–5), pacing (1–5), character consistency (1–5)
|
||||
3. HQS = average across all dimensions, normalized to 1–10
|
||||
|
||||
### Continuity Error Rate (CER)
|
||||
|
||||
After generation, manually review character states and key plot facts across chapters. Count:
|
||||
- Character location contradictions
|
||||
- Continuity breaks (held items, injuries, time-of-day)
|
||||
- Plot event contradictions (character alive vs. dead)
|
||||
|
||||
Report as errors per 10 chapters.
|
||||
0
export/__init__.py
Normal file
0
export/__init__.py
Normal file
@@ -2,7 +2,8 @@ import os
|
||||
import markdown
|
||||
from docx import Document
|
||||
from ebooklib import epub
|
||||
from . import utils
|
||||
from core import utils
|
||||
|
||||
|
||||
def create_readme(folder, bp):
|
||||
meta = bp['book_metadata']
|
||||
@@ -10,6 +11,7 @@ def create_readme(folder, bp):
|
||||
content = f"""# {meta['title']}\n**Generated by BookApp**\n\n## Stats Used\n- **Type:** {ls.get('label', 'Custom')}\n- **Planned Chapters:** {ls['chapters']}\n- **Logic Depth:** {ls['depth']}\n- **Target Words:** {ls.get('words', 'Unknown')}"""
|
||||
with open(os.path.join(folder, "README.md"), "w") as f: f.write(content)
|
||||
|
||||
|
||||
def compile_files(bp, ms, folder):
|
||||
utils.log("SYSTEM", "Compiling EPUB and DOCX...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
@@ -18,19 +20,19 @@ def compile_files(bp, ms, folder):
|
||||
if meta.get('filename'):
|
||||
safe = meta['filename']
|
||||
else:
|
||||
safe = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_")
|
||||
safe = utils.sanitize_filename(title)
|
||||
|
||||
doc = Document(); doc.add_heading(title, 0)
|
||||
book = epub.EpubBook(); book.set_title(title); spine = ['nav']
|
||||
|
||||
# Add Cover if exists
|
||||
cover_path = os.path.join(folder, "cover.png")
|
||||
if os.path.exists(cover_path):
|
||||
with open(cover_path, 'rb') as f:
|
||||
book.set_cover("cover.png", f.read())
|
||||
|
||||
ms.sort(key=utils.chapter_sort_key)
|
||||
|
||||
for c in ms:
|
||||
# Determine filename/type
|
||||
num_str = str(c['num']).lower()
|
||||
if num_str == '0' or 'prologue' in num_str:
|
||||
filename = "prologue.xhtml"
|
||||
@@ -42,7 +44,6 @@ def compile_files(bp, ms, folder):
|
||||
filename = f"ch_{c['num']}.xhtml"
|
||||
default_header = f"Ch {c['num']}: {c['title']}"
|
||||
|
||||
# Check for AI-generated header in content
|
||||
content = c['content'].strip()
|
||||
clean_content = content.replace("```markdown", "").replace("```", "").strip()
|
||||
lines = clean_content.split('\n')
|
||||
320
main.py
320
main.py
@@ -1,320 +0,0 @@
|
||||
import json, os, time, sys, shutil
|
||||
import config
|
||||
from rich.prompt import Confirm
|
||||
from modules import ai, story, marketing, export, utils
|
||||
|
||||
def process_book(bp, folder, context="", resume=False):
|
||||
# Create lock file to indicate active processing
|
||||
lock_path = os.path.join(folder, ".in_progress")
|
||||
with open(lock_path, "w") as f: f.write("running")
|
||||
|
||||
total_start = time.time()
|
||||
|
||||
# 1. Check completion
|
||||
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
|
||||
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
|
||||
|
||||
# Clean up zombie lock file if it exists
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
return
|
||||
|
||||
# 2. Load or Create Blueprint
|
||||
bp_path = os.path.join(folder, "blueprint_initial.json")
|
||||
t_step = time.time()
|
||||
if resume and os.path.exists(bp_path):
|
||||
utils.log("RESUME", "Loading existing blueprint...")
|
||||
saved_bp = utils.load_json(bp_path)
|
||||
# Merge latest metadata from Bible (passed in bp) into saved blueprint
|
||||
if saved_bp:
|
||||
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
|
||||
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
|
||||
if k in bp['book_metadata']:
|
||||
saved_bp['book_metadata'][k] = bp['book_metadata'][k]
|
||||
if 'series_metadata' in bp:
|
||||
saved_bp['series_metadata'] = bp['series_metadata']
|
||||
bp = saved_bp
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
else:
|
||||
bp = utils.normalize_settings(bp)
|
||||
bp = story.enrich(bp, folder, context)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Ensure Persona Exists (Auto-create if missing)
|
||||
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
|
||||
bp['book_metadata']['author_details'] = story.create_initial_persona(bp, folder)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 3. Events (Plan & Expand)
|
||||
events_path = os.path.join(folder, "events.json")
|
||||
t_step = time.time()
|
||||
if resume and os.path.exists(events_path):
|
||||
utils.log("RESUME", "Loading existing events...")
|
||||
events = utils.load_json(events_path)
|
||||
else:
|
||||
events = story.plan_structure(bp, folder)
|
||||
depth = bp['length_settings']['depth']
|
||||
target_chaps = bp['length_settings']['chapters']
|
||||
for d in range(1, depth+1):
|
||||
events = story.expand(events, d, target_chaps, bp, folder)
|
||||
time.sleep(1)
|
||||
with open(events_path, "w") as f: json.dump(events, f, indent=2)
|
||||
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 4. Chapter Plan
|
||||
chapters_path = os.path.join(folder, "chapters.json")
|
||||
t_step = time.time()
|
||||
if resume and os.path.exists(chapters_path):
|
||||
utils.log("RESUME", "Loading existing chapter plan...")
|
||||
chapters = utils.load_json(chapters_path)
|
||||
else:
|
||||
chapters = story.create_chapter_plan(events, bp, folder)
|
||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 5. Writing Loop
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else []
|
||||
|
||||
# Load Tracking
|
||||
events_track_path = os.path.join(folder, "tracking_events.json")
|
||||
chars_track_path = os.path.join(folder, "tracking_characters.json")
|
||||
warn_track_path = os.path.join(folder, "tracking_warnings.json")
|
||||
|
||||
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
||||
if resume:
|
||||
if os.path.exists(events_track_path):
|
||||
tracking['events'] = utils.load_json(events_track_path)
|
||||
if os.path.exists(chars_track_path):
|
||||
tracking['characters'] = utils.load_json(chars_track_path)
|
||||
if os.path.exists(warn_track_path):
|
||||
tracking['content_warnings'] = utils.load_json(warn_track_path)
|
||||
|
||||
summary = "The story begins."
|
||||
if ms:
|
||||
# Generate summary from ALL written chapters to maintain continuity
|
||||
utils.log("RESUME", "Rebuilding 'Story So Far' from existing manuscript...")
|
||||
try:
|
||||
combined_text = "\n".join([f"Chapter {c['num']}: {c['content']}" for c in ms])
|
||||
resp_sum = ai.model_writer.generate_content(f"Create a detailed, cumulative 'Story So Far' summary from the following text. Use dense, factual bullet points. Focus on character meetings, relationships, and known information:\n{combined_text}")
|
||||
utils.log_usage(folder, "writer-flash", resp_sum.usage_metadata)
|
||||
summary = resp_sum.text
|
||||
except: summary = "The story continues."
|
||||
|
||||
t_step = time.time()
|
||||
session_chapters = 0
|
||||
session_time = 0
|
||||
|
||||
for i in range(len(ms), len(chapters)):
|
||||
ch_start = time.time()
|
||||
ch = chapters[i]
|
||||
|
||||
# Pass previous chapter content for continuity if available
|
||||
prev_content = ms[-1]['content'] if ms else None
|
||||
|
||||
txt = story.write_chapter(ch, bp, folder, summary, tracking, prev_content)
|
||||
|
||||
# Refine Persona to match the actual output (Consistency Loop)
|
||||
if (i == 0 or i % 3 == 0) and txt:
|
||||
bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Look ahead for context to ensure relevant details are captured
|
||||
next_info = ""
|
||||
if i + 1 < len(chapters):
|
||||
next_ch = chapters[i+1]
|
||||
next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}"
|
||||
|
||||
try:
|
||||
update_prompt = f"""
|
||||
Update the 'Story So Far' summary to include the events of this new chapter.
|
||||
|
||||
STYLE: Dense, factual, chronological bullet points. Avoid narrative prose.
|
||||
GOAL: Maintain a perfect memory of the plot for continuity.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. CUMULATIVE: Do NOT remove old events. Append and integrate new information.
|
||||
2. TRACKING: Explicitly note who met whom, who knows what, and current locations.
|
||||
3. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved.
|
||||
|
||||
CURRENT STORY SO FAR:
|
||||
{summary}
|
||||
|
||||
NEW CHAPTER CONTENT:
|
||||
{txt}
|
||||
{next_info}
|
||||
"""
|
||||
resp_sum = ai.model_writer.generate_content(update_prompt)
|
||||
utils.log_usage(folder, "writer-flash", resp_sum.usage_metadata)
|
||||
summary = resp_sum.text
|
||||
except:
|
||||
try:
|
||||
resp_fallback = ai.model_writer.generate_content(f"Summarize plot points:\n{txt}")
|
||||
utils.log_usage(folder, "writer-flash", resp_fallback.usage_metadata)
|
||||
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
|
||||
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
|
||||
|
||||
ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt})
|
||||
|
||||
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
|
||||
|
||||
# Update Tracking
|
||||
tracking = story.update_tracking(folder, ch['chapter_number'], txt, tracking)
|
||||
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
|
||||
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
||||
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
||||
|
||||
duration = time.time() - ch_start
|
||||
session_chapters += 1
|
||||
session_time += duration
|
||||
avg_time = session_time / session_chapters
|
||||
eta = avg_time * (len(chapters) - (i + 1))
|
||||
utils.log("TIMING", f" -> Chapter {ch['chapter_number']} finished in {duration:.1f}s | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s")
|
||||
|
||||
utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s")
|
||||
|
||||
# Harvest
|
||||
t_step = time.time()
|
||||
bp = story.harvest_metadata(bp, folder, ms)
|
||||
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Create Assets
|
||||
marketing.create_marketing_assets(bp, folder, tracking)
|
||||
|
||||
# Update Persona
|
||||
story.update_persona_sample(bp, folder)
|
||||
|
||||
export.compile_files(bp, ms, folder)
|
||||
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
|
||||
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
|
||||
|
||||
# Remove lock file on success
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
|
||||
# --- 6. ENTRY POINT ---
|
||||
def run_generation(target=None, specific_run_id=None):
|
||||
ai.init_models()
|
||||
|
||||
if not target: target = config.DEFAULT_BLUEPRINT
|
||||
data = utils.load_json(target)
|
||||
|
||||
if not data:
|
||||
utils.log("SYSTEM", f"Could not load {target}")
|
||||
return
|
||||
|
||||
# --- NEW BIBLE FORMAT SUPPORT ---
|
||||
if 'project_metadata' in data and 'books' in data:
|
||||
utils.log("SYSTEM", "Detected Bible Format. Starting Series Generation...")
|
||||
|
||||
# Determine Run Directory: projects/{Project}/runs/bible/run_X
|
||||
# target is likely .../projects/{Project}/bible.json
|
||||
project_dir = os.path.dirname(os.path.abspath(target))
|
||||
runs_base = os.path.join(project_dir, "runs", "bible")
|
||||
|
||||
run_dir = None
|
||||
resume_mode = False
|
||||
|
||||
if specific_run_id:
|
||||
# WEB/WORKER MODE: Non-interactive, specific ID
|
||||
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
|
||||
if not os.path.exists(run_dir): os.makedirs(run_dir)
|
||||
resume_mode = True # Always try to resume if files exist in this specific run
|
||||
else:
|
||||
# CLI MODE: Interactive checks
|
||||
latest_run = utils.get_latest_run_folder(runs_base)
|
||||
if latest_run:
|
||||
has_lock = False
|
||||
for root, dirs, files in os.walk(latest_run):
|
||||
if ".in_progress" in files:
|
||||
has_lock = True
|
||||
break
|
||||
|
||||
if has_lock:
|
||||
if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True):
|
||||
run_dir = latest_run
|
||||
resume_mode = True
|
||||
elif Confirm.ask(f"Delete artifacts in '{os.path.basename(latest_run)}' and start over?", default=False):
|
||||
shutil.rmtree(latest_run)
|
||||
os.makedirs(latest_run)
|
||||
run_dir = latest_run
|
||||
|
||||
if not run_dir: run_dir = utils.get_run_folder(runs_base)
|
||||
utils.log("SYSTEM", f"Run Directory: {run_dir}")
|
||||
|
||||
previous_context = ""
|
||||
|
||||
for i, book in enumerate(data['books']):
|
||||
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
|
||||
|
||||
# Adapter: Bible -> Blueprint
|
||||
meta = data['project_metadata']
|
||||
bp = {
|
||||
"book_metadata": {
|
||||
"title": book.get('title'),
|
||||
"filename": book.get('filename'),
|
||||
"author": meta.get('author'),
|
||||
"genre": meta.get('genre'),
|
||||
"target_audience": meta.get('target_audience'),
|
||||
"style": meta.get('style', {}),
|
||||
"author_details": meta.get('author_details', {}),
|
||||
"author_bio": meta.get('author_bio', ''),
|
||||
},
|
||||
"length_settings": meta.get('length_settings', {}),
|
||||
"characters": data.get('characters', []),
|
||||
"manual_instruction": book.get('manual_instruction', ''),
|
||||
"plot_beats": book.get('plot_beats', []),
|
||||
"series_metadata": {
|
||||
"is_series": meta.get('is_series', False),
|
||||
"series_title": meta.get('title', ''),
|
||||
"book_number": book.get('book_number', i+1),
|
||||
"total_books": len(data['books'])
|
||||
}
|
||||
}
|
||||
|
||||
# Create Book Subfolder
|
||||
safe_title = "".join([c for c in book.get('title', f"Book_{i+1}") if c.isalnum() or c=='_']).replace(" ", "_")
|
||||
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
|
||||
if not os.path.exists(book_folder): os.makedirs(book_folder)
|
||||
|
||||
# Process
|
||||
process_book(bp, book_folder, context=previous_context, resume=resume_mode)
|
||||
|
||||
# Update Context for next book
|
||||
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
|
||||
if os.path.exists(final_bp_path):
|
||||
final_bp = utils.load_json(final_bp_path)
|
||||
|
||||
# --- Update World Bible with new characters ---
|
||||
# This ensures future books know about characters invented in this book
|
||||
new_chars = final_bp.get('characters', [])
|
||||
|
||||
# RELOAD BIBLE to avoid race conditions (User might have edited it in UI)
|
||||
if os.path.exists(target):
|
||||
current_bible = utils.load_json(target)
|
||||
|
||||
# 1. Merge New Characters
|
||||
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
|
||||
for char in new_chars:
|
||||
if char['name'].lower() not in existing_names:
|
||||
current_bible['characters'].append(char)
|
||||
|
||||
# 2. Sync Generated Book Metadata (Title, Beats) back to Bible
|
||||
for b in current_bible.get('books', []):
|
||||
if b.get('book_number') == book.get('book_number'):
|
||||
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
|
||||
b['plot_beats'] = final_bp.get('plot_beats', b.get('plot_beats'))
|
||||
b['manual_instruction'] = final_bp.get('manual_instruction', b.get('manual_instruction'))
|
||||
break
|
||||
|
||||
with open(target, 'w') as f: json.dump(current_bible, f, indent=2)
|
||||
utils.log("SERIES", "Updated World Bible with new characters and plot data.")
|
||||
|
||||
last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book."
|
||||
previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}"
|
||||
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
run_generation(target_arg)
|
||||
@@ -1,19 +0,0 @@
|
||||
import sys
|
||||
from modules.web_app import app
|
||||
from modules.web_db import db, User
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python make_admin.py <username>")
|
||||
sys.exit(1)
|
||||
|
||||
username = sys.argv[1]
|
||||
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
user.is_admin = True
|
||||
db.session.commit()
|
||||
print(f"✅ Success: User '{username}' has been promoted to Admin.")
|
||||
else:
|
||||
print(f"❌ Error: User '{username}' not found. Please register via the Web UI first.")
|
||||
0
marketing/__init__.py
Normal file
0
marketing/__init__.py
Normal file
7
marketing/assets.py
Normal file
7
marketing/assets.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from marketing.blurb import generate_blurb
|
||||
from marketing.cover import generate_cover
|
||||
|
||||
|
||||
def create_marketing_assets(bp, folder, tracking=None, interactive=False):
|
||||
generate_blurb(bp, folder)
|
||||
generate_cover(bp, folder, tracking, interactive=interactive)
|
||||
67
marketing/blurb.py
Normal file
67
marketing/blurb.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import json
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
def generate_blurb(bp, folder):
|
||||
utils.log("MARKETING", "Generating blurb...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
beats = bp.get('plot_beats', [])
|
||||
beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)"
|
||||
|
||||
chars = bp.get('characters', [])
|
||||
protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None)
|
||||
protagonist_desc = f"{protagonist['name']} — {protagonist.get('description', '')}" if protagonist else "the protagonist"
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Marketing Copywriter
|
||||
TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel.
|
||||
|
||||
BOOK DETAILS:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {meta.get('genre')}
|
||||
- AUDIENCE: {meta.get('target_audience', 'General')}
|
||||
- PROTAGONIST: {protagonist_desc}
|
||||
- LOGLINE: {bp.get('manual_instruction', '(none)')}
|
||||
- KEY PLOT BEATS:
|
||||
{beats_text}
|
||||
|
||||
BLURB STRUCTURE:
|
||||
1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent.
|
||||
2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose?
|
||||
3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution.
|
||||
4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book.
|
||||
|
||||
RULES:
|
||||
- 150-200 words total.
|
||||
- DO NOT reveal the ending or resolution.
|
||||
- Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden).
|
||||
- Use present tense for the blurb voice.
|
||||
- No "Blurb:", no title prefix, no labels — marketing copy only.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||
blurb = response.text.strip()
|
||||
|
||||
# Trim to 220 words if model overshot the 150-200 word target
|
||||
words = blurb.split()
|
||||
if len(words) > 220:
|
||||
blurb = " ".join(words[:220])
|
||||
# End at the last sentence boundary within those 220 words
|
||||
for end_ch in ['.', '!', '?']:
|
||||
last_sent = blurb.rfind(end_ch)
|
||||
if last_sent > len(blurb) // 2:
|
||||
blurb = blurb[:last_sent + 1]
|
||||
break
|
||||
utils.log("MARKETING", f" -> Blurb trimmed to {len(blurb.split())} words.")
|
||||
|
||||
with open(os.path.join(folder, "blurb.txt"), "w", encoding='utf-8') as f:
|
||||
f.write(blurb)
|
||||
with open(os.path.join(folder, "back_cover.txt"), "w", encoding='utf-8') as f:
|
||||
f.write(blurb)
|
||||
utils.log("MARKETING", f" -> Blurb: {len(blurb.split())} words.")
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Failed to generate blurb: {e}")
|
||||
554
marketing/cover.py
Normal file
554
marketing/cover.py
Normal file
@@ -0,0 +1,554 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import textwrap
|
||||
import subprocess
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from marketing.fonts import download_font
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageStat
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
# Score gates (mirrors chapter writing pipeline thresholds)
|
||||
ART_SCORE_AUTO_ACCEPT = 8 # Stop retrying — image is excellent
|
||||
ART_SCORE_PASSING = 7 # Acceptable; keep as best candidate
|
||||
LAYOUT_SCORE_PASSING = 7 # Accept layout and stop retrying
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def evaluate_cover_art(image_path, genre, title, model, folder=None):
|
||||
"""Score generated cover art against a professional book-cover rubric.
|
||||
|
||||
Returns (score: int | None, critique: str).
|
||||
Auto-fail conditions:
|
||||
- Any visible text/watermarks → score capped at 4
|
||||
- Blurry or deformed anatomy → deduct 2 points
|
||||
"""
|
||||
if not HAS_PIL:
|
||||
return None, "PIL not installed"
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
prompt = f"""
|
||||
ROLE: Professional Book Cover Art Critic
|
||||
TASK: Score this AI-generated cover art for a {genre} novel titled '{title}'.
|
||||
|
||||
SCORING RUBRIC (1-10):
|
||||
1. VISUAL IMPACT: Is the image immediately arresting? Does it demand attention on a shelf?
|
||||
2. GENRE FIT: Does the visual style, mood, and colour palette unmistakably signal {genre}?
|
||||
3. COMPOSITION: Is there a clear focal point? Are the top or bottom thirds usable for title/author text overlay?
|
||||
4. TECHNICAL QUALITY: Sharp, detailed, free of deformities, blurring, or AI artefacts?
|
||||
5. CLEAN IMAGE: Absolutely NO text, letters, numbers, watermarks, logos, or UI elements?
|
||||
|
||||
SCORING SCALE:
|
||||
- 9-10: Masterclass cover art, ready for a major publisher
|
||||
- 7-8: Professional quality, genre-appropriate, minor flaws only
|
||||
- 5-6: Usable but generic or has one significant flaw
|
||||
- 1-4: Unusable — major artefacts, wrong genre, deformed figures, or visible text
|
||||
|
||||
AUTO-FAIL RULES (apply before scoring):
|
||||
- If ANY text, letters, watermarks or UI elements are visible → score CANNOT exceed 4. State this explicitly.
|
||||
- If figures have deformed anatomy or blurring → deduct 2 from your final score.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{"score": int, "critique": "Specific issues citing what to fix in the next attempt.", "actionable": "One concrete change to the image prompt that would improve the next attempt."}}
|
||||
"""
|
||||
response = model.generate_content([prompt, img])
|
||||
model_name = getattr(model, 'name', "logic")
|
||||
if folder:
|
||||
utils.log_usage(folder, model_name, response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
score = data.get('score')
|
||||
critique = data.get('critique', '')
|
||||
if data.get('actionable'):
|
||||
critique += f" FIX: {data['actionable']}"
|
||||
return score, critique
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def evaluate_cover_layout(image_path, title, author, genre, font_name, model, folder=None):
|
||||
"""Score the finished cover (art + text overlay) as a professional book cover.
|
||||
|
||||
Returns (score: int | None, critique: str).
|
||||
"""
|
||||
if not HAS_PIL:
|
||||
return None, "PIL not installed"
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
prompt = f"""
|
||||
ROLE: Graphic Design Critic
|
||||
TASK: Score this finished book cover for '{title}' by {author} ({genre}).
|
||||
|
||||
SCORING RUBRIC (1-10):
|
||||
1. LEGIBILITY: Is the title instantly readable? High contrast against the background?
|
||||
2. TYPOGRAPHY: Does the font '{font_name}' suit the {genre} genre? Is sizing proportional?
|
||||
3. PLACEMENT: Is the title placed where it doesn't obscure the focal point? Is the author name readable?
|
||||
4. PROFESSIONAL POLISH: Does this look like a published, commercially-viable cover?
|
||||
5. GENRE SIGNAL: At a glance, does the whole cover (art + text) correctly signal {genre}?
|
||||
|
||||
SCORING SCALE:
|
||||
- 9-10: Indistinguishable from a professional published cover
|
||||
- 7-8: Strong cover, minor refinement would help
|
||||
- 5-6: Passable but text placement or contrast needs work
|
||||
- 1-4: Unusable — unreadable text, clashing colours, or amateurish layout
|
||||
|
||||
AUTO-FAIL: If the title text is illegible (low contrast, obscured, or missing) → score CANNOT exceed 4.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{"score": int, "critique": "Specific layout issues.", "actionable": "One change to position, colour, or font size that would fix the worst problem."}}
|
||||
"""
|
||||
response = model.generate_content([prompt, img])
|
||||
model_name = getattr(model, 'name', "logic")
|
||||
if folder:
|
||||
utils.log_usage(folder, model_name, response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
score = data.get('score')
|
||||
critique = data.get('critique', '')
|
||||
if data.get('actionable'):
|
||||
critique += f" FIX: {data['actionable']}"
|
||||
return score, critique
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Art prompt pre-validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_art_prompt(art_prompt, meta, model, folder=None):
|
||||
"""Pre-validate and improve the image generation prompt before calling Imagen.
|
||||
|
||||
Checks for: accidental text instructions, vague focal point, missing composition
|
||||
guidance, and genre mismatch. Returns improved prompt or original on failure.
|
||||
"""
|
||||
genre = meta.get('genre', 'Fiction')
|
||||
title = meta.get('title', 'Untitled')
|
||||
|
||||
check_prompt = f"""
|
||||
ROLE: Art Director
|
||||
TASK: Review and improve this image generation prompt for a {genre} book cover titled '{title}'.
|
||||
|
||||
CURRENT_PROMPT:
|
||||
{art_prompt}
|
||||
|
||||
CHECK FOR AND FIX:
|
||||
1. Any instruction to render text, letters, or the title? → Remove it (text is overlaid separately).
|
||||
2. Is there a specific, memorable FOCAL POINT described? → Add one if missing.
|
||||
3. Does the colour palette and style match {genre} conventions? → Correct if off.
|
||||
4. Is RULE OF THIRDS composition mentioned (space at top/bottom for title overlay)? → Add if missing.
|
||||
5. Does it end with "No text, no letters, no watermarks"? → Ensure this is present.
|
||||
|
||||
Return the improved prompt under 200 words.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{"improved_prompt": "..."}}
|
||||
"""
|
||||
try:
|
||||
resp = model.generate_content(check_prompt)
|
||||
if folder:
|
||||
utils.log_usage(folder, model.name, resp.usage_metadata)
|
||||
data = json.loads(utils.clean_json(resp.text))
|
||||
improved = data.get('improved_prompt', '').strip()
|
||||
if improved and len(improved) > 50:
|
||||
utils.log("MARKETING", " -> Art prompt validated and improved.")
|
||||
return improved
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f" -> Art prompt validation failed: {e}. Using original.")
|
||||
return art_prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visual context helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_visual_context(bp, tracking):
|
||||
"""Extract structured visual context: protagonist, antagonist, key themes."""
|
||||
lines = []
|
||||
chars = bp.get('characters', [])
|
||||
protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None)
|
||||
if protagonist:
|
||||
lines.append(f"PROTAGONIST: {protagonist.get('name')} — {protagonist.get('description', '')[:200]}")
|
||||
antagonist = next((c for c in chars if 'antagonist' in c.get('role', '').lower()), None)
|
||||
if antagonist:
|
||||
lines.append(f"ANTAGONIST: {antagonist.get('name')} — {antagonist.get('description', '')[:150]}")
|
||||
if tracking and tracking.get('characters'):
|
||||
for name, data in list(tracking['characters'].items())[:2]:
|
||||
desc = ', '.join(data.get('descriptors', []))[:120]
|
||||
if desc:
|
||||
lines.append(f"CHARACTER VISUAL ({name}): {desc}")
|
||||
if tracking and tracking.get('events'):
|
||||
recent = [e for e in tracking['events'][-3:] if isinstance(e, str)]
|
||||
if recent:
|
||||
lines.append(f"KEY THEMES/EVENTS: {'; '.join(recent)[:200]}")
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
||||
if not HAS_PIL:
|
||||
utils.log("MARKETING", "Pillow not installed. Skipping cover.")
|
||||
return
|
||||
|
||||
utils.log("MARKETING", "Generating cover...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
|
||||
ar = "3:4"
|
||||
if orientation == "Landscape": ar = "4:3"
|
||||
elif orientation == "Square": ar = "1:1"
|
||||
|
||||
visual_context = _build_visual_context(bp, tracking)
|
||||
|
||||
regenerate_image = True
|
||||
design_instruction = ""
|
||||
|
||||
if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback:
|
||||
regenerate_image = False
|
||||
|
||||
if feedback and feedback.strip():
|
||||
utils.log("MARKETING", f"Analysing feedback: '{feedback}'...")
|
||||
analysis_prompt = f"""
|
||||
ROLE: Design Assistant
|
||||
TASK: Analyse user feedback on a book cover.
|
||||
FEEDBACK: "{feedback}"
|
||||
DECISION:
|
||||
1. Keep the background image; change only text/layout/colour → REGENERATE_LAYOUT
|
||||
2. Create a completely new background image → REGENERATE_IMAGE
|
||||
OUTPUT_FORMAT (JSON): {{"action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for the Art Director."}}
|
||||
"""
|
||||
try:
|
||||
resp = ai_models.model_logic.generate_content(analysis_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
|
||||
decision = json.loads(utils.clean_json(resp.text))
|
||||
if decision.get('action') == 'REGENERATE_LAYOUT':
|
||||
regenerate_image = False
|
||||
utils.log("MARKETING", "Feedback: keeping image, regenerating layout only.")
|
||||
design_instruction = decision.get('instruction', feedback)
|
||||
except Exception:
|
||||
utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.")
|
||||
|
||||
genre = meta.get('genre', 'Fiction')
|
||||
tone = meta.get('style', {}).get('tone', 'Balanced')
|
||||
genre_style_map = {
|
||||
'thriller': 'dark, cinematic, high-contrast photography style',
|
||||
'mystery': 'moody, atmospheric, noir-inspired painting',
|
||||
'romance': 'warm, painterly, soft-focus illustration',
|
||||
'fantasy': 'epic digital painting, rich colours, mythic scale',
|
||||
'science fiction': 'sharp digital art, cool palette, futuristic',
|
||||
'horror': 'unsettling dark atmospheric painting, desaturated',
|
||||
'historical fiction':'classical oil painting style, period-accurate',
|
||||
'young adult': 'vibrant illustrated style, bold colours',
|
||||
}
|
||||
suggested_style = genre_style_map.get(genre.lower(), 'professional digital illustration')
|
||||
|
||||
design_prompt = f"""
|
||||
ROLE: Art Director
|
||||
TASK: Design a professional book cover for an AI image generator.
|
||||
|
||||
BOOK:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {genre}
|
||||
- TONE: {tone}
|
||||
- SUGGESTED_VISUAL_STYLE: {suggested_style}
|
||||
|
||||
VISUAL_CONTEXT (characters and themes from the finished story — use these):
|
||||
{visual_context if visual_context else "Use strong genre conventions."}
|
||||
|
||||
USER_FEEDBACK: {feedback if feedback else "None"}
|
||||
DESIGN_INSTRUCTION: {design_instruction if design_instruction else "Create a compelling, genre-appropriate cover."}
|
||||
|
||||
COVER_ART_RULES:
|
||||
- The art_prompt MUST produce an image with ABSOLUTELY NO text, letters, numbers, watermarks, UI elements, or logos. Text is overlaid separately.
|
||||
- Describe a specific, memorable FOCAL POINT (e.g. protagonist mid-action, a symbolic object, a dramatic landscape).
|
||||
- Use RULE OF THIRDS composition — preserve visual space at top AND bottom for title/author text overlay.
|
||||
- Describe LIGHTING that reinforces the tone (e.g. "harsh neon backlight", "golden hour", "cold winter dawn").
|
||||
- Specify the COLOUR PALETTE explicitly (e.g. "deep crimson and shadow-black", "soft rose gold and ivory cream").
|
||||
- If characters are described in VISUAL_CONTEXT, their appearance MUST match those descriptions exactly.
|
||||
- End the art_prompt with: "No text, no letters, no watermarks, no UI elements. {suggested_style} quality, 8k detail."
|
||||
|
||||
OUTPUT_FORMAT (JSON only, no markdown wrapper):
|
||||
{{
|
||||
"font_name": "One Google Font suited to {genre} (e.g. Cinzel for fantasy, Oswald for thriller, Playfair Display for romance)",
|
||||
"primary_color": "#HexCode",
|
||||
"text_color": "#HexCode (high contrast against primary_color)",
|
||||
"art_prompt": "Detailed image generation prompt. Style → Focal point → Composition → Lighting → Colour palette → Characters (if any). End with the NO TEXT clause."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_artist.generate_content(design_prompt)
|
||||
utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata)
|
||||
design = json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Cover design failed: {e}")
|
||||
return
|
||||
|
||||
bg_color = design.get('primary_color', '#252570')
|
||||
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
|
||||
font_name = design.get('font_name') or 'Playfair Display'
|
||||
|
||||
# Pre-validate and improve the art prompt before handing to Imagen
|
||||
art_prompt = validate_art_prompt(art_prompt, meta, ai_models.model_logic, folder)
|
||||
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
|
||||
f.write(art_prompt)
|
||||
|
||||
img = None
|
||||
width, height = 600, 900
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Phase 1: Art generation loop (evaluate → critique → refine → retry)
|
||||
# -----------------------------------------------------------------------
|
||||
best_art_score = 0
|
||||
best_art_path = None
|
||||
current_art_prompt = art_prompt
|
||||
MAX_ART_ATTEMPTS = 3
|
||||
|
||||
if regenerate_image:
|
||||
for attempt in range(1, MAX_ART_ATTEMPTS + 1):
|
||||
utils.log("MARKETING", f"Generating cover art (Attempt {attempt}/{MAX_ART_ATTEMPTS})...")
|
||||
attempt_path = os.path.join(folder, f"cover_art_attempt_{attempt}.png")
|
||||
gen_status = "success"
|
||||
|
||||
try:
|
||||
if not ai_models.model_image:
|
||||
raise ImportError("No image generation model available.")
|
||||
|
||||
try:
|
||||
result = ai_models.model_image.generate_images(
|
||||
prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
except Exception as img_err:
|
||||
err_lower = str(img_err).lower()
|
||||
if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
|
||||
try:
|
||||
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...")
|
||||
fb = ai_models.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001")
|
||||
result = fb.generate_images(prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
gen_status = "success_fast"
|
||||
except Exception:
|
||||
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...")
|
||||
fb = ai_models.VertexImageModel.from_pretrained("imagegeneration@006")
|
||||
result = fb.generate_images(prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
gen_status = "success_fallback"
|
||||
else:
|
||||
raise img_err
|
||||
|
||||
result.images[0].save(attempt_path)
|
||||
utils.log_usage(folder, "imagen", image_count=1)
|
||||
|
||||
score, critique = evaluate_cover_art(
|
||||
attempt_path, genre, meta.get('title', ''), ai_models.model_logic, folder)
|
||||
if score is None:
|
||||
score = 0
|
||||
utils.log("MARKETING", f" -> Art Score: {score}/10. Critique: {critique}")
|
||||
utils.log_image_attempt(folder, "cover", current_art_prompt,
|
||||
f"cover_art_attempt_{attempt}.png", gen_status,
|
||||
score=score, critique=critique)
|
||||
|
||||
if interactive:
|
||||
try:
|
||||
if os.name == 'nt': os.startfile(attempt_path)
|
||||
elif sys.platform == 'darwin': subprocess.call(('open', attempt_path))
|
||||
else: subprocess.call(('xdg-open', attempt_path))
|
||||
except Exception:
|
||||
pass
|
||||
from rich.prompt import Confirm
|
||||
if Confirm.ask(f"Accept cover art attempt {attempt} (score {score})?", default=True):
|
||||
best_art_path = attempt_path
|
||||
best_art_score = score
|
||||
break
|
||||
else:
|
||||
utils.log("MARKETING", "User rejected art. Regenerating...")
|
||||
continue
|
||||
|
||||
# Track best image — prefer passing threshold; keep first usable as fallback
|
||||
if score >= ART_SCORE_PASSING and score > best_art_score:
|
||||
best_art_score = score
|
||||
best_art_path = attempt_path
|
||||
elif best_art_path is None and score > 0:
|
||||
best_art_score = score
|
||||
best_art_path = attempt_path
|
||||
|
||||
if score >= ART_SCORE_AUTO_ACCEPT:
|
||||
utils.log("MARKETING", " -> High-quality art accepted early.")
|
||||
break
|
||||
|
||||
# Critique-driven prompt refinement for next attempt
|
||||
if attempt < MAX_ART_ATTEMPTS and critique:
|
||||
refine_req = f"""
|
||||
ROLE: Art Director
|
||||
TASK: Rewrite the image prompt to fix the critique below. Keep under 200 words.
|
||||
|
||||
CRITIQUE: {critique}
|
||||
ORIGINAL_PROMPT: {current_art_prompt}
|
||||
|
||||
RULES:
|
||||
- Preserve genre style, focal point, and colour palette unless explicitly criticised.
|
||||
- If text/watermarks were visible: reinforce "absolutely no text, no letters, no watermarks."
|
||||
- If anatomy was deformed: add "perfect anatomy, professional figure illustration."
|
||||
- If blurry: add "tack-sharp focus, highly detailed."
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{"improved_prompt": "..."}}
|
||||
"""
|
||||
try:
|
||||
rr = ai_models.model_logic.generate_content(refine_req)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, rr.usage_metadata)
|
||||
rd = json.loads(utils.clean_json(rr.text))
|
||||
improved = rd.get('improved_prompt', '').strip()
|
||||
if improved and len(improved) > 50:
|
||||
current_art_prompt = improved
|
||||
utils.log("MARKETING", " -> Art prompt refined for next attempt.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Image generation attempt {attempt} failed: {e}")
|
||||
if "quota" in str(e).lower():
|
||||
break
|
||||
|
||||
if best_art_path and os.path.exists(best_art_path):
|
||||
final_art_path = os.path.join(folder, "cover_art.png")
|
||||
if best_art_path != final_art_path:
|
||||
shutil.copy(best_art_path, final_art_path)
|
||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||
utils.log("MARKETING", f" -> Best art: {best_art_score}/10.")
|
||||
else:
|
||||
utils.log("MARKETING", "⚠️ No usable art generated. Falling back to solid colour cover.")
|
||||
img = Image.new('RGB', (width, height), color=bg_color)
|
||||
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
||||
else:
|
||||
final_art_path = os.path.join(folder, "cover_art.png")
|
||||
if os.path.exists(final_art_path):
|
||||
utils.log("MARKETING", "Using existing cover art (layout update only).")
|
||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||
else:
|
||||
utils.log("MARKETING", "Existing art not found. Using solid colour fallback.")
|
||||
img = Image.new('RGB', (width, height), color=bg_color)
|
||||
|
||||
if img is None:
|
||||
utils.log("MARKETING", "Cover generation aborted — no image available.")
|
||||
return
|
||||
|
||||
font_path = download_font(font_name)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Phase 2: Text layout loop (evaluate → critique → adjust → retry)
|
||||
# -----------------------------------------------------------------------
|
||||
best_layout_score = 0
|
||||
best_layout_path = None
|
||||
|
||||
base_layout_prompt = f"""
|
||||
ROLE: Graphic Designer
|
||||
TASK: Determine precise text layout coordinates for a 600×900 book cover image.
|
||||
|
||||
BOOK:
|
||||
- TITLE: {meta.get('title')}
|
||||
- AUTHOR: {meta.get('author', 'Unknown')}
|
||||
- GENRE: {genre}
|
||||
- FONT: {font_name}
|
||||
- TEXT_COLOR: {design.get('text_color', '#FFFFFF')}
|
||||
|
||||
PLACEMENT RULES:
|
||||
- Title in top third OR bottom third (not centre — that obscures the focal art).
|
||||
- Author name in the opposite zone, or just below the title.
|
||||
- Font sizes: title ~60-80px, author ~28-36px for a 600px-wide canvas.
|
||||
- Do NOT place text over faces or the primary focal point.
|
||||
- Coordinates are the CENTER of the text block (x=300 is horizontal centre).
|
||||
|
||||
{f"USER FEEDBACK: {feedback}. Adjust placement/colour accordingly." if feedback else ""}
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"title": {{"x": Int, "y": Int, "font_size": Int, "font_name": "{font_name}", "color": "#Hex"}},
|
||||
"author": {{"x": Int, "y": Int, "font_size": Int, "font_name": "{font_name}", "color": "#Hex"}}
|
||||
}}
|
||||
"""
|
||||
|
||||
layout_prompt = base_layout_prompt
|
||||
MAX_LAYOUT_ATTEMPTS = 5
|
||||
|
||||
for attempt in range(1, MAX_LAYOUT_ATTEMPTS + 1):
|
||||
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/{MAX_LAYOUT_ATTEMPTS})...")
|
||||
try:
|
||||
resp = ai_models.model_writer.generate_content([layout_prompt, img])
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata)
|
||||
layout = json.loads(utils.clean_json(resp.text))
|
||||
if isinstance(layout, list):
|
||||
layout = layout[0] if layout else {}
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Layout generation failed: {e}")
|
||||
continue
|
||||
|
||||
img_copy = img.copy()
|
||||
draw = ImageDraw.Draw(img_copy)
|
||||
|
||||
def draw_element(key, text_override=None):
|
||||
elem = layout.get(key)
|
||||
if not elem:
|
||||
return
|
||||
if isinstance(elem, list):
|
||||
elem = elem[0] if elem else {}
|
||||
text = text_override if text_override else elem.get('text')
|
||||
if not text:
|
||||
return
|
||||
f_name = elem.get('font_name') or font_name
|
||||
f_p = download_font(f_name)
|
||||
try:
|
||||
fnt = ImageFont.truetype(f_p, elem.get('font_size', 40)) if f_p else ImageFont.load_default()
|
||||
except Exception:
|
||||
fnt = ImageFont.load_default()
|
||||
x, y = elem.get('x', 300), elem.get('y', 450)
|
||||
color = elem.get('color') or design.get('text_color', '#FFFFFF')
|
||||
avg_w = fnt.getlength("A")
|
||||
wrap_w = int(550 / avg_w) if avg_w > 0 else 20
|
||||
lines = textwrap.wrap(text, width=wrap_w)
|
||||
line_heights = []
|
||||
for ln in lines:
|
||||
bbox = draw.textbbox((0, 0), ln, font=fnt)
|
||||
line_heights.append(bbox[3] - bbox[1] + 10)
|
||||
total_h = sum(line_heights)
|
||||
current_y = y - (total_h // 2)
|
||||
for idx, ln in enumerate(lines):
|
||||
bbox = draw.textbbox((0, 0), ln, font=fnt)
|
||||
lx = x - ((bbox[2] - bbox[0]) / 2)
|
||||
draw.text((lx, current_y), ln, font=fnt, fill=color)
|
||||
current_y += line_heights[idx]
|
||||
|
||||
draw_element('title', meta.get('title'))
|
||||
draw_element('author', meta.get('author'))
|
||||
|
||||
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
||||
img_copy.save(attempt_path)
|
||||
|
||||
score, critique = evaluate_cover_layout(
|
||||
attempt_path, meta.get('title', ''), meta.get('author', ''), genre, font_name,
|
||||
ai_models.model_writer, folder
|
||||
)
|
||||
if score is None:
|
||||
score = 0
|
||||
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score > best_layout_score:
|
||||
best_layout_score = score
|
||||
best_layout_path = attempt_path
|
||||
|
||||
if score >= LAYOUT_SCORE_PASSING:
|
||||
utils.log("MARKETING", f" -> Layout accepted (score {score} ≥ {LAYOUT_SCORE_PASSING}).")
|
||||
break
|
||||
|
||||
if attempt < MAX_LAYOUT_ATTEMPTS:
|
||||
layout_prompt = (base_layout_prompt
|
||||
+ f"\n\nCRITIQUE OF ATTEMPT {attempt}: {critique}\n"
|
||||
+ "Adjust coordinates, font_size, or color to fix these issues exactly.")
|
||||
|
||||
if best_layout_path:
|
||||
shutil.copy(best_layout_path, os.path.join(folder, "cover.png"))
|
||||
utils.log("MARKETING", f"Cover saved. Best layout score: {best_layout_score}/10.")
|
||||
else:
|
||||
utils.log("MARKETING", "⚠️ No layout produced. Cover not saved.")
|
||||
61
marketing/fonts.py
Normal file
61
marketing/fonts.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import requests
|
||||
from core import config, utils
|
||||
|
||||
|
||||
def download_font(font_name):
|
||||
if not font_name: font_name = "Roboto"
|
||||
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
|
||||
|
||||
if "," in font_name: font_name = font_name.split(",")[0].strip()
|
||||
|
||||
if font_name.lower().endswith(('.ttf', '.otf')):
|
||||
font_name = os.path.splitext(font_name)[0]
|
||||
|
||||
font_name = font_name.strip().strip("'").strip('"')
|
||||
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
|
||||
if font_name.endswith(suffix):
|
||||
font_name = font_name[:-len(suffix)]
|
||||
font_name = font_name.strip()
|
||||
|
||||
clean_name = font_name.replace(" ", "").lower()
|
||||
font_filename = f"{clean_name}.ttf"
|
||||
font_path = os.path.join(config.FONTS_DIR, font_filename)
|
||||
|
||||
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
|
||||
utils.log("ASSETS", f"Using cached font: {font_path}")
|
||||
return font_path
|
||||
|
||||
utils.log("ASSETS", f"Downloading font: {font_name}...")
|
||||
compact_name = font_name.replace(" ", "")
|
||||
title_compact = "".join(x.title() for x in font_name.split())
|
||||
|
||||
patterns = [
|
||||
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
|
||||
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
|
||||
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
|
||||
f"{title_compact}-Regular.otf",
|
||||
]
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
|
||||
for license_type in ["ofl", "apache", "ufl"]:
|
||||
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
||||
for pattern in patterns:
|
||||
try:
|
||||
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=6)
|
||||
if r.status_code == 200 and len(r.content) > 1000:
|
||||
with open(font_path, 'wb') as f:
|
||||
f.write(r.content)
|
||||
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
||||
return font_path
|
||||
except requests.exceptions.Timeout:
|
||||
utils.log("ASSETS", f" Font download timeout for {font_name} ({pattern}). Trying next...")
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if clean_name != "roboto":
|
||||
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found on Google Fonts. Falling back to Roboto.")
|
||||
return download_font("Roboto")
|
||||
utils.log("ASSETS", "⚠️ Roboto fallback also failed. PIL will use built-in default font.")
|
||||
return None
|
||||
215
modules/ai.py
215
modules/ai.py
@@ -1,215 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
import google.generativeai as genai
|
||||
import config
|
||||
from . import utils
|
||||
|
||||
# Suppress Vertex AI warnings
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
|
||||
|
||||
try:
|
||||
import vertexai
|
||||
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
|
||||
HAS_VERTEX = True
|
||||
except ImportError:
|
||||
HAS_VERTEX = False
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
HAS_OAUTH = True
|
||||
except ImportError:
|
||||
HAS_OAUTH = False
|
||||
|
||||
model_logic = None
|
||||
model_writer = None
|
||||
model_artist = None
|
||||
model_image = None
|
||||
|
||||
def get_optimal_model(base_type="pro"):
|
||||
try:
|
||||
models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
|
||||
candidates = [m.name for m in models if base_type in m.name]
|
||||
if not candidates: return f"models/gemini-1.5-{base_type}"
|
||||
def score(n):
|
||||
# Prioritize stable models (higher quotas) over experimental/beta ones
|
||||
if "exp" in n or "beta" in n: return 0
|
||||
if "latest" in n: return 50
|
||||
return 100
|
||||
return sorted(candidates, key=score, reverse=True)[0]
|
||||
except: return f"models/gemini-1.5-{base_type}"
|
||||
|
||||
def get_default_models():
|
||||
return {
|
||||
"logic": {"model": "models/gemini-1.5-pro", "reason": "Fallback: Default Pro model selected."},
|
||||
"writer": {"model": "models/gemini-1.5-flash", "reason": "Fallback: Default Flash model selected."},
|
||||
"artist": {"model": "models/gemini-1.5-flash", "reason": "Fallback: Default Flash model selected."},
|
||||
"ranking": []
|
||||
}
|
||||
|
||||
def select_best_models(force_refresh=False):
|
||||
"""
|
||||
Uses a safe bootstrapper model to analyze available models and pick the best ones.
|
||||
Caches the result for 24 hours.
|
||||
"""
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
cached_models = None
|
||||
|
||||
# 1. Check Cache
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f:
|
||||
cached = json.load(f)
|
||||
cached_models = cached.get('models', {})
|
||||
# Check if within 24 hours (86400 seconds)
|
||||
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
|
||||
models = cached_models
|
||||
# Validate format (must be dicts with reasons, not just strings)
|
||||
if isinstance(models.get('logic'), dict) and 'reason' in models['logic']:
|
||||
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
|
||||
return models
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
|
||||
|
||||
try:
|
||||
utils.log("SYSTEM", "Refreshing AI model list from API...")
|
||||
models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
|
||||
|
||||
bootstrapper = "models/gemini-1.5-flash"
|
||||
if bootstrapper not in models:
|
||||
candidates = [m for m in models if 'flash' in m]
|
||||
bootstrapper = candidates[0] if candidates else "models/gemini-pro"
|
||||
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
|
||||
|
||||
model = genai.GenerativeModel(bootstrapper)
|
||||
prompt = f"Analyze this list of available Google Gemini models:\n{json.dumps(models)}\n\nSelect the best model for each of these three roles based on these criteria:\n- Most recent version with best features and ability.\n- Beta versions are okay, but avoid 'experimental' if a stable beta/prod version exists.\n- Consider quota efficiency (Flash is cheaper/faster, Pro is smarter).\n\nROLES:\n1. LOGIC: For complex reasoning, JSON structuring, and plot planning.\n2. WRITER: For creative fiction writing, prose generation, and speed.\n3. ARTIST: For generating visual art prompts and design instructions.\n\nAlso provide a 'ranking' list of ALL models analyzed, ordered from best/most useful to worst/least useful, with a short reason.\n\nReturn JSON: {{ 'logic': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'writer': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'artist': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'ranking': [ {{ 'model': 'model_name', 'reason': 'reasoning' }} ] }}"
|
||||
|
||||
response = model.generate_content(prompt)
|
||||
selection = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR)
|
||||
with open(cache_path, 'w') as f:
|
||||
json.dump({"timestamp": int(time.time()), "models": selection, "available_at_time": models}, f, indent=2)
|
||||
return selection
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"AI Model Selection failed: {e}.")
|
||||
|
||||
# 3. Fallback to Stale Cache if available (Better than heuristics)
|
||||
# Relaxed check: If we successfully loaded ANY JSON from the cache, use it.
|
||||
if cached_models:
|
||||
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
|
||||
return cached_models
|
||||
|
||||
utils.log("SYSTEM", "Falling back to heuristics.")
|
||||
fallback = get_default_models()
|
||||
|
||||
# Save fallback to cache if file doesn't exist OR if we couldn't load it (corrupt/None)
|
||||
# This ensures we have a valid file on disk for the web UI to read.
|
||||
try:
|
||||
with open(cache_path, 'w') as f:
|
||||
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
|
||||
except: pass
|
||||
return fallback
|
||||
|
||||
def init_models(force=False):
|
||||
global model_logic, model_writer, model_artist, model_image
|
||||
if model_logic and not force: return
|
||||
genai.configure(api_key=config.API_KEY)
|
||||
|
||||
# Check cache to skip frequent validation
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
skip_validation = False
|
||||
if not force and os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f: cached = json.load(f)
|
||||
if time.time() - cached.get('timestamp', 0) < 86400: skip_validation = True
|
||||
except: pass
|
||||
|
||||
if not skip_validation:
|
||||
# Validate Gemini API Key
|
||||
utils.log("SYSTEM", "Validating credentials...")
|
||||
try:
|
||||
list(genai.list_models(page_size=1))
|
||||
utils.log("SYSTEM", "✅ Gemini API Key is valid.")
|
||||
except Exception as e:
|
||||
# Check if we have a cache file we can rely on before exiting
|
||||
if os.path.exists(cache_path):
|
||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
|
||||
else:
|
||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.")
|
||||
|
||||
utils.log("SYSTEM", "Selecting optimal models via AI...")
|
||||
selected_models = select_best_models(force_refresh=force)
|
||||
|
||||
def get_model_name(role_data):
|
||||
if isinstance(role_data, dict): return role_data.get('model')
|
||||
return role_data
|
||||
|
||||
logic_name = get_model_name(selected_models['logic']) if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||
writer_name = get_model_name(selected_models['writer']) if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
||||
artist_name = get_model_name(selected_models['artist']) if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
|
||||
utils.log("SYSTEM", f"Models: Logic={logic_name} | Writer={writer_name} | Artist={artist_name}")
|
||||
|
||||
model_logic = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
model_writer = genai.GenerativeModel(writer_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
model_artist = genai.GenerativeModel(artist_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
|
||||
# Initialize Image Model (Default to None)
|
||||
model_image = None
|
||||
if hasattr(genai, 'ImageGenerationModel'):
|
||||
try: model_image = genai.ImageGenerationModel("imagen-3.0-generate-001")
|
||||
except: pass
|
||||
|
||||
img_source = "Gemini API" if model_image else "None"
|
||||
|
||||
if HAS_VERTEX and config.GCP_PROJECT:
|
||||
creds = None
|
||||
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account
|
||||
if HAS_OAUTH:
|
||||
gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var
|
||||
if gac and os.path.exists(gac):
|
||||
try:
|
||||
with open(gac, 'r') as f: data = json.load(f)
|
||||
if 'installed' in data or 'web' in data:
|
||||
# It's an OAuth Client ID. Unset env var to avoid library crash.
|
||||
if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
|
||||
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
|
||||
|
||||
token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json')
|
||||
SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
|
||||
if os.path.exists(token_path):
|
||||
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception:
|
||||
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
else:
|
||||
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
with open(token_path, 'w') as token: token.write(creds.to_json())
|
||||
|
||||
utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}")
|
||||
|
||||
vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
|
||||
utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
|
||||
|
||||
# Override with Vertex Image Model if available
|
||||
try:
|
||||
model_image = VertexImageModel.from_pretrained("imagen-3.0-generate-001")
|
||||
img_source = "Vertex AI"
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", f"Image Generation Provider: {img_source}")
|
||||
@@ -1,350 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import textwrap
|
||||
import requests
|
||||
import google.generativeai as genai
|
||||
from . import utils
|
||||
import config
|
||||
from modules import ai
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageStat
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
def download_font(font_name):
|
||||
"""Attempts to download a Google Font from GitHub."""
|
||||
if not font_name: font_name = "Roboto"
|
||||
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
|
||||
|
||||
# Handle CSS-style lists (e.g. "Roboto, sans-serif")
|
||||
if "," in font_name: font_name = font_name.split(",")[0].strip()
|
||||
|
||||
# Handle filenames provided by AI
|
||||
if font_name.lower().endswith(('.ttf', '.otf')):
|
||||
font_name = os.path.splitext(font_name)[0]
|
||||
|
||||
font_name = font_name.strip().strip("'").strip('"')
|
||||
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
|
||||
if font_name.endswith(suffix):
|
||||
font_name = font_name[:-len(suffix)]
|
||||
font_name = font_name.strip()
|
||||
|
||||
clean_name = font_name.replace(" ", "").lower()
|
||||
font_filename = f"{clean_name}.ttf"
|
||||
font_path = os.path.join(config.FONTS_DIR, font_filename)
|
||||
|
||||
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
|
||||
utils.log("ASSETS", f"Using cached font: {font_path}")
|
||||
return font_path
|
||||
|
||||
utils.log("ASSETS", f"Downloading font: {font_name}...")
|
||||
compact_name = font_name.replace(" ", "")
|
||||
title_compact = "".join(x.title() for x in font_name.split())
|
||||
|
||||
patterns = [
|
||||
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
|
||||
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
|
||||
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
|
||||
f"{title_compact}-Regular.otf",
|
||||
]
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
|
||||
for license_type in ["ofl", "apache", "ufl"]:
|
||||
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
||||
for pattern in patterns:
|
||||
try:
|
||||
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
|
||||
if r.status_code == 200 and len(r.content) > 1000:
|
||||
with open(font_path, 'wb') as f: f.write(r.content)
|
||||
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
||||
return font_path
|
||||
except Exception: continue
|
||||
|
||||
if clean_name != "roboto":
|
||||
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
|
||||
return download_font("Roboto")
|
||||
return None
|
||||
|
||||
def evaluate_image_quality(image_path, prompt, model, folder=None):
|
||||
if not HAS_PIL: return None, "PIL not installed"
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
response = model.generate_content([f"Analyze this generated image against the description: '{prompt}'.\nRate accuracy/relevance on a scale of 1-10.\nProvide a 1-sentence critique.\nReturn JSON: {{'score': int, 'reason': 'string'}}", img])
|
||||
if folder: utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
return data.get('score'), data.get('reason')
|
||||
except Exception as e: return None, str(e)
|
||||
|
||||
def generate_blurb(bp, folder):
|
||||
utils.log("MARKETING", "Generating blurb...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
prompt = f"""
|
||||
Write a compelling back-cover blurb (approx 150-200 words) for this book.
|
||||
TITLE: {meta.get('title')}
|
||||
GENRE: {meta.get('genre')}
|
||||
LOGLINE: {bp.get('manual_instruction')}
|
||||
PLOT: {json.dumps(bp.get('plot_beats', []))}
|
||||
CHARACTERS: {json.dumps(bp.get('characters', []))}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, "writer-flash", response.usage_metadata)
|
||||
blurb = response.text
|
||||
with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb)
|
||||
with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb)
|
||||
except:
|
||||
utils.log("MARKETING", "Failed to generate blurb.")
|
||||
|
||||
def generate_cover(bp, folder, tracking=None, feedback=None):
|
||||
if not HAS_PIL:
|
||||
utils.log("MARKETING", "Pillow not installed. Skipping image cover.")
|
||||
return
|
||||
|
||||
utils.log("MARKETING", "Generating cover...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
series = bp.get('series_metadata', {})
|
||||
|
||||
orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
|
||||
ar = "3:4"
|
||||
if orientation == "Landscape": ar = "4:3"
|
||||
elif orientation == "Square": ar = "1:1"
|
||||
|
||||
visual_context = ""
|
||||
if tracking:
|
||||
visual_context = "IMPORTANT VISUAL CONTEXT:\n"
|
||||
if 'events' in tracking:
|
||||
visual_context += f"Key Events/Themes: {json.dumps(tracking['events'][-5:])}\n"
|
||||
if 'characters' in tracking:
|
||||
visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n"
|
||||
|
||||
# Feedback Analysis
|
||||
regenerate_image = True
|
||||
design_instruction = ""
|
||||
|
||||
if feedback and feedback.strip():
|
||||
utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...")
|
||||
analysis_prompt = f"""
|
||||
User Feedback on Book Cover: "{feedback}"
|
||||
Determine if the user wants to:
|
||||
1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT).
|
||||
2. Create a completely new background image (REGENERATE_IMAGE).
|
||||
|
||||
NOTE: If the feedback is generic (e.g. "regenerate", "try again") or does not explicitly mention keeping the image/changing text only, default to REGENERATE_IMAGE.
|
||||
Return JSON: {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for the Art Director" }}
|
||||
"""
|
||||
try:
|
||||
resp = ai.model_logic.generate_content(analysis_prompt)
|
||||
decision = json.loads(utils.clean_json(resp.text))
|
||||
if decision.get('action') == 'REGENERATE_LAYOUT':
|
||||
regenerate_image = False
|
||||
utils.log("MARKETING", "Feedback indicates keeping image. Regenerating layout only.")
|
||||
design_instruction = decision.get('instruction', feedback)
|
||||
except:
|
||||
utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.")
|
||||
|
||||
design_prompt = f"""
|
||||
Act as an Art Director. Design the cover for this book.
|
||||
TITLE: {meta.get('title')}
|
||||
GENRE: {meta.get('genre')}
|
||||
TONE: {meta.get('style', {}).get('tone')}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. CHARACTER APPEARANCE: Strictly adhere to the provided character descriptions (hair, eyes, race, age, clothing) in the Visual Context.
|
||||
2. GENRE EXPRESSIONS: Ensure character facial expressions and body language heavily reflect the GENRE (e.g. Horror = terrified/menacing, Romance = longing/soft, Thriller = intense/alert).
|
||||
|
||||
{visual_context}
|
||||
{f"USER FEEDBACK: {feedback}" if feedback else ""}
|
||||
{f"INSTRUCTION: {design_instruction}" if design_instruction else ""}
|
||||
|
||||
Provide JSON output:
|
||||
{{
|
||||
"font_name": "Name of a popular Google Font (e.g. Roboto, Cinzel, Oswald, Playfair Display)",
|
||||
"primary_color": "#HexCode (Background)",
|
||||
"text_color": "#HexCode (Contrast)",
|
||||
"art_prompt": "A detailed description of the cover art for an image generator. Explicitly describe characters based on the visual context."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_artist.generate_content(design_prompt)
|
||||
utils.log_usage(folder, "artist-flash", response.usage_metadata)
|
||||
design = json.loads(utils.clean_json(response.text))
|
||||
|
||||
bg_color = design.get('primary_color', '#252570')
|
||||
text_color = design.get('text_color', '#FFFFFF')
|
||||
|
||||
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
|
||||
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
|
||||
f.write(art_prompt)
|
||||
|
||||
img = None
|
||||
image_generated = False
|
||||
width, height = 600, 900
|
||||
|
||||
best_img_score = 0
|
||||
best_img_path = None
|
||||
|
||||
if regenerate_image:
|
||||
for i in range(1, 6):
|
||||
utils.log("MARKETING", f"Generating cover art (Attempt {i}/5)...")
|
||||
try:
|
||||
if not ai.model_image: raise ImportError("No Image Generation Model available.")
|
||||
|
||||
status = "success"
|
||||
try:
|
||||
result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
except Exception as e:
|
||||
if "resource" in str(e).lower() and ai.HAS_VERTEX:
|
||||
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 2...")
|
||||
fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006")
|
||||
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
status = "success_fallback"
|
||||
else: raise e
|
||||
|
||||
attempt_path = os.path.join(folder, f"cover_art_attempt_{i}.png")
|
||||
result.images[0].save(attempt_path)
|
||||
utils.log_usage(folder, "imagen", image_count=1)
|
||||
|
||||
score, critique = evaluate_image_quality(attempt_path, art_prompt, ai.model_logic, folder)
|
||||
if score is None: score = 0
|
||||
|
||||
utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}")
|
||||
utils.log_image_attempt(folder, "cover", art_prompt, f"cover_art_{i}.png", status, score=score, critique=critique)
|
||||
|
||||
if score > best_img_score:
|
||||
best_img_score = score
|
||||
best_img_path = attempt_path
|
||||
|
||||
if score == 10:
|
||||
utils.log("MARKETING", " -> Perfect image accepted.")
|
||||
break
|
||||
|
||||
if "scar" in critique.lower() or "deform" in critique.lower() or "blur" in critique.lower():
|
||||
art_prompt += " (Ensure high quality, clear skin, no scars, sharp focus)."
|
||||
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Image generation failed: {e}")
|
||||
if "quota" in str(e).lower(): break
|
||||
|
||||
if best_img_path and os.path.exists(best_img_path):
|
||||
final_art_path = os.path.join(folder, "cover_art.png")
|
||||
if best_img_path != final_art_path:
|
||||
shutil.copy(best_img_path, final_art_path)
|
||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||
image_generated = True
|
||||
else:
|
||||
utils.log("MARKETING", "Falling back to solid color cover.")
|
||||
img = Image.new('RGB', (width, height), color=bg_color)
|
||||
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
||||
else:
|
||||
# Load existing art
|
||||
final_art_path = os.path.join(folder, "cover_art.png")
|
||||
if os.path.exists(final_art_path):
|
||||
utils.log("MARKETING", "Using existing cover art (Layout update only).")
|
||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||
else:
|
||||
utils.log("MARKETING", "Existing art not found. Forcing regeneration.")
|
||||
# Fallback to solid color if we were supposed to reuse but couldn't find it
|
||||
img = Image.new('RGB', (width, height), color=bg_color)
|
||||
|
||||
font_path = download_font(design.get('font_name') or 'Arial')
|
||||
|
||||
best_layout_score = 0
|
||||
best_layout_path = None
|
||||
|
||||
base_layout_prompt = f"""
|
||||
Act as a Senior Book Cover Designer. Analyze this 600x900 cover art.
|
||||
BOOK DETAILS: Title: {meta.get('title')}, Author: {meta.get('author')}, Genre: {meta.get('genre')}
|
||||
TASK: Determine best (x, y) coordinates for Title and Author. Do NOT place text over faces.
|
||||
RETURN JSON: {{ "title": {{ "x": int, "y": int, "font_size": int, "font_name": "String", "color": "#Hex" }}, "author": {{ "x": int, "y": int, "font_size": int, "font_name": "String", "color": "#Hex" }} }}
|
||||
"""
|
||||
|
||||
if feedback:
|
||||
base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly."
|
||||
|
||||
layout_prompt = base_layout_prompt
|
||||
|
||||
for attempt in range(1, 6):
|
||||
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
|
||||
try:
|
||||
response = ai.model_logic.generate_content([layout_prompt, img])
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
layout = json.loads(utils.clean_json(response.text))
|
||||
if isinstance(layout, list): layout = layout[0] if layout else {}
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Layout generation failed: {e}")
|
||||
continue
|
||||
|
||||
img_copy = img.copy()
|
||||
draw = ImageDraw.Draw(img_copy)
|
||||
|
||||
def draw_element(key, text_override=None):
|
||||
elem = layout.get(key)
|
||||
if not elem: return
|
||||
if isinstance(elem, list): elem = elem[0] if elem else {}
|
||||
text = text_override if text_override else elem.get('text')
|
||||
if not text: return
|
||||
|
||||
f_name = elem.get('font_name') or 'Arial'
|
||||
f_path = download_font(f_name)
|
||||
try:
|
||||
if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40))
|
||||
else: raise IOError("Font not found")
|
||||
except: font = ImageFont.load_default()
|
||||
|
||||
x, y = elem.get('x', 300), elem.get('y', 450)
|
||||
color = elem.get('color') or '#FFFFFF'
|
||||
|
||||
avg_char_w = font.getlength("A")
|
||||
wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20
|
||||
lines = textwrap.wrap(text, width=wrap_w)
|
||||
|
||||
line_heights = []
|
||||
for l in lines:
|
||||
bbox = draw.textbbox((0, 0), l, font=font)
|
||||
line_heights.append(bbox[3] - bbox[1] + 10)
|
||||
|
||||
total_h = sum(line_heights)
|
||||
current_y = y - (total_h // 2)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
lx = x - ((bbox[2] - bbox[0]) / 2)
|
||||
draw.text((lx, current_y), line, font=font, fill=color)
|
||||
current_y += line_heights[i]
|
||||
|
||||
draw_element('title', meta.get('title'))
|
||||
draw_element('author', meta.get('author'))
|
||||
|
||||
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
||||
img_copy.save(attempt_path)
|
||||
|
||||
# Evaluate Layout
|
||||
eval_prompt = f"Analyze this book cover layout. Is the text legible? Is the contrast good? Does it look professional? Title: {meta.get('title')}"
|
||||
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_logic, folder)
|
||||
if score is None: score = 0
|
||||
|
||||
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score > best_layout_score:
|
||||
best_layout_score = score
|
||||
best_layout_path = attempt_path
|
||||
|
||||
if score == 10:
|
||||
utils.log("MARKETING", " -> Perfect layout accepted.")
|
||||
break
|
||||
|
||||
layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this."
|
||||
|
||||
if best_layout_path:
|
||||
shutil.copy(best_layout_path, os.path.join(folder, "cover.png"))
|
||||
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Cover generation failed: {e}")
|
||||
|
||||
def create_marketing_assets(bp, folder, tracking=None):
|
||||
generate_blurb(bp, folder)
|
||||
generate_cover(bp, folder, tracking)
|
||||
626
modules/story.py
626
modules/story.py
@@ -1,626 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import config
|
||||
from modules import ai
|
||||
from . import utils
|
||||
|
||||
def enrich(bp, folder, context=""):
|
||||
utils.log("ENRICHER", "Fleshing out details from description...")
|
||||
|
||||
# If book_metadata is missing, create empty dict so AI can fill it
|
||||
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
||||
if 'characters' not in bp: bp['characters'] = []
|
||||
if 'plot_beats' not in bp: bp['plot_beats'] = []
|
||||
|
||||
prompt = f"""
|
||||
You are a Creative Director.
|
||||
The user has provided a minimal description. You must build a full Book Bible.
|
||||
|
||||
USER DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}"
|
||||
CONTEXT (Sequel): {context}
|
||||
|
||||
TASK:
|
||||
1. Generate a catchy Title.
|
||||
2. Define the Genre and Tone.
|
||||
3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future").
|
||||
4. Define Formatting Rules for text messages, thoughts, and chapter headers.
|
||||
5. Create Protagonist and Antagonist/Love Interest.
|
||||
- IF SEQUEL: Decide if we continue with previous protagonists or shift to side characters based on USER DESCRIPTION.
|
||||
- IF NEW CHARACTERS: Create them.
|
||||
- IF RETURNING: Reuse details from CONTEXT.
|
||||
6. Outline 5-7 core Plot Beats.
|
||||
7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural").
|
||||
|
||||
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"] }} }},
|
||||
"characters": [ {{ "name": "Name", "role": "Role", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
||||
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
# Merge AI response with existing data (don't overwrite if user provided specific keys)
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
response_text = response.text
|
||||
cleaned_json = utils.clean_json(response_text)
|
||||
ai_data = json.loads(cleaned_json)
|
||||
|
||||
# Smart Merge: Only fill missing fields
|
||||
if 'book_metadata' not in bp:
|
||||
bp['book_metadata'] = {}
|
||||
|
||||
if 'title' not in bp['book_metadata']:
|
||||
bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title')
|
||||
if 'structure_prompt' not in bp['book_metadata']:
|
||||
bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt')
|
||||
if 'content_warnings' not in bp['book_metadata']:
|
||||
bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', [])
|
||||
|
||||
# Merge Style (Flexible)
|
||||
if 'style' not in bp['book_metadata']:
|
||||
bp['book_metadata']['style'] = {}
|
||||
|
||||
# Handle AI returning legacy keys or new style key
|
||||
source_style = ai_data.get('book_metadata', {}).get('style', {})
|
||||
|
||||
for k, v in source_style.items():
|
||||
if k not in bp['book_metadata']['style']:
|
||||
bp['book_metadata']['style'][k] = v
|
||||
|
||||
if 'characters' not in bp or not bp['characters']:
|
||||
bp['characters'] = ai_data.get('characters', [])
|
||||
if 'plot_beats' not in bp or not bp['plot_beats']:
|
||||
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
||||
|
||||
return bp
|
||||
except Exception as e:
|
||||
utils.log("ENRICHER", f"Enrichment failed: {e}")
|
||||
return bp
|
||||
|
||||
def plan_structure(bp, folder):
|
||||
utils.log("ARCHITECT", "Creating structure...")
|
||||
|
||||
if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict):
|
||||
po = bp['plot_outline']
|
||||
if 'beats' in po and isinstance(po['beats'], list):
|
||||
events = []
|
||||
for act in po['beats']:
|
||||
if 'plot_points' in act and isinstance(act['plot_points'], list):
|
||||
for pp in act['plot_points']:
|
||||
desc = pp.get('description')
|
||||
point = pp.get('point', 'Event')
|
||||
if desc: events.append({"description": desc, "purpose": point})
|
||||
if events:
|
||||
utils.log("ARCHITECT", f"Using {len(events)} events from Plot Outline as base structure.")
|
||||
return events
|
||||
|
||||
structure_type = bp.get('book_metadata', {}).get('structure_prompt')
|
||||
|
||||
if not structure_type:
|
||||
label = bp.get('length_settings', {}).get('label', 'Novel')
|
||||
structures = {
|
||||
"Chapter Book": "Create a simple episodic structure with clear chapter hooks.",
|
||||
"Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.",
|
||||
"Flash Fiction": "Create a single, impactful scene structure with a twist.",
|
||||
"Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).",
|
||||
"Novella": "Create a standard 3-Act Structure.",
|
||||
"Novel": "Create a detailed 3-Act Structure with A and B plots.",
|
||||
"Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events."
|
||||
}
|
||||
structure_type = structures.get(label, "Create a 3-Act Structure.")
|
||||
|
||||
beats_context = []
|
||||
if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict):
|
||||
po = bp['plot_outline']
|
||||
if 'beats' in po:
|
||||
for act in po['beats']:
|
||||
beats_context.append(f"ACT {act.get('act', '?')}: {act.get('title', '')} - {act.get('summary', '')}")
|
||||
for pp in act.get('plot_points', []):
|
||||
beats_context.append(f" * {pp.get('point', 'Beat')}: {pp.get('description', '')}")
|
||||
|
||||
if not beats_context:
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
|
||||
prompt = f"{structure_type}\nTITLE: {bp['book_metadata']['title']}\nBEATS: {json.dumps(beats_context)}\nReturn JSON: {{'events': [{{'description':'...', 'purpose':'...'}}]}}"
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))['events']
|
||||
except:
|
||||
return []
|
||||
|
||||
def expand(events, pass_num, target_chapters, bp, folder):
|
||||
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
|
||||
|
||||
beats_context = []
|
||||
if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict):
|
||||
po = bp['plot_outline']
|
||||
if 'beats' in po:
|
||||
for act in po['beats']:
|
||||
beats_context.append(f"ACT {act.get('act', '?')}: {act.get('title', '')} - {act.get('summary', '')}")
|
||||
for pp in act.get('plot_points', []):
|
||||
beats_context.append(f" * {pp.get('point', 'Beat')}: {pp.get('description', '')}")
|
||||
|
||||
if not beats_context:
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
|
||||
prompt = f"""
|
||||
You are a Story Architect.
|
||||
Goal: Flesh out this outline for a {target_chapters}-chapter book.
|
||||
Current Status: {len(events)} beats.
|
||||
|
||||
ORIGINAL OUTLINE:
|
||||
{json.dumps(beats_context)}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Look for jumps in time or logic.
|
||||
2. Insert new intermediate events to smooth the pacing.
|
||||
3. Deepen subplots while staying true to the ORIGINAL OUTLINE.
|
||||
4. Do NOT remove or drastically alter the original outline points; expand AROUND them.
|
||||
|
||||
CURRENT EVENTS:
|
||||
{json.dumps(events)}
|
||||
|
||||
Return JSON: {{'events': [ ...updated full list... ]}}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_events = json.loads(utils.clean_json(response.text))['events']
|
||||
|
||||
if len(new_events) > len(events):
|
||||
utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.")
|
||||
elif len(str(new_events)) > len(str(events)) + 20:
|
||||
utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).")
|
||||
else:
|
||||
utils.log("ARCHITECT", " -> No significant changes.")
|
||||
return new_events
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}")
|
||||
return events
|
||||
|
||||
def create_chapter_plan(events, bp, folder):
|
||||
utils.log("ARCHITECT", "Finalizing Chapters...")
|
||||
target = bp['length_settings']['chapters']
|
||||
words = bp['length_settings'].get('words', 'Flexible')
|
||||
|
||||
include_prologue = bp.get('length_settings', {}).get('include_prologue', False)
|
||||
include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False)
|
||||
|
||||
structure_instructions = ""
|
||||
if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n"
|
||||
if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n"
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
pov_chars = style.get('pov_characters', [])
|
||||
pov_instruction = ""
|
||||
if pov_chars:
|
||||
pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}."
|
||||
|
||||
prompt = f"""
|
||||
Group events into Chapters.
|
||||
TARGET CHAPTERS: {target} (Approximate. Feel free to adjust +/- 20% for better pacing).
|
||||
TARGET WORDS: {words} (Total for the book).
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Vary chapter pacing. Options: 'Very Fast', 'Fast', 'Standard', 'Slow', 'Very Slow'.
|
||||
- Assign an estimated word count to each chapter based on its pacing and content.
|
||||
{structure_instructions}
|
||||
{pov_instruction}
|
||||
|
||||
EVENTS: {json.dumps(events)}
|
||||
Return JSON: [{{'chapter_number':1, 'title':'...', 'pov_character': 'Name', 'pacing': 'Standard', 'estimated_words': 2000, 'beats':[...]}}]
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
plan = json.loads(utils.clean_json(response.text))
|
||||
|
||||
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
|
||||
target_val = 0
|
||||
if '-' in target_str:
|
||||
try:
|
||||
parts = target_str.split('-')
|
||||
target_val = int((int(parts[0]) + int(parts[1])) / 2)
|
||||
except: pass
|
||||
else:
|
||||
try: target_val = int(target_str)
|
||||
except: pass
|
||||
|
||||
if target_val > 0:
|
||||
variance = random.uniform(0.90, 1.10)
|
||||
target_val = int(target_val * variance)
|
||||
utils.log("ARCHITECT", f"Target adjusted with variance ({variance:.2f}x): {target_val} words.")
|
||||
|
||||
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
|
||||
if current_sum > 0:
|
||||
factor = target_val / current_sum
|
||||
utils.log("ARCHITECT", f"Adjusting chapter lengths by {factor:.2f}x to match target.")
|
||||
for c in plan:
|
||||
c['estimated_words'] = int(c.get('estimated_words', 0) * factor)
|
||||
|
||||
return plan
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Failed to create chapter plan: {e}")
|
||||
return []
|
||||
|
||||
def update_tracking(folder, chapter_num, chapter_text, current_tracking):
|
||||
utils.log("TRACKER", f"Updating world state & character visuals for Ch {chapter_num}...")
|
||||
|
||||
prompt = f"""
|
||||
Analyze this chapter text to update the Story Bible.
|
||||
|
||||
CURRENT TRACKING DATA:
|
||||
{json.dumps(current_tracking)}
|
||||
|
||||
NEW CHAPTER TEXT:
|
||||
{chapter_text[:500000]}
|
||||
|
||||
TASK:
|
||||
1. EVENTS: Append 1-3 concise bullet points summarizing key plot events in this chapter to the 'events' list.
|
||||
2. CHARACTERS: Update entries for any characters appearing in the scene.
|
||||
- "descriptors": List of strings. Add PERMANENT physical traits (height, hair, eyes), specific items (jewelry, weapons). Avoid duplicates.
|
||||
- "likes_dislikes": List of strings. Add specific preferences, likes, or dislikes mentioned (e.g., "Hates coffee", "Loves jazz").
|
||||
- "last_worn": String. Update if specific clothing is described. IMPORTANT: If a significant time jump occurred (e.g. next day) and no new clothing is described, reset this to "Unknown".
|
||||
- "major_events": List of strings. Log significant life-altering events occurring in THIS chapter (e.g. "Lost an arm", "Married", "Betrayed by X").
|
||||
3. CONTENT_WARNINGS: List of strings. Identify specific triggers present in this chapter (e.g. "Graphic Violence", "Sexual Assault", "Torture", "Self-Harm"). Append to existing list.
|
||||
|
||||
RETURN JSON with the SAME structure as CURRENT TRACKING DATA (events list, characters dict, content_warnings list).
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("TRACKER", f"Failed to update tracking: {e}")
|
||||
return current_tracking
|
||||
|
||||
def evaluate_chapter_quality(text, chapter_title, model, folder):
|
||||
prompt = f"""
|
||||
Analyze this book chapter text.
|
||||
CHAPTER TITLE: {chapter_title}
|
||||
|
||||
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?
|
||||
2. ENGAGEMENT: Is it interesting? Does it hook the reader?
|
||||
3. REPETITION: Is sentence structure varied? Are words repeated unnecessarily?
|
||||
4. PROGRESSION: Does the story move forward, or is it spinning its wheels?
|
||||
|
||||
Rate on a scale of 1-10.
|
||||
Provide a concise critique focusing on the biggest flaw.
|
||||
|
||||
Return JSON: {{'score': int, 'critique': 'string'}}
|
||||
"""
|
||||
try:
|
||||
response = model.generate_content([prompt, text[:30000]])
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
return data.get('score', 0), data.get('critique', 'No critique provided.')
|
||||
except Exception as e:
|
||||
return 0, f"Evaluation error: {str(e)}"
|
||||
|
||||
def create_initial_persona(bp, folder):
|
||||
utils.log("SYSTEM", "Generating initial Author Persona based on genre/tone...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
prompt = f"""
|
||||
Create a fictional 'Author Persona' best suited to write this book.
|
||||
|
||||
BOOK DETAILS:
|
||||
Title: {meta.get('title')}
|
||||
Genre: {meta.get('genre')}
|
||||
Tone: {style.get('tone')}
|
||||
Target Audience: {meta.get('target_audience')}
|
||||
|
||||
TASK:
|
||||
Create a profile for the ideal writer of this book.
|
||||
Return JSON: {{ "name": "Pen Name", "bio": "Description of writing style (voice, sentence structure, vocabulary)...", "age": "...", "gender": "..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Persona generation failed: {e}")
|
||||
return {"name": "AI Author", "bio": "Standard, balanced writing style."}
|
||||
|
||||
def refine_persona(bp, text, folder):
|
||||
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
|
||||
ad = bp.get('book_metadata', {}).get('author_details', {})
|
||||
current_bio = ad.get('bio', 'Standard style.')
|
||||
|
||||
prompt = f"""
|
||||
Analyze this text sample from the book.
|
||||
|
||||
TEXT:
|
||||
{text[:3000]}
|
||||
|
||||
CURRENT AUTHOR BIO:
|
||||
{current_bio}
|
||||
|
||||
TASK:
|
||||
Refine the Author Bio to better match the actual text produced.
|
||||
Highlight specific stylistic quirks, sentence patterns, or vocabulary choices found in the text.
|
||||
The goal is to ensure future chapters sound exactly like this one.
|
||||
|
||||
Return JSON: {{ "bio": "Updated bio..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
|
||||
if new_bio:
|
||||
ad['bio'] = new_bio
|
||||
utils.log("SYSTEM", " -> Persona bio updated.")
|
||||
return ad
|
||||
except: pass
|
||||
return ad
|
||||
|
||||
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
|
||||
pacing = chap.get('pacing', 'Standard')
|
||||
est_words = chap.get('estimated_words', 'Flexible')
|
||||
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
|
||||
ls = bp['length_settings']
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
pov_char = chap.get('pov_character', '')
|
||||
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
persona_info = meta['author_bio']
|
||||
else:
|
||||
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('age'): persona_info += f"Age: {ad['age']}\n"
|
||||
if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n"
|
||||
if ad.get('race'): persona_info += f"Race: {ad['race']}\n"
|
||||
if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n"
|
||||
if ad.get('language'): persona_info += f"Language: {ad['language']}\n"
|
||||
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
samples = []
|
||||
if ad.get('sample_text'):
|
||||
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
|
||||
|
||||
if ad.get('sample_files'):
|
||||
for fname in ad['sample_files']:
|
||||
fpath = os.path.join(config.PERSONAS_DIR, fname)
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read(3000)
|
||||
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
|
||||
except: pass
|
||||
|
||||
if samples:
|
||||
persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
|
||||
|
||||
char_visuals = ""
|
||||
if tracking and 'characters' in tracking:
|
||||
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
|
||||
for name, data in tracking['characters'].items():
|
||||
desc = ", ".join(data.get('descriptors', []))
|
||||
likes = ", ".join(data.get('likes_dislikes', []))
|
||||
worn = data.get('last_worn', 'Unknown')
|
||||
char_visuals += f"- {name}: {desc}\n * Likes/Dislikes: {likes}\n"
|
||||
|
||||
major = data.get('major_events', [])
|
||||
if major: char_visuals += f" * Major Events: {'; '.join(major)}\n"
|
||||
|
||||
if worn and worn != 'Unknown':
|
||||
char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n"
|
||||
|
||||
style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))])
|
||||
if 'tropes' in style and isinstance(style['tropes'], list):
|
||||
style_block += f"\n- Tropes: {', '.join(style['tropes'])}"
|
||||
|
||||
if 'formatting_rules' in style and isinstance(style['formatting_rules'], list):
|
||||
style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules'])
|
||||
|
||||
prev_context_block = ""
|
||||
if prev_content:
|
||||
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{prev_content}\n"
|
||||
|
||||
prompt = f"""
|
||||
Write Chapter {chap['chapter_number']}: {chap['title']}
|
||||
|
||||
PACING GUIDE:
|
||||
- Format: {ls.get('label', 'Story')}
|
||||
- Chapter Pacing: {pacing}
|
||||
- Target Word Count: ~{est_words} (Use this as a guide, but prioritize story flow. Allow flexibility.)
|
||||
- POV Character: {pov_char if pov_char else 'Protagonist'}
|
||||
|
||||
STYLE & FORMATTING:
|
||||
{style_block}
|
||||
|
||||
AUTHOR VOICE (CRITICAL):
|
||||
{persona_info}
|
||||
|
||||
INSTRUCTION:
|
||||
Write the scene.
|
||||
- Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style.
|
||||
|
||||
- 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.
|
||||
- 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').
|
||||
- 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.
|
||||
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
|
||||
- 'Very Fast': Rapid fire, pure action/dialogue, minimal description.
|
||||
- 'Fast': Punchy, keep it moving.
|
||||
- 'Standard': Balanced dialogue and description.
|
||||
- 'Slow': Detailed, atmospheric, immersive.
|
||||
- 'Very Slow': Deep introspection, heavy sensory detail, slow burn.
|
||||
|
||||
PREVIOUS CONTEXT (Story So Far): {prev_sum}
|
||||
{prev_context_block}
|
||||
CHARACTERS: {json.dumps(bp['characters'])}
|
||||
{char_visuals}
|
||||
SCENE BEATS: {json.dumps(chap['beats'])}
|
||||
|
||||
Output Markdown.
|
||||
"""
|
||||
current_text = ""
|
||||
try:
|
||||
resp_draft = ai.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, "writer-flash", resp_draft.usage_metadata)
|
||||
current_text = resp_draft.text
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||
|
||||
# Refinement Loop
|
||||
max_attempts = 3
|
||||
best_score = 0
|
||||
best_text = current_text
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
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)
|
||||
|
||||
if "Evaluation error" in critique:
|
||||
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
|
||||
if best_score == 0: best_text = current_text
|
||||
break
|
||||
|
||||
utils.log("WRITER", f" Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score >= 8:
|
||||
utils.log("WRITER", " Quality threshold met.")
|
||||
return current_text
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_text = current_text
|
||||
|
||||
if attempt == max_attempts:
|
||||
utils.log("WRITER", " Max attempts reached. Using best version.")
|
||||
return best_text
|
||||
|
||||
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
||||
refine_prompt = f"""
|
||||
Act as a Senior Editor. Rewrite this chapter to fix the issues identified below.
|
||||
|
||||
CRITIQUE TO ADDRESS:
|
||||
{critique}
|
||||
|
||||
ADDITIONAL OBJECTIVES:
|
||||
1. NATURAL FLOW: Fix stilted phrasing. Ensure the prose flows naturally for the genre ({meta.get('genre', 'Fiction')}) and tone ({style.get('tone', 'Standard')}).
|
||||
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.
|
||||
4. CONTINUITY: Ensure consistency with the Story So Far.
|
||||
|
||||
STORY SO FAR:
|
||||
{prev_sum}
|
||||
{prev_context_block}
|
||||
|
||||
CURRENT DRAFT:
|
||||
{current_text}
|
||||
|
||||
Return the polished, final version of the chapter in Markdown.
|
||||
"""
|
||||
try:
|
||||
resp_refine = ai.model_writer.generate_content(refine_prompt)
|
||||
utils.log_usage(folder, "writer-flash", resp_refine.usage_metadata)
|
||||
current_text = resp_refine.text
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Refinement failed: {e}")
|
||||
return best_text
|
||||
|
||||
return best_text
|
||||
|
||||
def harvest_metadata(bp, folder, full_manuscript):
|
||||
utils.log("HARVESTER", "Scanning for new characters...")
|
||||
full_text = "\n".join([c['content'] for c in full_manuscript])[:50000]
|
||||
prompt = f"Identify new significant characters NOT in:\n{json.dumps(bp['characters'])}\nTEXT:\n{full_text}\nReturn JSON: {{'new_characters': [{{'name':'...', 'role':'...', 'description':'...'}}]}}"
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
|
||||
if new_chars:
|
||||
utils.log("HARVESTER", f"Found {len(new_chars)} new chars.")
|
||||
bp['characters'].extend(new_chars)
|
||||
except: pass
|
||||
return bp
|
||||
|
||||
def update_persona_sample(bp, folder):
|
||||
utils.log("SYSTEM", "Extracting author persona from manuscript...")
|
||||
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
if not os.path.exists(ms_path): return
|
||||
ms = utils.load_json(ms_path)
|
||||
if not ms: return
|
||||
|
||||
# 1. Extract Text Sample
|
||||
full_text = "\n".join([c.get('content', '') for c in ms])
|
||||
if len(full_text) < 500: return
|
||||
|
||||
# 2. Save Sample File
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
safe_title = "".join([c for c in meta.get('title', 'book') if c.isalnum() or c=='_']).replace(" ", "_")[:20]
|
||||
timestamp = int(time.time())
|
||||
filename = f"sample_{safe_title}_{timestamp}.txt"
|
||||
filepath = os.path.join(config.PERSONAS_DIR, filename)
|
||||
|
||||
sample_text = full_text[:3000]
|
||||
with open(filepath, 'w', encoding='utf-8') as f: f.write(sample_text)
|
||||
|
||||
# 3. Update or Create Persona
|
||||
author_name = meta.get('author', 'Unknown Author')
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if author_name not in personas:
|
||||
utils.log("SYSTEM", f"Generating new persona profile for '{author_name}'...")
|
||||
prompt = f"Analyze this writing style (Tone, Voice, Vocabulary). Write a 1-sentence author bio describing it.\nTEXT: {sample_text[:1000]}"
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
bio = response.text.strip()
|
||||
except: bio = "Style analysis unavailable."
|
||||
|
||||
personas[author_name] = {
|
||||
"name": author_name,
|
||||
"bio": bio,
|
||||
"sample_files": [filename],
|
||||
"sample_text": sample_text[:500]
|
||||
}
|
||||
else:
|
||||
utils.log("SYSTEM", f"Updating persona '{author_name}' with new sample.")
|
||||
if 'sample_files' not in personas[author_name]: personas[author_name]['sample_files'] = []
|
||||
if filename not in personas[author_name]['sample_files']:
|
||||
personas[author_name]['sample_files'].append(filename)
|
||||
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
|
||||
def refine_bible(bible, instruction, folder):
|
||||
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
|
||||
prompt = f"""
|
||||
Act as a Book Editor.
|
||||
CURRENT JSON: {json.dumps(bible)}
|
||||
USER INSTRUCTION: {instruction}
|
||||
|
||||
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
||||
RETURN ONLY THE JSON.
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Refinement failed: {e}")
|
||||
return None
|
||||
202
modules/utils.py
202
modules/utils.py
@@ -1,202 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import time
|
||||
import config
|
||||
import threading
|
||||
|
||||
SAFETY_SETTINGS = [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||||
]
|
||||
|
||||
# Thread-local storage for logging context
|
||||
_log_context = threading.local()
|
||||
|
||||
def set_log_file(filepath):
|
||||
_log_context.log_file = filepath
|
||||
|
||||
def set_log_callback(callback):
|
||||
_log_context.callback = callback
|
||||
|
||||
def clean_json(text):
|
||||
text = text.replace("```json", "").replace("```", "").strip()
|
||||
# Robust extraction: find first { or [ and last } or ]
|
||||
start_obj = text.find('{')
|
||||
start_arr = text.find('[')
|
||||
if start_obj == -1 and start_arr == -1: return text
|
||||
if start_obj != -1 and (start_arr == -1 or start_obj < start_arr):
|
||||
return text[start_obj:text.rfind('}')+1]
|
||||
else:
|
||||
return text[start_arr:text.rfind(']')+1]
|
||||
|
||||
# --- SHARED UTILS ---
|
||||
def log(phase, msg):
|
||||
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
line = f"[{timestamp}] {phase:<15} | {msg}"
|
||||
print(line)
|
||||
|
||||
# Write to thread-specific log file if set
|
||||
if getattr(_log_context, 'log_file', None):
|
||||
with open(_log_context.log_file, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
# Trigger callback if set (e.g. for Database logging)
|
||||
if getattr(_log_context, 'callback', None):
|
||||
try: _log_context.callback(phase, msg)
|
||||
except: pass
|
||||
|
||||
def load_json(path):
|
||||
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
||||
|
||||
def create_default_personas():
|
||||
# Initialize empty personas file if it doesn't exist
|
||||
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():
|
||||
"""Returns a dict mapping Label -> Settings for use in main.py"""
|
||||
presets = {}
|
||||
for k, v in config.LENGTH_DEFINITIONS.items():
|
||||
presets[v['label']] = v
|
||||
return presets
|
||||
|
||||
def log_image_attempt(folder, img_type, prompt, filename, status, error=None, score=None, critique=None):
|
||||
log_path = os.path.join(folder, "image_log.json")
|
||||
entry = {
|
||||
"timestamp": int(time.time()),
|
||||
"type": img_type,
|
||||
"prompt": prompt,
|
||||
"filename": filename,
|
||||
"status": status,
|
||||
"error": str(error) if error else None,
|
||||
"score": score,
|
||||
"critique": critique
|
||||
}
|
||||
data = []
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
with open(log_path, 'r') as f: data = json.load(f)
|
||||
except:
|
||||
pass
|
||||
data.append(entry)
|
||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
||||
|
||||
def get_run_folder(base_name):
|
||||
if not os.path.exists(base_name): os.makedirs(base_name)
|
||||
runs = [d for d in os.listdir(base_name) if d.startswith("run_")]
|
||||
next_num = max([int(r.split("_")[1]) for r in runs if r.split("_")[1].isdigit()] + [0]) + 1
|
||||
folder = os.path.join(base_name, f"run_{next_num}")
|
||||
os.makedirs(folder)
|
||||
return folder
|
||||
|
||||
def get_latest_run_folder(base_name):
|
||||
if not os.path.exists(base_name): return None
|
||||
runs = [d for d in os.listdir(base_name) if d.startswith("run_")]
|
||||
if not runs: return None
|
||||
runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
return os.path.join(base_name, runs[-1])
|
||||
|
||||
def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
||||
if not folder or not os.path.exists(folder): return
|
||||
|
||||
log_path = os.path.join(folder, "usage_log.json")
|
||||
|
||||
entry = {
|
||||
"timestamp": int(time.time()),
|
||||
"model": model_label,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"images": image_count
|
||||
}
|
||||
|
||||
if usage_metadata:
|
||||
try:
|
||||
entry["input_tokens"] = usage_metadata.prompt_token_count
|
||||
entry["output_tokens"] = usage_metadata.candidates_token_count
|
||||
except: pass
|
||||
|
||||
data = {"log": [], "totals": {"input_tokens": 0, "output_tokens": 0, "images": 0, "est_cost_usd": 0.0}}
|
||||
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
loaded = json.load(open(log_path, 'r'))
|
||||
if isinstance(loaded, list): data["log"] = loaded
|
||||
else: data = loaded
|
||||
except: pass
|
||||
|
||||
data["log"].append(entry)
|
||||
|
||||
# Recalculate totals
|
||||
t_in = sum(x.get('input_tokens', 0) for x in data["log"])
|
||||
t_out = sum(x.get('output_tokens', 0) for x in data["log"])
|
||||
t_img = sum(x.get('images', 0) for x in data["log"])
|
||||
|
||||
cost = 0.0
|
||||
for x in data["log"]:
|
||||
m = x.get('model', '').lower()
|
||||
i = x.get('input_tokens', 0)
|
||||
o = x.get('output_tokens', 0)
|
||||
imgs = x.get('images', 0)
|
||||
|
||||
if 'flash' in m:
|
||||
cost += (i / 1_000_000 * 0.075) + (o / 1_000_000 * 0.30)
|
||||
elif 'pro' in m or 'logic' in m:
|
||||
cost += (i / 1_000_000 * 3.50) + (o / 1_000_000 * 10.50)
|
||||
elif 'imagen' in m or imgs > 0:
|
||||
cost += (imgs * 0.04)
|
||||
|
||||
data["totals"] = {
|
||||
"input_tokens": t_in,
|
||||
"output_tokens": t_out,
|
||||
"images": t_img,
|
||||
"est_cost_usd": round(cost, 4)
|
||||
}
|
||||
|
||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
||||
|
||||
def normalize_settings(bp):
|
||||
"""
|
||||
CRITICAL: Enforces defaults.
|
||||
1. If series_metadata is missing, force it to SINGLE mode.
|
||||
2. If length_settings is missing, force explicit numbers.
|
||||
"""
|
||||
# Force Series Default (1 Book)
|
||||
if 'series_metadata' not in bp:
|
||||
bp['series_metadata'] = {
|
||||
"is_series": False,
|
||||
"mode": "single",
|
||||
"series_title": "Standalone",
|
||||
"total_books_to_generate": 1
|
||||
}
|
||||
|
||||
# Check for empty series count just in case
|
||||
if bp['series_metadata'].get('total_books_to_generate') is None:
|
||||
bp['series_metadata']['total_books_to_generate'] = 1
|
||||
|
||||
# Force Length Defaults
|
||||
settings = bp.get('length_settings', {})
|
||||
label = settings.get('label', 'Novella') # Default to Novella if nothing provided
|
||||
|
||||
# Get defaults based on label (or Novella if unknown)
|
||||
presets = get_length_presets()
|
||||
defaults = presets.get(label, presets['Novella'])
|
||||
|
||||
if 'chapters' not in settings: settings['chapters'] = defaults['chapters']
|
||||
if 'words' not in settings: settings['words'] = defaults['words']
|
||||
|
||||
# Smart Depth Calculation (if not manually set)
|
||||
if 'depth' not in settings:
|
||||
c = int(settings['chapters'])
|
||||
if c <= 5: settings['depth'] = 1
|
||||
elif c <= 20: settings['depth'] = 2
|
||||
elif c <= 40: settings['depth'] = 3
|
||||
else: settings['depth'] = 4
|
||||
|
||||
bp['length_settings'] = settings
|
||||
return bp
|
||||
1132
modules/web_app.py
1132
modules/web_app.py
File diff suppressed because it is too large
Load Diff
@@ -1,218 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from huey import SqliteHuey
|
||||
from .web_db import db, Run, User, Project
|
||||
from . import utils
|
||||
import main
|
||||
import config
|
||||
|
||||
# Configure Huey (Task Queue)
|
||||
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
|
||||
|
||||
def db_log_callback(db_path, run_id, phase, msg):
|
||||
"""Writes log entry directly to SQLite to avoid Flask Context issues in threads."""
|
||||
for _ in range(5):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=5) as conn:
|
||||
conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)",
|
||||
(run_id, datetime.utcnow(), phase, str(msg)))
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
time.sleep(0.1)
|
||||
except: break
|
||||
|
||||
@huey.task()
|
||||
def generate_book_task(run_id, project_path, bible_path, allow_copy=True):
|
||||
"""
|
||||
Background task to run the book generation.
|
||||
"""
|
||||
# 1. Setup Logging
|
||||
log_filename = f"system_log_{run_id}.txt"
|
||||
log_path = os.path.join(project_path, "runs", "bible", f"run_{run_id}", log_filename)
|
||||
|
||||
# Log to project root initially until run folder is created by main
|
||||
initial_log = os.path.join(project_path, log_filename)
|
||||
utils.set_log_file(initial_log)
|
||||
|
||||
# Hook up Database Logging
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
|
||||
# Set Status to Running
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10) as conn:
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", f"Starting Job #{run_id}")
|
||||
|
||||
try:
|
||||
# 1.5 Copy Forward Logic (Series Optimization)
|
||||
# Check for previous runs and copy completed books to skip re-generation
|
||||
runs_dir = os.path.join(project_path, "runs", "bible")
|
||||
if allow_copy and os.path.exists(runs_dir):
|
||||
# Get all run folders except current
|
||||
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
|
||||
# Sort by ID (ascending)
|
||||
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
|
||||
if all_runs:
|
||||
latest_run_dir = os.path.join(runs_dir, all_runs[-1])
|
||||
current_run_dir = os.path.join(runs_dir, f"run_{run_id}")
|
||||
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
||||
|
||||
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
|
||||
for item in os.listdir(latest_run_dir):
|
||||
# Copy only folders that look like books and have a manuscript
|
||||
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
|
||||
if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")):
|
||||
src = os.path.join(latest_run_dir, item)
|
||||
dst = os.path.join(current_run_dir, item)
|
||||
try:
|
||||
shutil.copytree(src, dst)
|
||||
utils.log("SYSTEM", f" -> Copied {item} (Skipping generation).")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
|
||||
|
||||
# 2. Run Generation
|
||||
# We call the existing entry point
|
||||
main.run_generation(bible_path, specific_run_id=run_id)
|
||||
|
||||
utils.log("SYSTEM", "Job Complete.")
|
||||
status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Job Failed: {e}")
|
||||
status = "failed"
|
||||
|
||||
# 3. Calculate Cost & Cleanup
|
||||
# Use the specific run folder we know main.py used
|
||||
run_dir = os.path.join(project_path, "runs", "bible", f"run_{run_id}")
|
||||
|
||||
total_cost = 0.0
|
||||
final_log_path = initial_log
|
||||
|
||||
if os.path.exists(run_dir):
|
||||
# Move our log file there
|
||||
final_log_path = os.path.join(run_dir, "web_console.log")
|
||||
if os.path.exists(initial_log):
|
||||
try:
|
||||
os.rename(initial_log, final_log_path)
|
||||
except OSError:
|
||||
# If rename fails (e.g. across filesystems), copy and delete
|
||||
shutil.copy2(initial_log, final_log_path)
|
||||
os.remove(initial_log)
|
||||
|
||||
# Calculate Total Cost from all Book subfolders
|
||||
# usage_log.json is inside each Book folder
|
||||
for item in os.listdir(run_dir):
|
||||
item_path = os.path.join(run_dir, item)
|
||||
if os.path.isdir(item_path) and item.startswith("Book_"):
|
||||
usage_path = os.path.join(item_path, "usage_log.json")
|
||||
if os.path.exists(usage_path):
|
||||
data = utils.load_json(usage_path)
|
||||
total_cost += data.get('totals', {}).get('est_cost_usd', 0.0)
|
||||
|
||||
# 4. Update Database with Final Status
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10) as conn:
|
||||
conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ? WHERE id = ?",
|
||||
(status, total_cost, datetime.utcnow(), final_log_path, run_id))
|
||||
except Exception as e:
|
||||
print(f"Failed to update run status in DB: {e}")
|
||||
|
||||
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
|
||||
|
||||
@huey.task()
|
||||
def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
# Hook up Database Logging & Status
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
|
||||
# Truncate log file to ensure clean slate
|
||||
log_filename = f"system_log_{run_id}.txt"
|
||||
initial_log = os.path.join(project_path, log_filename)
|
||||
with open(initial_log, 'w', encoding='utf-8') as f: f.write("")
|
||||
utils.set_log_file(initial_log)
|
||||
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", "Starting Artifact Regeneration...")
|
||||
|
||||
# 1. Setup Paths
|
||||
run_dir = os.path.join(project_path, "runs", "bible", f"run_{run_id}")
|
||||
|
||||
# Detect Book Subfolder
|
||||
book_dir = 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_")])
|
||||
if subdirs: book_dir = os.path.join(run_dir, subdirs[0])
|
||||
|
||||
bible_path = os.path.join(project_path, "bible.json")
|
||||
|
||||
if not os.path.exists(run_dir) or not os.path.exists(bible_path):
|
||||
utils.log("ERROR", "Run directory or Bible not found.")
|
||||
return
|
||||
|
||||
# 2. Load Data
|
||||
bible = utils.load_json(bible_path)
|
||||
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
|
||||
ms_path = os.path.join(book_dir, "manuscript.json")
|
||||
|
||||
if not os.path.exists(final_bp_path) or not os.path.exists(ms_path):
|
||||
utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}")
|
||||
return
|
||||
|
||||
bp = utils.load_json(final_bp_path)
|
||||
ms = utils.load_json(ms_path)
|
||||
|
||||
# 3. Update Blueprint with new Metadata from Bible
|
||||
meta = bible.get('project_metadata', {})
|
||||
if 'book_metadata' in bp:
|
||||
# Sync all core metadata
|
||||
for k in ['author', 'genre', 'target_audience', 'style']:
|
||||
if k in meta:
|
||||
bp['book_metadata'][k] = meta[k]
|
||||
|
||||
if bp.get('series_metadata', {}).get('is_series'):
|
||||
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
|
||||
# Find specific book title from Bible
|
||||
b_num = bp['series_metadata'].get('book_number')
|
||||
for b in bible.get('books', []):
|
||||
if b.get('book_number') == b_num:
|
||||
bp['book_metadata']['title'] = b.get('title', bp['book_metadata'].get('title'))
|
||||
break
|
||||
else:
|
||||
bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title'))
|
||||
|
||||
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# 4. Regenerate
|
||||
try:
|
||||
main.ai.init_models()
|
||||
|
||||
tracking = None
|
||||
events_path = os.path.join(book_dir, "tracking_events.json")
|
||||
if os.path.exists(events_path):
|
||||
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
|
||||
|
||||
main.marketing.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
||||
main.export.compile_files(bp, ms, book_dir)
|
||||
|
||||
utils.log("SYSTEM", "Regeneration Complete.")
|
||||
final_status = 'completed'
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Regeneration Failed: {e}")
|
||||
final_status = 'failed'
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
|
||||
except: pass
|
||||
0
story/__init__.py
Normal file
0
story/__init__.py
Normal file
235
story/bible_tracker.py
Normal file
235
story/bible_tracker.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import json
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
def merge_selected_changes(original, draft, selected_keys):
|
||||
def sort_key(k):
|
||||
return [int(p) if p.isdigit() else p for p in k.split('.')]
|
||||
selected_keys.sort(key=sort_key)
|
||||
|
||||
for key in selected_keys:
|
||||
parts = key.split('.')
|
||||
|
||||
if parts[0] == 'meta' and len(parts) == 2:
|
||||
field = parts[1]
|
||||
if field == 'tone':
|
||||
original['project_metadata']['style']['tone'] = draft['project_metadata']['style']['tone']
|
||||
elif field in original['project_metadata']:
|
||||
original['project_metadata'][field] = draft['project_metadata'][field]
|
||||
|
||||
elif parts[0] == 'char' and len(parts) >= 2:
|
||||
try:
|
||||
idx = int(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
utils.log("SYSTEM", f"⚠️ Skipping malformed bible merge key: '{key}'")
|
||||
continue
|
||||
if idx < len(draft['characters']):
|
||||
if idx < len(original['characters']):
|
||||
original['characters'][idx] = draft['characters'][idx]
|
||||
else:
|
||||
original['characters'].append(draft['characters'][idx])
|
||||
|
||||
elif parts[0] == 'book' and len(parts) >= 2:
|
||||
try:
|
||||
book_num = int(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
utils.log("SYSTEM", f"⚠️ Skipping malformed bible merge key: '{key}'")
|
||||
continue
|
||||
orig_book = next((b for b in original['books'] if b['book_number'] == book_num), None)
|
||||
draft_book = next((b for b in draft['books'] if b['book_number'] == book_num), None)
|
||||
|
||||
if draft_book:
|
||||
if not orig_book:
|
||||
original['books'].append(draft_book)
|
||||
original['books'].sort(key=lambda x: x.get('book_number', 999))
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
orig_book['title'] = draft_book['title']
|
||||
orig_book['manual_instruction'] = draft_book['manual_instruction']
|
||||
|
||||
elif len(parts) == 4 and parts[2] == 'beat':
|
||||
try:
|
||||
beat_idx = int(parts[3])
|
||||
except (ValueError, IndexError):
|
||||
utils.log("SYSTEM", f"⚠️ Skipping malformed beat merge key: '{key}'")
|
||||
continue
|
||||
if beat_idx < len(draft_book['plot_beats']):
|
||||
while len(orig_book['plot_beats']) <= beat_idx:
|
||||
orig_book['plot_beats'].append("")
|
||||
orig_book['plot_beats'][beat_idx] = draft_book['plot_beats'][beat_idx]
|
||||
return original
|
||||
|
||||
|
||||
def filter_characters(chars):
|
||||
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 update_tracking(folder, chapter_num, chapter_text, current_tracking):
|
||||
utils.log("TRACKER", f"Updating world state & character visuals for Ch {chapter_num}...")
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Continuity Tracker
|
||||
TASK: Update the Story Bible based on the new chapter.
|
||||
|
||||
INPUT_TRACKING:
|
||||
{json.dumps(current_tracking)}
|
||||
|
||||
NEW_TEXT:
|
||||
{chapter_text[:20000]}
|
||||
|
||||
OPERATIONS:
|
||||
1. EVENTS: Append 1-3 key plot points to 'events'.
|
||||
2. CHARACTERS: Update 'descriptors', 'likes_dislikes', 'speech_style', 'last_worn', 'major_events', 'current_location', 'time_of_day', 'held_items'.
|
||||
- "descriptors": List of strings. Add PERMANENT physical traits (height, hair, eyes), specific items (jewelry, weapons). Avoid duplicates.
|
||||
- "likes_dislikes": List of strings. Add specific preferences, likes, or dislikes mentioned (e.g., "Hates coffee", "Loves jazz").
|
||||
- "speech_style": String. Describe how they speak (e.g. "Formal, no contractions", "Uses slang", "Stutters", "Short sentences").
|
||||
- "last_worn": String. Update if specific clothing is described. IMPORTANT: If a significant time jump occurred (e.g. next day) and no new clothing is described, reset this to "Unknown".
|
||||
- "major_events": List of strings. Log significant life-altering events occurring in THIS chapter (e.g. "Lost an arm", "Married", "Betrayed by X").
|
||||
- "current_location": String. The character's physical location at the END of this chapter (e.g., "The King's Throne Room", "Aboard the Nighthawk ship"). Update whenever the character moves.
|
||||
- "time_of_day": String. The approximate time of day at the END of this chapter (e.g., "Dawn", "Late afternoon", "Midnight"). Reset to "Unknown" if unclear.
|
||||
- "held_items": List of strings. Items the character is actively carrying or holding at chapter end (e.g., "Iron sword", "Stolen ledger"). Remove items they have dropped or given away.
|
||||
3. WARNINGS: Append new 'content_warnings'.
|
||||
|
||||
OUTPUT_FORMAT (JSON): Return the updated tracking object structure.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("TRACKER", f"Failed to update tracking: {e}")
|
||||
return current_tracking
|
||||
|
||||
|
||||
def update_lore_index(folder, chapter_text, current_lore):
|
||||
"""Extract canonical descriptions of locations and key items from a chapter
|
||||
and merge them into the lore index dict. Returns the updated lore dict."""
|
||||
utils.log("TRACKER", "Updating lore index from chapter...")
|
||||
prompt = f"""
|
||||
ROLE: Lore Keeper
|
||||
TASK: Extract canonical descriptions of locations and key items from this chapter.
|
||||
|
||||
EXISTING_LORE:
|
||||
{json.dumps(current_lore)}
|
||||
|
||||
CHAPTER_TEXT:
|
||||
{chapter_text[:15000]}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. For each LOCATION mentioned: provide a 1-2 sentence canonical description (appearance, atmosphere, notable features).
|
||||
2. For each KEY ITEM or ARTIFACT mentioned: provide a 1-2 sentence canonical description (appearance, properties, significance).
|
||||
3. Do NOT add characters — only physical places and objects.
|
||||
4. If an entry already exists in EXISTING_LORE, update or preserve it — do not duplicate.
|
||||
5. Use the exact name as the key (e.g., "The Thornwood Inn", "The Sunstone Amulet").
|
||||
6. Only include entries that have meaningful descriptive detail in the chapter text.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{"LocationOrItemName": "Description.", ...}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_entries = json.loads(utils.clean_json(response.text))
|
||||
if isinstance(new_entries, dict):
|
||||
current_lore.update(new_entries)
|
||||
return current_lore
|
||||
except Exception as e:
|
||||
utils.log("TRACKER", f"Lore index update failed: {e}")
|
||||
return current_lore
|
||||
|
||||
|
||||
def merge_tracking_to_bible(bible, tracking):
|
||||
"""Merge dynamic tracking state back into the bible dict.
|
||||
|
||||
Makes bible.json the single persistent source of truth by updating
|
||||
character data and lore from the in-memory tracking object.
|
||||
Returns the modified bible dict.
|
||||
"""
|
||||
for name, data in tracking.get('characters', {}).items():
|
||||
matched = False
|
||||
for char in bible.get('characters', []):
|
||||
if char.get('name') == name:
|
||||
char.update(data)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
utils.log("TRACKER", f" -> Character '{name}' in tracking not found in bible. Skipping.")
|
||||
|
||||
if 'lore' not in bible:
|
||||
bible['lore'] = {}
|
||||
bible['lore'].update(tracking.get('lore', {}))
|
||||
|
||||
return bible
|
||||
|
||||
|
||||
def harvest_metadata(bp, folder, full_manuscript):
|
||||
utils.log("HARVESTER", "Scanning for new characters...")
|
||||
full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000]
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Data Extractor
|
||||
TASK: Identify NEW significant characters.
|
||||
|
||||
INPUT_TEXT:
|
||||
{full_text}
|
||||
|
||||
KNOWN_CHARACTERS: {json.dumps(bp['characters'])}
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "new_characters": [{{ "name": "String", "role": "String", "description": "String" }}] }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
|
||||
if new_chars:
|
||||
valid_chars = filter_characters(new_chars)
|
||||
if valid_chars:
|
||||
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
|
||||
bp['characters'].extend(valid_chars)
|
||||
except Exception as e:
|
||||
utils.log("HARVESTER", f"⚠️ Metadata harvest failed: {e}")
|
||||
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):
|
||||
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
|
||||
prompt = f"""
|
||||
ROLE: Senior Developmental Editor
|
||||
TASK: Update the Bible JSON based on instruction.
|
||||
|
||||
INPUT_DATA:
|
||||
- CURRENT_JSON: {json.dumps(bible)}
|
||||
- INSTRUCTION: {instruction}
|
||||
|
||||
CONSTRAINTS:
|
||||
- Maintain valid JSON structure.
|
||||
- Ensure consistency.
|
||||
|
||||
OUTPUT_FORMAT (JSON): The full updated Bible JSON object.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Refinement failed: {e}")
|
||||
return None
|
||||
413
story/editor.py
Normal file
413
story/editor.py
Normal file
@@ -0,0 +1,413 @@
|
||||
import json
|
||||
import os
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from story.style_persona import get_style_guidelines
|
||||
|
||||
|
||||
def evaluate_chapter_quality(text, chapter_title, genre, model, folder, series_context=""):
|
||||
guidelines = get_style_guidelines()
|
||||
ai_isms = "', '".join(guidelines['ai_isms'])
|
||||
fw_examples = ", ".join([f"'He {w}'" for w in guidelines['filter_words'][:5]])
|
||||
|
||||
word_count = len(text.split()) if text else 0
|
||||
min_sugg = max(3, int(word_count / 500))
|
||||
max_sugg = min_sugg + 2
|
||||
suggestion_range = f"{min_sugg}-{max_sugg}"
|
||||
|
||||
series_line = f"\n - {series_context}" if series_context else ""
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Senior Literary Editor
|
||||
TASK: Critique chapter draft. Apply STRICT scoring — do not inflate scores.
|
||||
|
||||
METADATA:
|
||||
- TITLE: {chapter_title}
|
||||
- GENRE: {genre}{series_line}
|
||||
|
||||
PROHIBITED_PATTERNS:
|
||||
- AI_ISMS: {ai_isms}
|
||||
- FILTER_WORDS: {fw_examples} — these are telling words that distance the reader from the scene.
|
||||
- CLICHES: White Room, As You Know Bob, Summary Mode, Anachronisms.
|
||||
- SYNTAX: Repetitive structure, Passive Voice, Adverb Reliance.
|
||||
|
||||
DEEP_POV_ENFORCEMENT (AUTOMATIC FAIL CONDITIONS):
|
||||
- FILTER_WORD_DENSITY: Scan the entire text for filter words (felt, saw, heard, realized, decided, noticed, knew, thought, wondered, seemed, appeared, watched, observed, sensed). If these words appear more than once per 120 words on average, criterion 5 MUST score 1-4 and the overall score CANNOT exceed 5.
|
||||
- SUMMARY_MODE: If any passage narrates events in summary rather than dramatizing them in real-time scene (e.g., "Over the next hour, they discussed...", "He had spent years..."), flag it. Summary mode in a scene that should be dramatized drops criterion 2 to 1-3 and the overall score CANNOT exceed 6.
|
||||
- TELLING_EMOTIONS: Phrases like "She felt sad," "He was angry," "She was nervous" — labeling emotions instead of showing them through physical action — are automatic criterion 5 failures. Each instance must be called out.
|
||||
|
||||
QUALITY_RUBRIC (1-10):
|
||||
1. ENGAGEMENT & TENSION: Does the story grip the reader from the first line? Is there conflict or tension in every scene?
|
||||
2. SCENE EXECUTION: Is the middle of the chapter fully fleshed out? Does it avoid "sagging" or summarizing key moments? (Automatic 1-3 if summary mode detected.)
|
||||
3. VOICE & TONE: Is the narrative voice distinct? Does it match the genre?
|
||||
4. SENSORY IMMERSION: Does the text use sensory details effectively without being overwhelming?
|
||||
5. SHOW, DON'T TELL / DEEP POV: STRICT ENFORCEMENT. Emotions must be rendered through physical reactions, micro-behaviours, and subtext — NOT named or labelled. Score 1-4 if filter word density is high. Score 1-2 if the chapter names emotions directly ("she felt," "he was angry") more than 3 times. Score 7-10 ONLY if the reader experiences the POV character's state without being told what it is.
|
||||
6. CHARACTER AGENCY: Do characters drive the plot through active choices?
|
||||
7. PACING: Does the chapter feel rushed? Does the ending land with impact, or does it cut off too abruptly?
|
||||
8. GENRE APPROPRIATENESS: Are introductions of characters, places, items, or actions consistent with the {genre} conventions?
|
||||
9. DIALOGUE AUTHENTICITY: Do characters sound distinct? Is there subtext? Avoids "on-the-nose" dialogue.
|
||||
10. PLOT RELEVANCE: Does the chapter advance the plot or character arcs significantly? Avoids filler.
|
||||
11. STAGING & FLOW: Do characters enter/exit physically? Do paragraphs transition logically (Action -> Reaction)?
|
||||
12. PROSE DYNAMICS: Is there sentence variety? Avoids purple prose, adjective stacking, and excessive modification.
|
||||
13. CLARITY & READABILITY: Is the text easy to follow? Are sentences clear and concise?
|
||||
|
||||
SCORING_SCALE:
|
||||
- 10 (Masterpiece): Flawless, impactful, ready for print.
|
||||
- 9 (Bestseller): Exceptional quality, minor style tweaks only.
|
||||
- 7-8 (Professional): Good draft, solid structure, needs editing.
|
||||
- 6 (Passable): Average, has issues with pacing or voice. Needs heavy refinement.
|
||||
- 1-5 (Fail): Structural flaws, summary mode detected, heavy filter word reliance, or incoherent. Needs full rewrite.
|
||||
- IMPORTANT: A score of 7+ CANNOT be awarded if filter word density is high or if any emotion is directly named/labelled.
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"score": int,
|
||||
"critique": "Detailed analysis of flaws, citing specific examples from the text.",
|
||||
"actionable_feedback": "List of {suggestion_range} specific, ruthless instructions for the rewrite (e.g. 'Expand the middle dialogue', 'Add sensory details about the rain', 'Dramatize the argument instead of summarizing it')."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = model.generate_content([prompt, utils.truncate_to_tokens(text, 7500, keep_head=True)])
|
||||
model_name = getattr(model, 'name', ai_models.logic_model_name)
|
||||
utils.log_usage(folder, model_name, response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
critique_text = data.get('critique', 'No critique provided.')
|
||||
if data.get('actionable_feedback'):
|
||||
critique_text += "\n\nREQUIRED FIXES:\n" + str(data.get('actionable_feedback'))
|
||||
|
||||
return data.get('score', 0), critique_text
|
||||
except Exception as e:
|
||||
return 0, f"Evaluation error: {str(e)}"
|
||||
|
||||
|
||||
def check_pacing(bp, summary, last_chapter_text, last_chapter_data, remaining_chapters, folder):
|
||||
utils.log("ARCHITECT", "Checking pacing and structure health...")
|
||||
|
||||
if not remaining_chapters:
|
||||
return None
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Structural Editor
|
||||
TASK: Analyze pacing.
|
||||
|
||||
CONTEXT:
|
||||
- PREVIOUS_SUMMARY: {utils.truncate_to_tokens(summary, 1000)}
|
||||
- CURRENT_CHAPTER: {utils.truncate_to_tokens(last_chapter_text, 800)}
|
||||
- UPCOMING: {json.dumps([c['title'] for c in remaining_chapters[:3]])}
|
||||
- REMAINING_COUNT: {len(remaining_chapters)}
|
||||
|
||||
LOGIC:
|
||||
- IF skipped major beats -> ADD_BRIDGE
|
||||
- IF covered next chapter's beats -> CUT_NEXT
|
||||
- ELSE -> OK
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"status": "ok" or "add_bridge" or "cut_next",
|
||||
"reason": "Explanation...",
|
||||
"new_chapter": {{ "title": "...", "beats": ["..."], "pov_character": "..." }} (Required if add_bridge)
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Pacing check failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def analyze_consistency(bp, manuscript, folder):
|
||||
utils.log("EDITOR", "Analyzing manuscript for continuity errors...")
|
||||
|
||||
if not manuscript: return {"issues": ["No manuscript found."], "score": 0}
|
||||
if not bp: return {"issues": ["No blueprint found."], "score": 0}
|
||||
|
||||
chapter_summaries = []
|
||||
for ch in manuscript:
|
||||
text = ch.get('content', '')
|
||||
if len(text) > 3000:
|
||||
mid = len(text) // 2
|
||||
excerpt = text[:800] + "\n...\n" + text[mid - 200:mid + 200] + "\n...\n" + text[-800:]
|
||||
elif len(text) > 1600:
|
||||
excerpt = text[:800] + "\n...\n" + text[-800:]
|
||||
else:
|
||||
excerpt = text
|
||||
chapter_summaries.append(f"Ch {ch.get('num')}: {excerpt}")
|
||||
|
||||
context = "\n".join(chapter_summaries)
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Continuity Editor
|
||||
TASK: Analyze book summary for plot holes.
|
||||
|
||||
INPUT_DATA:
|
||||
- CHARACTERS: {json.dumps(bp.get('characters', []))}
|
||||
- SUMMARIES:
|
||||
{context}
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "issues": ["Issue 1", "Issue 2"], "score": 8, "summary": "Brief overall assessment." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
return {"issues": [f"Analysis failed: {e}"], "score": 0, "summary": "Error during analysis."}
|
||||
|
||||
|
||||
def rewrite_chapter_content(bp, manuscript, chapter_num, instruction, folder):
|
||||
utils.log("WRITER", f"Rewriting Ch {chapter_num} with instruction: {instruction}")
|
||||
|
||||
target_chap = next((c for c in manuscript if str(c.get('num')) == str(chapter_num)), None)
|
||||
if not target_chap: return None
|
||||
|
||||
prev_text = ""
|
||||
prev_chap = None
|
||||
if isinstance(chapter_num, int):
|
||||
prev_chap = next((c for c in manuscript if c['num'] == chapter_num - 1), None)
|
||||
elif str(chapter_num).lower() == "epilogue":
|
||||
numbered_chaps = [c for c in manuscript if isinstance(c['num'], int)]
|
||||
if numbered_chaps:
|
||||
prev_chap = max(numbered_chaps, key=lambda x: x['num'])
|
||||
|
||||
if prev_chap:
|
||||
prev_text = prev_chap.get('content', '')[-3000:]
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
persona_info = meta['author_bio']
|
||||
else:
|
||||
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
char_visuals = ""
|
||||
from core import config
|
||||
tracking_path = os.path.join(folder, "tracking_characters.json")
|
||||
if os.path.exists(tracking_path):
|
||||
try:
|
||||
tracking_chars = utils.load_json(tracking_path)
|
||||
if tracking_chars:
|
||||
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
|
||||
for name, data in tracking_chars.items():
|
||||
desc = ", ".join(data.get('descriptors', []))
|
||||
speech = data.get('speech_style', 'Unknown')
|
||||
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n"
|
||||
except: pass
|
||||
|
||||
guidelines = get_style_guidelines()
|
||||
fw_list = '", "'.join(guidelines['filter_words'])
|
||||
|
||||
prompt = f"""
|
||||
You are an expert fiction writing AI. Your task is to rewrite a specific chapter based on a user directive.
|
||||
|
||||
INPUT DATA:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {meta.get('genre')}
|
||||
- TONE: {meta.get('style', {}).get('tone')}
|
||||
- AUTHOR_VOICE: {persona_info}
|
||||
- PREVIOUS_CONTEXT: {prev_text}
|
||||
- CURRENT_DRAFT: {target_chap.get('content', '')[:5000]}
|
||||
- CHARACTERS: {json.dumps(bp.get('characters', []))}
|
||||
{char_visuals}
|
||||
|
||||
PRIMARY DIRECTIVE (USER INSTRUCTION):
|
||||
{instruction}
|
||||
|
||||
EXECUTION RULES:
|
||||
1. CONTINUITY: The new text must flow logically from PREVIOUS_CONTEXT.
|
||||
2. ADHERENCE: The PRIMARY DIRECTIVE overrides any conflicting details in CURRENT_DRAFT.
|
||||
3. VOICE: Strictly emulate the AUTHOR_VOICE.
|
||||
4. GENRE: Enforce {meta.get('genre')} conventions. No anachronisms.
|
||||
5. LOGIC: Enforce strict causality (Action -> Reaction). No teleporting characters.
|
||||
|
||||
PROSE OPTIMIZATION RULES (STRICT ENFORCEMENT):
|
||||
- FILTER_REMOVAL: Scan for words [{fw_list}]. If found, rewrite the sentence to remove the filter and describe the sensation directly.
|
||||
- SENTENCE_VARIETY: Penalize consecutive sentences starting with the same pronoun or article. Vary structure.
|
||||
- SHOW_DONT_TELL: Convert internal summaries of emotion into physical actions or subtextual dialogue.
|
||||
- ACTIVE_VOICE: Convert passive voice ("was [verb]ed") to active voice.
|
||||
- SENSORY_ANCHORING: The first paragraph must establish the setting using at least one non-visual sense (smell, sound, touch).
|
||||
- SUBTEXT: Dialogue must imply meaning rather than stating it outright.
|
||||
|
||||
RETURN JSON:
|
||||
{{
|
||||
"content": "The full chapter text in Markdown...",
|
||||
"summary": "A concise summary of the chapter's events and ending state (for continuity checks)."
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||
try:
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
return data.get('content'), data.get('summary')
|
||||
except:
|
||||
return response.text, None
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Rewrite failed: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def check_and_propagate(bp, manuscript, changed_chap_num, folder, change_summary=None):
|
||||
utils.log("WRITER", f"Checking ripple effects from Ch {changed_chap_num}...")
|
||||
|
||||
changed_chap = next((c for c in manuscript if c['num'] == changed_chap_num), None)
|
||||
if not changed_chap: return None
|
||||
|
||||
if change_summary:
|
||||
current_context = change_summary
|
||||
else:
|
||||
change_summary_prompt = f"""
|
||||
ROLE: Summarizer
|
||||
TASK: Summarize the key events and ending state of this chapter for continuity tracking.
|
||||
|
||||
TEXT:
|
||||
{utils.truncate_to_tokens(changed_chap.get('content', ''), 2500)}
|
||||
|
||||
FOCUS:
|
||||
- Major plot points.
|
||||
- Character status changes (injuries, items acquired, location changes).
|
||||
- New information revealed.
|
||||
|
||||
OUTPUT: Concise text summary.
|
||||
"""
|
||||
try:
|
||||
resp = ai_models.model_writer.generate_content(change_summary_prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata)
|
||||
current_context = resp.text
|
||||
except:
|
||||
current_context = changed_chap.get('content', '')[-2000:]
|
||||
|
||||
original_change_context = current_context
|
||||
sorted_ms = sorted(manuscript, key=utils.chapter_sort_key)
|
||||
start_index = -1
|
||||
for i, c in enumerate(sorted_ms):
|
||||
if str(c['num']) == str(changed_chap_num):
|
||||
start_index = i
|
||||
break
|
||||
|
||||
if start_index == -1 or start_index == len(sorted_ms) - 1:
|
||||
return None
|
||||
|
||||
changes_made = False
|
||||
consecutive_no_changes = 0
|
||||
potential_impact_chapters = []
|
||||
|
||||
for i in range(start_index + 1, len(sorted_ms)):
|
||||
target_chap = sorted_ms[i]
|
||||
|
||||
if consecutive_no_changes >= 2:
|
||||
if target_chap['num'] not in potential_impact_chapters:
|
||||
future_flags = [n for n in potential_impact_chapters if isinstance(n, int) and isinstance(target_chap['num'], int) and n > target_chap['num']]
|
||||
|
||||
if not future_flags:
|
||||
remaining_chaps = sorted_ms[i:]
|
||||
if not remaining_chaps: break
|
||||
|
||||
utils.log("WRITER", " -> Short-term ripple dissipated. Scanning remaining chapters for long-range impacts...")
|
||||
|
||||
chapter_summaries = []
|
||||
for rc in remaining_chaps:
|
||||
text = rc.get('content', '')
|
||||
excerpt = text[:500] + "\n...\n" + text[-500:] if len(text) > 1000 else text
|
||||
chapter_summaries.append(f"Ch {rc['num']}: {excerpt}")
|
||||
|
||||
scan_prompt = f"""
|
||||
ROLE: Continuity Scanner
|
||||
TASK: Identify chapters impacted by a change.
|
||||
|
||||
CHANGE_CONTEXT:
|
||||
{original_change_context}
|
||||
|
||||
CHAPTER_SUMMARIES:
|
||||
{json.dumps(chapter_summaries)}
|
||||
|
||||
CRITERIA: Identify later chapters that mention items, characters, or locations involved in the Change Context.
|
||||
|
||||
OUTPUT_FORMAT (JSON): [Chapter_Number_Int, ...]
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = ai_models.model_logic.generate_content(scan_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
|
||||
potential_impact_chapters = json.loads(utils.clean_json(resp.text))
|
||||
if not isinstance(potential_impact_chapters, list): potential_impact_chapters = []
|
||||
potential_impact_chapters = [int(x) for x in potential_impact_chapters if str(x).isdigit()]
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f" -> Scan failed: {e}. Stopping.")
|
||||
break
|
||||
|
||||
if not potential_impact_chapters:
|
||||
utils.log("WRITER", " -> No long-range impacts detected. Stopping.")
|
||||
break
|
||||
else:
|
||||
utils.log("WRITER", f" -> Detected potential impact in chapters: {potential_impact_chapters}")
|
||||
|
||||
if isinstance(target_chap['num'], int) and target_chap['num'] not in potential_impact_chapters:
|
||||
utils.log("WRITER", f" -> Skipping Ch {target_chap['num']} (Not flagged).")
|
||||
continue
|
||||
|
||||
utils.log("WRITER", f" -> Checking Ch {target_chap['num']} for continuity...")
|
||||
|
||||
chap_word_count = len(target_chap.get('content', '').split())
|
||||
prompt = f"""
|
||||
ROLE: Continuity Checker
|
||||
TASK: Determine if a chapter contradicts a story change. If it does, rewrite it to fix the contradiction.
|
||||
|
||||
CHANGED_CHAPTER: {changed_chap_num}
|
||||
CHANGE_SUMMARY: {current_context}
|
||||
|
||||
CHAPTER_TO_CHECK (Ch {target_chap['num']}):
|
||||
{utils.truncate_to_tokens(target_chap['content'], 3000)}
|
||||
|
||||
DECISION_LOGIC:
|
||||
- If the chapter directly contradicts the change (references dead characters, items that no longer exist, events that didn't happen), status = REWRITE.
|
||||
- If the chapter is consistent or only tangentially related, status = NO_CHANGE.
|
||||
- Be conservative — only rewrite if there is a genuine contradiction.
|
||||
|
||||
REWRITE_RULES (apply only if REWRITE):
|
||||
- Fix the specific contradiction. Preserve all other content.
|
||||
- The rewritten chapter MUST be approximately {chap_word_count} words (same length as original).
|
||||
- Include the chapter header formatted as Markdown H1.
|
||||
- Do not add new plot points not in the original.
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"status": "NO_CHANGE" or "REWRITE",
|
||||
"reason": "Brief explanation of the contradiction or why it's consistent",
|
||||
"content": "Full Markdown rewritten chapter (ONLY if status is REWRITE, otherwise null)"
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if data.get('status') == 'NO_CHANGE':
|
||||
utils.log("WRITER", f" -> Ch {target_chap['num']} is consistent.")
|
||||
current_context = f"Ch {target_chap['num']} Summary: " + target_chap.get('content', '')[-2000:]
|
||||
consecutive_no_changes += 1
|
||||
elif data.get('status') == 'REWRITE' and data.get('content'):
|
||||
new_text = data.get('content')
|
||||
if new_text:
|
||||
utils.log("WRITER", f" -> Rewriting Ch {target_chap['num']} to fix continuity.")
|
||||
target_chap['content'] = new_text
|
||||
changes_made = True
|
||||
current_context = f"Ch {target_chap['num']} Summary: " + new_text[-2000:]
|
||||
consecutive_no_changes = 0
|
||||
|
||||
try:
|
||||
with open(os.path.join(folder, "manuscript.json"), 'w') as f: json.dump(manuscript, f, indent=2)
|
||||
except: pass
|
||||
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f" -> Check failed: {e}")
|
||||
|
||||
return manuscript if changes_made else None
|
||||
473
story/eval_logger.py
Normal file
473
story/eval_logger.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""eval_logger.py — Per-chapter evaluation log and HTML report generator.
|
||||
|
||||
Writes a structured eval_log.json to the book folder during writing, then
|
||||
generates a self-contained HTML report that can be downloaded and shared with
|
||||
critics / prompt engineers to analyse quality patterns across a run.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from core import utils
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log writer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def append_eval_entry(folder, entry):
|
||||
"""Append one chapter's evaluation record to eval_log.json.
|
||||
|
||||
Called from story/writer.py at every return point in write_chapter().
|
||||
Each entry captures the chapter metadata, polish decision, per-attempt
|
||||
scores/critiques/decisions, and the final accepted score.
|
||||
"""
|
||||
log_path = os.path.join(folder, "eval_log.json")
|
||||
data = []
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
except Exception:
|
||||
data = []
|
||||
data.append(entry)
|
||||
try:
|
||||
with open(log_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
utils.log("EVAL", f"Failed to write eval log: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_html_report(folder, bp=None):
|
||||
"""Generate a self-contained HTML evaluation report from eval_log.json.
|
||||
|
||||
Returns the HTML string, or None if no log file exists / is empty.
|
||||
"""
|
||||
log_path = os.path.join(folder, "eval_log.json")
|
||||
if not os.path.exists(log_path):
|
||||
return None
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
chapters = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not isinstance(chapters, list) or not chapters:
|
||||
return None
|
||||
|
||||
title, genre = "Unknown Book", "Fiction"
|
||||
if bp:
|
||||
meta = bp.get('book_metadata', {})
|
||||
title = meta.get('title', title)
|
||||
genre = meta.get('genre', genre)
|
||||
|
||||
# --- Summary stats ---
|
||||
scores = [c.get('final_score', 0) for c in chapters if isinstance(c.get('final_score'), (int, float)) and c.get('final_score', 0) > 0]
|
||||
avg_score = round(sum(scores) / len(scores), 2) if scores else 0
|
||||
total = len(chapters)
|
||||
auto_accepted = sum(1 for c in chapters if c.get('final_decision') == 'auto_accepted')
|
||||
multi_attempt = sum(1 for c in chapters if len(c.get('attempts', [])) > 1)
|
||||
full_rewrites = sum(1 for c in chapters for a in c.get('attempts', []) if a.get('decision') == 'full_rewrite')
|
||||
below_threshold = sum(1 for c in chapters if c.get('final_decision') == 'below_threshold')
|
||||
polish_applied = sum(1 for c in chapters if c.get('polish_applied'))
|
||||
|
||||
score_dist = {i: 0 for i in range(1, 11)}
|
||||
for c in chapters:
|
||||
s = c.get('final_score', 0)
|
||||
if isinstance(s, int) and 1 <= s <= 10:
|
||||
score_dist[s] += 1
|
||||
|
||||
patterns = _mine_critique_patterns(chapters, total)
|
||||
report_date = time.strftime('%Y-%m-%d %H:%M')
|
||||
return _build_html(title, genre, report_date, chapters, avg_score, total,
|
||||
auto_accepted, multi_attempt, full_rewrites, below_threshold,
|
||||
polish_applied, score_dist, patterns)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern mining
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mine_critique_patterns(chapters, total):
|
||||
pattern_keywords = {
|
||||
"Filter words (felt/saw/noticed)": ["filter word", "filter", "felt ", "noticed ", "realized ", "saw the", "heard the"],
|
||||
"Summary mode / telling": ["summary mode", "summariz", "telling", "show don't tell", "show, don't tell", "instead of dramatiz"],
|
||||
"Emotion labeling": ["emotion label", "told the reader", "labeling", "labelling", "she felt", "he felt", "was nervous", "was angry", "was sad"],
|
||||
"Deep POV issues": ["deep pov", "deep point of view", "distant narration", "remove the reader", "external narration"],
|
||||
"Pacing problems": ["pacing", "rushing", "too fast", "too slow", "dragging", "sagging", "abrupt"],
|
||||
"Dialogue too on-the-nose": ["on-the-nose", "on the nose", "subtext", "exposition dump", "characters explain"],
|
||||
"Weak chapter hook / ending": ["hook", "cliffhanger", "cut off abruptly", "anticlimax", "ending falls flat", "no tension"],
|
||||
"Passive voice / weak verbs": ["passive voice", "was [v", "were [v", "weak verb", "adverb"],
|
||||
"AI-isms / clichés": ["ai-ism", "cliché", "tapestry", "palpable", "testament", "azure", "cerulean", "bustling"],
|
||||
"Voice / tone inconsistency": ["voice", "tone inconsist", "persona", "shift in tone", "register"],
|
||||
"Missing sensory / atmosphere": ["sensory", "grounding", "atmosphere", "immersiv", "white room"],
|
||||
}
|
||||
counts = {}
|
||||
for pattern, keywords in pattern_keywords.items():
|
||||
matching = []
|
||||
for c in chapters:
|
||||
critique_blob = " ".join(
|
||||
a.get('critique', '').lower()
|
||||
for a in c.get('attempts', [])
|
||||
)
|
||||
if any(kw.lower() in critique_blob for kw in keywords):
|
||||
matching.append(c.get('chapter_num', '?'))
|
||||
counts[pattern] = {'count': len(matching), 'chapters': matching}
|
||||
return dict(sorted(counts.items(), key=lambda x: x[1]['count'], reverse=True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _score_color(s):
|
||||
try:
|
||||
s = float(s)
|
||||
except (TypeError, ValueError):
|
||||
return '#6c757d'
|
||||
if s >= 8: return '#28a745'
|
||||
if s >= 7: return '#20c997'
|
||||
if s >= 6: return '#ffc107'
|
||||
return '#dc3545'
|
||||
|
||||
|
||||
def _decision_badge(d):
|
||||
MAP = {
|
||||
'auto_accepted': ('⚡ Auto-Accept', '#28a745'),
|
||||
'accepted': ('✓ Accepted', '#17a2b8'),
|
||||
'accepted_at_max': ('✓ Accepted', '#17a2b8'),
|
||||
'below_threshold': ('⚠ Below Threshold', '#dc3545'),
|
||||
'below_threshold_accepted': ('⚠ Below Threshold', '#dc3545'),
|
||||
'full_rewrite': ('🔄 Full Rewrite', '#6f42c1'),
|
||||
'full_rewrite_failed': ('🔄✗ Rewrite Failed','#6f42c1'),
|
||||
'refinement': ('✏ Refined', '#fd7e14'),
|
||||
'refinement_failed': ('✏✗ Refine Failed', '#fd7e14'),
|
||||
'eval_error': ('⚠ Eval Error', '#6c757d'),
|
||||
}
|
||||
label, color = MAP.get(d, (d or '?', '#6c757d'))
|
||||
return f'<span style="background:{color};color:white;padding:2px 8px;border-radius:4px;font-size:0.78em">{label}</span>'
|
||||
|
||||
|
||||
def _safe_int_fmt(v):
|
||||
try:
|
||||
return f"{int(v):,}"
|
||||
except (TypeError, ValueError):
|
||||
return str(v) if v else '?'
|
||||
|
||||
|
||||
def _build_html(title, genre, report_date, chapters, avg_score, total,
|
||||
auto_accepted, multi_attempt, full_rewrites, below_threshold,
|
||||
polish_applied, score_dist, patterns):
|
||||
|
||||
avg_color = _score_color(avg_score)
|
||||
|
||||
# --- Score timeline ---
|
||||
MAX_BAR = 260
|
||||
timeline_rows = ''
|
||||
for c in chapters:
|
||||
s = c.get('final_score', 0)
|
||||
color = _score_color(s)
|
||||
width = max(2, int((s / 10) * MAX_BAR)) if s else 2
|
||||
ch_num = c.get('chapter_num', '?')
|
||||
ch_title = str(c.get('title', ''))[:35]
|
||||
timeline_rows += (
|
||||
f'<div style="display:flex;align-items:center;margin-bottom:4px;font-size:0.8em">'
|
||||
f'<div style="width:45px;text-align:right;margin-right:8px;color:#888;flex-shrink:0">Ch {ch_num}</div>'
|
||||
f'<div style="background:{color};height:16px;width:{width}px;border-radius:2px;flex-shrink:0"></div>'
|
||||
f'<div style="margin-left:8px;color:#555">{s}/10 — {ch_title}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# --- Score distribution ---
|
||||
max_dist = max(score_dist.values()) if any(score_dist.values()) else 1
|
||||
dist_rows = ''
|
||||
for sv in range(10, 0, -1):
|
||||
count = score_dist.get(sv, 0)
|
||||
w = max(2, int((count / max_dist) * 200)) if count else 0
|
||||
color = _score_color(sv)
|
||||
dist_rows += (
|
||||
f'<div style="display:flex;align-items:center;margin-bottom:4px;font-size:0.85em">'
|
||||
f'<div style="width:28px;text-align:right;margin-right:8px;font-weight:bold;color:{color}">{sv}</div>'
|
||||
f'<div style="background:{color};height:15px;width:{w}px;border-radius:2px;opacity:0.85"></div>'
|
||||
f'<div style="margin-left:8px;color:#666">{count} ch{"apters" if count != 1 else "apter"}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# --- Chapter rows ---
|
||||
chapter_rows = ''
|
||||
for c in chapters:
|
||||
cid = c.get('chapter_num', 0)
|
||||
ch_title = str(c.get('title', '')).replace('<', '<').replace('>', '>')
|
||||
pov = str(c.get('pov_character') or '—')
|
||||
pace = str(c.get('pacing') or '—')
|
||||
target_w = _safe_int_fmt(c.get('target_words'))
|
||||
actual_w = _safe_int_fmt(c.get('actual_words'))
|
||||
pos = c.get('chapter_position')
|
||||
pos_pct = f"{int(pos * 100)}%" if pos is not None else '—'
|
||||
threshold = c.get('score_threshold', '?')
|
||||
fw_dens = c.get('filter_word_density', 0)
|
||||
polish = '✓' if c.get('polish_applied') else '✗'
|
||||
polish_c = '#28a745' if c.get('polish_applied') else '#aaa'
|
||||
fs = c.get('final_score', 0)
|
||||
fd = c.get('final_decision', '')
|
||||
attempts = c.get('attempts', [])
|
||||
n_att = len(attempts)
|
||||
fs_color = _score_color(fs)
|
||||
fd_badge = _decision_badge(fd)
|
||||
|
||||
# Attempt detail sub-rows
|
||||
att_rows = ''
|
||||
for att in attempts:
|
||||
an = att.get('n', '?')
|
||||
ascr = att.get('score', '?')
|
||||
adec = att.get('decision', '')
|
||||
acrit = str(att.get('critique', 'No critique.')).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
ac = _score_color(ascr)
|
||||
abadge = _decision_badge(adec)
|
||||
att_rows += (
|
||||
f'<tr style="background:#f6f8fa">'
|
||||
f'<td colspan="11" style="padding:12px 16px 12px 56px;border-bottom:1px solid #e8eaed">'
|
||||
f'<div style="margin-bottom:6px"><strong>Attempt {an}:</strong>'
|
||||
f'<span style="font-size:1.1em;font-weight:bold;color:{ac};margin:0 8px">{ascr}/10</span>'
|
||||
f'{abadge}</div>'
|
||||
f'<div style="font-size:0.83em;color:#444;line-height:1.55;white-space:pre-wrap;'
|
||||
f'background:#fff;padding:10px 12px;border-left:3px solid {ac};border-radius:2px;'
|
||||
f'max-height:300px;overflow-y:auto">{acrit}</div>'
|
||||
f'</td></tr>'
|
||||
)
|
||||
|
||||
chapter_rows += (
|
||||
f'<tr class="chrow" onclick="toggle({cid})" style="cursor:pointer">'
|
||||
f'<td style="font-weight:700;text-align:center">{cid}</td>'
|
||||
f'<td>{ch_title}</td>'
|
||||
f'<td style="color:#666;font-size:0.85em">{pov}</td>'
|
||||
f'<td style="color:#666;font-size:0.85em">{pace}</td>'
|
||||
f'<td style="text-align:right">{actual_w} <span style="color:#aaa">/{target_w}</span></td>'
|
||||
f'<td style="text-align:center;color:#888">{pos_pct}</td>'
|
||||
f'<td style="text-align:center">{threshold}</td>'
|
||||
f'<td style="text-align:center;color:{polish_c}">{polish} <span style="color:#aaa;font-size:0.8em">{fw_dens:.3f}</span></td>'
|
||||
f'<td style="text-align:center;font-weight:700;font-size:1.1em;color:{fs_color}">{fs}</td>'
|
||||
f'<td style="text-align:center;color:#888">{n_att}×</td>'
|
||||
f'<td>{fd_badge}</td>'
|
||||
f'</tr>'
|
||||
f'<tr id="d{cid}" class="detrow">{att_rows}</tr>'
|
||||
)
|
||||
|
||||
# --- Critique patterns ---
|
||||
pat_rows = ''
|
||||
for pattern, data in patterns.items():
|
||||
count = data['count']
|
||||
if count == 0:
|
||||
continue
|
||||
pct = int(count / total * 100) if total else 0
|
||||
sev_color = '#dc3545' if pct >= 50 else '#fd7e14' if pct >= 30 else '#17a2b8'
|
||||
chlist = ', '.join(f'Ch {x}' for x in data['chapters'][:10])
|
||||
if len(data['chapters']) > 10:
|
||||
chlist += f' (+{len(data["chapters"]) - 10} more)'
|
||||
pat_rows += (
|
||||
f'<tr>'
|
||||
f'<td><strong>{pattern}</strong></td>'
|
||||
f'<td style="text-align:center;color:{sev_color};font-weight:700">{count}/{total} ({pct}%)</td>'
|
||||
f'<td style="color:#666;font-size:0.83em">{chlist}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
if not pat_rows:
|
||||
pat_rows = '<tr><td colspan="3" style="color:#666;text-align:center;padding:12px">No significant patterns detected.</td></tr>'
|
||||
|
||||
# --- Prompt tuning notes ---
|
||||
notes = _generate_prompt_notes(chapters, avg_score, total, full_rewrites, below_threshold, patterns)
|
||||
notes_html = ''.join(f'<li style="margin-bottom:8px;line-height:1.55">{n}</li>' for n in notes)
|
||||
|
||||
return f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Eval Report — {title}</title>
|
||||
<style>
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;padding:20px}}
|
||||
.wrap{{max-width:1280px;margin:0 auto}}
|
||||
header{{background:#1a1d23;color:#fff;padding:22px 28px;border-radius:10px;margin-bottom:22px}}
|
||||
header h1{{font-size:0.9em;color:#8b92a1;margin-bottom:4px;font-weight:500}}
|
||||
header h2{{font-size:1.9em;font-weight:700;margin-bottom:6px}}
|
||||
header p{{color:#8b92a1;font-size:0.88em}}
|
||||
.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:20px}}
|
||||
.card{{background:#fff;border-radius:8px;padding:16px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.08)}}
|
||||
.card .val{{font-size:2em;font-weight:700}}
|
||||
.card .lbl{{font-size:0.75em;color:#888;margin-top:4px;line-height:1.3}}
|
||||
.two-col{{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}}
|
||||
section{{background:#fff;border-radius:8px;padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}}
|
||||
section h3{{font-size:1em;font-weight:700;border-bottom:2px solid #f0f0f0;padding-bottom:8px;margin-bottom:14px}}
|
||||
table{{width:100%;border-collapse:collapse;font-size:0.86em}}
|
||||
th{{background:#f7f8fa;padding:8px 10px;text-align:left;font-weight:600;color:#555;border-bottom:2px solid #e0e4ea;white-space:nowrap}}
|
||||
td{{padding:8px 10px;border-bottom:1px solid #f0f0f0;vertical-align:middle}}
|
||||
.chrow:hover{{background:#f7f8fa}}
|
||||
.detrow{{display:none}}
|
||||
.legend{{display:flex;gap:14px;flex-wrap:wrap;font-size:0.78em;color:#777;margin-bottom:10px}}
|
||||
.dot{{display:inline-block;width:11px;height:11px;border-radius:50%;vertical-align:middle;margin-right:3px}}
|
||||
ul.notes{{padding-left:20px}}
|
||||
@media(max-width:768px){{.two-col{{grid-template-columns:1fr}}}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>BookApp — Evaluation Report</h1>
|
||||
<h2>{title}</h2>
|
||||
<p>Genre: {genre} | Generated: {report_date} | {total} chapter{"s" if total != 1 else ""}</p>
|
||||
</header>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card"><div class="val" style="color:{avg_color}">{avg_score}</div><div class="lbl">Avg Score /10</div></div>
|
||||
<div class="card"><div class="val" style="color:#28a745">{auto_accepted}</div><div class="lbl">Auto-Accepted (8+)</div></div>
|
||||
<div class="card"><div class="val" style="color:#17a2b8">{multi_attempt}</div><div class="lbl">Multi-Attempt</div></div>
|
||||
<div class="card"><div class="val" style="color:#6f42c1">{full_rewrites}</div><div class="lbl">Full Rewrites</div></div>
|
||||
<div class="card"><div class="val" style="color:#dc3545">{below_threshold}</div><div class="lbl">Below Threshold</div></div>
|
||||
<div class="card"><div class="val" style="color:#fd7e14">{polish_applied}</div><div class="lbl">Polish Passes</div></div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<section>
|
||||
<h3>📊 Score Timeline</h3>
|
||||
<div class="legend">
|
||||
<span><span class="dot" style="background:#28a745"></span>8–10 Great</span>
|
||||
<span><span class="dot" style="background:#20c997"></span>7–7.9 Good</span>
|
||||
<span><span class="dot" style="background:#ffc107"></span>6–6.9 Passable</span>
|
||||
<span><span class="dot" style="background:#dc3545"></span><6 Fail</span>
|
||||
</div>
|
||||
<div style="overflow-y:auto;max-height:420px;padding-right:4px">{timeline_rows}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>📈 Score Distribution</h3>
|
||||
<div style="margin-top:8px">{dist_rows}</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3>📋 Chapter Breakdown <small style="font-weight:400;color:#888">(click any row to expand critiques)</small></h3>
|
||||
<div style="overflow-x:auto">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>#</th><th>Title</th><th>POV</th><th>Pacing</th>
|
||||
<th style="text-align:right">Words</th>
|
||||
<th style="text-align:center">Pos%</th>
|
||||
<th style="text-align:center">Threshold</th>
|
||||
<th style="text-align:center">Polish / FW</th>
|
||||
<th style="text-align:center">Score</th>
|
||||
<th style="text-align:center">Att.</th>
|
||||
<th>Decision</th>
|
||||
</tr></thead>
|
||||
<tbody>{chapter_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>🔍 Critique Patterns <small style="font-weight:400;color:#888">Keyword frequency across all evaluation critiques — high % = prompt gap</small></h3>
|
||||
<table>
|
||||
<thead><tr><th>Issue Pattern</th><th style="text-align:center">Frequency</th><th>Affected Chapters</th></tr></thead>
|
||||
<tbody>{pat_rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>💡 Prompt Tuning Observations</h3>
|
||||
<ul class="notes">{notes_html}</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function toggle(id){{
|
||||
var r=document.getElementById('d'+id);
|
||||
if(r) r.style.display=(r.style.display==='none'||r.style.display==='')?'table-row':'none';
|
||||
}}
|
||||
document.querySelectorAll('.detrow').forEach(function(r){{r.style.display='none';}});
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-observations for prompt tuning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _generate_prompt_notes(chapters, avg_score, total, full_rewrites, below_threshold, patterns):
|
||||
notes = []
|
||||
|
||||
# Overall score
|
||||
if avg_score >= 8:
|
||||
notes.append(f"✅ <strong>High average score ({avg_score}/10).</strong> The generation pipeline is performing well. Focus on the few outlier chapters below the threshold.")
|
||||
elif avg_score >= 7:
|
||||
notes.append(f"✓ <strong>Solid average score ({avg_score}/10).</strong> Minor prompt reinforcement should push this above 8. Focus on the most common critique pattern.")
|
||||
elif avg_score >= 6:
|
||||
notes.append(f"⚠ <strong>Average score of {avg_score}/10 is below target.</strong> Strengthen the draft prompt's Deep POV mandate and filter-word removal rules.")
|
||||
else:
|
||||
notes.append(f"🚨 <strong>Low average score ({avg_score}/10).</strong> The core writing prompt needs significant work — review the Deep POV mandate, genre mandates, and consider adding concrete negative examples.")
|
||||
|
||||
# Full rewrite rate
|
||||
if total > 0:
|
||||
rw_pct = int(full_rewrites / total * 100)
|
||||
if rw_pct > 30:
|
||||
notes.append(f"🔄 <strong>High full-rewrite rate ({rw_pct}%, {full_rewrites} triggers).</strong> The initial draft prompt produces too many sub-6 drafts. Add stronger examples or tighten the DEEP_POV_MANDATE and PROSE_RULES sections.")
|
||||
elif rw_pct > 15:
|
||||
notes.append(f"↩ <strong>Moderate full-rewrite rate ({rw_pct}%, {full_rewrites} triggers).</strong> The draft quality could be improved. Check the genre mandates for the types of chapters that rewrite most often.")
|
||||
|
||||
# Below threshold
|
||||
if below_threshold > 0:
|
||||
bt_pct = int(below_threshold / total * 100)
|
||||
notes.append(f"⚠ <strong>{below_threshold} chapter{'s' if below_threshold != 1 else ''} ({bt_pct}%) finished below the quality threshold.</strong> Inspect the individual critiques to see if these cluster by POV, pacing, or story position.")
|
||||
|
||||
# Top critique patterns
|
||||
for pattern, data in list(patterns.items())[:5]:
|
||||
pct = int(data['count'] / total * 100) if total else 0
|
||||
if pct >= 50:
|
||||
notes.append(f"🔴 <strong>'{pattern}' appears in {pct}% of critiques.</strong> This is systemic — the current prompt does not prevent it. Add an explicit enforcement instruction with a concrete example of the wrong pattern and the correct alternative.")
|
||||
elif pct >= 30:
|
||||
notes.append(f"🟡 <strong>'{pattern}' mentioned in {pct}% of critiques.</strong> Consider reinforcing the relevant prompt instruction with a stronger negative example.")
|
||||
|
||||
# Climax vs. early chapter comparison
|
||||
high_scores = [c.get('final_score', 0) for c in chapters if isinstance(c.get('chapter_position'), float) and c['chapter_position'] >= 0.75]
|
||||
low_scores = [c.get('final_score', 0) for c in chapters if isinstance(c.get('chapter_position'), float) and c['chapter_position'] < 0.25]
|
||||
if high_scores and low_scores:
|
||||
avg_climax = round(sum(high_scores) / len(high_scores), 1)
|
||||
avg_early = round(sum(low_scores) / len(low_scores), 1)
|
||||
if avg_climax < avg_early - 0.5:
|
||||
notes.append(f"📅 <strong>Climax chapters average {avg_climax}/10 vs early chapters {avg_early}/10.</strong> The high-stakes scenes underperform. Strengthen the genre mandates for climax pacing and consider adding specific instructions for emotional payoff.")
|
||||
elif avg_climax > avg_early + 0.5:
|
||||
notes.append(f"📅 <strong>Climax chapters outperform early chapters ({avg_climax} vs {avg_early}).</strong> Good — the adaptive threshold and extra attempts are concentrating quality where it matters.")
|
||||
|
||||
# POV character analysis
|
||||
pov_scores = {}
|
||||
for c in chapters:
|
||||
pov = c.get('pov_character') or 'Unknown'
|
||||
s = c.get('final_score', 0)
|
||||
if s > 0:
|
||||
pov_scores.setdefault(pov, []).append(s)
|
||||
for pov, sc in sorted(pov_scores.items(), key=lambda x: sum(x[1]) / len(x[1])):
|
||||
if len(sc) >= 2 and sum(sc) / len(sc) < 6.5:
|
||||
avg_pov = round(sum(sc) / len(sc), 1)
|
||||
notes.append(f"👤 <strong>POV '{pov}' averages {avg_pov}/10.</strong> Consider adding or strengthening a character voice profile for this character, or refining the persona bio to match how this POV character should speak and think.")
|
||||
|
||||
# Pacing analysis
|
||||
pace_scores = {}
|
||||
for c in chapters:
|
||||
pace = c.get('pacing', 'Standard')
|
||||
s = c.get('final_score', 0)
|
||||
if s > 0:
|
||||
pace_scores.setdefault(pace, []).append(s)
|
||||
for pace, sc in pace_scores.items():
|
||||
if len(sc) >= 3 and sum(sc) / len(sc) < 6.5:
|
||||
avg_p = round(sum(sc) / len(sc), 1)
|
||||
notes.append(f"⏩ <strong>'{pace}' pacing chapters average {avg_p}/10.</strong> The writing model struggles with this rhythm. Revisit the PACING_GUIDE instructions for '{pace}' chapters — they may need more concrete direction.")
|
||||
|
||||
if not notes:
|
||||
notes.append("No significant patterns detected. Review the individual chapter critiques for targeted improvements.")
|
||||
return notes
|
||||
361
story/planner.py
Normal file
361
story/planner.py
Normal file
@@ -0,0 +1,361 @@
|
||||
import json
|
||||
import random
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from story.bible_tracker import filter_characters
|
||||
|
||||
|
||||
def enrich(bp, folder, context=""):
|
||||
utils.log("ENRICHER", "Fleshing out details from description...")
|
||||
|
||||
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
||||
if 'characters' not in bp: bp['characters'] = []
|
||||
if 'plot_beats' not in bp: bp['plot_beats'] = []
|
||||
|
||||
series_meta = bp.get('series_metadata', {})
|
||||
series_block = ""
|
||||
if series_meta.get('is_series'):
|
||||
series_title = series_meta.get('series_title', 'this series')
|
||||
book_num = series_meta.get('book_number', '?')
|
||||
total_books = series_meta.get('total_books', '?')
|
||||
series_block = (
|
||||
f"\n - SERIES_CONTEXT: This is Book {book_num} of {total_books} in the '{series_title}' series. "
|
||||
f"Pace character arcs and plot resolution accordingly. "
|
||||
f"Book {book_num} of {total_books} should reflect its position: "
|
||||
f"{'establish the world and core characters' if str(book_num) == '1' else 'escalate stakes and deepen arcs' if str(book_num) != str(total_books) else 'resolve all major threads with a satisfying conclusion'}."
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Creative Director
|
||||
TASK: Create a comprehensive Book Bible from the user description.
|
||||
|
||||
INPUT DATA:
|
||||
- USER_DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}"
|
||||
- CONTEXT (Sequel): {context}{series_block}
|
||||
|
||||
STEPS:
|
||||
1. Generate a catchy Title.
|
||||
2. Define the Genre and Tone.
|
||||
3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future").
|
||||
4. Define Formatting Rules for text messages, thoughts, and chapter headers.
|
||||
5. Create Protagonist and Antagonist/Love Interest.
|
||||
- Logic: If sequel, reuse context. If new, create.
|
||||
6. Outline 5-7 core Plot Beats.
|
||||
7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural").
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"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": "John Doe", "role": "Protagonist", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
||||
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
ai_data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
||||
|
||||
if 'title' not in bp['book_metadata']:
|
||||
bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title')
|
||||
if 'structure_prompt' not in bp['book_metadata']:
|
||||
bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt')
|
||||
if 'content_warnings' not in bp['book_metadata']:
|
||||
bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', [])
|
||||
|
||||
if 'style' not in bp['book_metadata']: bp['book_metadata']['style'] = {}
|
||||
|
||||
source_style = ai_data.get('book_metadata', {}).get('style', {})
|
||||
for k, v in source_style.items():
|
||||
if k not in bp['book_metadata']['style']:
|
||||
bp['book_metadata']['style'][k] = v
|
||||
|
||||
if 'characters' not in bp or not bp['characters']:
|
||||
bp['characters'] = ai_data.get('characters', [])
|
||||
|
||||
if 'characters' in bp:
|
||||
bp['characters'] = filter_characters(bp['characters'])
|
||||
|
||||
if 'plot_beats' not in bp or not bp['plot_beats']:
|
||||
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
||||
|
||||
# Validate critical fields after enrichment
|
||||
title = bp.get('book_metadata', {}).get('title')
|
||||
genre = bp.get('book_metadata', {}).get('genre')
|
||||
if not title:
|
||||
utils.log("ENRICHER", "⚠️ Warning: book_metadata.title is missing after enrichment.")
|
||||
if not genre:
|
||||
utils.log("ENRICHER", "⚠️ Warning: book_metadata.genre is missing after enrichment.")
|
||||
|
||||
return bp
|
||||
except Exception as e:
|
||||
utils.log("ENRICHER", f"Enrichment failed: {e}")
|
||||
return bp
|
||||
|
||||
|
||||
def plan_structure(bp, folder):
|
||||
utils.log("ARCHITECT", "Creating structure...")
|
||||
|
||||
structure_type = bp.get('book_metadata', {}).get('structure_prompt')
|
||||
|
||||
if not structure_type:
|
||||
label = bp.get('length_settings', {}).get('label', 'Novel')
|
||||
structures = {
|
||||
"Chapter Book": "Create a simple episodic structure with clear chapter hooks.",
|
||||
"Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.",
|
||||
"Flash Fiction": "Create a single, impactful scene structure with a twist.",
|
||||
"Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).",
|
||||
"Novella": "Create a standard 3-Act Structure.",
|
||||
"Novel": "Create a detailed 3-Act Structure with A and B plots.",
|
||||
"Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events."
|
||||
}
|
||||
structure_type = structures.get(label, "Create a 3-Act Structure.")
|
||||
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
target_chapters = bp.get('length_settings', {}).get('chapters', 'flexible')
|
||||
target_words = bp.get('length_settings', {}).get('words', 'flexible')
|
||||
chars_summary = [{"name": c.get("name"), "role": c.get("role")} for c in bp.get('characters', [])]
|
||||
|
||||
series_meta = bp.get('series_metadata', {})
|
||||
series_block = ""
|
||||
if series_meta.get('is_series'):
|
||||
series_title = series_meta.get('series_title', 'this series')
|
||||
book_num = series_meta.get('book_number', '?')
|
||||
total_books = series_meta.get('total_books', '?')
|
||||
series_block = (
|
||||
f"\n - SERIES_CONTEXT: This is Book {book_num} of {total_books} in the '{series_title}' series. "
|
||||
f"Structure the arc to fit its position in the series: "
|
||||
f"{'introduce all major characters and the central conflict; leave threads open for future books' if str(book_num) == '1' else 'deepen existing character arcs and escalate the overarching conflict; do not resolve the series-level stakes' if str(book_num) != str(total_books) else 'resolve all series-level threads; provide a satisfying conclusion for every major character arc'}."
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Story Architect
|
||||
TASK: Create a detailed structural event outline for a {target_chapters}-chapter book.
|
||||
|
||||
BOOK:
|
||||
- TITLE: {bp['book_metadata']['title']}
|
||||
- GENRE: {bp.get('book_metadata', {}).get('genre', 'Fiction')}
|
||||
- TARGET_CHAPTERS: {target_chapters}
|
||||
- TARGET_WORDS: {target_words}
|
||||
- STRUCTURE: {structure_type}{series_block}
|
||||
|
||||
CHARACTERS: {json.dumps(chars_summary)}
|
||||
|
||||
USER_BEATS (must all be preserved and woven into the outline):
|
||||
{json.dumps(beats_context)}
|
||||
|
||||
REQUIREMENTS:
|
||||
- Produce enough events to fill approximately {target_chapters} chapters.
|
||||
- Each event must serve a narrative purpose (setup, escalation, reversal, climax, resolution).
|
||||
- Distribute events across a beginning, middle, and end — avoid front-loading.
|
||||
- Character arcs must be visible through the events (growth, change, revelation).
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "events": [{{ "description": "String", "purpose": "String" }}] }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))['events']
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def expand(events, pass_num, target_chapters, bp, folder):
|
||||
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
|
||||
|
||||
event_ceiling = int(target_chapters * 1.5)
|
||||
if len(events) >= event_ceiling:
|
||||
task = (
|
||||
f"The outline already has {len(events)} beats for a {target_chapters}-chapter book — do NOT add more events. "
|
||||
f"Instead, enrich each existing beat's description with more specific detail: setting, characters involved, emotional stakes, and how it connects to what follows."
|
||||
)
|
||||
else:
|
||||
task = (
|
||||
f"Expand the outline toward {target_chapters} chapters. "
|
||||
f"Current count: {len(events)} beats. "
|
||||
f"Add intermediate events to fill pacing gaps, deepen subplots, and ensure character arcs are visible. "
|
||||
f"Do not overshoot — aim for {target_chapters} to {event_ceiling} total events."
|
||||
)
|
||||
|
||||
original_beats = bp.get('plot_beats', [])
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Story Architect
|
||||
TASK: {task}
|
||||
|
||||
ORIGINAL_USER_BEATS (must all remain present):
|
||||
{json.dumps(original_beats)}
|
||||
|
||||
CURRENT_EVENTS:
|
||||
{json.dumps(events)}
|
||||
|
||||
RULES:
|
||||
1. PRESERVE all original user beats — do not remove or alter them.
|
||||
2. New events must serve a clear narrative purpose (tension, character, world, reversal).
|
||||
3. Avoid repetitive events — each beat must be distinct.
|
||||
4. Distribute additions evenly — do not front-load the outline.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "events": [{{"description": "String", "purpose": "String"}}] }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_events = json.loads(utils.clean_json(response.text))['events']
|
||||
|
||||
if len(new_events) > len(events):
|
||||
utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.")
|
||||
elif len(str(new_events)) > len(str(events)) + 20:
|
||||
utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).")
|
||||
else:
|
||||
utils.log("ARCHITECT", " -> No significant changes.")
|
||||
return new_events
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}")
|
||||
return events
|
||||
|
||||
|
||||
def create_chapter_plan(events, bp, folder):
|
||||
utils.log("ARCHITECT", "Finalizing Chapters...")
|
||||
target = bp['length_settings']['chapters']
|
||||
words = bp['length_settings'].get('words', 'Flexible')
|
||||
|
||||
include_prologue = bp.get('length_settings', {}).get('include_prologue', False)
|
||||
include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False)
|
||||
|
||||
structure_instructions = ""
|
||||
if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n"
|
||||
if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n"
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
pov_chars = style.get('pov_characters', [])
|
||||
pov_instruction = ""
|
||||
if pov_chars:
|
||||
pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}."
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Pacing Specialist
|
||||
TASK: Group the provided events into chapters for a {meta.get('genre', 'Fiction')} {bp['length_settings'].get('label', 'novel')}.
|
||||
|
||||
GUIDELINES:
|
||||
- AIM for approximately {target} chapters, but the final count may vary ±15% if the story structure demands it.
|
||||
- TARGET_WORDS for the whole book: {words}
|
||||
- Assign pacing to each chapter: Very Fast / Fast / Standard / Slow / Very Slow
|
||||
- estimated_words per chapter should reflect its pacing:
|
||||
Very Fast ≈ 60% of average, Fast ≈ 80%, Standard ≈ 100%, Slow ≈ 125%, Very Slow ≈ 150%
|
||||
- Do NOT force equal word counts. Natural variation makes the book feel alive.
|
||||
{structure_instructions}
|
||||
{pov_instruction}
|
||||
|
||||
INPUT_EVENTS: {json.dumps(events)}
|
||||
|
||||
OUTPUT_FORMAT (JSON): [{{"chapter_number": 1, "title": "String", "pov_character": "String", "pacing": "String", "estimated_words": 2000, "beats": ["String"]}}]
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
plan = json.loads(utils.clean_json(response.text))
|
||||
|
||||
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
|
||||
target_val = 0
|
||||
if '-' in target_str:
|
||||
try:
|
||||
parts = target_str.split('-')
|
||||
target_val = int((int(parts[0]) + int(parts[1])) / 2)
|
||||
except: pass
|
||||
else:
|
||||
try: target_val = int(target_str)
|
||||
except: pass
|
||||
|
||||
if target_val > 0:
|
||||
variance = random.uniform(0.92, 1.08)
|
||||
target_val = int(target_val * variance)
|
||||
utils.log("ARCHITECT", f"Word target after variance ({variance:.2f}x): {target_val} words.")
|
||||
|
||||
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
|
||||
if current_sum > 0:
|
||||
base_factor = target_val / current_sum
|
||||
pacing_weight = {
|
||||
'very fast': 0.60, 'fast': 0.80, 'standard': 1.00,
|
||||
'slow': 1.25, 'very slow': 1.50
|
||||
}
|
||||
for c in plan:
|
||||
pw = pacing_weight.get(c.get('pacing', 'standard').lower(), 1.0)
|
||||
c['estimated_words'] = max(300, int(c.get('estimated_words', 0) * base_factor * pw))
|
||||
|
||||
adjusted_sum = sum(c['estimated_words'] for c in plan)
|
||||
if adjusted_sum > 0:
|
||||
norm = target_val / adjusted_sum
|
||||
for c in plan:
|
||||
c['estimated_words'] = max(300, int(c['estimated_words'] * norm))
|
||||
|
||||
utils.log("ARCHITECT", f"Chapter lengths scaled by pacing. Total ≈ {sum(c['estimated_words'] for c in plan)} words across {len(plan)} chapters.")
|
||||
|
||||
return plan
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Failed to create chapter plan: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def validate_outline(events, chapters, bp, folder):
|
||||
"""Pre-generation outline validation gate (Action Plan Step 3: Alt 2-B).
|
||||
|
||||
Checks for: missing required beats, character continuity issues, severe pacing
|
||||
imbalances, and POV logic errors. Returns findings but never blocks generation —
|
||||
issues are logged as warnings so the writer can proceed.
|
||||
"""
|
||||
utils.log("ARCHITECT", "Validating outline before writing phase...")
|
||||
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
chars_summary = [{"name": c.get("name"), "role": c.get("role")} for c in bp.get('characters', [])]
|
||||
|
||||
# Sample chapter data to keep prompt size manageable
|
||||
chapters_sample = chapters[:5] + chapters[-5:] if len(chapters) > 10 else chapters
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Continuity Editor
|
||||
TASK: Review this chapter outline for issues that could cause expensive rewrites later.
|
||||
|
||||
REQUIRED_BEATS (must all appear somewhere in the chapter plan):
|
||||
{json.dumps(beats_context)}
|
||||
|
||||
CHARACTERS:
|
||||
{json.dumps(chars_summary)}
|
||||
|
||||
CHAPTER_PLAN (sample — first 5 and last 5 chapters):
|
||||
{json.dumps(chapters_sample)}
|
||||
|
||||
CHECK FOR:
|
||||
1. MISSING_BEATS: Are all required plot beats present? List any absent beats by name.
|
||||
2. CONTINUITY: Are there character deaths/revivals, unacknowledged time jumps, or contradictions visible in the outline?
|
||||
3. PACING: Are there 3+ consecutive chapters with identical pacing that would create reader fatigue?
|
||||
4. POV_LOGIC: Are key emotional scenes assigned to the most appropriate POV character?
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"issues": [
|
||||
{{"type": "missing_beat|continuity|pacing|pov", "description": "...", "severity": "critical|warning"}}
|
||||
],
|
||||
"overall_severity": "ok|warning|critical",
|
||||
"summary": "One-sentence summary of findings."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
result = json.loads(utils.clean_json(response.text))
|
||||
|
||||
severity = result.get('overall_severity', 'ok')
|
||||
issues = result.get('issues', [])
|
||||
summary = result.get('summary', 'No issues found.')
|
||||
|
||||
for issue in issues:
|
||||
prefix = "⚠️" if issue.get('severity') == 'warning' else "🚨"
|
||||
utils.log("ARCHITECT", f" {prefix} Outline {issue.get('type', 'issue')}: {issue.get('description', '')}")
|
||||
|
||||
utils.log("ARCHITECT", f"Outline validation complete: {severity.upper()} — {summary}")
|
||||
return result
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Outline validation failed (non-blocking): {e}")
|
||||
return {"issues": [], "overall_severity": "ok", "summary": "Validation skipped."}
|
||||
123
story/state.py
Normal file
123
story/state.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import os
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
def _empty_state():
|
||||
return {"active_threads": [], "immediate_handoff": "", "resolved_threads": [], "chapter": 0}
|
||||
|
||||
|
||||
def load_story_state(folder, project_id=None):
|
||||
"""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")
|
||||
if os.path.exists(path):
|
||||
return utils.load_json(path) or _empty_state()
|
||||
return _empty_state()
|
||||
|
||||
|
||||
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
|
||||
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}...")
|
||||
prompt = f"""
|
||||
ROLE: Story State Tracker
|
||||
TASK: Update the structured story state based on the new chapter.
|
||||
|
||||
CURRENT_STATE:
|
||||
{json.dumps(current_state)}
|
||||
|
||||
NEW_CHAPTER (Chapter {chapter_num}):
|
||||
{utils.truncate_to_tokens(chapter_text, 4000)}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. ACTIVE_THREADS: 2-5 concise strings, each describing what a key character is currently trying to achieve.
|
||||
- Carry forward unresolved threads from CURRENT_STATE.
|
||||
- Add new threads introduced in this chapter.
|
||||
- Remove threads that are now resolved.
|
||||
2. IMMEDIATE_HANDOFF: Write exactly 3 sentences describing how this chapter ended:
|
||||
- Sentence 1: Where are the key characters physically right now?
|
||||
- Sentence 2: What emotional state are they in at the very end of this chapter?
|
||||
- Sentence 3: What immediate unresolved threat, question, or decision is hanging in the air?
|
||||
3. RESOLVED_THREADS: Carry forward from CURRENT_STATE + add threads explicitly resolved in this chapter.
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"active_threads": ["Thread 1", "Thread 2"],
|
||||
"immediate_handoff": "Sentence 1. Sentence 2. Sentence 3.",
|
||||
"resolved_threads": ["Resolved thread 1"],
|
||||
"chapter": {chapter_num}
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_state = json.loads(utils.clean_json(response.text))
|
||||
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")
|
||||
with open(path, 'w') as f:
|
||||
json.dump(new_state, f, indent=2)
|
||||
|
||||
utils.log("STATE", f" -> Story state saved. Active threads: {len(new_state.get('active_threads', []))}")
|
||||
return new_state
|
||||
except Exception as e:
|
||||
utils.log("STATE", f" -> Story state update failed: {e}. Keeping previous state.")
|
||||
return current_state
|
||||
|
||||
|
||||
def format_for_prompt(state, chapter_beats=None):
|
||||
"""Format the story state into a prompt-ready string.
|
||||
Active threads and immediate handoff are always included.
|
||||
Resolved threads are only included if referenced in the chapter's beats."""
|
||||
if not state or (not state.get('immediate_handoff') and not state.get('active_threads')):
|
||||
return None
|
||||
|
||||
beats_text = " ".join(str(b) for b in (chapter_beats or [])).lower()
|
||||
lines = []
|
||||
|
||||
if state.get('immediate_handoff'):
|
||||
lines.append(f"IMMEDIATE STORY HANDOFF (exactly how the previous chapter ended):\n{state['immediate_handoff']}")
|
||||
|
||||
if state.get('active_threads'):
|
||||
lines.append("ACTIVE PLOT THREADS:")
|
||||
for t in state['active_threads']:
|
||||
lines.append(f" - {t}")
|
||||
|
||||
relevant_resolved = [
|
||||
t for t in state.get('resolved_threads', [])
|
||||
if any(w in beats_text for w in t.lower().split() if len(w) > 4)
|
||||
]
|
||||
if relevant_resolved:
|
||||
lines.append("RESOLVED THREADS (context only — do not re-introduce):")
|
||||
for t in relevant_resolved:
|
||||
lines.append(f" - {t}")
|
||||
|
||||
return "\n".join(lines)
|
||||
305
story/style_persona.py
Normal file
305
story/style_persona.py
Normal file
@@ -0,0 +1,305 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
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',
|
||||
'delved', 'mined', 'neon-lit', 'bustling', 'weaved', 'intricately',
|
||||
'a reminder that', 'couldn\'t help but', 'it occurred to',
|
||||
'the air was thick with', 'etched in', 'a wave of', 'wash of emotion',
|
||||
'intertwined', 'navigate', 'realm', 'in the grand scheme',
|
||||
'at the end of the day', 'painting a picture', 'a dance between',
|
||||
'the weight of', 'visceral reminder', 'stark reminder',
|
||||
'a symphony', 'a mosaic', 'rich tapestry', 'whirlwind of',
|
||||
'his/her heart raced', 'time seemed to slow', 'the world fell away',
|
||||
'needless to say', 'it goes without saying', 'importantly',
|
||||
'it is worth noting', 'commendable', 'meticulous', 'pivotal',
|
||||
'in conclusion', 'overall', 'in summary', 'to summarize'
|
||||
],
|
||||
"filter_words": [
|
||||
'felt', 'saw', 'heard', 'realized', 'decided', 'noticed', 'knew', 'thought',
|
||||
'wondered', 'seemed', 'appeared', 'looked like', 'watched', 'observed', 'sensed'
|
||||
]
|
||||
}
|
||||
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"""
|
||||
ROLE: Literary Editor
|
||||
TASK: Update 'Banned Words' lists for AI writing.
|
||||
|
||||
INPUT_DATA:
|
||||
- CURRENT_AI_ISMS: {json.dumps(current.get('ai_isms', []))}
|
||||
- CURRENT_FILTER_WORDS: {json.dumps(current.get('filter_words', []))}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Review lists. Remove false positives.
|
||||
2. Add new common AI tropes (e.g. 'neon-lit', 'bustling', 'a sense of', 'mined', 'delved').
|
||||
3. Ensure robustness.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "ai_isms": [strings], "filter_words": [strings] }}
|
||||
"""
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
model_name = getattr(model, 'name', ai_models.logic_model_name)
|
||||
if folder: utils.log_usage(folder, model_name, response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
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 create_initial_persona(bp, folder):
|
||||
utils.log("SYSTEM", "Generating initial Author Persona based on genre/tone...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Creative Director
|
||||
TASK: Create a fictional 'Author Persona'.
|
||||
|
||||
METADATA:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {meta.get('genre')}
|
||||
- TONE: {style.get('tone')}
|
||||
- AUDIENCE: {meta.get('target_audience')}
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "name": "Pen Name", "bio": "Description of writing style (voice, sentence structure, vocabulary)...", "age": "...", "gender": "..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Persona generation failed: {e}")
|
||||
return {"name": "AI Author", "bio": "Standard, balanced writing style."}
|
||||
|
||||
|
||||
def validate_persona(bp, persona_details, folder):
|
||||
"""Validate a newly created persona by generating a 200-word sample and scoring it.
|
||||
|
||||
Experiment 6 (Iterative Persona Validation): generates a test passage in the
|
||||
persona's voice and evaluates voice quality before accepting it. This front-loads
|
||||
quality assurance so Phase 3 starts with a well-calibrated author voice.
|
||||
|
||||
Returns (is_valid: bool, score: int). Threshold: score >= 7 → accepted.
|
||||
"""
|
||||
meta = bp.get('book_metadata', {})
|
||||
genre = meta.get('genre', 'Fiction')
|
||||
tone = meta.get('style', {}).get('tone', 'balanced')
|
||||
name = persona_details.get('name', 'Unknown Author')
|
||||
bio = persona_details.get('bio', 'Standard style.')
|
||||
|
||||
sample_prompt = f"""
|
||||
ROLE: Fiction Writer
|
||||
TASK: Write a 400-word opening scene that perfectly demonstrates this author's voice.
|
||||
|
||||
AUTHOR_PERSONA:
|
||||
Name: {name}
|
||||
Style/Bio: {bio}
|
||||
|
||||
GENRE: {genre}
|
||||
TONE: {tone}
|
||||
|
||||
RULES:
|
||||
- Exactly ~400 words of prose (no chapter header, no commentary)
|
||||
- Must reflect the persona's stated sentence structure, vocabulary, and voice
|
||||
- Show, don't tell — no filter words (felt, saw, heard, realized, noticed)
|
||||
- Deep POV: immerse the reader in a character's immediate experience
|
||||
|
||||
OUTPUT: Prose only.
|
||||
"""
|
||||
try:
|
||||
resp = ai_models.model_logic.generate_content(sample_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
|
||||
sample_text = resp.text
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f" -> Persona validation sample failed: {e}. Accepting persona.")
|
||||
return True, 7
|
||||
|
||||
# Lightweight scoring: focused on voice quality (not full 13-rubric)
|
||||
score_prompt = f"""
|
||||
ROLE: Literary Editor
|
||||
TASK: Score this prose sample for author voice quality.
|
||||
|
||||
EXPECTED_PERSONA:
|
||||
{bio}
|
||||
|
||||
SAMPLE:
|
||||
{sample_text}
|
||||
|
||||
CRITERIA:
|
||||
1. Does the prose reflect the stated author persona? (voice, register, sentence style)
|
||||
2. Is the prose free of filter words (felt, saw, heard, noticed, realized)?
|
||||
3. Is it deep POV — immediate, immersive, not distant narration?
|
||||
4. Is there genuine sentence variety and strong verb choice?
|
||||
|
||||
SCORING (1-10):
|
||||
- 8-10: Voice is distinct, matches persona, clean deep POV
|
||||
- 6-7: Reasonable voice, minor filter word issues
|
||||
- 1-5: Generic AI prose, heavy filter words, or persona not reflected
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{"score": int, "reason": "One sentence."}}
|
||||
"""
|
||||
try:
|
||||
resp2 = ai_models.model_logic.generate_content(score_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp2.usage_metadata)
|
||||
data = json.loads(utils.clean_json(resp2.text))
|
||||
score = int(data.get('score', 7))
|
||||
reason = data.get('reason', '')
|
||||
is_valid = score >= 7
|
||||
utils.log("SYSTEM", f" -> Persona validation: {score}/10 {'✅ Accepted' if is_valid else '❌ Rejected'} — {reason}")
|
||||
return is_valid, score
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f" -> Persona scoring failed: {e}. Accepting persona.")
|
||||
return True, 7
|
||||
|
||||
|
||||
def refine_persona(bp, text, folder, pov_character=None):
|
||||
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
|
||||
ad = bp.get('book_metadata', {}).get('author_details', {})
|
||||
|
||||
# If a POV character is given and has a voice_profile, refine that instead
|
||||
if pov_character:
|
||||
for char in bp.get('characters', []):
|
||||
if char.get('name') == pov_character and char.get('voice_profile'):
|
||||
vp = char['voice_profile']
|
||||
current_bio = vp.get('bio', 'Standard style.')
|
||||
prompt = f"""
|
||||
ROLE: Literary Stylist
|
||||
TASK: Refine a POV character's voice profile based on the text sample.
|
||||
|
||||
INPUT_DATA:
|
||||
- TEXT_SAMPLE: {text[:3000]}
|
||||
- CHARACTER: {pov_character}
|
||||
- CURRENT_VOICE_BIO: {current_bio}
|
||||
|
||||
GOAL: Ensure future chapters for this POV character sound exactly like the sample. Highlight quirks, patterns, vocabulary specific to this character's perspective.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "bio": "Updated voice bio..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
|
||||
if new_bio:
|
||||
char['voice_profile']['bio'] = new_bio
|
||||
utils.log("SYSTEM", f" -> Voice profile bio updated for '{pov_character}'.")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f" -> Voice profile refinement failed for '{pov_character}': {e}")
|
||||
return ad # Return author_details unchanged
|
||||
|
||||
# Default: refine the main author persona bio
|
||||
current_bio = ad.get('bio', 'Standard style.')
|
||||
prompt = f"""
|
||||
ROLE: Literary Stylist
|
||||
TASK: Refine Author Bio based on text sample.
|
||||
|
||||
INPUT_DATA:
|
||||
- TEXT_SAMPLE: {text[:3000]}
|
||||
- CURRENT_BIO: {current_bio}
|
||||
|
||||
GOAL: Ensure future chapters sound exactly like the sample. Highlight quirks, patterns, vocabulary.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "bio": "Updated bio..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
|
||||
if new_bio:
|
||||
ad['bio'] = new_bio
|
||||
utils.log("SYSTEM", " -> Persona bio updated.")
|
||||
return ad
|
||||
except: pass
|
||||
return ad
|
||||
|
||||
|
||||
def update_persona_sample(bp, folder):
|
||||
utils.log("SYSTEM", "Extracting author persona from manuscript...")
|
||||
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
if not os.path.exists(ms_path): return
|
||||
ms = utils.load_json(ms_path)
|
||||
if not ms: return
|
||||
|
||||
full_text = "\n".join([c.get('content', '') for c in ms])
|
||||
if len(full_text) < 500: return
|
||||
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
safe_title = utils.sanitize_filename(meta.get('title', 'book'))[:20]
|
||||
timestamp = int(time.time())
|
||||
filename = f"sample_{safe_title}_{timestamp}.txt"
|
||||
filepath = os.path.join(config.PERSONAS_DIR, filename)
|
||||
|
||||
sample_text = full_text[:3000]
|
||||
with open(filepath, 'w', encoding='utf-8') as f: f.write(sample_text)
|
||||
|
||||
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 = {}
|
||||
if os.path.exists(_personas_file):
|
||||
try:
|
||||
with open(_personas_file, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if author_name not in personas:
|
||||
utils.log("SYSTEM", f"Generating new persona profile for '{author_name}'...")
|
||||
prompt = f"""
|
||||
ROLE: Literary Analyst
|
||||
TASK: Analyze writing style (Tone, Voice, Vocabulary).
|
||||
TEXT: {sample_text[:1000]}
|
||||
OUTPUT: 1-sentence author bio.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
bio = response.text.strip()
|
||||
except: bio = "Style analysis unavailable."
|
||||
|
||||
personas[author_name] = {
|
||||
"name": author_name,
|
||||
"bio": bio,
|
||||
"sample_files": [filename],
|
||||
"sample_text": sample_text[:500]
|
||||
}
|
||||
else:
|
||||
utils.log("SYSTEM", f"Updating persona '{author_name}' with new sample.")
|
||||
if 'sample_files' not in personas[author_name]: personas[author_name]['sample_files'] = []
|
||||
if filename not in personas[author_name]['sample_files']:
|
||||
personas[author_name]['sample_files'].append(filename)
|
||||
|
||||
with open(_personas_file, 'w') as f: json.dump(personas, f, indent=2)
|
||||
622
story/writer.py
Normal file
622
story/writer.py
Normal file
@@ -0,0 +1,622 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from story.style_persona import get_style_guidelines
|
||||
from story.editor import evaluate_chapter_quality
|
||||
from story import eval_logger
|
||||
|
||||
|
||||
def get_genre_instructions(genre):
|
||||
"""Return genre-specific writing mandates to inject into the draft prompt."""
|
||||
g = genre.lower()
|
||||
if any(x in g for x in ['thriller', 'mystery', 'crime', 'suspense']):
|
||||
return (
|
||||
"GENRE_MANDATES (Thriller/Mystery):\n"
|
||||
"- Every scene must end on a hook: a revelation, reversal, or imminent threat.\n"
|
||||
"- Clues must be planted through detail, not narrated as clues.\n"
|
||||
"- Danger must feel visceral — use short, punchy sentences during action beats.\n"
|
||||
"- Internal monologue must reflect calculation and suspicion, not passive observation.\n"
|
||||
"- NEVER explain the mystery through the narrator — show the protagonist piecing it together."
|
||||
)
|
||||
elif any(x in g for x in ['romance', 'romantic']):
|
||||
return (
|
||||
"GENRE_MANDATES (Romance):\n"
|
||||
"- Show attraction through micro-actions: eye contact, proximity, hesitation, body heat.\n"
|
||||
"- NEVER tell the reader they feel attraction — render it through physical involuntary response.\n"
|
||||
"- Dialogue must carry subtext — what is NOT said is as important as what is said.\n"
|
||||
"- Every scene must shift the relationship dynamic (closer together or further apart).\n"
|
||||
"- The POV character's emotional wound must be present even in light-hearted scenes."
|
||||
)
|
||||
elif any(x in g for x in ['fantasy', 'epic', 'sword', 'magic']):
|
||||
return (
|
||||
"GENRE_MANDATES (Fantasy):\n"
|
||||
"- Introduce world-building through the POV character's reactions — not exposition dumps.\n"
|
||||
"- Magic and the fantastical must have visible cost or consequence — no deus ex machina.\n"
|
||||
"- Use concrete, grounded sensory details even in otherworldly settings.\n"
|
||||
"- Character motivation must be rooted in tangible personal stakes, not abstract prophecy or destiny.\n"
|
||||
"- NEVER use 'As you know Bob' exposition — characters who live in this world do not explain it to each other."
|
||||
)
|
||||
elif any(x in g for x in ['science fiction', 'sci-fi', 'scifi', 'space', 'cyberpunk']):
|
||||
return (
|
||||
"GENRE_MANDATES (Science Fiction):\n"
|
||||
"- Introduce technology through its sensory and social impact, not technical exposition.\n"
|
||||
"- The speculative premise must colour every scene — do not write contemporary fiction with sci-fi decoration.\n"
|
||||
"- Characters must treat their environment as natives, not tourists — no wonder at ordinary things.\n"
|
||||
"- Avoid anachronistic emotional or social responses inconsistent with the world's norms.\n"
|
||||
"- Themes (AI, surveillance, cloning) must emerge from plot choices and character conflict, not speeches."
|
||||
)
|
||||
elif any(x in g for x in ['horror', 'dark', 'gothic']):
|
||||
return (
|
||||
"GENRE_MANDATES (Horror):\n"
|
||||
"- Dread is built through implication — show what is wrong, never describe the monster directly.\n"
|
||||
"- Use the environment as an active hostile force — the setting must feel alive and threatening.\n"
|
||||
"- The POV character's psychology IS the true horror: isolation, doubt, paranoia.\n"
|
||||
"- Avoid jump-scare prose (sudden capitalised noises). Build sustained, crawling unease.\n"
|
||||
"- Sensory details must feel 'off' — wrong smells, sounds that don't belong, textures that repel."
|
||||
)
|
||||
elif any(x in g for x in ['historical', 'period', 'regency', 'victorian']):
|
||||
return (
|
||||
"GENRE_MANDATES (Historical Fiction):\n"
|
||||
"- Characters must think and speak with period-accurate worldviews — avoid modern anachronisms.\n"
|
||||
"- Historical detail must be woven into action and dialogue, never listed in descriptive passages.\n"
|
||||
"- Social hierarchy and constraint must feel like real, material limits on character choices.\n"
|
||||
"- Avoid modern idioms, slang, or metaphors that did not exist in the era.\n"
|
||||
"- The tension between historical inevitability and personal agency is the engine of the story."
|
||||
)
|
||||
else:
|
||||
return (
|
||||
"GENRE_MANDATES (General Fiction):\n"
|
||||
"- Every scene must change the character's situation, knowledge, or emotional state.\n"
|
||||
"- Conflict must be present in every scene — internal, interpersonal, or external.\n"
|
||||
"- Subtext: characters rarely say exactly what they mean — write the gap between intent and words.\n"
|
||||
"- The end of every chapter must be earned through causality, not arbitrary stopping.\n"
|
||||
"- Avoid coincidence as a plot driver — every event must have a clear cause."
|
||||
)
|
||||
|
||||
|
||||
def build_persona_info(bp):
|
||||
"""Build the author persona string from bp['book_metadata']['author_details'].
|
||||
|
||||
Extracted as a standalone function so engine.py can pre-load the persona once
|
||||
for the entire writing phase instead of re-reading sample files for every chapter.
|
||||
Returns the assembled persona string, or None if no author_details are present.
|
||||
"""
|
||||
meta = bp.get('book_metadata', {})
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
return meta['author_bio']
|
||||
if not ad:
|
||||
return None
|
||||
|
||||
info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('age'): info += f"Age: {ad['age']}\n"
|
||||
if ad.get('gender'): info += f"Gender: {ad['gender']}\n"
|
||||
if ad.get('race'): info += f"Race: {ad['race']}\n"
|
||||
if ad.get('nationality'): info += f"Nationality: {ad['nationality']}\n"
|
||||
if ad.get('language'): info += f"Language: {ad['language']}\n"
|
||||
if ad.get('bio'): info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
samples = []
|
||||
if ad.get('sample_text'):
|
||||
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
|
||||
|
||||
if ad.get('sample_files'):
|
||||
for fname in ad['sample_files']:
|
||||
fpath = os.path.join(config.PERSONAS_DIR, fname)
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read(3000)
|
||||
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
|
||||
except:
|
||||
pass
|
||||
|
||||
if samples:
|
||||
info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def expand_beats_to_treatment(beats, pov_char, genre, folder):
|
||||
"""Expand sparse scene beats into a Director's Treatment using a fast model.
|
||||
This pre-flight step gives the writer detailed staging and emotional direction,
|
||||
reducing rewrites by preventing skipped beats and flat pacing."""
|
||||
if not beats:
|
||||
return None
|
||||
prompt = f"""
|
||||
ROLE: Story Director
|
||||
TASK: Expand the following sparse scene beats into a concise "Director's Treatment".
|
||||
|
||||
GENRE: {genre}
|
||||
POV_CHARACTER: {pov_char or 'Protagonist'}
|
||||
SCENE_BEATS: {json.dumps(beats)}
|
||||
|
||||
For EACH beat, provide 3-4 sentences covering:
|
||||
1. STAGING: Where are characters physically? How do they enter/exit the scene?
|
||||
2. SENSORY ANCHOR: One specific sensory detail (sound, smell, texture) to ground the beat.
|
||||
3. EMOTIONAL SHIFT: What is the POV character's internal state at the START vs END of this beat?
|
||||
4. SUBTEXT: What does the POV character want vs. what they actually do or say?
|
||||
|
||||
OUTPUT: Prose treatment only. Do NOT write the chapter prose itself.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
utils.log("WRITER", " -> Beat expansion complete.")
|
||||
return response.text
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f" -> Beat expansion failed: {e}. Using raw beats.")
|
||||
return None
|
||||
|
||||
|
||||
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint="", prebuilt_persona=None, chapter_position=None):
|
||||
"""Write a single chapter with iterative quality evaluation.
|
||||
|
||||
Args:
|
||||
prebuilt_persona: Pre-loaded persona string from build_persona_info(bp).
|
||||
When provided, skips per-chapter file reads (persona cache optimisation).
|
||||
chapter_position: Float 0.0–1.0 indicating position in book. Used for
|
||||
adaptive scoring thresholds (setup = lenient, climax = strict).
|
||||
"""
|
||||
pacing = chap.get('pacing', 'Standard')
|
||||
est_words = chap.get('estimated_words', 'Flexible')
|
||||
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
|
||||
ls = bp['length_settings']
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
genre = meta.get('genre', 'Fiction')
|
||||
|
||||
pov_char = chap.get('pov_character', '')
|
||||
|
||||
# Check for character-specific voice profile (Step 2: Character Voice Profiles)
|
||||
character_voice = None
|
||||
if pov_char:
|
||||
for char in bp.get('characters', []):
|
||||
if char.get('name') == pov_char and char.get('voice_profile'):
|
||||
vp = char['voice_profile']
|
||||
character_voice = f"Style/Bio: {vp.get('bio', '')}\nKeywords: {', '.join(vp.get('keywords', []))}"
|
||||
utils.log("WRITER", f" -> Using voice profile for POV character: {pov_char}")
|
||||
break
|
||||
|
||||
if character_voice:
|
||||
persona_info = character_voice
|
||||
elif prebuilt_persona is not None:
|
||||
persona_info = prebuilt_persona
|
||||
else:
|
||||
persona_info = build_persona_info(bp) or "Standard, balanced writing style."
|
||||
|
||||
# Only inject characters named in the chapter beats + the POV character
|
||||
beats_text = " ".join(str(b) for b in chap.get('beats', []))
|
||||
pov_lower = pov_char.lower() if pov_char else ""
|
||||
chars_for_writer = [
|
||||
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
|
||||
for c in bp.get('characters', [])
|
||||
if c.get("name") and (
|
||||
c["name"].lower() in beats_text.lower() or
|
||||
(pov_lower and c["name"].lower() == pov_lower)
|
||||
)
|
||||
]
|
||||
if not chars_for_writer:
|
||||
chars_for_writer = [
|
||||
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
|
||||
for c in bp.get('characters', [])
|
||||
]
|
||||
relevant_names = {c["name"] for c in chars_for_writer}
|
||||
|
||||
char_visuals = ""
|
||||
if tracking and 'characters' in tracking:
|
||||
char_visuals = "\nCHARACTER TRACKING (Visuals, State & Scene Position):\n"
|
||||
for name, data in tracking['characters'].items():
|
||||
if name not in relevant_names:
|
||||
continue
|
||||
desc = ", ".join(data.get('descriptors', []))
|
||||
likes = ", ".join(data.get('likes_dislikes', []))
|
||||
speech = data.get('speech_style', 'Unknown')
|
||||
worn = data.get('last_worn', 'Unknown')
|
||||
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n * Likes/Dislikes: {likes}\n"
|
||||
|
||||
major = data.get('major_events', [])
|
||||
if major: char_visuals += f" * Major Events: {'; '.join(major)}\n"
|
||||
|
||||
if worn and worn != 'Unknown':
|
||||
char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n"
|
||||
|
||||
location = data.get('current_location', '')
|
||||
items = data.get('held_items', [])
|
||||
if location:
|
||||
char_visuals += f" * Current Location: {location}\n"
|
||||
if items:
|
||||
char_visuals += f" * Held Items: {', '.join(items)}\n"
|
||||
|
||||
# Build lore block: pull only locations/items relevant to this chapter
|
||||
lore_block = ""
|
||||
if tracking and tracking.get('lore'):
|
||||
chapter_locations = chap.get('locations', [])
|
||||
chapter_items = chap.get('key_items', [])
|
||||
lore = tracking['lore']
|
||||
relevant_lore = {
|
||||
name: desc for name, desc in lore.items()
|
||||
if any(name.lower() in ref.lower() or ref.lower() in name.lower()
|
||||
for ref in chapter_locations + chapter_items)
|
||||
}
|
||||
if relevant_lore:
|
||||
lore_block = "\nLORE_CONTEXT (Canonical descriptions for this chapter — use these exactly):\n"
|
||||
for name, desc in relevant_lore.items():
|
||||
lore_block += f"- {name}: {desc}\n"
|
||||
|
||||
style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))])
|
||||
if 'tropes' in style and isinstance(style['tropes'], list):
|
||||
style_block += f"\n- Tropes: {', '.join(style['tropes'])}"
|
||||
|
||||
if 'formatting_rules' in style and isinstance(style['formatting_rules'], list):
|
||||
style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules'])
|
||||
|
||||
prev_context_block = ""
|
||||
if prev_content:
|
||||
trunc_content = utils.truncate_to_tokens(prev_content, 1000)
|
||||
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (Last ~1000 Tokens — For Immediate Continuity):\n{trunc_content}\n"
|
||||
|
||||
# Skip beat expansion if beats are already detailed (saves ~5K tokens per chapter)
|
||||
beats_list = chap.get('beats', [])
|
||||
total_beat_words = sum(len(str(b).split()) for b in beats_list)
|
||||
if total_beat_words > 100:
|
||||
utils.log("WRITER", f" -> Beats already detailed ({total_beat_words} words). Skipping expansion.")
|
||||
treatment = None
|
||||
else:
|
||||
utils.log("WRITER", f" -> Expanding beats to Director's Treatment...")
|
||||
treatment = expand_beats_to_treatment(beats_list, pov_char, genre, folder)
|
||||
treatment_block = f"\n DIRECTORS_TREATMENT (Staged expansion of the beats — use this as your scene blueprint; DRAMATIZE every moment, do NOT summarize):\n{treatment}\n" if treatment else ""
|
||||
|
||||
genre_mandates = get_genre_instructions(genre)
|
||||
|
||||
series_meta = bp.get('series_metadata', {})
|
||||
series_block = ""
|
||||
if series_meta.get('is_series'):
|
||||
series_title = series_meta.get('series_title', 'this series')
|
||||
book_num = series_meta.get('book_number', '?')
|
||||
total_books = series_meta.get('total_books', '?')
|
||||
series_block = (
|
||||
f"\n - SERIES_CONTEXT: This is Book {book_num} of {total_books} in the '{series_title}' series. "
|
||||
f"Pace character arcs and emotional resolution to reflect this book's position in the series: "
|
||||
f"{'establish foundations, plant seeds, avoid premature resolution of series-level stakes' if str(book_num) == '1' else 'escalate the overarching conflict, deepen character arcs, end on a compelling hook that carries into the next book' if str(book_num) != str(total_books) else 'resolve all major character arcs and series-level conflicts with earned, satisfying payoffs'}."
|
||||
)
|
||||
|
||||
total_chapters = ls.get('chapters', '?')
|
||||
prompt = f"""
|
||||
ROLE: Fiction Writer
|
||||
TASK: Write Chapter {chap['chapter_number']}: {chap['title']}
|
||||
|
||||
METADATA:
|
||||
- GENRE: {genre}
|
||||
- FORMAT: {ls.get('label', 'Story')}
|
||||
- POSITION: Chapter {chap['chapter_number']} of {total_chapters} — calibrate narrative tension accordingly (early = setup/intrigue, middle = escalation, final third = payoff/climax)
|
||||
- PACING: {pacing} — see PACING_GUIDE below
|
||||
- TARGET_WORDS: ~{est_words} (write to this length; do not summarise to save space)
|
||||
- POV: {pov_char if pov_char else 'Protagonist'}{series_block}
|
||||
|
||||
PACING_GUIDE:
|
||||
- 'Very Fast': Pure action/dialogue. Minimal description. Short punchy paragraphs.
|
||||
- 'Fast': Keep momentum. No lingering. Cut to the next beat quickly.
|
||||
- 'Standard': Balanced dialogue and description. Standard paragraph lengths.
|
||||
- 'Slow': Detailed, atmospheric. Linger on emotion and environment.
|
||||
- 'Very Slow': Deep introspection. Heavy sensory immersion. Slow burn tension.
|
||||
|
||||
STYLE_GUIDE:
|
||||
{style_block}
|
||||
|
||||
AUTHOR_VOICE:
|
||||
{persona_info}
|
||||
|
||||
{genre_mandates}
|
||||
|
||||
DEEP_POV_MANDATE (NON-NEGOTIABLE):
|
||||
- SUMMARY MODE IS BANNED. Every scene beat must be DRAMATIZED in real-time. Do NOT write "Over the next hour they discussed..." — write the actual exchange.
|
||||
- FILTER WORDS ARE BANNED: Do NOT write "She felt nervous," "He saw the door," "She realized she was late," "He noticed the knife." Instead, render the sensation directly: the reader must experience it, not be told about it.
|
||||
- BANNED FILTER WORDS: felt, saw, heard, realized, decided, noticed, knew, thought, wondered, seemed, appeared, watched, observed, sensed — remove all instances and rewrite to show the underlying experience.
|
||||
- EMOTION RENDERING: Never label an emotion. "She was terrified" → show the dry mouth, the locked knees, the way her vision narrowed to a single point. "He was angry" → show the jaw tightening, the controlled breath, the clipped syllables.
|
||||
- DEEP POV means: the reader is inside the POV character's skull at all times. The prose must feel like consciousness, not narration about a character.
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style.
|
||||
|
||||
- SENSORY ANCHORING: Start scenes by establishing Who, Where, and When immediately.
|
||||
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state. (See DEEP_POV_MANDATE above.)
|
||||
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. NEVER summarize feelings; show the physical manifestation of them.
|
||||
- CAUSALITY: Ensure events follow a "Because of X, Y happened" logic, not just "And then X, and then Y".
|
||||
- STAGING: When characters enter, describe their entrance. Don't let them just "appear" in dialogue.
|
||||
- SENSORY DETAILS: Use specific sensory details sparingly to ground the scene. Avoid stacking adjectives (e.g. "crisp white blouses, sharp legal briefs").
|
||||
- ACTIVE VOICE: Use active voice. Subject -> Verb -> Object. Avoid "was/were" constructions.
|
||||
- STRONG VERBS: Delete adverbs. Use specific verbs (e.g. "trudged" instead of "walked slowly").
|
||||
- NO INFO-DUMPS: Weave backstory into dialogue or action. Do not stop the story to explain history.
|
||||
- AVOID AI-ISMS: Banned phrases — 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to', 'tapestry of', 'azure', 'cerulean', 'delved', 'mined', 'bustling', 'neon-lit', 'a sense of', 'symphony of', 'the weight of'. Any of these appearing is an automatic quality failure.
|
||||
- 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.
|
||||
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
|
||||
- GENRE CONSISTENCY: Ensure all introductions of characters, places, items, or actions are strictly appropriate for the {genre} genre. Avoid anachronisms or tonal clashes.
|
||||
- DIALOGUE VOICE: Every character speaks with their own distinct voice (see CHARACTER TRACKING for speech styles). No two characters may sound the same. Vary sentence length, vocabulary, and register per character.
|
||||
- CHAPTER HOOK: End this chapter with unresolved tension — a decision pending, a threat imminent, or a question unanswered.{f" Seed subtle anticipation for the next scene: '{next_chapter_hint}'." if next_chapter_hint else " Do not neatly resolve all threads."}
|
||||
|
||||
QUALITY_CRITERIA:
|
||||
1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene.
|
||||
2. SCENE EXECUTION: Flesh out the middle. Avoid summarizing key moments.
|
||||
3. VOICE & TONE: Distinct narrative voice matching the genre.
|
||||
4. SENSORY IMMERSION: Engage all five senses.
|
||||
5. SHOW, DON'T TELL: Show emotions through physical reactions and subtext.
|
||||
6. CHARACTER AGENCY: Characters must drive the plot through active choices.
|
||||
7. PACING: Avoid rushing. Ensure the ending lands with impact.
|
||||
8. GENRE APPROPRIATENESS: Introductions of characters, places, items, or actions must be consistent with {genre} conventions.
|
||||
9. DIALOGUE AUTHENTICITY: Characters must sound distinct. Use subtext. Avoid "on-the-nose" dialogue.
|
||||
10. PLOT RELEVANCE: Every scene must advance the plot or character arcs. No filler.
|
||||
11. STAGING & FLOW: Characters must enter and exit physically. Paragraphs must transition logically.
|
||||
12. PROSE DYNAMICS: Vary sentence length. Use strong verbs. Avoid passive voice.
|
||||
13. CLARITY: Ensure sentences are clear and readable. Avoid convoluted phrasing.
|
||||
|
||||
CONTEXT:
|
||||
- STORY_SO_FAR: {prev_sum}
|
||||
{prev_context_block}
|
||||
- CHARACTERS: {json.dumps(chars_for_writer)}
|
||||
{char_visuals}
|
||||
{lore_block}
|
||||
- SCENE_BEATS: {json.dumps(chap['beats'])}
|
||||
{treatment_block}
|
||||
|
||||
OUTPUT: Markdown text.
|
||||
"""
|
||||
current_text = ""
|
||||
try:
|
||||
resp_draft = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_draft.usage_metadata)
|
||||
current_text = resp_draft.text
|
||||
draft_words = len(current_text.split()) if current_text else 0
|
||||
utils.log("WRITER", f" -> Draft: {draft_words:,} words (target: ~{est_words})")
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||
|
||||
# Exp 7: Two-Pass Drafting — Polish rough draft with the logic (Pro) model before evaluation.
|
||||
# Skip when local filter-word heuristic shows draft is already clean (saves ~8K tokens/chapter).
|
||||
_guidelines_for_polish = get_style_guidelines()
|
||||
_fw_set = set(_guidelines_for_polish['filter_words'])
|
||||
_draft_word_list = current_text.lower().split() if current_text else []
|
||||
_fw_hit_count = sum(1 for w in _draft_word_list if w in _fw_set)
|
||||
_fw_density = _fw_hit_count / max(len(_draft_word_list), 1)
|
||||
_skip_polish = _fw_density < 0.008 # < ~1 filter word per 125 words → draft already clean
|
||||
|
||||
if current_text and not _skip_polish:
|
||||
utils.log("WRITER", f" -> Two-pass polish (Pro model, FW density {_fw_density:.3f})...")
|
||||
fw_list = '", "'.join(_guidelines_for_polish['filter_words'])
|
||||
polish_prompt = f"""
|
||||
ROLE: Senior Fiction Editor
|
||||
TASK: Polish this rough draft into publication-ready prose.
|
||||
|
||||
AUTHOR_VOICE:
|
||||
{persona_info}
|
||||
|
||||
GENRE: {genre}
|
||||
TARGET_WORDS: ~{est_words}
|
||||
BEATS (must all be covered): {json.dumps(chap.get('beats', []))}
|
||||
|
||||
CONTINUITY (maintain seamless flow from previous chapter):
|
||||
{prev_context_block if prev_context_block else "First chapter — no prior context."}
|
||||
|
||||
POLISH_CHECKLIST:
|
||||
1. FILTER_REMOVAL: Remove all filter words [{fw_list}] — rewrite each to show the sensation directly.
|
||||
2. DEEP_POV: Ensure the reader is inside the POV character's experience at all times — no external narration.
|
||||
3. ACTIVE_VOICE: Replace all 'was/were + -ing' constructions with active alternatives.
|
||||
4. SENTENCE_VARIETY: No two consecutive sentences starting with the same word. Vary length for rhythm.
|
||||
5. STRONG_VERBS: Delete adverbs; replace with precise verbs.
|
||||
6. NO_AI_ISMS: Remove: 'testament to', 'tapestry', 'palpable tension', 'azure', 'cerulean', 'bustling', 'a sense of'.
|
||||
7. CHAPTER_HOOK: Ensure the final paragraph ends on unresolved tension, a question, or a threat.
|
||||
8. PRESERVE: Keep all narrative beats, approximate word count (±15%), and chapter header.
|
||||
|
||||
ROUGH_DRAFT:
|
||||
{current_text}
|
||||
|
||||
OUTPUT: Complete polished chapter in Markdown.
|
||||
"""
|
||||
try:
|
||||
resp_polish = ai_models.model_logic.generate_content(polish_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp_polish.usage_metadata)
|
||||
polished = resp_polish.text
|
||||
if polished:
|
||||
polished_words = len(polished.split())
|
||||
utils.log("WRITER", f" -> Polished: {polished_words:,} words.")
|
||||
current_text = polished
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f" -> Polish pass failed: {e}. Proceeding with raw draft.")
|
||||
elif current_text:
|
||||
utils.log("WRITER", f" -> Draft clean (FW density {_fw_density:.3f}). Skipping polish pass.")
|
||||
|
||||
# Adaptive attempts: climax/resolution chapters (position >= 0.75) get 3 passes;
|
||||
# earlier chapters keep 2 (polish pass already refines prose before evaluation).
|
||||
if chapter_position is not None and chapter_position >= 0.75:
|
||||
max_attempts = 3
|
||||
else:
|
||||
max_attempts = 2
|
||||
SCORE_AUTO_ACCEPT = 8
|
||||
# Adaptive passing threshold: lenient for early setup chapters, strict for climax/resolution.
|
||||
# chapter_position=0.0 → setup (SCORE_PASSING=6.5), chapter_position=1.0 → climax (7.5)
|
||||
if chapter_position is not None:
|
||||
SCORE_PASSING = round(6.5 + chapter_position * 1.0, 1)
|
||||
utils.log("WRITER", f" -> Adaptive threshold: SCORE_PASSING={SCORE_PASSING} (position={chapter_position:.2f})")
|
||||
else:
|
||||
SCORE_PASSING = 7
|
||||
SCORE_REWRITE_THRESHOLD = 6
|
||||
|
||||
# Evaluation log entry — written to eval_log.json for the HTML report.
|
||||
_eval_entry = {
|
||||
"ts": time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"chapter_num": chap['chapter_number'],
|
||||
"title": chap.get('title', ''),
|
||||
"pov_character": chap.get('pov_character', ''),
|
||||
"pacing": pacing,
|
||||
"target_words": est_words,
|
||||
"actual_words": draft_words,
|
||||
"chapter_position": chapter_position,
|
||||
"score_threshold": SCORE_PASSING,
|
||||
"score_auto_accept": SCORE_AUTO_ACCEPT,
|
||||
"polish_applied": bool(current_text and not _skip_polish),
|
||||
"filter_word_density": round(_fw_density, 4),
|
||||
"attempts": [],
|
||||
"final_score": 0,
|
||||
"final_decision": "unknown",
|
||||
}
|
||||
|
||||
best_score = 0
|
||||
best_text = current_text
|
||||
past_critiques = []
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
|
||||
score, critique = evaluate_chapter_quality(current_text, chap['title'], meta.get('genre', 'Fiction'), ai_models.model_logic, folder, series_context=series_block.strip())
|
||||
|
||||
past_critiques.append(f"Attempt {attempt}: {critique}")
|
||||
_att = {"n": attempt, "score": score, "critique": critique[:700], "decision": None}
|
||||
|
||||
if "Evaluation error" in critique:
|
||||
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
|
||||
if best_score == 0: best_text = current_text
|
||||
_att["decision"] = "eval_error"
|
||||
_eval_entry["attempts"].append(_att)
|
||||
_eval_entry["final_score"] = best_score
|
||||
_eval_entry["final_decision"] = "eval_error"
|
||||
eval_logger.append_eval_entry(folder, _eval_entry)
|
||||
break
|
||||
|
||||
utils.log("WRITER", f" Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score >= SCORE_AUTO_ACCEPT:
|
||||
utils.log("WRITER", " 🌟 Auto-Accept threshold met.")
|
||||
_att["decision"] = "auto_accepted"
|
||||
_eval_entry["attempts"].append(_att)
|
||||
_eval_entry["final_score"] = score
|
||||
_eval_entry["final_decision"] = "auto_accepted"
|
||||
eval_logger.append_eval_entry(folder, _eval_entry)
|
||||
return current_text
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_text = current_text
|
||||
|
||||
if attempt == max_attempts:
|
||||
if best_score >= SCORE_PASSING:
|
||||
utils.log("WRITER", f" ✅ Max attempts reached. Accepting best score ({best_score}).")
|
||||
_att["decision"] = "accepted"
|
||||
_eval_entry["attempts"].append(_att)
|
||||
_eval_entry["final_score"] = best_score
|
||||
_eval_entry["final_decision"] = "accepted"
|
||||
eval_logger.append_eval_entry(folder, _eval_entry)
|
||||
return best_text
|
||||
else:
|
||||
utils.log("WRITER", f" ⚠️ Quality low ({best_score}/{SCORE_PASSING}) but max attempts reached. Proceeding.")
|
||||
_att["decision"] = "below_threshold"
|
||||
_eval_entry["attempts"].append(_att)
|
||||
_eval_entry["final_score"] = best_score
|
||||
_eval_entry["final_decision"] = "below_threshold"
|
||||
eval_logger.append_eval_entry(folder, _eval_entry)
|
||||
return best_text
|
||||
|
||||
if score < SCORE_REWRITE_THRESHOLD:
|
||||
utils.log("WRITER", f" -> Score {score} < {SCORE_REWRITE_THRESHOLD}. Triggering FULL REWRITE (Fresh Draft)...")
|
||||
|
||||
full_rewrite_prompt = prompt + f"""
|
||||
|
||||
[SYSTEM ALERT: QUALITY CHECK FAILED]
|
||||
The previous draft was rejected.
|
||||
CRITIQUE: {critique}
|
||||
|
||||
NEW TASK: Discard the previous attempt. Write a FRESH version of the chapter that addresses the critique above.
|
||||
"""
|
||||
|
||||
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)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
|
||||
current_text = resp_rewrite.text
|
||||
ai_models.model_logic.update(ai_models.logic_model_name)
|
||||
_att["decision"] = "full_rewrite"
|
||||
_eval_entry["attempts"].append(_att)
|
||||
continue
|
||||
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.")
|
||||
_att["decision"] = "full_rewrite_failed"
|
||||
# fall through to refinement; decision will be overwritten below
|
||||
|
||||
else:
|
||||
_att["decision"] = "refinement"
|
||||
|
||||
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[-3:-1]) if len(past_critiques) > 1 else "None"
|
||||
|
||||
refine_prompt = f"""
|
||||
ROLE: Automated Editor
|
||||
TASK: Rewrite the draft chapter to address the critique. Preserve the narrative content and approximate word count.
|
||||
|
||||
CURRENT_CRITIQUE:
|
||||
{critique}
|
||||
|
||||
PREVIOUS_ATTEMPTS (context only):
|
||||
{history_str}
|
||||
|
||||
HARD_CONSTRAINTS:
|
||||
- TARGET_WORDS: ~{est_words} words (aim for this; ±20% is acceptable if the scene genuinely demands it — but do not condense beats to save space)
|
||||
- BEATS MUST BE COVERED: {json.dumps(chap.get('beats', []))}
|
||||
- SUMMARY CONTEXT: {utils.truncate_to_tokens(prev_sum, 600)}
|
||||
|
||||
AUTHOR_VOICE:
|
||||
{persona_info}
|
||||
|
||||
STYLE:
|
||||
{style_block}
|
||||
{char_visuals}
|
||||
|
||||
PROSE_RULES (fix each one found in the draft):
|
||||
1. FILTER_REMOVAL: Remove filter words [{fw_list}] — rewrite to show the sensation directly.
|
||||
2. VARIETY: No two consecutive sentences starting with the same word or pronoun.
|
||||
3. SUBTEXT: Dialogue must imply meaning — not state it outright.
|
||||
4. TONE: Match {meta.get('genre', 'Fiction')} conventions throughout.
|
||||
5. ENVIRONMENT: Characters interact with their physical space.
|
||||
6. NO_SUMMARY_MODE: Dramatise key moments — do not skip or summarise them.
|
||||
7. ACTIVE_VOICE: Replace 'was/were + verb-ing' constructions with active alternatives.
|
||||
8. SHOWING: Render emotion through physical reactions, not labels.
|
||||
9. STAGING: Characters must enter and exit physically — no teleporting.
|
||||
10. CLARITY: Prefer simple sentence structures over convoluted ones.
|
||||
|
||||
DRAFT_TO_REWRITE:
|
||||
{current_text}
|
||||
|
||||
PREVIOUS_CHAPTER_ENDING (maintain continuity):
|
||||
{prev_context_block}
|
||||
|
||||
OUTPUT: Complete polished chapter in Markdown. Include the chapter header. Same approximate length as the draft.
|
||||
"""
|
||||
try:
|
||||
resp_refine = ai_models.model_writer.generate_content(refine_prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_refine.usage_metadata)
|
||||
current_text = resp_refine.text
|
||||
if _att["decision"] == "full_rewrite_failed":
|
||||
_att["decision"] = "refinement" # rewrite failed, fell back to refinement
|
||||
_eval_entry["attempts"].append(_att)
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Refinement failed: {e}")
|
||||
_att["decision"] = "refinement_failed"
|
||||
_eval_entry["attempts"].append(_att)
|
||||
_eval_entry["final_score"] = best_score
|
||||
_eval_entry["final_decision"] = "refinement_failed"
|
||||
eval_logger.append_eval_entry(folder, _eval_entry)
|
||||
return best_text
|
||||
|
||||
# Reached only if eval_error break occurred; write log before returning.
|
||||
if _eval_entry["final_decision"] == "unknown":
|
||||
_eval_entry["final_score"] = best_score
|
||||
_eval_entry["final_decision"] = "best_available"
|
||||
eval_logger.append_eval_entry(folder, _eval_entry)
|
||||
return best_text
|
||||
@@ -7,8 +7,8 @@
|
||||
<p class="text-muted">System management and user administration.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
<a href="{{ url_for('admin.admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<td>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
|
||||
<a href="{{ url_for('impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||
<a href="{{ url_for('admin.impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
|
||||
@@ -61,6 +61,16 @@
|
||||
|
||||
<!-- System Stats & Reset -->
|
||||
<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.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-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p class="text-muted">Aggregate cost analysis per user.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
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.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('admin.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 %}
|
||||
@@ -28,12 +28,12 @@
|
||||
{% if session.get('original_admin_id') %}
|
||||
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
|
||||
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
|
||||
<a href="{{ url_for('stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||
<a href="{{ url_for('admin.stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/"><i class="fas fa-book-open me-2"></i>BookApp AI</a>
|
||||
<a class="navbar-brand" href="/"><i class="fas fa-book-open me-2"></i>BookApp AI <small class="text-muted fs-6 ms-1">v{{ app_version }}</small></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
@@ -81,6 +81,13 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Initialize Bootstrap Tooltips globally
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
})
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
333
templates/bible_comparison.html
Normal file
333
templates/bible_comparison.html
Normal file
@@ -0,0 +1,333 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.diff-changed { background-color: #fff3cd; transition: background 0.5s; }
|
||||
.diff-added { background-color: #d1e7dd; transition: background 0.5s; }
|
||||
.diff-removed { background-color: #f8d7da; text-decoration: line-through; opacity: 0.7; transition: background 0.5s; }
|
||||
.diff-moved { background-color: #cff4fc; transition: background 0.5s; }
|
||||
.select-checkbox { transform: scale(1.2); cursor: pointer; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-balance-scale me-2"></i>Review Changes</h2>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>Review the AI's proposed changes below. You can accept them, discard them, or ask for further refinements on the <strong>New Draft</strong>.
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="card shadow-sm mb-4 sticky-top" style="top: 20px; z-index: 100;">
|
||||
<div class="card-body bg-light">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<form id="refineForm" onsubmit="submitRefine(event); return false;" action="javascript:void(0);" class="d-flex">
|
||||
<input type="hidden" name="source" value="draft">
|
||||
<input type="hidden" name="selected_keys" id="refineSelectedKeys">
|
||||
<input type="text" name="instruction" class="form-control me-2" placeholder="Refine this draft further (e.g. 'Fix the name spelling')..." required>
|
||||
<button type="submit" id="btnRefine" class="btn btn-warning text-nowrap"><i class="fas fa-magic me-1"></i> Refine Draft</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<form action="/project/{{ project.id }}/refine_bible/confirm" method="POST" class="d-inline">
|
||||
<input type="hidden" name="selected_keys" id="confirmSelectedKeys">
|
||||
<div class="form-check form-switch d-inline-block me-3 align-middle">
|
||||
<input class="form-check-input" type="checkbox" id="syncScroll" checked>
|
||||
<label class="form-check-label small" for="syncScroll">Sync Scroll</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary me-2" id="btnSelectAll" style="display:none;"><i class="fas fa-check-square me-1"></i> Select All</button>
|
||||
<button type="submit" name="action" value="decline" class="btn btn-outline-danger me-2"><i class="fas fa-times me-1"></i> Discard</button>
|
||||
<button type="submit" name="action" value="accept_selected" class="btn btn-outline-success me-2" id="btnAcceptSelected" disabled><i class="fas fa-check-double me-1"></i> Accept Selected</button>
|
||||
<button type="submit" name="action" value="accept" class="btn btn-success"><i class="fas fa-check me-1"></i> Accept Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro render_bible(bible) %}
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted text-uppercase small fw-bold border-bottom pb-1">Metadata</h6>
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-sm-4">Title</dt><dd class="col-sm-8"><input type="checkbox" class="select-checkbox me-2 d-none" value="meta.title"><span data-diff-key="meta.title">{{ bible.project_metadata.title }}</span></dd>
|
||||
<dt class="col-sm-4">Genre</dt><dd class="col-sm-8"><input type="checkbox" class="select-checkbox me-2 d-none" value="meta.genre"><span data-diff-key="meta.genre">{{ bible.project_metadata.genre }}</span></dd>
|
||||
<dt class="col-sm-4">Tone</dt><dd class="col-sm-8"><input type="checkbox" class="select-checkbox me-2 d-none" value="meta.tone"><span data-diff-key="meta.tone">{{ bible.project_metadata.style.tone }}</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted text-uppercase small fw-bold border-bottom pb-1">Characters ({{ bible.characters|length }})</h6>
|
||||
<ul class="list-unstyled small">
|
||||
{% for c in bible.characters %}
|
||||
<li class="mb-2" data-diff-key="char.{{ loop.index0 }}" data-stable-id="char:{{ c.name|e }}">
|
||||
<input type="checkbox" class="select-checkbox me-2 d-none" value="char.{{ loop.index0 }}">
|
||||
<strong data-diff-key="char.{{ loop.index0 }}.name">{{ c.name }}</strong> <span class="badge bg-light text-dark border" data-diff-key="char.{{ loop.index0 }}.role">{{ c.role }}</span><br>
|
||||
<span class="text-muted ms-4" data-diff-key="char.{{ loop.index0 }}.desc">{{ c.description }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted text-uppercase small fw-bold border-bottom pb-1">Plot Structure</h6>
|
||||
{% for book in bible.books %}
|
||||
<div class="mb-2" data-diff-key="book.{{ book.book_number }}" data-stable-id="book:{{ book.title|e }}">
|
||||
<input type="checkbox" class="select-checkbox me-2 d-none" value="book.{{ book.book_number }}">
|
||||
<strong data-diff-key="book.{{ book.book_number }}.title">Book {{ book.book_number }}: {{ book.title }}</strong>
|
||||
<p class="fst-italic small text-muted mb-1 ms-4" data-diff-key="book.{{ book.book_number }}.instr">{{ book.manual_instruction }}</p>
|
||||
<ol class="small ps-3 mb-0">
|
||||
{% for beat in book.plot_beats %}
|
||||
<li><input type="checkbox" class="select-checkbox me-2 d-none" value="book.{{ book.book_number }}.beat.{{ loop.index0 }}"><span data-diff-key="book.{{ book.book_number }}.beat.{{ loop.index0 }}" data-stable-id="beat:{{ beat|e }}">{{ beat }}</span></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="row">
|
||||
<!-- ORIGINAL -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0">Original</h5>
|
||||
</div>
|
||||
<div class="card-body bg-light" id="original-col" style="max-height: 800px; overflow-y: auto;">
|
||||
{{ render_bible(original) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEW DRAFT -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-success mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">New Draft</h5>
|
||||
</div>
|
||||
<div class="card-body bg-white" id="new-col" style="max-height: 800px; overflow-y: auto;">
|
||||
{{ render_bible(new) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let refinePollInterval = null;
|
||||
|
||||
function showLoading(form) {
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refining...';
|
||||
}
|
||||
|
||||
function submitRefine(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
showLoading(form);
|
||||
|
||||
const instruction = form.querySelector('input[name="instruction"]').value;
|
||||
const source = form.querySelector('input[name="source"]').value;
|
||||
const selectedKeys = form.querySelector('input[name="selected_keys"]').value;
|
||||
|
||||
fetch(`/project/{{ project.id }}/refine_bible`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ instruction: instruction, source: source, selected_keys: selectedKeys })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.task_id) {
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="refineProgressModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-body text-center p-4">
|
||||
<div class="spinner-border text-warning mb-3" style="width: 3rem; height: 3rem;"></div>
|
||||
<h4>Refining Draft...</h4><p class="text-muted">The AI is processing your changes.</p>
|
||||
</div></div></div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('refineProgressModal'));
|
||||
modal.show();
|
||||
|
||||
refinePollInterval = setInterval(() => {
|
||||
fetch(`/task_status/${data.task_id}`)
|
||||
.then(r => r.json())
|
||||
.then(status => {
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(refinePollInterval);
|
||||
if (status.success === false) {
|
||||
alert("Refinement failed: " + (status.error || "Check logs"));
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(e));
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert("Request failed: " + err);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magic me-1"></i> Refine Draft';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const original = document.getElementById('original-col');
|
||||
const newDraft = document.getElementById('new-col');
|
||||
const confirmInput = document.getElementById('confirmSelectedKeys');
|
||||
const refineInput = document.getElementById('refineSelectedKeys');
|
||||
const btnAcceptSelected = document.getElementById('btnAcceptSelected');
|
||||
const btnRefine = document.getElementById('btnRefine');
|
||||
const btnSelectAll = document.getElementById('btnSelectAll');
|
||||
|
||||
function findCounterpart(el, contextRoot) {
|
||||
const key = el.getAttribute('data-diff-key');
|
||||
const stableParent = el.closest('[data-stable-id]');
|
||||
if (stableParent) {
|
||||
const stableId = stableParent.getAttribute('data-stable-id');
|
||||
const otherParent = contextRoot.querySelector(`[data-stable-id="${stableId.replace(/"/g, '\\"')}"]`);
|
||||
if (otherParent) {
|
||||
const parentKey = stableParent.getAttribute('data-diff-key');
|
||||
if (key.startsWith(parentKey)) {
|
||||
const suffix = key.substring(parentKey.length);
|
||||
const otherParentKey = otherParent.getAttribute('data-diff-key');
|
||||
return contextRoot.querySelector(`[data-diff-key="${otherParentKey + suffix}"]`);
|
||||
}
|
||||
return otherParent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return contextRoot.querySelector(`[data-diff-key="${key}"]`);
|
||||
}
|
||||
|
||||
// 1. Highlight Differences
|
||||
const newElements = newDraft.querySelectorAll('[data-diff-key]');
|
||||
newElements.forEach(el => {
|
||||
const key = el.getAttribute('data-diff-key');
|
||||
const origEl = findCounterpart(el, original);
|
||||
|
||||
// Find associated checkbox (it might be a sibling or parent wrapper)
|
||||
// In our macro, checkbox is usually a sibling of the span with data-diff-key
|
||||
let checkbox = el.parentElement.querySelector(`input[value="${key}"]`);
|
||||
if (!checkbox) {
|
||||
// Try finding it in the parent li/div if the key matches the container
|
||||
// Also handle nested keys: char.0.name -> char.0
|
||||
let parentKey = key;
|
||||
if (key.startsWith('char.') && key.split('.').length > 2) {
|
||||
parentKey = key.split('.').slice(0, 2).join('.');
|
||||
} else if (key.startsWith('book.') && key.split('.').length === 3 && key.split('.')[2] !== 'beat') {
|
||||
// book.1.title -> book.1 (but book.1.beat.0 stays book.1.beat.0)
|
||||
parentKey = key.split('.').slice(0, 2).join('.');
|
||||
}
|
||||
|
||||
const container = el.closest('li, div');
|
||||
checkbox = container ? container.querySelector(`input[value="${parentKey}"]`) : null;
|
||||
}
|
||||
|
||||
if (!origEl) {
|
||||
el.classList.add('diff-added');
|
||||
el.title = "New item added";
|
||||
if (checkbox) {
|
||||
checkbox.classList.remove('d-none');
|
||||
checkbox.checked = true;
|
||||
}
|
||||
} else if (el.getAttribute('data-diff-key') !== origEl.getAttribute('data-diff-key')) {
|
||||
// Moved (Index changed but content matched by ID)
|
||||
el.classList.add('diff-moved');
|
||||
el.title = "Moved from original position";
|
||||
if (checkbox) {
|
||||
checkbox.classList.remove('d-none');
|
||||
checkbox.checked = true;
|
||||
}
|
||||
} else if (el.innerText.trim() !== origEl.innerText.trim()) {
|
||||
el.classList.add('diff-changed');
|
||||
origEl.classList.add('diff-changed');
|
||||
el.title = "Changed from original";
|
||||
if (checkbox) {
|
||||
checkbox.classList.remove('d-none');
|
||||
checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check for removed items
|
||||
const origElements = original.querySelectorAll('[data-diff-key]');
|
||||
origElements.forEach(el => {
|
||||
const newEl = findCounterpart(el, newDraft);
|
||||
if (!newEl) {
|
||||
el.classList.add('diff-removed');
|
||||
el.title = "Removed in new draft";
|
||||
}
|
||||
});
|
||||
|
||||
// Show Select All if there are visible checkboxes
|
||||
if (newDraft.querySelector('.select-checkbox:not(.d-none)')) {
|
||||
btnSelectAll.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function updateSelectionState() {
|
||||
const checkboxes = newDraft.querySelectorAll('.select-checkbox:checked');
|
||||
const keys = Array.from(checkboxes).map(cb => cb.value);
|
||||
const jsonKeys = JSON.stringify(keys);
|
||||
|
||||
confirmInput.value = jsonKeys;
|
||||
refineInput.value = jsonKeys;
|
||||
|
||||
const count = keys.length;
|
||||
|
||||
// Update Accept Button
|
||||
btnAcceptSelected.disabled = count === 0;
|
||||
btnAcceptSelected.innerHTML = count > 0 ?
|
||||
`<i class="fas fa-check-double me-1"></i> Accept ${count} Selected` :
|
||||
`<i class="fas fa-check-double me-1"></i> Accept Selected`;
|
||||
|
||||
// Update Refine Button
|
||||
btnRefine.innerHTML = count > 0 ?
|
||||
`<i class="fas fa-magic me-1"></i> Refine ${count} Selected` :
|
||||
`<i class="fas fa-magic me-1"></i> Refine Draft`;
|
||||
}
|
||||
|
||||
// 2. Handle Checkbox Selection
|
||||
newDraft.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('select-checkbox')) {
|
||||
updateSelectionState();
|
||||
}
|
||||
});
|
||||
|
||||
btnSelectAll.addEventListener('click', function() {
|
||||
const checkboxes = newDraft.querySelectorAll('.select-checkbox:not(.d-none)');
|
||||
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||||
checkboxes.forEach(cb => cb.checked = !allChecked);
|
||||
updateSelectionState();
|
||||
});
|
||||
|
||||
// Initialize state with default selections
|
||||
updateSelectionState();
|
||||
|
||||
// 3. Sync Scroll
|
||||
let isSyncingLeft = false;
|
||||
let isSyncingRight = false;
|
||||
|
||||
original.onscroll = function() {
|
||||
if (!isSyncingLeft && document.getElementById('syncScroll').checked) {
|
||||
isSyncingRight = true;
|
||||
newDraft.scrollTop = this.scrollTop;
|
||||
}
|
||||
isSyncingLeft = false;
|
||||
};
|
||||
|
||||
newDraft.onscroll = function() {
|
||||
if (!isSyncingRight && document.getElementById('syncScroll').checked) {
|
||||
isSyncingLeft = true;
|
||||
original.scrollTop = this.scrollTop;
|
||||
}
|
||||
isSyncingRight = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
43
templates/consistency_report.html
Normal file
43
templates/consistency_report.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% 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-search me-2"></i>Consistency Report</h2>
|
||||
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-{{ 'success' if report.score >= 8 else 'warning' if report.score >= 5 else 'danger' }} text-white">
|
||||
<h4 class="mb-0">Consistency Score: {{ report.score }}/10</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="lead">{{ report.summary }}</p>
|
||||
<hr>
|
||||
<h5 class="text-danger"><i class="fas fa-exclamation-circle me-2"></i>Issues Detected</h5>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for issue in report.issues %}
|
||||
<li class="list-group-item">
|
||||
<i class="fas fa-bug text-danger me-2"></i> {{ issue }}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item text-success">No major issues found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-light">
|
||||
<small class="text-muted mb-3 d-block">Tip: Use the "Read & Edit" feature to fix issues manually, or use the form below to queue a full AI book revision.</small>
|
||||
<form action="{{ url_for('run.revise_book', run_id=run.id, book_folder=book_folder) }}" method="POST" onsubmit="return confirm('This will start a new run to regenerate this book with your instruction applied. Continue?');">
|
||||
<div class="input-group">
|
||||
<input type="text" name="instruction" class="form-control" placeholder="e.g. Fix the timeline contradictions in the middle chapters" required>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="fas fa-sync-alt me-2"></i>Redo Book
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -13,10 +13,15 @@
|
||||
<a href="/system/status" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-server me-2"></i>System Status
|
||||
</a>
|
||||
<button class="btn btn-outline-info me-2" onclick="optimizeModels()">
|
||||
{% if current_user.is_admin %}
|
||||
<button class="btn btn-outline-info me-2" onclick="optimizeModels()" data-bs-toggle="tooltip" title="Check API limits and select the best AI models for Logic, Writing, and Art.">
|
||||
<i class="fas fa-sync me-2"></i>Find New Models
|
||||
</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newProjectModal">
|
||||
{% endif %}
|
||||
<button class="btn btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#importProjectModal" data-bs-toggle="tooltip" title="Upload a bible.json file to restore a project.">
|
||||
<i class="fas fa-file-upload me-2"></i>Import Bible
|
||||
</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newProjectModal" data-bs-toggle="tooltip" title="Start the Wizard to create a new book series from scratch.">
|
||||
<i class="fas fa-plus me-2"></i>New Project
|
||||
</button>
|
||||
</div>
|
||||
@@ -29,13 +34,47 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ p.name }}</h5>
|
||||
<p class="card-text text-muted small">Created: {{ p.created_at.strftime('%Y-%m-%d') }}</p>
|
||||
<a href="/project/{{ p.id }}" class="btn btn-outline-primary stretched-link">Open Project</a>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<a href="/project/{{ p.id }}" class="btn btn-outline-primary">Open Project</a>
|
||||
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal{{ p.id }}" title="Delete project">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal for {{ p.name }} -->
|
||||
<div class="modal fade" id="deleteModal{{ p.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ p.id }}/delete" method="POST">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title"><i class="fas fa-exclamation-triangle me-2"></i>Delete Project</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Permanently delete <strong>{{ p.name }}</strong> and all its runs and generated files?</p>
|
||||
<p class="text-danger fw-bold mb-0">This cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-center py-5">
|
||||
<h4 class="text-muted">No projects yet. Start writing!</h4>
|
||||
<h4 class="text-muted mb-3">No projects yet. Start writing!</h4>
|
||||
<div class="alert alert-info d-inline-block text-start" style="max-width: 600px;">
|
||||
<h5><i class="fas fa-info-circle me-2"></i>How to use BookApp:</h5>
|
||||
<ol class="mb-0">
|
||||
<li>Click <strong>New Project</strong> to launch the AI Wizard.</li>
|
||||
<li>Describe your idea, and the AI will plan your characters and plot.</li>
|
||||
<li>Review the "Bible" (the plan), then click <strong>Generate</strong>.</li>
|
||||
<li>Read the book, edit it, and export to EPUB/Kindle.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -62,6 +101,29 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Project Modal -->
|
||||
<div class="modal fade" id="importProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/import" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import Existing Bible</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted">Upload a <code>bible.json</code> file to create a new project from it.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bible JSON File</label>
|
||||
<input type="file" name="bible_file" class="form-control" accept=".json" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Import Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('save_persona') }}" method="POST">
|
||||
<form action="{{ url_for('persona.save_persona') }}" method="POST">
|
||||
<input type="hidden" name="old_name" value="{{ name }}">
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<a href="{{ url_for('persona.list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Persona</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
|
||||
<a href="{{ url_for('new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||
<a href="{{ url_for('persona.new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -16,8 +16,8 @@
|
||||
<p class="card-text small">{{ p.bio[:150] }}...</p>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
|
||||
<a href="{{ url_for('edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<form action="{{ url_for('delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||
<a href="{{ url_for('persona.edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<form action="{{ url_for('persona.delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,17 @@
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<h1 class="mb-0 me-3">{{ project.name }}</h1>
|
||||
{% if not locked and not is_refining %}
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editProjectModal"><i class="fas fa-edit"></i></button>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-outline-info ms-2" data-bs-toggle="modal" data-bs-target="#cloneProjectModal" title="Clone/Fork Project" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
</button>
|
||||
{% if not locked %}
|
||||
<button class="btn btn-sm btn-outline-danger ms-2" data-bs-toggle="modal" data-bs-target="#deleteProjectModal" title="Delete Project">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-secondary">{{ bible.project_metadata.genre }}</span>
|
||||
@@ -14,23 +24,65 @@
|
||||
</div>
|
||||
<div>
|
||||
<form action="/project/{{ project.id }}/run" method="POST" class="d-inline">
|
||||
<button class="btn btn-success shadow px-4 py-2" {% if active_run and active_run.status in ['running', 'queued'] %}disabled{% endif %}>
|
||||
<i class="fas fa-play me-2"></i>{{ 'Generating...' if runs and runs[0].status in ['running', 'queued'] else 'Generate New Book' }}
|
||||
<button class="btn btn-success shadow px-4 py-2" {% if active_runs %}disabled{% endif %} data-bs-toggle="tooltip" title="Start the AI writer. It will write the next book in the plan.">
|
||||
<i class="fas fa-play me-2"></i>{{ 'Generating...' if active_runs else 'Generate New Book' }}
|
||||
</button>
|
||||
</form>
|
||||
{% if runs and runs[0].status in ['running', 'queued'] %}
|
||||
<form action="/run/{{ runs[0].id }}/stop" method="POST" class="d-inline ms-2">
|
||||
<button class="btn btn-danger shadow px-3 py-2" title="Stop/Cancel Run" onclick="return confirm('Are you sure you want to stop this job? If the server restarted, this will simply unlock the UI.')">
|
||||
<i class="fas fa-stop"></i>
|
||||
{% for ar in active_runs %}
|
||||
<form action="/run/{{ ar.id }}/stop" method="POST" class="d-inline ms-2">
|
||||
<button class="btn btn-danger shadow px-3 py-2" title="Stop Run #{{ ar.id }}" onclick="return confirm('Stop Run #{{ ar.id }}? If the server restarted, this will simply unlock the UI.')">
|
||||
<i class="fas fa-stop me-1"></i>#{{ ar.id }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workflow Help -->
|
||||
<div class="alert alert-light border shadow-sm mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-info-circle text-primary fa-2x me-3"></i>
|
||||
<div>
|
||||
<strong>Workflow:</strong>
|
||||
1. Review the <a href="#bible-section">World Bible</a> below.
|
||||
2. Click <span class="badge bg-success">Generate New Book</span>.
|
||||
3. When finished, <a href="#latest-run">Download</a> the files or click <span class="badge bg-primary">Read & Edit</span> to refine the text.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIVE JOBS CARD — shows all currently running/queued jobs -->
|
||||
{% if active_runs %}
|
||||
<div class="card mb-4 border-0 shadow-sm border-start border-warning border-4">
|
||||
<div class="card-header bg-warning bg-opacity-10 border-0 pt-3 px-4 pb-2">
|
||||
<h5 class="mb-0"><i class="fas fa-spinner fa-spin text-warning me-2"></i>Active Jobs ({{ active_runs|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for ar in active_runs %}
|
||||
<div class="list-group-item d-flex align-items-center px-4 py-3">
|
||||
<span class="badge bg-{{ 'warning text-dark' if ar.status == 'queued' else 'primary' }} me-3">{{ ar.status|upper }}</span>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Run #{{ ar.id }}</strong>
|
||||
<span class="text-muted ms-2 small">Started: {{ ar.start_time.strftime('%Y-%m-%d %H:%M') if ar.start_time else 'Pending' }}</span>
|
||||
{% if ar.progress %}
|
||||
<div class="progress mt-1" style="height: 6px; max-width: 200px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ ar.progress }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ url_for('run.view_run', id=ar.id) }}" class="btn btn-sm btn-outline-primary me-2">
|
||||
<i class="fas fa-eye me-1"></i>View Details
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- LATEST RUN CARD -->
|
||||
<div class="card mb-4 border-0 shadow-sm">
|
||||
<div class="card mb-4 border-0 shadow-sm" id="latest-run">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 px-4">
|
||||
<h4 class="card-title"><i class="fas fa-bolt text-warning me-2"></i>Active Run (ID: {{ active_run.id if active_run else '-' }})</h4>
|
||||
</div>
|
||||
@@ -66,7 +118,7 @@
|
||||
<i class="fas fa-download me-1"></i> Download {{ file.type }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<button class="btn btn-outline-dark" data-bs-toggle="modal" data-bs-target="#regenerateModal">
|
||||
<button class="btn btn-outline-dark" data-bs-toggle="modal" data-bs-target="#regenerateModal" data-bs-toggle="tooltip" title="Re-create the cover art or re-compile the EPUB without rewriting the text.">
|
||||
<i class="fas fa-paint-brush me-1"></i> Regenerate Cover / Files
|
||||
</button>
|
||||
</div>
|
||||
@@ -90,10 +142,13 @@
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="spinner-border text-primary spinner-border-sm me-2" role="status"></div>
|
||||
<strong class="text-primary" id="statusPhase">Initializing...</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto py-0" onclick="fetchLog()" title="Manually refresh status">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<h5 class="card-title mb-3" id="statusMessage">Preparing environment...</h5>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 100%"></div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" id="statusTime"></small>
|
||||
</div>
|
||||
@@ -147,8 +202,8 @@
|
||||
</td>
|
||||
<td>${{ "%.4f"|format(r.cost) }}</td>
|
||||
<td>
|
||||
<a href="/project/{{ project.id }}/run/{{ r.id }}" class="btn btn-sm btn-outline-primary">
|
||||
{{ 'View Active' if active_run and r.id == active_run.id else 'View' }}
|
||||
<a href="{{ url_for('run.view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
{{ 'View Active' if active_run and r.id == active_run.id and active_run.status in ['running', 'queued'] else 'View' }}
|
||||
</a>
|
||||
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}
|
||||
<form action="/run/{{ r.id }}/restart" method="POST" class="d-inline ms-1">
|
||||
@@ -160,7 +215,7 @@
|
||||
{% endif %}
|
||||
{% if r.status not in ['running', 'queued'] %}
|
||||
<form action="/run/{{ r.id }}/restart" method="POST" class="d-inline ms-1" onsubmit="return confirm('This will delete all files for this run and start over. Are you sure?');">
|
||||
<input type="hidden" name="mode" value="restart">
|
||||
<input type="hidden" name="mode" value="restart_clean">
|
||||
<button class="btn btn-sm btn-outline-danger" title="Re-run (Wipe & Restart)">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
@@ -197,7 +252,16 @@
|
||||
<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>
|
||||
{% 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 %}
|
||||
<span class="badge bg-secondary">Planned</span>
|
||||
{% endif %}
|
||||
@@ -208,14 +272,16 @@
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editBookModal{{ book.book_number }}" title="Edit Details">
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editBookModal{{ book.book_number }}" title="Edit Details" {% if is_refining %}disabled{% endif %}>
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</button>
|
||||
{% if not locked %}
|
||||
{% if not generated_books.get(book.book_number) %}
|
||||
<form action="/project/{{ project.id }}/delete_book/{{ book.book_number }}" method="POST" onsubmit="return confirm('Remove this book from the plan?');">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" {% if is_refining %}disabled{% endif %}><i class="fas fa-trash"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,16 +297,18 @@
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" name="title" class="form-control" value="{{ book.title }}">
|
||||
<input type="text" name="title" class="form-control" value="{{ book.title }}" {% if locked %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Plot Summary / Instruction</label>
|
||||
<textarea name="instruction" class="form-control" rows="6">{{ book.manual_instruction }}</textarea>
|
||||
<textarea name="instruction" class="form-control" rows="6" {% if locked %}disabled{% endif %}>{{ book.manual_instruction }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
{% if not locked %}
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -249,6 +317,7 @@
|
||||
{% endfor %}
|
||||
|
||||
<!-- Add Book Card -->
|
||||
{% if not locked and not is_refining %}
|
||||
<div class="col-md-4 col-lg-3" style="min-width: 200px;">
|
||||
<div class="card h-100 border-dashed d-flex align-items-center justify-content-center bg-light" style="border: 2px dashed #ccc; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#addBookModal">
|
||||
<div class="text-center text-muted py-5">
|
||||
@@ -257,28 +326,61 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WORLD BIBLE & LINKED SERIES -->
|
||||
<div class="row mb-4">
|
||||
<div class="row mb-4" id="bible-section">
|
||||
<div class="col-md-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-globe me-2"></i>World Bible & Characters</h5>
|
||||
<div>
|
||||
{% if is_refining %}
|
||||
<span class="badge bg-warning text-dark me-2">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span> Refining...
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<a href="/project/{{ project.id }}/review" class="btn btn-sm btn-outline-info me-1">
|
||||
<i class="fas fa-list-alt me-1"></i> Full Review
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importCharModal">
|
||||
{% if not locked and not is_refining %}
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importCharModal" data-bs-toggle="tooltip" title="Import characters from another project to create a shared universe.">
|
||||
<i class="fas fa-link me-1"></i> Link / Import Series
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary ms-1" data-bs-toggle="modal" data-bs-target="#refineBibleModal">
|
||||
|
||||
{% if has_draft %}
|
||||
<a href="/project/{{ project.id }}/bible_comparison" class="btn btn-sm btn-warning ms-1 fw-bold">
|
||||
<i class="fas fa-balance-scale me-1"></i> Review Draft
|
||||
</a>
|
||||
{% elif is_refining %}
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" disabled>
|
||||
<i class="fas fa-magic me-1"></i> Refining...
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-outline-primary ms-1" data-bs-toggle="modal" data-bs-target="#refineBibleModal" data-bs-toggle="tooltip" title="Use AI to bulk-edit characters or plot points based on your instructions.">
|
||||
<i class="fas fa-magic me-1"></i> Refine
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if has_draft %}
|
||||
<div class="alert alert-warning shadow-sm mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
<strong>Draft Pending:</strong> You have an unreviewed Bible refinement waiting.
|
||||
</div>
|
||||
<a href="/project/{{ project.id }}/bible_comparison" class="btn btn-warning btn-sm fw-bold">Review Changes</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 border-end">
|
||||
<h6 class="text-muted text-uppercase small fw-bold mb-3">Project Metadata</h6>
|
||||
@@ -328,6 +430,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone Project Modal -->
|
||||
<div class="modal fade" id="cloneProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/clone" method="POST" onsubmit="showLoading(this)">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clone & Modify Project</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Create a new project based on this one, with AI modifications.</p>
|
||||
<div class="mb-3"><label class="form-label">New Project Name</label><input type="text" name="new_name" class="form-control" value="{{ project.name }} (Copy)" required></div>
|
||||
<div class="mb-3"><label class="form-label">AI Instructions (Optional)</label><textarea name="instruction" class="form-control" rows="3" placeholder="e.g. 'Change the genre to Sci-Fi', 'Make the protagonist a villain', 'Rewrite as a comedy'."></textarea></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-info">Clone Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Book Modal -->
|
||||
<div class="modal fade" id="addBookModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@@ -408,7 +531,7 @@
|
||||
<!-- Refine Bible Modal -->
|
||||
<div class="modal fade" id="refineBibleModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/refine_bible" method="POST">
|
||||
<form class="modal-content" onsubmit="submitRefineModal(event); return false;" action="javascript:void(0);">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Refine Bible with AI</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -428,6 +551,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Project Modal -->
|
||||
<div class="modal fade" id="deleteProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/delete" method="POST">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title"><i class="fas fa-exclamation-triangle me-2"></i>Delete Project</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This will permanently delete <strong>{{ project.name }}</strong> and all its runs, files, and generated books.</p>
|
||||
<p class="text-danger fw-bold">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Bible JSON Modal -->
|
||||
<div class="modal fade" id="fullBibleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
@@ -450,6 +593,7 @@
|
||||
let activeInterval = null;
|
||||
// Only auto-poll if we have a latest run
|
||||
let currentRunId = {{ active_run.id if active_run else 'null' }};
|
||||
const initialRunStatus = "{{ active_run.status if active_run else '' }}";
|
||||
|
||||
function fetchLog() {
|
||||
if (!currentRunId) return;
|
||||
@@ -472,6 +616,24 @@
|
||||
const costSpan = document.querySelector(`.cost-${currentRunId}`);
|
||||
if (costSpan) costSpan.innerText = parseFloat(data.cost).toFixed(4);
|
||||
|
||||
// Update Progress Bar Width
|
||||
const progBar = document.getElementById('progressBar');
|
||||
if (progBar && data.percent !== undefined) {
|
||||
progBar.style.width = data.percent + "%";
|
||||
|
||||
let label = data.percent + "%";
|
||||
if (data.status === 'running' && data.percent > 2 && data.start_time) {
|
||||
const elapsed = (Date.now() / 1000) - data.start_time;
|
||||
if (elapsed > 5) {
|
||||
const remaining = (elapsed / (data.percent / 100)) - elapsed;
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = Math.floor(remaining % 60);
|
||||
if (remaining > 0 && remaining < 86400) label += ` (~${m}m ${s}s)`;
|
||||
}
|
||||
}
|
||||
progBar.innerText = label;
|
||||
}
|
||||
|
||||
// Update Status Bar
|
||||
if (data.progress && data.progress.message) {
|
||||
const phaseEl = document.getElementById('statusPhase');
|
||||
@@ -500,16 +662,107 @@
|
||||
} else {
|
||||
if (activeInterval) clearInterval(activeInterval);
|
||||
activeInterval = null;
|
||||
// Reload page on completion to show download buttons
|
||||
if (data.status === 'completed' && !document.querySelector('.alert-success')) {
|
||||
|
||||
// Reload if we were polling (watched it finish) OR if page loaded as running but is now done
|
||||
if (initialRunStatus === 'running' || initialRunStatus === 'queued') {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Polling failed:", err);
|
||||
// Resume polling so the UI doesn't silently stop updating
|
||||
if (!activeInterval) activeInterval = setInterval(fetchLog, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
{% if active_run %}
|
||||
fetchLog();
|
||||
{% endif %}
|
||||
|
||||
function showRefiningModal() {
|
||||
if (!document.getElementById('refineProgressModal')) {
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="refineProgressModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-body text-center p-4">
|
||||
<div class="spinner-border text-warning mb-3" style="width: 3rem; height: 3rem;"></div>
|
||||
<h4>Refining Bible...</h4><p class="text-muted">The AI is processing your changes.</p>
|
||||
</div></div></div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
}
|
||||
const modal = new bootstrap.Modal(document.getElementById('refineProgressModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function submitRefineModal(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Queueing...';
|
||||
|
||||
const instruction = form.querySelector('textarea[name="instruction"]').value;
|
||||
|
||||
fetch(`/project/{{ project.id }}/refine_bible`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ instruction: instruction, source: 'original' })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.task_id) {
|
||||
const inputModalEl = document.getElementById('refineBibleModal');
|
||||
const inputModal = bootstrap.Modal.getInstance(inputModalEl);
|
||||
inputModal.hide();
|
||||
|
||||
showRefiningModal();
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
fetch(`/task_status/${data.task_id}`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error("Server error checking status");
|
||||
return r.json();
|
||||
})
|
||||
.then(status => {
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
if (status.success) {
|
||||
window.location.href = "/project/{{ project.id }}/bible_comparison";
|
||||
} else {
|
||||
alert("Refinement failed: " + (status.error || "Check logs for details."));
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Polling error:", err);
|
||||
}
|
||||
);
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert("Error: " + err);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
{% if is_refining %}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
showRefiningModal();
|
||||
const pollInterval = setInterval(() => {
|
||||
fetch("/project/{{ project.id }}/is_refining").then(r => r.json()).then(data => {
|
||||
if (!data.is_refining) {
|
||||
clearInterval(pollInterval);
|
||||
window.location.href = "/project/{{ project.id }}/bible_comparison";
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -12,7 +12,7 @@
|
||||
<p class="text-muted">The AI has generated your characters and plot structure. Review them below and refine if needed.</p>
|
||||
|
||||
<!-- Refinement Bar -->
|
||||
<form action="/project/{{ project.id }}/refine_bible" method="POST" class="mb-4" onsubmit="showLoading(this)">
|
||||
<form id="refineForm" onsubmit="submitRefine(event); return false;" action="javascript:void(0);" class="mb-4">
|
||||
<div class="input-group shadow-sm">
|
||||
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
||||
<input type="text" name="instruction" class="form-control" placeholder="AI Instruction: e.g. 'Change the ending of Book 1', 'Add a plot point about the ring', 'Make the tone darker'" required>
|
||||
@@ -88,5 +88,46 @@ function showLoading(form) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refining...';
|
||||
}
|
||||
|
||||
function submitRefine(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
showLoading(form);
|
||||
|
||||
const instruction = form.querySelector('input[name="instruction"]').value;
|
||||
|
||||
fetch(`/project/{{ project.id }}/refine_bible`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ instruction: instruction, source: 'original' })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.task_id) {
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="refineProgressModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-body text-center p-4">
|
||||
<div class="spinner-border text-warning mb-3" style="width: 3rem; height: 3rem;"></div>
|
||||
<h4>Refining Bible...</h4><p class="text-muted">The AI is processing your changes.</p>
|
||||
</div></div></div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('refineProgressModal'));
|
||||
modal.show();
|
||||
|
||||
setInterval(() => {
|
||||
fetch(`/task_status/${data.task_id}`).then(r => r.json()).then(status => {
|
||||
if (status.status === 'completed') window.location.href = `/project/{{ project.id }}/bible_comparison`;
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert("Request failed: " + err);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Refine with AI';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<!-- Refinement Bar -->
|
||||
<div class="input-group mb-4 shadow-sm">
|
||||
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
||||
<span class="input-group-text bg-warning text-dark" data-bs-toggle="tooltip" title="Ask AI to change the suggestions"><i class="fas fa-magic"></i></span>
|
||||
<input type="text" name="refine_instruction" class="form-control" placeholder="AI Instruction: e.g. 'Make it a trilogy', 'Change genre to Cyberpunk', 'Make the tone darker'">
|
||||
<button type="submit" formaction="/project/setup/refine" class="btn btn-warning">Refine with AI</button>
|
||||
</div>
|
||||
@@ -106,14 +106,14 @@
|
||||
<!-- Style -->
|
||||
<h5 class="text-primary mb-3">Style & Tone</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Tone</label><input type="text" name="tone" class="form-control" value="{{ s.tone }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Tone</label><input type="text" name="tone" class="form-control" value="{{ s.tone }}" data-bs-toggle="tooltip" title="e.g. Dark, Whimsical, Cynical, Hopeful"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">POV Style</label><input type="text" name="pov_style" class="form-control" value="{{ s.pov_style }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Time Period</label><input type="text" name="time_period" class="form-control" value="{{ s.time_period }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Spice Level</label><input type="text" name="spice" class="form-control" value="{{ s.spice }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Violence</label><input type="text" name="violence" class="form-control" value="{{ s.violence }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Spice Level</label><input type="text" name="spice" class="form-control" value="{{ s.spice }}" data-bs-toggle="tooltip" title="e.g. Clean, Fade-to-Black, Explicit"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Violence</label><input type="text" name="violence" class="form-control" value="{{ s.violence }}" data-bs-toggle="tooltip" title="e.g. None, Mild, Graphic"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Narrative Tense</label><input type="text" name="narrative_tense" class="form-control" value="{{ s.narrative_tense }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Language Style</label><input type="text" name="language_style" class="form-control" value="{{ s.language_style }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Dialogue Style</label><input type="text" name="dialogue_style" class="form-control" value="{{ s.dialogue_style }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Dialogue Style</label><input type="text" name="dialogue_style" class="form-control" value="{{ s.dialogue_style }}" data-bs-toggle="tooltip" title="e.g. Witty, Formal, Slang-heavy"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Page Orientation</label>
|
||||
<select name="page_orientation" class="form-select"><option value="Portrait" {% if s.page_orientation == 'Portrait' %}selected{% endif %}>Portrait</option><option value="Landscape" {% if s.page_orientation == 'Landscape' %}selected{% endif %}>Landscape</option><option value="Square" {% if s.page_orientation == 'Square' %}selected{% endif %}>Square</option></select>
|
||||
</div>
|
||||
@@ -121,12 +121,12 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Tropes (comma separated)</label>
|
||||
<input type="text" name="tropes" class="form-control" value="{{ s.tropes|join(', ') }}">
|
||||
<input type="text" name="tropes" class="form-control" value="{{ (s.tropes or [])|join(', ') }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Formatting Rules (comma separated)</label>
|
||||
<input type="text" name="formatting_rules" class="form-control" value="{{ s.formatting_rules|join(', ') }}">
|
||||
<input type="text" name="formatting_rules" class="form-control" value="{{ (s.formatting_rules or [])|join(', ') }}">
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
|
||||
228
templates/read_book.html
Normal file
228
templates/read_book.html
Normal file
@@ -0,0 +1,228 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 sticky-top bg-white py-3 border-bottom" style="z-index: 100;">
|
||||
<div>
|
||||
<h3 class="mb-0"><i class="fas fa-book-reader me-2"></i>{{ book_folder }}</h3>
|
||||
<small class="text-muted">Run #{{ run.id }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<form action="{{ url_for('run.sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
|
||||
<button type="submit" class="btn btn-outline-info" data-bs-toggle="tooltip" title="Scans your manual edits to update the character database and author writing style. Use this after making significant edits.">
|
||||
<i class="fas fa-sync me-2"></i>Sync Metadata
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
{% for ch in manuscript %}
|
||||
<div class="card shadow-sm mb-5" id="ch-{{ ch.num }}">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Chapter {{ ch.num }}: {{ ch.title }}</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-warning me-1" data-bs-toggle="modal" data-bs-target="#rewriteModal{{ ch.num|string|replace(' ', '') }}" data-bs-toggle="tooltip" title="Ask AI to rewrite this chapter based on new instructions.">
|
||||
<i class="fas fa-magic"></i> Rewrite
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleEdit('{{ ch.num }}')">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Mode -->
|
||||
<div class="card-body chapter-content" id="view-{{ ch.num }}">
|
||||
<div class="prose" style="font-family: 'Georgia', serif; font-size: 1.1rem; line-height: 1.6; color: #333;">
|
||||
{{ ch.html_content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<div class="card-body d-none" id="edit-{{ ch.num }}">
|
||||
<textarea class="form-control font-monospace" id="text-{{ ch.num }}" rows="20">{{ ch.content }}</textarea>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button class="btn btn-secondary me-2" onclick="toggleEdit('{{ ch.num }}')">Cancel</button>
|
||||
<button class="btn btn-success" onclick="saveChapter('{{ ch.num }}')">Save Changes</button>
|
||||
</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 -->
|
||||
<div class="modal fade" id="rewriteModal{{ ch.num|string|replace(' ', '') }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Rewrite Chapter {{ ch.num }} with AI</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Instructions</label>
|
||||
<textarea name="instruction" class="form-control" rows="4" placeholder="e.g. 'Change the setting to a train station', 'Make the protagonist refuse the offer', 'Fix the pacing in the middle section'." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-warning" onclick="startRewrite('{{ ch.num }}')">Rewrite Chapter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Table of Contents Sidebar -->
|
||||
<div class="col-lg-3 d-none d-lg-block">
|
||||
<div class="sticky-top" style="top: 100px;">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">Table of Contents</div>
|
||||
<div class="list-group list-group-flush" style="max-height: 70vh; overflow-y: auto;">
|
||||
{% for ch in manuscript %}
|
||||
<a href="#ch-{{ ch.num }}" class="list-group-item list-group-item-action small">
|
||||
{{ ch.num }}. {{ ch.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Modal -->
|
||||
<div class="modal fade" id="progressModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body text-center p-4">
|
||||
<div class="spinner-border text-primary mb-3" style="width: 3rem; height: 3rem;"></div>
|
||||
<h4>Processing AI Request...</h4>
|
||||
<p class="text-muted mb-0">This may take a few minutes, especially if subsequent chapters need updates. Please wait.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let rewritePollInterval = null;
|
||||
|
||||
function toggleEdit(num) {
|
||||
const viewDiv = document.getElementById(`view-${num}`);
|
||||
const editDiv = document.getElementById(`edit-${num}`);
|
||||
|
||||
if (editDiv.classList.contains('d-none')) {
|
||||
editDiv.classList.remove('d-none');
|
||||
viewDiv.classList.add('d-none');
|
||||
} else {
|
||||
editDiv.classList.add('d-none');
|
||||
viewDiv.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function startRewrite(num) {
|
||||
const modal = document.getElementById(`rewriteModal${String(num).replace(' ', '')}`);
|
||||
const instruction = modal.querySelector('textarea[name="instruction"]').value;
|
||||
|
||||
if (!instruction) {
|
||||
alert("Please provide an instruction for the AI.");
|
||||
return;
|
||||
}
|
||||
|
||||
const modalInstance = bootstrap.Modal.getInstance(modal);
|
||||
modalInstance.hide();
|
||||
|
||||
const progressModal = new bootstrap.Modal(document.getElementById('progressModal'));
|
||||
progressModal.show();
|
||||
|
||||
const data = {
|
||||
book_folder: "{{ book_folder }}",
|
||||
chapter_num: num,
|
||||
instruction: instruction
|
||||
};
|
||||
|
||||
fetch(`/project/{{ run.id }}/rewrite_chapter`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error("Failed to start rewrite task.");
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.task_id) {
|
||||
rewritePollInterval = setInterval(() => pollRewriteStatus(data.task_id), 3000);
|
||||
} else {
|
||||
throw new Error("Did not receive task ID.");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
progressModal.hide();
|
||||
alert("Error: " + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function pollRewriteStatus(taskId) {
|
||||
fetch(`/task_status/${taskId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.status === 'completed') {
|
||||
clearInterval(rewritePollInterval);
|
||||
setTimeout(() => { window.location.reload(); }, 500);
|
||||
}
|
||||
})
|
||||
.catch(err => { clearInterval(rewritePollInterval); alert("Error checking status. Please reload manually."); });
|
||||
}
|
||||
|
||||
function saveChapter(num) {
|
||||
const content = document.getElementById(`text-${num}`).value;
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerText;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerText = "Saving...";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('book_folder', "{{ book_folder }}");
|
||||
formData.append('chapter_num', num);
|
||||
formData.append('content', content);
|
||||
|
||||
fetch(`/project/{{ run.id }}/save_chapter`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
alert("Chapter saved! Reloading to render changes...");
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Error saving chapter.");
|
||||
btn.disabled = false;
|
||||
btn.innerText = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,13 +4,27 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
|
||||
<p class="text-muted mb-0">Project: <a href="{{ url_for('view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||
<p class="text-muted mb-0">Project: <a href="{{ url_for('project.view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
</button>
|
||||
<a href="{{ url_for('view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
||||
<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').">
|
||||
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
|
||||
</button>
|
||||
{% 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>
|
||||
|
||||
@@ -94,68 +108,143 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="fw-bold" id="status-text">Status: {{ run.status|title }}</span>
|
||||
<span class="text-muted" id="run-duration">{{ run.duration() }}</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="status-bar" class="progress-bar {% if run.status == 'running' %}progress-bar-striped progress-bar-animated{% elif run.status == 'failed' %}bg-danger{% else %}bg-success{% endif %}"
|
||||
role="progressbar" style="width: {% if run.status == 'completed' %}100%{% elif run.status == 'running' %}100%{% else %}5%{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Left Column: Cover Art -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-image me-2"></i>Cover Art</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">
|
||||
<!-- 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 %}
|
||||
<div class="alert alert-secondary py-5">
|
||||
<i class="fas fa-image fa-3x mb-3"></i><br>
|
||||
No cover generated yet.
|
||||
</div>
|
||||
<span class="text-muted small fst-italic">No tags</span>
|
||||
{% 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 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 -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-bold" id="status-text">Status: {{ run.status|title }}</span>
|
||||
<div>
|
||||
<span class="text-muted me-2" id="run-duration">{{ run.duration() }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="updateLog()" title="Manually refresh status">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="status-bar" class="progress-bar {% if run.status == 'running' %}progress-bar-striped progress-bar-animated{% elif run.status == 'failed' %}bg-danger{% else %}bg-success{% endif %}"
|
||||
role="progressbar" style="width: {% if run.status == 'completed' %}100%{% else %}{{ run.progress }}%{% endif %}">
|
||||
{% if run.status == 'running' %}{{ run.progress }}%{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Blurb & Stats -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<!-- Blurb -->
|
||||
<!-- Generated Books in this Run -->
|
||||
{% for book in books %}
|
||||
<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>
|
||||
<h5 class="mb-0"><i class="fas fa-book me-2"></i>{{ book.folder }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if blurb %}
|
||||
<p class="card-text" style="white-space: pre-wrap;">{{ blurb }}</p>
|
||||
<div class="row">
|
||||
<!-- Left Column: Cover Art -->
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="text-center">
|
||||
{% if book.cover %}
|
||||
<img src="{{ url_for('run.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 %}
|
||||
<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('run.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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<!-- Right Column: Blurb -->
|
||||
<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('run.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 class="mt-3">
|
||||
<a href="{{ url_for('run.read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
|
||||
<i class="fas fa-book-reader me-2"></i>Read & Edit
|
||||
</a>
|
||||
<a href="{{ url_for('run.check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
|
||||
<i class="fas fa-search me-2"></i>Check Consistency
|
||||
</a>
|
||||
<a href="{{ url_for('run.eval_report', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-info ms-2" title="Download evaluation report (scores, critiques, prompt tuning notes)">
|
||||
<i class="fas fa-chart-bar me-2"></i>Eval Report
|
||||
</a>
|
||||
<button class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#reviseBookModal{{ loop.index }}" title="Regenerate this book with changes, keeping others.">
|
||||
<i class="fas fa-pencil-alt me-2"></i>Revise
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revise Book Modal -->
|
||||
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="{{ url_for('project.revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Revise Book</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">This will start a <strong>new run</strong>. All other books will be copied over, but this book will be regenerated based on your instructions.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Instructions</label>
|
||||
<textarea name="instruction" class="form-control" rows="4" placeholder="e.g. 'Change the ending', 'Make the pacing faster', 'Add a scene about X'." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Start Revision</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-6">
|
||||
<div class="card shadow-sm text-center">
|
||||
@@ -233,6 +322,25 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Live Status Panel -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||
<span class="small text-muted fw-semibold">Poll:</span>
|
||||
<span id="poll-state" class="badge bg-secondary">Initializing...</span>
|
||||
<span class="small text-muted">Last update:</span>
|
||||
<span id="last-update-time" class="small fw-bold text-info">—</span>
|
||||
<span id="db-diagnostics" class="small text-muted"></span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-info py-0 px-2" onclick="forceRefresh()" title="Immediately trigger a new poll request">
|
||||
<i class="fas fa-bolt me-1"></i>Force Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="poll-error-msg" class="small text-danger mt-1" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Log -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#logCollapse">
|
||||
@@ -248,48 +356,218 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modify Run Modal -->
|
||||
<div class="modal fade" id="modifyRunModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/run/{{ run.id }}/restart" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Modify & Re-run</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">This will create a <strong>new run</strong> based on this one. You can ask the AI to change the plot, style, or characters.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Instructions / Feedback</label>
|
||||
<textarea name="feedback" class="form-control" rows="4" placeholder="e.g. 'Make the ending happier', 'Change the setting to Mars', 'Rewrite Chapter 1 to be faster paced'." required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="keep_cover" id="keepCoverCheck" checked>
|
||||
<label class="form-check-label" for="keepCoverCheck">Keep existing cover art (if possible)</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="force_regenerate" id="forceRegenCheck">
|
||||
<label class="form-check-label" for="forceRegenCheck">Force Regenerate (Don't copy text from previous run)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Start New Run</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const runId = {{ run.id }};
|
||||
const initialStatus = "{{ run.status }}";
|
||||
const consoleEl = document.getElementById('console-log');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const costEl = document.getElementById('run-cost');
|
||||
|
||||
let lastLog = '';
|
||||
let pollTimer = null;
|
||||
let countdownInterval = null;
|
||||
|
||||
// Phase → colour mapping (matches utils.log phase labels)
|
||||
const PHASE_COLORS = {
|
||||
'WRITER': '#4fc3f7',
|
||||
'ARCHITECT': '#81c784',
|
||||
'TIMING': '#78909c',
|
||||
'SYSTEM': '#fff176',
|
||||
'TRACKER': '#ce93d8',
|
||||
'RESUME': '#ffb74d',
|
||||
'SERIES': '#64b5f6',
|
||||
'ENRICHER': '#4dd0e1',
|
||||
'HARVESTER': '#ff8a65',
|
||||
'EDITOR': '#f48fb1',
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function colorizeLog(logText) {
|
||||
if (!logText) return '';
|
||||
return logText.split('\n').map(line => {
|
||||
const m = line.match(/^(\[[\d:]+\])\s+(\w+)\s+\|(.*)$/);
|
||||
if (!m) return '<span style="color:#666">' + escapeHtml(line) + '</span>';
|
||||
const [, ts, phase, msg] = m;
|
||||
const color = PHASE_COLORS[phase] || '#aaaaaa';
|
||||
return '<span style="color:#555">' + escapeHtml(ts) + '</span> '
|
||||
+ '<span style="color:' + color + ';font-weight:bold">' + phase.padEnd(14) + '</span>'
|
||||
+ '<span style="color:#ccc">|' + escapeHtml(msg) + '</span>';
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function getCurrentPhase(logText) {
|
||||
if (!logText) return '';
|
||||
const lines = logText.split('\n').filter(l => l.trim());
|
||||
for (let k = lines.length - 1; k >= 0; k--) {
|
||||
const m = lines[k].match(/\]\s+(\w+)\s+\|/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// --- Live Status Panel helpers ---
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||
}
|
||||
|
||||
function setPollState(text, badgeClass) {
|
||||
const el = document.getElementById('poll-state');
|
||||
if (el) { el.className = 'badge ' + badgeClass; el.innerText = text; }
|
||||
}
|
||||
|
||||
function setPollError(msg) {
|
||||
const el = document.getElementById('poll-error-msg');
|
||||
if (!el) return;
|
||||
if (msg) { el.innerText = 'Last error: ' + msg; el.style.display = ''; }
|
||||
else { el.innerText = ''; el.style.display = 'none'; }
|
||||
}
|
||||
|
||||
function startWaitCountdown(seconds, isError) {
|
||||
clearCountdown();
|
||||
let rem = seconds;
|
||||
const cls = isError ? 'bg-danger' : 'bg-secondary';
|
||||
const prefix = isError ? 'Error — retry in' : 'Waiting';
|
||||
setPollState(prefix + ' (' + rem + 's)', cls);
|
||||
countdownInterval = setInterval(() => {
|
||||
rem--;
|
||||
if (rem <= 0) { clearCountdown(); }
|
||||
else { setPollState(prefix + ' (' + rem + 's)', cls); }
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function forceRefresh() {
|
||||
clearCountdown();
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
updateLog();
|
||||
}
|
||||
|
||||
// --- Main polling function ---
|
||||
|
||||
function updateLog() {
|
||||
setPollState('Requesting...', 'bg-primary');
|
||||
fetch(`/run/${runId}/status`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update Status Text
|
||||
statusText.innerText = "Status: " + data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
||||
// Update "Last Successful Update" timestamp
|
||||
const now = new Date();
|
||||
const lastUpdateEl = document.getElementById('last-update-time');
|
||||
if (lastUpdateEl) lastUpdateEl.innerText = now.toLocaleTimeString();
|
||||
|
||||
// Update DB diagnostics
|
||||
const diagEl = document.getElementById('db-diagnostics');
|
||||
if (diagEl) {
|
||||
const parts = [];
|
||||
if (data.db_log_count !== undefined) parts.push('DB logs: ' + data.db_log_count);
|
||||
if (data.latest_log_timestamp) parts.push('Latest: ' + String(data.latest_log_timestamp).substring(11, 19));
|
||||
diagEl.innerText = parts.join(' | ');
|
||||
}
|
||||
|
||||
// Clear any previous poll error
|
||||
setPollError(null);
|
||||
|
||||
// Update Status Text + current phase
|
||||
const statusLabel = data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
||||
if (data.status === 'running') {
|
||||
const phase = getCurrentPhase(data.log);
|
||||
statusText.innerText = 'Status: Running' + (phase ? ' — ' + phase : '');
|
||||
} else {
|
||||
statusText.innerText = 'Status: ' + statusLabel;
|
||||
}
|
||||
costEl.innerText = '$' + parseFloat(data.cost).toFixed(4);
|
||||
|
||||
// Update Status Bar
|
||||
if (data.status === 'running' || data.status === 'queued') {
|
||||
statusBar.className = "progress-bar progress-bar-striped progress-bar-animated";
|
||||
statusBar.style.width = "100%";
|
||||
statusBar.style.width = (data.percent || 5) + "%";
|
||||
|
||||
let label = (data.percent || 0) + "%";
|
||||
if (data.status === 'running' && data.percent > 2 && data.start_time) {
|
||||
const elapsed = (Date.now() / 1000) - data.start_time;
|
||||
if (elapsed > 5) {
|
||||
const remaining = (elapsed / (data.percent / 100)) - elapsed;
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = Math.floor(remaining % 60);
|
||||
if (remaining > 0 && remaining < 86400) label += ` (~${m}m ${s}s)`;
|
||||
}
|
||||
}
|
||||
statusBar.innerText = label;
|
||||
} else if (data.status === 'failed') {
|
||||
statusBar.className = "progress-bar bg-danger";
|
||||
statusBar.style.width = "100%";
|
||||
} else {
|
||||
statusBar.className = "progress-bar bg-success";
|
||||
statusBar.style.width = "100%";
|
||||
statusBar.innerText = "";
|
||||
}
|
||||
|
||||
// Update Log (only if changed to avoid scroll jitter)
|
||||
if (consoleEl.innerText !== data.log) {
|
||||
// Update Log with phase colorization (only if changed to avoid scroll jitter)
|
||||
if (lastLog !== data.log) {
|
||||
lastLog = data.log;
|
||||
const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50;
|
||||
consoleEl.innerText = data.log;
|
||||
consoleEl.innerHTML = colorizeLog(data.log);
|
||||
if (isScrolledToBottom) {
|
||||
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll if running
|
||||
// Schedule next poll or stop
|
||||
if (data.status === 'running' || data.status === 'queued') {
|
||||
setTimeout(updateLog, 2000);
|
||||
startWaitCountdown(2, false);
|
||||
pollTimer = setTimeout(updateLog, 2000);
|
||||
} else {
|
||||
setPollState('Idle', 'bg-success');
|
||||
// If the run was active when we loaded the page, reload to show artifacts
|
||||
if (initialStatus === 'running' || initialStatus === 'queued') {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
.catch(err => {
|
||||
console.error("Polling failed:", err);
|
||||
const errMsg = err.message || String(err);
|
||||
setPollError(errMsg);
|
||||
startWaitCountdown(5, true);
|
||||
pollTimer = setTimeout(updateLog, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Start polling
|
||||
|
||||
@@ -7,15 +7,27 @@
|
||||
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||
<form action="{{ url_for('optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-sync me-2"></i>Refresh & Optimize
|
||||
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||
<button id="styleBtn" class="btn btn-outline-info me-2" onclick="refreshStyleGuidelines()">
|
||||
<span id="styleIcon"><i class="fas fa-filter me-2"></i></span>
|
||||
<span id="styleSpinner" class="spinner-border spinner-border-sm me-2 d-none" role="status"></span>
|
||||
<span id="styleLabel">Refresh Style Rules</span>
|
||||
</button>
|
||||
<button id="refreshBtn" class="btn btn-primary" onclick="refreshModels()">
|
||||
<span id="refreshIcon"><i class="fas fa-sync me-2"></i></span>
|
||||
<span id="refreshSpinner" class="spinner-border spinner-border-sm me-2 d-none" role="status"></span>
|
||||
<span id="refreshLabel">Refresh & Optimize</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if cache.error %}
|
||||
<div class="alert alert-danger shadow-sm">
|
||||
<h5 class="alert-heading"><i class="fas fa-exclamation-triangle me-2"></i>Last Scan Error</h5>
|
||||
<p class="mb-0">{{ cache.error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-robot me-2"></i>AI Model Selection</h5>
|
||||
@@ -27,6 +39,7 @@
|
||||
<tr>
|
||||
<th style="width: 15%">Role</th>
|
||||
<th style="width: 25%">Selected Model</th>
|
||||
<th style="width: 15%">Est. Cost</th>
|
||||
<th>Selection Reasoning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -37,22 +50,33 @@
|
||||
<tr>
|
||||
<td class="fw-bold text-uppercase">{{ role }}</td>
|
||||
<td>
|
||||
{% if info is mapping %}
|
||||
<span class="badge bg-info text-dark">{{ info.model }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ info }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ info.estimated_cost }}</span>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{% if info is mapping %}
|
||||
{{ info.reason }}
|
||||
{% else %}
|
||||
<em>Legacy format. Please refresh models.</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="fw-bold text-uppercase">Image</td>
|
||||
<td>
|
||||
{% if image_model %}
|
||||
<span class="badge bg-success">{{ image_model }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Unavailable</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ image_source or 'None' }}</span>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{% if image_model %}Imagen model used for book cover generation.{% else %}No image generation model could be initialized. Check GCP credentials or Gemini API key.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted">
|
||||
@@ -79,6 +103,7 @@
|
||||
<tr>
|
||||
<th style="width: 10%">Rank</th>
|
||||
<th style="width: 30%">Model Name</th>
|
||||
<th style="width: 15%">Est. Cost</th>
|
||||
<th>Reasoning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -88,6 +113,7 @@
|
||||
<tr>
|
||||
<td class="fw-bold">{{ loop.index }}</td>
|
||||
<td><span class="badge bg-secondary">{{ item.model }}</span></td>
|
||||
<td><small>{{ item.estimated_cost }}</small></td>
|
||||
<td class="small text-muted">{{ item.reason }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -104,21 +130,141 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw API Output -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#rawOutput">
|
||||
<h5 class="mb-0"><i class="fas fa-terminal me-2"></i>Raw API Output</h5>
|
||||
<span class="badge bg-secondary">Click to Toggle</span>
|
||||
</div>
|
||||
<div id="rawOutput" class="collapse">
|
||||
<div class="card-body bg-dark text-light font-monospace">
|
||||
<p class="text-muted mb-2"># Full list of models returned by google.generativeai.list_models():</p>
|
||||
<ul class="list-unstyled mb-0" style="column-count: 2;">
|
||||
{% if cache.raw_models %}
|
||||
{% for m in cache.raw_models %}
|
||||
<li>
|
||||
<span class="{{ 'text-success' if 'gemini' in m else 'text-muted' }}">{{ m }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-muted">No raw data available. Run "Refresh & Optimize".</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Info -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-clock me-2"></i>Cache Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">
|
||||
<p class="mb-1">
|
||||
<strong>Last Scan:</strong>
|
||||
{% if cache and cache.timestamp %}
|
||||
{{ datetime.fromtimestamp(cache.timestamp).strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{{ datetime.fromtimestamp(cache.timestamp).strftime('%Y-%m-%d %H:%M:%S') }} UTC
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-muted small mb-0">Model selection is cached for 24 hours to save API calls.</p>
|
||||
<p class="mb-0">
|
||||
<strong>Next Refresh:</strong>
|
||||
{% if cache and cache.timestamp %}
|
||||
{% set expires = cache.timestamp + 86400 %}
|
||||
{% set now_ts = datetime.utcnow().timestamp() %}
|
||||
{% if expires > now_ts %}
|
||||
{% set remaining = (expires - now_ts) | int %}
|
||||
{% set h = remaining // 3600 %}{% set m = (remaining % 3600) // 60 %}
|
||||
in {{ h }}h {{ m }}m
|
||||
<span class="badge bg-success ms-1">Cache Valid</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Expired — click Refresh & Optimize</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">No cache — click Refresh & Optimize</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-muted small mt-2 mb-0">Model selection is cached for 24 hours to save API calls.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1100">
|
||||
<div id="refreshToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div id="toastBody" class="toast-body fw-semibold"></div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function refreshModels() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
const icon = document.getElementById('refreshIcon');
|
||||
const spinner = document.getElementById('refreshSpinner');
|
||||
const label = document.getElementById('refreshLabel');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.classList.add('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
label.textContent = 'Processing...';
|
||||
|
||||
try {
|
||||
const resp = await fetch("{{ url_for('admin.optimize_models') }}", {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
showToast(data.message, resp.ok ? 'bg-success text-white' : 'bg-danger text-white');
|
||||
if (resp.ok) {
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Request failed: ' + err.message, 'bg-danger text-white');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
icon.classList.remove('d-none');
|
||||
spinner.classList.add('d-none');
|
||||
label.textContent = 'Refresh & Optimize';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStyleGuidelines() {
|
||||
const btn = document.getElementById('styleBtn');
|
||||
const icon = document.getElementById('styleIcon');
|
||||
const spinner = document.getElementById('styleSpinner');
|
||||
const label = document.getElementById('styleLabel');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.classList.add('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
label.textContent = 'Updating...';
|
||||
|
||||
try {
|
||||
const resp = await fetch("{{ url_for('admin.refresh_style_guidelines_route') }}", {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
showToast(data.message, resp.ok ? 'bg-success text-white' : 'bg-danger text-white');
|
||||
} catch (err) {
|
||||
showToast('Request failed: ' + err.message, 'bg-danger text-white');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
icon.classList.remove('d-none');
|
||||
spinner.classList.add('d-none');
|
||||
label.textContent = 'Refresh Style Rules';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, classes) {
|
||||
const toast = document.getElementById('refreshToast');
|
||||
const body = document.getElementById('toastBody');
|
||||
toast.className = 'toast align-items-center border-0 ' + classes;
|
||||
body.textContent = message;
|
||||
bootstrap.Toast.getOrCreateInstance(toast, { delay: 4000 }).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
229
web/app.py
Normal file
229
web/app.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from werkzeug.security import generate_password_hash
|
||||
from web.db import db, User, Run
|
||||
from web.tasks import huey
|
||||
from core import config
|
||||
|
||||
# Ensure stdout is UTF-8 in all environments (Docker, Windows, Raspberry Pi)
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _log(msg):
|
||||
"""Print to stdout with flush so Docker logs capture it immediately."""
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
# Calculate paths relative to this file (web/app.py -> project root is two levels up)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
|
||||
|
||||
app = Flask(__name__, template_folder=TEMPLATE_DIR)
|
||||
app.url_map.strict_slashes = False
|
||||
app.config['SECRET_KEY'] = config.FLASK_SECRET
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.init_app(app)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return dict(app_version=config.VERSION)
|
||||
|
||||
|
||||
# Register Blueprints
|
||||
from web.routes.auth import auth_bp
|
||||
from web.routes.project import project_bp
|
||||
from web.routes.run import run_bp
|
||||
from web.routes.persona import persona_bp
|
||||
from web.routes.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(project_bp)
|
||||
app.register_blueprint(run_bp)
|
||||
app.register_blueprint(persona_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
|
||||
# --- STARTUP DIAGNOSTIC BANNER ---
|
||||
_log("=" * 60)
|
||||
_log(f"BookApp v{config.VERSION} starting up")
|
||||
_log(f" Python : {sys.version}")
|
||||
_log(f" Platform : {platform.platform()}")
|
||||
_log(f" Data dir : {config.DATA_DIR}")
|
||||
_log(f" Queue db : {os.path.join(config.DATA_DIR, 'queue.db')}")
|
||||
_log(f" App db : {os.path.join(config.DATA_DIR, 'bookapp.db')}")
|
||||
try:
|
||||
import huey as _huey_pkg
|
||||
_log(f" Huey : {_huey_pkg.__version__}")
|
||||
except Exception:
|
||||
_log(" Huey : (version unknown)")
|
||||
_log("=" * 60)
|
||||
|
||||
|
||||
# --- SETUP ---
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Auto-create Admin from Environment Variables (Docker/Portainer Setup)
|
||||
if config.ADMIN_USER and config.ADMIN_PASSWORD:
|
||||
admin = User.query.filter_by(username=config.ADMIN_USER).first()
|
||||
if not admin:
|
||||
_log(f"System: Creating Admin User '{config.ADMIN_USER}' from environment variables.")
|
||||
admin = User(username=config.ADMIN_USER, password=generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256'), is_admin=True)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
else:
|
||||
_log(f"System: Syncing Admin User '{config.ADMIN_USER}' settings from environment.")
|
||||
if not admin.is_admin: admin.is_admin = True
|
||||
admin.password = generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
elif not User.query.filter_by(is_admin=True).first():
|
||||
_log("System: No Admin credentials found in environment variables. Admin account not created.")
|
||||
|
||||
# Migration: Add 'progress' column if missing
|
||||
try:
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE run ADD COLUMN progress INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
_log("System: Added 'progress' column to Run table.")
|
||||
except: pass
|
||||
|
||||
# Migration: Add 'last_heartbeat' column if missing
|
||||
try:
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE run ADD COLUMN last_heartbeat DATETIME"))
|
||||
conn.commit()
|
||||
_log("System: Added 'last_heartbeat' column to Run table.")
|
||||
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)
|
||||
# The Huey consumer restarts with the app, so any in-flight tasks are gone.
|
||||
try:
|
||||
_NON_TERMINAL = ['running', 'queued', 'interrupted']
|
||||
non_terminal = Run.query.filter(Run.status.in_(_NON_TERMINAL)).all()
|
||||
if non_terminal:
|
||||
_log(f"System: Resetting {len(non_terminal)} non-terminal run(s) to 'failed' on startup:")
|
||||
for r in non_terminal:
|
||||
_log(f" - Run #{r.id} was '{r.status}' — now 'failed'.")
|
||||
r.status = 'failed'
|
||||
r.end_time = datetime.utcnow()
|
||||
db.session.commit()
|
||||
else:
|
||||
_log("System: No non-terminal runs found. Clean startup.")
|
||||
except Exception as e:
|
||||
_log(f"System: Startup cleanup error: {e}")
|
||||
|
||||
|
||||
# --- STALE JOB WATCHER ---
|
||||
# Background thread that periodically detects jobs where the heartbeat has
|
||||
# gone silent (>15 min) or the total run has exceeded 2 hours.
|
||||
|
||||
def _stale_job_watcher():
|
||||
import time as _time
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
|
||||
_HEARTBEAT_THRESHOLD = _td(minutes=15)
|
||||
_MAX_RUN_THRESHOLD = _td(hours=2)
|
||||
_CHECK_INTERVAL = 5 * 60 # seconds
|
||||
|
||||
while True:
|
||||
_time.sleep(_CHECK_INTERVAL)
|
||||
try:
|
||||
with app.app_context():
|
||||
now = _dt.utcnow()
|
||||
stale = Run.query.filter_by(status='running').all()
|
||||
for r in stale:
|
||||
# Check heartbeat first (shorter threshold)
|
||||
if r.last_heartbeat and (now - r.last_heartbeat) > _HEARTBEAT_THRESHOLD:
|
||||
_log(f"System: [StaleWatcher] Run #{r.id} heartbeat is {now - r.last_heartbeat} old — marking failed.")
|
||||
r.status = 'failed'
|
||||
r.end_time = now
|
||||
db.session.add(r)
|
||||
# Fallback: check start_time if no heartbeat recorded
|
||||
elif not r.last_heartbeat and r.start_time and (now - r.start_time) > _MAX_RUN_THRESHOLD:
|
||||
_log(f"System: [StaleWatcher] Run #{r.id} running {now - r.start_time} with no heartbeat — marking failed.")
|
||||
r.status = 'failed'
|
||||
r.end_time = now
|
||||
db.session.add(r)
|
||||
db.session.commit()
|
||||
except Exception as _e:
|
||||
_log(f"System: [StaleWatcher] Error during stale-job check: {_e}")
|
||||
|
||||
|
||||
# --- HUEY CONSUMER ---
|
||||
# Start the Huey task consumer in a background thread whenever the app loads.
|
||||
# Guard against the Werkzeug reloader spawning a second consumer in the child process,
|
||||
# and against test runners or importers that should not start background workers.
|
||||
import threading as _threading
|
||||
|
||||
def _start_huey_consumer():
|
||||
import logging as _logging
|
||||
# INFO level so task pick-up/completion appears in docker logs
|
||||
_logging.basicConfig(
|
||||
level=_logging.INFO,
|
||||
format='[%(asctime)s] HUEY %(levelname)s | %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
stream=sys.stdout,
|
||||
force=True,
|
||||
)
|
||||
try:
|
||||
from huey.consumer import Consumer
|
||||
# NOTE: Huey 2.6.0 does NOT accept a `loglevel` kwarg — omit it.
|
||||
consumer = Consumer(huey, workers=1, worker_type='thread')
|
||||
_log("System: Huey task consumer started successfully.")
|
||||
consumer.run() # blocks until app exits
|
||||
except Exception as e:
|
||||
msg = f"System: Huey consumer FAILED to start: {type(e).__name__}: {e}"
|
||||
_log(msg)
|
||||
# Also write to a persistent file for diagnosis when stdout is piped away
|
||||
try:
|
||||
_err_path = os.path.join(config.DATA_DIR, "consumer_error.log")
|
||||
with open(_err_path, 'a', encoding='utf-8') as _f:
|
||||
_f.write(f"[{datetime.utcnow().isoformat()}] {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_is_reloader_child = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
||||
_is_testing = os.environ.get('FLASK_TESTING') == '1'
|
||||
|
||||
if not _is_reloader_child and not _is_testing:
|
||||
_log("System: Launching Huey consumer thread...")
|
||||
_huey_thread = _threading.Thread(target=_start_huey_consumer, daemon=True, name="huey-consumer")
|
||||
_huey_thread.start()
|
||||
_log("System: Launching stale-job watcher thread (checks every 5 min)...")
|
||||
_watcher_thread = _threading.Thread(target=_stale_job_watcher, daemon=True, name="stale-job-watcher")
|
||||
_watcher_thread.start()
|
||||
else:
|
||||
_log(f"System: Skipping Huey consumer (WERKZEUG_RUN_MAIN={os.environ.get('WERKZEUG_RUN_MAIN')}, FLASK_TESTING={os.environ.get('FLASK_TESTING')}).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
@@ -4,14 +4,16 @@ from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password = db.Column(db.String(150), nullable=False)
|
||||
api_key = db.Column(db.String(200), nullable=True) # Optional: User-specific Gemini Key
|
||||
api_key = db.Column(db.String(200), nullable=True)
|
||||
total_spend = db.Column(db.Float, default=0.0)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
class Project(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
@@ -19,19 +21,22 @@ class Project(db.Model):
|
||||
folder_path = db.Column(db.String(300), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Run(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||
status = db.Column(db.String(50), default="queued") # queued, running, completed, failed
|
||||
status = db.Column(db.String(50), default="queued")
|
||||
start_time = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
log_file = db.Column(db.String(300), nullable=True)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
progress = db.Column(db.Integer, default=0)
|
||||
last_heartbeat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
tags = db.Column(db.String(300), nullable=True)
|
||||
|
||||
# Relationships
|
||||
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
def duration(self):
|
||||
@@ -39,9 +44,23 @@ class Run(db.Model):
|
||||
return str(self.end_time - self.start_time).split('.')[0]
|
||||
return "Running..."
|
||||
|
||||
|
||||
class LogEntry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
phase = db.Column(db.String(50))
|
||||
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)
|
||||
25
web/helpers.py
Normal file
25
web/helpers.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from flask import redirect, url_for, flash, request
|
||||
from flask_login import current_user
|
||||
from web.db import Run
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
flash("Admin access required.")
|
||||
return redirect(url_for('project.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_project_locked(project_id):
|
||||
return Run.query.filter_by(project_id=project_id, status='completed').count() > 0
|
||||
|
||||
|
||||
def is_safe_url(target):
|
||||
ref_url = urlparse(request.host_url)
|
||||
test_url = urlparse(urljoin(request.host_url, target))
|
||||
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
|
||||
@@ -1,7 +1,7 @@
|
||||
flask
|
||||
flask-login
|
||||
flask-sqlalchemy
|
||||
huey
|
||||
huey==2.6.0
|
||||
werkzeug
|
||||
google-generativeai
|
||||
python-dotenv
|
||||
0
web/routes/__init__.py
Normal file
0
web/routes/__init__.py
Normal file
249
web/routes/admin.py
Normal file
249
web/routes/admin.py
Normal file
@@ -0,0 +1,249 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from flask_login import login_required, login_user, current_user
|
||||
from sqlalchemy import func
|
||||
from web.db import db, User, Project, Run, Persona
|
||||
from web.helpers import admin_required
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import style_persona, bible_tracker
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
users = User.query.all()
|
||||
projects = Project.query.all()
|
||||
return render_template('admin_dashboard.html', users=users, projects=projects)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/user/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_delete_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
flash("Cannot delete yourself.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if user:
|
||||
user_path = os.path.join(config.DATA_DIR, "users", str(user.id))
|
||||
if os.path.exists(user_path):
|
||||
try: shutil.rmtree(user_path)
|
||||
except: pass
|
||||
|
||||
projects = Project.query.filter_by(user_id=user.id).all()
|
||||
for p in projects:
|
||||
db.session.delete(p)
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f"User {user.username} deleted.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/project/<int:project_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_delete_project(project_id):
|
||||
proj = db.session.get(Project, project_id)
|
||||
if proj:
|
||||
if os.path.exists(proj.folder_path):
|
||||
try: shutil.rmtree(proj.folder_path)
|
||||
except: pass
|
||||
db.session.delete(proj)
|
||||
db.session.commit()
|
||||
flash(f"Project {proj.name} deleted.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/reset', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_factory_reset():
|
||||
projects = Project.query.all()
|
||||
for p in projects:
|
||||
if os.path.exists(p.folder_path):
|
||||
try: shutil.rmtree(p.folder_path)
|
||||
except: pass
|
||||
db.session.delete(p)
|
||||
|
||||
users = User.query.filter(User.id != current_user.id).all()
|
||||
for u in users:
|
||||
user_path = os.path.join(config.DATA_DIR, "users", str(u.id))
|
||||
if os.path.exists(user_path):
|
||||
try: shutil.rmtree(user_path)
|
||||
except: pass
|
||||
db.session.delete(u)
|
||||
|
||||
Persona.query.delete()
|
||||
|
||||
db.session.commit()
|
||||
flash("Factory Reset Complete. All other users and projects have been wiped.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/spend')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_spend_report():
|
||||
days = request.args.get('days', 30, type=int)
|
||||
|
||||
if days > 0:
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
else:
|
||||
start_date = datetime.min
|
||||
|
||||
results = db.session.query(
|
||||
User.username,
|
||||
func.count(Run.id),
|
||||
func.sum(Run.cost)
|
||||
).join(Project, Project.user_id == User.id)\
|
||||
.join(Run, Run.project_id == Project.id)\
|
||||
.filter(Run.start_time >= start_date)\
|
||||
.group_by(User.id, User.username).all()
|
||||
|
||||
report = []
|
||||
total_period_spend = 0.0
|
||||
for r in results:
|
||||
cost = r[2] if r[2] else 0.0
|
||||
report.append({"username": r[0], "runs": r[1], "cost": cost})
|
||||
total_period_spend += cost
|
||||
|
||||
return render_template('admin_spend.html', report=report, days=days, total=total_period_spend)
|
||||
|
||||
|
||||
@admin_bp.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.admin_style_guidelines'))
|
||||
|
||||
data = style_persona.get_style_guidelines()
|
||||
return render_template('admin_style.html', data=data)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/impersonate/<int:user_id>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def impersonate_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
flash("Cannot impersonate yourself.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if user:
|
||||
session['original_admin_id'] = current_user.id
|
||||
login_user(user)
|
||||
flash(f"Now viewing as {user.username}")
|
||||
return redirect(url_for('project.index'))
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/stop_impersonate')
|
||||
@login_required
|
||||
def stop_impersonate():
|
||||
admin_id = session.get('original_admin_id')
|
||||
if admin_id:
|
||||
admin = db.session.get(User, admin_id)
|
||||
if admin:
|
||||
login_user(admin)
|
||||
session.pop('original_admin_id', None)
|
||||
flash("Restored admin session.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
|
||||
@admin_bp.route('/debug/routes')
|
||||
@login_required
|
||||
@admin_required
|
||||
def debug_routes():
|
||||
from flask import current_app
|
||||
output = []
|
||||
for rule in current_app.url_map.iter_rules():
|
||||
methods = ','.join(rule.methods)
|
||||
rule_str = str(rule).replace('<', '[').replace('>', ']')
|
||||
line = "{:50s} {:20s} {}".format(rule.endpoint, methods, rule_str)
|
||||
output.append(line)
|
||||
return "<pre>" + "\n".join(output) + "</pre>"
|
||||
|
||||
|
||||
@admin_bp.route('/system/optimize_models', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def optimize_models():
|
||||
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
try:
|
||||
ai_setup.init_models(force=True)
|
||||
|
||||
if ai_models.model_logic:
|
||||
style_persona.refresh_style_guidelines(ai_models.model_logic)
|
||||
|
||||
if is_ajax:
|
||||
return jsonify({'status': 'ok', 'message': 'AI Models refreshed and Style Guidelines updated.'})
|
||||
flash("AI Models refreshed and Style Guidelines updated.")
|
||||
except Exception as e:
|
||||
if is_ajax:
|
||||
return jsonify({'status': 'error', 'message': f'Error refreshing models: {e}'}), 500
|
||||
flash(f"Error refreshing models: {e}")
|
||||
|
||||
return redirect(request.referrer or url_for('project.index'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/refresh-style-guidelines', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def refresh_style_guidelines_route():
|
||||
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
try:
|
||||
if not ai_models.model_logic:
|
||||
raise Exception("No AI model available. Run 'Refresh & Optimize' first.")
|
||||
new_data = style_persona.refresh_style_guidelines(ai_models.model_logic)
|
||||
msg = f"Style Guidelines updated — {len(new_data.get('ai_isms', []))} AI-isms, {len(new_data.get('filter_words', []))} filter words."
|
||||
utils.log("SYSTEM", msg)
|
||||
if is_ajax:
|
||||
return jsonify({'status': 'ok', 'message': msg})
|
||||
flash(msg)
|
||||
except Exception as e:
|
||||
if is_ajax:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
flash(f"Error refreshing style guidelines: {e}")
|
||||
return redirect(request.referrer or url_for('admin.system_status'))
|
||||
|
||||
|
||||
@admin_bp.route('/system/status')
|
||||
@login_required
|
||||
def system_status():
|
||||
models_info = {}
|
||||
cache_data = {}
|
||||
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
models_info = cache_data.get('models', {})
|
||||
except: pass
|
||||
|
||||
return render_template('system_status.html', models=models_info, cache=cache_data, datetime=datetime,
|
||||
image_model=ai_models.image_model_name, image_source=ai_models.image_model_source)
|
||||
57
web/routes/auth.py
Normal file
57
web/routes/auth.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from web.db import db, User
|
||||
from web.helpers import is_safe_url
|
||||
from core import config
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and check_password_hash(user.password, password):
|
||||
login_user(user)
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not is_safe_url(next_page):
|
||||
next_page = url_for('project.index')
|
||||
return redirect(next_page)
|
||||
if user and user.is_admin:
|
||||
print(f"⚠️ System: Admin login failed for '{username}'. Password hash mismatch.")
|
||||
flash('Invalid credentials')
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username exists')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256'))
|
||||
if config.ADMIN_USER and username == config.ADMIN_USER:
|
||||
new_user.is_admin = True
|
||||
try:
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
login_user(new_user)
|
||||
return redirect(url_for('project.index'))
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash('Username exists')
|
||||
return redirect(url_for('auth.register'))
|
||||
return render_template('register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
155
web/routes/persona.py
Normal file
155
web/routes/persona.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from web.db import db, Persona
|
||||
|
||||
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')
|
||||
@login_required
|
||||
def list_personas():
|
||||
personas = _all_personas_dict()
|
||||
return render_template('personas.html', personas=personas)
|
||||
|
||||
|
||||
@persona_bp.route('/persona/new')
|
||||
@login_required
|
||||
def new_persona():
|
||||
return render_template('persona_edit.html', persona={}, name="")
|
||||
|
||||
|
||||
@persona_bp.route('/persona/<string:name>')
|
||||
@login_required
|
||||
def edit_persona(name):
|
||||
record = Persona.query.filter_by(name=name).first()
|
||||
if not record:
|
||||
flash(f"Persona '{name}' not found.")
|
||||
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)
|
||||
|
||||
|
||||
@persona_bp.route('/persona/save', methods=['POST'])
|
||||
@login_required
|
||||
def save_persona():
|
||||
old_name = request.form.get('old_name')
|
||||
name = request.form.get('name')
|
||||
|
||||
if not name:
|
||||
flash("Persona name is required.")
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
persona_data = {
|
||||
"name": name,
|
||||
"bio": request.form.get('bio'),
|
||||
"age": request.form.get('age'),
|
||||
"gender": request.form.get('gender'),
|
||||
"race": request.form.get('race'),
|
||||
"nationality": request.form.get('nationality'),
|
||||
"language": request.form.get('language'),
|
||||
"sample_text": request.form.get('sample_text'),
|
||||
"voice_keywords": request.form.get('voice_keywords'),
|
||||
"style_inspirations": request.form.get('style_inspirations')
|
||||
}
|
||||
|
||||
# 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()
|
||||
|
||||
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.")
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
|
||||
@persona_bp.route('/persona/delete/<string:name>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_persona(name):
|
||||
record = Persona.query.filter_by(name=name).first()
|
||||
if record:
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
flash(f"Persona '{name}' deleted.")
|
||||
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
|
||||
@persona_bp.route('/persona/analyze', methods=['POST'])
|
||||
@login_required
|
||||
def analyze_persona():
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
if not ai_models.model_logic:
|
||||
return {"error": "AI models not initialized."}, 500
|
||||
|
||||
data = request.json
|
||||
sample = data.get('sample_text', '')
|
||||
|
||||
# Cache by a hash of the inputs to avoid redundant API calls for unchanged data
|
||||
cache_key = utils.make_cache_key(
|
||||
"persona_analyze",
|
||||
data.get('name', ''),
|
||||
data.get('age', ''),
|
||||
data.get('gender', ''),
|
||||
data.get('nationality', ''),
|
||||
sample[:500]
|
||||
)
|
||||
cached = utils.get_ai_cache(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Literary Analyst
|
||||
TASK: Create or analyze an Author Persona profile.
|
||||
|
||||
INPUT_DATA:
|
||||
- NAME: {data.get('name')}
|
||||
- DEMOGRAPHICS: Age: {data.get('age')} | Gender: {data.get('gender')} | Nationality: {data.get('nationality')}
|
||||
- SAMPLE_TEXT: {utils.truncate_to_tokens(sample, 750)}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. BIO: Write a 2-3 sentence description of the writing style. If sample is provided, analyze it. If not, invent a style that fits the demographics/name.
|
||||
2. KEYWORDS: Comma-separated list of 3-5 adjectives describing the voice (e.g. Gritty, Whimsical, Sarcastic).
|
||||
3. INSPIRATIONS: Comma-separated list of 1-3 famous authors or genres that this style resembles.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "bio": "String", "voice_keywords": "String", "style_inspirations": "String" }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
result = json.loads(utils.clean_json(response.text))
|
||||
utils.set_ai_cache(cache_key, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
829
web/routes/project.py
Normal file
829
web/routes/project.py
Normal file
@@ -0,0 +1,829 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from web.db import db, Project, Run, Persona, StoryState
|
||||
from web.helpers import is_project_locked
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import planner, bible_tracker
|
||||
from web.tasks import generate_book_task, refine_bible_task
|
||||
|
||||
project_bp = Blueprint('project', __name__)
|
||||
|
||||
|
||||
@project_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
projects = Project.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('dashboard.html', projects=projects, user=current_user)
|
||||
|
||||
|
||||
@project_bp.route('/project/setup', methods=['POST'])
|
||||
@login_required
|
||||
def project_setup_wizard():
|
||||
concept = request.form.get('concept')
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Publishing Analyst
|
||||
TASK: Suggest metadata for a story concept.
|
||||
|
||||
CONCEPT: {concept}
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"title": "String",
|
||||
"genre": "String",
|
||||
"target_audience": "String",
|
||||
"tone": "String",
|
||||
"length_category": "String (Select code: '01'=Chapter Book, '1'=Flash Fiction, '2'=Short Story, '2b'=Young Adult, '3'=Novella, '4'=Novel, '5'=Epic)",
|
||||
"estimated_chapters": Int,
|
||||
"estimated_word_count": "String (e.g. '75,000')",
|
||||
"include_prologue": Bool,
|
||||
"include_epilogue": Bool,
|
||||
"tropes": ["String"],
|
||||
"pov_style": "String",
|
||||
"time_period": "String",
|
||||
"spice": "String",
|
||||
"violence": "String",
|
||||
"is_series": Bool,
|
||||
"series_title": "String",
|
||||
"narrative_tense": "String",
|
||||
"language_style": "String",
|
||||
"dialogue_style": "String",
|
||||
"page_orientation": "Portrait|Landscape|Square",
|
||||
"formatting_rules": ["String (e.g. 'Chapter Headers: Number + Title')"],
|
||||
"author_bio": "String"
|
||||
}}
|
||||
"""
|
||||
|
||||
_default_suggestions = {
|
||||
"title": concept[:60] if concept else "New Project",
|
||||
"genre": "Fiction",
|
||||
"target_audience": "",
|
||||
"tone": "",
|
||||
"length_category": "4",
|
||||
"estimated_chapters": 20,
|
||||
"estimated_word_count": "75,000",
|
||||
"include_prologue": False,
|
||||
"include_epilogue": False,
|
||||
"tropes": [],
|
||||
"pov_style": "",
|
||||
"time_period": "Modern",
|
||||
"spice": "",
|
||||
"violence": "",
|
||||
"is_series": False,
|
||||
"series_title": "",
|
||||
"narrative_tense": "",
|
||||
"language_style": "",
|
||||
"dialogue_style": "",
|
||||
"page_orientation": "Portrait",
|
||||
"formatting_rules": [],
|
||||
"author_bio": ""
|
||||
}
|
||||
|
||||
suggestions = {}
|
||||
if not ai_models.model_logic:
|
||||
flash("AI models not initialized — fill in the details manually.", "warning")
|
||||
suggestions = _default_suggestions
|
||||
else:
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
suggestions = json.loads(utils.clean_json(response.text))
|
||||
# Ensure list fields are always lists
|
||||
for list_field in ("tropes", "formatting_rules"):
|
||||
if not isinstance(suggestions.get(list_field), list):
|
||||
suggestions[list_field] = []
|
||||
except Exception as e:
|
||||
flash(f"AI Analysis failed — fill in the details manually. ({e})", "warning")
|
||||
suggestions = _default_suggestions
|
||||
|
||||
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||
|
||||
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
||||
|
||||
|
||||
@project_bp.route('/project/setup/refine', methods=['POST'])
|
||||
@login_required
|
||||
def project_setup_refine():
|
||||
concept = request.form.get('concept')
|
||||
instruction = request.form.get('refine_instruction')
|
||||
|
||||
current_state = {
|
||||
"title": request.form.get('title'),
|
||||
"genre": request.form.get('genre'),
|
||||
"target_audience": request.form.get('audience'),
|
||||
"tone": request.form.get('tone'),
|
||||
}
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Publishing Analyst
|
||||
TASK: Refine project metadata based on user instruction.
|
||||
|
||||
INPUT_DATA:
|
||||
- ORIGINAL_CONCEPT: {concept}
|
||||
- CURRENT_TITLE: {current_state['title']}
|
||||
- INSTRUCTION: {instruction}
|
||||
|
||||
OUTPUT_FORMAT (JSON): Same structure as the initial analysis (title, genre, length_category, etc). Ensure length_category matches the word count.
|
||||
"""
|
||||
|
||||
suggestions = {}
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
suggestions = json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
flash(f"Refinement failed: {e}")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||
|
||||
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
||||
|
||||
|
||||
@project_bp.route('/project/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_project_final():
|
||||
title = request.form.get('title')
|
||||
safe_title = utils.sanitize_filename(title)
|
||||
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(proj_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(proj_path, exist_ok=True)
|
||||
|
||||
length_cat = request.form.get('length_category')
|
||||
len_def = config.LENGTH_DEFINITIONS.get(length_cat, config.LENGTH_DEFINITIONS['4']).copy()
|
||||
|
||||
try: len_def['chapters'] = int(request.form.get('chapters'))
|
||||
except: pass
|
||||
len_def['words'] = request.form.get('words')
|
||||
len_def['include_prologue'] = 'include_prologue' in request.form
|
||||
len_def['include_epilogue'] = 'include_epilogue' in request.form
|
||||
|
||||
is_series = 'is_series' in request.form
|
||||
|
||||
style = {
|
||||
"tone": request.form.get('tone'),
|
||||
"pov_style": request.form.get('pov_style'),
|
||||
"time_period": request.form.get('time_period'),
|
||||
"spice": request.form.get('spice'),
|
||||
"violence": request.form.get('violence'),
|
||||
"narrative_tense": request.form.get('narrative_tense'),
|
||||
"language_style": request.form.get('language_style'),
|
||||
"dialogue_style": request.form.get('dialogue_style'),
|
||||
"page_orientation": request.form.get('page_orientation'),
|
||||
"tropes": [x.strip() for x in request.form.get('tropes', '').split(',') if x.strip()],
|
||||
"formatting_rules": [x.strip() for x in request.form.get('formatting_rules', '').split(',') if x.strip()]
|
||||
}
|
||||
|
||||
bible = {
|
||||
"project_metadata": {
|
||||
"title": title,
|
||||
"author": request.form.get('author'),
|
||||
"author_bio": request.form.get('author_bio'),
|
||||
"genre": request.form.get('genre'),
|
||||
"target_audience": request.form.get('audience'),
|
||||
"is_series": is_series,
|
||||
"length_settings": len_def,
|
||||
"style": style
|
||||
},
|
||||
"books": [],
|
||||
"characters": []
|
||||
}
|
||||
|
||||
count = 1
|
||||
if is_series:
|
||||
try: count = int(request.form.get('series_count', 1))
|
||||
except: count = 3
|
||||
|
||||
concept = request.form.get('concept', '')
|
||||
|
||||
for i in range(count):
|
||||
bible['books'].append({
|
||||
"book_number": i+1,
|
||||
"title": f"{title} - Book {i+1}" if is_series else title,
|
||||
"manual_instruction": concept if i==0 else "",
|
||||
"plot_beats": []
|
||||
})
|
||||
|
||||
try:
|
||||
ai_setup.init_models()
|
||||
# Build a per-book blueprint matching what enrich() expects
|
||||
first_book = bible['books'][0] if bible.get('books') else {}
|
||||
bp = {
|
||||
'manual_instruction': first_book.get('manual_instruction', concept),
|
||||
'book_metadata': {
|
||||
'title': bible['project_metadata']['title'],
|
||||
'genre': bible['project_metadata']['genre'],
|
||||
'style': dict(bible['project_metadata'].get('style', {})),
|
||||
},
|
||||
'length_settings': dict(bible['project_metadata'].get('length_settings', {})),
|
||||
'characters': [],
|
||||
'plot_beats': [],
|
||||
}
|
||||
bp = planner.enrich(bp, proj_path)
|
||||
# Merge enriched characters and plot_beats back into the bible
|
||||
if bp.get('characters'):
|
||||
bible['characters'] = bp['characters']
|
||||
if bp.get('plot_beats') and bible.get('books'):
|
||||
bible['books'][0]['plot_beats'] = bp['plot_beats']
|
||||
# Merge enriched style fields back (structure_prompt, content_warnings)
|
||||
bm = bp.get('book_metadata', {})
|
||||
if bm.get('structure_prompt') and bible.get('books'):
|
||||
bible['books'][0]['structure_prompt'] = bm['structure_prompt']
|
||||
if bm.get('content_warnings'):
|
||||
bible['project_metadata']['content_warnings'] = bm['content_warnings']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
|
||||
json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('project.view_project', id=new_proj.id))
|
||||
|
||||
|
||||
@project_bp.route('/project/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_project():
|
||||
if 'bible_file' not in request.files:
|
||||
flash('No file part')
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
file = request.files['bible_file']
|
||||
if file.filename == '':
|
||||
flash('No selected file')
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
if file:
|
||||
try:
|
||||
bible = json.load(file)
|
||||
if 'project_metadata' not in bible or 'title' not in bible['project_metadata']:
|
||||
flash("Invalid Bible format: Missing project_metadata or title.")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
title = bible['project_metadata']['title']
|
||||
safe_title = utils.sanitize_filename(title)
|
||||
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(proj_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(proj_path)
|
||||
|
||||
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
|
||||
json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Project '{title}' imported successfully.")
|
||||
return redirect(url_for('project.view_project', id=new_proj.id))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Import failed: {str(e)}")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>')
|
||||
@login_required
|
||||
def view_project(id):
|
||||
proj = db.session.get(Project, id)
|
||||
if not proj: return "Project not found", 404
|
||||
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible_data = utils.load_json(bible_path)
|
||||
|
||||
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||
has_draft = os.path.exists(draft_path)
|
||||
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
||||
|
||||
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||
|
||||
runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
|
||||
latest_run = runs[0] if runs else None
|
||||
active_runs = [r for r in runs if r.status in ('running', 'queued')]
|
||||
|
||||
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
|
||||
|
||||
artifacts = []
|
||||
cover_image = None
|
||||
generated_books = {}
|
||||
locked = is_project_locked(id)
|
||||
|
||||
for r in runs:
|
||||
if r.status == 'completed':
|
||||
run_dir = os.path.join(proj.folder_path, "runs", f"run_{r.id}")
|
||||
if os.path.exists(run_dir):
|
||||
for d in os.listdir(run_dir):
|
||||
if d.startswith("Book_") and os.path.isdir(os.path.join(run_dir, d)):
|
||||
if os.path.exists(os.path.join(run_dir, d, "manuscript.json")):
|
||||
try:
|
||||
parts = d.split('_')
|
||||
if len(parts) > 1 and parts[1].isdigit():
|
||||
b_num = int(parts[1])
|
||||
if b_num not in generated_books:
|
||||
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
|
||||
|
||||
if latest_run:
|
||||
run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}")
|
||||
if os.path.exists(run_dir):
|
||||
if os.path.exists(os.path.join(run_dir, "cover.png")):
|
||||
cover_image = "cover.png"
|
||||
else:
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
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 f in files:
|
||||
if f.lower().endswith(('.epub', '.docx')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), run_dir)
|
||||
artifacts.append({
|
||||
'name': f,
|
||||
'path': rel_path.replace("\\", "/"),
|
||||
'type': f.split('.')[-1].upper()
|
||||
})
|
||||
|
||||
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, active_runs=active_runs, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects, locked=locked, has_draft=has_draft, is_refining=is_refining)
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/run', methods=['POST'])
|
||||
@login_required
|
||||
def run_project(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
|
||||
new_run = Run(project_id=id, status="queued")
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
generate_book_task(new_run.id, proj.folder_path, bible_path, allow_copy=True)
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_project(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id:
|
||||
return "Unauthorized", 403
|
||||
|
||||
active = Run.query.filter_by(project_id=id).filter(Run.status.in_(['running', 'queued'])).first()
|
||||
if active:
|
||||
flash("Cannot delete a project with an active run. Stop the run first.", "danger")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
# Delete filesystem folder
|
||||
if proj.folder_path and os.path.exists(proj.folder_path):
|
||||
try:
|
||||
shutil.rmtree(proj.folder_path)
|
||||
except Exception as e:
|
||||
flash(f"Warning: could not delete project files: {e}", "warning")
|
||||
|
||||
# Delete StoryState records (no cascade on Project yet)
|
||||
StoryState.query.filter_by(project_id=id).delete()
|
||||
|
||||
# Delete project (cascade handles Runs and LogEntries)
|
||||
db.session.delete(proj)
|
||||
db.session.commit()
|
||||
|
||||
flash("Project deleted.", "success")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/review')
|
||||
@login_required
|
||||
def review_project(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
return render_template('project_review.html', project=proj, bible=bible)
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_project_metadata(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
new_title = request.form.get('title')
|
||||
new_author = request.form.get('author')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
if new_title:
|
||||
bible['project_metadata']['title'] = new_title
|
||||
proj.name = new_title
|
||||
if new_author:
|
||||
bible['project_metadata']['author'] = new_author
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/clone', methods=['POST'])
|
||||
@login_required
|
||||
def clone_project(id):
|
||||
source_proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if source_proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
new_name = request.form.get('new_name')
|
||||
instruction = request.form.get('instruction')
|
||||
|
||||
safe_title = utils.sanitize_filename(new_name)
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
new_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(new_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
new_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(new_path)
|
||||
|
||||
source_bible_path = os.path.join(source_proj.folder_path, "bible.json")
|
||||
if os.path.exists(source_bible_path):
|
||||
bible = utils.load_json(source_bible_path)
|
||||
bible['project_metadata']['title'] = new_name
|
||||
|
||||
if instruction:
|
||||
try:
|
||||
ai_setup.init_models()
|
||||
bible = bible_tracker.refine_bible(bible, instruction, new_path) or bible
|
||||
except: pass
|
||||
|
||||
with open(os.path.join(new_path, "bible.json"), 'w') as f: json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=new_name, folder_path=new_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Project cloned as '{new_name}'.")
|
||||
return redirect(url_for('project.view_project', id=new_proj.id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/bible_comparison')
|
||||
@login_required
|
||||
def bible_comparison(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||
|
||||
if not os.path.exists(draft_path):
|
||||
flash("No draft found. Please refine the bible first.")
|
||||
return redirect(url_for('project.review_project', id=id))
|
||||
|
||||
original = utils.load_json(bible_path)
|
||||
new_draft = utils.load_json(draft_path)
|
||||
|
||||
if not original or not new_draft:
|
||||
flash("Error loading bible data. Draft may be corrupt.")
|
||||
return redirect(url_for('project.review_project', id=id))
|
||||
|
||||
return render_template('bible_comparison.html', project=proj, original=original, new=new_draft)
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/refine_bible', methods=['POST'])
|
||||
@login_required
|
||||
def refine_bible_route(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
data = request.json if request.is_json else request.form
|
||||
instruction = data.get('instruction')
|
||||
|
||||
if not instruction:
|
||||
return {"error": "Instruction required"}, 400
|
||||
|
||||
source_type = data.get('source', 'original')
|
||||
selected_keys = data.get('selected_keys')
|
||||
if isinstance(selected_keys, str):
|
||||
try: selected_keys = json.loads(selected_keys) if selected_keys.strip() else []
|
||||
except: selected_keys = []
|
||||
|
||||
task = refine_bible_task(proj.folder_path, instruction, source_type, selected_keys)
|
||||
|
||||
return {"status": "queued", "task_id": task.id}
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/is_refining')
|
||||
@login_required
|
||||
def check_refinement_status(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
||||
return {"is_refining": is_refining}
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/refine_bible/confirm', methods=['POST'])
|
||||
@login_required
|
||||
def confirm_bible_refinement(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
action = request.form.get('action')
|
||||
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
|
||||
if action == 'accept' or action == 'accept_all':
|
||||
if os.path.exists(draft_path):
|
||||
shutil.move(draft_path, bible_path)
|
||||
flash("Bible updated successfully.")
|
||||
else:
|
||||
flash("Draft expired or missing.")
|
||||
|
||||
elif action == 'accept_selected':
|
||||
if os.path.exists(draft_path) and os.path.exists(bible_path):
|
||||
selected_keys_json = request.form.get('selected_keys', '[]')
|
||||
try:
|
||||
selected_keys = json.loads(selected_keys_json)
|
||||
draft = utils.load_json(draft_path)
|
||||
original = utils.load_json(bible_path)
|
||||
|
||||
original = bible_tracker.merge_selected_changes(original, draft, selected_keys)
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(original, f, indent=2)
|
||||
os.remove(draft_path)
|
||||
flash(f"Merged {len(selected_keys)} changes into Bible.")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Merge failed: {e}")
|
||||
else:
|
||||
flash("Files missing.")
|
||||
|
||||
elif action == 'decline':
|
||||
if os.path.exists(draft_path):
|
||||
os.remove(draft_path)
|
||||
flash("Changes discarded.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/add_book', methods=['POST'])
|
||||
@login_required
|
||||
def add_book(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
title = request.form.get('title', 'Untitled')
|
||||
instruction = request.form.get('instruction', '')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
if 'books' not in bible: bible['books'] = []
|
||||
next_num = len(bible['books']) + 1
|
||||
|
||||
new_book = {
|
||||
"book_number": next_num,
|
||||
"title": title,
|
||||
"manual_instruction": instruction,
|
||||
"plot_beats": []
|
||||
}
|
||||
bible['books'].append(new_book)
|
||||
|
||||
if 'project_metadata' in bible:
|
||||
bible['project_metadata']['is_series'] = True
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Added Book {next_num}: {title}")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/book/<int:book_num>/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_book_details(id, book_num):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
new_title = request.form.get('title')
|
||||
new_instruction = request.form.get('instruction')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible and 'books' in bible:
|
||||
for b in bible['books']:
|
||||
if b.get('book_number') == book_num:
|
||||
if new_title: b['title'] = new_title
|
||||
if new_instruction is not None: b['manual_instruction'] = new_instruction
|
||||
break
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Book {book_num} updated.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/delete_book/<int:book_num>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_book(id, book_num):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible and 'books' in bible:
|
||||
bible['books'] = [b for b in bible['books'] if b.get('book_number') != book_num]
|
||||
for i, b in enumerate(bible['books']):
|
||||
b['book_number'] = i + 1
|
||||
|
||||
if 'project_metadata' in bible:
|
||||
bible['project_metadata']['is_series'] = (len(bible['books']) > 1)
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash("Book deleted from plan.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/import_characters', methods=['POST'])
|
||||
@login_required
|
||||
def import_characters(id):
|
||||
target_proj = db.session.get(Project, id)
|
||||
source_id = request.form.get('source_project_id')
|
||||
source_proj = db.session.get(Project, source_id)
|
||||
|
||||
if not target_proj or not source_proj: return "Project not found", 404
|
||||
if target_proj.user_id != current_user.id or source_proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
target_bible = utils.load_json(os.path.join(target_proj.folder_path, "bible.json"))
|
||||
source_bible = utils.load_json(os.path.join(source_proj.folder_path, "bible.json"))
|
||||
|
||||
if target_bible and source_bible:
|
||||
existing_names = {c['name'].lower() for c in target_bible.get('characters', [])}
|
||||
added_count = 0
|
||||
|
||||
for char in source_bible.get('characters', []):
|
||||
if char['name'].lower() not in existing_names:
|
||||
target_bible['characters'].append(char)
|
||||
added_count += 1
|
||||
|
||||
if added_count > 0:
|
||||
with open(os.path.join(target_proj.folder_path, "bible.json"), 'w') as f:
|
||||
json.dump(target_bible, f, indent=2)
|
||||
flash(f"Imported {added_count} characters from {source_proj.name}.")
|
||||
else:
|
||||
flash("No new characters found to import.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/set_persona', methods=['POST'])
|
||||
@login_required
|
||||
def set_project_persona(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
persona_name = request.form.get('persona_name')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
|
||||
|
||||
if persona_name in personas:
|
||||
bible['project_metadata']['author_details'] = personas[persona_name]
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Project voice updated to persona: {persona_name}")
|
||||
else:
|
||||
flash("Persona not found.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/run/<int:id>/stop', methods=['POST'])
|
||||
@login_required
|
||||
def stop_run(id):
|
||||
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status in ['queued', 'running']:
|
||||
run.status = 'cancelled'
|
||||
run.end_time = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
if os.path.exists(run_dir):
|
||||
with open(os.path.join(run_dir, ".stop"), 'w') as f: f.write("stop")
|
||||
|
||||
flash(f"Run {id} marked as cancelled.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=run.project_id))
|
||||
|
||||
|
||||
@project_bp.route('/run/<int:id>/restart', methods=['POST'])
|
||||
@login_required
|
||||
def restart_run(id):
|
||||
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
new_run = Run(project_id=run.project_id, status="queued")
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
mode = request.form.get('mode', 'resume')
|
||||
feedback = request.form.get('feedback')
|
||||
keep_cover = 'keep_cover' in request.form
|
||||
force_regen = 'force_regenerate' in request.form
|
||||
allow_copy = (mode == 'resume' and not force_regen)
|
||||
if feedback: allow_copy = False
|
||||
|
||||
generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy, feedback=feedback, source_run_id=id if feedback else None, keep_cover=keep_cover)
|
||||
flash(f"Started new Run #{new_run.id}" + (" with modifications." if feedback else "."))
|
||||
return redirect(url_for('project.view_project', id=run.project_id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:run_id>/revise_book/<string:book_folder>', methods=['POST'])
|
||||
@login_required
|
||||
def revise_book(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
instruction = request.form.get('instruction')
|
||||
|
||||
new_run = Run(project_id=run.project_id, status="queued")
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
generate_book_task(
|
||||
new_run.id,
|
||||
run.project.folder_path,
|
||||
os.path.join(run.project.folder_path, "bible.json"),
|
||||
allow_copy=True,
|
||||
feedback=instruction,
|
||||
source_run_id=run.id,
|
||||
keep_cover=True,
|
||||
exclude_folders=[book_folder]
|
||||
)
|
||||
|
||||
flash(f"Started Revision Run #{new_run.id}. Book '{book_folder}' will be regenerated.")
|
||||
return redirect(url_for('project.view_project', id=run.project_id))
|
||||
514
web/routes/run.py
Normal file
514
web/routes/run.py
Normal file
@@ -0,0 +1,514 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import markdown
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
|
||||
from flask_login import login_required, current_user
|
||||
from web.db import db, Run, LogEntry
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import editor as story_editor
|
||||
from story import bible_tracker, style_persona, eval_logger as story_eval_logger
|
||||
from export import exporter
|
||||
from web.tasks import huey, regenerate_artifacts_task, rewrite_chapter_task
|
||||
|
||||
run_bp = Blueprint('run', __name__)
|
||||
|
||||
|
||||
@run_bp.route('/run/<int:id>')
|
||||
@login_required
|
||||
def view_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
|
||||
|
||||
log_content = ""
|
||||
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
|
||||
if logs:
|
||||
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
|
||||
elif run.log_file and os.path.exists(run.log_file):
|
||||
with open(run.log_file, 'r') as f: log_content = f.read()
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
|
||||
books_data = []
|
||||
if os.path.exists(run_dir):
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
|
||||
for d in subdirs:
|
||||
b_path = os.path.join(run_dir, d)
|
||||
b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''}
|
||||
|
||||
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("\\", "/")})
|
||||
|
||||
if os.path.exists(os.path.join(b_path, "cover.png")):
|
||||
b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/")
|
||||
|
||||
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)
|
||||
|
||||
bible_path = os.path.join(run.project.folder_path, "bible.json")
|
||||
bible_data = utils.load_json(bible_path)
|
||||
|
||||
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
||||
book_dir = os.path.join(run_dir, books_data[-1]['folder']) if books_data else run_dir
|
||||
if os.path.exists(book_dir):
|
||||
t_ev = os.path.join(book_dir, "tracking_events.json")
|
||||
t_ch = os.path.join(book_dir, "tracking_characters.json")
|
||||
t_wn = os.path.join(book_dir, "tracking_warnings.json")
|
||||
|
||||
if os.path.exists(t_ev): tracking['events'] = utils.load_json(t_ev) or []
|
||||
if os.path.exists(t_ch): tracking['characters'] = utils.load_json(t_ch) or {}
|
||||
if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
|
||||
|
||||
return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking)
|
||||
|
||||
|
||||
@run_bp.route('/run/<int:id>/status')
|
||||
@login_required
|
||||
def run_status(id):
|
||||
import sqlite3 as _sql3
|
||||
import sys as _sys
|
||||
from core import config as _cfg
|
||||
|
||||
# Expire session so we always read fresh values from disk (not cached state)
|
||||
db.session.expire_all()
|
||||
run = db.session.get(Run, id)
|
||||
if not run:
|
||||
return {"status": "not_found", "log": "", "cost": 0, "percent": 0, "start_time": None}, 404
|
||||
|
||||
log_content = ""
|
||||
last_log = None
|
||||
|
||||
# 1. ORM query for log entries
|
||||
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
|
||||
if logs:
|
||||
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
|
||||
last_log = logs[-1]
|
||||
|
||||
# 2. Raw sqlite3 fallback — bypasses any SQLAlchemy session caching
|
||||
if not log_content:
|
||||
try:
|
||||
_db_path = os.path.join(_cfg.DATA_DIR, "bookapp.db")
|
||||
with _sql3.connect(_db_path, timeout=5) as _conn:
|
||||
_rows = _conn.execute(
|
||||
"SELECT timestamp, phase, message FROM log_entry WHERE run_id = ? ORDER BY timestamp",
|
||||
(id,)
|
||||
).fetchall()
|
||||
if _rows:
|
||||
log_content = "\n".join([
|
||||
f"[{str(r[0])[:8]}] {str(r[1]):<15} | {r[2]}"
|
||||
for r in _rows
|
||||
])
|
||||
except Exception as _e:
|
||||
print(f"[run_status] sqlite3 fallback error for run {id}: {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
|
||||
|
||||
# 3. File fallback — reads the log file written by the task worker
|
||||
if not log_content:
|
||||
try:
|
||||
if run.log_file and os.path.exists(run.log_file):
|
||||
with open(run.log_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
log_content = f.read()
|
||||
elif run.status in ['queued', 'running']:
|
||||
project_folder = run.project.folder_path
|
||||
# Temp log written at task start (before run dir exists)
|
||||
temp_log = os.path.join(project_folder, f"system_log_{run.id}.txt")
|
||||
if os.path.exists(temp_log):
|
||||
with open(temp_log, 'r', encoding='utf-8', errors='replace') as f:
|
||||
log_content = f.read()
|
||||
else:
|
||||
# Also check inside the run directory (after engine creates it)
|
||||
run_dir = os.path.join(project_folder, "runs", f"run_{run.id}")
|
||||
console_log = os.path.join(run_dir, "web_console.log")
|
||||
if os.path.exists(console_log):
|
||||
with open(console_log, 'r', encoding='utf-8', errors='replace') as f:
|
||||
log_content = f.read()
|
||||
except Exception as _e:
|
||||
print(f"[run_status] file fallback error for run {id}: {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
|
||||
|
||||
response = {
|
||||
"status": run.status,
|
||||
"log": log_content,
|
||||
"cost": run.cost,
|
||||
"percent": run.progress,
|
||||
"start_time": run.start_time.timestamp() if run.start_time else None,
|
||||
"server_timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"db_log_count": len(logs),
|
||||
"latest_log_timestamp": last_log.timestamp.isoformat() if last_log else None,
|
||||
}
|
||||
|
||||
if last_log:
|
||||
response["progress"] = {
|
||||
"phase": last_log.phase,
|
||||
"message": last_log.message,
|
||||
"timestamp": last_log.timestamp.timestamp()
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/download')
|
||||
@login_required
|
||||
def download_artifact(run_id):
|
||||
filename = request.args.get('file')
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if not filename: return "Missing filename", 400
|
||||
|
||||
if os.path.isabs(filename) or ".." in os.path.normpath(filename) or ":" in filename:
|
||||
return "Invalid filename", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
|
||||
if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir):
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
for d in subdirs:
|
||||
possible_path = os.path.join(d, filename)
|
||||
if os.path.exists(os.path.join(run_dir, possible_path)):
|
||||
filename = possible_path
|
||||
break
|
||||
|
||||
return send_from_directory(run_dir, filename, as_attachment=True)
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/read/<string:book_folder>')
|
||||
@login_required
|
||||
def read_book(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
|
||||
if not os.path.exists(ms_path):
|
||||
flash("Manuscript not found.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
manuscript = utils.load_json(ms_path)
|
||||
manuscript.sort(key=utils.chapter_sort_key)
|
||||
|
||||
for ch in manuscript:
|
||||
ch['html_content'] = markdown.markdown(ch.get('content', ''))
|
||||
|
||||
return render_template('read_book.html', run=run, book_folder=book_folder, manuscript=manuscript)
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/save_chapter', methods=['POST'])
|
||||
@login_required
|
||||
def save_chapter(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
return "Cannot edit chapter while run is active.", 409
|
||||
|
||||
book_folder = request.form.get('book_folder')
|
||||
chap_num_raw = request.form.get('chapter_num')
|
||||
try: chap_num = int(chap_num_raw)
|
||||
except: chap_num = chap_num_raw
|
||||
new_content = request.form.get('content')
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
ms_path = os.path.join(run_dir, book_folder, "manuscript.json")
|
||||
|
||||
if os.path.exists(ms_path):
|
||||
ms = utils.load_json(ms_path)
|
||||
for ch in ms:
|
||||
if str(ch.get('num')) == str(chap_num):
|
||||
ch['content'] = new_content
|
||||
break
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
if os.path.exists(bp_path):
|
||||
bp = utils.load_json(bp_path)
|
||||
exporter.compile_files(bp, ms, book_path)
|
||||
|
||||
return "Saved", 200
|
||||
return "Error", 500
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/check_consistency/<string:book_folder>')
|
||||
@login_required
|
||||
def check_consistency(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json"))
|
||||
ms = utils.load_json(os.path.join(book_path, "manuscript.json"))
|
||||
|
||||
if not bp or not ms:
|
||||
return "Data files missing or corrupt.", 404
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
report = story_editor.analyze_consistency(bp, ms, book_path)
|
||||
return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder)
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/sync_book/<string:book_folder>', methods=['POST'])
|
||||
@login_required
|
||||
def sync_book_metadata(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
flash("Cannot sync metadata while run is active.")
|
||||
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
|
||||
if os.path.exists(ms_path) and os.path.exists(bp_path):
|
||||
ms = utils.load_json(ms_path)
|
||||
bp = utils.load_json(bp_path)
|
||||
|
||||
if not ms or not bp:
|
||||
flash("Data files corrupt.")
|
||||
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
bp = bible_tracker.harvest_metadata(bp, book_path, ms)
|
||||
|
||||
tracking_path = os.path.join(book_path, "tracking_characters.json")
|
||||
if os.path.exists(tracking_path):
|
||||
tracking_chars = utils.load_json(tracking_path) or {}
|
||||
updated_tracking = False
|
||||
for c in bp.get('characters', []):
|
||||
if c.get('name') and c['name'] not in tracking_chars:
|
||||
tracking_chars[c['name']] = {"descriptors": [c.get('description', '')], "likes_dislikes": [], "last_worn": "Unknown"}
|
||||
updated_tracking = True
|
||||
if updated_tracking:
|
||||
with open(tracking_path, 'w') as f: json.dump(tracking_chars, f, indent=2)
|
||||
|
||||
style_persona.update_persona_sample(bp, book_path)
|
||||
|
||||
with open(bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
flash("Metadata synced. Future generations will respect your edits.")
|
||||
else:
|
||||
flash("Files not found.")
|
||||
|
||||
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/rewrite_chapter', methods=['POST'])
|
||||
@login_required
|
||||
def rewrite_chapter(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id:
|
||||
return {"error": "Unauthorized"}, 403
|
||||
|
||||
if run.status == 'running':
|
||||
return {"error": "Cannot rewrite while run is active."}, 409
|
||||
|
||||
data = request.json
|
||||
book_folder = data.get('book_folder')
|
||||
chap_num = data.get('chapter_num')
|
||||
instruction = data.get('instruction')
|
||||
|
||||
if not book_folder or chap_num is None or not instruction:
|
||||
return {"error": "Missing parameters"}, 400
|
||||
|
||||
if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400
|
||||
|
||||
try: chap_num = int(chap_num)
|
||||
except: pass
|
||||
|
||||
task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction)
|
||||
|
||||
session['rewrite_task_id'] = task.id
|
||||
|
||||
return {"status": "queued", "task_id": task.id}, 202
|
||||
|
||||
|
||||
@run_bp.route('/task_status/<string:task_id>')
|
||||
@login_required
|
||||
def get_task_status(task_id):
|
||||
try:
|
||||
task_result = huey.result(task_id, preserve=True)
|
||||
except Exception as e:
|
||||
return {"status": "completed", "success": False, "error": str(e)}
|
||||
|
||||
if task_result is None:
|
||||
return {"status": "running"}
|
||||
else:
|
||||
return {"status": "completed", "success": task_result}
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/revise_book/<string:book_folder>', methods=['POST'])
|
||||
@login_required
|
||||
def revise_book(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id:
|
||||
flash("Unauthorized.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
if run.status == 'running':
|
||||
flash("A run is already active. Please wait for it to finish.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
instruction = request.form.get('instruction', '').strip()
|
||||
if not instruction:
|
||||
flash("Please provide an instruction describing what to fix.")
|
||||
return redirect(url_for('run.check_consistency', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
bible_path = os.path.join(run.project.folder_path, "bible.json")
|
||||
if not os.path.exists(bible_path):
|
||||
flash("Bible file not found. Cannot start revision.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
new_run = Run(project_id=run.project_id, status='queued', start_time=datetime.utcnow())
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
from web.tasks import generate_book_task
|
||||
generate_book_task(new_run.id, run.project.folder_path, bible_path, feedback=instruction, source_run_id=run.id)
|
||||
|
||||
flash(f"Book revision queued. Instruction: '{instruction[:80]}...' — a new run has been started.")
|
||||
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('/project/<int:run_id>/eval_report/<string:book_folder>')
|
||||
@login_required
|
||||
def eval_report(run_id, book_folder):
|
||||
"""Generate and download the self-contained HTML evaluation report."""
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id:
|
||||
return "Unauthorized", 403
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder:
|
||||
return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json")) or \
|
||||
utils.load_json(os.path.join(book_path, "blueprint_initial.json"))
|
||||
|
||||
html = story_eval_logger.generate_html_report(book_path, bp)
|
||||
if not html:
|
||||
return (
|
||||
"<html><body style='font-family:sans-serif;padding:40px'>"
|
||||
"<h2>No evaluation data yet.</h2>"
|
||||
"<p>The evaluation report is generated during the writing phase. "
|
||||
"Start a generation run and the report will be available once chapters have been evaluated.</p>"
|
||||
"</body></html>"
|
||||
), 200
|
||||
|
||||
from flask import Response
|
||||
safe_title = utils.sanitize_filename(
|
||||
(bp or {}).get('book_metadata', {}).get('title', book_folder) or book_folder
|
||||
)[:40]
|
||||
filename = f"eval_report_{safe_title}.html"
|
||||
return Response(
|
||||
html,
|
||||
mimetype='text/html',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
||||
)
|
||||
|
||||
|
||||
@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'])
|
||||
@login_required
|
||||
def regenerate_artifacts(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
flash("Run is already active. Please wait for it to finish.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
feedback = request.form.get('feedback')
|
||||
|
||||
run.status = 'queued'
|
||||
db.session.commit()
|
||||
|
||||
regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback)
|
||||
flash("Regenerating cover and files with updated metadata...")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
573
web/tasks.py
Normal file
573
web/tasks.py
Normal file
@@ -0,0 +1,573 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from huey import SqliteHuey
|
||||
from web.db import db, Run, User, Project
|
||||
from core import utils, config
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import bible_tracker
|
||||
from marketing import cover as marketing_cover
|
||||
from export import exporter
|
||||
|
||||
# Configure Huey (Task Queue)
|
||||
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
|
||||
|
||||
def _robust_update_run_status(db_path, run_id, status, retries=5, **extra_cols):
|
||||
"""Update run status with exponential-backoff retry. Raises RuntimeError if all retries fail."""
|
||||
import sys as _sys
|
||||
cols = {"status": status}
|
||||
cols.update(extra_cols)
|
||||
set_clause = ", ".join(f"{k} = ?" for k in cols)
|
||||
values = list(cols.values()) + [run_id]
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=30, check_same_thread=False) as conn:
|
||||
conn.execute(f"UPDATE run SET {set_clause} WHERE id = ?", values)
|
||||
return
|
||||
except sqlite3.OperationalError as e:
|
||||
wait = attempt + 1
|
||||
print(f"[DB WARN run={run_id}] Status update locked (attempt {attempt+1}/{retries}), retry in {wait}s: {e}", flush=True, file=_sys.stdout)
|
||||
time.sleep(wait)
|
||||
except Exception as e:
|
||||
print(f"[DB ERROR run={run_id}] Unexpected error on status update: {type(e).__name__}: {e}", flush=True, file=_sys.stdout)
|
||||
raise
|
||||
|
||||
msg = f"[DB CRITICAL run={run_id}] Failed to update status='{status}' after {retries} attempts."
|
||||
print(msg, flush=True, file=_sys.stdout)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def db_heartbeat_callback(db_path, run_id):
|
||||
"""Updates last_heartbeat timestamp for the run in SQLite."""
|
||||
import sys as _sys
|
||||
for _ in range(3):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10, check_same_thread=False) as conn:
|
||||
conn.execute("UPDATE run SET last_heartbeat = ? WHERE id = ?",
|
||||
(datetime.utcnow().isoformat(), run_id))
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
time.sleep(0.2)
|
||||
except Exception as _e:
|
||||
print(f"[db_heartbeat ERROR run={run_id}] {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
|
||||
return
|
||||
|
||||
|
||||
def db_log_callback(db_path, run_id, phase, msg):
|
||||
"""Writes log entry directly to SQLite to avoid Flask Context issues in threads."""
|
||||
import sys as _sys
|
||||
for _ in range(5):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=30, check_same_thread=False) as conn:
|
||||
conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)",
|
||||
(run_id, datetime.utcnow().isoformat(), phase, str(msg)))
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
time.sleep(0.1)
|
||||
except Exception as _e:
|
||||
print(f"[db_log_callback ERROR run={run_id}] {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
|
||||
try:
|
||||
import os as _os
|
||||
from core import config as _cfg
|
||||
_app_log = _os.path.join(_cfg.DATA_DIR, "app.log")
|
||||
with open(_app_log, 'a', encoding='utf-8') as _f:
|
||||
_f.write(f"[db_log_callback ERROR run={run_id}] {type(_e).__name__}: {_e}\n")
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
def db_progress_callback(db_path, run_id, percent):
|
||||
"""Updates run progress in SQLite."""
|
||||
import sys as _sys
|
||||
for _ in range(5):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=30, check_same_thread=False) as conn:
|
||||
conn.execute("UPDATE run SET progress = ? WHERE id = ?", (percent, run_id))
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
time.sleep(0.1)
|
||||
except Exception as _e:
|
||||
print(f"[db_progress_callback ERROR run={run_id}] {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout)
|
||||
break
|
||||
|
||||
@huey.task()
|
||||
def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedback=None, source_run_id=None, keep_cover=False, exclude_folders=None):
|
||||
"""
|
||||
Background task to run the book generation.
|
||||
"""
|
||||
import sys as _sys
|
||||
def _task_log(msg):
|
||||
"""Print directly to stdout (docker logs) regardless of utils state."""
|
||||
print(f"[TASK run={run_id}] {msg}", flush=True, file=_sys.stdout)
|
||||
|
||||
_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
|
||||
log_filename = f"system_log_{run_id}.txt"
|
||||
|
||||
# Log to project root initially until run folder is created by engine
|
||||
initial_log = os.path.join(project_path, log_filename)
|
||||
|
||||
# Touch the file immediately so the UI has something to poll even if the
|
||||
# worker crashes before the first utils.log() call.
|
||||
try:
|
||||
with open(initial_log, 'a', encoding='utf-8') as _f:
|
||||
pass
|
||||
_task_log(f"Log file created: {initial_log}")
|
||||
except Exception as _e:
|
||||
_task_log(f"WARNING: Could not touch log file {initial_log}: {_e}")
|
||||
|
||||
utils.set_log_file(initial_log)
|
||||
|
||||
# Hook up Database Logging
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
utils.set_progress_callback(lambda p: db_progress_callback(db_path, run_id, p))
|
||||
utils.set_heartbeat_callback(lambda: db_heartbeat_callback(db_path, run_id))
|
||||
|
||||
# Set Status to Running (with start_time and initial heartbeat)
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'running',
|
||||
start_time=datetime.utcnow().isoformat(),
|
||||
last_heartbeat=datetime.utcnow().isoformat())
|
||||
_task_log("Run status set to 'running' in DB.")
|
||||
except Exception as e:
|
||||
_task_log(f"WARNING: Could not set run status to 'running': {e}")
|
||||
utils.log("SYSTEM", f"WARNING: run status update failed (run {run_id}): {e}")
|
||||
|
||||
utils.log("SYSTEM", f"Starting Job #{run_id}")
|
||||
|
||||
status = "failed" # Default to failed; overwritten to "completed" only on clean success
|
||||
total_cost = 0.0
|
||||
final_log_path = initial_log
|
||||
|
||||
try:
|
||||
# 1.1 Handle Feedback / Modification (Re-run logic)
|
||||
if feedback and source_run_id:
|
||||
utils.log("SYSTEM", f"Applying feedback to Run #{source_run_id}: '{feedback}'")
|
||||
|
||||
bible_data = utils.load_json(bible_path)
|
||||
|
||||
if bible_data:
|
||||
try:
|
||||
ai_setup.init_models()
|
||||
new_bible = bible_tracker.refine_bible(bible_data, feedback, project_path)
|
||||
if new_bible:
|
||||
bible_data = new_bible
|
||||
with open(bible_path, 'w') as f: json.dump(bible_data, f, indent=2)
|
||||
utils.log("SYSTEM", "Bible updated with feedback.")
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Failed to refine bible: {e}")
|
||||
|
||||
# 1.2 Keep Cover Art Logic
|
||||
if keep_cover:
|
||||
source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
|
||||
if os.path.exists(source_run_dir):
|
||||
utils.log("SYSTEM", "Attempting to preserve cover art...")
|
||||
|
||||
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
||||
|
||||
source_books = {}
|
||||
for d in os.listdir(source_run_dir):
|
||||
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
|
||||
parts = d.split('_')
|
||||
if len(parts) > 1 and parts[1].isdigit():
|
||||
source_books[int(parts[1])] = os.path.join(source_run_dir, d)
|
||||
|
||||
if bible_data and 'books' in bible_data:
|
||||
for i, book in enumerate(bible_data['books']):
|
||||
b_num = book.get('book_number', i+1)
|
||||
if b_num in source_books:
|
||||
src_folder = source_books[b_num]
|
||||
safe_title = utils.sanitize_filename(book.get('title', f"Book_{b_num}"))
|
||||
target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
src_cover = os.path.join(src_folder, "cover.png")
|
||||
if os.path.exists(src_cover):
|
||||
shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
|
||||
if os.path.exists(os.path.join(src_folder, "cover_art.png")):
|
||||
shutil.copy2(os.path.join(src_folder, "cover_art.png"), os.path.join(target_folder, "cover_art.png"))
|
||||
utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
|
||||
|
||||
# 1.5 Copy Forward Logic (Series Optimization)
|
||||
is_series = False
|
||||
if os.path.exists(bible_path):
|
||||
bible_data = utils.load_json(bible_path)
|
||||
if bible_data:
|
||||
is_series = bible_data.get('project_metadata', {}).get('is_series', False)
|
||||
|
||||
runs_dir = os.path.join(project_path, "runs")
|
||||
|
||||
if allow_copy and is_series and os.path.exists(runs_dir):
|
||||
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
|
||||
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
|
||||
if all_runs:
|
||||
latest_run_dir = os.path.join(runs_dir, all_runs[-1])
|
||||
current_run_dir = os.path.join(runs_dir, f"run_{run_id}")
|
||||
os.makedirs(current_run_dir, exist_ok=True)
|
||||
|
||||
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
|
||||
for item in os.listdir(latest_run_dir):
|
||||
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
|
||||
if exclude_folders and item in exclude_folders:
|
||||
utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).")
|
||||
continue
|
||||
|
||||
if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")):
|
||||
src = os.path.join(latest_run_dir, item)
|
||||
dst = os.path.join(current_run_dir, item)
|
||||
try:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
utils.log("SYSTEM", f" -> Copied {item} (Skipping generation).")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
|
||||
|
||||
# 2. Save Bible Snapshot alongside this run
|
||||
run_dir_early = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
os.makedirs(run_dir_early, exist_ok=True)
|
||||
if os.path.exists(bible_path):
|
||||
snapshot_path = os.path.join(run_dir_early, "bible_snapshot.json")
|
||||
try:
|
||||
shutil.copy2(bible_path, snapshot_path)
|
||||
utils.log("SYSTEM", f"Bible snapshot saved to run folder.")
|
||||
except Exception as _e:
|
||||
utils.log("SYSTEM", f"WARNING: Could not save bible snapshot: {_e}")
|
||||
|
||||
# 3. Run Generation
|
||||
from cli.engine import run_generation
|
||||
run_generation(bible_path, specific_run_id=run_id)
|
||||
|
||||
utils.log("SYSTEM", "Job Complete.")
|
||||
utils.update_progress(100)
|
||||
status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
import traceback as _tb
|
||||
_task_log(f"ERROR: Job failed — {type(e).__name__}: {e}")
|
||||
_task_log(_tb.format_exc())
|
||||
utils.log("ERROR", f"Job Failed: {e}")
|
||||
# status remains "failed" (set before try block)
|
||||
|
||||
finally:
|
||||
# 3. Calculate Cost & Cleanup — guaranteed to run even if worker crashes
|
||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
|
||||
if os.path.exists(run_dir):
|
||||
final_log_path = os.path.join(run_dir, "web_console.log")
|
||||
if os.path.exists(initial_log):
|
||||
try:
|
||||
os.rename(initial_log, final_log_path)
|
||||
except OSError:
|
||||
shutil.copy2(initial_log, final_log_path)
|
||||
os.remove(initial_log)
|
||||
|
||||
for item in os.listdir(run_dir):
|
||||
item_path = os.path.join(run_dir, item)
|
||||
if os.path.isdir(item_path) and item.startswith("Book_"):
|
||||
usage_path = os.path.join(item_path, "usage_log.json")
|
||||
if os.path.exists(usage_path):
|
||||
data = utils.load_json(usage_path)
|
||||
total_cost += data.get('totals', {}).get('est_cost_usd', 0.0)
|
||||
|
||||
# 4. Update Database with Final Status — run is never left in 'running' state
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, status,
|
||||
cost=total_cost,
|
||||
end_time=datetime.utcnow().isoformat(),
|
||||
log_file=final_log_path,
|
||||
progress=100)
|
||||
except Exception as e:
|
||||
print(f"[CRITICAL run={run_id}] Final status update failed after all retries: {e}", flush=True)
|
||||
|
||||
_task_log(f"Task finished. status={status} cost=${total_cost:.4f}")
|
||||
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
|
||||
|
||||
|
||||
@huey.task()
|
||||
def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
|
||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
log_file = os.path.join(run_dir, "web_console.log")
|
||||
|
||||
if not os.path.exists(run_dir):
|
||||
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
||||
|
||||
try:
|
||||
with open(log_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n")
|
||||
except: pass
|
||||
|
||||
utils.set_log_file(log_file)
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=30, check_same_thread=False) as conn:
|
||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not clear log_entry for regen: {_e}", flush=True)
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'running',
|
||||
start_time=datetime.utcnow().isoformat(),
|
||||
last_heartbeat=datetime.utcnow().isoformat())
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not set status to 'running' for regen: {_e}", flush=True)
|
||||
|
||||
utils.log("SYSTEM", "Starting Artifact Regeneration...")
|
||||
|
||||
book_dir = run_dir
|
||||
if os.path.exists(run_dir):
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
if subdirs: book_dir = os.path.join(run_dir, subdirs[0])
|
||||
|
||||
bible_path = os.path.join(project_path, "bible.json")
|
||||
|
||||
if not os.path.exists(run_dir) or not os.path.exists(bible_path):
|
||||
utils.log("ERROR", "Run directory or Bible not found.")
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'failed')
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not set status to 'failed': {_e}", flush=True)
|
||||
return
|
||||
|
||||
bible = utils.load_json(bible_path)
|
||||
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
|
||||
ms_path = os.path.join(book_dir, "manuscript.json")
|
||||
|
||||
if not os.path.exists(final_bp_path) or not os.path.exists(ms_path):
|
||||
utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}")
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'failed')
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not set status to 'failed': {_e}", flush=True)
|
||||
return
|
||||
|
||||
bp = utils.load_json(final_bp_path)
|
||||
ms = utils.load_json(ms_path)
|
||||
|
||||
meta = bible.get('project_metadata', {})
|
||||
if 'book_metadata' in bp:
|
||||
for k in ['author', 'genre', 'target_audience', 'style']:
|
||||
if k in meta:
|
||||
bp['book_metadata'][k] = meta[k]
|
||||
|
||||
if bp.get('series_metadata', {}).get('is_series'):
|
||||
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
|
||||
b_num = bp['series_metadata'].get('book_number')
|
||||
for b in bible.get('books', []):
|
||||
if b.get('book_number') == b_num:
|
||||
bp['book_metadata']['title'] = b.get('title', bp['book_metadata'].get('title'))
|
||||
break
|
||||
else:
|
||||
bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title'))
|
||||
|
||||
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
|
||||
try:
|
||||
ai_setup.init_models()
|
||||
|
||||
tracking = None
|
||||
events_path = os.path.join(book_dir, "tracking_events.json")
|
||||
if os.path.exists(events_path):
|
||||
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
|
||||
|
||||
marketing_cover.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
||||
exporter.compile_files(bp, ms, book_dir)
|
||||
|
||||
utils.log("SYSTEM", "Regeneration Complete.")
|
||||
final_status = 'completed'
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Regeneration Failed: {e}")
|
||||
final_status = 'failed'
|
||||
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, final_status)
|
||||
except Exception as _e:
|
||||
print(f"[CRITICAL run={run_id}] Final regen status update failed: {_e}", flush=True)
|
||||
|
||||
|
||||
@huey.task()
|
||||
def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction):
|
||||
"""
|
||||
Background task to rewrite a single chapter and propagate changes.
|
||||
"""
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
|
||||
try:
|
||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
|
||||
log_file = os.path.join(run_dir, "web_console.log")
|
||||
if not os.path.exists(log_file):
|
||||
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
||||
|
||||
try:
|
||||
with open(log_file, 'w', encoding='utf-8') as f: f.write("")
|
||||
except: pass
|
||||
|
||||
utils.set_log_file(log_file)
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=30, check_same_thread=False) as conn:
|
||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not clear log_entry for rewrite: {_e}", flush=True)
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'running',
|
||||
start_time=datetime.utcnow().isoformat(),
|
||||
last_heartbeat=datetime.utcnow().isoformat())
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not set status to 'running' for rewrite: {_e}", flush=True)
|
||||
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
|
||||
if not (os.path.exists(ms_path) and os.path.exists(bp_path)):
|
||||
utils.log("ERROR", f"Rewrite failed: files not found for run {run_id}/{book_folder}")
|
||||
return False
|
||||
|
||||
ms = utils.load_json(ms_path)
|
||||
bp = utils.load_json(bp_path)
|
||||
|
||||
ai_setup.init_models()
|
||||
|
||||
from story import editor as story_editor
|
||||
result = story_editor.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
|
||||
|
||||
if result and result[0]:
|
||||
new_text, summary = result
|
||||
for ch in ms:
|
||||
if str(ch.get('num')) == str(chap_num):
|
||||
ch['content'] = new_text
|
||||
break
|
||||
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
|
||||
updated_ms = story_editor.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
|
||||
if updated_ms:
|
||||
ms = updated_ms
|
||||
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
exporter.compile_files(bp, ms, book_path)
|
||||
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'completed',
|
||||
end_time=datetime.utcnow().isoformat())
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not set status to 'completed': {_e}", flush=True)
|
||||
return True
|
||||
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'completed',
|
||||
end_time=datetime.utcnow().isoformat())
|
||||
except Exception as _e:
|
||||
print(f"[WARN run={run_id}] Could not set status to 'completed': {_e}", flush=True)
|
||||
return False
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Rewrite task exception for run {run_id}/{book_folder}: {e}")
|
||||
try:
|
||||
_robust_update_run_status(db_path, run_id, 'failed',
|
||||
end_time=datetime.utcnow().isoformat())
|
||||
except Exception as _e:
|
||||
print(f"[CRITICAL run={run_id}] Could not set status to 'failed' after rewrite error: {_e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
@huey.task()
|
||||
def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
|
||||
"""
|
||||
Background task to refine the Bible.
|
||||
Handles partial merging of selected keys into a temp base before refinement.
|
||||
"""
|
||||
try:
|
||||
bible_path = os.path.join(project_path, "bible.json")
|
||||
draft_path = os.path.join(project_path, "bible_draft.json")
|
||||
lock_path = os.path.join(project_path, ".refining")
|
||||
|
||||
with open(lock_path, 'w') as f: f.write("running")
|
||||
|
||||
base_bible = utils.load_json(bible_path)
|
||||
if not base_bible: return False
|
||||
|
||||
if source_type == 'draft' and os.path.exists(draft_path):
|
||||
draft_bible = utils.load_json(draft_path)
|
||||
|
||||
if selected_keys is not None and draft_bible:
|
||||
base_bible = bible_tracker.merge_selected_changes(base_bible, draft_bible, selected_keys)
|
||||
elif draft_bible:
|
||||
base_bible = draft_bible
|
||||
|
||||
ai_setup.init_models()
|
||||
|
||||
new_bible = bible_tracker.refine_bible(base_bible, instruction, project_path)
|
||||
|
||||
if new_bible:
|
||||
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Bible refinement task failed: {e}")
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
Reference in New Issue
Block a user