deerflow-code/offline-backend-20260512/backend/app/gateway/document_rewrite_illustrations.py
2026-09-07 18:24:55 +08:00

264 lines
9.7 KiB
Python

"""Safe, optional illustrations for a sandbox Markdown full rewrite.
The writer streams prose first. When requested, this module derives a small
set of heading-scoped illustration prompts, persists the returned bytes inside
the same user-owned artifact tree, then adds only local ``/mnt`` references to
the Markdown. Provider URLs never reach the browser or the saved document.
"""
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from deerflow.agents.deep_research.adapters.image import ImageGenerationError, ImageProvider
_HEADING_RE = re.compile(r"^##[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_TITLE_RE = re.compile(r"^#[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_START_MARKER = "<!-- deerflow:ai-rewrite-image:start -->"
_END_MARKER = "<!-- deerflow:ai-rewrite-image:end -->"
_MANAGED_IMAGE_BLOCK_RE = re.compile(
rf"\n?{re.escape(_START_MARKER)}.*?{re.escape(_END_MARKER)}\n?",
re.DOTALL,
)
_MIME_EXTENSIONS = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
@dataclass(frozen=True)
class DocumentIllustration:
"""A provider result that has not been exposed or persisted yet."""
section: str
position: int
data: bytes
mime_type: str
alt: str
@dataclass(frozen=True)
class PersistedDocumentIllustration:
"""A generated image with a virtual artifact path safe for Markdown."""
section: str
position: int
virtual_path: str
alt: str
def strip_managed_document_illustrations(markdown: str) -> str:
"""Remove only DeerFlow-generated image blocks before a fresh image pass."""
return _MANAGED_IMAGE_BLOCK_RE.sub("\n", markdown).strip()
def select_illustration_sections(markdown: str, maximum: int) -> list[str]:
"""Pick distinct H2 sections; fall back to the title for short documents."""
limit = max(0, min(maximum, 4))
if not limit:
return []
sections: list[str] = []
seen: set[str] = set()
for match in _HEADING_RE.finditer(markdown):
section = _clean_heading(match.group(1))
if section and section not in seen:
seen.add(section)
sections.append(section)
if len(sections) >= limit:
return sections
if sections:
return sections
title_match = _TITLE_RE.search(markdown)
title = _clean_heading(title_match.group(1)) if title_match else "文档主题"
return [title or "文档主题"]
async def generate_document_illustrations(
provider: ImageProvider,
*,
markdown: str,
document_name: str,
maximum: int,
on_started=None,
on_generated=None,
) -> tuple[list[DocumentIllustration], list[str]]:
"""Generate a bounded, sequential image set and keep failures recoverable."""
sections = select_illustration_sections(markdown, maximum)
title_match = _TITLE_RE.search(markdown)
document_title = _clean_heading(title_match.group(1)) if title_match else Path(document_name).stem
generated: list[DocumentIllustration] = []
warnings: list[str] = []
for position, section in enumerate(sections, 1):
if on_started is not None:
await on_started(section, position, len(sections))
try:
result = await provider.generate(
_illustration_prompt(document_title, section),
section_id=section,
)
data = result.get("data") if isinstance(result, dict) else None
if not isinstance(data, bytes) or not data:
raise ImageGenerationError("图片生成服务未返回可保存的图片数据")
mime_type = str(result.get("mime_type") or "image/png").lower()
if mime_type not in _MIME_EXTENSIONS:
mime_type = "image/png"
illustration = DocumentIllustration(
section=section,
position=position,
data=data,
mime_type=mime_type,
alt=_clean_alt(str(result.get("alt") or f"{section}配图")),
)
generated.append(illustration)
if on_generated is not None:
await on_generated(illustration, len(sections), len(generated))
except ImageGenerationError as exc:
warnings.append(f"“{section}”配图未生成:{exc}")
except Exception: # noqa: BLE001
# Generation remains best effort: an unavailable image provider
# must never discard an otherwise complete document rewrite.
warnings.append(f"“{section}”配图生成失败,已保留文字改写结果。")
return generated, warnings
def persist_document_illustrations(
illustrations: list[DocumentIllustration],
*,
markdown_path: Path,
virtual_markdown_path: str,
job_id: str,
) -> tuple[list[PersistedDocumentIllustration], list[str]]:
"""Best-effort persist each asset beside the rewritten Markdown file.
A single failed local write must not discard earlier successful images. In
particular, Windows development reloaders can briefly recreate an output
directory while a report is being written. The caller receives warnings
for the timeline, while any persisted image remains usable in the final
Markdown.
"""
if not illustrations:
return [], []
# Keep this name intentionally short. On Windows the local sandbox path
# already contains user/thread UUIDs; including a long report title plus a
# UUID-based temporary filename exceeded MAX_PATH (and surfaced as a
# misleading FileNotFoundError). Job ids make names unique across files,
# so a single short hidden directory per outputs folder is sufficient.
asset_directory_name = ".ai-images"
asset_directory = markdown_path.parent / asset_directory_name
virtual_parent = PurePosixPath(virtual_markdown_path).parent.as_posix().rstrip("/")
persisted: list[PersistedDocumentIllustration] = []
warnings: list[str] = []
for image in illustrations:
try:
extension = _MIME_EXTENSIONS[image.mime_type]
file_name = f"{job_id}-{image.position:02d}.{extension}"
_atomic_write_bytes(asset_directory / file_name, image.data)
persisted.append(
PersistedDocumentIllustration(
section=image.section,
position=image.position,
virtual_path=f"{virtual_parent}/{asset_directory_name}/{file_name}",
alt=image.alt,
)
)
except OSError:
warnings.append(f"“{image.section}”配图保存失败,已继续保留其他成功配图。")
return persisted, warnings
def insert_document_illustrations(
markdown: str,
illustrations: list[PersistedDocumentIllustration],
) -> str:
"""Insert fenced local-image blocks directly below their selected sections."""
amended = strip_managed_document_illustrations(markdown)
for image in illustrations:
marker = f"## {image.section}"
block = (
f"\n\n{_START_MARKER}\n"
f"![{image.alt}]({image.virtual_path})\n"
f"{_END_MARKER}\n"
)
if marker in amended:
amended = amended.replace(marker, f"{marker}{block}", 1)
else:
amended = f"{amended.rstrip()}{block}"
return amended.strip()
def _atomic_write_bytes(path: Path, content: bytes) -> None:
"""Write one image atomically with Windows-safe temporary path lengths."""
last_error: FileNotFoundError | None = None
for _attempt in range(3):
temporary: Path | None = None
try:
# A runtime cleanup can remove a freshly-created asset folder
# between image calls. Recreate it immediately before writing and
# retry this small local operation. NamedTemporaryFile deliberately
# keeps the temporary basename short: appending a report title,
# job id, and UUID can exceed Windows MAX_PATH before os.replace.
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=".w-",
suffix=".tmp",
delete=False,
) as file:
temporary = Path(file.name)
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, path)
return
except FileNotFoundError as exc:
last_error = exc
finally:
try:
if temporary is not None and temporary.exists():
temporary.unlink()
except OSError:
pass
if last_error is not None:
raise last_error
def _illustration_prompt(document_title: str, section: str) -> str:
return (
"为一份专业 Markdown 文档生成一张可嵌入的章节配图。"
f"文档主题:{_clean_heading(document_title)}。章节:{section}。"
"画面应信息性、简洁、与正文内容一致,可使用抽象示意、数据可视化风格或场景插画。"
"不要在图片中放置可读文字、品牌标志、水印或人物肖像;不得虚构具体数据或事实。"
)
def _clean_heading(value: str) -> str:
return " ".join(value.replace("\r", " ").replace("\n", " ").split())[:160]
def _clean_alt(value: str) -> str:
return _clean_heading(value).replace("[", "").replace("]", "") or "文档配图"
__all__ = [
"DocumentIllustration",
"PersistedDocumentIllustration",
"generate_document_illustrations",
"insert_document_illustrations",
"persist_document_illustrations",
"select_illustration_sections",
"strip_managed_document_illustrations",
]