Bläddra i källkod

fix: align webnovel state/index/archive schema

lingfengQAQ 5 månader sedan
förälder
incheckning
258069ce76

+ 2 - 2
.claude/agents/consistency-checker.md

@@ -45,7 +45,7 @@ allowed-tools: Read, Grep
 ```
 
 **Check Against**:
-- state.json: `protagonist.power.realm`, `protagonist.power.layer`
+- state.json: `protagonist_state.power.realm`, `protagonist_state.power.layer`
 - 设定集/修炼体系.md: Realm ability restrictions
 
 #### Tier 2: Location & Character Consistency (地点/角色检查)
@@ -67,7 +67,7 @@ allowed-tools: Read, Grep
 ```
 
 **Check Against**:
-- state.json: `protagonist.location`
+- state.json: `protagonist_state.location.current`
 - 设定集/角色卡/: Character profiles
 
 #### Tier 3: Timeline Consistency (时间线检查)

+ 6 - 6
.claude/commands/webnovel-query.md

@@ -1,5 +1,5 @@
 ---
-allowed-tools: Read, Grep
+allowed-tools: Read, Grep, Bash, AskUserQuestion
 argument-hint: [关键词]
 description: 快速查询设定集中的信息(角色/实力/势力/物品/伏笔),**支持伏笔紧急度分析和金手指状态追踪**,严格遵循查询流程
 ---
@@ -8,7 +8,7 @@ description: 快速查询设定集中的信息(角色/实力/势力/物品/伏
 
 > **System Prompt**: You are the **Archivist AI** of the Webnovel Studio. Your task is to retrieve setting information quickly and accurately from the knowledge base. You have access to specialized query types including **foreshadowing urgency analysis** and **golden finger status tracking**.
 
-> **Reference**: `references/cool-points-guide.md` (伏笔管理三层级), `references/golden-finger-templates.md` (金手指模板)
+> **Reference**: `references/cool-points-guide.md` (伏笔管理三层级), `templates/golden-finger-templates.md` (金手指模板)
 
 ## CRITICAL WARNING ⚠️
 
@@ -266,9 +266,9 @@ python .claude/skills/webnovel-writer/scripts/status_reporter.py --focus urgency
 4. **Calculate urgency score** for each foreshadowing:
    ```
    紧急度 = (已过章节 / 目标回收章节) × 层级权重
-   - 核心伏笔权重: 1.5x
-   - 支线伏笔权重: 1.0x
-   - 装饰伏笔权重: 0.5x
+   - 核心伏笔权重: 3.0x
+   - 支线伏笔权重: 2.0x
+   - 装饰伏笔权重: 1.0x
    ```
 5. **Sort by urgency** (highest first)
 6. **Generate warnings**:
@@ -412,7 +412,7 @@ python .claude/skills/webnovel-writer/scripts/status_reporter.py --focus urgency
 
 **Keyword**: "金手指", "系统", "外挂", "cheat", "golden finger"
 
-> **Reference**: `references/golden-finger-templates.md` (金手指模板)
+> **Reference**: `templates/golden-finger-templates.md` (金手指模板)
 
 **Purpose**: 完整追踪主角金手指的当前状态、技能解锁进度、冷却时间和未来发展方向。
 

+ 30 - 2
.claude/commands/webnovel-review.md

@@ -1,5 +1,5 @@
 ---
-allowed-tools: Read, Bash
+allowed-tools: Read, Grep, Write, Edit, Bash, Task, AskUserQuestion
 argument-hint: [起始章-结束章]
 description: 对指定范围的章节进行质量审查,强制调用 5 个专职审查员生成综合报告
 ---
@@ -31,7 +31,22 @@ description: 对指定范围的章节进行质量审查,强制调用 5 个专
 
 ---
 
-## 5 Mandatory Checkers (SEQUENTIAL - DO NOT SKIP)
+## Invocation (MANDATORY)
+
+**YOU MUST use the Task tool** to call all 5 checker subagents (can be parallel).
+- `high-point-checker`
+- `consistency-checker`
+- `pacing-checker`
+- `ooc-checker`
+- `continuity-checker`
+
+Each subagent prompt should include:
+- The chapter range (e.g., "1-10")
+- Instruction to read chapter files from `正文/` and follow its own protocol
+
+---
+
+## 5 Mandatory Checkers (MANDATORY - DO NOT SKIP)
 
 ### Checker 1: high-point-checker(爽点密度检查)
 
@@ -314,6 +329,19 @@ description: 对指定范围的章节进行质量审查,强制调用 5 个专
 
 ---
 
+## Step 7: Update state.json (MANDATORY)
+
+After saving the report file, **YOU MUST run**:
+
+```bash
+python .claude/skills/webnovel-writer/scripts/update_state.py \\
+  --add-review "{起始章}-{结束章}" "审查报告/第{起始章}-{结束章}章审查报告.md"
+```
+
+**Purpose**: record `review_checkpoints` so `/webnovel-write` can load the latest review feedback.
+
+---
+
 ## Execution Checklist (VERIFY BEFORE CLAIMING "DONE")
 
 Before you tell the user "Quality review complete", **YOU MUST verify**:

+ 3 - 3
.claude/commands/webnovel-write.md

@@ -779,9 +779,9 @@ python .claude/skills/webnovel-writer/scripts/archive_manager.py --auto-check
 **Purpose**: 防止 state.json 无限增长(200万字长跑保障)
 
 **Archiving Strategy**:
-- **角色归档**: 超过 50 章未出场的次要角色 → `archive/characters.json`
-- **伏笔归档**: status="已回收" 且超过 20 章的伏笔 → `archive/plot_threads.json`
-- **报告归档**: 超过 50 章的旧审查报告 → `archive/reviews.json`
+- **角色归档**: 超过 50 章未出场的次要角色 → `.webnovel/archive/characters.json`
+- **伏笔归档**: status="已回收" 且超过 20 章的伏笔 → `.webnovel/archive/plot_threads.json`
+- **报告归档**: 超过 50 章的旧审查报告 → `.webnovel/archive/reviews.json`
 
 **Trigger Conditions** (满足任一即执行):
 - state.json 大小 ≥ 1 MB

+ 6 - 6
.claude/skills/webnovel-writer/references/archiving-strategy.md

@@ -55,7 +55,7 @@
 - **超过 50 章未出场**(`current_chapter - last_appearance_chapter ≥ 50`)
 
 **归档目标**:
-- `archive/characters.json`
+- `.webnovel/.webnovel/archive/characters.json`
 
 **保留数据**:
 - 主要角色(`importance="major"`)**永不归档**
@@ -82,7 +82,7 @@ python archive_manager.py --restore-character "李雪"
 - **超过 20 章**(`current_chapter - resolved_chapter ≥ 20`)
 
 **归档目标**:
-- `archive/plot_threads.json`
+- `.webnovel/.webnovel/archive/plot_threads.json`
 
 **保留数据**:
 - 未回收的伏笔(`status="未回收"`)**永不归档**
@@ -107,7 +107,7 @@ python archive_manager.py --restore-character "李雪"
 - 审查报告超过 **50 章**(`current_chapter - review_chapter ≥ 50`)
 
 **归档目标**:
-- `archive/reviews.json`
+- `.webnovel/.webnovel/archive/reviews.json`
 
 **保留数据**:
 - 最近 50 章的审查报告保持活跃
@@ -225,7 +225,7 @@ python archive_manager.py --auto-check --dry-run
 
 **归档文件格式**(示例):
 
-**archive/characters.json**:
+**.webnovel/archive/characters.json**:
 ```json
 [
   {
@@ -245,7 +245,7 @@ python archive_manager.py --auto-check --dry-run
 ]
 ```
 
-**archive/plot_threads.json**:
+**.webnovel/archive/plot_threads.json**:
 ```json
 [
   {
@@ -308,7 +308,7 @@ python archive_manager.py --restore-character "李雪"
 ```
 
 **执行逻辑**:
-1. 从 `archive/characters.json` 中查找角色"李雪"
+1. 从 `.webnovel/.webnovel/archive/characters.json` 中查找角色"李雪"
 2. 移除 `archived_at` 字段
 3. 将角色恢复到 `state.json` 的 `entities.characters`
 4. 从归档文件中移除该角色

+ 121 - 25
.claude/skills/webnovel-writer/scripts/archive_manager.py

@@ -81,8 +81,8 @@ class ArchiveManager:
         self.config = {
             "character_inactive_threshold": 50,  # 角色超过 50 章未出场视为不活跃
             "plot_resolved_threshold": 20,       # 已回收伏笔超过 20 章后归档
-            "review_old_threshold": 20,          # 审查报告超过 20 章后归档(从 50 降至 20)
-            "file_size_trigger_mb": 0.5,         # state.json 超过 0.5MB 触发归档(从 1.0 降至 0.5)
+            "review_old_threshold": 50,          # 审查报告超过 50 章后归档
+            "file_size_trigger_mb": 1.0,         # state.json 超过 1.0MB 触发强制归档
             "chapter_trigger": 10                # 每 10 章检查一次
         }
 
@@ -152,11 +152,22 @@ class ArchiveManager:
         inactive = []
         for char in characters:
             # 只归档次要角色(importance="minor")
-            if char.get("importance") != "minor":
+            importance = char.get("importance")
+            if not importance:
+                tier = str(char.get("tier", "")).strip()
+                importance = "major" if tier == "核心" else "minor"
+            if importance != "minor":
                 continue
 
             # 检查最后出场章节
             last_appearance = char.get("last_appearance_chapter", 0)
+            try:
+                last_appearance = int(last_appearance)
+            except (TypeError, ValueError):
+                last_appearance = 0
+            if last_appearance <= 0:
+                continue
+
             inactive_chapters = current_chapter - last_appearance
 
             if inactive_chapters >= threshold:
@@ -171,21 +182,48 @@ class ArchiveManager:
     def identify_resolved_plot_threads(self, state):
         """识别可归档的已回收伏笔"""
         current_chapter = state.get("progress", {}).get("current_chapter", 0)
-        plot_threads = state.get("plot_threads", {}).get("active", [])
-        resolved = state.get("plot_threads", {}).get("resolved", [])
+        plot_threads = state.get("plot_threads", {}) or {}
+        foreshadowing = plot_threads.get("foreshadowing", []) or []
+        resolved_legacy = plot_threads.get("resolved", []) or []
         threshold = self.config["plot_resolved_threshold"]
 
         archivable = []
-        for thread in resolved:
-            resolved_chapter = thread.get("resolved_chapter", 0)
-            chapters_since_resolved = current_chapter - resolved_chapter
-
-            if chapters_since_resolved >= threshold:
-                archivable.append({
-                    "thread": thread,
-                    "chapters_since_resolved": chapters_since_resolved,
-                    "resolved_chapter": resolved_chapter
-                })
+        # 新格式:plot_threads.foreshadowing(用 status 标识是否已回收)
+        if isinstance(foreshadowing, list):
+            for item in foreshadowing:
+                if not isinstance(item, dict):
+                    continue
+                status = str(item.get("status", "")).strip()
+                if status not in ["已回收", "resolved"]:
+                    continue
+                try:
+                    resolved_chapter = int(item.get("resolved_chapter", 0))
+                except (TypeError, ValueError):
+                    continue
+                chapters_since_resolved = current_chapter - resolved_chapter
+                if chapters_since_resolved >= threshold:
+                    archivable.append({
+                        "thread": item,
+                        "chapters_since_resolved": chapters_since_resolved,
+                        "resolved_chapter": resolved_chapter
+                    })
+
+        # 旧格式兼容:plot_threads.resolved(直接存已回收列表)
+        if isinstance(resolved_legacy, list):
+            for item in resolved_legacy:
+                if not isinstance(item, dict):
+                    continue
+                try:
+                    resolved_chapter = int(item.get("resolved_chapter", 0))
+                except (TypeError, ValueError):
+                    continue
+                chapters_since_resolved = current_chapter - resolved_chapter
+                if chapters_since_resolved >= threshold:
+                    archivable.append({
+                        "thread": item,
+                        "chapters_since_resolved": chapters_since_resolved,
+                        "resolved_chapter": resolved_chapter
+                    })
 
         return archivable
 
@@ -195,9 +233,47 @@ class ArchiveManager:
         reviews = state.get("review_checkpoints", [])
         threshold = self.config["review_old_threshold"]
 
+        def _parse_end_chapter(review: dict) -> int:
+            # 新格式:{"chapters":"5-6","report":"...","reviewed_at":"..."}
+            chapters = review.get("chapters")
+            if isinstance(chapters, str):
+                parts = [p.strip() for p in chapters.replace("—", "-").split("-") if p.strip()]
+                if parts:
+                    try:
+                        return int(parts[-1])
+                    except ValueError:
+                        pass
+
+            # 旧格式:{"chapter_range":[5,6], "date":"..."}
+            cr = review.get("chapter_range")
+            if isinstance(cr, (list, tuple)) and len(cr) >= 2:
+                try:
+                    return int(cr[1])
+                except (TypeError, ValueError):
+                    pass
+
+            # 兜底:从 report 文件名里抓 "Ch5-6" 或 "第005-006"
+            report = review.get("report")
+            if isinstance(report, str):
+                import re
+                m = re.search(r"Ch(\d+)[-–—](\d+)", report)
+                if m:
+                    try:
+                        return int(m.group(2))
+                    except ValueError:
+                        pass
+                m = re.search(r"第(\d+)[-–—](\d+)章", report)
+                if m:
+                    try:
+                        return int(m.group(2))
+                    except ValueError:
+                        pass
+
+            return 0
+
         old_reviews = []
         for review in reviews:
-            review_chapter = review.get("chapter_range", [0, 0])[1]  # 取结束章节
+            review_chapter = _parse_end_chapter(review)
             chapters_since_review = current_chapter - review_chapter
 
             if chapters_since_review >= threshold:
@@ -296,18 +372,37 @@ class ArchiveManager:
 
         # 移除已归档的伏笔
         if resolved_threads:
-            thread_ids = {item["thread"]["description"] for item in resolved_threads}
-            state["plot_threads"]["resolved"] = [
-                thread for thread in state["plot_threads"]["resolved"]
-                if thread["description"] not in thread_ids
-            ]
+            thread_ids = {
+                (item.get("thread", {}) or {}).get("content") or (item.get("thread", {}) or {}).get("description")
+                for item in resolved_threads
+            }
+            thread_ids = {t for t in thread_ids if isinstance(t, str) and t.strip()}
+
+            plot_threads = state.get("plot_threads", {}) or {}
+            if isinstance(plot_threads.get("foreshadowing"), list):
+                plot_threads["foreshadowing"] = [
+                    t for t in plot_threads["foreshadowing"]
+                    if not isinstance(t, dict) or (t.get("content") or t.get("description")) not in thread_ids
+                ]
+            if isinstance(plot_threads.get("resolved"), list):
+                plot_threads["resolved"] = [
+                    t for t in plot_threads["resolved"]
+                    if not isinstance(t, dict) or (t.get("content") or t.get("description")) not in thread_ids
+                ]
+            state["plot_threads"] = plot_threads
 
         # 移除旧审查报告
         if old_reviews:
-            review_dates = {item["review"]["date"] for item in old_reviews}
+            review_keys = set()
+            for item in old_reviews:
+                review = item.get("review", {}) or {}
+                key = review.get("report") or review.get("reviewed_at") or review.get("date")
+                if isinstance(key, str) and key.strip():
+                    review_keys.add(key)
+
             state["review_checkpoints"] = [
-                review for review in state["review_checkpoints"]
-                if review["date"] not in review_dates
+                review for review in state.get("review_checkpoints", [])
+                if (review.get("report") or review.get("reviewed_at") or review.get("date")) not in review_keys
             ]
 
         return state
@@ -354,7 +449,8 @@ class ArchiveManager:
             if resolved_threads:
                 print("\n   已回收伏笔:")
                 for item in resolved_threads[:5]:
-                    print(f"   - {item['thread']['description'][:30]}... (已回收 {item['chapters_since_resolved']} 章)")
+                    desc = item["thread"].get("content") or item["thread"].get("description") or ""
+                    print(f"   - {str(desc)[:30]}... (已回收 {item['chapters_since_resolved']} 章)")
             if old_reviews:
                 print("\n   旧审查报告:")
                 for item in old_reviews[:5]:

+ 39 - 16
.claude/skills/webnovel-writer/scripts/extract_entities.py

@@ -395,9 +395,18 @@ def update_state_json(
     default_planted_chapter: Optional[int] = None,
 ):
     """更新 state.json 中的实体记录(支持层级分类/金手指技能/伏笔结构化)"""
+
+    def _to_int(value: Any, default: int = 0) -> int:
+        try:
+            return int(value)
+        except (TypeError, ValueError):
+            return default
+
     with open(state_file, 'r', encoding='utf-8') as f:
         state = json.load(f)
 
+    first_seen_chapter = _to_int(default_planted_chapter, 0)
+
     # 确保存在实体列表
     if 'entities' not in state:
         state['entities'] = {
@@ -411,33 +420,44 @@ def update_state_json(
     # 确保存在金手指技能列表
     if 'protagonist_state' not in state:
         state['protagonist_state'] = {}
-    if 'golden_finger' not in state['protagonist_state']:
-        state['protagonist_state']['golden_finger'] = {
-            "name": "",
-            "skills": [],
-            "level": 1
-        }
+    golden_finger = state['protagonist_state'].get('golden_finger')
+    if not isinstance(golden_finger, dict):
+        golden_finger = {}
+        state['protagonist_state']['golden_finger'] = golden_finger
+    golden_finger.setdefault("name", "")
+    golden_finger.setdefault("level", 1)
+    golden_finger.setdefault("cooldown", 0)
+    golden_finger.setdefault("skills", [])
 
     for entity in entities:
         entity_type = entity['type']
         entity_tier = entity.get('tier', '支线')
 
         if entity_type == "角色":
-            if entity['name'] not in [c.get('name') for c in state['entities']['characters']]:
-                state['entities']['characters'].append({
-                    "name": entity['name'],
-                    "desc": entity['desc'],
-                    "category": categorize_character(entity['desc']),
-                    "tier": entity_tier,
-                    "first_appearance": entity.get('source_file', ''),
-                    "added_at": datetime.now().strftime('%Y-%m-%d')
-                })
+            chars = state['entities']['characters']
+            record = next((c for c in chars if c.get('name') == entity['name']), None)
+            if record is None:
+                record = {"name": entity['name']}
+                chars.append(record)
+
+            desc = entity['desc']
+            record.setdefault("desc", desc)
+            record.setdefault("description", desc)  # 兼容 structured_index
+            record.setdefault("category", categorize_character(desc))
+            record.setdefault("tier", entity_tier)
+            record.setdefault("importance", "major" if entity_tier == "核心" else "minor")
+            record.setdefault("first_appearance", entity.get('source_file', ''))
+            if first_seen_chapter:
+                record.setdefault("first_appearance_chapter", first_seen_chapter)
+                record["last_appearance_chapter"] = max(_to_int(record.get("last_appearance_chapter"), 0), first_seen_chapter)
+            record.setdefault("added_at", datetime.now().strftime('%Y-%m-%d'))
 
         elif entity_type == "地点":
             if entity['name'] not in [l.get('name') for l in state['entities']['locations']]:
                 state['entities']['locations'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -448,6 +468,7 @@ def update_state_json(
                 state['entities']['items'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -458,6 +479,7 @@ def update_state_json(
                 state['entities']['factions'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -468,6 +490,7 @@ def update_state_json(
                 state['entities']['techniques'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -532,7 +555,7 @@ def update_state_json(
             if found is None:
                 existing.append({
                     "content": content,
-                    "status": "active",
+                    "status": "未回收",
                     "tier": tier,
                     "planted_chapter": planted,
                     "target_chapter": target,

+ 6 - 1
.claude/skills/webnovel-writer/scripts/init_project.py

@@ -203,7 +203,12 @@ def init_project(
     # 读取内置模板(可选)
     script_dir = Path(__file__).resolve().parent
     templates_dir = script_dir.parent / "templates"
-    genre_template = _read_text_if_exists(templates_dir / "genres" / f"{genre}.md")
+    genre_key = (genre or "").strip()
+    genre_template_key = {
+        "修仙/玄幻": "修仙",
+        "玄幻": "修仙",
+    }.get(genre_key, genre_key)
+    genre_template = _read_text_if_exists(templates_dir / "genres" / f"{genre_template_key}.md")
     golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
 
     # 基础文件(只在缺失时生成,避免覆盖已有内容)

+ 119 - 4
.claude/skills/webnovel-writer/scripts/structured_index.py

@@ -41,6 +41,7 @@ import argparse
 import sqlite3
 import hashlib
 import re
+import tempfile
 from datetime import datetime
 from pathlib import Path
 from typing import Optional, List, Dict, Tuple
@@ -219,6 +220,87 @@ class StructuredIndex:
         self.conn.commit()
         print(f"✅ 章节索引已更新:Ch{chapter_num} - {metadata['title']}")
 
+    def bump_character_last_appearance_in_state(self, chapter_num: int, character_names: List[str]) -> int:
+        """将本章出场角色同步回 state.json 的 last_appearance_chapter(轻量级)"""
+        if not character_names:
+            return 0
+        if not self.state_file.exists():
+            return 0
+
+        try:
+            with open(self.state_file, 'r', encoding='utf-8') as f:
+                state = json.load(f)
+        except json.JSONDecodeError:
+            return 0
+
+        entities = state.get("entities", {}) or {}
+        characters = entities.get("characters", [])
+        if not isinstance(characters, list):
+            return 0
+
+        name_set = {str(n).strip() for n in character_names if str(n).strip()}
+        if not name_set:
+            return 0
+
+        updated = 0
+        changed = False
+        for char in characters:
+            if not isinstance(char, dict):
+                continue
+            name = str(char.get("name", "")).strip()
+            if not name or name not in name_set:
+                continue
+
+            tier = str(char.get("tier", "")).strip()
+            if "importance" not in char:
+                char["importance"] = "major" if tier == "核心" else "minor"
+                changed = True
+
+            if "description" not in char and isinstance(char.get("desc"), str):
+                char["description"] = char.get("desc", "")
+                changed = True
+
+            prev = char.get("last_appearance_chapter")
+            try:
+                prev_int = int(prev)
+            except (TypeError, ValueError):
+                prev_int = 0
+
+            new_last = max(prev_int, int(chapter_num))
+            if new_last != prev_int:
+                char["last_appearance_chapter"] = new_last
+                updated += 1
+                changed = True
+
+            if not char.get("first_appearance_chapter"):
+                char["first_appearance_chapter"] = int(chapter_num)
+                changed = True
+
+        if not changed:
+            return 0
+
+        tmp_path = None
+        try:
+            with tempfile.NamedTemporaryFile(
+                mode="w",
+                encoding="utf-8",
+                suffix=".tmp",
+                delete=False,
+                dir=str(self.state_file.parent),
+            ) as tf:
+                tmp_path = Path(tf.name)
+                json.dump(state, tf, ensure_ascii=False, indent=2)
+                tf.write("\n")
+            os.replace(str(tmp_path), str(self.state_file))
+        finally:
+            if tmp_path and tmp_path.exists():
+                try:
+                    tmp_path.unlink()
+                except OSError:
+                    pass
+
+        return updated
+
     def query_chapters_by_location(self, location: str, limit: int = 10) -> List[Tuple]:
         """O(log n) 查询:返回该地点的最近 N 章
 
@@ -470,6 +552,32 @@ class StructuredIndex:
 
     def _index_character(self, char: Dict, status: str = 'active'):
         """为单个角色建立索引"""
+        description = char.get('description') or char.get('desc') or ''
+        tier = str(char.get('tier', '') or '').strip()
+        importance = char.get('importance') or ('major' if tier == '核心' else 'minor')
+
+        first_appearance = char.get('first_appearance_chapter', 0) or 0
+        try:
+            first_appearance = int(first_appearance)
+        except (TypeError, ValueError):
+            first_appearance = 0
+
+        if first_appearance == 0:
+            src = char.get('first_appearance')
+            if isinstance(src, str):
+                m = re.search(r'第(\d+)章', src)
+                if m:
+                    try:
+                        first_appearance = int(m.group(1))
+                    except ValueError:
+                        first_appearance = 0
+
+        last_appearance = char.get('last_appearance_chapter', 0) or first_appearance
+        try:
+            last_appearance = int(last_appearance)
+        except (TypeError, ValueError):
+            last_appearance = first_appearance
+
         self.conn.execute("""
             INSERT OR REPLACE INTO characters
             (name, description, personality, importance, power_level,
@@ -477,12 +585,12 @@ class StructuredIndex:
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
         """, (
             char.get('name', ''),
-            char.get('description', ''),
+            description,
             char.get('personality', ''),
-            char.get('importance', 'minor'),
+            importance,
             char.get('power_level', ''),
-            char.get('first_appearance_chapter', 0),
-            char.get('last_appearance_chapter', 0),
+            first_appearance,
+            last_appearance,
             status
         ))
 
@@ -628,6 +736,7 @@ class StructuredIndex:
 
         # 同步伏笔索引
         self.sync_foreshadowing_from_state()
+        self.sync_characters_from_state()
 
         print(f"✅ 批量重建完成:{len(seen)} 章")
 
@@ -723,6 +832,8 @@ def main():
 
                 # 同步伏笔索引
                 index.sync_foreshadowing_from_state()
+                index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+                index.sync_characters_from_state()
 
             except json.JSONDecodeError as e:
                 print(f"❌ JSON 解析失败: {e}")
@@ -746,6 +857,8 @@ def main():
 
                 # 同步伏笔索引
                 index.sync_foreshadowing_from_state()
+                index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+                index.sync_characters_from_state()
 
             except json.JSONDecodeError as e:
                 print(f"❌ JSON 解析失败: {e}")
@@ -770,6 +883,8 @@ def main():
 
             # 同步伏笔索引
             index.sync_foreshadowing_from_state()
+            index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+            index.sync_characters_from_state()
 
         else:
             print("❌ 缺少参数:--metadata-file (推荐) / --metadata-json / --metadata")

+ 10 - 5
.claude/skills/webnovel-writer/scripts/update_state.py

@@ -208,11 +208,16 @@ class StateUpdater:
 
     def update_golden_finger(self, name: str, level: int, cooldown: int):
         """更新金手指状态"""
-        self.state["protagonist_state"]["golden_finger"] = {
-            "name": name,
-            "level": level,
-            "cooldown": cooldown
-        }
+        ps = self.state.setdefault("protagonist_state", {})
+        golden_finger = ps.get("golden_finger")
+        if not isinstance(golden_finger, dict):
+            golden_finger = {}
+            ps["golden_finger"] = golden_finger
+
+        golden_finger.setdefault("skills", [])
+        golden_finger["name"] = name
+        golden_finger["level"] = level
+        golden_finger["cooldown"] = cooldown
         print(f"📝 更新金手指: {name} Lv.{level}, 冷却: {cooldown}天")
 
     def update_relationship(self, char_name: str, key: str, value: Any):