Paper2Any / dataflow_agent /workflow /wf_paper2ppt_parallel.py
pzp5700's picture
Fix PDF export + mask image API key
0ba29d1
Raw
History Blame Contribute Delete
26.2 kB
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 ""
# TODO 当前版本仅支持单图 asset_ref;若包含多图,仅保留第一张。
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
# table 走占位提取
if _is_table_asset(asset_ref):
# 优先使用 item 自己带的表格图(如果调用方已经生成过)
table_img_path = item.get("table_img_path") or item.get("table_png_path") or ""
table_img_path = str(table_img_path).strip()
# 若没有,则调用 table_extractor agent:生成 html->png,并写入 state.table_img_path
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)
# 如果表格图像不存在,则退化为 text2img:不走编辑,返回 use_edit=False
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)
# 如果图片不存在,则退化为 text2img:不走编辑,返回 use_edit=False
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: # noqa: N802
"""
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 []
# 兼容:有些调用方把 style 放 state.style,而不是 request.style
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:
# 如果是 all_edited_down,说明用户只想打包下载,不需要生成或编辑,直接去导出
if getattr(state.request, "all_edited_down", False):
return "export_ppt_assets"
# gen_down == False: 第一次批量生成
if not getattr(state, "gen_down", False):
return "generate_pages"
# gen_down == True: 进入按页编辑
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: # noqa: BLE001
last_err = e
log.error(f"[paper2ppt] image gen failed attempt {attempt}/{retries}: {e}")
if attempt < retries:
try:
await asyncio.sleep(delay)
except Exception:
# sleep 失败不影响后续重试
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())
# Case B: pagecontent 本身就是图片路径
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)):
# 规则 2:只做风格化编辑
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,
}
# Case A: 结构化页面
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:
# 注意:_make_prompt_for_structured_page 可能是 async 的,因为它可能调用 table_extractor agent
prompt, image_path, use_edit = await _make_prompt_for_structured_page(item, style=style, state=state)
except Exception as e: # noqa: BLE001
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()
# 使用 gather 并发执行所有任务
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):
# 理论上 _process_single_page 内部捕获了大部分异常,这里是防漏
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_dict = res # type: ignore
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")
# 取出原图路径:优先 generated_pages,其次 pagecontent[i].ppt_img_path
old_path: Optional[str] = None
# Debug log
log.info(f"[paper2ppt] edit_single_page: idx={idx}")
# log.info(f"[paper2ppt] generated_pages={getattr(state, 'generated_pages', None)}")
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)
# B1 策略:编辑时直接覆盖原始 page_{idx:03d}.png,避免 *_edit_*.png 累积
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}"
)
# 使用新的 API 版本函数(带 inpainting 支持)
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, # 启用 API inpainting
)
# 可选:把导出结果路径挂到 state 上,方便后续使用
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