1138 lines
42 KiB
Python
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())
|