Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

1138 lines
42 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Strategist confirmation stage UI Server (Step 4)
Lightweight Flask backend for the interactive, visual Strategist confirmation stage page.
Strategist writes its recommendations to
``<project>/confirm_ui/recommendations.json``; this server renders them as a
clickable page (color swatches, live font previews, candidate picks). On
submit it writes the user's final choices to
``<project>/confirm_ui/result.json`` for the AI to read back.
This is the confirmation surface only. The chat fallback always remains valid:
if the browser cannot open (remote / headless / web host), the AI presents the
same Strategist confirmation stage in chat.
See scripts/docs/confirm_ui.md for the round-trip data contract and schema.
Usage:
python3 scripts/confirm_ui/server.py <project_dir>
Examples:
python3 scripts/confirm_ui/server.py projects/my-project
python3 scripts/confirm_ui/server.py projects/my-project --port 5051
python3 scripts/confirm_ui/server.py projects/my-project --no-browser
python3 scripts/confirm_ui/server.py projects/my-project --daemon --wait
Dependencies:
flask>=3.0.0
"""
import argparse
import atexit
import json
import logging
import os
import re
import signal
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
import webbrowser
from pathlib import Path
from typing import Optional
from flask import Flask, jsonify, request, send_from_directory
# Local — sys.path injection for sibling module (code-style.md §3)
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from server_common import ( # noqa: E402
claim_lock as _claim_lock,
clear_lock as _clear_lock,
find_free_port as _find_free_port,
lock_pid as _lock_pid,
popen_detached as _popen_detached,
process_alive as _process_alive,
read_lock as _read_lock,
release_lock as _release_lock,
)
configure_utf8_stdio()
logger = logging.getLogger('confirm_ui')
# Per-project lock file. Lives at <project_path>/.confirm_ui.lock and matches
# the *.lock entry already in the repo .gitignore. Independent of the live
# preview lock so the two surfaces never collide.
LOCK_FILE_NAME = '.confirm_ui.lock'
# Round-trip/session files, all under <project_path>/confirm_ui/.
CONFIRM_DIR_NAME = 'confirm_ui'
RECOMMENDATIONS_NAME = 'recommendations.json'
RESULT_NAME = 'result.json'
SESSION_NAME = 'session.json'
# Static option universe served at /api/catalogs (canvas synced live from config).
_CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json'
_ICON_LIBRARY_DIR = Path(__file__).resolve().parents[2] / 'templates' / 'icons'
_AI_IMAGE_COMPARISON_DIR = Path(__file__).resolve().parents[2] / 'references' / 'ai-image-comparison'
_ICON_PREVIEW_SAMPLES = {
'chunk-filled': ('home', 'chart-line', 'users', 'target'),
'tabler-filled': ('home', 'chart-dots', 'user', 'bulb'),
'tabler-outline': ('home', 'chart-line', 'users', 'bulb'),
'phosphor-duotone': ('house', 'chart-line', 'users', 'target'),
}
# Shares port 5050 with the live preview server (svg_editor/server.py). The two
# never run at once: confirm is Step 4 and shuts down on confirm (or idle),
# freeing the port before live preview starts at Step 6. One port = one forward
# rule for the whole pipeline. They still keep separate processes and locks.
DEFAULT_PORT = 5050
PUBLIC_HOST = '127.0.0.1'
STARTUP_TIMEOUT = 10
# Default --wait budget, kept just under the 600s Bash-tool ceiling so the
# parent (waiting) command returns before the calling harness kills it. The
# detached child server keeps running on its own --timeout idle budget, so a
# slow user can still confirm after the wait returns; the caller re-checks
# result.json before falling back to chat.
WAIT_TIMEOUT_DEFAULT = 590
def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict:
"""Read a JSON object, retrying briefly around non-atomic external writes."""
last_error: Exception = ValueError('unknown JSON read error')
for attempt in range(retries + 1):
try:
data = json.loads(path.read_text(encoding='utf-8-sig'))
if isinstance(data, dict):
return data
raise ValueError(f'{path} top-level JSON value must be an object')
except (OSError, json.JSONDecodeError, ValueError) as exc:
last_error = exc
if attempt < retries:
time.sleep(delay)
continue
raise last_error
raise last_error
def _write_json_atomic(path: Path, data: dict) -> None:
"""Write a JSON object with replace semantics so waiters never see a partial file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp')
try:
tmp.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding='utf-8',
)
os.replace(tmp, path)
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def _server_url(port: int, path: str = '') -> str:
"""Return the loopback URL shown to users and used by readiness probes."""
suffix = path if path.startswith('/') or not path else f'/{path}'
return f'http://{PUBLIC_HOST}:{port}{suffix}'
def _wait_for_server_ready(
port: int,
proc: subprocess.Popen,
timeout: int = STARTUP_TIMEOUT,
) -> bool:
"""Wait until the detached child is accepting HTTP requests."""
deadline = time.time() + timeout
last_error = ''
health_url = _server_url(port, '/api/health')
while time.time() < deadline:
returncode = proc.poll()
if returncode is not None:
logger.error('confirm UI exited during startup (code=%s)', returncode)
return False
try:
with urllib.request.urlopen(health_url, timeout=1) as resp:
if 200 <= resp.status < 500:
return True
except (OSError, urllib.error.URLError) as exc:
last_error = str(exc)
time.sleep(0.2)
logger.error(
'confirm UI did not become ready at %s within %ss%s',
health_url,
timeout,
f' (last error: {last_error})' if last_error else '',
)
return False
def _launch_background_server(
project_path: Path,
*,
preferred_port: int,
idle_timeout: int,
open_browser: bool,
) -> tuple[subprocess.Popen, int, Path]:
"""Start the confirm server child and wait until it is reachable."""
confirm_dir = project_path / CONFIRM_DIR_NAME
confirm_dir.mkdir(parents=True, exist_ok=True)
log_path = confirm_dir / 'server.log'
port = _find_free_port(preferred_port)
cmd = [
sys.executable,
str(Path(__file__).resolve()),
str(project_path),
'--port',
str(port),
'--timeout',
str(idle_timeout),
'--no-browser',
]
with log_path.open('a', encoding='utf-8') as log:
proc = _popen_detached(
cmd,
stdout=log,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
logger=logger,
)
logger.info('log: %s', log_path)
if not _wait_for_server_ready(port, proc):
raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}')
_sync_session_state(confirm_dir, server_port=port, event='server-ready')
url = _server_url(port)
logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
if open_browser:
webbrowser.open(url)
return proc, port, log_path
def _live_lock(lock_file: Path) -> Optional[dict]:
"""Return a live lock; stale entries are overwritten by the recovered child."""
existing = _read_lock(lock_file)
if not existing:
return None
if _process_alive(_lock_pid(existing)):
return existing
return None
def _preferred_recovery_port(lock_file: Path, fallback: int) -> int:
"""Prefer a stale lock's port so an already-open browser can reconnect."""
existing = _read_lock(lock_file)
try:
port = int((existing or {}).get('port', 0) or 0)
except (TypeError, ValueError):
port = 0
return port or fallback
def _open_browser_async(url: str, delay: float = 0.4) -> None:
"""Open the browser after Flask has had a moment to bind its socket."""
def _open() -> None:
time.sleep(delay)
webbrowser.open(url)
threading.Thread(target=_open, daemon=True).start()
def _wait_for_result(
result_file: Path,
proc: subprocess.Popen,
started_at: float,
timeout: int,
) -> int:
"""Wait until this launch writes a fresh result file or the server exits."""
logger.info('waiting for browser confirmation...')
deadline = None if timeout <= 0 else time.time() + timeout
while True:
if result_file.exists():
try:
if result_file.stat().st_mtime >= started_at:
logger.info('confirmation received: %s', result_file)
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
pass
return 0
except OSError:
pass
skip_error = _stage_skip_error(result_file.parent)
if skip_error:
logger.error('%s', skip_error)
return 2
returncode = proc.poll()
if returncode is not None:
logger.error('confirm UI exited before a fresh result was written')
return returncode or 1
if deadline is not None and time.time() >= deadline:
logger.error(
'timed out waiting for browser confirmation — the page is still '
'open; re-check %s before falling back to chat', result_file,
)
return 124
time.sleep(0.5)
def _result_stage(result_file: Path) -> Optional[str]:
"""Return the canonical ``stage`` field of result.json, or None."""
try:
data = _read_json_object(result_file)
except (OSError, json.JSONDecodeError, ValueError):
return None
return _stage_key(data.get('stage'))
def _stage_key(value: object) -> Optional[str]:
"""Normalize current stage names while accepting legacy tier values."""
if value is None:
return None
raw = str(value).strip().lower()
if raw in {'1', 'stage1', 'tier1'}:
return 'stage1'
if raw in {'2', 'stage2', 'tier2'}:
return 'stage2'
if raw in {'3', 'stage3', 'tier3'}:
return 'stage3'
if raw == 'final':
return 'final'
return None
def _recommendation_stage(data: dict) -> int:
"""Return recommendations.json stage number, with legacy tier fallback."""
stage = _stage_key(data.get('stage'))
if not stage and 'tier' in data:
stage = _stage_key(data.get('tier'))
if stage == 'stage1':
return 1
if stage == 'stage2':
return 2
if stage == 'stage3':
return 3
return 0
def _stage_name(number: Optional[int]) -> Optional[str]:
"""Return the canonical stage key for a stage number."""
if number == 1:
return 'stage1'
if number == 2:
return 'stage2'
if number == 3:
return 'stage3'
return None
def _result_stage_number(stage: Optional[str]) -> int:
"""Return result progression: stage1=1, stage2=2, final=4."""
if stage == 'stage1':
return 1
if stage == 'stage2':
return 2
if stage == 'final':
return 4
return 0
def _stage_skip(rec_stage_number: int, result_stage: Optional[str]) -> bool:
"""Detect a staged recommendation running ahead of the confirmed progression.
Stages confirm strictly in order (stage1 → stage2 → stage3): a
recommendation may only run one stage past the last confirmed result, so
e.g. a ``stage3`` file while only stage 1 is confirmed is a skip — the
page must not render it (an active template never exempts stage 2).
Legacy single-pass recommendations (no ``stage``) are not staged and are
exempt, as is any state after the final confirmation.
"""
if rec_stage_number <= 1 or result_stage == 'final':
return False
return rec_stage_number > _result_stage_number(result_stage) + 1
def _stage_skip_error(confirm_dir: Path) -> Optional[str]:
"""Return a directive error when recommendations.json skips a stage."""
try:
rec_data = _read_json_object(confirm_dir / RECOMMENDATIONS_NAME)
except (OSError, json.JSONDecodeError, ValueError):
return None
rec_stage_number = _recommendation_stage(rec_data)
result_stage = _result_stage(confirm_dir / RESULT_NAME)
if not _stage_skip(rec_stage_number, result_stage):
return None
expected = _stage_name(_result_stage_number(result_stage) + 1)
reattach = (
'--daemon --wait' if expected == 'stage1'
else '--wait-only --wait-stage stage2'
)
return (
f'stage skip detected: recommendations.json is {_stage_name(rec_stage_number)} but the last '
f'confirmed result is {result_stage or "absent"} — the page will not render a skipped stage. '
f'Stages confirm in order and an active template does not exempt stage2 (SKILL.md Step 4). '
f'Overwrite recommendations.json with the {expected} recommendations, then re-run with {reattach}.'
)
def _file_version(path: Path) -> Optional[float]:
"""Return a cheap file version for polling state, or None when absent."""
try:
return path.stat().st_mtime
except OSError:
return None
def _read_session(confirm_dir: Path) -> dict:
"""Read session.json if present, returning an object."""
session_file = confirm_dir / SESSION_NAME
if not session_file.exists():
return {}
try:
return _read_json_object(session_file)
except (OSError, json.JSONDecodeError, ValueError):
return {}
def _build_session_state(
confirm_dir: Path,
*,
server_port: Optional[int] = None,
event: Optional[str] = None,
) -> dict:
"""Derive the resumable Confirm UI state from disk artifacts."""
previous = _read_session(confirm_dir)
rec_file = confirm_dir / RECOMMENDATIONS_NAME
result_file = confirm_dir / RESULT_NAME
rec_stage_number = 0
rec_stage = None
rec_error = None
if rec_file.exists():
try:
rec_data = _read_json_object(rec_file)
rec_stage_number = _recommendation_stage(rec_data)
rec_stage = _stage_name(rec_stage_number)
except (OSError, json.JSONDecodeError, ValueError) as exc:
rec_error = str(exc)
result_stage = _result_stage(result_file)
result_stage_number = _result_stage_number(result_stage)
stage_skip = _stage_skip(rec_stage_number, result_stage)
# A skipped stage is never presented: the page keeps its "deriving…" state
# (waiting_agent) until the AI rewrites recommendations.json in order.
if result_stage == 'final':
expected_stage_number = None
status = 'done'
current_stage = 'final'
elif result_stage == 'stage2':
expected_stage_number = 3
status = 'ready_user' if rec_stage_number >= 3 else 'waiting_agent'
current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 3 else 'stage2'
elif result_stage == 'stage1':
expected_stage_number = 2
ready = rec_stage_number >= 2 and not stage_skip
status = 'ready_user' if ready else 'waiting_agent'
current_stage = _stage_name(rec_stage_number) if ready else 'stage1'
else:
expected_stage_number = 1 if stage_skip else (rec_stage_number or 1)
ready = bool(rec_stage_number) and not stage_skip
status = 'ready_user' if ready else 'waiting_agent'
current_stage = rec_stage if ready else 'stage1'
return {
'session_id': previous.get('session_id') or uuid.uuid4().hex,
'status': status,
'current_stage': current_stage,
'stage_skip': stage_skip,
'expected_stage': _stage_name(expected_stage_number),
'expected_stage_number': expected_stage_number,
'recommendation_stage': rec_stage,
'recommendation_stage_number': rec_stage_number,
'recommendation_version': _file_version(rec_file),
'recommendation_error': rec_error,
'result_stage': result_stage,
'result_stage_number': result_stage_number,
'result_version': _file_version(result_file),
'server_port': server_port or previous.get('server_port'),
'event': event or previous.get('event') or 'derived',
}
def _write_session_state(confirm_dir: Path, session: dict) -> None:
"""Persist session.json only when stable state changes."""
previous = _read_session(confirm_dir)
comparable_previous = dict(previous)
comparable_current = dict(session)
comparable_previous.pop('updated_at', None)
comparable_current.pop('updated_at', None)
if comparable_previous == comparable_current:
return
session = dict(session)
session['updated_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_json_atomic(confirm_dir / SESSION_NAME, session)
def _sync_session_state(
confirm_dir: Path,
*,
server_port: Optional[int] = None,
event: Optional[str] = None,
) -> dict:
"""Derive and persist the current session state."""
session = _build_session_state(
confirm_dir,
server_port=server_port,
event=event,
)
_write_session_state(confirm_dir, session)
return session
# Stage-1 anchors and Stage-2 design-system choices. On later pages these sections
# are not rendered (they were already confirmed), so their values live only in
# browser STATE — lost on a refresh. Folding them from result.json into the
# served recommendations lets a refresh / reopen re-initialize from the user's
# actual choices instead of catalog defaults.
_ANCHOR_RECOMMEND_KEYS = (
'canvas',
'mode',
'visual_style',
'delivery_purpose',
'template_adherence',
)
_ANCHOR_VALUE_KEYS = ('audience', 'content_divergence')
_DESIGN_RECOMMEND_KEYS = ('icons', 'formula_policy')
def _template_adherence_enabled(project_path: Path) -> bool:
"""Return whether Step 3 loaded a deck/layout template into the project."""
spec_path = project_path / 'templates' / 'design_spec.md'
try:
lines = spec_path.read_text(encoding='utf-8').splitlines()
except OSError:
return False
if not lines or lines[0].strip() != '---':
return False
for line in lines[1:]:
stripped = line.strip()
if stripped == '---':
return False
match = re.fullmatch(r'kind\s*:\s*["\']?(brand|layout|deck)["\']?', stripped)
if match:
return match.group(1) in {'layout', 'deck'}
return False
def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
"""Fold already-confirmed choices into later-stage recommendations."""
try:
res = _read_json_object(result_file)
except (OSError, json.JSONDecodeError, ValueError):
return
recommend = data.setdefault('recommend', {})
if not isinstance(recommend, dict):
recommend = data['recommend'] = {}
for key in _ANCHOR_RECOMMEND_KEYS:
if res.get(key) not in (None, ''):
recommend[key] = res[key]
for key in _ANCHOR_VALUE_KEYS:
if key in res:
data[key] = {'value': res.get(key) or ''}
if _recommendation_stage(data) < 3:
return
for key in _DESIGN_RECOMMEND_KEYS:
if res.get(key) not in (None, ''):
recommend[key] = res[key]
if 'page_count' in res:
data['page_count'] = {'value': res.get('page_count') or ''}
if isinstance(res.get('color'), dict):
data['color'] = {'selected': 0, 'candidates': [res['color']]}
if isinstance(res.get('typography'), dict):
typography = {'selected': 0, 'candidates': [res['typography']]}
if res.get('formula_policy') not in (None, ''):
typography['formula_policy'] = {'value': res['formula_policy']}
data['typography'] = typography
def _wait_only_for_result(
result_file: Path,
lock_file: Path,
timeout: int,
target_stage: str = 'final',
) -> int:
"""Attach to an already-running confirm server and wait for a target stage.
No child is launched here: the page is still open from the first ``--wait``
launch, so liveness is tracked via the recorded pid, not a ``proc`` handle.
Only the stage guard is used (no mtime gate), because intermediate submits
may happen before this wait command is issued.
"""
logger.info('waiting for browser confirmation stage=%s...', target_stage)
deadline = None if timeout <= 0 else time.time() + timeout
while True:
if _result_stage(result_file) == target_stage:
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
return 0
skip_error = _stage_skip_error(result_file.parent)
if skip_error:
logger.error('%s', skip_error)
return 2
lock = _read_lock(lock_file)
pid = _lock_pid(lock)
if not pid or not _process_alive(pid):
logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
return 1
if deadline is not None and time.time() >= deadline:
logger.error(
'timed out waiting for confirmation stage=%s — the page may still '
'be open; re-check %s before falling back to chat', target_stage, result_file,
)
return 124
time.sleep(0.5)
def _shutdown_existing(lock_file: Path) -> int:
"""Stop a confirm server left running for this project (idempotent).
Step 4 always calls this on exit so the page never lingers on the shared
port 5050 — whether the user clicked **Confirm** (the page already shut the
server down) or replied in chat instead (the server is still up). Tries a
graceful ``/api/shutdown`` first, falls back to killing the recorded pid,
then clears the lock. A no-op when nothing is running.
"""
existing = _read_lock(lock_file)
if not existing:
logger.info('no confirm server running — nothing to stop')
return 0
pid = _lock_pid(existing)
port = existing.get('port')
if not _process_alive(pid):
_clear_lock(lock_file)
logger.info('confirm server already stopped; cleared stale lock')
return 0
# Graceful first: the server flushes and releases its own lock.
if port:
try:
req = urllib.request.Request(
f'http://127.0.0.1:{port}/api/shutdown',
data=b'{"reason": "step4-cleanup"}',
headers={'Content-Type': 'application/json'},
method='POST',
)
urllib.request.urlopen(req, timeout=3)
except OSError:
pass # server may already be exiting; fall through to the kill path
for _ in range(20): # up to ~2s for the graceful exit to land
if not _process_alive(pid):
break
time.sleep(0.1)
if _process_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
_clear_lock(lock_file)
logger.info('confirm server stopped (pid=%s)', pid)
return 0
def _build_catalogs() -> dict:
"""Return the static catalog set with the canvas list synced live from
``config.CANVAS_FORMATS`` — the single source of truth for canvas formats —
so the confirm page can never drift from the pipeline's real formats. The
set of formats and their dimensions come from config; trilingual labels and
use text are kept from catalogs.json (with a plain fallback for any new id).
"""
data = json.loads(_CATALOGS_PATH.read_text(encoding='utf-8'))
try:
import config # scripts/ is on sys.path (injected at import time)
formats = config.CANVAS_FORMATS
except (ImportError, AttributeError): # missing module/attr → static canvas
return data
existing = {
c.get('id'): c
for c in data.get('canvas', [])
if isinstance(c, dict) and c.get('id')
}
canvas = []
for cid, fmt in formats.items():
entry = dict(existing.get(cid, {}))
entry['id'] = cid
entry['dim'] = fmt.get('dimensions', entry.get('dim', ''))
if not entry.get('label'):
name = fmt.get('name', cid)
entry['label'] = name
entry.setdefault('label_zh', name)
entry.setdefault('label_en', name)
if not entry.get('use_en') and fmt.get('use_case'):
entry['use_en'] = fmt['use_case']
canvas.append(entry)
data['canvas'] = canvas
return data
def _icon_preview_svg(library: str, name: str) -> str:
"""Read a trusted sample SVG from the bundled icon templates."""
icon_path = _ICON_LIBRARY_DIR / library / f'{name}.svg'
raw = icon_path.read_text(encoding='utf-8')
raw = re.sub(r'<\?xml[^>]*>\s*', '', raw)
raw = re.sub(r'<!--.*?-->\s*', '', raw, flags=re.S)
return raw.strip()
def _build_icon_previews() -> dict:
previews = {}
for library, names in _ICON_PREVIEW_SAMPLES.items():
items = []
for name in names:
try:
items.append({'name': name, 'svg': _icon_preview_svg(library, name)})
except OSError as exc:
logger.warning('icon preview sample missing: %s/%s (%s)', library, name, exc)
previews[library] = items
return previews
def _ai_comparison_items(kind: str) -> list[dict[str, str]]:
manifest = _AI_IMAGE_COMPARISON_DIR / kind / '_manifest.json'
if not manifest.exists():
return []
data = json.loads(manifest.read_text(encoding='utf-8'))
items = []
for item in data.get('items', []):
filename = item.get('filename')
if not isinstance(filename, str) or not filename.endswith('.png'):
continue
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename):
continue
if not (_AI_IMAGE_COMPARISON_DIR / kind / filename).exists():
continue
item_id = Path(filename).stem
items.append({
'id': item_id,
'label': item.get('type') or item_id,
'filename': filename,
'purpose': item.get('purpose') or '',
'alt_text': item.get('alt_text') or '',
})
return items
def _build_ai_image_comparison() -> dict:
return {
'rendering': _ai_comparison_items('rendering'),
'palette': _ai_comparison_items('palette'),
'type': _ai_comparison_items('type'),
}
# --- app --------------------------------------------------------------------
def create_app(
project_dir: str,
idle_timeout: int = 900,
lock_file: Optional[Path] = None,
server_port: Optional[int] = None,
) -> Flask:
"""Create and configure the Flask app for a given project directory."""
project_path = Path(project_dir).resolve()
confirm_dir = project_path / CONFIRM_DIR_NAME
app = Flask(__name__, static_folder='static', static_url_path='/static')
app.config['PROJECT_PATH'] = project_path
app.config['CONFIRM_DIR'] = confirm_dir
app.config['LOCK_FILE'] = lock_file
app.config['SERVER_PORT'] = server_port
app.config['LAST_REQUEST_TIME'] = time.time()
@app.before_request
def _update_activity():
app.config['LAST_REQUEST_TIME'] = time.time()
def _exit_with_lock_release(code: int = 0) -> None:
lf = app.config.get('LOCK_FILE')
if lf is not None:
_release_lock(lf)
os._exit(code)
def _idle_watchdog():
if idle_timeout <= 0:
return
while True:
time.sleep(10)
elapsed = time.time() - app.config['LAST_REQUEST_TIME']
if elapsed > idle_timeout:
logger.info('idle for %ds, shutting down', idle_timeout)
_exit_with_lock_release(0)
watchdog = threading.Thread(target=_idle_watchdog, daemon=True)
watchdog.start()
@app.route('/api/shutdown', methods=['POST'])
def shutdown():
data = request.get_json(silent=True) or {}
reason = data.get('reason') or 'shutdown'
def _stop():
time.sleep(0.5) # let HTTP response flush before killing the process
logger.info('shutting down (%s)', reason)
_exit_with_lock_release(0)
threading.Thread(target=_stop, daemon=True).start()
return jsonify({'status': 'ok'})
@app.route('/')
def index():
return send_from_directory(app.static_folder, 'index.html')
@app.route('/api/health')
def health():
"""Expose a cheap readiness probe for the daemon launcher."""
rec_file = confirm_dir / RECOMMENDATIONS_NAME
rec_ok = False
stage = None
if rec_file.exists():
try:
rec_data = _read_json_object(rec_file, retries=0)
rec_ok = True
stage = _recommendation_stage(rec_data)
except (OSError, json.JSONDecodeError, ValueError):
rec_ok = False
resp = jsonify({
'status': 'ok',
'project': str(project_path),
'recommendations': rec_ok,
'stage': stage,
'session': _build_session_state(
confirm_dir,
server_port=app.config.get('SERVER_PORT'),
),
})
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/api/session')
def get_session():
"""Expose the derived three-stage wizard state for browser polling."""
session = _sync_session_state(
confirm_dir,
server_port=app.config.get('SERVER_PORT'),
event='poll',
)
resp = jsonify(session)
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/api/catalogs')
def get_catalogs():
"""Serve the option universe; canvas is synced live from config.py so
the static catalogs.json copy can never drift from the real formats."""
try:
resp = jsonify(_build_catalogs())
resp.headers['Cache-Control'] = 'no-store'
return resp
except (OSError, json.JSONDecodeError) as exc:
return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500
@app.route('/api/icon-previews')
def get_icon_previews():
"""Serve real sample icons from templates/icons for the icon chooser."""
resp = jsonify(_build_icon_previews())
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/api/ai-image-comparison')
def get_ai_image_comparison_manifest():
"""Serve generated-image reference options from ai-image-comparison."""
try:
resp = jsonify(_build_ai_image_comparison())
resp.headers['Cache-Control'] = 'no-store'
return resp
except (OSError, json.JSONDecodeError) as exc:
return jsonify({'error': f'invalid ai-image-comparison manifest: {exc}'}), 500
@app.route('/ai-image-comparison/<kind>/<filename>')
def get_ai_image_comparison(kind: str, filename: str):
"""Serve reference images for generated-image strategy candidates."""
if kind not in {'rendering', 'palette', 'type'}:
return jsonify({'error': 'invalid comparison kind'}), 404
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename or ''):
return jsonify({'error': 'invalid comparison filename'}), 404
return send_from_directory(_AI_IMAGE_COMPARISON_DIR / kind, filename)
@app.route('/api/recommendations')
def get_recommendations():
"""Serve the Strategist-authored recommendations for this project."""
rec_file = confirm_dir / RECOMMENDATIONS_NAME
if not rec_file.exists():
return jsonify({'error': 'recommendations not found'}), 404
try:
data = _read_json_object(rec_file)
except (OSError, json.JSONDecodeError, ValueError) as exc:
return jsonify({'error': f'invalid recommendations.json: {exc}'}), 400
# Report whether a result already exists (re-open after confirm).
result_file = confirm_dir / RESULT_NAME
data['_already_confirmed'] = result_file.exists()
# Later stages render only downstream sections, so fold earlier confirmed
# choices from result.json back in. A refresh / reopen then re-inits from
# the user's choices instead of catalog defaults.
if _recommendation_stage(data) >= 2 and result_file.exists():
_merge_confirmed_choices(data, result_file)
template_adherence_enabled = _template_adherence_enabled(project_path)
data['_template_adherence_enabled'] = template_adherence_enabled
recommend = data.get('recommend')
if template_adherence_enabled:
if not isinstance(recommend, dict):
recommend = data['recommend'] = {}
recommend.setdefault('template_adherence', 'adaptive')
else:
if isinstance(recommend, dict):
recommend.pop('template_adherence', None)
data.pop('template_adherence', None)
# The page polls this endpoint after a stage-1 confirm until the AI
# overwrites the file with the re-derived stage-2 recommendations, so it
# must never be served from a cache.
resp = jsonify(data)
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/api/confirm', methods=['POST'])
def confirm():
"""Persist the user's final choices to result.json for the AI to read."""
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify({'error': 'invalid payload'}), 400
confirm_dir.mkdir(parents=True, exist_ok=True)
result = dict(payload)
# Staged flow: stage-1 / stage-2 submits record intermediate choices but do
# NOT close the page. Only a final submit is a full confirmation. A
# payload with no stage is a single-pass confirmation (chat-opt-out parity).
stage = _stage_key(result.get('stage'))
if stage in {'stage1', 'stage2'}:
result['stage'] = stage
result['status'] = f'{stage}-confirmed'
else:
result['stage'] = 'final'
result['status'] = 'confirmed'
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
result_file = confirm_dir / RESULT_NAME
_write_json_atomic(result_file, result)
_sync_session_state(
confirm_dir,
server_port=app.config.get('SERVER_PORT'),
event=f'{result["stage"]}-submitted',
)
logger.info('%s confirmation written to %s', result['stage'], result_file)
return jsonify({'status': 'ok'})
return app
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description='PPT Master Strategist confirmation stage UI',
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument('project_dir', help='Path to project directory')
parser.add_argument(
'--port', type=int, default=DEFAULT_PORT,
help=f'Port to listen on (default: {DEFAULT_PORT})',
)
parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
parser.add_argument(
'--daemon', action='store_true',
help='Start the server in the background; combine with --wait to block until confirmation',
)
parser.add_argument(
'--wait', action='store_true',
help='With --daemon, wait until a fresh result.json is written',
)
parser.add_argument(
'--wait-only', action='store_true',
help='Attach to the confirm server for this project and wait for an '
'already-open page to write result.json. If the server died, '
'recover it on the recorded/default port so browser polling can resume.',
)
parser.add_argument(
'--wait-stage', default='final', metavar='{stage2,final}',
help='With --wait-only, wait for this result.json stage (default: final). '
'Use stage2 for the middle handoff in the three-stage flow.',
)
parser.add_argument(
'--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
help=f'Seconds the --wait parent blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
'0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.',
)
parser.add_argument(
'--timeout', type=int, default=900,
help='Server idle timeout in seconds (default: 900; 0 = disabled)',
)
parser.add_argument(
'--shutdown', action='store_true',
help='Stop a confirm server left running for this project, then exit '
'(idempotent). Run at the end of Step 4 so the page never lingers '
'on the shared port before live preview starts.',
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s',
datefmt='%H:%M:%S',
)
project_path = Path(args.project_dir).resolve()
if not project_path.is_dir():
logger.error('%s is not a directory', project_path)
return 1
wait_stage = _stage_key(args.wait_stage)
if wait_stage not in {'stage2', 'final'}:
logger.error('--wait-stage must be stage2 or final')
return 2
# Step 4 cleanup: stop any lingering confirm server and exit. Independent of
# recommendations.json (the page may never have been confirmed).
if args.shutdown:
return _shutdown_existing(project_path / LOCK_FILE_NAME)
# Staged wait: attach to the server launched by the first --wait and block
# until the page writes the requested intermediate or final result.json.
if args.wait_only:
lock_file = project_path / LOCK_FILE_NAME
if not _live_lock(lock_file):
if not (project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME).exists():
logger.error(
'%s not found — cannot recover confirm UI before wait-only',
project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME,
)
return 1
recovery_port = _preferred_recovery_port(lock_file, args.port)
try:
_, actual_port, _ = _launch_background_server(
project_path,
preferred_port=recovery_port,
idle_timeout=args.timeout,
open_browser=False,
)
except RuntimeError as exc:
logger.error('%s', exc)
return 1
if actual_port != recovery_port and not args.no_browser:
webbrowser.open(_server_url(actual_port))
logger.info(
'recovered confirm UI for wait-only at %s; the browser polling should resume',
_server_url(actual_port),
)
return _wait_only_for_result(
project_path / CONFIRM_DIR_NAME / RESULT_NAME,
lock_file,
args.wait_timeout,
wait_stage,
)
rec_file = project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME
if not rec_file.exists():
logger.error(
'%s not found — Strategist must write recommendations.json before launch',
rec_file,
)
return 1
if args.daemon:
lock_file = project_path / LOCK_FILE_NAME
existing = _read_lock(lock_file)
if existing and _process_alive(_lock_pid(existing)):
existing_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?')
logger.error(
'confirm UI is already running for this project '
'(pid=%s, port=%s). Open http://%s:%s',
existing_pid, existing_port, PUBLIC_HOST, existing_port,
)
return 1
confirm_dir = project_path / CONFIRM_DIR_NAME
result_file = confirm_dir / RESULT_NAME
started_at = time.time()
try:
proc, port, _ = _launch_background_server(
project_path,
preferred_port=args.port,
idle_timeout=args.timeout,
open_browser=not args.no_browser,
)
except RuntimeError as exc:
logger.error('%s', exc)
return 1
if args.wait:
return _wait_for_result(result_file, proc, started_at, args.wait_timeout)
return 0
# Per-project mutual exclusion: refuse duplicate launches. Stale locks
# (dead pid) are overwritten by _claim_lock.
lock_file = project_path / LOCK_FILE_NAME
existing = _claim_lock(lock_file, args.port)
if existing:
existing_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?')
logger.error(
'confirm UI is already running for this project '
'(pid=%s, port=%s). Open http://%s:%s, or run: kill %s',
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
)
return 1
atexit.register(_release_lock, lock_file)
def _on_sigterm(signum: int, _frame) -> None:
logger.info('received signal %s, exiting', signum)
sys.exit(0)
try:
signal.signal(signal.SIGTERM, _on_sigterm)
except (ValueError, OSError):
pass
app = create_app(
str(project_path),
idle_timeout=args.timeout,
lock_file=lock_file,
server_port=args.port,
)
url = _server_url(args.port)
if not args.no_browser:
_open_browser_async(url)
logger.info('running at %s', url)
logger.info('project: %s', project_path)
logger.info('idle timeout: %ds (0 = disabled)', args.timeout)
app.run(host=PUBLIC_HOST, port=args.port, debug=False)
return 0
if __name__ == '__main__':
raise SystemExit(main())