import json
import logging
import os
from datetime import datetime, timezone
from typing import Optional
from openjiuwen_deepsearch.config.config import Config
from openjiuwen_deepsearch.framework.openjiuwen.agent.search_context import Outline
from openjiuwen_deepsearch.utils.common_utils.security_utils import ensure_safe_directory
from openjiuwen_deepsearch.utils.debug_utils.outline_visualization import OutlineToExcelExporter
logger = logging.getLogger(__name__)
class ResultExporter:
"""
安全结果导出管理器
"""
_initialized: bool = False
_export_enabled: bool = False
_results_dir: Optional[str] = None
_validated_dir: Optional[str] = None
_SAFE_BASE: str = os.path.realpath("./output/results")
@classmethod
def _sanitize_filename_component(cls, value, fallback: str) -> str:
"""Convert untrusted text into a single safe filename component."""
text = str(value or "").strip()
chars = []
for char in text:
if char.isalnum() or char in {"-", "_", "."}:
chars.append(char)
else:
chars.append("_")
component = "".join(chars).strip("._")
return (component or fallback)[:100]
@classmethod
def _safe_output_path(cls, output_dir: str, filename: str) -> str:
output_root = os.path.realpath(output_dir)
candidate = os.path.realpath(os.path.join(output_root, filename))
if os.path.commonpath([output_root, candidate]) != output_root:
raise ValueError(
f"Unsafe export path outside outline directory: {candidate}"
)
return candidate
@classmethod
def init(cls, results_dir: Optional[str] = None) -> None:
"""初始化导出器运行目录和基础状态。"""
if cls._initialized:
return
config = Config()
cls._export_enabled = config.service_config.model_dump().get(
"export_intermediate_results", False
)
cls._results_dir = results_dir
if cls._export_enabled:
try:
cls._validated_dir = ensure_safe_directory(
cls._results_dir or cls._SAFE_BASE, cls._SAFE_BASE
)
except Exception as e:
cls._validated_dir = None
logger.warning(
"[ResultExporter] ensure_safe_directory failed while export_intermediate_results "
"is enabled; intermediate export is disabled. requested_dir=%s safe_base=%s error=%s",
cls._results_dir or cls._SAFE_BASE,
cls._SAFE_BASE,
e,
exc_info=True,
)
cls._initialized = True
@classmethod
def export_outline(cls, outline, session_id) -> None:
"""导出大纲(自动初始化)"""
if not cls._initialized:
cls.init()
log_prefix = f"[{cls.__class__.__name__}]"
if not cls._export_enabled:
logger.warning(f"{log_prefix} Export is disabled")
return
if not cls._validated_dir:
logger.warning(f"{log_prefix} Result dir is invalid, "
f"result_dir: {cls._results_dir}, safe_base_dir: {cls._SAFE_BASE}")
return
try:
if isinstance(outline, Outline):
data = outline.model_dump()
outline_title = outline.title
elif isinstance(outline, dict):
data = outline
outline_title = outline.get("title")
else:
return
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
safe_title = cls._sanitize_filename_component(outline_title, "outline")
safe_session_id = cls._sanitize_filename_component(session_id, "session")
base_name = f"{safe_title}_{safe_session_id}_{timestamp}"
output_dir = os.path.join(cls._validated_dir, "outline")
os.makedirs(output_dir, exist_ok=True)
json_path = cls._safe_output_path(output_dir, f"{base_name}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"{log_prefix} Exported Outline JSON: {json_path}")
excel_path = cls._safe_output_path(output_dir, f"{base_name}.xlsx")
exporter = OutlineToExcelExporter(data)
exporter.export_to_excel(excel_path)
logger.info(f"{log_prefix} Exported Outline Excel: {excel_path}")
except Exception as e:
logger.error(f"{log_prefix} Failed to export outline: {e}", exc_info=True)