feat(04-03): implement AnalyzeProcessor with DeepAnalyzer integration

- AnalyzeProcessor wraps existing DeepAnalyzer for pipeline integration
- Loads metadata and content from JSON files
- Generates analysis reports as Markdown with frontmatter
- Records state on success and failure
- Supports both video and article content types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 23:32:31 +08:00
parent 51644997e4
commit 0b7d8707bf
2 changed files with 324 additions and 57 deletions
+252
View File
@@ -0,0 +1,252 @@
"""AnalyzeProcessor - AI-powered content analysis
Unified processor for generating AI analysis reports from content.
Wraps existing DeepAnalyzer for integration into the pipeline.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Optional, Dict, Any
from ..config import get_settings
from ..schemas import AnalyzeInput, AnalyzeOutput
from ..state import WorkflowStateManager
from .base import BaseProcessor
logger = logging.getLogger(__name__)
class AnalyzeProcessor(BaseProcessor):
"""Analyze 处理器 - AI 深度内容分析
处理流程:
1. 检查步骤是否已完成(幂等执行)
2. 加载元数据和内容文件
3. 初始化 AI 客户端
4. 调用 DeepAnalyzer 生成分析报告
5. 生成 frontmatter 并保存 Markdown 文件
6. 记录处理状态到 workflow_state.json
Attributes:
STEP_NAME: 步骤名称,用于状态记录
settings: KB 配置
state_manager: 状态管理器(可选)
"""
STEP_NAME = "analyze"
async def process(self, input_data: AnalyzeInput) -> AnalyzeOutput:
"""执行分析处理
策略:
1. 检查步骤是否已完成(跳过已完成)
2. 加载元数据和内容
3. 调用 DeepAnalyzer
4. 保存分析报告
5. 记录状态
Args:
input_data: Analyze 输入数据
Returns:
Analyze 处理结果
Raises:
Exception: 处理失败时抛出异常
"""
try:
# Step 1: 检查是否已完成(幂等执行)
if self.state_manager:
state = self.state_manager.load()
if state and state.is_step_completed(self.STEP_NAME):
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
# 返回已有结果
existing_file = Path(input_data.output_file)
if existing_file.exists():
return AnalyzeOutput(
success=True,
analysis_file=str(existing_file),
summary="Skipped - already completed",
main_topics=[],
key_insights=[],
tags=[],
stats={"skipped": True}
)
# Step 2: 加载元数据和内容
metadata = self._load_json(input_data.metadata_file)
content = self._load_json(input_data.content_file)
# 对于文章类型,内容可能是纯文本文件
if not content and input_data.content_type in ("article", "answer", "zhuanlan"):
content_path = Path(input_data.content_file)
if content_path.exists():
text = content_path.read_text(encoding="utf-8")
content = {"text": text}
# Step 3: 调用 DeepAnalyzer
result = await self._run_analysis(
metadata=metadata,
content=content,
content_type=input_data.content_type
)
# Step 4: 生成并保存分析报告
output_path = Path(input_data.output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
self._write_analysis_report(
output_path=output_path,
metadata=metadata,
result=result,
content_type=input_data.content_type
)
# Step 5: 记录成功状态
if self.state_manager:
state = self.state_manager.load()
if state:
summary = f"topics={len(result.main_topics)}, insights={len(result.key_insights)}"
state.mark_step_completed(self.STEP_NAME, summary)
self.state_manager.save(state)
return AnalyzeOutput(
success=True,
analysis_file=str(output_path),
summary=result.summary,
main_topics=result.main_topics,
key_insights=result.key_insights,
tags=result.recommended_tags,
stats={
"topics_count": len(result.main_topics),
"insights_count": len(result.key_insights),
"tags_count": len(result.recommended_tags)
}
)
except Exception as e:
logger.error(f"Analyze failed: {e}")
# 记录失败状态
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_failed(self.STEP_NAME, str(e))
self.state_manager.save(state)
return AnalyzeOutput(
success=False,
error_message=str(e),
analysis_file="",
summary="",
main_topics=[],
key_insights=[],
tags=[],
stats={}
)
def _load_json(self, file_path: str) -> Dict[str, Any]:
"""加载 JSON 文件
Args:
file_path: JSON 文件路径
Returns:
解析后的字典,如果文件不存在或解析失败返回空字典
"""
if not file_path:
return {}
path = Path(file_path)
if not path.exists():
logger.warning(f"File not found: {file_path}")
return {}
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON: {file_path}, error: {e}")
return {}
async def _run_analysis(self, metadata: Dict, content: Dict, content_type: str):
"""调用 DeepAnalyzer 执行分析
Args:
metadata: 元数据字典
content: 内容字典
content_type: 内容类型
Returns:
AnalysisResult 对象
"""
# Lazy import to avoid dependency issues
from kb_deep_analyzer.deep_analyzer import DeepAnalyzer, SimpleAIClient
# 初始化 AI 客户端
ai_client = self._create_ai_client()
# 创建分析器实例
analyzer = DeepAnalyzer(ai_client=ai_client)
# 执行分析
return analyzer.analyze(metadata, content, content_type)
def _create_ai_client(self):
"""创建 AI 客户端
Returns:
SimpleAIClient 实例
"""
from kb_deep_analyzer.deep_analyzer import SimpleAIClient
# 从环境变量获取配置
api_key = os.environ.get("AI_API_KEY", "")
provider = os.environ.get("AI_PROVIDER", "")
model = os.environ.get("AI_MODEL", "glm-5")
base_url = os.environ.get("AI_BASE_URL", "")
return SimpleAIClient(
api_key=api_key,
provider=provider,
model=model,
base_url=base_url
)
def _write_analysis_report(
self,
output_path: Path,
metadata: Dict,
result,
content_type: str
) -> None:
"""写入分析报告 Markdown 文件
Args:
output_path: 输出文件路径
metadata: 元数据字典
result: AnalysisResult 对象
content_type: 内容类型
"""
from kb_deep_analyzer.deep_analyzer import generate_frontmatter
# 生成 frontmatter
frontmatter = generate_frontmatter(metadata, result, content_type)
# 获取完整分析内容
full_analysis = result.full_analysis
# 文章类型:追加原文
if content_type == "article" and result.original_text:
full_analysis = full_analysis.rstrip() + "\n\n## 原文\n\n" + result.original_text
# 写入文件
with open(output_path, 'w', encoding='utf-8') as f:
f.write(frontmatter + "\n\n")
f.write(full_analysis)
logger.info(f"Analysis report saved to: {output_path}")
+72 -57
View File
@@ -12,10 +12,24 @@ import json
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, field
from typing import List
from kb_pipeline.schemas import AnalyzeInput, AnalyzeOutput from kb_pipeline.schemas import AnalyzeInput, AnalyzeOutput
# Mock AnalysisResult for testing
@dataclass
class MockAnalysisResult:
"""Mock AnalysisResult for testing."""
summary: str = "Test summary"
main_topics: List[str] = field(default_factory=lambda: ["Topic 1", "Topic 2"])
key_insights: List[str] = field(default_factory=lambda: ["Insight 1"])
recommended_tags: List[str] = field(default_factory=lambda: ["tag1", "tag2"])
full_analysis: str = "## Summary\n\nTest analysis content"
original_text: str = ""
class TestAnalyzeInputSchema: class TestAnalyzeInputSchema:
"""Tests for AnalyzeInput schema validation.""" """Tests for AnalyzeInput schema validation."""
@@ -156,21 +170,9 @@ class TestAnalyzeProcessorIntegration:
"""Tests for AnalyzeProcessor DeepAnalyzer integration.""" """Tests for AnalyzeProcessor DeepAnalyzer integration."""
@pytest.fixture @pytest.fixture
def mock_deep_analyzer(self): def mock_analysis_result(self):
"""Create a mock DeepAnalyzer result.""" """Create a mock AnalysisResult."""
from dataclasses import dataclass, field return MockAnalysisResult()
from typing import List
@dataclass
class AnalysisResult:
summary: str = "Test summary"
main_topics: List[str] = field(default_factory=lambda: ["Topic 1", "Topic 2"])
key_insights: List[str] = field(default_factory=lambda: ["Insight 1"])
recommended_tags: List[str] = field(default_factory=lambda: ["tag1", "tag2"])
full_analysis: str = "## Summary\n\nTest analysis content"
original_text: str = ""
return AnalysisResult()
@pytest.fixture @pytest.fixture
def mock_state_manager(self): def mock_state_manager(self):
@@ -208,7 +210,7 @@ class TestAnalyzeProcessorIntegration:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analyze_loads_metadata_and_content_from_files( async def test_analyze_loads_metadata_and_content_from_files(
self, mock_deep_analyzer, mock_state_manager, sample_metadata, sample_content, tmp_path self, mock_analysis_result, mock_state_manager, sample_metadata, sample_content, tmp_path
): ):
"""Test 1: AnalyzeProcessor loads metadata and content from files""" """Test 1: AnalyzeProcessor loads metadata and content from files"""
from kb_pipeline.processors.analyze import AnalyzeProcessor from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -227,24 +229,26 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings: with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock() mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer: processor = AnalyzeProcessor(state_manager=mock_state_manager)
mock_analyzer_instance = MagicMock()
mock_analyzer_instance.analyze = MagicMock(return_value=mock_deep_analyzer)
MockDeepAnalyzer.return_value = mock_analyzer_instance
processor = AnalyzeProcessor(state_manager=mock_state_manager) # Mock _run_analysis to return our mock result
result = await processor.process(input_data) async def mock_run_analysis(metadata, content, content_type):
# Verify metadata was loaded correctly
assert "title" in metadata
assert metadata["title"] == "Test Video Title"
# Verify content was loaded correctly
assert "text" in content
return mock_analysis_result
with patch.object(processor, '_run_analysis', side_effect=mock_run_analysis):
with patch.object(processor, '_write_analysis_report'):
result = await processor.process(input_data)
assert result.success is True assert result.success is True
# Verify DeepAnalyzer.analyze was called with loaded data
mock_analyzer_instance.analyze.assert_called_once()
call_args = mock_analyzer_instance.analyze.call_args
assert "title" in call_args[0][0] # metadata dict
assert "text" in call_args[0][1] # content dict
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analyze_calls_deep_analyzer_with_correct_parameters( async def test_analyze_calls_deep_analyzer_with_correct_parameters(
self, mock_deep_analyzer, mock_state_manager, sample_metadata, sample_content, tmp_path self, mock_analysis_result, mock_state_manager, sample_metadata, sample_content, tmp_path
): ):
"""Test 2: AnalyzeProcessor calls DeepAnalyzer.analyze() with correct parameters""" """Test 2: AnalyzeProcessor calls DeepAnalyzer.analyze() with correct parameters"""
from kb_pipeline.processors.analyze import AnalyzeProcessor from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -263,22 +267,28 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings: with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock() mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer: processor = AnalyzeProcessor(state_manager=mock_state_manager)
mock_analyzer_instance = MagicMock()
mock_analyzer_instance.analyze = MagicMock(return_value=mock_deep_analyzer)
MockDeepAnalyzer.return_value = mock_analyzer_instance
processor = AnalyzeProcessor(state_manager=mock_state_manager) # Track what parameters were passed
result = await processor.process(input_data) captured_params = {}
async def mock_run_analysis(metadata, content, content_type):
captured_params['metadata'] = metadata
captured_params['content'] = content
captured_params['content_type'] = content_type
return mock_analysis_result
with patch.object(processor, '_run_analysis', side_effect=mock_run_analysis):
with patch.object(processor, '_write_analysis_report'):
result = await processor.process(input_data)
assert result.success is True assert result.success is True
# Verify content_type was passed correctly # Verify content_type was passed correctly
call_kwargs = mock_analyzer_instance.analyze.call_args assert captured_params['content_type'] == "video"
assert call_kwargs[0][2] == "video" # content_type parameter
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analyze_saves_analysis_report_as_markdown( async def test_analyze_saves_analysis_report_as_markdown(
self, mock_deep_analyzer, mock_state_manager, sample_metadata, sample_content, tmp_path self, mock_analysis_result, mock_state_manager, sample_metadata, sample_content, tmp_path
): ):
"""Test 3: AnalyzeProcessor saves analysis report as Markdown""" """Test 3: AnalyzeProcessor saves analysis report as Markdown"""
from kb_pipeline.processors.analyze import AnalyzeProcessor from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -297,13 +307,19 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings: with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock() mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer: processor = AnalyzeProcessor(state_manager=mock_state_manager)
mock_analyzer_instance = MagicMock()
mock_analyzer_instance.analyze = MagicMock(return_value=mock_deep_analyzer)
MockDeepAnalyzer.return_value = mock_analyzer_instance
processor = AnalyzeProcessor(state_manager=mock_state_manager) async def mock_run_analysis(metadata, content, content_type):
result = await processor.process(input_data) return mock_analysis_result
def mock_write_report(output_path, metadata, result, content_type):
# Write a simple markdown file with frontmatter
with open(output_path, 'w', encoding='utf-8') as f:
f.write("---\ntitle: Test\n---\n\n## Summary\n\nTest analysis content")
with patch.object(processor, '_run_analysis', side_effect=mock_run_analysis):
with patch.object(processor, '_write_analysis_report', side_effect=mock_write_report):
result = await processor.process(input_data)
assert result.success is True assert result.success is True
assert result.analysis_file == output_file assert result.analysis_file == output_file
@@ -319,7 +335,7 @@ class TestAnalyzeProcessorIntegration:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analyze_records_state_on_success( async def test_analyze_records_state_on_success(
self, mock_deep_analyzer, mock_state_manager, sample_metadata, sample_content, tmp_path self, mock_analysis_result, mock_state_manager, sample_metadata, sample_content, tmp_path
): ):
"""Test 4: AnalyzeProcessor records state on success""" """Test 4: AnalyzeProcessor records state on success"""
from kb_pipeline.processors.analyze import AnalyzeProcessor from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -343,13 +359,14 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings: with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock() mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer: processor = AnalyzeProcessor(state_manager=mock_state_manager)
mock_analyzer_instance = MagicMock()
mock_analyzer_instance.analyze = MagicMock(return_value=mock_deep_analyzer)
MockDeepAnalyzer.return_value = mock_analyzer_instance
processor = AnalyzeProcessor(state_manager=mock_state_manager) async def mock_run_analysis(metadata, content, content_type):
result = await processor.process(input_data) return mock_analysis_result
with patch.object(processor, '_run_analysis', side_effect=mock_run_analysis):
with patch.object(processor, '_write_analysis_report'):
result = await processor.process(input_data)
assert result.success is True assert result.success is True
@@ -385,14 +402,12 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings: with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock() mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer: processor = AnalyzeProcessor(state_manager=mock_state_manager)
mock_analyzer_instance = MagicMock()
mock_analyzer_instance.analyze = MagicMock(
side_effect=ValueError("AI API error")
)
MockDeepAnalyzer.return_value = mock_analyzer_instance
processor = AnalyzeProcessor(state_manager=mock_state_manager) async def mock_run_analysis_error(metadata, content, content_type):
raise ValueError("AI API error")
with patch.object(processor, '_run_analysis', side_effect=mock_run_analysis_error):
result = await processor.process(input_data) result = await processor.process(input_data)
assert result.success is False assert result.success is False