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 unittest.mock import AsyncMock, MagicMock, patch
from pathlib import Path
from dataclasses import dataclass, field
from typing import List
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:
"""Tests for AnalyzeInput schema validation."""
@@ -156,21 +170,9 @@ class TestAnalyzeProcessorIntegration:
"""Tests for AnalyzeProcessor DeepAnalyzer integration."""
@pytest.fixture
def mock_deep_analyzer(self):
"""Create a mock DeepAnalyzer result."""
from dataclasses import dataclass, field
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()
def mock_analysis_result(self):
"""Create a mock AnalysisResult."""
return MockAnalysisResult()
@pytest.fixture
def mock_state_manager(self):
@@ -208,7 +210,7 @@ class TestAnalyzeProcessorIntegration:
@pytest.mark.asyncio
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"""
from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -227,24 +229,26 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer:
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)
processor = AnalyzeProcessor(state_manager=mock_state_manager)
result = await processor.process(input_data)
# Mock _run_analysis to return our mock result
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
# 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
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"""
from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -263,22 +267,28 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer:
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)
processor = AnalyzeProcessor(state_manager=mock_state_manager)
result = await processor.process(input_data)
# Track what parameters were passed
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
# Verify content_type was passed correctly
call_kwargs = mock_analyzer_instance.analyze.call_args
assert call_kwargs[0][2] == "video" # content_type parameter
assert captured_params['content_type'] == "video"
@pytest.mark.asyncio
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"""
from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -297,13 +307,19 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer:
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)
processor = AnalyzeProcessor(state_manager=mock_state_manager)
result = await processor.process(input_data)
async def mock_run_analysis(metadata, content, content_type):
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.analysis_file == output_file
@@ -319,7 +335,7 @@ class TestAnalyzeProcessorIntegration:
@pytest.mark.asyncio
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"""
from kb_pipeline.processors.analyze import AnalyzeProcessor
@@ -343,13 +359,14 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer:
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)
processor = AnalyzeProcessor(state_manager=mock_state_manager)
result = await processor.process(input_data)
async def mock_run_analysis(metadata, content, 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
@@ -385,14 +402,12 @@ class TestAnalyzeProcessorIntegration:
with patch("kb_pipeline.processors.base.get_settings") as mock_settings:
mock_settings.return_value = MagicMock()
with patch("kb_pipeline.processors.analyze.DeepAnalyzer") as MockDeepAnalyzer:
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)
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)
assert result.success is False