| from __future__ import annotations |
|
|
| import asyncio |
| import json |
| import os |
| import time |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Tuple, Union |
|
|
| from dataflow_agent.graphbuilder.graph_builder import GenericGraphBuilder |
| from dataflow_agent.logger import get_logger |
| from dataflow_agent.state import Paper2FigureState |
| from dataflow_agent.utils import get_project_root |
| from dataflow_agent.workflow.registry import register |
| from dataflow_agent.agentroles import create_react_agent |
| from dataflow_agent.toolkits.imtool.req_img import generate_or_edit_and_save_image_async |
| from dataflow_agent.toolkits.imtool.ppt_tool import convert_images_dir_to_pdf_and_ppt, convert_images_dir_to_pdf_and_ppt_api |
|
|
| log = get_logger(__name__) |
|
|
|
|
| def _ensure_result_path(state: Paper2FigureState) -> str: |
| """ |
| 统一 paper2ppt workflow 的根输出目录: |
| - 若 state.result_path 已存在(通常由调用方传入),直接使用; |
| - 否则:使用 get_project_root()/outputs/paper2ppt/<timestamp> 初始化,并写回 state.result_path。 |
| """ |
| raw = getattr(state, "result_path", None) |
| if raw: |
| return raw |
|
|
| root = get_project_root() |
| ts = int(time.time()) |
| base_dir = (root / "outputs" / "paper2ppt" / str(ts)).resolve() |
| base_dir.mkdir(parents=True, exist_ok=True) |
| state.result_path = str(base_dir) |
| return state.result_path |
|
|
|
|
| def _abs_path(p: str) -> str: |
| if not p: |
| return "" |
| try: |
| return str(Path(p).expanduser().resolve()) |
| except Exception: |
| return p |
|
|
|
|
| def _is_table_asset(asset_ref: Optional[str]) -> bool: |
| """ |
| 你给的约定:asset 是 Table 时,通过 asset_ref: "Table 2" 这种字符串标记。 |
| """ |
| if not asset_ref: |
| return False |
| s = str(asset_ref).strip().lower() |
| return s.startswith("table") |
|
|
|
|
| def _serialize_prompt_dict(d: Dict[str, Any]) -> str: |
| """ |
| 把 dict 安全序列化为 prompt 文本(中文不转义)。 |
| """ |
| try: |
| return json.dumps(d, ensure_ascii=False, indent=2) |
| except Exception: |
| |
| return str(d) |
|
|
|
|
| def _normalize_single_asset_ref(asset_ref: str) -> str: |
| """ |
| 规范化 asset_ref,仅保留第一张图的路径/文件名。 |
| |
| 当前版本不支持多图编辑: |
| - 如果 asset_ref 中包含逗号等分隔符,如 "a.jpg,b.jpg", |
| 只取第一段 "a.jpg"。 |
| - TODO: 后续可以扩展多图 asset_ref 支持。 |
| """ |
| if not asset_ref: |
| return "" |
| s = str(asset_ref).strip() |
| if not s: |
| return "" |
|
|
| |
| parts = [p.strip() for p in s.split(",") if p.strip()] |
| if not parts: |
| return "" |
|
|
| if len(parts) > 1: |
| log.warning( |
| "[paper2ppt] asset_ref 包含多张图片,仅使用第一张。" |
| f" raw={asset_ref!r}, first={parts[0]!r} # TODO: 支持多图 asset_ref" |
| ) |
|
|
| return parts[0] |
|
|
|
|
| async def _make_prompt_for_structured_page(item: Dict[str, Any], style: str, state: Paper2FigureState) -> Tuple[str, Optional[str], bool]: |
| """ |
| 根据结构化 page item 生成: |
| - prompt |
| - image_path (如果是编辑模式) |
| - use_edit |
| |
| 规则: |
| 1) asset 为空:text2img,用 “json(去asset)” + “根据上述内容生成{style}风格的PPT” |
| 2) asset 是图片路径:img2img/edit,用 “json(去asset)” + “把这个图作为PPT的一部分...” |
| 3) asset 是 Table(asset_ref="Table 2"):先提取 table png(这里先占位),再走 edit |
| """ |
| asset_ref = item.get("asset_ref") or item.get("asset") or item.get("assetRef") or "" |
| asset_ref = str(asset_ref).strip() if asset_ref is not None else "" |
| |
| asset_ref = _normalize_single_asset_ref(asset_ref) |
|
|
| prompt_dict = dict(item) |
| for k in ["asset_ref", "asset", "assetRef", "asset_type", "type"]: |
| if k in prompt_dict: |
| prompt_dict.pop(k, None) |
|
|
| base = _serialize_prompt_dict(prompt_dict) |
|
|
| if not asset_ref: |
| prompt = f"{base}\n\n根据上述内容。生成{style}风格的 PPT 图像, \n 使用语言:{state.request.language}" |
| return prompt, None, False |
|
|
| |
| if _is_table_asset(asset_ref): |
| |
| table_img_path = item.get("table_img_path") or item.get("table_png_path") or "" |
| table_img_path = str(table_img_path).strip() |
|
|
| |
| if not table_img_path: |
| state.asset_ref = asset_ref |
| agent = create_react_agent( |
| name="table_extractor", |
| temperature=0.1, |
| max_retries=6, |
| parser_type="json", |
| ) |
| state = await agent.execute(state=state) |
|
|
| table_img_path = str(getattr(state, "table_img_path", "") or "").strip() |
| log.critical(f'[table_img_path 表格图像路径]: {table_img_path}') |
|
|
| if not table_img_path: |
| raise ValueError(f"[paper2ppt] 表格提取失败,未得到 table_img_path。asset_ref={asset_ref}") |
|
|
| image_path = _resolve_asset_path(table_img_path, state) |
| |
| if not image_path or not os.path.exists(image_path): |
| log.error(f"[paper2ppt] 表格图像文件不存在: {image_path!r} (asset_ref={asset_ref})") |
| prompt = f"{base}\n\n根据上述内容生成{style}风格的 PPT 图像, \n 使用语言:{state.request.language}" |
| return prompt, None, False |
|
|
| prompt = f"{base}\n\n根据上述内容绘制ppt,把这个图作为PPT的一部分。生成{style}风格的PPT. \n 使用语言:{state.request.language} !!!" |
| return prompt, image_path, True |
|
|
| |
| image_path = _resolve_asset_path(asset_ref, state) |
| |
| if not image_path or not os.path.exists(image_path): |
| log.error(f"[paper2ppt] 图片文件不存在: {image_path!r} (asset_ref={asset_ref})") |
| prompt = f"{base}\n\n根据上述内容生成{style}风格的 PPT 图像, \n 使用语言:{state.request.language}" |
| return prompt, None, False |
|
|
| prompt = f"{base}\n\n根据上述内容绘制ppt,把这个图作为PPT的一部分。生成{style}风格的PPT. \n 使用语言:{state.request.language} !!!" |
| return prompt, image_path, True |
|
|
|
|
| def _resolve_asset_path(asset_ref: str, state: Paper2FigureState) -> str: |
| """ |
| 根据 state 解析 asset 引用为绝对路径。 |
| |
| 规则: |
| - 为空直接返回 ""; |
| - 绝对路径或以 ~ 开头:直接通过 _abs_path 规范化; |
| - 相对路径: |
| * 优先挂在 state.mineru_root(MinerU 输出根目录)下; |
| * 否则挂在 state.result_path 下; |
| * 再否则退化为当前工作目录下的相对路径解析(_abs_path)。 |
| """ |
| if not asset_ref: |
| return "" |
| s = str(asset_ref).strip() |
| if not s: |
| return "" |
|
|
| p = Path(s) |
|
|
| |
| if p.is_absolute() or s.startswith("~"): |
| return _abs_path(s) |
|
|
| base_dir = getattr(state, "mineru_root", None) or getattr(state, "result_path", None) |
| log.critical(f'[base_dir _resolve_asset_path]: {base_dir}') |
| |
| if base_dir: |
| try: |
| return str((Path(base_dir) / p).resolve()) |
| except Exception: |
| return _abs_path(s) |
|
|
| return _abs_path(s) |
|
|
|
|
| def _extract_image_path_from_pagecontent_item(item: Any) -> Optional[str]: |
| """ |
| 支持 pagecontent 直接是图片路径的几种形态: |
| - "/abs/xxx.png" |
| - {"ppt_img_path": "/abs/xxx.png"} |
| - {"img_path": "/abs/xxx.png"} |
| - {"path": "/abs/xxx.png"} |
| """ |
| if not item: |
| return None |
| if isinstance(item, str): |
| return item.strip() |
| if isinstance(item, dict): |
| for k in ["ppt_img_path", "img_path", "path", "image_path"]: |
| v = item.get(k) |
| if v: |
| return str(v).strip() |
| return None |
|
|
|
|
| @register("paper2ppt_parallel") |
| def create_paper2ppt_parallel_graph() -> GenericGraphBuilder: |
| """ |
| Workflow factory: dfa run --wf paper2ppt_parallel |
| |
| 功能: |
| - 并行版本:并发调用 AI 接口生成/编辑所有 PPT 页面 |
| - 若 state.gen_down == False:批量生成/编辑每页 PPT 图,保存到统一目录 |
| - 若 state.gen_down == True:按 0-based edit_page_num 对已有页面图做二次编辑(edit_page_prompt) |
| """ |
| builder = GenericGraphBuilder(state_model=Paper2FigureState, entry_point="_start_") |
|
|
| def _start_(state: Paper2FigureState) -> Paper2FigureState: |
| _ensure_result_path(state) |
| state.pagecontent = state.pagecontent or [] |
| state.generated_pages = state.generated_pages or [] |
| |
| if not getattr(state.request, "style", None) and getattr(state, "style", None): |
| state.request.style = getattr(state, "style") |
| return state |
|
|
| def _route(state: Paper2FigureState) -> str: |
| |
| if getattr(state.request, "all_edited_down", False): |
| return "export_ppt_assets" |
|
|
| |
| if not getattr(state, "gen_down", False): |
| return "generate_pages" |
| |
| return "edit_single_page" |
|
|
| async def generate_pages(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 批量生成/编辑页面图(并行版本): |
| - pagecontent 是结构化 list[dict]:按 asset 规则决定 text2img / img2img |
| - pagecontent 直接是图片路径列表:逐页用“修改成xxx风格”编辑 |
| |
| 并发调用:使用 asyncio.gather 同时处理所有页面。 |
| """ |
| import asyncio |
|
|
| async def _call_image_api_with_retry(coro_factory, retries: int = 3, delay: float = 1.0) -> bool: |
| """ |
| 对图像生成/编辑进行最多 retries 次重试。 |
| - 成功:返回 True |
| - 多次失败:返回 False(由上层决定如何处理当前页) |
| """ |
| last_err: Optional[Exception] = None |
| for attempt in range(1, retries + 1): |
| try: |
| await coro_factory() |
| return True |
| except Exception as e: |
| last_err = e |
| log.error(f"[paper2ppt] image gen failed attempt {attempt}/{retries}: {e}") |
| if attempt < retries: |
| try: |
| await asyncio.sleep(delay) |
| except Exception: |
| |
| pass |
| log.error(f"[paper2ppt] image gen failed after {retries} attempts, skip this page. last_err={last_err}") |
| return False |
|
|
| result_root = Path(_ensure_result_path(state)) |
| img_dir = result_root / "ppt_pages" |
| img_dir.mkdir(parents=True, exist_ok=True) |
|
|
| style = getattr(state.request, "style", None) or "kartoon" |
| aspect_ratio = getattr(state, "aspect_ratio", None) or "16:9" |
|
|
| |
| state.generated_pages = [] |
| |
| |
| async def _process_single_page(idx: int, item: Any) -> Dict[str, Any]: |
| """ |
| 处理单个页面:返回生成的 result item (dict)。 |
| 如果失败,result item 中的 generated_img_path 为 None。 |
| """ |
| save_path = str((img_dir / f"page_{idx:03d}.png").resolve()) |
| |
| |
| direct_img_path = _extract_image_path_from_pagecontent_item(item) |
| is_direct_image_list = bool(direct_img_path) and ( |
| isinstance(item, str) |
| or (isinstance(item, dict) and set(item.keys()).intersection({"ppt_img_path", "img_path", "path", "image_path"})) |
| ) |
|
|
| if is_direct_image_list and (not isinstance(item, dict) or ("title" not in item and "layout_description" not in item)): |
| |
| image_path = _abs_path(direct_img_path) |
| |
| prompt = ( |
| f"Please beautify and re-design this PowerPoint slide image. " |
| f"Keep all the original text and structure, but completely transform it into a professional, " |
| f"visually stunning presentation slide in {style} style. " |
| f"Make sure the colors, layout, and background are improved significantly." |
| ) |
| log.info(f"[paper2ppt] page={idx} direct image edit: image={image_path}, save={save_path}") |
|
|
| log.critical(f'[强化提示词,确保模型进行重绘而不是原图输出]: {prompt}') |
|
|
| ok = await _call_image_api_with_retry( |
| lambda: generate_or_edit_and_save_image_async( |
| prompt=prompt, |
| save_path=save_path, |
| aspect_ratio=aspect_ratio, |
| api_url=state.request.chat_api_url, |
| api_key=state.request.chat_api_key or os.getenv("DF_API_KEY") , |
| model=state.request.gen_fig_model, |
| image_path=image_path, |
| use_edit=True, |
| ) |
| ) |
| if not ok: |
| |
| return { |
| "source_img_path": image_path, |
| "generated_img_path": None, |
| "page_idx": idx, |
| "mode": "edit_direct_image_failed", |
| "style": style, |
| } |
|
|
| return { |
| "source_img_path": image_path, |
| "generated_img_path": save_path, |
| "page_idx": idx, |
| "mode": "edit_direct_image", |
| "style": style, |
| } |
|
|
| |
| if not isinstance(item, dict): |
| log.warning(f"[paper2ppt] page={idx} 非 dict 且非 image path,跳过。item={item}") |
| return { |
| "page_idx": idx, |
| "mode": "invalid_item_skipped", |
| "generated_img_path": None, |
| } |
|
|
| try: |
| |
| prompt, image_path, use_edit = await _make_prompt_for_structured_page(item, style=style, state=state) |
| except Exception as e: |
| log.error(f"[paper2ppt] page={idx} prompt 构造失败: {e}") |
| failed_item = dict(item) |
| failed_item.update({ |
| "generated_img_path": None, |
| "page_idx": idx, |
| "mode": "prompt_build_failed", |
| "style": style, |
| "error": str(e), |
| }) |
| return failed_item |
|
|
| log.info( |
| f"[paper2ppt] page={idx} structured: use_edit={use_edit}, " |
| f"image_path={image_path}, save={save_path}, \n 本次生成的 prompt 为:\n{prompt}" |
| ) |
|
|
| ok = await _call_image_api_with_retry( |
| lambda: generate_or_edit_and_save_image_async( |
| prompt=prompt, |
| save_path=save_path, |
| aspect_ratio=aspect_ratio, |
| api_url=state.request.chat_api_url, |
| api_key=state.request.chat_api_key or os.getenv("DF_API_KEY") , |
| model=state.request.gen_fig_model, |
| image_path=image_path, |
| use_edit=use_edit, |
| ) |
| ) |
| if not ok: |
| failed_item = dict(item) |
| failed_item.update({ |
| "generated_img_path": None, |
| "page_idx": idx, |
| "mode": "generate_failed" if not use_edit else "edit_failed", |
| "style": style, |
| }) |
| return failed_item |
|
|
| |
| out_item = dict(item) |
| out_item.update({ |
| "generated_img_path": save_path, |
| "page_idx": idx, |
| "mode": "edit" if use_edit else "generate", |
| "style": style, |
| }) |
| return out_item |
|
|
| |
| |
| |
| page_items = state.pagecontent or [] |
| tasks = [] |
| for idx, item in enumerate(page_items): |
| tasks.append(_process_single_page(idx, item)) |
| |
| log.info(f"[paper2ppt_parallel] start generating {len(tasks)} pages concurrently...") |
| start_time = time.time() |
| |
| |
| results = await asyncio.gather(*tasks, return_exceptions=True) |
| |
| cost_time = time.time() - start_time |
| log.info(f"[paper2ppt_parallel] all pages finished. cost={cost_time:.2f}s") |
|
|
| |
| new_pagecontent: List[Dict[str, Any]] = [] |
| state.generated_pages = [] |
|
|
| for i, res in enumerate(results): |
| if isinstance(res, Exception): |
| |
| log.error(f"[paper2ppt_parallel] page {i} unhandled exception: {res}") |
| |
| failed_item = dict(page_items[i]) if isinstance(page_items[i], dict) else {"raw_item": str(page_items[i])} |
| failed_item.update({ |
| "generated_img_path": None, |
| "page_idx": i, |
| "mode": "unhandled_exception", |
| "error": str(res) |
| }) |
| new_pagecontent.append(failed_item) |
| else: |
| |
| res_dict = res |
| new_pagecontent.append(res_dict) |
| gen_path = res_dict.get("generated_img_path") |
|
|
| if gen_path: |
| state.generated_pages.append(gen_path) |
| else: |
| |
| state.generated_pages.append("") |
|
|
| state.pagecontent = new_pagecontent |
| return state |
|
|
| async def edit_single_page(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| gen_down == True 时的路径: |
| 通过 edit_page_num(0-based) + edit_page_prompt 对已经生成好的某一页做二次编辑。 |
| |
| 当前策略(B1): |
| - 不再生成 *_edit_*.png 新文件; |
| - 直接覆盖原来的 page_{idx:03d}.png,保证导出时每页只有一张图。 |
| """ |
| idx = int(getattr(state, "edit_page_num", -1)) |
| prompt = (getattr(state, "edit_page_prompt", "") or "").strip() |
| if idx < 0: |
| raise ValueError("[paper2ppt] edit_page_num 必须是 0-based 且 >=0") |
| |
| |
| old_path: Optional[str] = None |
| |
| |
| log.info(f"[paper2ppt] edit_single_page: idx={idx}") |
| |
| |
| if getattr(state, "generated_pages", None) and idx < len(state.generated_pages): |
| old_path = state.generated_pages[idx] |
| log.info(f"[paper2ppt] got old_path from generated_pages: {old_path}") |
| |
| if not old_path and idx < len(state.pagecontent or []): |
| it = state.pagecontent[idx] |
| if isinstance(it, dict): |
| old_path = it.get("generated_img_path") or it.get("ppt_img_path") or it.get("img_path") |
| log.info(f"[paper2ppt] got old_path from pagecontent: {old_path}") |
| |
| if not old_path: |
| raise ValueError(f"[paper2ppt] 找不到要编辑的页图路径: idx={idx}") |
|
|
| old_path = _abs_path(old_path) |
|
|
| result_root = Path(_ensure_result_path(state)) |
| img_dir = result_root / "ppt_pages" |
| img_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| save_path = str((img_dir / f"page_{idx:03d}.png").resolve()) |
| aspect_ratio = getattr(state, "aspect_ratio", None) or "16:9" |
| style = getattr(state.request, "style", None) or "kartoon" |
|
|
| |
| if prompt: |
| |
| full_prompt = ( |
| f"Beautify this PowerPoint slide based on this instruction: '{prompt}'. " |
| f"Transform the existing design into a high-end, professional {style} style presentation. " |
| f"Enhance the visual aesthetics, layout, and background while preserving the core message." |
| ) |
| else: |
| |
| full_prompt = ( |
| f"Beautify and re-design this PowerPoint slide. " |
| f"Transform the existing design into a high-end, professional {style} style presentation. " |
| f"Enhance the visual aesthetics, layout, and background while preserving the core message." |
| ) |
|
|
| log.info(f"[paper2ppt] edit_single_page idx={idx} old={old_path} save={save_path}") |
| log.critical(f'[full_prompt] {full_prompt}') |
|
|
| await generate_or_edit_and_save_image_async( |
| prompt=full_prompt, |
| save_path=save_path, |
| aspect_ratio=aspect_ratio, |
| api_url=state.request.chat_api_url, |
| api_key=state.request.chat_api_key or os.getenv("DF_API_KEY") , |
| model=state.request.gen_fig_model, |
| image_path=old_path, |
| use_edit=True, |
| ) |
|
|
| |
| if getattr(state, "generated_pages", None) and idx < len(state.generated_pages): |
| state.generated_pages[idx] = save_path |
| if idx < len(state.pagecontent or []): |
| it = state.pagecontent[idx] |
| if isinstance(it, dict): |
| it["generated_img_path"] = save_path |
| it["edit_prompt"] = prompt |
| it["mode"] = "edit_again" |
|
|
| |
| state.edit_page_prompt = "" |
| state.edit_page_num = -1 |
| return state |
|
|
| async def export_ppt_assets(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 最终导出节点: |
| - 使用 ppt_tool.convert_images_dir_to_pdf_and_ppt_api(带 API inpainting 支持) |
| 将 result_path/ppt_pages 中的页面图导出为 PDF 和可编辑 PPTX。 |
| |
| 注意: |
| - gen_down == False(首次生成):始终导出; |
| - gen_down == True(编辑模式):只有在 request.all_edited_down == True 时才导出, |
| 否则直接跳过该节点。 |
| """ |
| |
| if getattr(state, "gen_down", False): |
| all_done = getattr(getattr(state, "request", None), "all_edited_down", False) |
| if not all_done: |
| log.info("[paper2ppt] export_ppt_assets skipped: gen_down=True & all_edited_down is False") |
| return state |
|
|
| result_root = Path(_ensure_result_path(state)) |
| img_dir = result_root / "ppt_pages" |
|
|
| if not img_dir.exists(): |
| raise ValueError(f"[paper2ppt] export_ppt_assets: image dir not found: {img_dir}") |
|
|
| pdf_path = result_root / "paper2ppt.pdf" |
| pptx_path = result_root / "paper2ppt_editable.pptx" |
|
|
| log.info( |
| f"[paper2ppt] export_ppt_assets: images_dir={img_dir}, " |
| f"pdf={pdf_path}, pptx={pptx_path}" |
| ) |
|
|
| |
| out = await convert_images_dir_to_pdf_and_ppt_api( |
| input_dir=str(img_dir), |
| output_pdf_path=str(pdf_path), |
| output_pptx_path=None, |
| api_url=state.request.chat_api_url, |
| api_key=state.request.chat_api_key or os.getenv("DF_API_KEY") , |
| model=state.request.gen_fig_model, |
| use_api_inpaint=False, |
| ) |
|
|
| |
| setattr(state, "ppt_pdf_path", out.get("pdf") or str(pdf_path)) |
| setattr(state, "ppt_pptx_path", None) |
|
|
| return state |
|
|
| nodes = { |
| "_start_": _start_, |
| "generate_pages": generate_pages, |
| "edit_single_page": edit_single_page, |
| "export_ppt_assets": export_ppt_assets, |
| "_end_": lambda state: state, |
| } |
|
|
| edges = [ |
| ("generate_pages", "export_ppt_assets"), |
| ("edit_single_page", "export_ppt_assets"), |
| ("export_ppt_assets", "_end_"), |
| ] |
|
|
| builder.add_nodes(nodes).add_edges(edges).add_conditional_edge("_start_", _route) |
| return builder |
|
|