v2.0.0: Modularize project into single-responsibility packages
Replaced monolithic modules/ package with a clean architecture:
- core/ config.py, utils.py
- ai/ models.py (ResilientModel), setup.py (init_models)
- story/ planner.py, writer.py, editor.py, style_persona.py, bible_tracker.py
- marketing/ cover.py, blurb.py, fonts.py, assets.py
- export/ exporter.py
- web/ app.py (Flask factory), db.py, helpers.py, tasks.py, routes/{auth,project,run,persona,admin}.py
- cli/ engine.py (run_generation), wizard.py (BookWizard)
Flask routes split into 5 Blueprints; all templates updated with blueprint-
prefixed url_for() calls. Dockerfile and docker-compose updated to use
web.app entry point and new package paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
ai/__init__.py
Normal file
0
ai/__init__.py
Normal file
71
ai/models.py
Normal file
71
ai/models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
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-pro"
|
||||
writer_model_name = "models/gemini-1.5-flash"
|
||||
artist_model_name = "models/gemini-1.5-flash"
|
||||
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)
|
||||
|
||||
def generate_content(self, *args, **kwargs):
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
base_delay = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
return self.model.generate_content(*args, **kwargs)
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
is_retryable = "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 "deadline" 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"⚠️ Quota error on {self.role} ({self.name}). Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
if retries == 0:
|
||||
utils.log("SYSTEM", "Attempting to re-optimize models to find alternative...")
|
||||
from ai import setup as _setup
|
||||
_setup.init_models(force=True)
|
||||
|
||||
retries += 1
|
||||
continue
|
||||
raise e
|
||||
289
ai/setup.py
Normal file
289
ai/setup.py
Normal file
@@ -0,0 +1,289 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
import google.generativeai as genai
|
||||
from core import config, utils
|
||||
from ai import models
|
||||
|
||||
|
||||
def get_optimal_model(base_type="pro"):
|
||||
try:
|
||||
available = [m for m in genai.list_models() 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 for complex reasoning and JSON adherence.", "estimated_cost": "$0.00/1M (Experimental)"},
|
||||
"writer": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for fast, high-quality creative writing.", "estimated_cost": "$0.10/1M"},
|
||||
"artist": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for visual prompt design.", "estimated_cost": "$0.10/1M"},
|
||||
"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())
|
||||
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. Prefer newer Gemini 2.x models when available.
|
||||
|
||||
AVAILABLE_MODELS:
|
||||
{json.dumps(compatible)}
|
||||
|
||||
PRICING_CONTEXT (USD per 1M tokens, approximate):
|
||||
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing.
|
||||
- Gemini 2.0 Flash: ~$0.10 Input / $0.40 Output. (Fast, cost-effective, excellent quality).
|
||||
- Gemini 2.0 Pro Exp: Free experimental tier with strong reasoning.
|
||||
- Gemini 1.5 Flash: ~$0.075 Input / $0.30 Output. (Legacy, still reliable).
|
||||
- Gemini 1.5 Pro: ~$1.25 Input / $5.00 Output. (Legacy, expensive).
|
||||
|
||||
CRITERIA:
|
||||
- LOGIC: Needs complex reasoning, strict JSON adherence, plot consistency, and instruction following.
|
||||
-> Prefer: Gemini 2.5 Pro > 2.0 Pro > 2.0 Flash > 1.5 Pro
|
||||
- WRITER: Needs creativity, prose quality, long-form text generation, and speed.
|
||||
-> Prefer: Gemini 2.5 Flash/Pro > 2.0 Flash > 1.5 Flash (balance quality/cost)
|
||||
- ARTIST: Needs rich visual description, prompt understanding for cover art design.
|
||||
-> Prefer: Gemini 2.0 Flash > 1.5 Flash (speed and visual understanding)
|
||||
|
||||
CONSTRAINTS:
|
||||
- Strongly prefer Gemini 2.x over 1.5 where available.
|
||||
- Avoid 'experimental' or 'preview' only if a stable 2.x version exists; otherwise experimental 2.x is fine.
|
||||
- 'thinking' models are too slow/expensive for Writer/Artist roles.
|
||||
- Provide a ranking of ALL available models from best to worst overall.
|
||||
|
||||
OUTPUT_FORMAT (JSON only, no markdown):
|
||||
{{
|
||||
"logic": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }},
|
||||
"writer": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }},
|
||||
"artist": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }},
|
||||
"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))
|
||||
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']:
|
||||
if 'estimated_cost' not in selected_models.get(role, {}) or selected_models[role].get('estimated_cost') == 'N/A':
|
||||
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')
|
||||
return role_data, 'N/A'
|
||||
|
||||
logic_name, logic_cost = get_model_details(selected_models['logic'])
|
||||
writer_name, writer_cost = get_model_details(selected_models['writer'])
|
||||
artist_name, artist_cost = get_model_details(selected_models['artist'])
|
||||
|
||||
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
|
||||
|
||||
utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}) | Writer={writer_name} ({writer_cost}) | Artist={artist_name}")
|
||||
|
||||
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)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(models.Request())
|
||||
except Exception:
|
||||
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:
|
||||
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)
|
||||
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'})")
|
||||
Reference in New Issue
Block a user