diff --git a/agent/prompts/role/spec_agent.md b/agent/prompts/role/spec_agent.md index cef68715..5ceb3d7b 100644 --- a/agent/prompts/role/spec_agent.md +++ b/agent/prompts/role/spec_agent.md @@ -2,6 +2,31 @@ You are the SPEC agent for spec-driven-presentation-maker. You handle Phase 1 through user dialogue. Write all spec files in the user's language. +## Flow selection (evaluate this FIRST on every turn) + +Before applying any other instruction in this prompt, decide which of the +two flows you are in: + +1. **Guide flow (edit branch)** — triggered when any tool response in the + conversation contains a `guideInstruction` field. The instruction asks + you to classify the user's intent and may direct you to a specific + guide (e.g. `import-pptx` for PPTX edits). Once a guide is active, + follow the guide's Steps 1 → 5 in order. Do NOT read any + `create-new-*` workflow, do NOT start a Phase 1 Briefing, and do NOT + ask the user questions about audience / tone / time budget / etc. + Those belong to the new-deck flow below. The guide auto-generates the + briefing / outline / art-direction from the source material in its + own Step 3, and the deck builds against a PPTX-derived placeholder + template (no template selection hearing). + +2. **New-deck flow** — triggered when no `guideInstruction` is pending and + the user wants to build a presentation from scratch. Run the Phase 1 + Flow described later in this prompt. + +If you are in a guide flow, all subsequent sections of this prompt titled +`## Phase 1 Flow` and `## Delegation to Composer` (new-deck specifics) do +NOT apply until the guide completes. + ## Hearing Your primary job is user hearing. Do not rush to produce output. @@ -14,6 +39,10 @@ and the better the composer's output. ## Phase 1 Flow +**This section applies to the new-deck flow only** (see "Flow selection" +above). When a guide is active, skip this section entirely and follow +the guide instead. + Phase 1 produces 3 spec files through sequential sub-phases. Each sub-phase has a workflow file that defines the deliverable format and procedure. You MUST read the workflow before starting that sub-phase — the deliverables have strict formats @@ -54,8 +83,45 @@ If it is not in the brief, it does not exist for the composer. ## Delegation to Composer -When all 3 spec files are approved: +When all 3 spec files are approved (new-deck flow) OR when the guide's +Step 5 completes (edit branch): - Call `compose_slides(deck_id=..., slide_groups=[...])` to delegate slide generation - You do NOT write slide JSON yourself. You do NOT call build/measure/preview tools directly - After compose_slides returns, follow the Post-Compose Workflow - For user modification requests, translate them into instructions and call compose_slides again + +## Guide-driven flows + +If a tool response (e.g. `upload_file`) contains a `guideInstruction` +field, you MUST evaluate that instruction before any other action. The +instruction may ask you to determine user intent and branch accordingly. + +**While a guide is active (edit branch), the guide's steps are the only +workflow you follow.** Do NOT read `create-new-*` workflows, do NOT ask +Phase 1 briefing/outline/art-direction questions, and do NOT invoke +Phase 1 Flow instructions. Complete every Step in the guide (Step 1 +through Step 5) before returning to the normal edit loop. + +For uploaded PPTX files specifically: + +1. `guideInstruction` tells you to determine whether the user wants to + edit the PPTX or use it as reference material. +2. If intent is clear → follow the branch directly. +3. If intent is ambiguous → use `hearing` once to ask, then branch. +4. **Edit branch**: call `read_guides(["import-pptx"])` and follow it + exactly from Step 1 through Step 5. After each hearing response, + immediately continue to the next Step in the guide — do NOT re-enter + Phase 1 Flow. The specs (brief / outline / art-direction) are + auto-generated from the PPTX content inside the guide, so you do NOT + need to run a briefing hearing. The deck-local placeholder template + (`template.pptx`) is copied automatically by `import_attachment`, so + no template selection hearing is needed either. **Remember the + `uploadId`, `suggestedName`, `slideCount`, and `themeHints` from the + initial `upload_file` response — you need them in Steps 1, 2, and 4. + Never ask the user to re-upload the file; those values remain in the + conversation context from the original attachment.** After Step 5 + completes, return to the normal edit loop (user requests → + `compose_slides`). +5. **Reference branch**: proceed with the normal briefing flow. Use + `read_uploaded_file(upload_id)` when you need content, and cite line + numbers in `specs/brief.md` Source Material. diff --git a/api/index.py b/api/index.py index 24c4bcfd..f9aa108b 100644 --- a/api/index.py +++ b/api/index.py @@ -1396,23 +1396,51 @@ def presign_upload() -> Dict[str, Any]: _TEXT_EXTRACTABLE = {"text/plain", "text/markdown", "application/json"} -def _extract_pptx_text(s3_key: str) -> str: - """Extract slide text from PPTX using zipfile + XML (no python-pptx needed).""" - import io - import re - import zipfile - obj = s3_client.get_object(Bucket=BUCKET_NAME, Key=s3_key) - data = obj["Body"].read() - slides_text = [] - with zipfile.ZipFile(io.BytesIO(data)) as zf: - slide_names = sorted(n for n in zf.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)) - for name in slide_names: - xml = zf.read(name).decode("utf-8") - texts = re.findall(r"([^<]+)", xml) - if texts: - slide_num = re.search(r"slide(\d+)", name).group(1) - slides_text.append(f"--- Slide {slide_num} ---\n" + "\n".join(texts)) - return "\n\n".join(slides_text) +# Shared guide instruction for PPTX uploads — same text as Local mode. +_PPTX_GUIDE_INSTRUCTION = ( + "This PPTX can either be converted into an editable deck, or used as " + "reference material for a new deck. " + "If the user's intent is to edit this PPTX, call read_guides(['import-pptx']) " + "and follow it exactly. " + "If the intent is to use as reference, proceed with the normal briefing flow " + "and call read_uploaded_file to access content. " + "If the user's intent is ambiguous, use the `hearing` tool once to clarify " + "before choosing." +) + + +def _pptx_guide_fields(item_or_values: Dict[str, Any]) -> Dict[str, Any]: + """Build the guide/guideInstruction/suggestedName/slideCount/themeHints fields + for a PPTX upload. Accepts either a fresh expr_values dict (from process_upload) + or a DDB item (from get_upload_status). Returns {} when the record has no + deck-structure metadata (non-PPTX uploads). + """ + theme = item_or_values.get(":th") if ":th" in item_or_values else item_or_values.get("themeHints") + slide_count = ( + item_or_values.get(":sc") if ":sc" in item_or_values else item_or_values.get("slideCount") + ) + suggested = ( + item_or_values.get(":sn") + if ":sn" in item_or_values + else item_or_values.get("suggestedName") + ) + if theme is None and slide_count is None and suggested is None: + return {} + if theme is not None: + # DDB returns numeric values as Decimal; JSON serializer on the + # response can't handle Decimal, so coerce backgroundLuminance back + # to a float. + from decimal import Decimal + bg_lum = theme.get("backgroundLuminance") if isinstance(theme, dict) else None + if isinstance(bg_lum, Decimal): + theme = {**theme, "backgroundLuminance": float(bg_lum)} + return { + "guide": "import-pptx", + "guideInstruction": _PPTX_GUIDE_INSTRUCTION, + "suggestedName": suggested, + "slideCount": int(slide_count) if slide_count is not None else None, + "themeHints": theme, + } @app.post("/uploads//process") @@ -1457,7 +1485,7 @@ def process_upload(upload_id: str) -> Dict[str, Any]: expr_values[":st"] = "completed" # --- Binary files (PDF/DOCX/XLSX/PPTX): download → convert → upload --- - elif ext in (".pdf", ".docx", ".xlsx") and s3_key: + elif ext in (".pdf", ".docx", ".xlsx", ".pptx") and s3_key: converted_prefix = f"uploads/{user_id}/{upload_id}/converted" try: with tempfile.TemporaryDirectory() as tmp: @@ -1496,6 +1524,22 @@ def process_upload(upload_id: str) -> Dict[str, Any]: if result.warnings: update_expr_parts.append("conversionWarnings = :cw") expr_values[":cw"] = result.warnings + # PPTX deck-structure metadata — stored for the edit guide + # and returned by process_upload / get_upload_status. + if result.deck_structure: + from decimal import Decimal + bg_lum = result.theme_hints.get("backgroundLuminance") + update_expr_parts.append("themeHints = :th") + expr_values[":th"] = { + # DynamoDB rejects native floats; round-trip via str(Decimal). + "backgroundLuminance": Decimal(str(bg_lum)) if bg_lum is not None else None, + "accentColors": result.theme_hints.get("accentColors", []), + "fonts": result.theme_hints.get("fonts", {}), + } + update_expr_parts.append("slideCount = :sc") + expr_values[":sc"] = result.slide_count + update_expr_parts.append("suggestedName = :sn") + expr_values[":sn"] = result.suggested_name except Exception as e: logger.exception("Conversion failed for %s", upload_id) @@ -1517,12 +1561,14 @@ def process_upload(upload_id: str) -> Dict[str, Any]: if file_type.startswith("image/") and s3_key: image_url = presigned_url(s3_client, BUCKET_NAME, s3_key) - return { + response: Dict[str, Any] = { "uploadId": upload_id, "status": expr_values[":st"], "extractedText": extracted_text, "imageUrl": image_url, } + response.update(_pptx_guide_fields(expr_values)) + return response @app.get("/uploads//status") @@ -1538,7 +1584,7 @@ def get_upload_status(upload_id: str) -> Dict[str, Any]: if item.get("fileType", "").startswith("image/") and item.get("s3KeyRaw"): image_url = presigned_url(s3_client, BUCKET_NAME, item["s3KeyRaw"]) - return { + response: Dict[str, Any] = { "uploadId": upload_id, "fileName": item.get("fileName", ""), "fileType": item.get("fileType", ""), @@ -1546,6 +1592,8 @@ def get_upload_status(upload_id: str) -> Dict[str, Any]: "extractedText": item.get("extractedText"), "imageUrl": image_url, } + response.update(_pptx_guide_fields(item)) + return response # --------------------------------------------------------------------------- diff --git a/api/requirements.txt b/api/requirements.txt index 9aeca1ba..d6984593 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -4,5 +4,12 @@ pdfplumber>=0.11.0 pypdf>=4.0.0 python-docx>=1.0.0 openpyxl>=3.1.0 +# Required for PPTX upload-time conversion (shared/ingest._convert_pptx). +# Mirrors skill/pyproject.toml because we copy skill/sdpm into the Lambda +# bundle without pip-installing the package itself. python-pptx>=1.0.0 lxml>=5.0.0 +Pillow>=10.0.0 +qrcode>=7.4.0 +pygments>=2.19.2 +defusedxml>=0.7.0 diff --git a/infra/lib/web-ui-stack.ts b/infra/lib/web-ui-stack.ts index bad94b79..b0e96e91 100644 --- a/infra/lib/web-ui-stack.ts +++ b/infra/lib/web-ui-stack.ts @@ -231,9 +231,7 @@ function handler(event) { "bash", "-c", "pip install -r /asset-input/api/requirements.txt -t /asset-output/ && " + "cp -r /asset-input/api/* /asset-input/shared /asset-output/ && " + - "mkdir -p /asset-output/sdpm && cp -r /asset-input/skill/sdpm/analyzer /asset-output/sdpm/ && " + - "cp /asset-input/skill/sdpm/__init__.py /asset-output/sdpm/ && " + - "mkdir -p /asset-output/sdpm/utils && cp /asset-input/skill/sdpm/utils/__init__.py /asset-input/skill/sdpm/utils/io.py /asset-output/sdpm/utils/", + "cp -r /asset-input/skill/sdpm /asset-output/sdpm", ], local: { tryBundle(outputDir: string): boolean { @@ -250,10 +248,9 @@ function handler(event) { } execSync(`cp -r ${root}/api/* ${outputDir}/`, { stdio: "inherit" }); execSync(`cp -r ${root}/shared ${outputDir}/shared`, { stdio: "inherit" }); - execSync(`mkdir -p ${outputDir}/sdpm/utils`, { stdio: "inherit" }); - execSync(`cp -r ${root}/skill/sdpm/analyzer ${outputDir}/sdpm/`, { stdio: "inherit" }); - execSync(`cp ${root}/skill/sdpm/__init__.py ${outputDir}/sdpm/`, { stdio: "inherit" }); - execSync(`cp ${root}/skill/sdpm/utils/__init__.py ${root}/skill/sdpm/utils/io.py ${outputDir}/sdpm/utils/`, { stdio: "inherit" }); + // sdpm Engine — required by shared.ingest._convert_pptx for upload-time + // PPTX → deck conversion (deck.json + slides/*.json + template.pptx). + execSync(`cp -r ${root}/skill/sdpm ${outputDir}/sdpm`, { stdio: "inherit" }); return true; }, }, diff --git a/mcp-local/acp-agent-prompts/spec-agent.md b/mcp-local/acp-agent-prompts/spec-agent.md index fe962790..16c2a791 100644 --- a/mcp-local/acp-agent-prompts/spec-agent.md +++ b/mcp-local/acp-agent-prompts/spec-agent.md @@ -3,6 +3,31 @@ You handle Phase 1 through user dialogue. Respond in the same language as the user. Write all spec files in the user's language. +## Flow selection (evaluate this FIRST on every turn) + +Before applying any other instruction in this prompt, decide which of the +two flows you are in: + +1. **Guide flow (edit branch)** — triggered when any tool response in the + conversation contains a `guideInstruction` field. The instruction asks + you to classify the user's intent and may direct you to a specific + guide (e.g. `import-pptx` for PPTX edits). Once a guide is active, + follow the guide's Steps 1 → 5 in order. Do NOT read any + `create-new-*` workflow, do NOT start a Phase 1 Briefing, and do NOT + ask the user questions about audience / tone / time budget / etc. + Those belong to the new-deck flow below. The guide auto-generates the + briefing / outline / art-direction from the source material in its + own Step 3, and the deck builds against a PPTX-derived placeholder + template (no template selection hearing). + +2. **New-deck flow** — triggered when no `guideInstruction` is pending and + the user wants to build a presentation from scratch. Run the Phase 1 + Flow described later in this prompt. + +If you are in a guide flow, all subsequent sections of this prompt titled +`## Phase 1 Flow`, `## Workflow: New Presentation`, and `## Slide Group +Assignment` do NOT apply until the guide completes. + ## Hearing Your primary job is user hearing. Do not rush to produce output. @@ -20,6 +45,10 @@ Limit to 5 questions per call. If you need more, call again after the user respo ## Phase 1 Flow +**This section applies to the new-deck flow only** (see "Flow selection" +above). When a guide is active, skip this section entirely and follow +the guide instead. + Phase 1 produces 3 spec files through sequential sub-phases. Each sub-phase has a workflow file that defines the deliverable format and procedure. You MUST read the workflow before starting that sub-phase — the deliverables have strict formats @@ -66,7 +95,8 @@ When user provides a file path or URL: ## Delegation to Composer -When all 3 spec files are approved: +When all 3 spec files are approved (new-deck flow) OR when the guide's +Step 5 completes (edit branch): - Split slides into groups and invoke sdpm-composer subagents in parallel (max 4) - Use `use_subagent` with `subagents: [{"query": "deck_id=... slides: slug1, slug2", "agent_name": "sdpm-composer"}, ...]` (max 4 parallel). ASCII-only queries. - You do NOT write slide JSON yourself. You do NOT call build/measure/preview tools directly @@ -76,6 +106,42 @@ When all 3 spec files are approved: Each subagent query MUST include: deck_id (path), assigned slide slugs, and a pointer to specs/. +## Guide-driven flows + +If a tool response (e.g. `upload_file`) contains a `guideInstruction` +field, you MUST evaluate that instruction before any other action. The +instruction may ask you to determine user intent and branch accordingly. + +**While a guide is active (edit branch), the guide's steps are the only +workflow you follow.** Do NOT read `create-new-*` workflows, do NOT ask +Phase 1 briefing/outline/art-direction questions, and do NOT invoke +Phase 1 Flow instructions. Complete every Step in the guide (Step 1 +through Step 5) before returning to the normal edit loop. + +For uploaded PPTX files specifically: + +1. `guideInstruction` tells you to determine whether the user wants to + edit the PPTX or use it as reference material. +2. If intent is clear → follow the branch directly. +3. If intent is ambiguous → use `hearing` once to ask, then branch. +4. **Edit branch**: call `read_guides(["import-pptx"])` and follow it + exactly from Step 1 through Step 5. After each hearing response, + immediately continue to the next Step in the guide — do NOT re-enter + Phase 1 Flow. The specs (brief / outline / art-direction) are + auto-generated from the PPTX content inside the guide, so you do NOT + need to run a briefing hearing. The deck-local placeholder template + (`template.pptx`) is copied automatically by `import_attachment`, so + no template selection hearing is needed either. **Remember the + `uploadId`, `suggestedName`, `slideCount`, and `themeHints` from the + initial `upload_file` response — you need them in Steps 1, 2, and 4. + Never ask the user to re-upload the file; those values remain in the + conversation context from the original attachment.** After Step 5 + completes, return to the normal edit loop (user requests → invoke + sdpm-composer subagents via `use_subagent`). +5. **Reference branch**: proceed with the normal briefing flow. Use + `read_uploaded_file(upload_id)` when you need content, and cite line + numbers in `specs/brief.md` Source Material. + ## Workflow: New Presentation → Read `read_workflows(["create-new-1-briefing"])` to start. Follow each file's Next Step from there. diff --git a/mcp-local/server.py b/mcp-local/server.py index 5196ed51..41339f3c 100644 --- a/mcp-local/server.py +++ b/mcp-local/server.py @@ -65,8 +65,11 @@ ## Workflow B: Edit Existing PPTX -When an existing PPTX is provided. -→ Read `read_workflows(["edit-existing"])` to start. +When an existing PPTX is provided. The Web UI / API path is the +preferred entrypoint: `upload_file` returns +`guideInstruction: "read_guides([\"import-pptx\"])"` automatically, +so just follow that instruction. For CLI-only flows, call +`read_guides(["import-pptx"])` directly. ## Workflow C: Hand-Edit Sync diff --git a/mcp-local/server_acp.py b/mcp-local/server_acp.py index b8b34305..70881d85 100644 --- a/mcp-local/server_acp.py +++ b/mcp-local/server_acp.py @@ -234,8 +234,8 @@ def init_presentation(name: str, template: str = "") -> str: from datetime import datetime from sdpm.analyzer import extract_fonts - root = os.environ.get("SDPM_DECK_ROOT", "") - base_dir = Path(root) if root else Path.home() / "Documents" / "SDPM-Presentations" + base_dir_env = os.environ.get("SDPM_DECK_ROOT", "").strip() + base_dir = Path(base_dir_env) if base_dir_env else Path.home() / "Documents" / "SDPM-Presentations" ts = datetime.now().strftime("%Y%m%d-%H%M") dir_name = f"{ts}-{name}" if name else ts out_dir = base_dir / dir_name diff --git a/mcp-local/tools.py b/mcp-local/tools.py index 98821a33..e8041ab2 100644 --- a/mcp-local/tools.py +++ b/mcp-local/tools.py @@ -157,9 +157,28 @@ def code_block( def pptx_to_json(pptx_path: str) -> dict[str, Any]: - """Convert PPTX to JSON representation.""" + """Convert PPTX to JSON representation. + + Writes a deck-structure directory (``deck.json`` + ``slides/slide-NN.json`` + + ``images/``) alongside the input PPTX (``{stem}/`` sibling directory). + The returned dict still contains ``{slides, fonts, defaultTextColor}`` for + in-memory use. + + Note: the on-disk output is deck-structure (since pptx-import-edit); + no single ``slides.json`` file is produced. Callers that need an + editable deck should prefer the upload → import_attachment flow. + + Returns: + Dict with slides list plus deck metadata (fonts, defaultTextColor), + plus ``deck_dir`` (string path to the written directory) for + downstream tooling. + """ from sdpm.converter import pptx_to_json as _convert path = Path(pptx_path) if not path.exists(): raise FileNotFoundError(f"PPTX not found: {pptx_path}") - return _convert(str(path)) + result = _convert(path) + deck_dir = path.with_suffix("") + if isinstance(result, dict): + result = {**result, "deck_dir": str(deck_dir)} + return result diff --git a/mcp-local/upload_tools.py b/mcp-local/upload_tools.py index 2d9202a3..a38d93c4 100644 --- a/mcp-local/upload_tools.py +++ b/mcp-local/upload_tools.py @@ -49,8 +49,10 @@ def _convert_webp_to_png(data: bytes) -> bytes: def _deck_root() -> Path: - root = os.environ.get("SDPM_DECK_ROOT", "") - return Path(root) if root else Path.home() / "Documents" / "SDPM-Presentations" + env_value = os.environ.get("SDPM_DECK_ROOT", "").strip() + if env_value: + return Path(env_value) + return Path.home() / "Documents" / "SDPM-Presentations" def _session_dir(session_id: str) -> Path: @@ -131,6 +133,18 @@ def _analyze_colors(file_path: Path) -> dict | None: return None +_GUIDE_INSTRUCTION = ( + "This PPTX can either be converted into an editable deck, or used as " + "reference material for a new deck. " + "If the user's intent is to edit this PPTX, call read_guides(['import-pptx']) " + "and follow it exactly. " + "If the intent is to use as reference, proceed with the normal briefing flow " + "and call read_uploaded_file to access content. " + "If the user's intent is ambiguous, use the `hearing` tool once to clarify " + "before choosing." +) + + def upload_file(session_id: str, file_path: str, filename: str = "") -> str: """Convert and store a file in session storage. @@ -140,7 +154,15 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str: filename: Original filename (defaults to basename of file_path). Returns: - JSON with {uploadId, fileName, fileType, status, filePath?, colorAnalysis?}. + JSON with {uploadId, fileName, fileType, status, warnings?, filePath?, colorAnalysis?}. + + For PPTX uploads that successfully converted to deck structure, the + response additionally contains: + guide: "import-pptx" + guideInstruction: + suggestedName: file stem + slideCount: number of slides + themeHints: {backgroundLuminance, accentColors, fonts} """ src = Path(file_path) if not src.exists(): @@ -161,6 +183,7 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str: "warnings": [], } + conversion_result = None # shared.ingest.ConversionResult or None try: # Passthrough: images/text — just copy the raw file if ext in IMAGE_EXTS or ext in TEXT_EXTS: @@ -169,13 +192,18 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str: meta["rawFile"] = original_name else: # Convert via shared pipeline - result = convert_file(src, upload_dir) - if result.status == "error": + conversion_result = convert_file(src, upload_dir) + if conversion_result.status == "error": meta["status"] = "error" - meta["error"] = result.error or "Conversion failed" + meta["error"] = conversion_result.error or "Conversion failed" else: meta["status"] = "converted" - meta["warnings"] = result.warnings + meta["warnings"] = conversion_result.warnings + if conversion_result.deck_structure: + meta["deckStructure"] = True + meta["slideCount"] = conversion_result.slide_count + meta["themeHints"] = conversion_result.theme_hints + meta["suggestedName"] = conversion_result.suggested_name except Exception as e: meta["status"] = "error" meta["error"] = str(e) @@ -198,16 +226,19 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str: if meta["status"] == "completed": response["filePath"] = str((upload_dir / original_name).resolve()) elif meta["status"] == "converted": - stem = original_name.rsplit(".", 1)[0] if "." in original_name else original_name - md_path = upload_dir / f"{stem}.md" - json_path = upload_dir / "slides.json" - if md_path.exists(): - response["filePath"] = str(md_path.resolve()) - elif json_path.exists(): - response["filePath"] = str(json_path.resolve()) - images_dir = upload_dir / "images" - if images_dir.exists(): - response["imagesDir"] = str(images_dir.resolve()) + # Deck-structure PPTX: filePath would only point at deck.json and miss + # slides/, so the guide/themeHints fields below replace it. + if conversion_result is None or not conversion_result.deck_structure: + stem = original_name.rsplit(".", 1)[0] if "." in original_name else original_name + md_path = upload_dir / f"{stem}.md" + json_path = upload_dir / "slides.json" + if md_path.exists(): + response["filePath"] = str(md_path.resolve()) + elif json_path.exists(): + response["filePath"] = str(json_path.resolve()) + images_dir = upload_dir / "images" + if images_dir.exists(): + response["imagesDir"] = str(images_dir.resolve()) # Color analysis for images if file_type.startswith("image/") and meta["status"] == "completed": @@ -215,6 +246,13 @@ def upload_file(session_id: str, file_path: str, filename: str = "") -> str: if colors: response["colorAnalysis"] = colors + # PPTX deck-structure: include guide hints for the agent + if conversion_result is not None and conversion_result.deck_structure: + response["guide"] = "import-pptx" + response["guideInstruction"] = _GUIDE_INSTRUCTION + response["suggestedName"] = conversion_result.suggested_name + response["slideCount"] = conversion_result.slide_count + response["themeHints"] = conversion_result.theme_hints return json.dumps(response, ensure_ascii=False) @@ -245,6 +283,82 @@ def _format_cat_n(text: str, file_name: str, offset: int, limit: int) -> str: return f"{header}\n\n{body}{footer}" +def _format_deck_text_summary(upload_dir: Path, file_name: str, offset: int, limit: int) -> str: + """Format a deck-structure upload (deck.json + slides/) as a markdown-style text summary. + + Output shape:: + + --- Slide 1: --- + <body text> + + --- Slide 2: <title> --- + ... + + The title is taken from slide["title"] (string or dict-with-text). + Body text concatenates element ``text``, ``paragraphs[].text``, ``items``, + table ``headers`` / ``rows``, and recursive group elements. + """ + slides_dir = upload_dir / "slides" + if not slides_dir.is_dir(): + return f"No slides/ directory found in {file_name}." + + def _extract_title(data: dict) -> str: + t = data.get("title") + if isinstance(t, str): + return t + if isinstance(t, dict): + return t.get("text", "") or "" + return "" + + def _collect_text(node, out: list[str]) -> None: + if isinstance(node, dict): + for key in ("text", "subtitle", "label", "date", "notes"): + v = node.get(key) + if isinstance(v, str) and v.strip(): + out.append(v) + for p in node.get("paragraphs", []) or []: + if isinstance(p, dict): + t = p.get("text") + if isinstance(t, str) and t.strip(): + out.append(t) + for item in node.get("items", []) or []: + if isinstance(item, str) and item.strip(): + out.append(item) + headers = node.get("headers") + if isinstance(headers, list): + out.extend(str(c) for c in headers if c) + rows = node.get("rows") + if isinstance(rows, list): + for row in rows: + if isinstance(row, list): + out.extend(str(c) for c in row if c) + for child in node.get("elements", []) or []: + _collect_text(child, out) + + sections: list[str] = [] + for i, slide_file in enumerate(sorted(slides_dir.glob("slide-*.json")), start=1): + try: + data = json.loads(slide_file.read_text(encoding="utf-8")) + except Exception: + continue + title = _extract_title(data) + header = f"--- Slide {i}: {title} ---" if title else f"--- Slide {i} ---" + body_parts: list[str] = [] + for el in data.get("elements", []) or []: + _collect_text(el, body_parts) + # Drop duplicates while keeping order + seen: set[str] = set() + deduped: list[str] = [] + for p in body_parts: + if p not in seen: + seen.add(p) + deduped.append(p) + body = "\n".join(deduped) + sections.append(f"{header}\n{body}".rstrip()) + + return _format_cat_n("\n\n".join(sections), file_name, offset, limit) + + def read_uploaded_file(upload_id: str, offset: int = 0, limit: int = 2000) -> list: """Read an uploaded file's pre-converted content (cat -n format).""" try: @@ -275,12 +389,34 @@ def read_uploaded_file(upload_id: str, offset: int = 0, limit: int = 2000) -> li # Converted (PDF/DOCX/XLSX/PPTX) if status == "converted": + # PPTX: deck structure — full text summary across slides + is_deck = (upload_dir / "deck.json").exists() and (upload_dir / "slides").is_dir() + if is_deck: + result.append(_format_deck_text_summary(upload_dir, file_name, offset, limit)) + # Image previews (converter extracted slide images) + img_dir = upload_dir / "images" + if img_dir.exists(): + imgs = sorted(img_dir.iterdir()) + for i, img_file in enumerate(imgs): + if i >= _MAX_IMAGE_PREVIEWS: + result.append(f"({len(imgs) - i} more images not previewed)") + break + try: + jpeg = _to_jpeg(img_file.read_bytes()) + result.append(f"Extracted image: {img_file.name}") + result.append(Image(data=jpeg, format="jpeg")) + except Exception: + continue + if warning_text: + result.append(warning_text) + return result # Try Markdown stem = file_name.rsplit(".", 1)[0] if "." in file_name else file_name md_path = upload_dir / f"{stem}.md" if md_path.exists(): result.append(_format_cat_n(md_path.read_text(encoding="utf-8"), file_name, offset, limit)) - # Try PPTX JSON + # Try PPTX JSON (legacy slides.json — kept for backward compat; deck structure is + # handled by the is_deck branch above). json_path = upload_dir / "slides.json" if not result and json_path.exists(): pretty = json.dumps(json.loads(json_path.read_text(encoding="utf-8")), ensure_ascii=False, indent=2) @@ -349,7 +485,7 @@ def _import_from_upload(upload_id: str, deck_id: str, filename: str) -> str: file_type = meta.get("fileType", "") short_id = uuid.uuid4().hex[:8] - result: dict = {"source": upload_id, "files": [], "image_mapping": {}} + result: dict = {"source": upload_id, "files": [], "image_mapping": {}, "shortId": short_id} images_dir = deck_dir / "images" attachments_dir = deck_dir / "attachments" @@ -366,19 +502,33 @@ def _import_from_upload(upload_id: str, deck_id: str, filename: str) -> str: shutil.copy2(img, images_dir / dest_name) result["files"].append(f"images/{dest_name}") result["image_mapping"][img.name] = f"images/{dest_name}" + elif src_file.is_dir(): + # Preserve directory structure (e.g. slides/) under + # attachments/{shortId}/{name}/ so agents can walk it. + dest_subdir = attachments_dir / short_id / src_file.name + dest_subdir.mkdir(parents=True, exist_ok=True) + for child in src_file.iterdir(): + if child.is_file(): + shutil.copy2(child, dest_subdir / child.name) + result["files"].append( + f"attachments/{short_id}/{src_file.name}/{child.name}" + ) elif src_file.is_file(): + # PPTX-derived placeholder template lives at deck/template.pptx + # so pptx_builder can resolve it via the deck-local relative path. + if src_file.name == "template.pptx": + shutil.copy2(src_file, deck_dir / "template.pptx") + result["files"].append("template.pptx") + result["templatePath"] = "template.pptx" + continue attachments_dir.mkdir(exist_ok=True) - if src_file.name == "slides.json": - dest_name = f"{short_id}_{file_name.rsplit('.', 1)[0]}.json" - shutil.copy2(src_file, attachments_dir / dest_name) - result["files"].append(f"attachments/{dest_name}") - result["json"] = f"attachments/{dest_name}" - else: - dest_name = f"{short_id}_{src_file.name}" - shutil.copy2(src_file, attachments_dir / dest_name) - result["files"].append(f"attachments/{dest_name}") - if src_file.suffix == ".md": - result["markdown"] = f"attachments/{dest_name}" + dest_name = f"{short_id}_{src_file.name}" + shutil.copy2(src_file, attachments_dir / dest_name) + result["files"].append(f"attachments/{dest_name}") + if src_file.suffix == ".md": + result["markdown"] = f"attachments/{dest_name}" + if src_file.name == "deck.json": + result["deckJson"] = f"attachments/{dest_name}" return json.dumps(result, ensure_ascii=False) if status == "completed": diff --git a/mcp-server/server.py b/mcp-server/server.py index ec2f88b5..7707e95a 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -54,11 +54,11 @@ → Read `read_workflows(["create-new-1-briefing"])` to start. Follow each file's Next Step from there. """ -# TODO: Add these workflows when web UI supports them -# ## Workflow B: Edit Existing PPTX -# When an existing PPTX is provided. -# → Read `read_workflows(["edit-existing"])` to start. +# Edit-existing-PPTX flow on Cloud is driven by upload_file returning +# guideInstruction: "read_guides([\"import-pptx\"])" — the spec agent +# follows that pointer instead of a hard-coded workflow name here. # +# TODO: Add these workflows when web UI supports them # ## Workflow C: Hand-Edit Sync # When the user hand-edits the generated PPTX in PowerPoint and then asks for further changes. # → Read `read_workflows(["create-new-4-hand-edit-sync"])` to start. @@ -207,18 +207,45 @@ def init_presentation(name: str) -> str: @mcp.tool() -def analyze_template(template: str) -> str: +def analyze_template(template: str, deck_id: str = "") -> str: """Get pre-analyzed template information — layouts, theme colors, fonts. Call this to understand what layouts are available before building slides. Args: - template: Template name from list_templates. Required. + template: Template name from list_templates, OR "template.pptx" to + analyze the deck-local placeholder template (set by import_attachment + during the import-pptx flow). When passing "template.pptx", deck_id + must also be supplied. + deck_id: Required only when template == "template.pptx" — identifies + the deck whose deck-local template.pptx should be analyzed. Returns: JSON with layouts, theme colors, and font information. """ if not template or not template.strip(): return json.dumps({"error": "template is required"}) + + # Deck-local placeholder template (PPTX-derived; copied by import_attachment + # during the import-pptx guide). Download from the deck workspace bucket + # and analyze on the fly so import-pptx Step 5 can extract theme colors + # and layouts without first registering the source PPTX as a sdpm template. + if template == "template.pptx": + if not deck_id: + return json.dumps({"error": "deck_id is required when template == 'template.pptx'"}) + try: + import tempfile + from pathlib import Path + from sdpm.analyzer import analyze_template as _analyze + data = _storage.download_file_from_pptx_bucket(f"decks/{deck_id}/template.pptx") + tmp = Path(tempfile.mkdtemp()) + tpl_path = tmp / "template.pptx" + tpl_path.write_bytes(data) + analysis = _analyze(tpl_path) + analysis["templateName"] = "template.pptx" + return json.dumps(analysis, ensure_ascii=False) + except Exception as e: + return json.dumps({"error": f"Failed to analyze deck-local template: {e}"}) + return json.dumps( template_mod.analyze_template(template_name=template, storage=_storage, user_id=_get_user_id()), ensure_ascii=False, @@ -613,8 +640,8 @@ def run_python(purpose: str, code: str, deck_id: str | None = None, save: bool = If deck_id is provided, the entire deck workspace is loaded as files: deck.json — deck metadata (template, fonts, defaultTextColor) - slides/{slug}.json — per-slide data (read/write via json.load/json.dump) - specs/brief.md — briefing document + slides/{slug}.json — per-slide data + specs/brief.md — briefing document specs/art-direction.html — design direction (HTML) specs/outline.md — slide outline (1 line = 1 slide = 1 message) includes/ — code block JSON files (created by code_to_slide) @@ -622,7 +649,22 @@ def run_python(purpose: str, code: str, deck_id: str | None = None, save: bool = Legacy decks with presentation.json are also supported (read-only compat). - All files are accessible via normal file I/O (open, read, write). + ## Sandbox helpers (preferred — identical API on Local and Cloud) + + read_json(path) → dict/list Read a JSON file + write_json(path, data) → None Write data as JSON + read_text(path) → str Read a text file + write_text(path, text) → None Write a text file + list_files(subdir=".") → list[str] List filenames in a subdirectory + + All paths are relative to the deck root. The helpers are injected + automatically — do NOT write `from _sdpm_helpers import ...` yourself; + the import is prepended by the sandbox. Using the helpers keeps the + same code portable between Local (AST-restricted) and Cloud. + + Raw `open()` / `json.load` still work on Cloud for backward compat, + but new code should prefer the helpers. + If save=True, all modified/new workspace files are written back to S3. **Always specify measure_slides when editing slides.** Runs validation after @@ -637,12 +679,25 @@ def run_python(purpose: str, code: str, deck_id: str | None = None, save: bool = Example: files=["uploads/tmp/user/abc/data.csv"] → accessible as "data.csv" in code. Examples: - Edit slides: run_python(code="...", deck_id="abc", save=True, measure_slides=["title", "feature-a"]) - Edit specs: run_python(code="open('specs/brief.md','w').write('...')", deck_id="abc", save=True) - Measure only: run_python(code="print('ok')", deck_id="abc", measure_slides=["title"]) - Compute: run_python(code="print(2**100)") - CSV: run_python(code="import pandas as pd; print(pd.read_csv('data.csv'))", - files=["uploads/tmp/user/x/data.csv"]) + Edit slide: + data = read_json("slides/title.json") + data["elements"][0]["text"] = "New Title" + write_json("slides/title.json", data) + # run_python(code=<above>, deck_id="abc", save=True, measure_slides=["title"]) + + Edit spec: + write_text("specs/brief.md", "# Brief\\n\\nContents...") + # run_python(code=<above>, deck_id="abc", save=True) + + Read deck metadata: + deck = read_json("deck.json") + print(deck.get("template")) + + List slide files: + print(list_files("slides")) + + General computation (no deck_id): + print(2 ** 100) Args: code: Python code to execute. diff --git a/mcp-server/tools/attachment.py b/mcp-server/tools/attachment.py index 59170e9e..7fcfc759 100644 --- a/mcp-server/tools/attachment.py +++ b/mcp-server/tools/attachment.py @@ -61,7 +61,7 @@ def _import_from_upload( file_type = item.get("fileType", "") s3_key = item.get("s3KeyRaw", "") - result = {"source": upload_id, "files": [], "image_mapping": {}} + result = {"source": upload_id, "files": [], "image_mapping": {}, "shortId": short_id} # --- Converted files (PDF/DOCX/XLSX/PPTX) --- if status == "converted": @@ -181,6 +181,8 @@ def _import_converted( ) -> str: """Copy converted files from S3 upload prefix to deck workspace.""" keys = storage.list_files(converted_prefix, bucket=storage.pptx_bucket) + # Ensure shortId is surfaced even when the branch above did not set it. + result.setdefault("shortId", short_id) for key in keys: rel = key[len(converted_prefix) + 1:] # strip prefix + / @@ -194,12 +196,33 @@ def _import_converted( storage.upload_file(key=dest_key, data=src_data, content_type=ct) result["files"].append(f"images/{dest_name}") result["image_mapping"][img_name] = f"images/{dest_name}" - elif rel == "slides.json": - dest_name = f"{short_id}_{file_name.rsplit('.', 1)[0]}.json" + elif rel.startswith("slides/"): + # Preserve deck-structure slides under attachments/{shortId}/slides/ + # so the agent can walk them during guide Step 5. + slide_name = rel.split("/", 1)[1] + dest_key = f"decks/{deck_id}/attachments/{short_id}/slides/{slide_name}" + storage.upload_file( + key=dest_key, data=src_data, content_type="application/json", + ) + result["files"].append(f"attachments/{short_id}/slides/{slide_name}") + elif rel == "deck.json": + dest_name = f"{short_id}_deck.json" dest_key = f"decks/{deck_id}/attachments/{dest_name}" - storage.upload_file(key=dest_key, data=src_data, content_type="application/json") + storage.upload_file( + key=dest_key, data=src_data, content_type="application/json", + ) result["files"].append(f"attachments/{dest_name}") - result["json"] = f"attachments/{dest_name}" + result["deckJson"] = f"attachments/{dest_name}" + elif rel == "template.pptx": + # PPTX-derived placeholder template lives at deck/template.pptx + # so pptx_builder can resolve it via the deck-local relative path. + dest_key = f"decks/{deck_id}/template.pptx" + storage.upload_file( + key=dest_key, data=src_data, + content_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + result["files"].append("template.pptx") + result["templatePath"] = "template.pptx" else: dest_name = f"{short_id}_{rel}" dest_key = f"decks/{deck_id}/attachments/{dest_name}" diff --git a/mcp-server/tools/generate.py b/mcp-server/tools/generate.py index 3cd038ba..b50c1dd4 100644 --- a/mcp-server/tools/generate.py +++ b/mcp-server/tools/generate.py @@ -198,17 +198,30 @@ def _prepare_workspace( # Resolve template template_key = "" + template_path = tmpdir / "template.pptx" tmpl_name = presentation.get("template", "") + # Deck-local placeholder template (PPTX-derived; copied by import_attachment). + # When deck.json["template"] == "template.pptx", read decks/{deck_id}/template.pptx + # from the deck workspace bucket instead of treating it as a sdpm template name. + if tmpl_name == "template.pptx": + deck_template_key = f"decks/{deck_id}/template.pptx" + try: + template_path.write_bytes( + storage.download_file_from_pptx_bucket(deck_template_key), + ) + tmpl_name = "" # signal: resolved + except Exception: + tmpl_name = "" # fall through to sdpm-template lookup below if tmpl_name: normalized = tmpl_name.removesuffix(".pptx") for t in storage.list_templates(): if t.get("name") == normalized: template_key = t.get("s3Key", "") break - if not template_key: - template_key = deck.get("templateS3Key", "templates/blank-dark.pptx") - template_path = tmpdir / "template.pptx" - template_path.write_bytes(storage.download_file(key=template_key)) + if not template_path.exists(): + if not template_key: + template_key = deck.get("templateS3Key", "templates/blank-dark.pptx") + template_path.write_bytes(storage.download_file(key=template_key)) # Fonts fonts = presentation.get("fonts") or deck.get("fonts") diff --git a/mcp-server/tools/sandbox.py b/mcp-server/tools/sandbox.py index 4ae89ef6..d17e9a32 100644 --- a/mcp-server/tools/sandbox.py +++ b/mcp-server/tools/sandbox.py @@ -31,6 +31,45 @@ _WORKSPACE_PREFIXES = ("deck.json", "slides/", "specs/", "includes/", "attachments/") +# Helpers injected into every sandbox session so agent code can be written once +# and run unchanged on Local (AST-restricted subprocess) and Cloud (AgentCore +# Code Interpreter). These mirror mcp-local/sandbox.py's _RUNNER_WITH_DECK. +_HELPERS_PY = '''\ +import json as _json +from pathlib import Path as _Path + +def read_json(path): + return _json.loads(_Path(path).read_text(encoding="utf-8")) + +def write_json(path, data): + p = _Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(_json.dumps(data, ensure_ascii=False, indent=2) + "\\n", encoding="utf-8") + +def read_text(path): + return _Path(path).read_text(encoding="utf-8") + +def write_text(path, text): + p = _Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + +def list_files(subdir="."): + d = _Path(subdir) + if not d.is_dir(): + raise FileNotFoundError(f"Not a directory: {subdir}") + return sorted(f.name for f in d.iterdir() if f.is_file()) +''' + +# Prepended to every user code invocation so read_json / write_json / ... +# are available without an explicit import. Agents must NOT write their own +# 'from _sdpm_helpers import ...' line — the injection handles it so Local +# and Cloud guide code can stay identical (Local's AST forbids import). +_HELPERS_IMPORT = ( + "from _sdpm_helpers import read_json, write_json, read_text, write_text, list_files\n" +) + + def execute_in_sandbox( code: str, storage: Storage, @@ -86,6 +125,10 @@ def execute_in_sandbox( if deck_id: _upload_deck_workspace(client, session_id, storage, deck_id) + # Inject shared sandbox helpers (read_json / write_json / ...) so user + # code can use the same API on Local and Cloud. + _inject_helpers(client, session_id) + # Upload additional files by basename if files: file_contents = [] @@ -95,12 +138,13 @@ def execute_in_sandbox( file_contents.append({"path": basename, "text": data.decode("utf-8")}) _write_files(client, session_id, file_contents) - # Execute user code + # Execute user code (prefixed with helper import so agents can use + # read_json / write_json / ... without an explicit import). response = client.invoke_code_interpreter( codeInterpreterIdentifier="aws.codeinterpreter.v1", sessionId=session_id, name="executeCode", - arguments={"language": "python", "code": code}, + arguments={"language": "python", "code": _HELPERS_IMPORT + code}, ) output = _collect_stream(response) @@ -267,6 +311,16 @@ def _save_deck_workspace( return outline_rejected, lint_diagnostics +def _inject_helpers(client: Any, session_id: str) -> None: + """Write the shared helper module into the sandbox cwd. + + Runs after _upload_deck_workspace so `_sdpm_helpers.py` sits alongside + the deck files. User code gets these helpers via the `_HELPERS_IMPORT` + prefix prepended to every invocation in `execute_in_sandbox`. + """ + _write_files(client, session_id, [{"path": "_sdpm_helpers.py", "text": _HELPERS_PY}]) + + def _write_files(client: Any, session_id: str, content: list[dict[str, str]]) -> None: """Write files into the sandbox. diff --git a/mcp-server/tools/upload.py b/mcp-server/tools/upload.py index deee8f00..b8f8cc48 100644 --- a/mcp-server/tools/upload.py +++ b/mcp-server/tools/upload.py @@ -207,6 +207,87 @@ def _format_cat_n(text: str, file_name: str, offset: int, limit: int) -> str: return f"{header}\n\n{body}{footer}" +def _format_deck_summary_from_s3( + storage: Storage, prefix: str, file_name: str, offset: int, limit: int, +) -> str | None: + """Return a markdown-style slide summary when the S3 prefix contains deck structure. + + Output shape mirrors the Local `_format_deck_text_summary` helper so agents + receive identical content from both modes. + """ + import json as _json + + # Cheap check: is there a deck.json + at least one slides/slide-*.json? + try: + deck_bytes = storage.download_file_from_pptx_bucket(f"{prefix}/deck.json") + _ = deck_bytes # only used to confirm existence + except Exception: + return None + + slide_keys = [ + k for k in storage.list_files(f"{prefix}/slides/", bucket=storage.pptx_bucket) + if k.endswith(".json") + ] + if not slide_keys: + return None + + def _extract_title(data: dict) -> str: + t = data.get("title") + if isinstance(t, str): + return t + if isinstance(t, dict): + return t.get("text", "") or "" + return "" + + def _collect_text(node, out: list[str]) -> None: + if isinstance(node, dict): + for key in ("text", "subtitle", "label", "date", "notes"): + v = node.get(key) + if isinstance(v, str) and v.strip(): + out.append(v) + for p in node.get("paragraphs", []) or []: + if isinstance(p, dict): + t = p.get("text") + if isinstance(t, str) and t.strip(): + out.append(t) + for item in node.get("items", []) or []: + if isinstance(item, str) and item.strip(): + out.append(item) + headers = node.get("headers") + if isinstance(headers, list): + out.extend(str(c) for c in headers if c) + rows = node.get("rows") + if isinstance(rows, list): + for row in rows: + if isinstance(row, list): + out.extend(str(c) for c in row if c) + for child in node.get("elements", []) or []: + _collect_text(child, out) + + sections: list[str] = [] + for i, key in enumerate(sorted(slide_keys), start=1): + try: + data = _json.loads( + storage.download_file_from_pptx_bucket(key).decode("utf-8"), + ) + except Exception: + continue + title = _extract_title(data) + header = f"--- Slide {i}: {title} ---" if title else f"--- Slide {i} ---" + body_parts: list[str] = [] + for el in data.get("elements", []) or []: + _collect_text(el, body_parts) + seen: set[str] = set() + deduped: list[str] = [] + for p in body_parts: + if p not in seen: + seen.add(p) + deduped.append(p) + sections.append(f"{header}\n{chr(10).join(deduped)}".rstrip()) + + return _format_cat_n("\n\n".join(sections), file_name, offset, limit) + + def _read_converted( storage: Storage, prefix: str, file_name: str, warning_text: str, offset: int, limit: int, @@ -214,25 +295,18 @@ def _read_converted( """Read converted files from S3 prefix (cat -n format).""" result: list = [] - # Try Markdown (.md) - md_found = False - stem = file_name.rsplit(".", 1)[0] if "." in file_name else file_name - candidate = f"{prefix}/{stem}.md" - try: - md_data = storage.download_file_from_pptx_bucket(candidate) - result.append(_format_cat_n(md_data.decode("utf-8"), file_name, offset, limit)) - md_found = True - except Exception: - pass + # Deck structure (PPTX): full slide text summary + deck_summary = _format_deck_summary_from_s3(storage, prefix, file_name, offset, limit) + if deck_summary is not None: + result.append(deck_summary) - # Try JSON (slides.json for PPTX) - if not md_found: + # Try Markdown (.md) — PDF/DOCX/XLSX path + if not result: + stem = file_name.rsplit(".", 1)[0] if "." in file_name else file_name + candidate = f"{prefix}/{stem}.md" try: - json_data = storage.download_file_from_pptx_bucket(f"{prefix}/slides.json") - # JSON is usually compact; format with line numbers too - import json as _json - pretty = _json.dumps(_json.loads(json_data), ensure_ascii=False, indent=2) - result.append(_format_cat_n(pretty, file_name, offset, limit)) + md_data = storage.download_file_from_pptx_bucket(candidate) + result.append(_format_cat_n(md_data.decode("utf-8"), file_name, offset, limit)) except Exception: pass diff --git a/prompts/composer-agent.md b/prompts/composer-agent.md deleted file mode 100644 index 47ffe11e..00000000 --- a/prompts/composer-agent.md +++ /dev/null @@ -1,34 +0,0 @@ -Current date and time: {now} - -You are the composer agent for spec-driven-presentation-maker. -You handle Phase 2 (compose slides) and Phase 3 (review + polish). -You work silently — no user interaction. Execute the instruction fully and return. - -## Target Deck -deck_id: {deck_id} -Use this deck_id for ALL run_python and generate_pptx calls. Do NOT call init_presentation. - -## Architecture -- Edit workspace files via `run_python(deck_id="{deck_id}", save=True)` using normal file I/O -- Measure: `run_python(code=..., deck_id="{deck_id}", save=True, measure_slides=["slug"])` — always specify measure_slides when editing slides -- MCP tools: generate_pptx, get_preview for build and preview -- Do NOT call read_workflows, read_guides, read_examples — all references are pre-loaded below -- Do NOT call init_presentation — the deck already exists - -## Your Role -- Read the instruction provided, which specifies which slides to compose -- deck.json is READ-ONLY — do not modify it -- Write each slide to slides/{{slug}}.json via run_python -- Follow the compose workflow below — you already have everything you need -- After composing, generate PPTX, measure, preview, and polish autonomously -- ALL references below are already loaded — skip any "Before starting, you MUST run/read" instructions in the workflow -- Your assigned slides are pre-loaded below. Other slides in slides/ are listed by name only — read them via run_python if you need to reference their content - -## Constraints -- Do NOT ask the user anything — you have no user interaction -- Do NOT modify deck.json, specs/brief.md, specs/outline.md, or specs/art-direction.html -- Write ONLY the slides assigned to you — NEVER write to other slides/*.json files - - Multiple composer agents run in parallel, each owning different slides - - Writing to another agent's slides causes data races and corrupts their work - -{common_context} diff --git a/prompts/spec-agent.md b/prompts/spec-agent.md deleted file mode 100644 index 5c78d6ea..00000000 --- a/prompts/spec-agent.md +++ /dev/null @@ -1,56 +0,0 @@ -Current date and time: {now} - -You are the SPEC agent for spec-driven-presentation-maker. -You handle Phase 1 (briefing → outline → art-direction) through user dialogue. -Respond in the same language as the user. - -{mcp_instructions} - -## Your Role -- Conduct Phase 1: briefing → outline → art direction — all through user dialogue - You MUST complete each step in order. Do NOT skip any step — the composer relies on all 3 spec files -- When Phase 1 is complete and the user approves, call `compose_slides(deck_id=..., slide_groups=[...])` to delegate slide generation to the composer agent -- Before calling compose_slides, confirm you have written all 3 spec files: - specs/brief.md, specs/outline.md, specs/art-direction.html - If any are missing, write them before proceeding — the composer cannot work without them -- The composer agent can only see specs/ files — it has no access to the conversation. - All information needed to compose slides (content, data, context, references) must be written into the spec files -- You do NOT write slide JSON yourself. You do NOT call build/measure/preview tools directly -- Do NOT read Phase 2/3 workflows (create-new-2-compose, create-new-3-review, slide-json-spec) or Phase 2 guides/examples (grid, components, patterns) — the composer agent has its own references pre-loaded -- After compose_slides returns, review the report and relay results to the user -- For user modification requests, translate them into instructions and call compose_slides again - -## Post-Compose Review -After compose_slides returns, perform a cross-slide consistency review: -1. Check `outline_check` in the report — if `missing` is non-empty, decide whether to retry or inform the user -2. Call `get_preview(deck_id, slide_numbers=[...])` to get preview images of ALL slides -3. Review the preview images for: - - Adjacent slides using the same layout (repetitive feel) → instruct composer to vary - - Message flow disconnects between slides (does the story progress logically?) - - Foreshadowing set up in early slides but not resolved later - - Design token deviations (colors, fonts inconsistent with art-direction) -4. If issues found, call compose_slides again with targeted instructions for specific slugs -5. Present the final result to the user with preview images - -## Slide Group Assignment for compose_slides -When calling compose_slides, split the outline into groups using this 2-step process: - -**Step 1 — Form core groups** (slides that MUST share the same design): -- Override-inherited slides (same slug prefix, e.g. demo-1, demo-2) → same group (required) -- Structurally identical roles (e.g. all intro slides, all demo slides) → same group (strongly recommended) -- Slides the user explicitly asked to unify → same group - -**Step 2 — Distribute independent slides** for load balancing: -- Assign remaining slides (title, closing, etc.) to existing groups so each group has roughly equal work -- Do NOT create a group with only independent slides - -Rules: -- Do NOT consider adjacent-slide layout diversity (handled by post-review) -- No constraint on group count or size (concurrency is controlled by semaphore) -- Do NOT create a core group with only 1 slide (nothing to unify — treat as independent) - -## File Uploads -- When a user message contains [Attached: filename (uploadId: xxx)], use read_uploaded_file(upload_id) to read content. Works before deck creation (no deck_id needed). -- For uploaded PDFs/DOCX/XLSX, use page_start=N to paginate through long documents. -- To use an uploaded file in slides, call import_attachment(source=upload_id, deck_id=...) after deck creation. -- To use a web image in slides, call import_attachment(source=url, deck_id=...). diff --git a/scripts/translate_apply.py b/scripts/translate_apply.py new file mode 100755 index 00000000..a042420e --- /dev/null +++ b/scripts/translate_apply.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Apply translations from a derived deck's translation_map.json. + +Usage:: + + uv run python3 scripts/translate_apply.py {deck_dir} [--dry-run] + +Reads ``{deck_dir}/translate/translation_map.json`` and rewrites each +``{deck_dir}/slides/*.json`` in place, replacing translatable strings whose +key exists in the dictionary (and whose value is non-empty). + +Structural attributes (layout, type, shape, src, fontColor, fontFamily, etc.) +are never touched. ``\x0b`` (vertical tab) is preserved because the map file +round-trips via ``json``. + +Entire-paragraph gradients sync automatically: when a paragraph carries +``_textGradientRuns`` whose concatenated text equals the paragraph's +``text``, the runs are replaced so they stay consistent with the translated +paragraph. Partial paragraph gradients are left unchanged — the operator +must adjust the runs manually. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Keep in sync with translate_extract.py. +_TRANSLATABLE_KEYS = { + "text", + "subtitle", + "title", + "date", + "label", + "notes", +} +_SKIP_KEYS = { + "layout", + "type", + "shape", + "preset", + "connectorType", + "src", + "masterIndex", + "fontFamily", + "fontColor", + "fill", + "color", + "fontSize", + "opacity", + "align", + "verticalAlign", + "id", + "x", + "y", + "width", + "height", + "defaultTextColor", + "background", + "template", +} + + +def _translate_string( + value: str, dictionary: dict[str, str], counter: dict[str, int], +) -> str: + """Return the translation when the key exists and value is non-empty.""" + if not isinstance(value, str): + return value + replacement = dictionary.get(value) + if replacement: # non-empty string means "translate" + counter["replaced"] = counter.get("replaced", 0) + 1 + return replacement + return value + + +def _apply( + node, dictionary: dict[str, str], counter: dict[str, int], +): + """Recursively rewrite translatable strings in-place and return node.""" + if isinstance(node, dict): + for key, value in list(node.items()): + if key in _SKIP_KEYS: + continue + if key == "_textGradientRuns": + continue # auto-synced later at paragraph level + if isinstance(value, str): + if key in _TRANSLATABLE_KEYS: + node[key] = _translate_string(value, dictionary, counter) + elif isinstance(value, list): + if key == "items": + node[key] = [ + _translate_string(item, dictionary, counter) + if isinstance(item, str) else _apply(item, dictionary, counter) + for item in value + ] + elif key == "headers": + node[key] = [ + _translate_string(cell, dictionary, counter) + if isinstance(cell, str) else cell + for cell in value + ] + elif key == "rows": + new_rows = [] + for row in value: + if isinstance(row, list): + new_rows.append([ + _translate_string(cell, dictionary, counter) + if isinstance(cell, str) else cell + for cell in row + ]) + else: + new_rows.append(row) + node[key] = new_rows + else: + for child in value: + _apply(child, dictionary, counter) + elif isinstance(value, dict): + _apply(value, dictionary, counter) + _sync_gradient_runs(node) + elif isinstance(node, list): + for child in node: + _apply(child, dictionary, counter) + return node + + +def _sync_gradient_runs(node: dict) -> None: + """When a paragraph's gradient covers the whole text, rewrite runs.text. + + Only applies when sum of run texts equalled the original ``text`` — that + is the entire-paragraph gradient case. Partial gradients keep the runs + intact; the operator must adjust them manually. + """ + if not isinstance(node, dict): + return + runs = node.get("_textGradientRuns") + text = node.get("text") + if not isinstance(runs, list) or not isinstance(text, str): + return + if not all(isinstance(r, dict) and isinstance(r.get("text", ""), str) for r in runs): + return + run_concat = "".join(r.get("text", "") for r in runs) + # Only sync when the runs originally spanned the entire paragraph. + if run_concat and run_concat == text: + # Already in sync (pre-translation state); no-op. + return + if run_concat and run_concat != text: + # If the text is now shorter/longer (translated), collapse to single run. + if len(runs) == 1: + runs[0]["text"] = text + + +def _diff_summary(before: dict, after: dict) -> list[tuple[str, str]]: + """Flatten-diff translatable strings for --dry-run reporting.""" + changes: list[tuple[str, str]] = [] + + def walk(b, a): + if isinstance(b, dict) and isinstance(a, dict): + for k in b.keys() | a.keys(): + if k in _SKIP_KEYS or k == "_textGradientRuns": + continue + if isinstance(b.get(k), str) and isinstance(a.get(k), str): + if b[k] != a[k] and k in _TRANSLATABLE_KEYS: + changes.append((b[k], a[k])) + elif isinstance(b.get(k), list) and isinstance(a.get(k), list): + for bi, ai in zip(b[k], a[k]): + if isinstance(bi, str) and isinstance(ai, str): + if bi != ai: + changes.append((bi, ai)) + else: + walk(bi, ai) + elif isinstance(b.get(k), dict) and isinstance(a.get(k), dict): + walk(b[k], a[k]) + elif isinstance(b, list) and isinstance(a, list): + for bi, ai in zip(b, a): + walk(bi, ai) + + walk(before, after) + return changes + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Apply translations from a derived deck's translation_map.json.", + ) + parser.add_argument("deck_dir", help="Path to the derived deck directory") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the diff without modifying any files", + ) + args = parser.parse_args() + + deck = Path(args.deck_dir).resolve() + if not deck.is_dir(): + print(f"Error: deck directory not found: {deck}", file=sys.stderr) + return 1 + + map_path = deck / "translate" / "translation_map.json" + if not map_path.exists(): + print(f"Error: {map_path} not found", file=sys.stderr) + return 1 + + try: + dictionary: dict[str, str] = json.loads(map_path.read_text(encoding="utf-8")) + except Exception as e: + print(f"Error: failed to parse translation_map.json: {e}", file=sys.stderr) + return 1 + if not isinstance(dictionary, dict): + print("Error: translation_map.json must be a JSON object", file=sys.stderr) + return 1 + + slides_dir = deck / "slides" + if not slides_dir.is_dir(): + print(f"Error: {slides_dir} not found", file=sys.stderr) + return 1 + + total_changes = 0 + for slide_file in sorted(slides_dir.glob("*.json")): + try: + data_text = slide_file.read_text(encoding="utf-8") + data = json.loads(data_text) + except Exception as e: + print(f"Warning: failed to parse {slide_file.name}: {e}", file=sys.stderr) + continue + counter: dict[str, int] = {} + # Work on a deep copy so dry-run diffs are accurate. + after = json.loads(data_text) + _apply(after, dictionary, counter) + + if args.dry_run: + diffs = _diff_summary(data, after) + if diffs: + print(f"{slide_file.name}: {len(diffs)} change(s)") + for before, now in diffs[:3]: + # Truncate for readability. + b = before if len(before) <= 60 else before[:57] + "..." + n = now if len(now) <= 60 else now[:57] + "..." + print(f" - {b!r} → {n!r}") + if len(diffs) > 3: + print(f" - ... ({len(diffs) - 3} more)") + total_changes += len(diffs) + else: + slide_file.write_text( + json.dumps(after, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + if counter.get("replaced"): + total_changes += counter["replaced"] + + if args.dry_run: + print(f"\n[dry-run] {total_changes} replacement(s) — no files modified.") + else: + print(f"Applied {total_changes} replacement(s) in-place.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/translate_extract.py b/scripts/translate_extract.py new file mode 100755 index 00000000..e148eddb --- /dev/null +++ b/scripts/translate_extract.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Extract translatable text from a deck and scaffold a derived deck. + +Workflow:: + + uv run python3 scripts/translate_extract.py {deck_dir} \\ + --target-lang ja \\ + [--skip-short N] \\ + [--output-dir <path>] + +Creates a derived deck ``{deck_dir}-{lang}/`` (or the path given via +``--output-dir``) containing copies of the source deck plus a +``translate/`` sub-directory with an empty dictionary template +(``translation_map.json``) and a review-oriented TSV dump +(``texts.tsv``). + +The original deck is untouched. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path + +# Copied verbatim to keep the script self-contained (no sdpm.* imports +# beyond the standard library). Keep in sync with translate_apply.py. +_TRANSLATABLE_KEYS = { + "text", + "subtitle", + "title", + "date", + "label", + "notes", +} +_SKIP_KEYS = { + "layout", + "type", + "shape", + "preset", + "connectorType", + "src", + "masterIndex", + "fontFamily", + "fontColor", + "fill", + "color", + "fontSize", + "opacity", + "align", + "verticalAlign", + "id", + "x", + "y", + "width", + "height", + "defaultTextColor", + "background", + "template", +} +# Directories/files NOT copied to the derived deck. +_SKIP_COPY = {"output.pptx", "preview", "compose", "translate"} + + +def _is_translatable_string(value: str, skip_short: int) -> bool: + """True when the string should be included in the dictionary template.""" + if not isinstance(value, str): + return False + stripped = value.strip() + if not stripped: + return False + if len(stripped) <= skip_short: + return False + if stripped.startswith(("http://", "https://")): + return False + return True + + +def _collect(node: object, out: dict[str, None], skip_short: int) -> None: + """Walk a slide dict/list and collect translatable strings as keys.""" + if isinstance(node, dict): + for key, value in node.items(): + if key in _SKIP_KEYS: + continue + if key == "_textGradientRuns": + # Auto-synced by translate_apply when the whole paragraph + # carries the gradient; not a primary extraction target. + continue + if isinstance(value, str): + if key in _TRANSLATABLE_KEYS and _is_translatable_string(value, skip_short): + out.setdefault(value, None) + elif isinstance(value, list): + if key == "items": + for item in value: + if isinstance(item, str) and _is_translatable_string(item, skip_short): + out.setdefault(item, None) + else: + _collect(item, out, skip_short) + elif key == "headers": + for cell in value: + if isinstance(cell, str) and _is_translatable_string(cell, skip_short): + out.setdefault(cell, None) + elif key == "rows": + for row in value: + if isinstance(row, list): + for cell in row: + if isinstance(cell, str) and _is_translatable_string(cell, skip_short): + out.setdefault(cell, None) + else: + for child in value: + _collect(child, out, skip_short) + elif isinstance(value, dict): + _collect(value, out, skip_short) + elif isinstance(node, list): + for child in node: + _collect(child, out, skip_short) + + +def _copy_deck(src: Path, dst: Path) -> None: + """Copy the source deck to the derived location, skipping generated files.""" + dst.mkdir(parents=True) + for entry in src.iterdir(): + if entry.name in _SKIP_COPY: + continue + target = dst / entry.name + if entry.is_dir(): + shutil.copytree(entry, target) + else: + shutil.copy2(entry, target) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Scaffold a derived translated deck by extracting translatable strings.", + ) + parser.add_argument("deck_dir", help="Path to the source deck directory") + parser.add_argument( + "--target-lang", + required=True, + help="Target language code (appended to derived-deck name)", + ) + parser.add_argument( + "--skip-short", + type=int, + default=0, + help="Exclude text of this many characters or fewer (default: 0 = keep all)", + ) + parser.add_argument( + "--output-dir", + default=None, + help="Override derived-deck path (default: {deck_dir}-{target-lang})", + ) + args = parser.parse_args() + + src = Path(args.deck_dir).resolve() + if not src.is_dir(): + print(f"Error: deck directory not found: {src}", file=sys.stderr) + return 1 + if not (src / "deck.json").exists(): + print(f"Error: {src}/deck.json not found — is this a deck directory?", file=sys.stderr) + return 1 + + if args.output_dir: + dst = Path(args.output_dir).resolve() + else: + dst = src.parent / f"{src.name}-{args.target_lang}" + + if dst.exists(): + print( + f"Error: derived deck already exists: {dst}\n" + f" Remove it or pick a different --output-dir / --target-lang.", + file=sys.stderr, + ) + return 1 + + _copy_deck(src, dst) + + # Walk slides/*.json in the derived deck and collect translatable text. + collected: dict[str, None] = {} + slides_dir = dst / "slides" + if slides_dir.is_dir(): + for slide_file in sorted(slides_dir.glob("*.json")): + try: + data = json.loads(slide_file.read_text(encoding="utf-8")) + except Exception as e: + print(f"Warning: failed to parse {slide_file.name}: {e}", file=sys.stderr) + continue + _collect(data, collected, args.skip_short) + + # Write translate/translation_map.json (empty values) + texts.tsv. + translate_dir = dst / "translate" + translate_dir.mkdir() + dictionary = {text: "" for text in collected} + (translate_dir / "translation_map.json").write_text( + json.dumps(dictionary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + with (translate_dir / "texts.tsv").open("w", encoding="utf-8") as tsv: + tsv.write("text\n") + for text in collected: + # TSV: single column, preserve tabs/newlines as literal escapes for review. + tsv.write(text.replace("\t", "\\t").replace("\n", "\\n") + "\n") + + print(f"Derived deck created: {dst}") + print(f" translate/translation_map.json ({len(dictionary)} entries)") + print(" translate/texts.tsv (review copy)") + print() + print("Next steps:") + print(f" 1. Fill values in {dst}/translate/translation_map.json") + print(f" 2. uv run python3 scripts/translate_apply.py {dst} --dry-run") + print(f" 3. uv run python3 scripts/translate_apply.py {dst}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/shared/ingest.py b/shared/ingest.py index 3e9b1865..bc30b032 100644 --- a/shared/ingest.py +++ b/shared/ingest.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import re import zipfile from dataclasses import dataclass, field from pathlib import Path @@ -49,6 +50,15 @@ class ConversionResult: images: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) error: str | None = None + # Deck-structure metadata (populated only when output_dir contains + # deck.json + slides/, i.e. for PPTX after pptx-import-edit). + deck_structure: bool = False + slide_count: int = 0 + theme_hints: dict | None = None + suggested_name: str | None = None + # Path (relative to output_dir) of the placeholder-only template extracted + # from the source PPTX. Populated only when deck_structure is True. + template_path: str | None = None def convert_file(file_path: Path, output_dir: Path) -> ConversionResult: @@ -390,12 +400,169 @@ def _convert_xlsx(file_path: Path, output_dir: Path, images_dir: Path) -> Conver # --------------------------------------------------------------------------- -# PPTX: pptx_to_json Engine +# PPTX: pptx_to_json Engine + deck-structure metadata extraction # --------------------------------------------------------------------------- +def _extract_theme_hints(pptx_path: Path, deck_json_path: Path, slides_dir: Path) -> dict: + """Extract theme hints (backgroundLuminance, accentColors, fonts) from a converted deck. + + Keys are stable across Local / Cloud / DynamoDB: ``backgroundLuminance``, + ``accentColors``, ``fonts``. + + Background luminance strategy (prioritised): + 1. If a slide declares an explicit ``background`` (hex), use that slide's color. + 2. Else, fall back to the template's light theme color (lt1 if available, + otherwise dk1). + 3. Compute per-slide luminance (0.299 R + 0.587 G + 0.114 B, 0-1) and + return the median across all slides. + """ + import json as _json + import statistics + + from sdpm.converter.color import extract_theme_colors_and_mapping + + # Theme colors. PowerPoint can ship multiple slideMasters with + # different clrMaps; corporate decks frequently flip bg1=dk1 (dark + # deck) on a secondary master while keeping master 0 light. Walk all + # masters and pick the one whose clrMap most slides actually use, so + # backgroundLuminance reflects the rendered deck rather than master 0. + theme_colors: dict = {} + color_mapping: dict = {} + try: + # Default to master 0. + theme_colors, color_mapping, _ = extract_theme_colors_and_mapping(pptx_path, 0) + except Exception: + pass + + # Discover all slideMasters and pick the one used by the most slides. + try: + with zipfile.ZipFile(pptx_path) as zf: + names = zf.namelist() + masters = sorted( + n for n in names + if n.startswith("ppt/slideMasters/slideMaster") and n.endswith(".xml") + ) + # layout → master index (0-based per master ordering above) + layout_to_master: dict[str, int] = {} + for idx, master in enumerate(masters): + rels_name = master.replace("slideMasters/", "slideMasters/_rels/") + ".rels" + if rels_name not in names: + continue + rels_xml = zf.read(rels_name).decode("utf-8", errors="ignore") + for layout_match in re.finditer(r'Target="\.\./slideLayouts/(slideLayout\d+\.xml)"', rels_xml): + layout_to_master[layout_match.group(1)] = idx + + # slide → layout → master + master_usage: dict[int, int] = {} + slide_files = sorted( + n for n in names + if re.match(r"ppt/slides/slide\d+\.xml$", n) + ) + for slide in slide_files: + rels_name = slide.replace("slides/", "slides/_rels/") + ".rels" + if rels_name not in names: + continue + rels_xml = zf.read(rels_name).decode("utf-8", errors="ignore") + m = re.search(r'Target="\.\./slideLayouts/(slideLayout\d+\.xml)"', rels_xml) + if m: + layout = m.group(1) + master_idx = layout_to_master.get(layout) + if master_idx is not None: + master_usage[master_idx] = master_usage.get(master_idx, 0) + 1 + + if master_usage: + dominant_master = max(master_usage.items(), key=lambda kv: kv[1])[0] + if dominant_master != 0: + try: + theme_colors, color_mapping, _ = extract_theme_colors_and_mapping( + pptx_path, dominant_master + ) + except Exception: + pass + except Exception: + # Multi-master discovery is best-effort; fall through to master 0 + # values that were already loaded above. + pass + + # Resolve bg1 through clrMap. PowerPoint's default mapping is + # bg1=lt1 / tx1=dk1, but corporate decks frequently flip it + # (bg1=dk1 / tx1=lt1) to declare a dark theme without changing the + # theme XML. The dominant slideMaster's clrMap is the authoritative + # answer. + bg1_target = color_mapping.get("bg1", "lt1") if color_mapping else "lt1" + resolved_bg = theme_colors.get(bg1_target) + lt1 = theme_colors.get("lt1", "#FFFFFF") + dk1 = theme_colors.get("dk1", "#000000") + # Fallback chain: slide bg > clrMap-resolved bg1 > lt1 > dk1 inverted + default_bg = resolved_bg if resolved_bg else (lt1 if lt1 else _invert_hex(dk1)) + + def _hex_to_luminance(hex_color: str) -> float: + h = hex_color.lstrip("#") + try: + r = int(h[0:2], 16) + g = int(h[2:4], 16) + b = int(h[4:6], 16) + except (ValueError, IndexError): + return 0.5 + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + + luminances: list[float] = [] + if slides_dir.is_dir(): + for slide_file in sorted(slides_dir.glob("slide-*.json")): + try: + data = _json.loads(slide_file.read_text(encoding="utf-8")) + except Exception: + continue + bg = data.get("background") or default_bg + luminances.append(_hex_to_luminance(bg)) + if not luminances: + luminances = [_hex_to_luminance(default_bg)] + + background_luminance = statistics.median(luminances) + + accent_colors: list[str] = [] + for name in ("accent1", "accent2", "accent3"): + c = theme_colors.get(name) + if isinstance(c, str) and c.startswith("#"): + accent_colors.append(c.upper()) + accent_colors = accent_colors[:3] + + fonts: dict = {} + if deck_json_path.exists(): + try: + deck = _json.loads(deck_json_path.read_text(encoding="utf-8")) + f = deck.get("fonts") + if isinstance(f, dict): + fonts = f + except Exception: + pass + + return { + "backgroundLuminance": round(background_luminance, 4), + "accentColors": accent_colors, + "fonts": fonts, + } + + +def _invert_hex(hex_color: str) -> str: + """Invert a #RRGGBB color (for the dk1-inverted fallback).""" + h = hex_color.lstrip("#") + try: + r = 255 - int(h[0:2], 16) + g = 255 - int(h[2:4], 16) + b = 255 - int(h[4:6], 16) + except (ValueError, IndexError): + return "#FFFFFF" + return f"#{r:02X}{g:02X}{b:02X}" + + def _convert_pptx(file_path: Path, output_dir: Path) -> ConversionResult: - """Convert PPTX to JSON via Engine's pptx_to_json.""" + """Convert PPTX to deck structure via Engine's pptx_to_json. + + Populates deck_structure, slide_count, theme_hints, suggested_name when + the output directory contains the new deck layout (deck.json + slides/). + """ import json try: @@ -405,7 +572,6 @@ def _convert_pptx(file_path: Path, output_dir: Path) -> ConversionResult: try: result = pptx_to_json(file_path, output_dir=output_dir) - # pptx_to_json writes slides.json + images/ to output_dir json_str = json.dumps(result, ensure_ascii=False) # Collect extracted images @@ -414,8 +580,42 @@ def _convert_pptx(file_path: Path, output_dir: Path) -> ConversionResult: if img_dir.exists(): images = [f.name for f in img_dir.iterdir() if f.is_file()] + deck_json_path = output_dir / "deck.json" + slides_dir = output_dir / "slides" + deck_structure = deck_json_path.exists() and slides_dir.is_dir() + + slide_count = 0 + theme_hints: dict | None = None + suggested_name: str | None = None + template_path: str | None = None + warnings: list[str] = [] + if deck_structure: + slide_count = len(list(slides_dir.glob("slide-*.json"))) + try: + theme_hints = _extract_theme_hints(file_path, deck_json_path, slides_dir) + except Exception as e: + logger.warning("theme_hints extraction failed: %s", e) + suggested_name = file_path.stem + try: + from sdpm.converter.template import extract_placeholder_template + + template_out = output_dir / "template.pptx" + extract_placeholder_template(file_path, template_out) + template_path = "template.pptx" + except Exception as e: + logger.warning("placeholder template extraction failed: %s", e) + warnings.append(f"placeholder template extraction failed: {e}") + return ConversionResult( - status="success", json_data=json_str, images=images, + status="success", + json_data=json_str, + images=images, + warnings=warnings, + deck_structure=deck_structure, + slide_count=slide_count, + theme_hints=theme_hints, + suggested_name=suggested_name, + template_path=template_path, ) except Exception as e: return ConversionResult(status="error", error=f"PPTX conversion failed: {e}") diff --git a/skill/SKILL.md b/skill/SKILL.md index ce9f27d9..c1293db4 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -30,8 +30,10 @@ When no existing PPTX is provided. ## Workflow B: Edit Existing PPTX -When an existing PPTX is provided. -→ Run `uv run python3 scripts/pptx_builder.py workflows edit-existing` to start. +When an existing PPTX is provided. Web UI / API path is preferred: +`upload_file` returns `guideInstruction: +"read_guides([\"import-pptx\"])"` automatically. For CLI flows, run +`uv run python3 scripts/pptx_builder.py guides import-pptx` to start. ## Workflow C: Hand-Edit Sync diff --git a/skill/references/guides/import-pptx.md b/skill/references/guides/import-pptx.md new file mode 100644 index 00000000..655a89a6 --- /dev/null +++ b/skill/references/guides/import-pptx.md @@ -0,0 +1,596 @@ +--- +name: import-pptx +description: "Convert an uploaded PPTX into an editable deck (invoked when upload_file returns guide='import-pptx' and user intent is edit)" +category: guide +--- + +# Import PPTX (Edit Existing Presentation) + +Invoke this guide when **both** are true: + +1. `upload_file` response contains `guide: "import-pptx"`, AND +2. The user's intent is confirmed to be **editing** the PPTX (not using + it as reference material for a new deck). + +If intent is ambiguous, use the `hearing` tool **once** to confirm before +entering this guide. If the user wants to use the PPTX as reference, stop +here and follow the normal briefing flow (use `read_uploaded_file` to +access content when writing `specs/brief.md`). + +## Overview + +This guide is the complete workflow for the edit branch. The user already +provided the PPTX itself — that *is* the brief. The PPTX-derived +**placeholder template** (extracted automatically during upload and +copied into the deck as `deck/template.pptx`) means there is no template +selection step: the deck builds against the source PPTX's own layouts. + +Steps 1 → 6 generate brief / outline / build / art-direction from the +PPTX content automatically; the only user-facing question is the final +review at Step 6. + +The build (Step 4) runs **before** art-direction (Step 5) on purpose. +art-direction.html is consumed by the **composer** when the user +later asks to edit slides — the initial reproduction does not need +it. Building first against the source's own placeholder template +lets you read the rendered slide previews and use them as ground +truth when authoring art-direction.html. + +User-facing `hearing` calls in this guide: + +- **Step 6** — final review and hand-off to the edit loop. + +Between Step 1 and Step 6, do not call `hearing`. Generate everything +from the PPTX content already in your context. + +## State you must carry through the guide + +The triggering `upload_file` response contains fields you reuse later: + +- `uploadId` — Step 2 (`import_attachment(source=uploadId, ...)`) +- `suggestedName` — Step 1 (`init_presentation(name=suggestedName)`) +- `slideCount`, `themeHints` — Step 4 validation and style selection + +These values stay in your conversation context. If you cannot locate +them, scroll back through the prior tool responses — do not ask the +user to re-upload. + +--- + +## Step 1 — Initialize the deck + +Call `init_presentation(name=<suggestedName>)` — **do NOT pass a template +argument**. + +- Cloud `init_presentation` has no template parameter, and Local's + template parameter would pre-populate fonts that Step 4 immediately + overwrites with PPTX-derived fonts. Skipping the argument keeps Local + and Cloud symmetric. +- Template (`"template.pptx"` — deck-local), fonts, and + `defaultTextColor` are written to `deck.json` in Step 4. +- Returns the new `deck_id` (directory path in Local, deckId in Cloud). + +--- + +## Step 2 — Import converted files + +Call `import_attachment(source=<uploadId>, deck_id=<deck_id>)`. + +The helper copies session files into the deck: + +- `template.pptx` — PPTX-derived placeholder template (deck root) +- `attachments/{shortId}_deck.json` — PPTX-derived fonts / defaultTextColor +- `attachments/{shortId}/slides/slide-NN.json` — per-slide JSON +- `images/{shortId}_*` — extracted images (flattened into deck/images/) + +The returned JSON includes `shortId`, `templatePath`, `deckJson`, and +`files[]`. Keep `shortId` — Step 3 and Step 4 need it to locate the +imported per-slide files. + +--- + +## Step 3 — Prepare brief and outline + +Populate `specs/brief.md` and `specs/outline.md` **before** Step 4 +builds the deck. `specs/art-direction.html` is intentionally deferred +to Step 5 — the rendered slide previews from Step 4 are a far better +input for it than the upload-time image extraction. Each sub-step +uses `run_python(save=True)` so the intermediate state is persisted — +Cloud discards the sandbox VM between calls, so `save=False` would +lose the write. + +You generate these specs from the PPTX content you imported in Step 2. +Do not call `hearing` in Step 3 — if a particular field is thin, leave +it succinct rather than asking the user. + +Sandbox helpers (`read_json / write_json / read_text / write_text / +list_files`) are available on both Local and Cloud. Do NOT use `open()` +or `import` inside the sandbox code — Local forbids both and the Cloud +import is already prepended. + +### 3-1. brief.md (Source Material from PPTX) + +First, explore the imported slides to extract titles and text (no save): + +```python +short_id = "<result['shortId'] from Step 2>" +files = list_files(f"attachments/{short_id}/slides") +for name in sorted(files): + data = read_json(f"attachments/{short_id}/slides/{name}") + title = data.get("title") or "" + if isinstance(title, dict): + title = title.get("text", "") + print(name, "::", title) +``` + +Run that via `run_python(code=<above>, deck_id=deck_id, save=False)` +(Cloud: prepend `purpose="Inspect PPTX slides"`). + +Then write `specs/brief.md` in a second call with `save=True`: + +```python +short_id = "<result['shortId']>" +lines = ["# Brief", "", "## Source Material", ""] +for name in sorted(list_files(f"attachments/{short_id}/slides")): + slug = name.removesuffix(".json") + data = read_json(f"attachments/{short_id}/slides/{name}") + title = data.get("title") or "" + if isinstance(title, dict): + title = title.get("text", "") + lines.append(f"### {slug}") + lines.append(f"Source: attachments/{short_id}/slides/{name}") + if title: + lines.append(f"Title: {title}") + lines.append("") +write_text("specs/brief.md", "\n".join(lines) + "\n") +print("brief.md written") +``` + +Call as `run_python(code=<above>, deck_id=deck_id, save=True)` +(Cloud: prepend `purpose="Write brief.md from PPTX content"`). + +### 3-2. outline.md (LLM summarization) + +Summarise each slide in one line (you, the agent, produce the summary — +the sandbox does NOT call LLMs). Pass the `(slug, message)` pairs as a +Python literal: + +```python +# Agent fills this list from slide content seen in Step 3-1. +pairs = [ + ("slide-01", "Introduction to the system"), + ("slide-02", "Storage classes overview"), + # ... one entry per slide, matching attachments/{shortId}/slides/*.json +] +lines = [f"- [{slug}] {msg}" for slug, msg in pairs] +write_text("specs/outline.md", "\n".join(lines) + "\n") +print("outline.md written:", len(pairs)) +``` + +Call with `run_python(code=<above>, deck_id=deck_id, save=True)` +(Cloud: add `purpose="Write outline.md from PPTX content"`). + +Requirements (outline lint will otherwise reject the write on Cloud): + +- Each slug MUST match the filename of an imported slide + (`slide-01`, `slide-02`, ...) — do not rename. +- Messages MUST be non-empty. +- One line per slide, no sub-items. + +--- + +## Step 4 — Place slides + build + preview + compose (single `run_python`) + +Copy the PPTX-derived slide JSON into `slides/`, merge deck metadata +into `deck.json` (using the deck-local `template.pptx`), and build the +deck in a **single** `run_python` call with `save=True`. + +**Do not split Step 4 into multiple calls.** Each Cloud `run_python` +runs in a fresh sandbox VM that is discarded afterward, so intermediate +`save=False` writes are lost. Keeping Step 4 in one call ensures the +copy, S3 writeback, build, preview, and compose all share a single VM. + +Assemble the slug list from Step 3-2 as a Python literal: + +```python +short_id = "<result['shortId']>" +slugs = ["slide-01", "slide-02", "slide-03"] # agent fills from Step 3-2 +# image_mapping is in the import_attachment result. It maps the original +# converter-emitted filename (e.g. "slide1_image1.png") to its +# deck-relative path after rename (e.g. "images/<shortId>_slide1_image1.png"). +image_mapping = {<paste image_mapping dict from Step 2 result here>} + +# 1. Merge PPTX-derived metadata into deck.json (deck-local placeholder template) +deck = read_json("deck.json") +imported = read_json(f"attachments/{short_id}_deck.json") +deck["template"] = "template.pptx" # deck-local; copied by import_attachment +deck["fonts"] = imported.get("fonts", {}) +deck["defaultTextColor"] = imported.get("defaultTextColor") +write_json("deck.json", deck) + +# 2. Pre-flight check — every slug must have a corresponding imported slide +missing = [] +for slug in slugs: + try: + _ = read_json(f"attachments/{short_id}/slides/{slug}.json") + except Exception: + missing.append(slug) +if missing: + print("ERROR missing:", missing) +else: + # 3. Copy each slide JSON from attachments/ into slides/, rewriting + # image src refs through image_mapping. import_attachment renames + # extracted images (e.g. "slide1_image1.png" → deck/images/<shortId>_slide1_image1.png), + # so the converter-emitted src strings ("images/slide1_image1.png") + # no longer resolve and the build silently drops the picture. + def _rewrite_image_refs(node): + if isinstance(node, dict): + if node.get("type") == "image" and isinstance(node.get("src"), str): + src = node["src"] + # src looks like "images/<original_name>" + base = src.split("/", 1)[1] if src.startswith("images/") else src + mapped = image_mapping.get(base) + if mapped: + node["src"] = mapped + for v in node.values(): + _rewrite_image_refs(v) + elif isinstance(node, list): + for item in node: + _rewrite_image_refs(item) + + for slug in slugs: + data = read_json(f"attachments/{short_id}/slides/{slug}.json") + _rewrite_image_refs(data) + write_json(f"slides/{slug}.json", data) + print("placed:", slugs) +``` + +Call as: + +``` +run_python( + code=<above>, + deck_id=deck_id, + save=True, + measure_slides=slugs, +) +``` + +Cloud: prepend `purpose="Import PPTX slides into deck and build"`. + +Because `specs/outline.md` was populated in Step 3-2, `save=True` +triggers a full build that includes every slide, followed by preview +and SVG compose. The PPTX-derived placeholder template means **layout +mismatch is impossible** — the build should succeed in one shot. + +After the `run_python` call returns successfully, call +`generate_pptx(deck_id=deck_id)` once. This persists `output.pptx` +to the deck workspace and updates the deck record's `pptxS3Key`, so +the Web UI can offer a "Download PPTX" button immediately. Without +this call the UI sees no PPTX yet and hides the download action, +even though the slides have rendered. + +--- + +## Step 5 — art-direction.html (deck-specific style) + +Goal: produce a `specs/art-direction.html` that **describes the source +PPTX's visual identity as a style specification** — design tokens +plus 5-6 demonstration slides that show *how the design rules apply*, +not what the source deck contained. + +The output follows the same conventions as every other sdpm style: + +- `:root` block with all design tokens as CSS variables (the style's + *machine-readable specification* — composer reads `var()` + references, not pixel values). +- 5-6 demonstration slides (cover, palette / type ramp / component + swatches / ...). Each slide demonstrates the design while + explaining the reasoning. This is **NOT** a re-render of the + source deck's content slides. +- 1920×1080 absolute positioning, pt units, `.t-*` text classes, + `.el` for absolute elements. (See `create-style` workflow for the + full rule list.) + +The composer reads this file when the user later asks to **edit** +slides — it consumes the tokens, not the demonstration markup. + +> **Critical reframe:** art-direction.html is a *style guide*, not a +> reproduction. If your demonstration slides contain the source +> deck's headlines, bullet points, charts, or specific data, you've +> written the wrong artifact. Demonstration slides should contain +> placeholder text like "Cover Title" / "Section header" / "Body +> sample with **bold** and accent" that exists purely to show how +> the design rules render. + +### 5-1. Load the style-authoring workflow + scaffold + +`create-style.md` is the canonical workflow for authoring sdpm +styles. **Read it first** so you understand what tokens to define, +the demonstration slide pattern, and the critical CSS rules. The +authoring conventions there apply unchanged to art-direction.html; +this guide only adds the import-pptx-specific signal extraction in +Step 5-2. + +``` +read_workflows(["create-style"]) +``` + +Key conventions you must follow (full list in the workflow): +- All design tokens in `:root` as CSS variables. +- All colors via `var()` references — never hardcoded in elements. +- Text style classes (`.t-cover-title`, `.t-slide-title`, `.t-body`, + ...) reference CSS variables. Use class names consistently. +- Component classes (`.card`, `.accent-bar`, `.divider`, ...) also + via CSS variables. +- Inline `style="..."` only for `left / top / width / height`. +- 5-6 demonstration slides (cover + design areas) — NOT the source + deck's slides. + +Then pull a built-in style as a structural reference: + +1. Call `list_styles()`. +2. Pick any scaffold — choose whichever you can read most easily. + The selection has no effect on the final output. +3. Call `apply_style(deck_id, <scaffold>)` (MCP tool — not via + `run_python`). +4. Read the copied file once with `read_text("specs/art-direction.html")` + to confirm the demonstration-slide pattern (cover slide first, + palette swatches, type ramp, then a couple of component-only + variants). Treat its colors / fonts / decorations as + **structural examples**, not values to keep. + +### 5-2. Extract the source PPTX's actual design tokens + +`themeHints` from `upload_file` is a coarse summary (a single +background luminance, three accent colors, two font families). The +source PPTX's master/theme XML and the **rendered slide previews +generated in Step 4** carry far more precise data — layout positions, +every theme color slot, true background fills, and the actual color +frequencies on each slide. + +Combine three lenses on the same source — each catches what the +others miss. + +**Lens A — Visual inspection of rendered previews via `get_preview`:** + +Pull the actual rendered slides into your context as images so you +can see them. PIL pixel statistics (Lens C below) give you frequency +of colors but not *meaning* — they cannot tell you that the orange +bar is a "section divider" or that the rounded box is a "card with +shadow". You have to look. + +``` +get_preview(deck_id, slugs=["slide-01", "slide-03", "slide-05", + "<a section-header slug>", + "<a content slug with cards / lists>"], + quality="high") +``` + +Pick 4-6 slugs that span the deck's variety: cover, a section +header, a typical content slide, any slide with charts/tables, the +closing slide. `quality="high"` (1280px) is worth the extra tokens +because decoration motifs (shadows, line weights, corner radii) are +hard to see at low quality. + +While inspecting each preview, write down: +- **Background** — solid? gradient? bitmap? if solid, the rough hex + (Lens C will pin it down). +- **Title vs body color** — is the title color the same as body, or + a separate accent? Is one of the accents used only in the title + band? +- **Decoration motifs** — accent bars (length / weight / position), + shadows (soft? hard? colored?), corner radii (sharp? rounded? + pill?), divider lines (1px? thicker? colored?), card backgrounds + (filled? bordered? shadowed?), bullet markers (round? square? + arrow?). +- **Layout grid** — left/right margin, where the title sits, where + body content starts, vertical rhythm. Cross-check with + `analyze_template().layouts[]`. +- **Typography hierarchy** — relative size of cover title vs slide + title vs body, weights, italics, font pair contrast. + +These are the qualitative tokens (`--decoration-*`, `--shadow-*`, +`--radius-*`, `--size-*`) that Lens B and C cannot give you. + +**Lens B — Theme XML / layouts via `analyze_template`:** + +Call the MCP tool on the deck-local template (`template.pptx` was +copied here in Step 2 by `import_attachment`). It returns the full +theme color map (lt1 / dk1 / accent1-6 / hlink / folHlink), font +pairs (latin/eastAsian/complex), and per-layout placeholder +positions. + +``` +# Cloud (deck-local placeholder template requires deck_id): +analyze_template(template="template.pptx", deck_id=<deck_id>) + +# Local (file path is fine; deck_id ignored): +analyze_template(template="template.pptx") +``` + +This is an MCP tool — do not wrap in `run_python`. + +Capture from the result: +- `theme_colors` — the canonical 12 theme slots. Use these as the + primary source for `--color-*` tokens. accent1-6 names map to + whatever the source PPTX intends (corporate primary, secondary, + highlight, etc.). Read every accent — `themeHints.accentColors` + truncates to 3. +- `fonts.latin / fonts.eastAsian / fonts.complex` — carry these + through verbatim. Don't substitute with system fonts unless the + source explicitly uses one. +- `layouts[]` — placeholder positions per layout. Use these to size + cover title, slide title, content area in `--size-*` and the + body x/y/width/height in your demonstration slides. + +**Lens C — Pixel-frequency sampling via PIL on `previews/`:** + +Theme XML tells you what colors are *defined*; the rendered slide +previews tell you what's actually *used* and in what proportion. +Step 4's build produced PNG previews under `previews/` — these are +the same images you saw via Lens A. Quantify the dominant hex values +across all of them so the visual impression is grounded in numbers: + +```python +from collections import Counter +from PIL import Image +import os + +# Step 4 wrote rendered slide previews here +preview_files = sorted(p for p in os.listdir("previews") if p.endswith(".png")) +sample = preview_files[:6] # cover + a few content slides +all_pixels = [] +for f in sample: + img = Image.open(os.path.join("previews", f)).convert("RGB").resize((150, 150)) + all_pixels.extend(img.getdata()) +common = Counter(all_pixels).most_common(20) +# Convert RGB tuples to #RRGGBB hex +swatches = ["#{:02X}{:02X}{:02X}".format(r, g, b) for (r, g, b), _ in common] +print("Top 20 hex by pixel frequency:", swatches) +``` + +Cross-reference these swatches with `theme_colors` (Lens B) and +your visual notes (Lens A): +- Frequencies near `theme_colors.lt1 / dk1` confirm the **actual + background** (which may differ from `themeHints.backgroundLuminance` + if the deck uses a non-default master). +- Frequencies near `theme_colors.accent1` confirm which accent is + the deck's hero color (the most-used one is rarely accent1 — pick + the most-frequent accent that isn't bg/text). +- Outliers (high frequency but no match) are deck-specific brand + colors not declared in the theme — capture them as their own + tokens (`--color-brand-orange`, etc.). +- If Lens A noticed a color that PIL ranks low (e.g. only on one + slide), still encode it — Lens A gives the meaning, Lens C only + the prevalence. + +### 5-3. Author art-direction.html following the create-style workflow + +You are now writing a style — follow the **`create-style` workflow** +you loaded in 5-1. The HTML skeleton, `:root` token conventions, +text-class naming (`.t-cover-title` / `.t-body` / ...), demonstration +slide pattern (cover + palette + type ramp + component variants, +total 5-6 slides), absolute-positioning rules, font-size unit, and +violation examples are all defined there. Do not re-invent any of +those conventions in this guide. + +This Step contributes only the **import-pptx-specific token +sourcing**: where each token value comes from. Map each token kind +to the lens that produced it in 5-2: + +| Token kind | Source | +|--------------------------------------------|--------------------------------------| +| `--color-bg` | Lens B `theme_colors.lt1` (light deck) or `dk1` (dark deck), confirmed by Lens C frequency. **Do not** use a guessed neutral or the scaffold's bg. | +| `--color-fg` / text | Lens B `theme_colors.dk1` (light deck) or `lt1` (dark deck) | +| `--color-accent-N` (1 per accent in use) | Lens B `theme_colors.accent1..6`. Hero is the most-used accent per Lens C, not necessarily accent1. | +| Brand color outside the theme | Lens C outliers (high frequency, not in theme_colors). Encode as `--color-brand-<name>`. Lens A confirms semantic role. | +| `--font-heading` / `--font-body` | Lens B `fonts.latin / eastAsian / complex`, verbatim. No system-font substitution. | +| `--size-cover-title` / `--size-slide-title` / `--size-body` | Lens B `layouts[]` text-frame heights → derive pt sizes; cross-check with Lens A visual hierarchy. | +| `--radius-*` / `--shadow-*` / `--border-*` / decoration motifs | Lens A only. If Lens A did not see it, do not declare it. | +| Margin / grid (where title sits, body x/y) | Lens B `layouts[]` placeholder x/y/width/height. | + +After populating tokens, write the demonstration slides. **The +demonstration slides are NOT a re-render of the source deck.** Read +the create-style workflow's "Plan slide composition" section: each +slide demonstrates one design rule with placeholder content like +"Cover Title" / "Section header" / "Body sample paragraph" / +"Component swatches". Do not paste source-deck headlines, bullet +lists, charts, or specific data into the demonstration slides — that +content lives in `slides/` (placed by Step 4), not in the style +specification. + +Write incrementally via `run_python(save=True)` — one call for the +skeleton + `:root` + first slide, then one or two more for the +remaining slides (per the create-style workflow's incremental writing +guidance): + +```python +# Cloud: prepend purpose="Author art-direction.html — skeleton + tokens" +header = """<!DOCTYPE html> +<html lang="ja"> +<head> +<meta charset="UTF-8"> +<title><source-PPTX visual system name> + + + +""" +cover_slide = """
+ +
+""" +write_text("specs/art-direction.html", header + cover_slide) +``` + +Subsequent calls append palette swatches, type ramp, and +component-only demonstration slides — see the create-style workflow +for the standard demonstration-slide set. + +Quality bar before considering this Step done: + +- `:root` declares every color, font, and size the slides reference. + No hardcoded hex / pt anywhere outside `:root`. +- Demonstration slides use `.t-*` text classes and `var(--*)` + references exclusively (verify with a `grep` for `style="font-size` + or `style="color`). +- Demonstration slide *content* is placeholder copy — not the + source deck's content. +- `--color-bg` matches what Lens A and Lens C agree the source + background actually is (dark theme decks have `--color-bg` set + to dark, not white). +- Total demonstration slides: 5-6 (cover counted). +- **No re-build is needed after writing art-direction.html.** Step 4 + already produced the as-is reproduction the user can review. The + file you write here is consumed by the composer the next time the + user asks to edit slides; until then the deck stays at its Step 4 + state. + +--- + +## Step 6 — Present to the user + +Call `get_preview` to surface visuals: + +- Local: `get_preview(slides_json_path=deck_id, pages="")` +- Cloud: `get_preview(deck_id, slugs=[...])` + +Then use a single `hearing` (the only user-facing hearing of this +guide) to wrap up: surface what was auto-generated and let the user +direct the next edits. Suggested `inference`: + +> 「PPTX を取り込んで以下の内容で deck を生成しました: +> - 概要 (brief): +> - 構成 (outline): <スライド数> ページ +> - art-direction: 元 PPTX の theme XML とプレビュー画像から抽出したスタイル +> +> このまま編集に進めて良いですか?他に変えたいところはありますか?」 + +A `free_text` question is appropriate here ("どこを変えたいですか?"). +After the user responds, return control to the normal edit loop +(Cloud: `compose_slides`; Local: `use_subagent` with `sdpm-composer`). + +--- + +## Notes on lossy conversion + +`pptx_to_json` has known limitations: + +- Connectors are rendered as straight lines. +- Arrow-head styles are not preserved. +- Complex gradients may render differently. + +Do NOT proactively warn the user about this — the converter is tracked +for improvement separately. Address specific visual regressions only +if the user reports them after previewing the deck. diff --git a/skill/references/workflows/edit-existing.md b/skill/references/workflows/edit-existing.md deleted file mode 100644 index 3617f0b3..00000000 --- a/skill/references/workflows/edit-existing.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: edit-existing -description: "Workflow B: Edit existing PPTX" -category: workflow ---- - -# Workflow B: Edit Existing PPTX - -Convert an existing PPTX to JSON and edit it. - ---- - -### 0. Review available guides - -Run `guides` to review available guides. Read any that are relevant to the edit. - ---- - -### 1. PPTX → JSON conversion - -```bash -# Use the project directory from `init` for all output -uv run python3 scripts/pptx_to_json.py {input_pptx} -o {project_dir} -# → Generates {project_dir}/slides.json + images/ -# → From here on, output_json = {project_dir}/slides.json -``` - ---- - -### 2. Edit JSON - -Edit `slides.json` directly. Roundtrip JSON is valid input for pptx_builder as-is. - -- Text changes: edit `text`, `items` (bullet array), or `paragraphs` (paragraph array) -- Add/remove/move elements: edit the `elements` array -- Elements inside groups: edit the `elements` array under `type: "group"` (not `children`) -- Table edits: `headers` is a cell array, `rows` is `[[cell, cell, ...], ...]` (each row is a list) -- Add/remove/reorder slides: edit the `slides` array -- Image references: point `src` to files in `{output_dir}/images/` - -**Constraints:** -- You MUST edit slides one at a time — do NOT batch-edit multiple slides simultaneously because it causes context overflow and coordinate errors -- You MUST NOT carry over colors or styles from source slides — always apply the new theme's design guidelines because source styles conflict with the target theme -- For new slides or layout changes, you MUST read: - - `python scripts/pptx_builder.py workflows slide-json-spec` - - `python scripts/pptx_builder.py examples components/all` - - Pattern details (`uv run python3 scripts/pptx_builder.py examples patterns/N`) — only the pages needed - - Icon search (`icon-search`) — only for the pages needed - ---- - -### 3. Generate + review - -```bash -uv run python3 scripts/pptx_builder.py generate {output_json} -o {output_pptx} -uv run python3 scripts/pptx_builder.py measure {output_json} -uv run python3 scripts/pptx_builder.py preview {output_json} -``` - -Review measure output and preview PNGs, fix and regenerate as needed. - -**Constraints:** -- You MUST NOT run generate until all slides are edited/added to the JSON - ---- - -## Translation - -For translating an existing PPTX to another language, use the dedicated workflow. -→ **Read `translate-pptx` before executing** diff --git a/skill/references/workflows/translate-pptx.md b/skill/references/workflows/translate-pptx.md index 6ffc8842..732ddb9b 100644 --- a/skill/references/workflows/translate-pptx.md +++ b/skill/references/workflows/translate-pptx.md @@ -1,147 +1,134 @@ --- name: translate-pptx -description: "Translate existing PPTX (e.g. EN→JA, JA→EN)" +description: "Translate an existing deck into another language as a derived deck" category: workflow --- -# PPTX Translation +# Translate Existing Deck -Translate an existing PPTX into another language. A variant of Workflow B. +Translate a deck to another language by creating a **derived deck**. The +original deck is untouched — translation is written to a sibling directory +(``{deck_dir}-{lang}/``) so the source language remains available. ---- - -### 0. Review available guides +## Prerequisites -Run `guides` to review available guides. Read any that are relevant to the translation. +- A deck in the new format: ``{deck_dir}/deck.json`` + ``slides/*.json`` + + ``specs/`` + ``images/``. If you only have the source PPTX, first + import it via the edit flow (see ``guides/import-pptx.md``). +- ``{deck_dir}/deck.json`` has ``template`` set to the original PPTX so + the derived deck's PPTX build finds the same layouts. --- -### 1. Reverse-convert +## Step 1 — Create the derived deck + extract translatable text ```bash -uv run python3 scripts/pptx_builder.py init {name} -# {project_dir} = the directory created by init (printed in output) -uv run python3 scripts/pptx_to_json.py {input_pptx} -o {project_dir} +uv run python3 scripts/translate_extract.py {deck_dir} --target-lang ja ``` -- Do NOT use `--minimal` — translation requires a full roundtrip -- The reverse-convert writes `"template"` into the JSON, so `generate` uses the original PPTX automatically - ---- +Creates:: -### 2. Extract text + generate dictionary template + {deck_dir}-ja/ + deck.json (copy) + slides/*.json (copy) + specs/ (copy — NOT translated by the script) + attachments/ (copy) + images/ (copy) + translate/translation_map.json (empty-value dictionary template) + translate/texts.tsv (review copy of translatable strings) -```bash -# Extract as TSV for review -uv run python3 scripts/translate_extract.py {project_dir}/slides.json -o {project_dir}/texts.tsv +``output.pptx``, ``preview/``, and ``compose/`` are intentionally not +copied — the derived deck regenerates them from scratch. -# Generate empty dictionary template (recommended — keys with \x0b and other control characters are generated accurately) -uv run python3 scripts/translate_extract.py {project_dir}/slides.json --generate-map -o {project_dir}/translation_map.json +Useful options: -# Exclude short text (technical abbreviations, numbers, etc.) -uv run python3 scripts/translate_extract.py {project_dir}/slides.json --generate-map --skip-short 3 -o {project_dir}/translation_map.json -``` +- ``--skip-short 3`` — exclude text of 3 characters or fewer (VPC, TAG, + BGP, numbers, etc.). +- ``--output-dir `` — pick an explicit path for the derived deck + instead of the default ``{deck_dir}-{target-lang}`` naming. -- `--generate-map` generates an empty dictionary template. Keys match the original text in slides.json exactly, preventing `\x0b` mismatches from manual copy-paste -- `--skip-short N` excludes text of N characters or fewer (VPC, TAG, BGP, etc.) -- `\x0b` (vertical tab) is invisible in TSV but exists in slides.json. Always generate dictionary keys with `--generate-map` or copy directly from slides.json +The script fails if the derived-deck path already exists, so you can +re-run safely without accidentally overwriting in-progress work. --- -### 3. Build translation dictionary + batch apply +## Step 2 — Fill the translation dictionary -Create a translation mapping dictionary in `translation_map.json` based on the extracted text. +Edit ``{deck_dir}-ja/translate/translation_map.json``: -**Dictionary creation procedure:** -1. Fill in the values of the template generated by `--generate-map` (NEVER write keys manually — `\x0b` and special quote mismatches will occur) -2. Leave value as empty string `""` for entries that should not be translated (skipped during apply) -3. For 100+ entries, split into batches of 50-80 -4. Run `--dry-run` after each batch to verify match status, then apply +- Each value is an empty string by default. Replace it with the + translation. Keys must NOT be edited — ``\x0b`` and other control + characters are encoded correctly only when the script generates them. +- An empty string ``""`` means **skip this key** — the apply script + leaves the original text in place. +- Preserve styled-text tags. Tag positions may need to move to match the + translated word boundaries: + ``"{{bold,#00D6C7:Contextual Planning}}{{16pt:- Builds...}}"`` → + ``"{{bold,#00D6C7:コンテキスト対応の計画}}{{16pt:- 設計、コード...}}"``. + +For 100+ entries, fill in batches of 50–80 and ``--dry-run`` after each +batch to catch copy-paste mistakes early. + +--- + +## Step 3 — Apply the translation ```bash -# Dry run to verify key matching (no file changes) -uv run python3 scripts/translate_apply.py {project_dir}/slides.json {project_dir}/translation_map.json --dry-run +# Dry run first — prints the diff without touching files. +uv run python3 scripts/translate_apply.py {deck_dir}-ja --dry-run -# Apply -uv run python3 scripts/translate_apply.py {project_dir}/slides.json {project_dir}/translation_map.json +# Apply when the dry-run output looks correct. +uv run python3 scripts/translate_apply.py {deck_dir}-ja ``` -**Translating styled text:** -- Preserve tag types (bold, italic, color, font, size) as-is -- Adjust tag positions to match the translated text — word boundaries shift across languages -- Example: `"{{bold,#00D6C7:Contextual Planning }}{{16pt:- Builds...}}"` → `"{{bold,#00D6C7:コンテキスト対応の計画 }}{{16pt:- 設計、コード...}}"` -- Dictionary keys must be exact matches of the original. The apply script handles replacement, so the agent adjusts tag positions when building the dictionary - -**Translating partial styles (text gradients, bold, etc.):** -- Bold / color / font changes → expressed as styled text tags (`{{bold:...}}`). Agent adjusts tag positions in the dictionary -- All runs share the same gradient → automatically promoted to `textGradient` (element level). No action needed -- Entire heading paragraph has gradient → `translate_apply.py` auto-syncs `_textGradientRuns[].text`. No action needed -- Only some runs within a paragraph have gradient → agent manually updates `_textGradientRuns[].text` in slides.json to match the translated text - -**Translation target keys:** -- `text`, `paragraphs[].text`, `items[]`, `title`, `subtitle`, `date`, `label`, `notes` -- `elements` inside `group` elements (recursively nested) -- Table `headers` and `rows` - -**Constraints:** -- You MUST NOT translate key names, layout names, or structural values (`layout`, `type`, `shape`, `preset`, `connectorType`, `src`, `masterIndex`, etc.) -- You MUST NOT translate values of `fontFamily`, `fontColor`, `fill`, `color`, `fontSize`, `opacity`, `align`, `verticalAlign` -- You MUST NOT translate copyright notices, legal text, company names, product names, or person names -- You MUST NOT translate URLs -- You MUST preserve `\x0b` (vertical tab) positions — used as line breaks -- You MUST match the original text exactly in the dictionary key (strip() fallback is handled by the script) - -**Skip criteria (do not include in dictionary):** -- Technical abbreviations only (VPC, TAG, BGP, GRE, TGW, etc.) -- Numbers only (36, 114, 700+, etc.) -- Code blocks, JSON, or other structured text -- Text inside images (cannot be translated) -- Use `--skip-short 3` to auto-exclude text of 3 characters or fewer +The apply script rewrites ``{deck_dir}-ja/slides/*.json`` in place. ---- +What the script handles automatically: -### 4. Check for untranslated text +- ``\x0b`` (vertical tab) preservation — JSON round-trips encode it. +- Styled-text tag preservation — the replacement is key-exact, so the + tag syntax you put in the dictionary value comes through verbatim. +- ``_textGradientRuns[].text`` sync when the runs originally spanned the + entire paragraph (single-run case). Partial-paragraph gradients are + left alone — adjust them manually in slides JSON. -Run extract again on the translated slides.json to find remaining text. +What the script does NOT handle (keep in mind): -```bash -uv run python3 scripts/translate_extract.py {project_dir}/slides.json -``` - -Person names, company names, technical abbreviations, and numbers remaining is expected. If other source-language text remains, add it to the dictionary and re-apply. +- ``specs/brief.md`` / ``specs/outline.md`` / ``specs/art-direction.html`` + are copied as-is. If you need translated specs, edit them separately + (LLM-assisted or by hand). +- Text rendered inside images. Swap the image or add speaker notes if + the image has critical translated content. --- -### 5. Generate + measure + preview +## Step 4 — Build the derived deck ```bash -uv run python3 scripts/pptx_builder.py generate {project_dir}/slides.json -o {project_dir}/output.pptx -uv run python3 scripts/pptx_builder.py measure {project_dir}/slides.json -uv run python3 scripts/pptx_builder.py preview {project_dir}/slides.json +uv run python3 scripts/pptx_builder.py generate {deck_dir}-ja -o {deck_dir}-ja/output.pptx +uv run python3 scripts/pptx_builder.py measure {deck_dir}-ja +uv run python3 scripts/pptx_builder.py preview {deck_dir}-ja # Check specific slides -uv run python3 scripts/pptx_builder.py preview {project_dir}/slides.json -p 1,3,5 +uv run python3 scripts/pptx_builder.py preview {deck_dir}-ja -p 1,3,5 ``` -**Layout breakage checks:** -- Japanese text is wider than English, so text may overflow or clip -- Prioritize slides where measure shows significant size discrepancies -- Review measure output and preview PNGs, fix slides.json for any broken slides, then regenerate -- Common fixes: - - Reduce `fontSize` - - Shorten text or increase element `width`/`height` - - Adjust line breaks — Japanese word-wrap breaks at different positions than English. Use `\n` to set explicit break points +Common post-translation fixes (layout breakage is typical when +translating EN → JA because Japanese characters are wider): -**Constraints:** -- You MUST use the original PPTX as template because the default template causes layout name mismatch errors — ensure `"template"` in the JSON points to the original PPTX -- Japanese text is typically wider than English — measure discrepancies are expected. Focus on slides where text significantly exceeds the declared height +- Reduce ``fontSize`` on overflowing elements. +- Widen the containing element (``width`` / ``height``). +- Insert explicit line breaks (``\n``) where auto-wrap produces awkward + splits. +- Re-run measure after each fix; iterate until the overflow warnings + clear. --- -### 6. Final checklist - -- [ ] All slides with significant measure discrepancies reviewed -- [ ] Cover and section slide titles checked for overflow -- [ ] Table cell text checked for overflow -- [ ] Speaker notes added for text inside images (cannot be translated) +## Notes -**Output file naming:** `{original_filename}-JA.pptx` (or target language code) +- Re-run ``translate_extract.py`` from the same source deck with a + different ``--target-lang`` to create additional language variants + without touching the existing ones. +- To translate ``specs/`` as well, do so separately. A reasonable path + is to run the spec files through an LLM with the translation_map + entries as glossary context so terminology stays consistent. diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py index 2f8b6e61..23509366 100644 --- a/skill/sdpm/builder/__init__.py +++ b/skill/sdpm/builder/__init__.py @@ -314,9 +314,12 @@ def add_slide(self, slide_def: dict): if "notes" in slide_def: notes_frame = slide.notes_slide.notes_text_frame - notes_frame.clear() - p = notes_frame.paragraphs[0] - self._apply_styled_text(p, slide_def["notes"]) + if notes_frame is not None: + notes_frame.clear() + p = notes_frame.paragraphs[0] + self._apply_styled_text(p, slide_def["notes"]) + # Else: notesMaster lacks a body placeholder (degenerate source PPTX); + # silently skip notes rather than crash the build. # Remove empty placeholders if not self.keep_empty_placeholders: diff --git a/skill/sdpm/converter/__init__.py b/skill/sdpm/converter/__init__.py index 264bff22..5b40d700 100644 --- a/skill/sdpm/converter/__init__.py +++ b/skill/sdpm/converter/__init__.py @@ -27,3 +27,4 @@ from .table import extract_table_element as extract_table_element from .chart import extract_chart_element as extract_chart_element from .constants import _NS as _NS, EMU_PER_PX as EMU_PER_PX +from .template import extract_placeholder_template as extract_placeholder_template diff --git a/skill/sdpm/converter/pipeline.py b/skill/sdpm/converter/pipeline.py index fe10048e..c1b5cfdb 100644 --- a/skill/sdpm/converter/pipeline.py +++ b/skill/sdpm/converter/pipeline.py @@ -1,6 +1,20 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 -"""PPTX to JSON conversion pipeline.""" +"""PPTX to JSON conversion pipeline. + +Output structure (deck format — stable since pptx-import-edit): + {output_dir}/ + ├── deck.json # {fonts, defaultTextColor} (template set by caller) + ├── slides/ + │ ├── slide-01.json + │ ├── slide-02.json + │ └── ... + └── images/ # extracted slide images + +The in-memory ``result`` dict still contains ``{slides: [...], fonts, defaultTextColor}`` +for callers that prefer the all-in-one structure (e.g. sdpm.diff). Only the on-disk +layout changed — the legacy single ``slides.json`` is no longer written. +""" import argparse import sys from pathlib import Path @@ -12,32 +26,49 @@ from sdpm.utils.io import write_json from sdpm.schema.defaults import sort_element_keys + def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: bool = True, minimal: bool = False): - """Convert PPTX to JSON. Output is a project folder with slides.json + images/.""" + """Convert PPTX to JSON. Output is a deck-structure directory. + + Args: + pptx_path: Input PPTX file. + output_dir: Output directory (auto-derived from pptx_path stem if None). + use_layout_names: Use layout names verbatim (else heuristic detection). + minimal: Strip defaults for clean output. + + Returns: + Dict with {slides, fonts, defaultTextColor} for in-memory consumers. + + On-disk output: + deck.json (fonts + defaultTextColor; template left unset) + slides/slide-{NN}.json (one per slide; NN is 2-digit 1-based) + images/ (unchanged — populated by extract_slide) + """ actual_path = pptx_path prs = Presentation(str(actual_path)) # Set EMU_PER_PX based on actual slide size from .constants import set_emu_per_px set_emu_per_px(int(prs.slide_width)) - + # Create output directory if output_dir is None: output_dir = pptx_path.with_suffix('') output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - - result = { - "slides": [] - } - + + slides_dir = output_dir / "slides" + slides_dir.mkdir(parents=True, exist_ok=True) + + result: dict = {"slides": []} + # Extract fonts from template try: from sdpm.analyzer import extract_fonts result["fonts"] = extract_fonts(actual_path) except Exception: pass - + # Compute builder's default text color (from slide_masters[0]) builder_text_color = None try: @@ -47,49 +78,68 @@ def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: boo except Exception: pass + if builder_text_color: + result["defaultTextColor"] = builder_text_color + for slide_idx, slide in enumerate(prs.slides): # Get slide master index slide_master = slide.slide_layout.slide_master master_idx = list(prs.slide_masters).index(slide_master) - + # Extract theme colors and mapping for this master theme_colors, color_mapping, theme_styles = extract_theme_colors_and_mapping(actual_path, master_idx) - - slide_dict = extract_slide(slide, theme_colors, color_mapping, theme_styles, master_idx, output_dir, slide_idx, pptx_path=actual_path, use_layout_names=use_layout_names, builder_text_color=builder_text_color) + + slide_dict = extract_slide( + slide, theme_colors, color_mapping, theme_styles, master_idx, output_dir, slide_idx, + pptx_path=actual_path, use_layout_names=use_layout_names, builder_text_color=builder_text_color, + ) slide_dict["elements"] = [sort_element_keys(e) for e in slide_dict.get("elements", [])] if minimal: from sdpm.schema.minimal import minimize slide_dict["elements"] = minimize(slide_dict["elements"]) result["slides"].append(slide_dict) - - # Output - json_path = output_dir / "slides.json" - write_json(json_path, result) - - # Cleanup + + # Write deck.json (fonts + defaultTextColor; template is caller's responsibility) + deck_meta: dict = {} + if "fonts" in result: + deck_meta["fonts"] = result["fonts"] + if "defaultTextColor" in result: + deck_meta["defaultTextColor"] = result["defaultTextColor"] + write_json(output_dir / "deck.json", deck_meta) + + # Write slides/slide-{NN}.json (1-based, zero-padded, hyphen separator to match parse_outline_slugs) + for idx, slide_dict in enumerate(result["slides"], start=1): + slug = f"slide-{idx:02d}" + slide_path = slides_dir / f"{slug}.json" + write_json(slide_path, slide_dict) + + # Status output print(f"Converted: {output_dir}/") - print(f" {json_path}") + print(" deck.json") + print(f" slides/ ({len(result['slides'])} files)") images_dir = output_dir / "images" if images_dir.exists(): count = len(list(images_dir.iterdir())) - print(f" {images_dir}/ ({count} files)") - + print(f" images/ ({count} files)") + return result + def main(): - parser = argparse.ArgumentParser(description="Convert PPTX to JSON") + parser = argparse.ArgumentParser(description="Convert PPTX to JSON (deck structure)") parser.add_argument("input", help="Input PPTX file") parser.add_argument("-o", "--output", help="Output directory (default: input filename without extension)") parser.add_argument("--minimal", action="store_true", help="Strip defaults, internal keys, and font tags for clean output") args = parser.parse_args() - + input_path = Path(args.input) if not input_path.exists(): print(f"Error: File not found: {input_path}", file=sys.stderr) sys.exit(1) - + output_dir = Path(args.output) if args.output else None pptx_to_json(input_path, output_dir, minimal=args.minimal) + if __name__ == "__main__": main() diff --git a/skill/sdpm/converter/template.py b/skill/sdpm/converter/template.py new file mode 100644 index 00000000..54aa3958 --- /dev/null +++ b/skill/sdpm/converter/template.py @@ -0,0 +1,89 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Extract a placeholder-only template PPTX from an arbitrary input PPTX. + +The output PPTX preserves every slide_master, slide_layout, and theme +from the source — nothing is removed from those parts. The original +slides are dropped and replaced with one placeholder-only sample slide +per layout that the source PPTX actually exercised. That way the +template stays browsable in PowerPoint (one preview per layout used), +remains a drop-in replacement for any layout the source used, *and* +keeps every other layout the deck author may want to reach for later. +""" +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation + + +_REL_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" + + +def extract_placeholder_template( + pptx_path: str | Path, + output_path: str | Path, +) -> dict: + """Build a placeholder-only template from a PPTX. + + Args: + pptx_path: Source PPTX file. + output_path: Destination path for the placeholder template. + + Returns: + Metadata dict: {input_size, output_size, layout_count, + master_count, used_layout_count}. + + Behavior: + - All slide_masters are retained. + - All slide_layouts are retained (including layouts the source + PPTX never used). + - All slides from the source are dropped, then one + placeholder-only sample slide is emitted for each layout that + at least one source slide used. Layouts the source never + touched do not get sample slides. + """ + src = Path(pptx_path) + dst = Path(output_path) + dst.parent.mkdir(parents=True, exist_ok=True) + + prs = Presentation(str(src)) + + # Step 1: figure out which layout parts are referenced by source slides. + used_layout_partnames: set[str] = set() + for slide in prs.slides: + used_layout_partnames.add(str(slide.slide_layout.part.partname)) + + # Step 2: drop every original slide (rel + sldIdLst entry). + sldIdLst = prs.slides._sldIdLst + for entry in list(sldIdLst): + rId = entry.attrib.get(f"{_REL_NS}id") + if rId: + try: + prs.part.drop_rel(rId) + except KeyError: + pass + sldIdLst.remove(entry) + + # Step 3: emit one placeholder-only sample slide per *used* layout. + # Iterate masters/layouts in their original order so the output + # presentation order is stable. + used_count = 0 + for master in prs.slide_masters: + for layout in master.slide_layouts: + if str(layout.part.partname) in used_layout_partnames: + prs.slides.add_slide(layout) + used_count += 1 + + master_count = len(prs.slide_masters) + layout_count = sum(len(m.slide_layouts) for m in prs.slide_masters) + + prs.save(str(dst)) + + return { + "input_size": src.stat().st_size, + "output_size": dst.stat().st_size, + "layout_count": layout_count, + "master_count": master_count, + "used_layout_count": used_count, + } diff --git a/skill/sdpm/diff/__init__.py b/skill/sdpm/diff/__init__.py index 1e0d91d2..75867fd4 100644 --- a/skill/sdpm/diff/__init__.py +++ b/skill/sdpm/diff/__init__.py @@ -134,6 +134,27 @@ def align_slides(base_slides, edit_slides, threshold=0.1): return result +def _load_deck_as_roundtrip(deck_dir: Path) -> dict: + """Load deck-structure output (deck.json + slides/*.json) as a single roundtrip dict. + + Restores the legacy {slides: [...], fonts, defaultTextColor} shape that the diff + algorithms expect. Slides are ordered by filename (slide-01, slide-02, ...). + """ + deck_json = deck_dir / "deck.json" + slides_dir = deck_dir / "slides" + data: dict = {} + if deck_json.exists(): + with open(deck_json) as f: + data = json.load(f) + slides: list = [] + if slides_dir.is_dir(): + for slide_file in sorted(slides_dir.glob("slide-*.json")): + with open(slide_file) as f: + slides.append(json.load(f)) + data["slides"] = slides + return data + + def load_slides_json_or_pptx(path): """Load roundtrip slides JSON from .json or .pptx.""" if path.endswith('.pptx'): @@ -142,8 +163,8 @@ def load_slides_json_or_pptx(path): [sys.executable, str(Path(__file__).resolve().parent.parent.parent / 'scripts' / 'pptx_to_json.py'), path, '-o', tmpdir], capture_output=True, text=True, check=True ) - with open(Path(tmpdir) / 'slides.json') as f: - return json.load(f) + # New deck-structure output: deck.json + slides/slide-NN.json + return _load_deck_as_roundtrip(Path(tmpdir)) with open(path) as f: data = json.load(f) # Check if this is a source JSON (not already a roundtrip JSON) by looking @@ -164,14 +185,14 @@ def load_slides_json_or_pptx(path): if is_source: with tempfile.TemporaryDirectory() as tmpdir: tmp_pptx = Path(tmpdir) / "tmp.pptx" - from sdpm.api import _find_template_in_dirs, get_templates_dirs + from sdpm.api import _find_template_in_dirs, _get_templates_dirs from sdpm.builder import PPTXBuilder, resolve_override tpl_name = data.get("template") if not tpl_name: raise ValueError("No \"template\" specified in JSON. Cannot build for diff.") template = Path(path).parent / tpl_name if not template.exists(): - named = _find_template_in_dirs(tpl_name, get_templates_dirs()) + named = _find_template_in_dirs(tpl_name, _get_templates_dirs()) if named is not None: template = named else: @@ -186,6 +207,5 @@ def load_slides_json_or_pptx(path): [sys.executable, str(Path(__file__).resolve().parent.parent.parent / 'scripts' / 'pptx_to_json.py'), str(tmp_pptx), '-o', tmpdir], capture_output=True, text=True, check=True ) - with open(Path(tmpdir) / 'slides.json') as f: - return json.load(f) + return _load_deck_as_roundtrip(Path(tmpdir)) return data diff --git a/tests/test_pptx_import.py b/tests/test_pptx_import.py new file mode 100644 index 00000000..9593127e --- /dev/null +++ b/tests/test_pptx_import.py @@ -0,0 +1,608 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Tests for PPTX import → deck structure conversion + upload integration. + +Covers T8 from .kiro/specs/pptx-import-edit/tasks.md: +- pptx_to_json output: deck.json + slides/slide-NN.json +- shared/ingest._convert_pptx: ConversionResult with deck_structure / theme_hints +- mcp-local/upload_tools: guide/guideInstruction in response +- mcp-local/upload_tools.read_uploaded_file: deck text summary +- mcp-local/upload_tools._import_from_upload: slides/ recursive copy + shortId +- pptx_builder.py CLI: accepts deck directory +- Non-regression: PDF/DOCX/XLSX import unchanged +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_FIXTURE_PPTX = _REPO_ROOT / "skill" / "references" / "examples" / "components.pptx" + + +@pytest.fixture +def fixture_pptx() -> Path: + """Return path to the bundled components.pptx fixture.""" + if not _FIXTURE_PPTX.exists(): + pytest.skip(f"Fixture PPTX not found: {_FIXTURE_PPTX}") + return _FIXTURE_PPTX + + +# --------------------------------------------------------------------------- +# T1: pptx_to_json output structure +# --------------------------------------------------------------------------- + + +class TestPptxToJsonDeckStructure: + """pptx_to_json must output deck.json + slides/slide-NN.json.""" + + def test_pptx_to_json_outputs_deck_structure(self, fixture_pptx: Path, tmp_path: Path) -> None: + """Output directory contains deck.json and slides/slide-NN.json — no single slides.json.""" + from sdpm.converter import pptx_to_json + + out_dir = tmp_path / "out" + pptx_to_json(fixture_pptx, output_dir=out_dir) + + assert (out_dir / "deck.json").exists(), "deck.json should be created" + slides_dir = out_dir / "slides" + assert slides_dir.is_dir(), "slides/ directory should be created" + slide_files = list(slides_dir.glob("slide-*.json")) + assert len(slide_files) > 0, "at least one slide-NN.json should exist" + # Legacy single slides.json must NOT be written + assert not (out_dir / "slides.json").exists(), "legacy slides.json must not be emitted" + + def test_pptx_to_json_slug_format(self, fixture_pptx: Path, tmp_path: Path) -> None: + """Slug format is slide-NN (hyphen + 2-digit zero-padded) and matches parse_outline_slugs regex.""" + from sdpm.api import parse_outline_slugs + from sdpm.converter import pptx_to_json + + out_dir = tmp_path / "out" + pptx_to_json(fixture_pptx, output_dir=out_dir) + + slide_files = sorted((out_dir / "slides").glob("*.json")) + slug_re = re.compile(r"^slide-\d{2}$") + for f in slide_files: + assert slug_re.match(f.stem), f"slug must match slide-NN format: {f.stem}" + + # Sanity: construct fake outline.md with these slugs and verify parse_outline_slugs accepts them + fake_outline = "\n".join(f"- [{f.stem}] msg" for f in slide_files) + outline_path = tmp_path / "outline.md" + outline_path.write_text(fake_outline, encoding="utf-8") + parsed = parse_outline_slugs(outline_path) + assert parsed == [f.stem for f in slide_files] + + def test_deck_json_has_fonts_and_default_text_color(self, fixture_pptx: Path, tmp_path: Path) -> None: + """deck.json contains fonts dict and defaultTextColor (both PPTX-derived).""" + from sdpm.converter import pptx_to_json + + out_dir = tmp_path / "out" + pptx_to_json(fixture_pptx, output_dir=out_dir) + + deck = json.loads((out_dir / "deck.json").read_text(encoding="utf-8")) + assert "fonts" in deck, "deck.json should contain fonts" + # defaultTextColor may be None if extraction failed, but the key should exist + assert "defaultTextColor" in deck, "deck.json should contain defaultTextColor key" + + +# --------------------------------------------------------------------------- +# T2: shared/ingest._convert_pptx + _extract_theme_hints +# --------------------------------------------------------------------------- + + +class TestConvertPptxThemeHints: + """_convert_pptx must populate deck_structure, slide_count, theme_hints, suggested_name.""" + + def test_convert_pptx_populates_new_fields(self, fixture_pptx: Path, tmp_path: Path) -> None: + from shared.ingest import convert_file + + out_dir = tmp_path / "out" + result = convert_file(fixture_pptx, out_dir) + + assert result.status == "success" + assert result.deck_structure is True, "deck_structure should be True for PPTX" + assert result.slide_count > 0, "slide_count should be positive" + assert result.suggested_name == fixture_pptx.stem + assert result.theme_hints is not None + + def test_convert_pptx_theme_hints_keys(self, fixture_pptx: Path, tmp_path: Path) -> None: + """theme_hints uses unified keys: backgroundLuminance / accentColors / fonts.""" + from shared.ingest import convert_file + + out_dir = tmp_path / "out" + result = convert_file(fixture_pptx, out_dir) + + assert "backgroundLuminance" in result.theme_hints + assert "accentColors" in result.theme_hints + assert "fonts" in result.theme_hints + # Luminance: 0.0-1.0 + lum = result.theme_hints["backgroundLuminance"] + assert isinstance(lum, (int, float)) + assert 0.0 <= lum <= 1.0 + # Accent colors: list of #RRGGBB, max 3 + assert isinstance(result.theme_hints["accentColors"], list) + assert len(result.theme_hints["accentColors"]) <= 3 + for c in result.theme_hints["accentColors"]: + assert re.match(r"^#[0-9A-Fa-f]{6}$", c), f"invalid hex: {c}" + # Fonts: dict with halfwidth/fullwidth + fonts = result.theme_hints["fonts"] + assert isinstance(fonts, dict) + + def test_convert_pptx_theme_hints_uses_slide_bg(self, tmp_path: Path) -> None: + """When slide has explicit background, luminance reflects it (median across slides). + + Synthetic fixture: modify slide XML to force explicit backgrounds. + If the fixture doesn't have explicit bg, we fall back to template-derived luminance + — both paths must yield a valid number. + """ + from shared.ingest import convert_file + + out_dir = tmp_path / "out" + result = convert_file(_FIXTURE_PPTX, out_dir) + # The fixture may or may not have explicit slide bg; we just verify the median is valid. + # (Full-fidelity synthetic testing of slide-bg override is deferred; the pipeline + # correctness is validated via test_convert_pptx_theme_hints_keys.) + assert 0.0 <= result.theme_hints["backgroundLuminance"] <= 1.0 + + +# --------------------------------------------------------------------------- +# T3: Local upload_file returns guide/guideInstruction for PPTX +# --------------------------------------------------------------------------- + + +class TestUploadFileGuideInstruction: + """Local upload_file must return guide, guideInstruction, suggestedName, slideCount, themeHints.""" + + def _upload_pptx(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + """Helper: upload the fixture PPTX via mcp-local/upload_tools.upload_file.""" + # Route SDPM_DECK_ROOT so session storage lives under tmp_path + monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root")) + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import upload_file + + # Copy fixture to tmp_path so the temp upload path is stable + src = tmp_path / "input.pptx" + shutil.copy2(_FIXTURE_PPTX, src) + + raw = upload_file("test-session", str(src), "input.pptx") + return json.loads(raw) + + def test_upload_file_returns_guide_for_pptx( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + resp = self._upload_pptx(tmp_path, monkeypatch) + assert resp.get("status") == "converted" + assert resp.get("guide") == "import-pptx" + assert "guideInstruction" in resp + assert resp.get("suggestedName") == "input" + assert isinstance(resp.get("slideCount"), int) + assert resp.get("slideCount") > 0 + theme = resp.get("themeHints") + assert theme is not None + assert "backgroundLuminance" in theme + assert "accentColors" in theme + assert "fonts" in theme + + def test_upload_file_does_not_include_head_text_or_slide_titles( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Per spec: headText / slideTitles must NOT be in the response.""" + resp = self._upload_pptx(tmp_path, monkeypatch) + assert "headText" not in resp + assert "slideTitles" not in resp + + def test_guide_instruction_contains_intent_branching( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """guideInstruction must mention edit / reference / hearing branching.""" + resp = self._upload_pptx(tmp_path, monkeypatch) + instruction = resp["guideInstruction"].lower() + assert "edit" in instruction + assert "reference" in instruction + assert "hearing" in instruction + + +# --------------------------------------------------------------------------- +# T3b: Local read_uploaded_file returns deck text summary +# --------------------------------------------------------------------------- + + +class TestReadUploadedFileTextSummary: + def test_read_uploaded_file_returns_text_summary( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root")) + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import read_uploaded_file, upload_file + + src = tmp_path / "input.pptx" + shutil.copy2(_FIXTURE_PPTX, src) + raw = upload_file("test-session", str(src), "input.pptx") + upload_id = json.loads(raw)["uploadId"] + + result = read_uploaded_file(upload_id) + # Must contain a markdown-style summary with "--- Slide N" section markers + text_parts = [r for r in result if isinstance(r, str)] + joined = "\n".join(text_parts) + assert "--- Slide 1" in joined or "Slide 1" in joined, \ + f"Expected slide section markers, got: {joined[:500]}" + + +# --------------------------------------------------------------------------- +# T3c: import_attachment recursive slides/ copy + shortId in result +# --------------------------------------------------------------------------- + + +class TestImportAttachmentSlidesDir: + def test_import_attachment_copies_slides_dir_and_returns_short_id( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root")) + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import import_attachment, upload_file + + src = tmp_path / "input.pptx" + shutil.copy2(_FIXTURE_PPTX, src) + raw = upload_file("test-session", str(src), "input.pptx") + upload_id = json.loads(raw)["uploadId"] + + # Create a deck dir + deck_dir = tmp_path / "deck" + deck_dir.mkdir() + + result_raw = import_attachment(source=upload_id, deck_id=str(deck_dir)) + result = json.loads(result_raw) + + assert "shortId" in result, "import_attachment must return shortId" + short_id = result["shortId"] + assert re.match(r"^[0-9a-f]{8}$", short_id) + + # attachments/{shortId}/slides/slide-NN.json must exist + slides_dir = deck_dir / "attachments" / short_id / "slides" + assert slides_dir.is_dir(), f"expected {slides_dir} to exist" + slide_files = list(slides_dir.glob("slide-*.json")) + assert len(slide_files) > 0 + + # deckJson path should be recorded in the result + assert "deckJson" in result + + def test_import_attachment_copies_template_pptx_to_deck_root( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Placeholder template lives at deck/template.pptx (deck-local path).""" + monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "deck_root")) + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import import_attachment, upload_file + + src = tmp_path / "input.pptx" + shutil.copy2(_FIXTURE_PPTX, src) + raw = upload_file("test-session", str(src), "input.pptx") + upload_id = json.loads(raw)["uploadId"] + + deck_dir = tmp_path / "deck" + deck_dir.mkdir() + + result_raw = import_attachment(source=upload_id, deck_id=str(deck_dir)) + result = json.loads(result_raw) + + assert result.get("templatePath") == "template.pptx" + assert (deck_dir / "template.pptx").is_file(), \ + "template.pptx must land at deck/template.pptx" + # And NOT under attachments/ + attachments_dir = deck_dir / "attachments" + if attachments_dir.exists(): + stray = list(attachments_dir.glob("*template.pptx")) + assert not stray, f"template.pptx must not be placed under attachments/: {stray}" + + +# --------------------------------------------------------------------------- +# T18 (Cloud): _import_converted copies template.pptx to deck/template.pptx +# --------------------------------------------------------------------------- + + +class TestCloudImportConvertedCopiesTemplate: + """mcp-server/tools/attachment._import_converted handles template.pptx specially.""" + + def test_import_converted_copies_template_to_deck_root(self) -> None: + import types + from unittest.mock import MagicMock + + sys.path.insert(0, str(_REPO_ROOT / "mcp-server")) + # mcp-server has its own dependency set; stub the modules that the + # attachment module imports at top level so we can import it under + # the local-test venv. + sys.modules.setdefault("requests", types.ModuleType("requests")) + if "storage" not in sys.modules: + stg_mod = types.ModuleType("storage") + stg_mod.Storage = object # type stub + sys.modules["storage"] = stg_mod + from tools.attachment import _import_converted + + converted_prefix = "uploads/u1/up1/converted" + deck_id = "deck1" + short_id = "abcd1234" + # Storage stub: list_files returns a few converted files including template.pptx + storage = MagicMock() + storage.pptx_bucket = "test-bucket" + files_in_prefix = [ + f"{converted_prefix}/deck.json", + f"{converted_prefix}/template.pptx", + f"{converted_prefix}/slides/slide-01.json", + ] + storage.list_files.return_value = files_in_prefix + storage.download_file_from_pptx_bucket.return_value = b"PPTX_BYTES" + + result = json.loads(_import_converted( + converted_prefix, deck_id, "u1", storage, "input.pptx", "", short_id, + {"source": "up1", "files": [], "image_mapping": {}, "shortId": short_id}, + )) + + # templatePath in result, files list contains "template.pptx" + assert result.get("templatePath") == "template.pptx" + assert "template.pptx" in result.get("files", []) + + # storage.upload_file was called with deck-local key for the template + upload_keys = [c.kwargs["key"] for c in storage.upload_file.call_args_list] + assert f"decks/{deck_id}/template.pptx" in upload_keys, \ + f"expected deck-local template key in upload_keys, got: {upload_keys}" + # And NOT under attachments/ + for k in upload_keys: + if k.endswith("template.pptx"): + assert "attachments" not in k, f"template ended up under attachments: {k}" + + +# --------------------------------------------------------------------------- +# T1 regression: pptx_builder.py CLI accepts deck directory +# --------------------------------------------------------------------------- + + +class TestPptxBuilderCliAcceptsDeckDir: + def test_pptx_builder_cli_generate_on_deck_dir(self, fixture_pptx: Path, tmp_path: Path) -> None: + """Convert PPTX → deck structure → pptx_builder.py generate should work on the directory.""" + from sdpm.converter import pptx_to_json + + deck_dir = tmp_path / "deck" + pptx_to_json(fixture_pptx, output_dir=deck_dir) + + # pptx_to_json writes deck.json (with template=None) — supply template for generate + deck_json_path = deck_dir / "deck.json" + deck_data = json.loads(deck_json_path.read_text(encoding="utf-8")) + deck_data["template"] = str(_REPO_ROOT / "skill" / "templates" / "blank-dark.pptx") + deck_json_path.write_text(json.dumps(deck_data, ensure_ascii=False, indent=2), encoding="utf-8") + + # Build an outline covering all slides + specs_dir = deck_dir / "specs" + specs_dir.mkdir(exist_ok=True) + slide_files = sorted((deck_dir / "slides").glob("*.json")) + outline_lines = [f"- [{f.stem}] Slide {f.stem}" for f in slide_files] + (specs_dir / "outline.md").write_text("\n".join(outline_lines), encoding="utf-8") + + # CLI: pptx_builder.py generate {deck_dir} -o {output} + output_pptx = tmp_path / "out.pptx" + cli = _REPO_ROOT / "skill" / "scripts" / "pptx_builder.py" + proc = subprocess.run( + [sys.executable, str(cli), "generate", str(deck_dir), "-o", str(output_pptx)], + capture_output=True, text=True, timeout=120, + ) + assert proc.returncode == 0, f"pptx_builder.py generate failed:\nSTDOUT: {proc.stdout}\nSTDERR: {proc.stderr}" + assert output_pptx.exists() + + +# --------------------------------------------------------------------------- +# Non-regression: PDF/DOCX/XLSX conversion still works (old flow) +# --------------------------------------------------------------------------- + + +class TestDeckRootEnvHandling: + """Protect against Path('') being truthy — a silent fallback bug. + + Prior to pptx-import-edit hardening, `Path(os.environ.get("SDPM_DECK_ROOT", ""))` + evaluated to `Path('.')` when the env var was unset, silently writing + sessions to the process cwd instead of the user's home directory. + """ + + def test_deck_root_falls_back_to_home_when_env_empty( + self, monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("SDPM_DECK_ROOT", raising=False) + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import _deck_root + + root = _deck_root() + assert root == Path.home() / "Documents" / "SDPM-Presentations", \ + f"expected home fallback, got {root}" + + def test_deck_root_respects_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("SDPM_DECK_ROOT", str(tmp_path / "custom")) + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import _deck_root + + assert _deck_root() == tmp_path / "custom" + + def test_deck_root_ignores_whitespace_only_env( + self, monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("SDPM_DECK_ROOT", " ") + sys.path.insert(0, str(_REPO_ROOT / "mcp-local")) + from upload_tools import _deck_root + + assert _deck_root() == Path.home() / "Documents" / "SDPM-Presentations" + + +# --------------------------------------------------------------------------- +# T16: extract_placeholder_template (FR-9) +# --------------------------------------------------------------------------- + + +class TestExtractPlaceholderTemplate: + """extract_placeholder_template keeps every master/layout, replaces slides + with one placeholder-only sample per *used* layout.""" + + def test_drops_source_content_emits_one_slide_per_used_layout( + self, fixture_pptx: Path, tmp_path: Path + ) -> None: + from pptx import Presentation + from sdpm.converter.template import extract_placeholder_template + + src_prs = Presentation(str(fixture_pptx)) + assert len(src_prs.slides) > 0 + used_layout_names = {s.slide_layout.name for s in src_prs.slides} + + out = tmp_path / "template.pptx" + meta = extract_placeholder_template(fixture_pptx, out) + assert out.exists() + + out_prs = Presentation(str(out)) + # Slide count equals the number of distinct layouts the source used. + assert len(out_prs.slides) == len(used_layout_names) + assert meta["used_layout_count"] == len(used_layout_names) + # Every emitted slide's layout is one of the used ones. + for s in out_prs.slides: + assert s.slide_layout.name in used_layout_names + + def test_preserves_all_layouts( + self, fixture_pptx: Path, tmp_path: Path + ) -> None: + """All slide_layouts are preserved (including layouts the source never used).""" + from pptx import Presentation + from sdpm.converter.template import extract_placeholder_template + + src_prs = Presentation(str(fixture_pptx)) + src_layout_names = [] + for master in src_prs.slide_masters: + src_layout_names.append(sorted(layout.name for layout in master.slide_layouts)) + + out = tmp_path / "template.pptx" + extract_placeholder_template(fixture_pptx, out) + + dst_prs = Presentation(str(out)) + dst_layout_names = [] + for master in dst_prs.slide_masters: + dst_layout_names.append(sorted(layout.name for layout in master.slide_layouts)) + + assert dst_layout_names == src_layout_names + + def test_preserves_all_masters( + self, fixture_pptx: Path, tmp_path: Path + ) -> None: + """slide_master count is preserved.""" + from pptx import Presentation + from sdpm.converter.template import extract_placeholder_template + + src_master_count = len(Presentation(str(fixture_pptx)).slide_masters) + + out = tmp_path / "template.pptx" + extract_placeholder_template(fixture_pptx, out) + + dst_master_count = len(Presentation(str(out)).slide_masters) + assert dst_master_count == src_master_count + + def test_output_smaller_than_input( + self, fixture_pptx: Path, tmp_path: Path + ) -> None: + """Dropping slide content shrinks the file.""" + from sdpm.converter.template import extract_placeholder_template + + out = tmp_path / "template.pptx" + meta = extract_placeholder_template(fixture_pptx, out) + assert meta["output_size"] < meta["input_size"] + + +# --------------------------------------------------------------------------- +# T17: shared/ingest._convert_pptx integrates placeholder template extraction +# --------------------------------------------------------------------------- + + +class TestConvertPptxOutputsTemplate: + """_convert_pptx writes deck.json + slides/ + template.pptx and reports template_path.""" + + def test_convert_pptx_outputs_template( + self, fixture_pptx: Path, tmp_path: Path + ) -> None: + from shared.ingest import convert_file + + out_dir = tmp_path / "out" + result = convert_file(fixture_pptx, out_dir) + + assert result.status == "success" + assert result.template_path == "template.pptx" + assert (out_dir / "template.pptx").is_file() + + def test_pptx_builder_with_derived_template( + self, fixture_pptx: Path, tmp_path: Path + ) -> None: + """E2E: convert PPTX → use derived template.pptx → pptx_builder generate succeeds.""" + from shared.ingest import convert_file + + deck_dir = tmp_path / "deck" + result = convert_file(fixture_pptx, deck_dir) + assert result.status == "success" + assert result.template_path == "template.pptx" + + # Wire deck.json to point at the derived template + populate outline + deck_json_path = deck_dir / "deck.json" + deck_data = json.loads(deck_json_path.read_text(encoding="utf-8")) + deck_data["template"] = str(deck_dir / "template.pptx") + deck_json_path.write_text( + json.dumps(deck_data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + specs_dir = deck_dir / "specs" + specs_dir.mkdir(exist_ok=True) + slide_files = sorted((deck_dir / "slides").glob("*.json")) + outline_lines = [f"- [{f.stem}] Slide {f.stem}" for f in slide_files] + (specs_dir / "outline.md").write_text( + "\n".join(outline_lines), encoding="utf-8" + ) + + output_pptx = tmp_path / "out.pptx" + cli = _REPO_ROOT / "skill" / "scripts" / "pptx_builder.py" + proc = subprocess.run( + [sys.executable, str(cli), "generate", str(deck_dir), "-o", str(output_pptx)], + capture_output=True, text=True, timeout=180, + ) + assert proc.returncode == 0, ( + f"pptx_builder.py generate failed:\nSTDOUT: {proc.stdout}\nSTDERR: {proc.stderr}" + ) + assert output_pptx.exists() + + +# --------------------------------------------------------------------------- +# Non-regression: PDF/DOCX/XLSX conversion still works (old flow) +# --------------------------------------------------------------------------- + + +class TestNonRegression: + """PDF/DOCX/XLSX conversion paths must not be affected by the PPTX deck-structure change.""" + + def test_convert_docx_still_produces_markdown(self, tmp_path: Path) -> None: + """A simple DOCX is converted to Markdown (deck_structure stays False).""" + try: + from docx import Document # noqa: F401 + except ImportError: + pytest.skip("python-docx not available") + + from docx import Document + from shared.ingest import convert_file + + src = tmp_path / "simple.docx" + doc = Document() + doc.add_heading("Test", level=1) + doc.add_paragraph("Hello world") + doc.save(str(src)) + + out_dir = tmp_path / "out" + result = convert_file(src, out_dir) + assert result.status in ("success", "partial") + # DOCX must not trigger deck_structure + assert result.deck_structure is False + md_path = out_dir / "simple.md" + assert md_path.exists() diff --git a/tests/test_translate.py b/tests/test_translate.py new file mode 100644 index 00000000..c5b20a3a --- /dev/null +++ b/tests/test_translate.py @@ -0,0 +1,315 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Tests for translate_extract.py / translate_apply.py (deck-structure aware). + +Covers T8 translate portion from .kiro/specs/pptx-import-edit/tasks.md. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_EXTRACT_CLI = _REPO_ROOT / "scripts" / "translate_extract.py" +_APPLY_CLI = _REPO_ROOT / "scripts" / "translate_apply.py" + + +def _make_minimal_deck(deck_dir: Path) -> None: + """Create a minimal but realistic deck directory for translation tests.""" + deck_dir.mkdir(parents=True, exist_ok=True) + (deck_dir / "deck.json").write_text( + json.dumps({"template": "blank-dark.pptx", "defaultTextColor": "#FFFFFF"}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + slides_dir = deck_dir / "slides" + slides_dir.mkdir() + + # slide-01: title + paragraphs + items + table + slide01 = { + "layout": "title", + "title": "Hello World", + "subtitle": "Welcome", + "elements": [ + { + "type": "textbox", + "text": "Vertical\x0btab preserved", # \x0b must roundtrip + "fontSize": 24, + "fontColor": "#FFFFFF", # structural: must NOT be in dict + }, + { + "type": "textbox", + "paragraphs": [ + {"text": "First paragraph"}, + {"text": "Second paragraph"}, + ], + }, + { + "type": "textbox", + "items": ["Item A", "Item B", "Ab"], # "Ab" short-text candidate + }, + { + "type": "table", + "headers": ["Name", "Value"], + "rows": [["Alpha", "1"], ["Beta", "2"]], + }, + { + "type": "group", + "elements": [ + {"type": "textbox", "text": "Inside group"}, + ], + }, + { + "type": "textbox", + "text": "{{bold:Styled}} text here", + }, + { + "type": "image", + "src": "images/foo.png", # structural: must NOT be translated + }, + { + "type": "textbox", + "text": "https://example.com", # URL: must NOT be translated + }, + ], + "notes": "speaker note text", + } + (slides_dir / "slide-01.json").write_text( + json.dumps(slide01, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + # specs (copied but not translated) + specs_dir = deck_dir / "specs" + specs_dir.mkdir() + (specs_dir / "brief.md").write_text("# Brief\n\nOriginal English content.", encoding="utf-8") + (specs_dir / "outline.md").write_text("- [slide-01] Hello World\n", encoding="utf-8") + + # attachments (copied) + attachments_dir = deck_dir / "attachments" + attachments_dir.mkdir() + (attachments_dir / "dummy.txt").write_text("attachment content", encoding="utf-8") + + # images (copied) + images_dir = deck_dir / "images" + images_dir.mkdir() + (images_dir / "foo.png").write_bytes(b"fake-png") + + # files that should NOT be copied + (deck_dir / "output.pptx").write_bytes(b"fake-pptx") + preview_dir = deck_dir / "preview" + preview_dir.mkdir() + (preview_dir / "page1.png").write_bytes(b"fake") + compose_dir = deck_dir / "compose" + compose_dir.mkdir() + (compose_dir / "defs_1.json").write_text("{}", encoding="utf-8") + + +def _run_extract(*args: str) -> subprocess.CompletedProcess: + if not _EXTRACT_CLI.exists(): + pytest.skip("translate_extract.py not yet implemented") + return subprocess.run( + [sys.executable, str(_EXTRACT_CLI), *args], + capture_output=True, text=True, timeout=60, + ) + + +def _run_apply(*args: str) -> subprocess.CompletedProcess: + if not _APPLY_CLI.exists(): + pytest.skip("translate_apply.py not yet implemented") + return subprocess.run( + [sys.executable, str(_APPLY_CLI), *args], + capture_output=True, text=True, timeout=60, + ) + + +# --------------------------------------------------------------------------- +# translate_extract.py — derived-deck creation +# --------------------------------------------------------------------------- + + +class TestTranslateExtract: + def test_creates_derived_deck(self, tmp_path: Path) -> None: + deck = tmp_path / "mydeck" + _make_minimal_deck(deck) + proc = _run_extract(str(deck), "--target-lang", "ja") + assert proc.returncode == 0, f"failed: {proc.stderr}" + + derived = tmp_path / "mydeck-ja" + assert derived.is_dir() + assert (derived / "deck.json").exists() + assert (derived / "slides" / "slide-01.json").exists() + assert (derived / "specs" / "brief.md").exists() + assert (derived / "attachments" / "dummy.txt").exists() + assert (derived / "images" / "foo.png").exists() + # Not copied: + assert not (derived / "output.pptx").exists() + assert not (derived / "preview").exists() + assert not (derived / "compose").exists() + # translate/ sub-dir created + assert (derived / "translate" / "translation_map.json").exists() + assert (derived / "translate" / "texts.tsv").exists() + + def test_generates_empty_map_with_all_translatable_texts(self, tmp_path: Path) -> None: + deck = tmp_path / "mydeck" + _make_minimal_deck(deck) + proc = _run_extract(str(deck), "--target-lang", "ja") + assert proc.returncode == 0 + + map_path = tmp_path / "mydeck-ja" / "translate" / "translation_map.json" + dictionary = json.loads(map_path.read_text(encoding="utf-8")) + + # All values must be empty strings (template) + assert all(v == "" for v in dictionary.values()) + + # Translatable keys must be present + translatable = [ + "Hello World", + "Welcome", + "Vertical\x0btab preserved", + "First paragraph", + "Second paragraph", + "Item A", + "Item B", + "Name", + "Value", + "Alpha", + "Beta", + "1", + "2", + "Inside group", + "{{bold:Styled}} text here", + "speaker note text", + ] + for key in translatable: + assert key in dictionary, f"Missing translatable key: {key!r}" + + # Non-translatable keys must be absent + assert "images/foo.png" not in dictionary # src + assert "https://example.com" not in dictionary # URL + assert "#FFFFFF" not in dictionary # fontColor / defaultTextColor + + def test_skip_short_option(self, tmp_path: Path) -> None: + deck = tmp_path / "mydeck" + _make_minimal_deck(deck) + proc = _run_extract(str(deck), "--target-lang", "ja", "--skip-short", "2") + assert proc.returncode == 0 + + map_path = tmp_path / "mydeck-ja" / "translate" / "translation_map.json" + dictionary = json.loads(map_path.read_text(encoding="utf-8")) + + # "1" and "2" (length 1) should be excluded + assert "1" not in dictionary + assert "2" not in dictionary + # "Ab" (length 2) should be excluded + assert "Ab" not in dictionary + # Longer text should remain + assert "Hello World" in dictionary + + def test_existing_target_errors(self, tmp_path: Path) -> None: + deck = tmp_path / "mydeck" + _make_minimal_deck(deck) + (tmp_path / "mydeck-ja").mkdir() # already exists + proc = _run_extract(str(deck), "--target-lang", "ja") + assert proc.returncode != 0, "Should fail when derived deck already exists" + + +# --------------------------------------------------------------------------- +# translate_apply.py — apply translations in-place on derived deck +# --------------------------------------------------------------------------- + + +class TestTranslateApply: + def _prepare_derived(self, tmp_path: Path, translations: dict[str, str]) -> Path: + """Build a derived deck with a filled-in translation_map.""" + deck = tmp_path / "mydeck" + _make_minimal_deck(deck) + proc = _run_extract(str(deck), "--target-lang", "ja") + assert proc.returncode == 0 + derived = tmp_path / "mydeck-ja" + map_path = derived / "translate" / "translation_map.json" + dictionary = json.loads(map_path.read_text(encoding="utf-8")) + for k, v in translations.items(): + if k in dictionary: + dictionary[k] = v + map_path.write_text( + json.dumps(dictionary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return derived + + def test_apply_in_place(self, tmp_path: Path) -> None: + derived = self._prepare_derived(tmp_path, { + "Hello World": "こんにちは世界", + "Welcome": "ようこそ", + "Item A": "項目A", + }) + proc = _run_apply(str(derived)) + assert proc.returncode == 0, f"failed: {proc.stderr}" + + slide = json.loads((derived / "slides" / "slide-01.json").read_text(encoding="utf-8")) + assert slide["title"] == "こんにちは世界" + assert slide["subtitle"] == "ようこそ" + # Items list: only "Item A" translated + items = next(e for e in slide["elements"] if e.get("type") == "textbox" and "items" in e) + assert items["items"][0] == "項目A" + + def test_dry_run_does_not_modify_files(self, tmp_path: Path) -> None: + derived = self._prepare_derived(tmp_path, {"Hello World": "こんにちは世界"}) + before = (derived / "slides" / "slide-01.json").read_text(encoding="utf-8") + proc = _run_apply(str(derived), "--dry-run") + assert proc.returncode == 0 + after = (derived / "slides" / "slide-01.json").read_text(encoding="utf-8") + assert before == after, "--dry-run must not modify slide files" + + def test_preserves_vertical_tab(self, tmp_path: Path) -> None: + derived = self._prepare_derived(tmp_path, { + "Vertical\x0btab preserved": "縦\x0bタブ保持", + }) + proc = _run_apply(str(derived)) + assert proc.returncode == 0 + slide = json.loads((derived / "slides" / "slide-01.json").read_text(encoding="utf-8")) + found = False + for e in slide["elements"]: + if e.get("text") == "縦\x0bタブ保持": + found = True + break + assert found, "vertical tab must roundtrip" + + def test_preserves_styled_text_tags(self, tmp_path: Path) -> None: + derived = self._prepare_derived(tmp_path, { + "{{bold:Styled}} text here": "{{bold:装飾された}} テキスト", + }) + proc = _run_apply(str(derived)) + assert proc.returncode == 0 + slide = json.loads((derived / "slides" / "slide-01.json").read_text(encoding="utf-8")) + found = False + for e in slide["elements"]: + if isinstance(e.get("text"), str) and e["text"].startswith("{{bold:"): + found = True + assert e["text"] == "{{bold:装飾された}} テキスト" + assert found, "styled text must be replaced and preserve tag syntax" + + def test_skips_structural_keys(self, tmp_path: Path) -> None: + """Structural attributes like fontColor/src must never be in the dictionary.""" + deck = tmp_path / "mydeck" + _make_minimal_deck(deck) + proc = _run_extract(str(deck), "--target-lang", "ja") + assert proc.returncode == 0 + + dictionary = json.loads( + (tmp_path / "mydeck-ja" / "translate" / "translation_map.json").read_text(encoding="utf-8") + ) + # fontColor value should not be a key + assert "#FFFFFF" not in dictionary + # src value should not be a key + assert "images/foo.png" not in dictionary + + def test_does_not_touch_specs(self, tmp_path: Path) -> None: + derived = self._prepare_derived(tmp_path, {"Hello World": "こんにちは世界"}) + proc = _run_apply(str(derived)) + assert proc.returncode == 0 + brief = (derived / "specs" / "brief.md").read_text(encoding="utf-8") + assert "Original English content" in brief, "specs/ must remain in source language" diff --git a/web-ui/src/app/(authenticated)/decks/page.tsx b/web-ui/src/app/(authenticated)/decks/page.tsx index 3a337bff..0e9c0d15 100644 --- a/web-ui/src/app/(authenticated)/decks/page.tsx +++ b/web-ui/src/app/(authenticated)/decks/page.tsx @@ -115,7 +115,7 @@ export default function DecksPage() { chatTab={ws.chatTab} onChatTabChange={ws.setChatTab} chatRef={chatRef} - deckId={ws.isWorkspace && !ws.isNew ? ws.activeDeckId : null} + deckId={ws.isWorkspace ? (ws.isNew ? (ws.createdDeckId ?? null) : ws.activeDeckId) : null} deckName={ws.deck?.name || null} chatSessionId={ws.deck?.chatSessionId} slideSlugs={ws.deck?.slides.map(s => s.slug || "") || []} @@ -224,7 +224,7 @@ export default function DecksPage() { chatTab={ws.chatTab} onChatTabChange={ws.setChatTab} chatRef={chatRef} - deckId={ws.isWorkspace && !ws.isNew ? ws.activeDeckId : null} + deckId={ws.isWorkspace ? (ws.isNew ? (ws.createdDeckId ?? null) : ws.activeDeckId) : null} deckName={ws.deck?.name || null} chatSessionId={ws.deck?.chatSessionId} slideSlugs={ws.deck?.slides.map(s => s.slug || "") || []} diff --git a/web-ui/src/components/chat/ChatPanel.tsx b/web-ui/src/components/chat/ChatPanel.tsx index 1f3e9400..3d52b86e 100644 --- a/web-ui/src/components/chat/ChatPanel.tsx +++ b/web-ui/src/components/chat/ChatPanel.tsx @@ -48,7 +48,23 @@ export const ChatPanel = forwardRef(function Ch if (deckId === "new") return generateSessionId() return deckId.padEnd(36, "0") }) - useEffect(() => { if (chatSessionId && chatSessionId !== sessionId) setSessionId(chatSessionId) }, [chatSessionId]) + // Mid-stream guard: when a deck is created while the agent is still + // streaming (init_presentation in the import-pptx guide), the parent + // re-renders with a fresh chatSessionId. Swapping sessionId here would + // re-trigger history loading and clobber the in-flight messages + // (toolUses disappear from the UI). Defer the swap until streaming + // finishes, then apply it once isLoading flips back to false. + const pendingChatSessionIdRef = useRef(null) + const isLoadingRef = useRef(false) + useEffect(() => { + if (chatSessionId && chatSessionId !== sessionId) { + if (isLoadingRef.current) { + pendingChatSessionIdRef.current = chatSessionId + } else { + setSessionId(chatSessionId) + } + } + }, [chatSessionId]) // --- Config --- const [configLoaded, setConfigLoaded] = useState(false) @@ -295,6 +311,13 @@ export const ChatPanel = forwardRef(function Ch // --- Reconnect to running background session (Local mode) --- useEffect(() => { if (!IS_LOCAL || !deckId || deckId === "new") return + // Skip reconnect when this ChatPanel is already streaming via + // /api/agent/invoke. Without this guard, the deckId prop swap from + // "new" → real deckId after init_presentation triggers a second SSE + // consumer (EventSource), which reads the same stream alongside the + // in-flight fetch — every chunk lands in setMessages twice and the + // tool cards / text either get clobbered or duplicated mid-stream. + if (isLoadingRef.current) return let es: EventSource | null = null let completion = "" const toolPositions = new Map() @@ -482,6 +505,19 @@ export const ChatPanel = forwardRef(function Ch }, [stream.isLoading, stream.messages]) const isLoading = stream.isLoading || reconnectLoading + + // Sync streaming flag and flush any deferred sessionId swap once + // streaming finishes — at that point loadHistory can safely re-fetch + // (the .chat.json server-side state is already complete). + useEffect(() => { + isLoadingRef.current = stream.isLoading + if (!stream.isLoading && pendingChatSessionIdRef.current) { + const next = pendingChatSessionIdRef.current + pendingChatSessionIdRef.current = null + setSessionId(next) + } + }, [stream.isLoading]) + const isInitial = stream.messages.length === 0 && !historyLoading return ( diff --git a/web-ui/src/components/chat/ChatPanelShell.tsx b/web-ui/src/components/chat/ChatPanelShell.tsx index b51233ba..99764bac 100644 --- a/web-ui/src/components/chat/ChatPanelShell.tsx +++ b/web-ui/src/components/chat/ChatPanelShell.tsx @@ -199,8 +199,8 @@ export function ChatPanelShell({ ([]) const [isLoading, setIsLoading] = useState(false) + // Synchronous re-entry guard: setIsLoading(true) below is React state + // and does not flip until the next render, so two near-simultaneous + // triggers (Enter key repeat, double click, fast tap) both pass the + // isLoading check above and end up firing /api/agent/invoke twice in + // parallel, producing duplicate user bubbles and parallel streams. + // Updated synchronously at function entry and reset in finally. + const isLoadingRef = useRef(false) const abortControllerRef = useRef(null) const messagesRef = useRef(messages) messagesRef.current = messages @@ -113,10 +120,15 @@ export function useChatStream({ sessionId, mode, deckId, onToolEvent, onSendComp ) => { if (!userMessage.trim() && (!uploadedFiles || uploadedFiles.length === 0) && (!snippets || snippets.length === 0)) return if (isLoading) return + if (isLoadingRef.current) return + isLoadingRef.current = true const accessToken = auth.user?.access_token const userId = auth.user?.profile?.sub - if (!IS_LOCAL && (!accessToken || !userId)) return + if (!IS_LOCAL && (!accessToken || !userId)) { + isLoadingRef.current = false + return + } const display = options?.displayContent ?? userMessage setMessages((prev) => [ @@ -282,6 +294,7 @@ export function useChatStream({ sessionId, mode, deckId, onToolEvent, onSendComp } } finally { abortControllerRef.current = null + isLoadingRef.current = false setIsLoading(false) onSendComplete?.() } diff --git a/web-ui/src/lib/attachmentMarker.ts b/web-ui/src/lib/attachmentMarker.ts index 9b361062..d6d5231b 100644 --- a/web-ui/src/lib/attachmentMarker.ts +++ b/web-ui/src/lib/attachmentMarker.ts @@ -5,6 +5,23 @@ import type { UploadedFile } from "@/services/uploadService" /** Build [Attached:...] marker string for a single file. */ export function buildAttachedMarker(file: UploadedFile): string { + // PPTX deck-structure: when upload_file produced a guide hint, include + // it inline so the agent branches into the import-pptx guide instead of + // probing list_workflows or treating the upload as generic reference + // material. uploadId is still surfaced so the agent can call + // import_attachment / read_uploaded_file later. + if (file.guide && file.guideInstruction) { + const parts = [ + `uploadId: ${file.uploadId}`, + `guide: ${file.guide}`, + `guideInstruction: ${JSON.stringify(file.guideInstruction)}`, + ] + if (file.suggestedName) parts.push(`suggestedName: "${file.suggestedName}"`) + if (typeof file.slideCount === "number") parts.push(`slideCount: ${file.slideCount}`) + if (file.themeHints) parts.push(`themeHints: ${JSON.stringify(file.themeHints)}`) + return `[Attached: ${file.fileName} (${parts.join(", ")})]` + } + if (IS_LOCAL && file.filePath) { const parts = [`path: "${file.filePath}"`] if (file.imagesDir) parts.push(`images: "${file.imagesDir}"`) diff --git a/web-ui/src/lib/local/acp-process.ts b/web-ui/src/lib/local/acp-process.ts index 0c8cb0af..b720cb98 100644 --- a/web-ui/src/lib/local/acp-process.ts +++ b/web-ui/src/lib/local/acp-process.ts @@ -50,13 +50,15 @@ function handleLine(ps: ProcessState, line: string) { let msg: Record try { msg = JSON.parse(line) } catch { return } - // JSON-RPC response + // JSON-RPC response — resolve the pending promise, then fall through + // so the end_turn detection below can flip ps.running back to false. + // Returning here would short-circuit reuse: each turn would spawn a fresh + // process, dropping the prior turn's context (read_guides, upload result, + // hearing answers) and making the agent appear to drift back into Phase 1. if (msg.id != null && ps.pending.has(msg.id as number)) { const resolve = ps.pending.get(msg.id as number)! ps.pending.delete(msg.id as number) resolve(msg.result) - for (const fn of ps.listeners) fn(msg) - return } // Auto-approve permission requests diff --git a/web-ui/src/services/uploadService.ts b/web-ui/src/services/uploadService.ts index 2372c74b..17e97b19 100644 --- a/web-ui/src/services/uploadService.ts +++ b/web-ui/src/services/uploadService.ts @@ -33,6 +33,15 @@ export interface UploadedFile { filePath?: string imagesDir?: string colorAnalysis?: { palette: { hex: string; ratio: number }[]; brightness: string; saturation: string } + // PPTX deck-structure conversion fields (set by mcp-local/upload_tools.py + // and the Cloud upload Lambda when convert_file produces a deck). The + // agent uses `guideInstruction` to branch into the import-pptx guide + // instead of treating the file as generic reference material. + guide?: string + guideInstruction?: string + suggestedName?: string + slideCount?: number + themeHints?: { backgroundLuminance?: number; accentColors?: string[]; fonts?: Record } } let apiBaseUrl = "" @@ -121,6 +130,14 @@ export async function uploadFile( if (data.filePath) uploaded.filePath = data.filePath if (data.imagesDir) uploaded.imagesDir = data.imagesDir if (data.colorAnalysis) uploaded.colorAnalysis = data.colorAnalysis + // PPTX deck-structure: forward guide hints so the agent can branch + // into the import-pptx guide instead of treating the upload as a + // generic reference file. + if (data.guide) uploaded.guide = data.guide + if (data.guideInstruction) uploaded.guideInstruction = data.guideInstruction + if (data.suggestedName) uploaded.suggestedName = data.suggestedName + if (typeof data.slideCount === "number") uploaded.slideCount = data.slideCount + if (data.themeHints) uploaded.themeHints = data.themeHints onProgress?.(uploaded) return uploaded } @@ -184,6 +201,13 @@ export async function uploadFile( uploadedFile.status = processResult.status uploadedFile.extractedText = processResult.extractedText uploadedFile.imageUrl = processResult.imageUrl + // PPTX deck-structure: forward guide hints from the Cloud upload Lambda + // so the agent can branch into the import-pptx guide. + if (processResult.guide) uploadedFile.guide = processResult.guide + if (processResult.guideInstruction) uploadedFile.guideInstruction = processResult.guideInstruction + if (processResult.suggestedName) uploadedFile.suggestedName = processResult.suggestedName + if (typeof processResult.slideCount === "number") uploadedFile.slideCount = processResult.slideCount + if (processResult.themeHints) uploadedFile.themeHints = processResult.themeHints onProgress?.(uploadedFile) return uploadedFile @@ -224,6 +248,14 @@ export async function pollUploadStatus( status: result.status, extractedText: result.extractedText, imageUrl: result.imageUrl, + // Forward PPTX deck-structure guide hints (set by Cloud upload Lambda + // when convert_file produces a deck) so polling clients see the same + // import-pptx branching signal as synchronous-completion clients. + guide: result.guide, + guideInstruction: result.guideInstruction, + suggestedName: result.suggestedName, + slideCount: typeof result.slideCount === "number" ? result.slideCount : undefined, + themeHints: result.themeHints, } }