557 lines
19 KiB
Python
557 lines
19 KiB
Python
|
|
"""
|
||
|
|
Fanfic Writer v2.0 - Workspace Manager
|
||
|
|
Handles workspace creation, run initialization, and directory management
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import json
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Dict, Any, Optional, Tuple, List
|
||
|
|
from .utils import (
|
||
|
|
generate_run_id, generate_book_uid, to_slug, sanitize_filename,
|
||
|
|
get_workspace_root, get_run_dir, create_directory_structure,
|
||
|
|
get_timestamp_iso, validate_path_in_workspace, validate_run_id_consistency
|
||
|
|
)
|
||
|
|
from .atomic_io import atomic_write_json, atomic_append_jsonl
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Workspace Manager
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
class WorkspaceManager:
|
||
|
|
"""
|
||
|
|
Manages novel workspace lifecycle:
|
||
|
|
- Create new book workspace
|
||
|
|
- Initialize new run
|
||
|
|
- Validate workspace integrity
|
||
|
|
- Handle resume/continue
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, base_dir: Path):
|
||
|
|
"""
|
||
|
|
Initialize WorkspaceManager
|
||
|
|
|
||
|
|
Args:
|
||
|
|
base_dir: Base directory for all novels (e.g., ~/.openclaw/novels)
|
||
|
|
"""
|
||
|
|
self.base_dir = Path(base_dir)
|
||
|
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
def create_new_book(
|
||
|
|
self,
|
||
|
|
book_title: str,
|
||
|
|
genre: str,
|
||
|
|
target_words: int,
|
||
|
|
**kwargs
|
||
|
|
) -> Tuple[Path, str, str, Dict[str, Any]]:
|
||
|
|
"""
|
||
|
|
Create a new book workspace with initial run
|
||
|
|
|
||
|
|
Args:
|
||
|
|
book_title: Book title (can be Chinese)
|
||
|
|
genre: Genre (e.g., "都市灵异")
|
||
|
|
target_words: Target word count (<= 500000)
|
||
|
|
**kwargs: Additional config options
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple of (run_dir, book_uid, run_id, paths_dict)
|
||
|
|
"""
|
||
|
|
# Generate IDs
|
||
|
|
book_uid = generate_book_uid(book_title)
|
||
|
|
title_slug = to_slug(book_title)
|
||
|
|
run_id = generate_run_id()
|
||
|
|
|
||
|
|
# Create workspace structure
|
||
|
|
workspace_root = get_workspace_root(self.base_dir, title_slug, book_uid)
|
||
|
|
run_dir = get_run_dir(workspace_root, run_id)
|
||
|
|
|
||
|
|
# Validate no collision
|
||
|
|
if run_dir.exists():
|
||
|
|
raise RuntimeError(f"Run directory already exists: {run_dir}")
|
||
|
|
|
||
|
|
# Create directory structure
|
||
|
|
paths = create_directory_structure(run_dir, book_title)
|
||
|
|
|
||
|
|
# Generate initial config
|
||
|
|
config = self._generate_initial_config(
|
||
|
|
book_title=book_title,
|
||
|
|
title_slug=title_slug,
|
||
|
|
book_uid=book_uid,
|
||
|
|
run_id=run_id,
|
||
|
|
genre=genre,
|
||
|
|
target_words=min(target_words, 500000), # Hard limit
|
||
|
|
**kwargs
|
||
|
|
)
|
||
|
|
|
||
|
|
# Write config atomically
|
||
|
|
if not atomic_write_json(paths['book_config'], config):
|
||
|
|
raise RuntimeError("Failed to write initial config")
|
||
|
|
|
||
|
|
# Create lock file
|
||
|
|
lock_data = {
|
||
|
|
'run_id': run_id,
|
||
|
|
'book_uid': book_uid,
|
||
|
|
'title_slug': title_slug,
|
||
|
|
'start_ts': get_timestamp_iso(),
|
||
|
|
'pid': os.getpid(),
|
||
|
|
'host': os.environ.get('COMPUTERNAME', 'unknown'),
|
||
|
|
'mode': kwargs.get('mode', 'manual')
|
||
|
|
}
|
||
|
|
lock_path = run_dir / ".lock.json"
|
||
|
|
atomic_write_json(lock_path, lock_data)
|
||
|
|
|
||
|
|
# Create initial writing state
|
||
|
|
writing_state = self._generate_initial_writing_state(
|
||
|
|
book_title, run_id, kwargs.get('mode', 'manual')
|
||
|
|
)
|
||
|
|
atomic_write_json(paths['writing_state'], writing_state)
|
||
|
|
|
||
|
|
# Create empty log files
|
||
|
|
(paths['logs_dir'] / ".gitkeep").touch()
|
||
|
|
(paths['logs_prompts'] / ".gitkeep").touch()
|
||
|
|
|
||
|
|
return run_dir, book_uid, run_id, paths
|
||
|
|
|
||
|
|
def _generate_initial_config(
|
||
|
|
self,
|
||
|
|
book_title: str,
|
||
|
|
title_slug: str,
|
||
|
|
book_uid: str,
|
||
|
|
run_id: str,
|
||
|
|
genre: str,
|
||
|
|
target_words: int,
|
||
|
|
**kwargs
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""Generate initial 0-book-config.json"""
|
||
|
|
|
||
|
|
chapter_target = kwargs.get('chapter_target_words', 2500)
|
||
|
|
if chapter_target < 1500:
|
||
|
|
chapter_target = 1500
|
||
|
|
elif chapter_target > 8000:
|
||
|
|
chapter_target = 8000
|
||
|
|
|
||
|
|
return {
|
||
|
|
'version': '2.0.0',
|
||
|
|
'book': {
|
||
|
|
'title': book_title,
|
||
|
|
'title_slug': title_slug,
|
||
|
|
'book_uid': book_uid,
|
||
|
|
'subtitle': kwargs.get('subtitle', None),
|
||
|
|
'genre': genre,
|
||
|
|
'subgenre': kwargs.get('subgenre', None),
|
||
|
|
'target_word_count': min(target_words, 500000),
|
||
|
|
'chapter_target_words': chapter_target,
|
||
|
|
'language': kwargs.get('language', 'zh'),
|
||
|
|
'rating': kwargs.get('rating', 'PG-13'),
|
||
|
|
'tone': kwargs.get('tone', '轻松')
|
||
|
|
},
|
||
|
|
'generation': {
|
||
|
|
'model': kwargs.get('model', 'nvidia/moonshotai/kimi-k2.5'),
|
||
|
|
'temperature_outline': kwargs.get('temperature_outline', 0.8),
|
||
|
|
'temperature_chapter': kwargs.get('temperature_chapter', 0.75),
|
||
|
|
'max_attempts': kwargs.get('max_attempts', 3),
|
||
|
|
'mode': kwargs.get('mode', 'manual'),
|
||
|
|
'auto_threshold': kwargs.get('auto_threshold', 85),
|
||
|
|
'auto_rescue_enabled': kwargs.get('auto_rescue_enabled', True),
|
||
|
|
'auto_rescue_max_rounds': kwargs.get('auto_rescue_max_rounds', 3)
|
||
|
|
},
|
||
|
|
'qc': {
|
||
|
|
'enabled': True,
|
||
|
|
'dimensions': [
|
||
|
|
'outline_adherence',
|
||
|
|
'main_plot',
|
||
|
|
'character',
|
||
|
|
'logic',
|
||
|
|
'continuity',
|
||
|
|
'pacing',
|
||
|
|
'style'
|
||
|
|
],
|
||
|
|
'weights': {
|
||
|
|
'outline_adherence': 20,
|
||
|
|
'main_plot': 15,
|
||
|
|
'character': 15,
|
||
|
|
'logic': 20,
|
||
|
|
'continuity': 10,
|
||
|
|
'pacing': 10,
|
||
|
|
'style': 10
|
||
|
|
},
|
||
|
|
'pass_threshold': 85,
|
||
|
|
'warning_threshold': 75
|
||
|
|
},
|
||
|
|
'backpatch': {
|
||
|
|
'frequency_chapters': kwargs.get('backpatch_frequency', 5),
|
||
|
|
'severity_threshold_for_block': 'high'
|
||
|
|
},
|
||
|
|
'time': {
|
||
|
|
'timezone_standard': 'Asia/Shanghai',
|
||
|
|
'timezone_offset': '+08:00',
|
||
|
|
'timestamp_format': 'ISO8601'
|
||
|
|
},
|
||
|
|
'run_id': run_id,
|
||
|
|
'created_at': get_timestamp_iso(),
|
||
|
|
'updated_at': get_timestamp_iso()
|
||
|
|
}
|
||
|
|
|
||
|
|
def _generate_initial_writing_state(
|
||
|
|
self,
|
||
|
|
book_title: str,
|
||
|
|
run_id: str,
|
||
|
|
mode: str
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""Generate initial 4-writing-state.json"""
|
||
|
|
return {
|
||
|
|
'book_title': book_title,
|
||
|
|
'run_id': run_id,
|
||
|
|
'mode': mode,
|
||
|
|
'current_chapter': 0,
|
||
|
|
'completed_chapters': [],
|
||
|
|
'current_attempt': 1,
|
||
|
|
'qc_score': 0,
|
||
|
|
'qc_status': 'INIT',
|
||
|
|
'forced_streak': 0,
|
||
|
|
'forced_streak_rules': {
|
||
|
|
'increment_on_forced': 'forced_streak += 1',
|
||
|
|
'reset_on_pass_warning': 'forced_streak = 0',
|
||
|
|
'decrement_on_backpatch': 'forced_streak = max(0, forced_streak-1)',
|
||
|
|
'threshold': 2
|
||
|
|
},
|
||
|
|
'flags': {
|
||
|
|
'is_paused': False,
|
||
|
|
'requires_backpatch': False,
|
||
|
|
'prev_chapter_forced': False
|
||
|
|
},
|
||
|
|
'ending_state': 'not_ready', # not_ready | ready_to_end | ended
|
||
|
|
'ending_checklist': {
|
||
|
|
'main_conflict_resolved': False,
|
||
|
|
'core_arc_closed': False,
|
||
|
|
'major_threads_resolved_ratio': 0.0,
|
||
|
|
'final_hook_present': False
|
||
|
|
},
|
||
|
|
'last_state_commit': get_timestamp_iso(),
|
||
|
|
'last_snapshot_id': None,
|
||
|
|
'last_outline_summary': '',
|
||
|
|
'warning_summary': None,
|
||
|
|
'token_spent': 0,
|
||
|
|
'cost_total_rmb': 0.0,
|
||
|
|
'created_at': get_timestamp_iso(),
|
||
|
|
'updated_at': get_timestamp_iso()
|
||
|
|
}
|
||
|
|
|
||
|
|
def detect_existing_run(
|
||
|
|
self,
|
||
|
|
book_title: str = None,
|
||
|
|
book_uid: str = None,
|
||
|
|
run_id: str = None
|
||
|
|
) -> Optional[Path]:
|
||
|
|
"""
|
||
|
|
Detect if a run already exists for the given parameters
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Path to run_dir if found, None otherwise
|
||
|
|
"""
|
||
|
|
if book_uid and run_id:
|
||
|
|
# Direct lookup
|
||
|
|
title_slug = to_slug(book_title) if book_title else "*"
|
||
|
|
pattern = f"{title_slug}__{book_uid}/runs/{run_id}"
|
||
|
|
matches = list(self.base_dir.glob(pattern))
|
||
|
|
if matches:
|
||
|
|
return matches[0]
|
||
|
|
|
||
|
|
if book_uid:
|
||
|
|
# Search by book_uid
|
||
|
|
for workspace in self.base_dir.iterdir():
|
||
|
|
if workspace.is_dir() and f"__{book_uid}" in workspace.name:
|
||
|
|
runs_dir = workspace / "runs"
|
||
|
|
if runs_dir.exists():
|
||
|
|
if run_id:
|
||
|
|
run_dir = runs_dir / run_id
|
||
|
|
if run_dir.exists():
|
||
|
|
return run_dir
|
||
|
|
else:
|
||
|
|
# Return most recent run
|
||
|
|
runs = sorted(runs_dir.iterdir(), key=lambda p: p.stat().st_mtime)
|
||
|
|
if runs:
|
||
|
|
return runs[-1]
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
def validate_workspace_integrity(self, run_dir: Path) -> Tuple[bool, List[str]]:
|
||
|
|
"""
|
||
|
|
Validate workspace integrity before operations
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple of (is_valid, list_of_errors)
|
||
|
|
"""
|
||
|
|
errors = []
|
||
|
|
|
||
|
|
# Check run_dir exists
|
||
|
|
if not run_dir.exists():
|
||
|
|
errors.append(f"Run directory does not exist: {run_dir}")
|
||
|
|
return False, errors
|
||
|
|
|
||
|
|
# Check required files
|
||
|
|
required_files = [
|
||
|
|
run_dir / "0-config" / "0-book-config.json",
|
||
|
|
run_dir / "4-state" / "4-writing-state.json"
|
||
|
|
]
|
||
|
|
|
||
|
|
for req_file in required_files:
|
||
|
|
if not req_file.exists():
|
||
|
|
errors.append(f"Required file missing: {req_file}")
|
||
|
|
|
||
|
|
# Validate lock consistency
|
||
|
|
lock_file = run_dir / ".lock.json"
|
||
|
|
if lock_file.exists():
|
||
|
|
try:
|
||
|
|
with open(lock_file, 'r', encoding='utf-8') as f:
|
||
|
|
lock_data = json.load(f)
|
||
|
|
|
||
|
|
run_id_from_dir = run_dir.name
|
||
|
|
run_id_from_lock = lock_data.get('run_id')
|
||
|
|
|
||
|
|
if run_id_from_lock != run_id_from_dir:
|
||
|
|
errors.append(f"Lock file run_id mismatch: {run_id_from_lock} != {run_id_from_dir}")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
errors.append(f"Failed to read lock file: {e}")
|
||
|
|
|
||
|
|
# Check no path escaping
|
||
|
|
if not validate_path_in_workspace(run_dir, run_dir):
|
||
|
|
errors.append("Workspace path validation failed")
|
||
|
|
|
||
|
|
return len(errors) == 0, errors
|
||
|
|
|
||
|
|
def get_book_list(self) -> List[Dict[str, Any]]:
|
||
|
|
"""List all books in the base directory"""
|
||
|
|
books = []
|
||
|
|
|
||
|
|
for workspace in self.base_dir.iterdir():
|
||
|
|
if not workspace.is_dir():
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Parse workspace name: {title_slug}__{book_uid}
|
||
|
|
parts = workspace.name.rsplit('__', 1)
|
||
|
|
if len(parts) != 2:
|
||
|
|
continue
|
||
|
|
|
||
|
|
title_slug, book_uid = parts
|
||
|
|
|
||
|
|
# Find runs
|
||
|
|
runs_dir = workspace / "runs"
|
||
|
|
if not runs_dir.exists():
|
||
|
|
continue
|
||
|
|
|
||
|
|
for run_dir in runs_dir.iterdir():
|
||
|
|
config_file = run_dir / "0-config" / "0-book-config.json"
|
||
|
|
if config_file.exists():
|
||
|
|
try:
|
||
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||
|
|
config = json.load(f)
|
||
|
|
|
||
|
|
books.append({
|
||
|
|
'book_title': config['book']['title'],
|
||
|
|
'book_uid': book_uid,
|
||
|
|
'run_id': run_dir.name,
|
||
|
|
'genre': config['book']['genre'],
|
||
|
|
'status': config['book'].get('status', 'unknown'),
|
||
|
|
'path': str(run_dir),
|
||
|
|
'created_at': config.get('created_at', 'unknown')
|
||
|
|
})
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return sorted(books, key=lambda x: x['created_at'], reverse=True)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Intent Checklist Generator
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
def generate_intent_checklist(book_config: Dict[str, Any]) -> Dict[str, Any]:
|
||
|
|
"""
|
||
|
|
Generate initial intent_checklist.json based on book config
|
||
|
|
|
||
|
|
This is the 10-item alignment checklist from design doc
|
||
|
|
"""
|
||
|
|
book = book_config.get('book', {})
|
||
|
|
genre = book.get('genre', '')
|
||
|
|
subgenre = book.get('subgenre', '')
|
||
|
|
tone = book.get('tone', '轻松')
|
||
|
|
|
||
|
|
return {
|
||
|
|
'version': '1.0',
|
||
|
|
'source': '0-book-config',
|
||
|
|
'items': [
|
||
|
|
{
|
||
|
|
'id': 1,
|
||
|
|
'name': '题材关键词',
|
||
|
|
'description': f'必须是{genre}',
|
||
|
|
'must_be': [genre] + ([subgenre] if subgenre else []),
|
||
|
|
'must_not': [],
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 2,
|
||
|
|
'name': '核心基调',
|
||
|
|
'description': f'必须是{tone}',
|
||
|
|
'must_be': tone,
|
||
|
|
'must_not': '黑暗' if tone != '暗黑' else '轻松',
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 3,
|
||
|
|
'name': '主角身份',
|
||
|
|
'description': '主角身份设定',
|
||
|
|
'must_be': '待设定',
|
||
|
|
'must_not': None,
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 4,
|
||
|
|
'name': '世界规则',
|
||
|
|
'description': '核心世界观规则存在',
|
||
|
|
'must_be': '待设定',
|
||
|
|
'must_not': None,
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 5,
|
||
|
|
'name': '主要冲突类型',
|
||
|
|
'description': '故事主要冲突类型',
|
||
|
|
'must_be': '待设定',
|
||
|
|
'must_not': None,
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 6,
|
||
|
|
'name': '叙事视角',
|
||
|
|
'description': '叙事视角设定',
|
||
|
|
'must_be': '第三人称限知',
|
||
|
|
'must_not': ['第一人称', '上帝视角'],
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 7,
|
||
|
|
'name': '目标受众',
|
||
|
|
'description': '目标读者群体',
|
||
|
|
'must_be': '网文',
|
||
|
|
'must_not': None,
|
||
|
|
'required': False,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 8,
|
||
|
|
'name': '核心伏笔',
|
||
|
|
'description': '主线伏笔设定',
|
||
|
|
'must_be': '待设定',
|
||
|
|
'must_not': None,
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 9,
|
||
|
|
'name': '力量等级',
|
||
|
|
'description': '力量/技能体系',
|
||
|
|
'must_be': '待设定',
|
||
|
|
'must_not': None,
|
||
|
|
'required': False,
|
||
|
|
'weight': 0.1
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 10,
|
||
|
|
'name': '结局走向',
|
||
|
|
'description': '故事结局方向',
|
||
|
|
'must_be': ['HE', '开放式'],
|
||
|
|
'must_not': 'BE',
|
||
|
|
'required': True,
|
||
|
|
'weight': 0.1
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Module Test
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
print("=== Workspace Manager Test ===\n")
|
||
|
|
|
||
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||
|
|
base_dir = Path(tmpdir) / "novels"
|
||
|
|
|
||
|
|
# Create manager
|
||
|
|
mgr = WorkspaceManager(base_dir)
|
||
|
|
|
||
|
|
# Test create new book
|
||
|
|
run_dir, book_uid, run_id, paths = mgr.create_new_book(
|
||
|
|
book_title="阴间外卖",
|
||
|
|
genre="都市灵异",
|
||
|
|
target_words=250000,
|
||
|
|
subgenre="系统流",
|
||
|
|
mode="manual"
|
||
|
|
)
|
||
|
|
|
||
|
|
print(f"[Test] Book created: {run_dir.exists()}")
|
||
|
|
print(f" book_uid: {book_uid}")
|
||
|
|
print(f" run_id: {run_id}")
|
||
|
|
|
||
|
|
# Test directory structure
|
||
|
|
print(f"\n[Test] Directory structure:")
|
||
|
|
print(f" 0-config exists: {paths['config_dir'].exists()}")
|
||
|
|
print(f" 4-state exists: {paths['state_dir'].exists()}")
|
||
|
|
print(f" chapters exists: {paths['chapters_dir'].exists()}")
|
||
|
|
print(f" logs exists: {paths['logs_dir'].exists()}")
|
||
|
|
|
||
|
|
# Test config written
|
||
|
|
config_path = paths['book_config']
|
||
|
|
print(f"\n[Test] Config file exists: {config_path.exists()}")
|
||
|
|
|
||
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||
|
|
config = json.load(f)
|
||
|
|
print(f" Title: {config['book']['title']}")
|
||
|
|
print(f" Genre: {config['book']['genre']}")
|
||
|
|
print(f" Target words: {config['book']['target_word_count']}")
|
||
|
|
print(f" Mode: {config['generation']['mode']}")
|
||
|
|
|
||
|
|
# Test writing state
|
||
|
|
state_path = paths['writing_state']
|
||
|
|
print(f"\n[Test] Writing state exists: {state_path.exists()}")
|
||
|
|
|
||
|
|
with open(state_path, 'r', encoding='utf-8') as f:
|
||
|
|
state = json.load(f)
|
||
|
|
print(f" Current chapter: {state['current_chapter']}")
|
||
|
|
print(f" QC status: {state['qc_status']}")
|
||
|
|
|
||
|
|
# Test detect existing run
|
||
|
|
detected = mgr.detect_existing_run(book_uid=book_uid)
|
||
|
|
print(f"\n[Test] Detect existing run: {detected == run_dir}")
|
||
|
|
|
||
|
|
# Test validate integrity
|
||
|
|
is_valid, errors = mgr.validate_workspace_integrity(run_dir)
|
||
|
|
print(f"\n[Test] Workspace integrity: {'PASS' if is_valid else 'FAIL'}")
|
||
|
|
if errors:
|
||
|
|
for err in errors:
|
||
|
|
print(f" Error: {err}")
|
||
|
|
|
||
|
|
# Test book list
|
||
|
|
books = mgr.get_book_list()
|
||
|
|
print(f"\n[Test] Book list: {len(books)} book(s)")
|
||
|
|
if books:
|
||
|
|
print(f" First book: {books[0]['book_title']}")
|
||
|
|
|
||
|
|
# Test intent checklist generation
|
||
|
|
checklist = generate_intent_checklist(config)
|
||
|
|
print(f"\n[Test] Intent checklist generated: {len(checklist['items'])} items")
|
||
|
|
|
||
|
|
print("\n=== All tests completed ===")
|