docs(05): create phase 5 plans for archive and directory optimization

This commit is contained in:
2026-03-16 23:03:14 +08:00
parent c92ace8e2d
commit a11a2dde99
4 changed files with 1277 additions and 9 deletions
+9 -9
View File
@@ -53,19 +53,19 @@ Plans:
#### Phase 5: 归档与目录优化
**Goal**: 统一中间目录结构,添加自动归档步骤,优化最终目录布局
**Depends on**: Phase 4
**Requirements**: INT-01, INT-02, INT-03, ARC-01, ARC-02, ARC-03, ARC-04, OUT-01, OUT-02, OUT-03, OUT-04
**Requirements**: INT-01, INT-02, ARC-01, ARC-02, ARC-03, ARC-04, OUT-01, OUT-02, OUT-03, OUT-04
**Success Criteria** (what must be TRUE):
1. 所有分析任务的中间文件统一存放在 `~/work/intermediate/analysis/{platform}_{content_id}/`
2. 分析完成后 md 文件自动复制到按平台分组的最终目录
3. 最终目录结构为 `~/kb/{platform}/{timestamp}_{content_id}_analysis.md`
4. 用户可通过配置文件自定义中间目录路径
5. 归档后可选择性清理中间文件
**Plans**: TBD
3. 最终目录结构为 `{KB_ARCHIVE_PATH}/{platform}/{YYYYMMDD}_{content_id}_analysis.md`
4. 归档时检测重复文件,询问用户是否覆盖
5. 不自动清理中间文件(用户手动维护)
**Plans**: 3 plans
Plans:
- [ ] 05-01: 中间目录统一和配置
- [ ] 05-02: ArchiveProcessor 归档处理器
- [ ] 05-03: 最终目录结构优化
- [ ] 05-01-PLAN.md — 中间目录统一 (INT-01, INT-02)
- [ ] 05-02-PLAN.md — ArchiveProcessor 归档处理器 (ARC-01, ARC-02, ARC-03, ARC-04)
- [ ] 05-03-PLAN.md — 最终目录结构验证和测试 (OUT-01, OUT-02, OUT-03, OUT-04)
#### Phase 6: 代码整合
**Goal**: 将独立的元数据技能和 Pipeline 模块整合进 kb-workflow
@@ -93,5 +93,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6
| 2. Adapters | v1.0 | 2/2 | Complete | 2026-03-15 |
| 3. Processors | v1.0 | 3/3 | Complete | 2026-03-15 |
| 4. Pipeline | v1.0 | 2/2 | Complete | 2026-03-15 |
| 5. 归档与目录优化 | v1.1 | 0/3 | Not started | - |
| 5. 归档与目录优化 | v1.1 | 0/3 | Ready to execute | - |
| 6. 代码整合 | v1.1 | 0/2 | Not started | - |
@@ -0,0 +1,171 @@
---
phase: 05-archive-directory
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- kb_pipeline/pipeline.py
- kb-workflow/kb_workflow.py
autonomous: true
requirements:
- INT-01
- INT-02
must_haves:
truths:
- "Pipeline uses ~/work/intermediate/analysis/ as intermediate directory"
- "Content directory follows {platform}_{content_id}/ naming convention"
- "CLI defaults to intermediate directory when no output_dir specified"
artifacts:
- path: "kb_pipeline/pipeline.py"
provides: "Intermediate directory path logic"
contains: "intermediate"
- path: "kb-workflow/kb_workflow.py"
provides: "CLI default output directory"
contains: "intermediate"
key_links:
- from: "kb-workflow/kb_workflow.py"
to: "kb_pipeline/pipeline.py"
via: "output_dir parameter"
pattern: "output_dir.*intermediate"
---
<objective>
统一中间目录结构到 `~/work/intermediate/analysis/`
Purpose: 为所有分析任务提供一致的中间文件存储位置,便于管理和清理
Output: 修改 Pipeline 和 CLI 使用统一的中间目录路径
</objective>
<execution_context>
@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md
@/home/starmen1265/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-archive-directory/05-CONTEXT.md
<interfaces>
<!-- Key interfaces from existing codebase -->
From kb_pipeline/pipeline.py:
```python
class Pipeline:
def __init__(
self,
processors: List[BaseProcessor],
config: Optional[PipelineConfig] = None,
output_dir: Optional[Path] = None,
):
self.output_dir = output_dir or Path.cwd()
def _get_content_dir(self, parsed: ParsedLink) -> Path:
content_dir = self.output_dir / f"{parsed.platform}_{parsed.content_id}"
content_dir.mkdir(parents=True, exist_ok=True)
return content_dir
```
From kb-workflow/kb_workflow.py:
```python
def process_command(
url: str,
output_dir: Optional[str] = None,
skip_completed: bool = True
) -> Dict[str, Any]:
out_dir = Path(output_dir) if output_dir else Path.cwd()
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add intermediate directory constant and update Pipeline</name>
<files>kb_pipeline/pipeline.py</files>
<action>
1. Add module-level constant for intermediate directory:
```python
INTERMEDIATE_DIR = Path.home() / "work" / "intermediate" / "analysis"
```
2. Modify `Pipeline.__init__` to use intermediate directory as default:
- Change `self.output_dir = output_dir or Path.cwd()` to use `INTERMEDIATE_DIR`
- The output_dir parameter still allows override for testing
3. Update `_get_content_dir` to ensure it follows `{platform}_{content_id}/` naming:
- Current implementation already does this: `f"{parsed.platform}_{parsed.content_id}"`
- Verify no changes needed
Key points:
- Hard-code path as `~/work/intermediate/analysis/` (per user decision)
- Do NOT add KB_INTERMEDIATE_PATH config option (user explicitly decided against this)
- Content directory naming stays as `{platform}_{content_id}/`
</action>
<verify>
<automated>python3 -c "from kb_pipeline.pipeline import INTERMEDIATE_DIR; assert str(INTERMEDIATE_DIR) == str(Path.home() / 'work' / 'intermediate' / 'analysis')"</automated>
</verify>
<done>
- INTERMEDIATE_DIR constant defined in pipeline.py
- Pipeline defaults to intermediate directory
- Content directory naming unchanged
</done>
</task>
<task type="auto">
<name>Task 2: Update CLI to use intermediate directory default</name>
<files>kb-workflow/kb_workflow.py</files>
<action>
1. Import INTERMEDIATE_DIR from kb_pipeline.pipeline:
```python
from kb_pipeline.pipeline import Pipeline, INTERMEDIATE_DIR
```
2. Modify `process_command` to use intermediate directory as default:
- Change: `out_dir = Path(output_dir) if output_dir else Path.cwd()`
- To: `out_dir = Path(output_dir) if output_dir else INTERMEDIATE_DIR`
3. Update docstring to document the default output directory behavior
Key points:
- When no -o/--output-dir specified, content goes to ~/work/intermediate/analysis/
- User can still override with -o flag for testing or special cases
</action>
<verify>
<automated>python3 -c "from kb_workflow import process_command; import inspect; src = inspect.getsource(process_command); assert 'INTERMEDIATE_DIR' in src"</automated>
</verify>
<done>
- CLI imports INTERMEDIATE_DIR
- Default output directory is ~/work/intermediate/analysis/
- Help text updated to reflect default
</done>
</task>
</tasks>
<verification>
1. Run existing tests to ensure no regression:
```bash
python3 -m pytest tests/ -v --tb=short
```
2. Verify intermediate directory path is correct:
```bash
python3 -c "from kb_pipeline.pipeline import INTERMEDIATE_DIR; print(INTERMEDIATE_DIR)"
```
Expected: /home/{user}/work/intermediate/analysis
</verification>
<success_criteria>
- INT-01: All analysis tasks use `~/work/intermediate/analysis/` as intermediate directory
- INT-02: Content directory naming is `{platform}_{content_id}/`
- No KB_INTERMEDIATE_PATH config option added (per user decision)
- Existing tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/05-archive-directory/05-01-SUMMARY.md`
</output>
@@ -0,0 +1,504 @@
---
phase: 05-archive-directory
plan: 02
type: execute
wave: 2
depends_on:
- 05-01
files_modified:
- kb_pipeline/schemas.py
- kb_pipeline/processors/__init__.py
- kb_pipeline/processors/archive.py
- kb_pipeline/pipeline.py
- kb-workflow/kb_workflow.py
autonomous: true
requirements:
- ARC-01
- ARC-02
- ARC-03
- ARC-04
must_haves:
truths:
- "ArchiveProcessor is the last step in pipeline"
- "After analysis, md file is copied to archive directory"
- "Only md files are archived, not json files"
- "Archive step status is recorded in workflow_state.json"
artifacts:
- path: "kb_pipeline/processors/archive.py"
provides: "ArchiveProcessor implementation"
contains: "class ArchiveProcessor"
- path: "kb_pipeline/schemas.py"
provides: "ArchiveInput and ArchiveOutput schemas"
contains: "class ArchiveInput"
- path: "kb_pipeline/pipeline.py"
provides: "Pipeline orchestration with archive step"
contains: "archive"
key_links:
- from: "kb_pipeline/pipeline.py"
to: "kb_pipeline/processors/archive.py"
via: "ArchiveProcessor instantiation"
pattern: "ArchiveProcessor"
- from: "ArchiveProcessor"
to: "KBSettings.archive_path"
via: "get_settings()"
pattern: "settings.archive_path"
---
<objective>
创建 ArchiveProcessor 作为管道最后一步,自动归档分析报告到最终目录
Purpose: 分析完成后自动将 md 文件复制到按平台分组的归档目录
Output: ArchiveProcessor 类、ArchiveInput/ArchiveOutput schemas、Pipeline 集成
</objective>
<execution_context>
@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md
@/home/starmen1265/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-archive-directory/05-CONTEXT.md
<interfaces>
<!-- Key interfaces from existing codebase -->
From kb_pipeline/processors/base.py:
```python
class BaseProcessor(ABC):
STEP_NAME: str
def __init__(self, state_manager: Optional[WorkflowStateManager] = None):
self.settings = get_settings()
self.state_manager = state_manager
@abstractmethod
async def process(self, input_data: StepInput) -> StepOutput:
pass
```
From kb_pipeline/config.py:
```python
class KBSettings(BaseSettings):
archive_path: str = Field(..., description="归档目录")
```
From kb_pipeline/schemas.py:
```python
class AnalyzeOutput(StepOutput):
analysis_file: str = Field(..., description="Path to generated analysis report")
# ... other fields
```
From kb_pipeline/pipeline.py:
```python
class Pipeline:
def __init__(self, processors: List[BaseProcessor], config: Optional[PipelineConfig] = None, output_dir: Optional[Path] = None):
self.processors: Dict[str, BaseProcessor] = {p.STEP_NAME: p for p in processors}
async def run(self, url: str) -> PipelineOutput:
for step_name in self.config.steps:
# ... step execution
```
From rich.prompt (existing pattern):
```python
try:
from rich.console import Console
from rich.prompt import Confirm
return Confirm.ask("Question?", default=False)
except ImportError:
response = input("Question? [y/N] ")
return response.lower() in ('y', 'yes')
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ArchiveInput and ArchiveOutput schemas</name>
<files>kb_pipeline/schemas.py</files>
<action>
Add new schema classes at the end of the schemas file:
```python
# =============================================================================
# Archive Schemas (Phase 5)
# =============================================================================
class ArchiveInput(StepInput):
"""Archive 步骤输入
继承 StepInput 的基础字段,添加归档特定字段。
Attributes:
content_dir: 内容目录路径(中间目录)
analysis_file: 分析报告 md 文件路径
content_type: 内容类型
"""
content_dir: str = Field(..., min_length=1, description="Content directory path (intermediate)")
analysis_file: str = Field(..., min_length=1, description="Path to analysis Markdown file")
content_type: str = Field(..., min_length=1, description="Content type (video, article, etc.)")
class ArchiveOutput(StepOutput):
"""Archive 步骤输出
继承 StepOutput 的基础字段,添加归档特定字段。
Attributes:
archive_file: 归档后的文件路径
skipped: 是否跳过(已有相同文件)
platform: 平台名称
"""
archive_file: str = Field(default="", description="Path to archived file")
skipped: bool = Field(default=False, description="Whether archiving was skipped")
platform: str = Field(default="", description="Platform name")
```
Follow existing schema patterns:
- Inherit from StepInput/StepOutput
- Use Field() with descriptions
- Include min_length validation for required strings
</action>
<verify>
<automated>python3 -c "from kb_pipeline.schemas import ArchiveInput, ArchiveOutput; print('OK')"</automated>
</verify>
<done>
- ArchiveInput class defined with content_dir, analysis_file, content_type fields
- ArchiveOutput class defined with archive_file, skipped, platform fields
- Both inherit from base StepInput/StepOutput
</done>
</task>
<task type="auto">
<name>Task 2: Implement ArchiveProcessor</name>
<files>kb_pipeline/processors/archive.py, kb_pipeline/processors/__init__.py</files>
<action>
Create new file `kb_pipeline/processors/archive.py`:
```python
"""ArchiveProcessor - Copy analysis reports to archive directory
Final step in pipeline that copies the generated analysis.md
to the archive directory organized by platform.
"""
from __future__ import annotations
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional
from ..config import get_settings
from ..schemas import ArchiveInput, ArchiveOutput
from ..state import WorkflowStateManager
from .base import BaseProcessor
logger = logging.getLogger(__name__)
class ArchiveProcessor(BaseProcessor):
"""Archive 处理器 - 归档分析报告
处理流程:
1. 检查步骤是否已完成(幂等执行)
2. 检查归档目录是否存在相同 content_id 的文件
3. 如果存在重复,询问用户是否覆盖
4. 复制 md 文件到归档目录
5. 记录处理状态到 workflow_state.json
归档目录结构:
{KB_ARCHIVE_PATH}/{platform}/{YYYYMMDD}_{content_id}_analysis.md
"""
STEP_NAME = "archive"
async def process(self, input_data: ArchiveInput) -> ArchiveOutput:
"""执行归档处理
Args:
input_data: Archive 输入数据
Returns:
Archive 处理结果
"""
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")
return ArchiveOutput(
success=True,
skipped=True,
platform=input_data.platform,
archive_file=""
)
# Step 2: 准备归档路径
archive_base = Path(self.settings.archive_path)
platform_dir = archive_base / input_data.platform
platform_dir.mkdir(parents=True, exist_ok=True)
# 文件命名: {YYYYMMDD}_{content_id}_analysis.md
timestamp = datetime.now().strftime("%Y%m%d")
archive_filename = f"{timestamp}_{input_data.content_id}_analysis.md"
archive_path = platform_dir / archive_filename
# Step 3: 检查重复
existing_file = self._find_existing_archive(
platform_dir, input_data.content_id
)
if existing_file:
should_overwrite = self._ask_overwrite(existing_file)
if not should_overwrite:
logger.info(f"User chose not to overwrite, skipping archive")
return ArchiveOutput(
success=True,
skipped=True,
platform=input_data.platform,
archive_file=str(existing_file)
)
# 删除旧文件
existing_file.unlink()
logger.info(f"Removed existing archive: {existing_file}")
# Step 4: 复制文件
source_path = Path(input_data.analysis_file)
if not source_path.exists():
raise FileNotFoundError(f"Analysis file not found: {source_path}")
shutil.copy2(source_path, archive_path)
logger.info(f"Archived: {source_path} -> {archive_path}")
# Step 5: 记录成功状态
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_completed(
self.STEP_NAME,
f"archived to {archive_path}"
)
self.state_manager.save(state)
return ArchiveOutput(
success=True,
skipped=False,
platform=input_data.platform,
archive_file=str(archive_path)
)
except Exception as e:
logger.error(f"Archive 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 ArchiveOutput(
success=False,
error_message=str(e),
platform=input_data.platform,
archive_file=""
)
def _find_existing_archive(self, platform_dir: Path, content_id: str) -> Optional[Path]:
"""查找已存在的归档文件
使用 glob 模式 `*_{content_id}_analysis.md` 搜索
Args:
platform_dir: 平台目录路径
content_id: 内容 ID
Returns:
已存在的文件路径,如果不存在返回 None
"""
pattern = f"*_{content_id}_analysis.md"
matches = list(platform_dir.glob(pattern))
return matches[0] if matches else None
def _ask_overwrite(self, existing_file: Path) -> bool:
"""询问用户是否覆盖已存在的文件
使用 rich.prompt.Confirm 模式(与 Pipeline._ask_rerun_step 相同)
Args:
existing_file: 已存在的文件路径
Returns:
True 如果用户选择覆盖
"""
try:
from rich.console import Console
from rich.prompt import Confirm
console = Console()
return Confirm.ask(
f"Archive already exists: {existing_file.name}\nOverwrite?",
default=False
)
except ImportError:
response = input(
f"Archive already exists: {existing_file.name}\nOverwrite? [y/N] "
)
return response.lower() in ('y', 'yes')
```
Update `kb_pipeline/processors/__init__.py` to export ArchiveProcessor:
```python
from .archive import ArchiveProcessor
```
Key points:
- Follow BaseProcessor pattern exactly
- Use settings.archive_path for archive base directory
- Only copy md files (not json)
- Glob pattern for duplicate detection
- Use rich.prompt.Confirm with input() fallback
</action>
<verify>
<automated>python3 -c "from kb_pipeline.processors import ArchiveProcessor; p = ArchiveProcessor(); assert p.STEP_NAME == 'archive'"</automated>
</verify>
<done>
- ArchiveProcessor class created with STEP_NAME = "archive"
- ArchiveInput/ArchiveOutput schemas imported
- _find_existing_archive uses glob pattern
- _ask_overwrite uses rich.prompt with fallback
- Export added to __init__.py
</done>
</task>
<task type="auto">
<name>Task 3: Integrate ArchiveProcessor into Pipeline</name>
<files>kb_pipeline/pipeline.py, kb-workflow/kb_workflow.py</files>
<action>
1. Update `kb_pipeline/pipeline.py`:
a. Update default steps in PipelineConfig to include "archive":
- Find where PipelineConfig is referenced
- The default steps are defined in PipelineConfig.steps in schemas.py
- We need to update schemas.py default_factory
b. Add archive step input creation in Pipeline.run():
```python
elif step_name == "archive":
# Archive needs analysis file from analyze step
analyze_result = results.get("analyze", {})
analysis_file = analyze_result.get("analysis_file", "")
step_input = ArchiveInput(
content_id=parsed.content_id,
platform=parsed.platform,
content_dir=str(content_dir),
analysis_file=analysis_file,
content_type=parsed.content_type,
)
```
c. Import ArchiveInput at top of file:
```python
from kb_pipeline.schemas import (
ParseInput, DownloadInput, ASRInput, AnalyzeInput, ArchiveInput
)
```
2. Update `kb_pipeline/schemas.py` PipelineConfig default steps:
```python
steps: List[str] = Field(
default_factory=lambda: ["parse", "download", "asr", "analyze", "archive"],
description="Steps to execute in order"
)
```
3. Update `kb-workflow/kb_workflow.py`:
a. Import ArchiveProcessor:
```python
from kb_pipeline.processors import (
ASRProcessor, AnalyzeProcessor, ArchiveProcessor,
DownloadProcessor, ParseProcessor
)
```
b. Add ArchiveProcessor to processors list:
```python
processors = [
ParseProcessor(),
DownloadProcessor(),
ASRProcessor(),
AnalyzeProcessor(),
ArchiveProcessor(), # Add this
]
```
c. Update config steps to include "archive":
```python
config = PipelineConfig(
steps=["parse", "download", "asr", "analyze", "archive"],
# ...
)
```
Key points:
- Archive is always the last step
- Archive step needs analysis_file from analyze step
- Pipeline must create ArchiveInput properly
</action>
<verify>
<automated>python3 -c "from kb_pipeline.schemas import PipelineConfig; c = PipelineConfig(); assert 'archive' in c.steps"</automated>
</verify>
<done>
- ArchiveProcessor added to processors list in CLI
- PipelineConfig default steps include "archive"
- Pipeline.run() handles ArchiveInput creation
- ArchiveInput imported in pipeline.py
</done>
</task>
</tasks>
<verification>
1. Verify ArchiveProcessor can be imported:
```bash
python3 -c "from kb_pipeline.processors import ArchiveProcessor; print('OK')"
```
2. Verify schemas:
```bash
python3 -c "from kb_pipeline.schemas import ArchiveInput, ArchiveOutput; print('OK')"
```
3. Verify PipelineConfig includes archive step:
```bash
python3 -c "from kb_pipeline.schemas import PipelineConfig; print(PipelineConfig().steps)"
```
Expected: ['parse', 'download', 'asr', 'analyze', 'archive']
4. Run existing tests:
```bash
python3 -m pytest tests/ -v --tb=short -k "not integration"
```
</verification>
<success_criteria>
- ARC-01: ArchiveProcessor is the last step in pipeline
- ARC-02: Analysis completion triggers automatic archiving
- ARC-03: Only md files are copied (not json)
- ARC-04: No automatic cleanup of intermediate files (user handles manually)
- Archive step status recorded in workflow_state.json
- Duplicate detection asks user for overwrite confirmation
</success_criteria>
<output>
After completion, create `.planning/phases/05-archive-directory/05-02-SUMMARY.md`
</output>
@@ -0,0 +1,593 @@
---
phase: 05-archive-directory
plan: 03
type: execute
wave: 3
depends_on:
- 05-02
files_modified:
- tests/test_pipeline.py
- tests/test_processors.py
autonomous: true
requirements:
- OUT-01
- OUT-02
- OUT-03
- OUT-04
must_haves:
truths:
- "Archive files are organized by platform (bilibili/, zhihu/)"
- "Archive files have timestamp prefix: {YYYYMMDD}_{content_id}_analysis.md"
- "No date-based subdirectories in archive"
- "Only md files in archive, no json files"
artifacts:
- path: "tests/test_pipeline.py"
provides: "Integration test for archive step"
contains: "test_archive"
- path: "tests/test_processors.py"
provides: "Unit tests for ArchiveProcessor"
contains: "ArchiveProcessor"
key_links:
- from: "ArchiveProcessor"
to: "{KB_ARCHIVE_PATH}/{platform}/"
via: "settings.archive_path / platform"
pattern: "platform_dir"
---
<objective>
验证最终目录结构和端到端归档流程
Purpose: 通过测试验证归档目录结构符合需求,确保只归档 md 文件
Output: ArchiveProcessor 单元测试和 Pipeline 集成测试
</objective>
<execution_context>
@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md
@/home/starmen1265/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-archive-directory/05-CONTEXT.md
<interfaces>
<!-- Key interfaces from existing codebase -->
From tests/test_processors.py (existing pattern):
```python
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
@pytest.mark.asyncio
async def test_processor_success():
# ... test implementation
```
From kb_pipeline/processors/archive.py (created in 05-02):
```python
class ArchiveProcessor(BaseProcessor):
STEP_NAME = "archive"
async def process(self, input_data: ArchiveInput) -> ArchiveOutput:
# ...
def _find_existing_archive(self, platform_dir: Path, content_id: str) -> Optional[Path]:
# ...
def _ask_overwrite(self, existing_file: Path) -> bool:
# ...
```
Expected archive structure:
```
{KB_ARCHIVE_PATH}/
├── bilibili/
│ ├── 20260316_BV1xxx_analysis.md
│ └── 20260316_BV1yyy_analysis.md
└── zhihu/
└── 20260316_123456_analysis.md
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add ArchiveProcessor unit tests</name>
<files>tests/test_processors.py</files>
<behavior>
- Test 1: ArchiveProcessor copies md file to correct platform directory
- Test 2: ArchiveProcessor uses correct filename format {YYYYMMDD}_{content_id}_analysis.md
- Test 3: ArchiveProcessor skips when step already completed
- Test 4: ArchiveProcessor detects existing archive by content_id
- Test 5: ArchiveProcessor handles missing analysis file with error
</behavior>
<action>
Add the following tests to `tests/test_processors.py`:
```python
# =============================================================================
# ArchiveProcessor Tests
# =============================================================================
import tempfile
import shutil
from datetime import datetime
from pathlib import Path
from unittest.mock import patch, MagicMock
from kb_pipeline.processors.archive import ArchiveProcessor
from kb_pipeline.schemas import ArchiveInput, ArchiveOutput
from kb_pipeline.state import WorkflowStateManager
@pytest.fixture
def archive_test_env(tmp_path):
"""Create test environment for archive tests"""
# Create intermediate content directory
content_dir = tmp_path / "intermediate" / "bilibili_BV1test"
content_dir.mkdir(parents=True)
# Create analysis file
analysis_file = content_dir / "analysis.md"
analysis_file.write_text("# Test Analysis\n\nContent here.")
# Create archive directory
archive_dir = tmp_path / "archive"
archive_dir.mkdir(parents=True)
# Create state manager
state_manager = WorkflowStateManager(content_dir)
return {
"content_dir": content_dir,
"analysis_file": analysis_file,
"archive_dir": archive_dir,
"state_manager": state_manager,
}
@pytest.mark.asyncio
async def test_archive_processor_copies_to_platform_dir(archive_test_env, monkeypatch):
"""Test that archive file is copied to correct platform directory"""
# Mock settings
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_test_env["archive_dir"])
with patch.object(ArchiveProcessor, '__init__', lambda self, sm: None):
processor = ArchiveProcessor()
processor.settings = mock_settings
processor.state_manager = None
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(archive_test_env["content_dir"]),
analysis_file=str(archive_test_env["analysis_file"]),
content_type="video"
)
result = await processor.process(input_data)
assert result.success is True
assert result.skipped is False
assert "bilibili" in result.archive_file
assert result.archive_file.endswith("_analysis.md")
@pytest.mark.asyncio
async def test_archive_processor_filename_format(archive_test_env, monkeypatch):
"""Test that archive filename follows {YYYYMMDD}_{content_id}_analysis.md format"""
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_test_env["archive_dir"])
with patch.object(ArchiveProcessor, '__init__', lambda self, sm: None):
processor = ArchiveProcessor()
processor.settings = mock_settings
processor.state_manager = None
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(archive_test_env["content_dir"]),
analysis_file=str(archive_test_env["analysis_file"]),
content_type="video"
)
result = await processor.process(input_data)
# Check filename format
archive_path = Path(result.archive_file)
filename = archive_path.name
today = datetime.now().strftime("%Y%m%d")
expected = f"{today}_BV1test_analysis.md"
assert filename == expected
@pytest.mark.asyncio
async def test_archive_processor_skips_completed(archive_test_env, monkeypatch):
"""Test that archive step is skipped when already completed"""
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_test_env["archive_dir"])
# Create state with archive already completed
state = archive_test_env["state_manager"].get_or_create(
content_id="BV1test",
platform="bilibili",
source_url="https://test.com"
)
state.mark_step_completed("archive")
archive_test_env["state_manager"].save(state)
with patch.object(ArchiveProcessor, '__init__', lambda self, sm: None):
processor = ArchiveProcessor()
processor.settings = mock_settings
processor.state_manager = archive_test_env["state_manager"]
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(archive_test_env["content_dir"]),
analysis_file=str(archive_test_env["analysis_file"]),
content_type="video"
)
result = await processor.process(input_data)
assert result.success is True
assert result.skipped is True
@pytest.mark.asyncio
async def test_archive_processor_detects_existing(archive_test_env, monkeypatch):
"""Test that archive processor detects existing archive by content_id"""
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_test_env["archive_dir"])
# Create existing archive with different timestamp
platform_dir = archive_test_env["archive_dir"] / "bilibili"
platform_dir.mkdir(parents=True, exist_ok=True)
existing = platform_dir / "20260101_BV1test_analysis.md"
existing.write_text("Old content")
with patch.object(ArchiveProcessor, '__init__', lambda self, sm: None):
processor = ArchiveProcessor()
processor.settings = mock_settings
processor.state_manager = None
processor._ask_overwrite = lambda f: False # User says no
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(archive_test_env["content_dir"]),
analysis_file=str(archive_test_env["analysis_file"]),
content_type="video"
)
result = await processor.process(input_data)
assert result.success is True
assert result.skipped is True
@pytest.mark.asyncio
async def test_archive_processor_handles_missing_file(archive_test_env, monkeypatch):
"""Test that archive processor handles missing analysis file"""
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_test_env["archive_dir"])
with patch.object(ArchiveProcessor, '__init__', lambda self, sm: None):
processor = ArchiveProcessor()
processor.settings = mock_settings
processor.state_manager = None
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(archive_test_env["content_dir"]),
analysis_file="/nonexistent/analysis.md", # Missing file
content_type="video"
)
result = await processor.process(input_data)
assert result.success is False
assert "not found" in result.error_message.lower()
```
Run tests to verify:
```bash
python3 -m pytest tests/test_processors.py -v -k "archive"
```
</action>
<verify>
<automated>python3 -m pytest tests/test_processors.py -v -k "archive" --tb=short</automated>
</verify>
<done>
- 5 unit tests for ArchiveProcessor pass
- Tests cover: copy, filename format, skip completed, detect existing, missing file
- All tests use tmp_path for isolation
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add Pipeline integration test for archive step</name>
<files>tests/test_pipeline.py</files>
<behavior>
- Test 1: Full pipeline with archive step copies file to archive directory
- Test 2: Archive directory structure is {archive_path}/{platform}/
- Test 3: Archive filename format is {YYYYMMDD}_{content_id}_analysis.md
</behavior>
<action>
Add the following tests to `tests/test_pipeline.py`:
```python
# =============================================================================
# Archive Integration Tests
# =============================================================================
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch, MagicMock
from kb_pipeline.pipeline import Pipeline, INTERMEDIATE_DIR
from kb_pipeline.schemas import PipelineConfig
from kb_pipeline.processors import (
ParseProcessor, DownloadProcessor, ASRProcessor,
AnalyzeProcessor, ArchiveProcessor
)
@pytest.fixture
def mock_processors_with_archive():
"""Create mock processors including archive"""
processors = []
for step_name in ["parse", "download", "asr", "analyze", "archive"]:
processor = MagicMock()
processor.STEP_NAME = step_name
processor.process = AsyncMock()
if step_name == "parse":
processor.process.return_value = MagicMock(
success=True,
platform="bilibili",
content_type="video",
content_id="BV1test",
resolved_url="https://www.bilibili.com/video/BV1test",
original_url="https://www.bilibili.com/video/BV1test"
)
elif step_name == "download":
processor.process.return_value = MagicMock(
success=True,
metadata_file="/tmp/metadata.json",
content_file="/tmp/content.json",
title="Test Video",
author="Test Author"
)
elif step_name == "asr":
processor.process.return_value = MagicMock(
success=True,
tool="whisper",
has_subtitle=False,
output_files={"json": "/tmp/subtitle.json"}
)
elif step_name == "analyze":
processor.process.return_value = MagicMock(
success=True,
analysis_file="/tmp/analysis.md",
summary="Test summary",
main_topics=["topic1"],
key_insights=["insight1"],
tags=["tag1"],
stats={}
)
elif step_name == "archive":
processor.process.return_value = MagicMock(
success=True,
skipped=False,
platform="bilibili",
archive_file="/archive/bilibili/20260316_BV1test_analysis.md"
)
processors.append(processor)
return processors
@pytest.mark.asyncio
async def test_pipeline_includes_archive_step(mock_processors_with_archive, tmp_path):
"""Test that pipeline includes archive as final step"""
config = PipelineConfig(
steps=["parse", "download", "asr", "analyze", "archive"],
output_dir=tmp_path,
skip_completed=False
)
pipeline = Pipeline(
processors=mock_processors_with_archive,
config=config,
output_dir=tmp_path
)
# Mock _parse_url and _get_content_dir
with patch.object(pipeline, '_parse_url') as mock_parse, \
patch.object(pipeline, '_get_content_dir') as mock_dir:
from kb_pipeline.link_parser import ParsedLink
mock_parse.return_value = ParsedLink(
platform="bilibili",
content_type="video",
content_id="BV1test",
original_url="https://www.bilibili.com/video/BV1test",
resolved_url="https://www.bilibili.com/video/BV1test"
)
mock_dir.return_value = tmp_path / "bilibili_BV1test"
result = await pipeline.run("https://www.bilibili.com/video/BV1test")
assert result.success is True
# Verify archive processor was called
archive_processor = next(p for p in mock_processors_with_archive if p.STEP_NAME == "archive")
archive_processor.process.assert_called_once()
@pytest.mark.asyncio
async def test_archive_uses_platform_directory(tmp_path):
"""Test that archive files go to {archive_path}/{platform}/ directory"""
from kb_pipeline.processors.archive import ArchiveProcessor
from kb_pipeline.schemas import ArchiveInput
# Setup
archive_dir = tmp_path / "archive"
content_dir = tmp_path / "intermediate" / "bilibili_BV1test"
content_dir.mkdir(parents=True)
analysis_file = content_dir / "analysis.md"
analysis_file.write_text("# Test")
# Mock settings
with patch('kb_pipeline.processors.archive.get_settings') as mock_get_settings:
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_dir)
mock_get_settings.return_value = mock_settings
processor = ArchiveProcessor()
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(content_dir),
analysis_file=str(analysis_file),
content_type="video"
)
result = await processor.process(input_data)
assert result.success is True
# Verify platform subdirectory
archive_path = Path(result.archive_file)
assert archive_path.parent.name == "bilibili"
@pytest.mark.asyncio
async def test_archive_no_date_subdirectories(tmp_path):
"""Test that archive does not create date-based subdirectories"""
from kb_pipeline.processors.archive import ArchiveProcessor
from kb_pipeline.schemas import ArchiveInput
archive_dir = tmp_path / "archive"
content_dir = tmp_path / "intermediate" / "bilibili_BV1test"
content_dir.mkdir(parents=True)
analysis_file = content_dir / "analysis.md"
analysis_file.write_text("# Test")
with patch('kb_pipeline.processors.archive.get_settings') as mock_get_settings:
mock_settings = MagicMock()
mock_settings.archive_path = str(archive_dir)
mock_get_settings.return_value = mock_settings
processor = ArchiveProcessor()
input_data = ArchiveInput(
content_id="BV1test",
platform="bilibili",
content_dir=str(content_dir),
analysis_file=str(analysis_file),
content_type="video"
)
result = await processor.process(input_data)
# Verify no date-based subdirectories
archive_path = Path(result.archive_file)
# Parent should be platform dir, grandparent should be archive base
assert archive_path.parent.parent == archive_dir
# Check that parent is just "bilibili", not a date
assert archive_path.parent.name == "bilibili"
```
Run tests:
```bash
python3 -m pytest tests/test_pipeline.py -v -k "archive" --tb=short
```
</action>
<verify>
<automated>python3 -m pytest tests/test_pipeline.py -v -k "archive" --tb=short</automated>
</verify>
<done>
- 3 integration tests for archive step pass
- Tests verify: archive included in pipeline, platform directory, no date subdirs
- All tests use tmp_path for isolation
</done>
</task>
<task type="auto">
<name>Task 3: Run full test suite and verify</name>
<files>tests/test_pipeline.py, tests/test_processors.py</files>
<action>
Run the complete test suite to verify all changes work together:
```bash
# Run all tests
python3 -m pytest tests/ -v --tb=short
# Specifically verify archive-related tests
python3 -m pytest tests/ -v -k "archive" --tb=short
```
Verify the following:
1. All existing tests still pass (no regression)
2. New archive tests pass
3. ArchiveProcessor can be imported and instantiated
4. PipelineConfig default steps include "archive"
If any tests fail, fix the issues before marking complete.
</action>
<verify>
<automated>python3 -m pytest tests/ -v --tb=short -x</automated>
</verify>
<done>
- All tests pass (existing + new)
- No regressions introduced
- Archive functionality fully tested
</done>
</task>
</tasks>
<verification>
1. Run full test suite:
```bash
python3 -m pytest tests/ -v --tb=short
```
2. Verify archive-specific tests:
```bash
python3 -m pytest tests/ -v -k "archive" --tb=short
```
3. Manual verification of archive structure:
```bash
python3 -c "
from pathlib import Path
from kb_pipeline.processors.archive import ArchiveProcessor
# Archive structure should be: {archive_path}/{platform}/{timestamp}_{id}_analysis.md
print('ArchiveProcessor loaded successfully')
"
```
</verification>
<success_criteria>
- OUT-01: Archive files organized by platform (bilibili/, zhihu/)
- OUT-02: No date-based subdirectories in archive
- OUT-03: Filename format is {YYYYMMDD}_{content_id}_analysis.md
- OUT-04: Only md files archived (json files stay in intermediate)
- All tests pass
- No regressions in existing functionality
</success_criteria>
<output>
After completion, create `.planning/phases/05-archive-directory/05-03-SUMMARY.md`
</output>