Browse Source

feat: integrate graph-aware rag workflow and simplify README history

lingfengQAQ 3 tháng trước cách đây
mục cha
commit
bc9b2ee2ce

+ 1 - 0
.claude/agents/context-agent.md

@@ -91,6 +91,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py" --chapter {NNN
 
 - 必须读取:`writing_guidance.guidance_items`
 - 推荐读取:`reader_signal` 与 `genre_profile.reference_hints`
+- 条件读取:`rag_assist`(当 `invoked=true` 且 `hits` 非空时,优先提炼为“接住上章/角色动机/场景约束”的证据)
 
 ### Step 1: 读取大纲与状态
 - 大纲:`大纲/卷N/第XXX章.md` 或 `大纲/第{卷}卷-详细大纲.md`

+ 8 - 1
.claude/scripts/data_modules/__init__.py

@@ -16,7 +16,13 @@ from .config import DataModulesConfig, get_config, set_project_root
 from .api_client import ModalAPIClient, get_client
 from .entity_linker import EntityLinker, DisambiguationResult
 from .state_manager import StateManager, EntityState, Relationship, StateChange
-from .index_manager import IndexManager, ChapterMeta, SceneMeta, ReviewMetrics
+from .index_manager import (
+    IndexManager,
+    ChapterMeta,
+    SceneMeta,
+    ReviewMetrics,
+    RelationshipEventMeta,
+)
 from .rag_adapter import RAGAdapter, SearchResult
 from .context_manager import ContextManager
 from .context_ranker import ContextRanker
@@ -45,6 +51,7 @@ __all__ = [
     "ChapterMeta",
     "SceneMeta",
     "ReviewMetrics",
+    "RelationshipEventMeta",
     # RAG Adapter
     "RAGAdapter",
     "SearchResult",

+ 15 - 0
.claude/scripts/data_modules/config.py

@@ -128,6 +128,17 @@ class DataModulesConfig:
     vector_prefilter_bm25_candidates: int = 200
     vector_prefilter_recent_candidates: int = 200
 
+    # ================= Graph-RAG 配置 =================
+    graph_rag_enabled: bool = False
+    graph_rag_expand_hops: int = 1
+    graph_rag_max_expanded_entities: int = 30
+    graph_rag_candidate_limit: int = 150
+    graph_rag_boost_same_entity: float = 0.2
+    graph_rag_boost_related_entity: float = 0.1
+    graph_rag_boost_recency: float = 0.05
+
+    relationship_graph_from_index_enabled: bool = True
+
     # ================= 实体提取配置 =================
     extraction_confidence_high: float = 0.8
     extraction_confidence_medium: float = 0.5
@@ -182,6 +193,10 @@ class DataModulesConfig:
     context_writing_score_persist_enabled: bool = True
     context_writing_score_include_reader_trend: bool = True
     context_writing_score_trend_window: int = 10
+    context_rag_assist_enabled: bool = True
+    context_rag_assist_top_k: int = 4
+    context_rag_assist_min_outline_chars: int = 40
+    context_rag_assist_max_query_chars: int = 120
     context_dynamic_budget_enabled: bool = True
     context_dynamic_budget_early_chapter: int = 30
     context_dynamic_budget_late_chapter: int = 120

+ 471 - 0
.claude/scripts/data_modules/index_entity_mixin.py

@@ -8,6 +8,8 @@ from __future__ import annotations
 
 import json
 import logging
+import re
+import sqlite3
 from datetime import datetime
 from typing import Any, Dict, List, Optional
 
@@ -506,6 +508,475 @@ class IndexEntityMixin:
             )
             return [dict(row) for row in cursor.fetchall()]
 
+    # ==================== v5.5 关系事件与图谱 ====================
+
+    def _infer_relationship_polarity(self, rel_type: str) -> int:
+        """基于关系类型推断极性:-1 敌对,0 中立,1 友好。"""
+        t = str(rel_type or "")
+        positive_keywords = ("盟友", "友好", "师徒", "同伴", "亲", "爱", "合作")
+        negative_keywords = ("敌", "仇", "恨", "对立", "冲突", "背叛", "追杀")
+
+        if any(k in t for k in negative_keywords):
+            return -1
+        if any(k in t for k in positive_keywords):
+            return 1
+        return 0
+
+    def record_relationship_event(self, event: RelationshipEventMeta) -> int:
+        """记录关系事件,返回事件 ID。"""
+        from_entity = str(getattr(event, "from_entity", "") or "").strip()
+        to_entity = str(getattr(event, "to_entity", "") or "").strip()
+        rel_type = str(getattr(event, "type", "") or "").strip()
+        if not from_entity or not to_entity or not rel_type:
+            return 0
+
+        action = str(getattr(event, "action", "update") or "update").strip().lower()
+        if action not in {"create", "update", "decay", "remove"}:
+            action = "update"
+
+        try:
+            chapter = int(getattr(event, "chapter", 0) or 0)
+        except (TypeError, ValueError):
+            return 0
+        if chapter <= 0:
+            return 0
+        try:
+            scene_index = int(getattr(event, "scene_index", 0) or 0)
+        except (TypeError, ValueError):
+            scene_index = 0
+
+        raw_polarity = getattr(event, "polarity", None)
+        if raw_polarity is None:
+            polarity = self._infer_relationship_polarity(rel_type)
+        else:
+            try:
+                polarity = int(raw_polarity)
+            except (TypeError, ValueError):
+                polarity = 0
+        if polarity > 1:
+            polarity = 1
+        elif polarity < -1:
+            polarity = -1
+
+        try:
+            strength = float(getattr(event, "strength", 0.5) or 0.5)
+        except (TypeError, ValueError):
+            strength = 0.5
+        strength = max(0.0, min(1.0, strength))
+
+        description = str(getattr(event, "description", "") or "").strip()
+        evidence = str(getattr(event, "evidence", "") or "").strip()
+        try:
+            confidence = float(getattr(event, "confidence", 1.0) or 1.0)
+        except (TypeError, ValueError):
+            confidence = 1.0
+        confidence = max(0.0, min(1.0, confidence))
+
+        with self._get_conn() as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                """
+                INSERT INTO relationship_events
+                (from_entity, to_entity, type, action, polarity, strength, description, chapter, scene_index, evidence, confidence)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+                (
+                    from_entity,
+                    to_entity,
+                    rel_type,
+                    action,
+                    polarity,
+                    strength,
+                    description,
+                    chapter,
+                    scene_index,
+                    evidence,
+                    confidence,
+                ),
+            )
+            conn.commit()
+            return int(cursor.lastrowid or 0)
+
+    def get_relationship_events(
+        self,
+        entity_id: str,
+        direction: str = "both",
+        from_chapter: Optional[int] = None,
+        to_chapter: Optional[int] = None,
+        limit: int = 100,
+    ) -> List[Dict[str, Any]]:
+        """按实体查询关系事件。"""
+        direction = str(direction or "both").lower()
+        clauses: List[str] = []
+        params: List[Any] = []
+
+        if direction == "from":
+            clauses.append("from_entity = ?")
+            params.append(entity_id)
+        elif direction == "to":
+            clauses.append("to_entity = ?")
+            params.append(entity_id)
+        else:
+            clauses.append("(from_entity = ? OR to_entity = ?)")
+            params.extend([entity_id, entity_id])
+
+        if from_chapter is not None:
+            clauses.append("chapter >= ?")
+            params.append(int(from_chapter))
+        if to_chapter is not None:
+            clauses.append("chapter <= ?")
+            params.append(int(to_chapter))
+
+        where_sql = " AND ".join(clauses) if clauses else "1=1"
+        with self._get_conn() as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                f"""
+                SELECT * FROM relationship_events
+                WHERE {where_sql}
+                ORDER BY chapter DESC, id DESC
+                LIMIT ?
+            """,
+                (*params, int(limit)),
+            )
+            return [dict(row) for row in cursor.fetchall()]
+
+    def get_relationship_timeline(
+        self,
+        entity1: str,
+        entity2: str,
+        from_chapter: Optional[int] = None,
+        to_chapter: Optional[int] = None,
+        limit: int = 100,
+    ) -> List[Dict[str, Any]]:
+        """查询两个实体之间的关系时间线。"""
+        clauses = [
+            "((from_entity = ? AND to_entity = ?) OR (from_entity = ? AND to_entity = ?))"
+        ]
+        params: List[Any] = [entity1, entity2, entity2, entity1]
+
+        if from_chapter is not None:
+            clauses.append("chapter >= ?")
+            params.append(int(from_chapter))
+        if to_chapter is not None:
+            clauses.append("chapter <= ?")
+            params.append(int(to_chapter))
+
+        where_sql = " AND ".join(clauses)
+        with self._get_conn() as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                f"""
+                SELECT * FROM relationship_events
+                WHERE {where_sql}
+                ORDER BY chapter ASC, id ASC
+                LIMIT ?
+            """,
+                (*params, int(limit)),
+            )
+            return [dict(row) for row in cursor.fetchall()]
+
+    def _load_effective_relationship_edges(
+        self,
+        chapter: Optional[int] = None,
+        relation_types: Optional[List[str]] = None,
+    ) -> List[Dict[str, Any]]:
+        """加载指定章节截面的有效关系边。"""
+        relation_types = [str(t) for t in (relation_types or []) if str(t).strip()]
+
+        with self._get_conn() as conn:
+            cursor = conn.cursor()
+            if chapter is None:
+                clauses = []
+                params: List[Any] = []
+                if relation_types:
+                    placeholders = ",".join("?" for _ in relation_types)
+                    clauses.append(f"type IN ({placeholders})")
+                    params.extend(relation_types)
+
+                where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+                cursor.execute(
+                    f"""
+                    SELECT from_entity, to_entity, type, description, chapter
+                    FROM relationships
+                    {where_sql}
+                    ORDER BY chapter DESC, id DESC
+                """,
+                    tuple(params),
+                )
+                rows = cursor.fetchall()
+                return [
+                    {
+                        "from": str(r["from_entity"]),
+                        "to": str(r["to_entity"]),
+                        "type": str(r["type"]),
+                        "description": str(r["description"] or ""),
+                        "chapter": int(r["chapter"] or 0),
+                        "action": "snapshot",
+                        "polarity": self._infer_relationship_polarity(str(r["type"])),
+                        "strength": 0.5,
+                        "evidence": "",
+                        "confidence": 1.0,
+                    }
+                    for r in rows
+                ]
+
+            clauses = ["chapter <= ?"]
+            params = [int(chapter)]
+            if relation_types:
+                placeholders = ",".join("?" for _ in relation_types)
+                clauses.append(f"type IN ({placeholders})")
+                params.extend(relation_types)
+
+            cursor.execute(
+                f"""
+                SELECT *
+                FROM relationship_events
+                WHERE {' AND '.join(clauses)}
+                ORDER BY chapter DESC, id DESC
+            """,
+                tuple(params),
+            )
+            event_rows = cursor.fetchall()
+
+            # 兼容旧数据:若事件流不完整,回退 relationships 快照补边
+            snapshot_clauses = ["chapter <= ?"]
+            snapshot_params: List[Any] = [int(chapter)]
+            if relation_types:
+                placeholders = ",".join("?" for _ in relation_types)
+                snapshot_clauses.append(f"type IN ({placeholders})")
+                snapshot_params.extend(relation_types)
+            cursor.execute(
+                f"""
+                SELECT from_entity, to_entity, type, description, chapter
+                FROM relationships
+                WHERE {' AND '.join(snapshot_clauses)}
+                ORDER BY chapter DESC, id DESC
+            """,
+                tuple(snapshot_params),
+            )
+            snapshot_rows = cursor.fetchall()
+
+        # 章节截面:相同关系只保留“最近一次事件”,remove 视为已失效。
+        effective: List[Dict[str, Any]] = []
+        seen: set[tuple[str, str, str]] = set()
+        for row in event_rows:
+            key = (
+                str(row["from_entity"]),
+                str(row["to_entity"]),
+                str(row["type"]),
+            )
+            if key in seen:
+                continue
+            seen.add(key)
+            action = str(row["action"] or "update")
+            if action == "remove":
+                continue
+            effective.append(
+                {
+                    "from": key[0],
+                    "to": key[1],
+                    "type": key[2],
+                    "description": str(row["description"] or ""),
+                    "chapter": int(row["chapter"] or 0),
+                    "action": action,
+                    "polarity": int(row["polarity"] or 0),
+                    "strength": float(row["strength"] or 0.5),
+                    "evidence": str(row["evidence"] or ""),
+                    "confidence": float(row["confidence"] or 1.0),
+                }
+            )
+
+        # 事件流缺失时,从关系快照补齐(若 key 已出现则以事件为准)
+        for row in snapshot_rows:
+            key = (
+                str(row["from_entity"]),
+                str(row["to_entity"]),
+                str(row["type"]),
+            )
+            if key in seen:
+                continue
+            effective.append(
+                {
+                    "from": key[0],
+                    "to": key[1],
+                    "type": key[2],
+                    "description": str(row["description"] or ""),
+                    "chapter": int(row["chapter"] or 0),
+                    "action": "snapshot",
+                    "polarity": self._infer_relationship_polarity(key[2]),
+                    "strength": 0.5,
+                    "evidence": "",
+                    "confidence": 1.0,
+                }
+            )
+        return effective
+
+    def build_relationship_subgraph(
+        self,
+        center_entity: str,
+        depth: int = 2,
+        chapter: Optional[int] = None,
+        top_edges: int = 50,
+        relation_types: Optional[List[str]] = None,
+    ) -> Dict[str, Any]:
+        """按中心实体构建关系子图。"""
+        center_entity = str(center_entity or "").strip()
+        depth = max(1, int(depth or 1))
+        top_edges = max(1, int(top_edges or 1))
+
+        edges_all = self._load_effective_relationship_edges(
+            chapter=chapter,
+            relation_types=relation_types,
+        )
+        edges_all.sort(key=lambda x: int(x.get("chapter", 0)), reverse=True)
+
+        selected_edges: List[Dict[str, Any]] = []
+        selected_keys: set[tuple[str, str, str]] = set()
+        visited_nodes: set[str] = {center_entity} if center_entity else set()
+        frontier: set[str] = {center_entity} if center_entity else set()
+
+        for _ in range(depth):
+            if not frontier:
+                break
+            next_frontier: set[str] = set()
+
+            for edge in edges_all:
+                from_entity = str(edge.get("from") or "")
+                to_entity = str(edge.get("to") or "")
+                if from_entity not in frontier and to_entity not in frontier:
+                    continue
+
+                key = (from_entity, to_entity, str(edge.get("type") or ""))
+                if key in selected_keys:
+                    continue
+                selected_keys.add(key)
+                selected_edges.append(edge)
+
+                if from_entity and from_entity not in visited_nodes:
+                    visited_nodes.add(from_entity)
+                    next_frontier.add(from_entity)
+                if to_entity and to_entity not in visited_nodes:
+                    visited_nodes.add(to_entity)
+                    next_frontier.add(to_entity)
+
+                if len(selected_edges) >= top_edges:
+                    break
+
+            frontier = next_frontier
+            if len(selected_edges) >= top_edges:
+                break
+
+        if center_entity and center_entity not in visited_nodes:
+            visited_nodes.add(center_entity)
+
+        # 查询节点详情
+        entity_map: Dict[str, Dict[str, Any]] = {}
+        if visited_nodes:
+            with self._get_conn() as conn:
+                cursor = conn.cursor()
+                placeholders = ",".join("?" for _ in visited_nodes)
+                cursor.execute(
+                    f"""
+                    SELECT id, canonical_name, type, tier, last_appearance
+                    FROM entities
+                    WHERE id IN ({placeholders})
+                """,
+                    tuple(visited_nodes),
+                )
+                for row in cursor.fetchall():
+                    entity_map[str(row["id"])] = {
+                        "id": str(row["id"]),
+                        "name": str(row["canonical_name"] or row["id"]),
+                        "type": str(row["type"] or "未知"),
+                        "tier": str(row["tier"] or "装饰"),
+                        "last_appearance": int(row["last_appearance"] or 0),
+                    }
+
+        nodes: List[Dict[str, Any]] = []
+        for entity_id in sorted(
+            visited_nodes,
+            key=lambda eid: (
+                0 if eid == center_entity else 1,
+                -(entity_map.get(eid, {}).get("last_appearance", 0)),
+                eid,
+            ),
+        ):
+            if entity_id in entity_map:
+                nodes.append(entity_map[entity_id])
+            else:
+                nodes.append(
+                    {
+                        "id": entity_id,
+                        "name": entity_id or "未知",
+                        "type": "未知",
+                        "tier": "装饰",
+                        "last_appearance": 0,
+                    }
+                )
+
+        return {
+            "center": center_entity,
+            "depth": depth,
+            "chapter": chapter,
+            "nodes": nodes,
+            "edges": selected_edges[:top_edges],
+            "generated_at": datetime.now().isoformat(timespec="seconds"),
+        }
+
+    def _sanitize_mermaid_node_id(self, raw_id: str) -> str:
+        safe = re.sub(r"[^0-9a-zA-Z_]", "_", str(raw_id or "node"))
+        if not safe:
+            safe = "node"
+        if safe[0].isdigit():
+            safe = f"n_{safe}"
+        return safe
+
+    def render_relationship_subgraph_mermaid(self, graph: Dict[str, Any]) -> str:
+        """将关系子图渲染为 Mermaid。"""
+        lines = ["```mermaid", "graph LR"]
+        nodes = graph.get("nodes") or []
+        edges = graph.get("edges") or []
+
+        if not nodes:
+            lines.append("    EMPTY[暂无关系数据]")
+            lines.append("```")
+            return "\n".join(lines)
+
+        node_alias: Dict[str, str] = {}
+        for node in nodes:
+            entity_id = str(node.get("id") or "")
+            if not entity_id:
+                continue
+            alias = self._sanitize_mermaid_node_id(entity_id)
+            node_alias[entity_id] = alias
+            label = str(node.get("name") or entity_id).replace('"', "'")
+            lines.append(f'    {alias}["{label}"]')
+
+        for edge in edges:
+            from_entity = str(edge.get("from") or "")
+            to_entity = str(edge.get("to") or "")
+            if from_entity not in node_alias or to_entity not in node_alias:
+                continue
+            edge_type = str(edge.get("type") or "关联")
+            chapter = edge.get("chapter")
+            chapter_suffix = f"@{chapter}" if chapter not in (None, "") else ""
+            label = f"{edge_type}{chapter_suffix}".replace('"', "'")
+            try:
+                polarity = int(edge.get("polarity", 0) or 0)
+            except (TypeError, ValueError):
+                polarity = 0
+            if polarity < 0:
+                connector = "-.->"
+            else:
+                connector = "-->"
+            lines.append(
+                f"    {node_alias[from_entity]} {connector}|{label}| {node_alias[to_entity]}"
+            )
+
+        lines.append("```")
+        return "\n".join(lines)
+
     # ==================== v5.3 Override Contract 操作 ====================
 
 

+ 133 - 0
.claude/scripts/data_modules/index_manager.py

@@ -115,6 +115,23 @@ class RelationshipMeta:
     chapter: int
 
 
+@dataclass
+class RelationshipEventMeta:
+    """关系事件记录 (v5.5 引入)"""
+
+    from_entity: str
+    to_entity: str
+    type: str
+    chapter: int
+    action: str = "update"  # create/update/decay/remove
+    polarity: int = 0  # -1/0/1
+    strength: float = 0.5  # 0~1
+    description: str = ""
+    scene_index: int = 0
+    evidence: str = ""
+    confidence: float = 1.0
+
+
 @dataclass
 class OverrideContractMeta:
     """Override Contract (v5.3 引入)"""
@@ -363,6 +380,37 @@ class IndexManager(IndexChapterMixin, IndexEntityMixin, IndexDebtMixin, IndexRea
                 "CREATE INDEX IF NOT EXISTS idx_relationships_chapter ON relationships(chapter)"
             )
 
+            # 关系事件表 (v5.5 引入,用于时序回放/图谱分析)
+            cursor.execute("""
+                CREATE TABLE IF NOT EXISTS relationship_events (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    from_entity TEXT NOT NULL,
+                    to_entity TEXT NOT NULL,
+                    type TEXT NOT NULL,
+                    action TEXT NOT NULL DEFAULT 'update',
+                    polarity INTEGER DEFAULT 0,
+                    strength REAL DEFAULT 0.5,
+                    description TEXT,
+                    chapter INTEGER NOT NULL,
+                    scene_index INTEGER DEFAULT 0,
+                    evidence TEXT,
+                    confidence REAL DEFAULT 1.0,
+                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+                )
+            """)
+            cursor.execute(
+                "CREATE INDEX IF NOT EXISTS idx_relationship_events_from_chapter ON relationship_events(from_entity, chapter)"
+            )
+            cursor.execute(
+                "CREATE INDEX IF NOT EXISTS idx_relationship_events_to_chapter ON relationship_events(to_entity, chapter)"
+            )
+            cursor.execute(
+                "CREATE INDEX IF NOT EXISTS idx_relationship_events_chapter ON relationship_events(chapter)"
+            )
+            cursor.execute(
+                "CREATE INDEX IF NOT EXISTS idx_relationship_events_type_chapter ON relationship_events(type, chapter)"
+            )
+
             # ==================== v5.3 引入表:追读力债务管理 ====================
 
             # Override Contract 表
@@ -664,6 +712,34 @@ def main():
         "--direction", choices=["from", "to", "both"], default="both"
     )
 
+    # 获取关系事件
+    rel_events_parser = subparsers.add_parser("get-relationship-events")
+    rel_events_parser.add_argument("--entity", required=True)
+    rel_events_parser.add_argument("--direction", choices=["from", "to", "both"], default="both")
+    rel_events_parser.add_argument("--from-chapter", type=int, default=None)
+    rel_events_parser.add_argument("--to-chapter", type=int, default=None)
+    rel_events_parser.add_argument("--limit", type=int, default=100)
+
+    # 获取关系图谱
+    rel_graph_parser = subparsers.add_parser("get-relationship-graph")
+    rel_graph_parser.add_argument("--center", required=True, help="中心实体 ID")
+    rel_graph_parser.add_argument("--depth", type=int, default=2)
+    rel_graph_parser.add_argument("--chapter", type=int, default=None)
+    rel_graph_parser.add_argument("--top-edges", type=int, default=50)
+    rel_graph_parser.add_argument("--format", choices=["json", "mermaid"], default="json")
+
+    # 获取关系时间线
+    rel_timeline_parser = subparsers.add_parser("get-relationship-timeline")
+    rel_timeline_parser.add_argument("--a", required=True, help="实体 A")
+    rel_timeline_parser.add_argument("--b", required=True, help="实体 B")
+    rel_timeline_parser.add_argument("--from-chapter", type=int, default=None)
+    rel_timeline_parser.add_argument("--to-chapter", type=int, default=None)
+    rel_timeline_parser.add_argument("--limit", type=int, default=100)
+
+    # 写入关系事件
+    rel_event_record_parser = subparsers.add_parser("record-relationship-event")
+    rel_event_record_parser.add_argument("--data", required=True, help="JSON 格式的关系事件数据")
+
     # 获取状态变化
     changes_parser = subparsers.add_parser("get-state-changes")
     changes_parser.add_argument("--entity", required=True)
@@ -901,10 +977,67 @@ def main():
         rels = manager.get_entity_relationships(args.entity, args.direction)
         emit_success(rels, message="relationships")
 
+    elif args.command == "get-relationship-events":
+        events = manager.get_relationship_events(
+            entity_id=args.entity,
+            direction=args.direction,
+            from_chapter=args.from_chapter,
+            to_chapter=args.to_chapter,
+            limit=args.limit,
+        )
+        emit_success(events, message="relationship_events")
+
+    elif args.command == "get-relationship-graph":
+        graph = manager.build_relationship_subgraph(
+            center_entity=args.center,
+            depth=args.depth,
+            chapter=args.chapter,
+            top_edges=args.top_edges,
+        )
+        if args.format == "mermaid":
+            emit_success({"mermaid": manager.render_relationship_subgraph_mermaid(graph)}, message="relationship_graph")
+        else:
+            emit_success(graph, message="relationship_graph")
+
+    elif args.command == "get-relationship-timeline":
+        timeline = manager.get_relationship_timeline(
+            entity1=args.a,
+            entity2=args.b,
+            from_chapter=args.from_chapter,
+            to_chapter=args.to_chapter,
+            limit=args.limit,
+        )
+        emit_success(timeline, message="relationship_timeline")
+
     elif args.command == "get-state-changes":
         changes = manager.get_entity_state_changes(args.entity, args.limit)
         emit_success(changes, message="state_changes")
 
+    elif args.command == "record-relationship-event":
+        try:
+            data = json.loads(args.data)
+        except (TypeError, ValueError, json.JSONDecodeError):
+            emit_error("INVALID_RELATIONSHIP_EVENT", "关系事件 JSON 无效")
+        else:
+            event = RelationshipEventMeta(
+                from_entity=data.get("from_entity", ""),
+                to_entity=data.get("to_entity", ""),
+                type=data.get("type", ""),
+                chapter=data.get("chapter", 0),
+                action=data.get("action", "update"),
+                polarity=data.get("polarity", 0),
+                strength=data.get("strength", 0.5),
+                description=data.get("description", ""),
+                scene_index=data.get("scene_index", 0),
+                evidence=data.get("evidence", ""),
+                confidence=data.get("confidence", 1.0),
+            )
+            event_id = manager.record_relationship_event(event)
+            if event_id > 0:
+                emit_success({"id": event_id}, message="relationship_event_recorded")
+            else:
+                emit_error("INVALID_RELATIONSHIP_EVENT", "关系事件参数无效,未写入")
+
     elif args.command == "upsert-entity":
         data = json.loads(args.data)
         entity = EntityMeta(

+ 4 - 0
.claude/scripts/data_modules/index_observability_mixin.py

@@ -177,6 +177,9 @@ class IndexObservabilityMixin:
             cursor.execute("SELECT COUNT(*) FROM relationships")
             relationships = cursor.fetchone()[0]
 
+            cursor.execute("SELECT COUNT(*) FROM relationship_events")
+            relationship_events = cursor.fetchone()[0]
+
             # v5.3 引入统计
             cursor.execute("SELECT COUNT(*) FROM override_contracts")
             override_contracts = cursor.fetchone()[0]
@@ -211,6 +214,7 @@ class IndexObservabilityMixin:
                 "aliases": aliases,
                 "state_changes": state_changes,
                 "relationships": relationships,
+                "relationship_events": relationship_events,
                 # v5.3 引入
                 "override_contracts": override_contracts,
                 "pending_overrides": pending_overrides,

+ 123 - 7
.claude/scripts/data_modules/query_router.py

@@ -4,24 +4,140 @@
 from __future__ import annotations
 
 import re
-from typing import List
+from typing import Any, Dict, List
 
 
 class QueryRouter:
     def __init__(self):
-        self.patterns = {
+        self.intent_patterns = {
+            "relationship": [r"关系", r"图谱", r"时间线", r"谁和谁", r"敌对", r"盟友"],
             "entity": [r"人物", r"角色", r"谁", r"身份", r"别名"],
             "scene": [r"地点", r"场景", r"哪里", r"位置"],
             "setting": [r"设定", r"规则", r"体系", r"世界观"],
             "plot": [r"剧情", r"发生", r"事件", r"经过"],
         }
+        self.patterns = {
+            "entity": list(self.intent_patterns["entity"]),
+            "scene": list(self.intent_patterns["scene"]),
+            "setting": list(self.intent_patterns["setting"]),
+            "plot": list(self.intent_patterns["plot"]),
+        }
+
+    def _extract_entities(self, query: str) -> List[str]:
+        # 轻量启发式提取:提取长度 2-6 的中文短语,过滤常见查询词
+        candidates = re.findall(r"[\u4e00-\u9fff]{2,6}", query)
+        stopwords = {
+            "关系",
+            "图谱",
+            "时间线",
+            "剧情",
+            "发生",
+            "事件",
+            "角色",
+            "人物",
+            "设定",
+            "世界观",
+            "地点",
+            "场景",
+        }
+        entities: List[str] = []
+        for c in candidates:
+            if c in stopwords:
+                continue
+            if c not in entities:
+                entities.append(c)
+        return entities[:4]
+
+    def _extract_time_scope(self, query: str) -> Dict[str, Any]:
+        m_range = re.search(r"第?\s*(\d+)\s*[-~到]\s*(\d+)\s*章", query)
+        if m_range:
+            start = int(m_range.group(1))
+            end = int(m_range.group(2))
+            if start > end:
+                start, end = end, start
+            return {"from_chapter": start, "to_chapter": end}
+
+        m_single = re.search(r"第?\s*(\d+)\s*章", query)
+        if m_single:
+            chapter = int(m_single.group(1))
+            return {"from_chapter": chapter, "to_chapter": chapter}
+
+        return {}
+
+    def route_intent(self, query: str) -> Dict[str, Any]:
+        query = str(query or "")
+        intent = "plot"
+        for intent_name, patterns in self.intent_patterns.items():
+            if any(re.search(pat, query) for pat in patterns):
+                intent = intent_name
+                break
+
+        time_scope = self._extract_time_scope(query)
+        entities = self._extract_entities(query)
+        needs_graph = intent == "relationship" or "关系" in query or "图谱" in query
+        return {
+            "intent": intent,
+            "entities": entities,
+            "time_scope": time_scope,
+            "needs_graph": needs_graph,
+            "raw_query": query,
+        }
+
+    def plan_subqueries(self, intent_payload: Dict[str, Any]) -> List[Dict[str, Any]]:
+        intent = str((intent_payload or {}).get("intent") or "plot")
+        entities = list((intent_payload or {}).get("entities") or [])
+        time_scope = dict((intent_payload or {}).get("time_scope") or {})
+        needs_graph = bool((intent_payload or {}).get("needs_graph"))
+
+        steps: List[Dict[str, Any]] = []
+        if intent == "relationship":
+            steps.append(
+                {
+                    "name": "relationship_graph",
+                    "strategy": "graph_lookup",
+                    "entities": entities,
+                    "time_scope": time_scope,
+                }
+            )
+            steps.append(
+                {
+                    "name": "relationship_evidence",
+                    "strategy": "graph_hybrid",
+                    "entities": entities,
+                    "time_scope": time_scope,
+                }
+            )
+            return steps
+
+        if needs_graph and entities:
+            steps.append(
+                {
+                    "name": "graph_enhanced_retrieval",
+                    "strategy": "graph_hybrid",
+                    "entities": entities,
+                    "time_scope": time_scope,
+                }
+            )
+            return steps
+
+        strategy_map = {
+            "entity": "hybrid",
+            "scene": "bm25",
+            "setting": "bm25",
+            "plot": "hybrid",
+        }
+        steps.append(
+            {
+                "name": "default_retrieval",
+                "strategy": strategy_map.get(intent, "hybrid"),
+                "entities": entities,
+                "time_scope": time_scope,
+            }
+        )
+        return steps
 
     def route(self, query: str) -> str:
-        for qtype, patterns in self.patterns.items():
-            for pat in patterns:
-                if re.search(pat, query):
-                    return qtype
-        return "plot"
+        return str(self.route_intent(query).get("intent") or "plot")
 
     def split(self, query: str) -> List[str]:
         parts = re.split(r"[,,;;以及和]\s*", query)

+ 506 - 13
.claude/scripts/data_modules/rag_adapter.py

@@ -31,6 +31,7 @@ from datetime import datetime
 from .config import get_config
 from .api_client import get_client
 from .index_manager import IndexManager
+from .query_router import QueryRouter
 from .observability import safe_log_tool_call
 
 
@@ -71,6 +72,7 @@ class RAGAdapter:
         self.config = config or get_config()
         self.api_client = get_client(config)
         self.index_manager = IndexManager(self.config)
+        self.query_router = QueryRouter()
         self._degraded_mode_reason: Optional[str] = None
         self._init_db()
 
@@ -257,16 +259,49 @@ class RAGAdapter:
             row = cursor.fetchone()
             return int(row[0] or 0) if row else 0
 
-    def _get_recent_chunk_ids(self, limit: int, chunk_type: str | None = None) -> List[str]:
+    def _get_recent_chunk_ids(
+        self,
+        limit: int,
+        chunk_type: str | None = None,
+        chapter: int | None = None,
+    ) -> List[str]:
         if limit <= 0:
             return []
         with self._get_conn() as conn:
             cursor = conn.cursor()
-            if chunk_type:
+            if chunk_type and chapter is not None:
                 cursor.execute(
-                    "SELECT chunk_id FROM vectors WHERE chunk_type = ? ORDER BY chapter DESC, scene_index DESC LIMIT ?",
+                    """
+                    SELECT chunk_id
+                    FROM vectors
+                    WHERE chunk_type = ? AND chapter <= ?
+                    ORDER BY chapter DESC, scene_index DESC
+                    LIMIT ?
+                """,
+                    (chunk_type, int(chapter), int(limit)),
+                )
+            elif chunk_type:
+                cursor.execute(
+                    """
+                    SELECT chunk_id
+                    FROM vectors
+                    WHERE chunk_type = ?
+                    ORDER BY chapter DESC, scene_index DESC
+                    LIMIT ?
+                """,
                     (chunk_type, int(limit)),
                 )
+            elif chapter is not None:
+                cursor.execute(
+                    """
+                    SELECT chunk_id
+                    FROM vectors
+                    WHERE chapter <= ?
+                    ORDER BY chapter DESC, scene_index DESC
+                    LIMIT ?
+                """,
+                    (int(chapter), int(limit)),
+                )
             else:
                 cursor.execute(
                     "SELECT chunk_id FROM vectors ORDER BY chapter DESC, scene_index DESC LIMIT ?",
@@ -547,11 +582,29 @@ class RAGAdapter:
         # 从数据库读取所有向量并计算相似度
         with self._get_conn() as conn:
             cursor = conn.cursor()
-            if chunk_type:
+            if chunk_type and chapter is not None:
+                cursor.execute(
+                    """
+                    SELECT chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file
+                    FROM vectors
+                    WHERE chunk_type = ? AND chapter <= ?
+                """,
+                    (chunk_type, int(chapter)),
+                )
+            elif chunk_type:
                 cursor.execute(
                     "SELECT chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file FROM vectors WHERE chunk_type = ?",
                     (chunk_type,),
                 )
+            elif chapter is not None:
+                cursor.execute(
+                    """
+                    SELECT chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file
+                    FROM vectors
+                    WHERE chapter <= ?
+                """,
+                    (int(chapter),),
+                )
             else:
                 cursor.execute(
                     "SELECT chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file FROM vectors"
@@ -666,7 +719,16 @@ class RAGAdapter:
             # 获取文档内容
             results = []
             for chunk_id, score in doc_scores.items():
-                if chunk_type:
+                if chunk_type and chapter is not None:
+                    cursor.execute(
+                        """
+                        SELECT chapter, scene_index, content, parent_chunk_id, chunk_type, source_file
+                        FROM vectors
+                        WHERE chunk_id = ? AND chunk_type = ? AND chapter <= ?
+                    """,
+                        (chunk_id, chunk_type, int(chapter)),
+                    )
+                elif chunk_type:
                     cursor.execute(
                         """
                         SELECT chapter, scene_index, content, parent_chunk_id, chunk_type, source_file
@@ -675,6 +737,15 @@ class RAGAdapter:
                     """,
                         (chunk_id, chunk_type),
                     )
+                elif chapter is not None:
+                    cursor.execute(
+                        """
+                        SELECT chapter, scene_index, content, parent_chunk_id, chunk_type, source_file
+                        FROM vectors
+                        WHERE chunk_id = ? AND chapter <= ?
+                    """,
+                        (chunk_id, int(chapter)),
+                    )
                 else:
                     cursor.execute(
                         """
@@ -705,6 +776,377 @@ class RAGAdapter:
             self._log_query(query, "bm25", results, latency_ms, chapter=chapter)
         return results
 
+    def _extract_query_seed_entities(self, query: str) -> List[str]:
+        """从查询中提取种子实体(通过别名和实体 ID 匹配)。"""
+        tokens = set(re.findall(r"[\u4e00-\u9fff]{2,8}|[A-Za-z][A-Za-z0-9_]{1,24}", query))
+        entity_ids: List[str] = []
+        for token in tokens:
+            if len(entity_ids) >= int(self.config.graph_rag_max_expanded_entities):
+                break
+
+            # 1) 通过别名匹配
+            alias_hits = self.index_manager.get_entities_by_alias(token)
+            for hit in alias_hits:
+                entity_id = str(hit.get("id") or "").strip()
+                if entity_id and entity_id not in entity_ids:
+                    entity_ids.append(entity_id)
+
+            if len(entity_ids) >= int(self.config.graph_rag_max_expanded_entities):
+                break
+
+            # 2) 通过实体 ID 直匹配
+            entity = self.index_manager.get_entity(token)
+            if entity:
+                entity_id = str(entity.get("id") or "").strip()
+                if entity_id and entity_id not in entity_ids:
+                    entity_ids.append(entity_id)
+
+        return entity_ids[: int(self.config.graph_rag_max_expanded_entities)]
+
+    def _normalize_entity_ids(self, candidates: List[str]) -> List[str]:
+        """将输入实体候选(名称/别名/ID)规范化为实体 ID 列表。"""
+        ids: List[str] = []
+        for token in candidates:
+            candidate = str(token or "").strip()
+            if not candidate:
+                continue
+            direct = self.index_manager.get_entity(candidate)
+            if direct and direct.get("id"):
+                entity_id = str(direct.get("id"))
+                if entity_id not in ids:
+                    ids.append(entity_id)
+                continue
+
+            for hit in self.index_manager.get_entities_by_alias(candidate):
+                entity_id = str(hit.get("id") or "").strip()
+                if entity_id and entity_id not in ids:
+                    ids.append(entity_id)
+        return ids[: int(self.config.graph_rag_max_expanded_entities)]
+
+    def _expand_related_entities(self, seed_entities: List[str], hops: int | None = None) -> List[str]:
+        """基于关系图扩展相关实体。"""
+        max_entities = int(self.config.graph_rag_max_expanded_entities)
+        hops = max(1, int(hops or self.config.graph_rag_expand_hops))
+        expanded: List[str] = []
+        for seed in seed_entities:
+            if seed not in expanded:
+                expanded.append(seed)
+            if len(expanded) >= max_entities:
+                break
+            graph = self.index_manager.build_relationship_subgraph(
+                center_entity=seed,
+                depth=hops,
+                top_edges=max(20, int(self.config.graph_rag_candidate_limit)),
+            )
+            for node in graph.get("nodes", []):
+                entity_id = str(node.get("id") or "").strip()
+                if entity_id and entity_id not in expanded:
+                    expanded.append(entity_id)
+                if len(expanded) >= max_entities:
+                    break
+            if len(expanded) >= max_entities:
+                break
+        return expanded[:max_entities]
+
+    def _collect_graph_candidate_chunk_ids(
+        self,
+        entity_ids: List[str],
+        *,
+        chapter: int | None = None,
+        limit: int | None = None,
+    ) -> List[str]:
+        """根据实体名称/别名在向量库正文中筛选候选 chunk。"""
+        if not entity_ids:
+            return []
+
+        limit = int(limit or self.config.graph_rag_candidate_limit)
+        entity_terms: Dict[str, set[str]] = {}
+        for entity_id in entity_ids:
+            terms: set[str] = set()
+            entity = self.index_manager.get_entity(entity_id)
+            if entity:
+                canonical_name = str(entity.get("canonical_name") or "").strip()
+                if canonical_name:
+                    terms.add(canonical_name)
+            for alias in self.index_manager.get_entity_aliases(entity_id):
+                alias_text = str(alias or "").strip()
+                if alias_text:
+                    terms.add(alias_text)
+            if terms:
+                entity_terms[entity_id] = terms
+
+        if not entity_terms:
+            return []
+
+        with self._get_conn() as conn:
+            cursor = conn.cursor()
+            if chapter is None:
+                cursor.execute(
+                    "SELECT chunk_id, chapter, content FROM vectors ORDER BY chapter DESC, scene_index DESC"
+                )
+            else:
+                cursor.execute(
+                    """
+                    SELECT chunk_id, chapter, content
+                    FROM vectors
+                    WHERE chapter <= ?
+                    ORDER BY chapter DESC, scene_index DESC
+                """,
+                    (int(chapter),),
+                )
+            rows = cursor.fetchall()
+
+        scored: List[Tuple[str, int, int]] = []
+        for chunk_id, chapter_no, content in rows:
+            text = str(content or "")
+            if not text:
+                continue
+            hit_score = 0
+            for terms in entity_terms.values():
+                hit_score += sum(1 for term in terms if term and term in text)
+            if hit_score > 0:
+                scored.append((str(chunk_id), int(chapter_no or 0), hit_score))
+
+        scored.sort(key=lambda x: (x[2], x[1]), reverse=True)
+        return [chunk_id for chunk_id, _chapter, _score in scored[:limit]]
+
+    async def _vector_search_by_chunk_ids(
+        self,
+        query: str,
+        chunk_ids: List[str],
+        *,
+        top_k: int,
+        chunk_type: str | None = None,
+    ) -> List[SearchResult]:
+        """在指定候选 chunk 范围内执行向量检索。"""
+        if not chunk_ids:
+            return []
+
+        query_embeddings = await self.api_client.embed([query])
+        if not query_embeddings:
+            self._update_degraded_mode()
+            return []
+        self._degraded_mode_reason = None
+
+        query_embedding = query_embeddings[0]
+        rows = await asyncio.to_thread(self._fetch_vectors_by_chunk_ids, chunk_ids)
+        if chunk_type:
+            rows = [r for r in rows if len(r) > 6 and r[6] == chunk_type]
+        return await asyncio.to_thread(
+            self._vector_search_rows,
+            query_embedding,
+            rows,
+            top_k=top_k,
+        )
+
+    def _apply_graph_priors(
+        self,
+        result: SearchResult,
+        *,
+        seed_terms: set[str],
+        related_terms: set[str],
+        max_chapter: int,
+    ) -> float:
+        """为图谱候选增加先验分。"""
+        score = float(result.score)
+        content = str(result.content or "")
+
+        if any(term and term in content for term in seed_terms):
+            score += float(self.config.graph_rag_boost_same_entity)
+        elif any(term and term in content for term in related_terms):
+            score += float(self.config.graph_rag_boost_related_entity)
+
+        if max_chapter > 0 and result.chapter is not None:
+            gap = max(0, max_chapter - int(result.chapter))
+            recency = max(0.0, 1.0 - min(gap, 100) / 100.0)
+            score += recency * float(self.config.graph_rag_boost_recency)
+
+        return score
+
+    async def graph_hybrid_search(
+        self,
+        query: str,
+        top_k: int = 5,
+        *,
+        chunk_type: str | None = None,
+        chapter: int | None = None,
+        center_entities: Optional[List[str]] = None,
+        log_query: bool = True,
+    ) -> List[SearchResult]:
+        """
+        图谱增强混合检索:
+        1) 先走现有 hybrid 作为基础召回;
+        2) 基于实体关系图扩展候选;
+        3) 向量重算 + 图谱先验融合;
+        4) rerank 产出最终结果。
+        """
+        start_time = time.perf_counter()
+
+        base_results = await self.hybrid_search(
+            query=query,
+            vector_top_k=max(top_k * 3, int(self.config.vector_top_k)),
+            bm25_top_k=max(top_k * 3, int(self.config.bm25_top_k)),
+            rerank_top_n=max(top_k * 2, int(self.config.rerank_top_n)),
+            chunk_type=chunk_type,
+            chapter=chapter,
+            log_query=False,
+        )
+        if not bool(self.config.graph_rag_enabled):
+            final = list(base_results)[:top_k]
+            if log_query:
+                latency_ms = int((time.perf_counter() - start_time) * 1000)
+                self._log_query(query, "graph_hybrid_fallback", final, latency_ms, chapter=chapter)
+            return final
+
+        seeds = self._normalize_entity_ids([s for s in (center_entities or []) if str(s).strip()])
+        if not seeds:
+            seeds = self._extract_query_seed_entities(query)
+
+        if not seeds:
+            final = list(base_results)[:top_k]
+            if log_query:
+                latency_ms = int((time.perf_counter() - start_time) * 1000)
+                self._log_query(query, "graph_hybrid_no_seed", final, latency_ms, chapter=chapter)
+            return final
+
+        expanded_entities = self._expand_related_entities(seeds)
+        candidate_chunk_ids = self._collect_graph_candidate_chunk_ids(
+            expanded_entities,
+            chapter=chapter,
+            limit=max(top_k * 8, int(self.config.graph_rag_candidate_limit)),
+        )
+
+        graph_vector_results = await self._vector_search_by_chunk_ids(
+            query,
+            candidate_chunk_ids,
+            top_k=max(top_k * 4, int(self.config.rerank_top_n) * 2),
+            chunk_type=chunk_type,
+        )
+
+        # 构建实体术语集用于先验分
+        seed_terms: set[str] = set()
+        related_terms: set[str] = set()
+        for idx, entity_id in enumerate(expanded_entities):
+            entity = self.index_manager.get_entity(entity_id)
+            canonical_name = str((entity or {}).get("canonical_name") or "").strip()
+            aliases = [str(a).strip() for a in self.index_manager.get_entity_aliases(entity_id)]
+            terms = {t for t in [canonical_name, *aliases] if t}
+            if idx < len(seeds):
+                seed_terms.update(terms)
+            else:
+                related_terms.update(terms)
+
+        max_chapter = 0
+        try:
+            max_chapter = int(self.get_stats().get("max_chapter") or 0)
+        except Exception:
+            max_chapter = 0
+        if chapter is not None:
+            try:
+                max_chapter = int(chapter)
+            except (TypeError, ValueError):
+                pass
+
+        merged: Dict[str, SearchResult] = {}
+        for result in base_results:
+            result.source = "graph_hybrid"
+            merged[result.chunk_id] = result
+
+        for result in graph_vector_results:
+            adjusted = self._apply_graph_priors(
+                result,
+                seed_terms=seed_terms,
+                related_terms=related_terms,
+                max_chapter=max_chapter,
+            )
+            result.score = adjusted
+            result.source = "graph_hybrid"
+            existing = merged.get(result.chunk_id)
+            if existing is None or result.score > existing.score:
+                merged[result.chunk_id] = result
+
+        sorted_candidates = sorted(merged.values(), key=lambda r: r.score, reverse=True)
+        candidates = sorted_candidates[: max(top_k * 3, int(self.config.rerank_top_n) * 2)]
+        if not candidates:
+            if log_query:
+                latency_ms = int((time.perf_counter() - start_time) * 1000)
+                self._log_query(query, "graph_hybrid", [], latency_ms, chapter=chapter)
+            return []
+
+        rerank_top_n = max(top_k, int(self.config.rerank_top_n))
+        rerank_input = [c.content for c in candidates]
+        rerank_results = await self.api_client.rerank(query, rerank_input, top_n=rerank_top_n)
+
+        final_results: List[SearchResult] = []
+        if rerank_results:
+            for item in rerank_results:
+                idx = int(item.get("index", 0))
+                if idx < 0 or idx >= len(candidates):
+                    continue
+                picked = candidates[idx]
+                picked.score = float(item.get("relevance_score", picked.score))
+                picked.source = "graph_hybrid"
+                final_results.append(picked)
+        else:
+            final_results = candidates[:rerank_top_n]
+
+        final_results = final_results[:top_k]
+        if log_query:
+            latency_ms = int((time.perf_counter() - start_time) * 1000)
+            self._log_query(query, "graph_hybrid", final_results, latency_ms, chapter=chapter)
+        return final_results
+
+    async def search(
+        self,
+        query: str,
+        top_k: int = 5,
+        *,
+        strategy: str = "auto",
+        chunk_type: str | None = None,
+        chapter: int | None = None,
+        center_entities: Optional[List[str]] = None,
+        filters: Optional[Dict[str, Any]] = None,
+    ) -> List[SearchResult]:
+        """统一检索入口。"""
+        strategy = str(strategy or "auto").lower()
+        if filters and chapter is None:
+            try:
+                chapter = int((filters or {}).get("to_chapter") or 0) or None
+            except (TypeError, ValueError):
+                chapter = None
+
+        if strategy == "auto":
+            intent_payload = self.query_router.route_intent(query)
+            if bool(self.config.graph_rag_enabled) and bool(intent_payload.get("needs_graph")):
+                strategy = "graph_hybrid"
+                if not center_entities:
+                    center_entities = list(intent_payload.get("entities") or [])
+            else:
+                strategy = "hybrid"
+
+        if strategy == "vector":
+            return await self.vector_search(query, top_k=top_k, chunk_type=chunk_type, chapter=chapter)
+        if strategy == "bm25":
+            return self.bm25_search(query, top_k=top_k, chunk_type=chunk_type, chapter=chapter)
+        if strategy == "backtrack":
+            return await self.search_with_backtrack(query, top_k=top_k)
+        if strategy == "graph_hybrid":
+            return await self.graph_hybrid_search(
+                query=query,
+                top_k=top_k,
+                chunk_type=chunk_type,
+                chapter=chapter,
+                center_entities=center_entities,
+            )
+        return await self.hybrid_search(
+            query=query,
+            vector_top_k=top_k,
+            bm25_top_k=top_k,
+            rerank_top_n=top_k,
+            chunk_type=chunk_type,
+            chapter=chapter,
+        )
+
     # ==================== 混合检索 ====================
 
     async def hybrid_search(
@@ -714,6 +1156,7 @@ class RAGAdapter:
         bm25_top_k: int = None,
         rerank_top_n: int = None,
         chunk_type: str | None = None,
+        chapter: int | None = None,
         log_query: bool = True,
     ) -> List[SearchResult]:
         """
@@ -737,8 +1180,8 @@ class RAGAdapter:
         if use_full_scan:
             # 并行执行向量和 BM25 检索
             vector_results, bm25_results = await asyncio.gather(
-                self.vector_search(query, vector_top_k, chunk_type=chunk_type, log_query=False),
-                asyncio.to_thread(self.bm25_search, query, bm25_top_k, 1.5, 0.75, chunk_type, False),
+                self.vector_search(query, vector_top_k, chunk_type=chunk_type, log_query=False, chapter=chapter),
+                asyncio.to_thread(self.bm25_search, query, bm25_top_k, 1.5, 0.75, chunk_type, False, chapter),
             )
         else:
             bm25_candidates = max(
@@ -753,8 +1196,17 @@ class RAGAdapter:
                 int(rerank_top_n) * 10,
             )
 
-            bm25_task = asyncio.to_thread(self.bm25_search, query, bm25_candidates, 1.5, 0.75, chunk_type, False)
-            recent_task = asyncio.to_thread(self._get_recent_chunk_ids, recent_candidates, chunk_type)
+            bm25_task = asyncio.to_thread(
+                self.bm25_search,
+                query,
+                bm25_candidates,
+                1.5,
+                0.75,
+                chunk_type,
+                False,
+                chapter,
+            )
+            recent_task = asyncio.to_thread(self._get_recent_chunk_ids, recent_candidates, chunk_type, chapter)
             embed_task = self.api_client.embed([query])
 
             bm25_candidates_results, recent_ids, query_embeddings = await asyncio.gather(
@@ -775,6 +1227,8 @@ class RAGAdapter:
             rows = await asyncio.to_thread(self._fetch_vectors_by_chunk_ids, list(candidate_ids))
             if chunk_type:
                 rows = [r for r in rows if len(r) > 6 and r[6] == chunk_type]
+            if chapter is not None:
+                rows = [r for r in rows if len(r) > 1 and int(r[1] or 0) <= int(chapter)]
             vector_results = await asyncio.to_thread(
                 self._vector_search_rows,
                 query_embedding,
@@ -813,7 +1267,7 @@ class RAGAdapter:
             final_results: List[SearchResult] = []
             latency_ms = int((time.perf_counter() - start_time) * 1000)
             if log_query:
-                self._log_query(query, "hybrid", final_results, latency_ms)
+                self._log_query(query, "hybrid", final_results, latency_ms, chapter=chapter)
             return final_results
 
         # 调用 Rerank API
@@ -825,7 +1279,7 @@ class RAGAdapter:
             final_results = [item["result"] for item in sorted_results[:rerank_top_n]]
             latency_ms = int((time.perf_counter() - start_time) * 1000)
             if log_query:
-                self._log_query(query, "hybrid", final_results, latency_ms)
+                self._log_query(query, "hybrid", final_results, latency_ms, chapter=chapter)
             return final_results
 
         # 组装最终结果
@@ -840,7 +1294,7 @@ class RAGAdapter:
 
         latency_ms = int((time.perf_counter() - start_time) * 1000)
         if log_query:
-            self._log_query(query, "hybrid", final_results, latency_ms)
+            self._log_query(query, "hybrid", final_results, latency_ms, chapter=chapter)
         return final_results
 
     def _get_chunks_by_ids(self, chunk_ids: List[str]) -> List[SearchResult]:
@@ -951,9 +1405,18 @@ def main():
     # 搜索
     search_parser = subparsers.add_parser("search")
     search_parser.add_argument("--query", required=True)
-    search_parser.add_argument("--mode", choices=["vector", "bm25", "hybrid", "backtrack"], default="hybrid")
+    search_parser.add_argument(
+        "--mode",
+        choices=["auto", "vector", "bm25", "hybrid", "graph_hybrid", "backtrack"],
+        default="hybrid",
+    )
     search_parser.add_argument("--top-k", type=int, default=5)
     search_parser.add_argument("--chunk-type", choices=["scene", "summary"], default=None)
+    search_parser.add_argument(
+        "--center-entities",
+        required=False,
+        help="中心实体列表(JSON 数组或逗号分隔)",
+    )
 
     args = parser.parse_args()
 
@@ -1034,12 +1497,42 @@ def main():
             emit_success(result, message="indexed")
 
     elif args.command == "search":
+        center_entities: List[str] | None = None
+        if getattr(args, "center_entities", None):
+            raw = str(args.center_entities).strip()
+            if raw:
+                try:
+                    parsed = json.loads(raw)
+                    if isinstance(parsed, list):
+                        center_entities = [str(x).strip() for x in parsed if str(x).strip()]
+                except Exception:
+                    center_entities = [x.strip() for x in re.split(r"[,,;;\s]+", raw) if x.strip()]
+
         if args.mode == "vector":
             results = asyncio.run(adapter.vector_search(args.query, args.top_k, chunk_type=args.chunk_type))
         elif args.mode == "bm25":
             results = adapter.bm25_search(args.query, args.top_k, chunk_type=args.chunk_type)
         elif args.mode == "backtrack":
             results = asyncio.run(adapter.search_with_backtrack(args.query, args.top_k))
+        elif args.mode == "graph_hybrid":
+            results = asyncio.run(
+                adapter.graph_hybrid_search(
+                    args.query,
+                    args.top_k,
+                    chunk_type=args.chunk_type,
+                    center_entities=center_entities,
+                )
+            )
+        elif args.mode == "auto":
+            results = asyncio.run(
+                adapter.search(
+                    args.query,
+                    args.top_k,
+                    strategy="auto",
+                    chunk_type=args.chunk_type,
+                    center_entities=center_entities,
+                )
+            )
         else:
             results = asyncio.run(adapter.hybrid_search(args.query, args.top_k, args.top_k, args.top_k, chunk_type=args.chunk_type))
 

+ 23 - 3
.claude/scripts/data_modules/sql_state_manager.py

@@ -21,7 +21,8 @@ from .index_manager import (
     IndexManager,
     EntityMeta,
     StateChangeMeta,
-    RelationshipMeta
+    RelationshipMeta,
+    RelationshipEventMeta,
 )
 from .config import get_config
 from .observability import safe_log_tool_call
@@ -384,12 +385,31 @@ class SQLStateManager:
             to_entity = rel.get("to", rel.get("to_entity"))
             if not from_entity or not to_entity:
                 continue
+            rel_type = rel.get("type", "相识")
+            description = rel.get("description", "")
+
+            # v5.5: 先记录关系事件,再更新关系快照
+            self._index_manager.record_relationship_event(
+                RelationshipEventMeta(
+                    from_entity=from_entity,
+                    to_entity=to_entity,
+                    type=rel_type,
+                    chapter=chapter,
+                    action=rel.get("action", "update"),
+                    polarity=rel.get("polarity", 0),
+                    strength=rel.get("strength", 0.5),
+                    description=description,
+                    scene_index=rel.get("scene_index", 0),
+                    evidence=rel.get("evidence", ""),
+                    confidence=rel.get("confidence", 1.0),
+                )
+            )
 
             self.upsert_relationship(
                 from_entity=from_entity,
                 to_entity=to_entity,
-                type=rel.get("type", "相识"),
-                description=rel.get("description", ""),
+                type=rel_type,
+                description=description,
                 chapter=chapter
             )
             stats["relationships"] += 1

+ 8 - 0
.claude/scripts/data_modules/tests/test_context_manager.py

@@ -101,6 +101,14 @@ def test_query_router():
     router = QueryRouter()
     assert router.route("角色是谁") == "entity"
     assert router.route("发生了什么剧情") == "plot"
+    intent = router.route_intent("第10-20章萧炎和药老关系图谱")
+    assert intent["intent"] == "relationship"
+    assert intent["needs_graph"] is True
+    assert intent["time_scope"]["from_chapter"] == 10
+    assert intent["time_scope"]["to_chapter"] == 20
+    plans = router.plan_subqueries(intent)
+    assert plans
+    assert plans[0]["strategy"] in {"graph_lookup", "graph_hybrid"}
     assert "A" in router.split("A, B;C")
 
 

+ 43 - 0
.claude/scripts/data_modules/tests/test_extract_chapter_context.py

@@ -158,6 +158,9 @@ def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
     assert isinstance(payload["writing_guidance"].get("checklist"), list)
     assert isinstance(payload["writing_guidance"].get("checklist_score"), dict)
     assert payload["genre_profile"].get("genre") == "xuanhuan"
+    assert "rag_assist" in payload
+    assert isinstance(payload["rag_assist"], dict)
+    assert payload["rag_assist"].get("invoked") is False
 
 
 def test_render_text_contains_writing_guidance_section(tmp_path):
@@ -212,3 +215,43 @@ def test_render_text_contains_writing_guidance_section(tmp_path):
     assert "### 执行评分" in text
     assert "- 评分: 81.5" in text
     assert "- 复合题材: xuanhuan + realistic" in text
+
+
+def test_render_text_contains_rag_assist_section_when_hits_exist(tmp_path):
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+    from extract_chapter_context import _render_text
+
+    payload = {
+        "chapter": 12,
+        "outline": "测试大纲",
+        "previous_summaries": [],
+        "state_summary": "状态",
+        "context_contract_version": "v2",
+        "reader_signal": {},
+        "genre_profile": {},
+        "writing_guidance": {},
+        "rag_assist": {
+            "invoked": True,
+            "mode": "auto",
+            "intent": "relationship",
+            "query": "第12章 人物关系与动机:萧炎与药老发生冲突",
+            "hits": [
+                {
+                    "chapter": 9,
+                    "scene_index": 2,
+                    "source": "graph_hybrid",
+                    "score": 0.91,
+                    "content": "萧炎与药老在修炼方向上发生分歧。",
+                }
+            ],
+        },
+    }
+
+    text = _render_text(payload)
+    assert "## RAG 检索线索" in text
+    assert "- 模式: auto" in text
+    assert "[graph_hybrid]" in text
+    assert "萧炎与药老" in text

+ 129 - 3
.claude/scripts/data_modules/tests/test_rag_adapter.py

@@ -9,12 +9,14 @@ import json
 import asyncio
 import logging
 import sqlite3
+from contextlib import closing
 
 import pytest
 
 import data_modules.rag_adapter as rag_module
 from data_modules.rag_adapter import RAGAdapter
 from data_modules.config import DataModulesConfig
+from data_modules.index_manager import EntityMeta, RelationshipMeta
 
 
 class StubClient:
@@ -121,6 +123,124 @@ async def test_hybrid_search_prefilter(tmp_path, monkeypatch):
     assert results
 
 
+@pytest.mark.asyncio
+async def test_search_respects_chapter_filter_across_strategies(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    cfg.vector_full_scan_max_vectors = 0  # 强制走预筛选分支
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
+    adapter = RAGAdapter(cfg)
+    await adapter.store_chunks(
+        [
+            {"chapter": 1, "scene_index": 1, "content": "前文线索,尚未涉及关键宝物"},
+            {"chapter": 2, "scene_index": 1, "content": "秘宝现世,引发争夺"},
+            {"chapter": 3, "scene_index": 1, "content": "秘宝大战彻底爆发"},
+        ]
+    )
+
+    vector_results = await adapter.vector_search("秘宝", top_k=5, chapter=1)
+    assert vector_results
+    assert all((r.chapter or 0) <= 1 for r in vector_results)
+
+    bm25_results = adapter.bm25_search("秘宝", top_k=5, chapter=1)
+    assert bm25_results
+    assert all((r.chapter or 0) <= 1 for r in bm25_results)
+
+    hybrid_results = await adapter.hybrid_search(
+        "秘宝",
+        vector_top_k=5,
+        bm25_top_k=5,
+        rerank_top_n=3,
+        chapter=1,
+    )
+    assert hybrid_results
+    assert all((r.chapter or 0) <= 1 for r in hybrid_results)
+
+
+@pytest.mark.asyncio
+async def test_graph_hybrid_search_with_entity_expansion(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    cfg.graph_rag_enabled = True
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
+    adapter = RAGAdapter(cfg)
+
+    adapter.index_manager.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={},
+            first_appearance=1,
+            last_appearance=2,
+        )
+    )
+    adapter.index_manager.upsert_entity(
+        EntityMeta(
+            id="yaolao",
+            type="角色",
+            canonical_name="药老",
+            current={},
+            first_appearance=1,
+            last_appearance=2,
+        )
+    )
+    adapter.index_manager.register_alias("萧炎", "xiaoyan", "角色")
+    adapter.index_manager.register_alias("药老", "yaolao", "角色")
+    adapter.index_manager.upsert_relationship(
+        RelationshipMeta(
+            from_entity="xiaoyan",
+            to_entity="yaolao",
+            type="师徒",
+            description="收徒",
+            chapter=1,
+        )
+    )
+
+    await adapter.store_chunks(
+        [
+            {"chapter": 1, "scene_index": 1, "content": "萧炎拜药老为师,正式成为师徒"},
+            {"chapter": 2, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"},
+        ]
+    )
+
+    results = await adapter.graph_hybrid_search(
+        "萧炎和药老关系",
+        top_k=2,
+        center_entities=["萧炎", "药老"],
+    )
+    assert results
+    assert any("药老" in r.content for r in results)
+    assert all(r.source == "graph_hybrid" for r in results)
+
+
+@pytest.mark.asyncio
+async def test_search_auto_uses_graph_strategy_when_enabled(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    cfg.graph_rag_enabled = True
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
+    adapter = RAGAdapter(cfg)
+    adapter.index_manager.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+        )
+    )
+    adapter.index_manager.register_alias("萧炎", "xiaoyan", "角色")
+    await adapter.store_chunks(
+        [{"chapter": 1, "scene_index": 1, "content": "萧炎突破斗师"}]
+    )
+
+    results = await adapter.search("萧炎关系", top_k=1, strategy="auto")
+    assert results
+    assert results[0].source in {"graph_hybrid", "hybrid"}
+
+
 @pytest.mark.asyncio
 async def test_search_with_backtrack(temp_project):
     adapter = RAGAdapter(temp_project)
@@ -165,10 +285,15 @@ def test_recent_and_fetch_vectors(temp_project):
             "INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
             ("ch0001_s1", 1, 1, "内容", b"", None, "scene", "正文/第0001章.md#scene_1"),
         )
+        cursor.execute(
+            "INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+            ("ch0002_s1", 2, 1, "后文内容", b"", None, "scene", "正文/第0002章.md#scene_1"),
+        )
         conn.commit()
 
-    assert adapter._get_vectors_count() == 1
-    assert adapter._get_recent_chunk_ids(1) == ["ch0001_s1"]
+    assert adapter._get_vectors_count() == 2
+    assert adapter._get_recent_chunk_ids(1) == ["ch0002_s1"]
+    assert adapter._get_recent_chunk_ids(10, chapter=1) == ["ch0001_s1"]
     rows = adapter._fetch_vectors_by_chunk_ids(["ch0001_s1"])
     assert len(rows) == 1
 
@@ -179,7 +304,7 @@ def test_init_db_migrates_legacy_vectors_schema(tmp_path, monkeypatch):
     monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
 
     # 旧结构:缺少 parent_chunk_id/chunk_type/source_file/created_at
-    with sqlite3.connect(str(cfg.vector_db)) as conn:
+    with closing(sqlite3.connect(str(cfg.vector_db))) as conn:
         cursor = conn.cursor()
         cursor.execute(
             """
@@ -246,6 +371,7 @@ def test_rag_adapter_cli(temp_project, monkeypatch, capsys):
     run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "bm25", "--top-k", "5"])
     run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "vector", "--top-k", "5"])
     run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "hybrid", "--top-k", "5"])
+    run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "auto", "--top-k", "5"])
 
     capsys.readouterr()
 

+ 333 - 0
.claude/scripts/data_modules/tests/test_relationship_graph.py

@@ -0,0 +1,333 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+关系事件与关系图谱测试
+"""
+
+import json
+import sys
+
+import pytest
+
+import data_modules.index_manager as index_manager_module
+from data_modules.config import DataModulesConfig
+from data_modules.index_manager import (
+    EntityMeta,
+    IndexManager,
+    RelationshipEventMeta,
+    RelationshipMeta,
+)
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_relationship_events_timeline_and_subgraph(temp_project):
+    manager = IndexManager(temp_project)
+    manager.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={},
+            first_appearance=1,
+            last_appearance=10,
+            is_protagonist=True,
+        )
+    )
+    manager.upsert_entity(
+        EntityMeta(
+            id="yaolao",
+            type="角色",
+            canonical_name="药老",
+            tier="重要",
+            current={},
+            first_appearance=1,
+            last_appearance=10,
+        )
+    )
+    manager.upsert_entity(
+        EntityMeta(
+            id="lintian",
+            type="角色",
+            canonical_name="林天",
+            tier="重要",
+            current={},
+            first_appearance=2,
+            last_appearance=10,
+        )
+    )
+    manager.upsert_relationship(
+        RelationshipMeta(
+            from_entity="xiaoyan",
+            to_entity="yaolao",
+            type="师徒",
+            description="正式拜师",
+            chapter=3,
+        )
+    )
+    manager.upsert_relationship(
+        RelationshipMeta(
+            from_entity="yaolao",
+            to_entity="lintian",
+            type="敌对",
+            description="理念冲突",
+            chapter=5,
+        )
+    )
+    event_id = manager.record_relationship_event(
+        RelationshipEventMeta(
+            from_entity="xiaoyan",
+            to_entity="yaolao",
+            type="师徒",
+            chapter=3,
+            action="create",
+            polarity=1,
+            strength=0.9,
+            description="拜师",
+            evidence="公开收徒",
+            confidence=0.95,
+        )
+    )
+    assert event_id > 0
+    manager.record_relationship_event(
+        RelationshipEventMeta(
+            from_entity="yaolao",
+            to_entity="lintian",
+            type="敌对",
+            chapter=5,
+            action="create",
+            polarity=-1,
+            strength=0.8,
+            description="结怨",
+            evidence="比斗失手",
+            confidence=0.8,
+        )
+    )
+
+    events = manager.get_relationship_events("xiaoyan", direction="both", limit=20)
+    assert events
+    timeline = manager.get_relationship_timeline("xiaoyan", "yaolao", limit=20)
+    assert timeline
+    assert timeline[0]["type"] == "师徒"
+
+    graph = manager.build_relationship_subgraph("xiaoyan", depth=2, chapter=10, top_edges=10)
+    node_ids = {n["id"] for n in graph["nodes"]}
+    assert "xiaoyan" in node_ids
+    assert "yaolao" in node_ids
+    assert "lintian" in node_ids
+    assert graph["edges"]
+    mermaid = manager.render_relationship_subgraph_mermaid(graph)
+    assert "mermaid" in mermaid
+    assert "师徒" in mermaid
+
+
+def test_relationship_subgraph_respects_chapter_slice(temp_project):
+    manager = IndexManager(temp_project)
+    manager.upsert_entity(
+        EntityMeta(
+            id="a",
+            type="角色",
+            canonical_name="甲",
+            current={},
+            first_appearance=1,
+            last_appearance=3,
+            is_protagonist=True,
+        )
+    )
+    manager.upsert_entity(
+        EntityMeta(
+            id="b",
+            type="角色",
+            canonical_name="乙",
+            current={},
+            first_appearance=1,
+            last_appearance=3,
+        )
+    )
+    manager.record_relationship_event(
+        RelationshipEventMeta(
+            from_entity="a",
+            to_entity="b",
+            type="同盟",
+            chapter=1,
+            action="create",
+            polarity=1,
+            strength=0.6,
+        )
+    )
+    manager.record_relationship_event(
+        RelationshipEventMeta(
+            from_entity="a",
+            to_entity="b",
+            type="同盟",
+            chapter=2,
+            action="remove",
+            polarity=0,
+            strength=0.0,
+        )
+    )
+
+    graph_ch1 = manager.build_relationship_subgraph("a", depth=1, chapter=1, top_edges=10)
+    graph_ch3 = manager.build_relationship_subgraph("a", depth=1, chapter=3, top_edges=10)
+    assert len(graph_ch1["edges"]) == 1
+    assert len(graph_ch3["edges"]) == 0
+
+
+def test_relationship_subgraph_fallbacks_to_snapshot_when_events_missing(temp_project):
+    manager = IndexManager(temp_project)
+    manager.upsert_entity(
+        EntityMeta(
+            id="a",
+            type="角色",
+            canonical_name="甲",
+            current={},
+            first_appearance=1,
+            last_appearance=5,
+            is_protagonist=True,
+        )
+    )
+    manager.upsert_entity(
+        EntityMeta(
+            id="b",
+            type="角色",
+            canonical_name="乙",
+            current={},
+            first_appearance=1,
+            last_appearance=5,
+        )
+    )
+    # 只写 relationships 快照,不写 relationship_events
+    manager.upsert_relationship(
+        RelationshipMeta(
+            from_entity="a",
+            to_entity="b",
+            type="同盟",
+            description="旧版快照数据",
+            chapter=3,
+        )
+    )
+
+    graph = manager.build_relationship_subgraph("a", depth=1, chapter=3, top_edges=10)
+    assert graph["edges"]
+    assert graph["edges"][0]["action"] == "snapshot"
+    assert graph["edges"][0]["type"] == "同盟"
+
+
+def test_relationship_graph_cli_commands(temp_project, monkeypatch, capsys):
+    manager = IndexManager(temp_project)
+    manager.upsert_entity(
+        EntityMeta(
+            id="hero",
+            type="角色",
+            canonical_name="主角",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+            is_protagonist=True,
+        )
+    )
+    manager.upsert_entity(
+        EntityMeta(
+            id="mentor",
+            type="角色",
+            canonical_name="师父",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+        )
+    )
+    manager.record_relationship_event(
+        RelationshipEventMeta(
+            from_entity="hero",
+            to_entity="mentor",
+            type="师徒",
+            chapter=1,
+            action="create",
+            polarity=1,
+            strength=0.9,
+        )
+    )
+
+    root = str(temp_project.project_root)
+
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", ["index_manager"] + args)
+        index_manager_module.main()
+        output = capsys.readouterr().out.strip().splitlines()
+        assert output
+        return json.loads(output[-1])
+
+    payload = run_cli(
+        [
+            "--project-root",
+            root,
+            "get-relationship-events",
+            "--entity",
+            "hero",
+            "--direction",
+            "both",
+            "--limit",
+            "10",
+        ]
+    )
+    assert payload["status"] == "success"
+    assert payload["data"]
+
+    payload = run_cli(
+        [
+            "--project-root",
+            root,
+            "get-relationship-graph",
+            "--center",
+            "hero",
+            "--depth",
+            "1",
+            "--chapter",
+            "1",
+            "--format",
+            "mermaid",
+        ]
+    )
+    assert payload["status"] == "success"
+    assert "mermaid" in payload["data"]["mermaid"]
+
+    payload = run_cli(
+        [
+            "--project-root",
+            root,
+            "get-relationship-timeline",
+            "--a",
+            "hero",
+            "--b",
+            "mentor",
+            "--limit",
+            "10",
+        ]
+    )
+    assert payload["status"] == "success"
+    assert payload["data"]
+
+    payload = run_cli(
+        [
+            "--project-root",
+            root,
+            "record-relationship-event",
+            "--data",
+            json.dumps(
+                {
+                    "from_entity": "hero",
+                    "type": "师徒",
+                    "chapter": 1,
+                },
+                ensure_ascii=False,
+            ),
+        ]
+    )
+    assert payload["status"] == "error"
+    assert payload["error"]["code"] == "INVALID_RELATIONSHIP_EVENT"

+ 2 - 0
.claude/scripts/data_modules/tests/test_sql_state_manager.py

@@ -105,6 +105,8 @@ def test_sql_state_manager_process_chapter_entities_and_exports(temp_project):
     )
     assert stats["entities_created"] >= 1
     assert stats["relationships"] == 1
+    rel_events = manager._index_manager.get_relationship_events("xiaoyan", direction="both")
+    assert len(rel_events) >= 1
 
     entities_v3 = manager.export_to_entities_v3_format()
     assert "角色" in entities_v3

+ 74 - 1
.claude/scripts/data_modules/tests/test_status_reporter.py

@@ -5,7 +5,13 @@ import json
 import tempfile
 
 from data_modules.config import DataModulesConfig
-from data_modules.index_manager import IndexManager, ChapterReadingPowerMeta
+from data_modules.index_manager import (
+    IndexManager,
+    ChapterReadingPowerMeta,
+    EntityMeta,
+    RelationshipMeta,
+    RelationshipEventMeta,
+)
 from status_reporter import StatusReporter
 
 
@@ -160,3 +166,70 @@ def test_pacing_analysis_marks_missing_data_instead_of_assuming_one_point_per_ch
         assert seg["rating"] == "数据不足"
         assert seg["missing_chapters"] == 1
 
+
+def test_relationship_graph_prefers_index_db_data():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config = DataModulesConfig.from_project_root(tmpdir)
+        config.ensure_dirs()
+        project_root = config.project_root
+
+        state = {
+            "progress": {"current_chapter": 12, "total_words": 24000},
+            "protagonist_state": {"name": "萧炎"},
+            "relationships": {"allies": [{"name": "旧盟友", "relation": "友好"}], "enemies": []},
+        }
+        _write_state(project_root, state)
+
+        idx = IndexManager(config)
+        idx.upsert_entity(
+            EntityMeta(
+                id="xiaoyan",
+                type="角色",
+                canonical_name="萧炎",
+                tier="核心",
+                current={},
+                first_appearance=1,
+                last_appearance=12,
+                is_protagonist=True,
+            )
+        )
+        idx.upsert_entity(
+            EntityMeta(
+                id="yaolao",
+                type="角色",
+                canonical_name="药老",
+                tier="重要",
+                current={},
+                first_appearance=1,
+                last_appearance=12,
+            )
+        )
+        idx.upsert_relationship(
+            RelationshipMeta(
+                from_entity="xiaoyan",
+                to_entity="yaolao",
+                type="师徒",
+                description="师徒关系",
+                chapter=10,
+            )
+        )
+        idx.record_relationship_event(
+            RelationshipEventMeta(
+                from_entity="xiaoyan",
+                to_entity="yaolao",
+                type="师徒",
+                chapter=10,
+                action="create",
+                polarity=1,
+                strength=0.9,
+                description="拜师",
+                evidence="萧炎拜药老为师",
+            )
+        )
+
+        reporter = StatusReporter(str(project_root))
+        assert reporter.load_state() is True
+        graph = reporter.generate_relationship_graph()
+        assert "mermaid" in graph
+        assert "药老" in graph
+        assert "师徒" in graph

+ 167 - 0
.claude/scripts/extract_chapter_context.py

@@ -13,6 +13,7 @@ Features:
 from __future__ import annotations
 
 import argparse
+import asyncio
 import json
 import re
 import sys
@@ -34,6 +35,22 @@ def _ensure_scripts_path():
 
 
 _CHAPTER_RANGE_RE = re.compile(r"^\s*(\d+)\s*-\s*(\d+)\s*$")
+_RAG_TRIGGER_KEYWORDS = (
+    "关系",
+    "恩怨",
+    "冲突",
+    "敌对",
+    "同盟",
+    "师徒",
+    "身份",
+    "线索",
+    "伏笔",
+    "回收",
+    "地点",
+    "势力",
+    "真相",
+    "来历",
+)
 
 
 def _parse_chapters_range(value: Any) -> tuple[int, int] | None:
@@ -238,6 +255,136 @@ def extract_state_summary(project_root: Path) -> str:
     return "\n".join(summary_parts)
 
 
+def _normalize_outline_text(outline: str) -> str:
+    text = str(outline or "")
+    if not text or text.startswith("⚠️"):
+        return ""
+    text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
+    text = re.sub(r"\s+", " ", text).strip()
+    return text
+
+
+def _build_rag_query(outline: str, chapter_num: int, min_chars: int, max_chars: int) -> str:
+    plain = _normalize_outline_text(outline)
+    if not plain or len(plain) < min_chars:
+        return ""
+
+    if not any(keyword in plain for keyword in _RAG_TRIGGER_KEYWORDS):
+        return ""
+
+    if "关系" in plain or "师徒" in plain or "敌对" in plain or "同盟" in plain:
+        topic = "人物关系与动机"
+    elif "地点" in plain or "势力" in plain:
+        topic = "地点势力与场景约束"
+    elif "伏笔" in plain or "线索" in plain or "回收" in plain:
+        topic = "伏笔与线索"
+    else:
+        topic = "剧情关键线索"
+
+    clean_max = max(40, int(max_chars))
+    return f"第{chapter_num}章 {topic}:{plain[:clean_max]}"
+
+
+def _search_with_rag(
+    project_root: Path,
+    chapter_num: int,
+    query: str,
+    top_k: int,
+) -> Dict[str, Any]:
+    _ensure_scripts_path()
+    from data_modules.config import DataModulesConfig
+    from data_modules.rag_adapter import RAGAdapter
+
+    config = DataModulesConfig.from_project_root(project_root)
+    adapter = RAGAdapter(config)
+    intent_payload = adapter.query_router.route_intent(query)
+    center_entities = list(intent_payload.get("entities") or [])
+
+    results = []
+    mode = "auto"
+    fallback_reason = ""
+    has_embed_key = bool(str(getattr(config, "embed_api_key", "") or "").strip())
+    if has_embed_key:
+        try:
+            results = asyncio.run(
+                adapter.search(
+                    query=query,
+                    top_k=top_k,
+                    strategy="auto",
+                    chapter=chapter_num,
+                    center_entities=center_entities,
+                )
+            )
+        except Exception as exc:
+            fallback_reason = f"auto_failed:{exc.__class__.__name__}"
+            mode = "bm25_fallback"
+            results = adapter.bm25_search(query=query, top_k=top_k, chapter=chapter_num)
+    else:
+        mode = "bm25_fallback"
+        fallback_reason = "missing_embed_api_key"
+        results = adapter.bm25_search(query=query, top_k=top_k, chapter=chapter_num)
+
+    hits: List[Dict[str, Any]] = []
+    for row in results:
+        content = re.sub(r"\s+", " ", str(getattr(row, "content", "") or "")).strip()
+        hits.append(
+            {
+                "chunk_id": str(getattr(row, "chunk_id", "") or ""),
+                "chapter": int(getattr(row, "chapter", 0) or 0),
+                "scene_index": int(getattr(row, "scene_index", 0) or 0),
+                "score": round(float(getattr(row, "score", 0.0) or 0.0), 6),
+                "source": str(getattr(row, "source", "") or mode),
+                "source_file": str(getattr(row, "source_file", "") or ""),
+                "content": content[:180],
+            }
+        )
+
+    return {
+        "invoked": True,
+        "query": query,
+        "mode": mode,
+        "reason": fallback_reason or ("ok" if hits else "no_hit"),
+        "intent": intent_payload.get("intent"),
+        "needs_graph": bool(intent_payload.get("needs_graph")),
+        "center_entities": center_entities,
+        "hits": hits,
+    }
+
+
+def _load_rag_assist(project_root: Path, chapter_num: int, outline: str) -> Dict[str, Any]:
+    _ensure_scripts_path()
+    from data_modules.config import DataModulesConfig
+
+    config = DataModulesConfig.from_project_root(project_root)
+    enabled = bool(getattr(config, "context_rag_assist_enabled", True))
+    top_k = max(1, int(getattr(config, "context_rag_assist_top_k", 4)))
+    min_chars = max(20, int(getattr(config, "context_rag_assist_min_outline_chars", 40)))
+    max_chars = max(40, int(getattr(config, "context_rag_assist_max_query_chars", 120)))
+    base_payload = {"enabled": enabled, "invoked": False, "reason": "", "query": "", "hits": []}
+
+    if not enabled:
+        base_payload["reason"] = "disabled_by_config"
+        return base_payload
+
+    query = _build_rag_query(outline, chapter_num=chapter_num, min_chars=min_chars, max_chars=max_chars)
+    if not query:
+        base_payload["reason"] = "outline_not_actionable"
+        return base_payload
+
+    vector_db = config.vector_db
+    if not vector_db.exists() or vector_db.stat().st_size <= 0:
+        base_payload["reason"] = "vector_db_missing_or_empty"
+        return base_payload
+
+    try:
+        rag_payload = _search_with_rag(project_root=project_root, chapter_num=chapter_num, query=query, top_k=top_k)
+        rag_payload["enabled"] = True
+        return rag_payload
+    except Exception as exc:
+        base_payload["reason"] = f"rag_error:{exc.__class__.__name__}"
+        return base_payload
+
+
 def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
     """Build context via ContextManager and return selected sections."""
     _ensure_scripts_path()
@@ -275,6 +422,7 @@ def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[
 
     state_summary = extract_state_summary(project_root)
     contract_context = _load_contract_context(project_root, chapter_num)
+    rag_assist = _load_rag_assist(project_root, chapter_num, outline)
 
     return {
         "chapter": chapter_num,
@@ -286,6 +434,7 @@ def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[
         "reader_signal": contract_context.get("reader_signal", {}),
         "genre_profile": contract_context.get("genre_profile", {}),
         "writing_guidance": contract_context.get("writing_guidance", {}),
+        "rag_assist": rag_assist,
     }
 
 
@@ -405,6 +554,24 @@ def _render_text(payload: Dict[str, Any]) -> str:
             lines.append(f"- {row}")
         lines.append("")
 
+    rag_assist = payload.get("rag_assist") or {}
+    hits = rag_assist.get("hits") or []
+    if rag_assist.get("invoked") and hits:
+        lines.append("## RAG 检索线索")
+        lines.append("")
+        lines.append(f"- 模式: {rag_assist.get('mode')}")
+        lines.append(f"- 意图: {rag_assist.get('intent')}")
+        lines.append(f"- 查询: {rag_assist.get('query')}")
+        lines.append("")
+        for idx, row in enumerate(hits[:5], start=1):
+            chapter = row.get("chapter", "?")
+            scene_index = row.get("scene_index", "?")
+            score = row.get("score", 0)
+            source = row.get("source", "unknown")
+            content = row.get("content", "")
+            lines.append(f"{idx}. [Ch{chapter}-S{scene_index}][{source}][score={score}] {content}")
+        lines.append("")
+
     return "\n".join(lines).rstrip() + "\n"
 
 

+ 48 - 0
.claude/scripts/status_reporter.py

@@ -737,11 +737,59 @@ class StatusReporter:
         else:
             return "偏低⚠️"
 
+    def _resolve_protagonist_entity_id(self) -> Optional[str]:
+        """解析主角实体 ID(优先 index.db)。"""
+        protagonist = self._index_manager.get_protagonist()
+        if protagonist and protagonist.get("id"):
+            return str(protagonist["id"])
+
+        if not self.state:
+            return None
+        name = str(self.state.get("protagonist_state", {}).get("name", "") or "").strip()
+        if not name:
+            return None
+        hits = self._index_manager.get_entities_by_alias(name)
+        if hits:
+            return str(hits[0].get("id") or "")
+        return None
+
+    def _generate_relationship_graph_from_index(self) -> str:
+        """基于 index.db 生成关系图。"""
+        protagonist_id = self._resolve_protagonist_entity_id()
+        if not protagonist_id:
+            return ""
+
+        current_chapter = 0
+        if self.state:
+            current_chapter = int(self.state.get("progress", {}).get("current_chapter", 0) or 0)
+        chapter = current_chapter if current_chapter > 0 else None
+
+        graph = self._index_manager.build_relationship_subgraph(
+            center_entity=protagonist_id,
+            depth=2,
+            chapter=chapter,
+            top_edges=40,
+        )
+        if not graph.get("nodes"):
+            return ""
+        return self._index_manager.render_relationship_subgraph_mermaid(graph)
+
     def generate_relationship_graph(self) -> str:
         """生成人际关系 Mermaid 图"""
         if not self.state:
             return ""
 
+        # v5.5: 优先使用 index.db 关系图谱(可通过配置关闭)
+        if bool(getattr(self.config, "relationship_graph_from_index_enabled", True)):
+            try:
+                graph = self._generate_relationship_graph_from_index()
+                if graph:
+                    return graph
+            except Exception:
+                # 回退老逻辑,避免报告生成中断
+                pass
+
+        # 兼容旧版 state.json relationships 结构
         relationships = self.state.get("relationships", {})
         protagonist_name = self.state.get("protagonist_state", {}).get("name", "主角")
 

+ 1 - 0
.claude/skills/webnovel-write/SKILL.md

@@ -84,6 +84,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py" --chapter {cha
 
 - 必读:`writing_guidance.guidance_items`
 - 选读:`reader_signal`、`genre_profile.reference_hints`
+- 条件必读:`rag_assist`(当 `invoked=true` 且 `hits` 非空,必须把检索命中转成可执行写作约束)
 
 ### Step 2A:正文起草
 

+ 23 - 126
README.md

@@ -21,6 +21,7 @@
 - [配置说明](#配置说明)
 - [项目结构](#项目结构)
 - [故障恢复](#故障恢复)
+- [版本历史(精简)](#版本历史精简)
 - [License](#license)
 
 ---
@@ -437,12 +438,13 @@ Step 6: Git 自动提交备份
 
 ## RAG 检索系统
 
-混合检索系统,支持语义搜索历史场景
+混合检索系统,支持语义搜索历史场景与关系证据召回。
 
 ### 架构
 
 ```
-查询 → [向量检索] + [BM25关键词] → RRF融合 → Rerank排序 → Top-K结果
+查询 → QueryRouter(auto) → vector / bm25 / hybrid / graph_hybrid
+                         └→ RRF 融合 + Rerank → Top-K 结果
 ```
 
 ### 配置
@@ -472,7 +474,9 @@ RERANK_API_KEY=jina_xxx
 
 ### 使用方式
 
-- **Context Agent** 自动调用 RAG 检索相关历史场景
+- **Context Agent** 在 Step 0.5 读取 `extract_chapter_context.py` 的 `rag_assist`
+  - 仅当大纲命中关系/伏笔/地点等触发词时才检索(避免无效召回)
+  - 优先 `auto` 策略(可走 `graph_hybrid`),失败或无 Embedding Key 时自动回退 BM25
 - **Data Agent** 自动将章节场景向量化存入数据库
 - 支持失败重试(指数退避,最多3次)
 
@@ -569,6 +573,12 @@ extraction_confidence_medium = 0.5 # 中置信度阈值(待确认)
 context_recent_summaries_window = 3   # 最近摘要数量
 context_max_appearing_characters = 10 # 最大出场角色数
 context_max_urgent_foreshadowing = 5  # 最大紧急伏笔数
+
+# 智能 RAG 辅助(Step 0.5)
+context_rag_assist_enabled = True          # 是否启用按需检索
+context_rag_assist_top_k = 4               # 召回条数
+context_rag_assist_min_outline_chars = 40  # 大纲最小触发长度
+context_rag_assist_max_query_chars = 120   # 查询截断长度
 ```
 
 ---
@@ -774,130 +784,17 @@ git checkout ch0045
 
 ---
 
-## 版本历史
+## 版本历史(精简)
+
+| 版本 | 里程碑 |
+|------|--------|
+| **v5.4.3 (当前)** | 智能 RAG 辅助上下文(按需触发 `auto/graph_hybrid`,失败回退 BM25);关系事件图谱与 Graph-RAG 链路完善 |
+| **v5.4.x** | Context Contract v2 完成(reader_signal / genre_profile / writing_guidance / checklist_score / 动态预算);审查趋势与调用可观测性 |
+| **v5.3** | 追读力系统落地(Hook/Cool-point/微兑现分类、Hard/Soft 约束、Override Contract、债务追踪) |
+| **v5.2** | 写作流程升级(Step 1.5 章节设计、reader-pull-checker、摘要分离到 `.webnovel/summaries/`) |
+| **v5.1-v5.0** | 双 Agent 基础架构 + SQLite 索引化(state 精简、实体/别名/状态变化入库) |
 
-### v5.4.2 (当前)
-- **创意约束系统**:三轴混搭 + 反套路触发器 + 镜像对抗 + 约束继承
-- **题材模板扩展**:从10+扩展到37+种题材模板
-- **复合题材支持**:支持"题材A+题材B"组合(1主1辅)
-- **反套路库**:修仙/玄幻反套路库(20条限制 + 15种非套路爽点)
-- **规则怪谈反套路库**:20条限制 + 20种非套路爽点
-- **创意银行**:idea_bank.json 存储生成的创意包
-- **人物设定扩展**:女主卡、主角组、反派设计模板
-- **世界构建扩展**:货币体系、境界链模板、社会阶层、资源分配
-- **webnovel-init 升级**:Phase 6.5 创意约束生成
-- **webnovel-plan 升级**:Phase 2.5 加载创意约束 + Phase 7 约束继承检查
-
-### v5.4.1
-- **Checker分层**:审查Agent输出结构化报告
-- **Context精简**:创作任务书优化
-
-### v5.4
-- **审查指标追踪**:review_metrics 表记录每次审查的评分/维度/问题数
-- **审查趋势统计**:get-review-trend-stats 查询近期审查均值和短板
-- **故事骨架采样**:context_manager 每 N 章采样历史摘要,构建长篇感知
-- **上下文工程升级**:基于 Context Engineering Guide 优化
-
-### Context Contract v2(阶段 A)
-
-- 上下文契约升级为 v2:新增 `meta.context_contract_version = "v2"`
-- 新增上下文排序器:`data_modules/context_ranker.py`
-- 排序策略:近期优先 + 频次稳定 + 钩子/风险信号加权
-- 工作流可观测性:`workflow_manager.py` 会写入 `.webnovel/observability/call_trace.jsonl`
-
-参考文档:`.claude/references/context-contract-v2.md`
-
-### Context Contract v2(阶段 B)
-
-- 新增 `reader_signal` 段:自动聚合追读力与审查趋势信号
-- 新增 `genre_profile` 段:自动按题材加载策略参考(支持回退)
-- 目标:让写作阶段更接近网文平台读者偏好(钩子强度、爽点分布、低分补救)
-
-### Context Contract v2(阶段 C)
-
-- 新增 `writing_guidance`:按章生成可执行写作建议(低分修复/钩子差异化/题材锚定)
-- 新增紧凑文本策略:超预算 section 使用 `…[TRUNCATED]` 保留头尾关键信息
-- 目标:在有限上下文预算下提升“可写性”和“网文感”
-
-### Context Contract v2(阶段 D)
-
-- `extract_chapter_context.py` 已接入 Contract v2 输出
-- JSON 输出新增:`context_contract_version` / `reader_signal` / `genre_profile` / `writing_guidance`
-- text 输出新增:`写作执行建议` 板块,供 Context Agent / Writer 直接使用
-- **invalid_facts 表**:追踪无效事实,支持 pending/confirmed 状态
-- **父子向量索引**:parent_chunk_id 支持摘要-场景层级检索
-- **Token 预算管理**:ContextManager 实现 40%/35%/25% 优先级分配
-- **webnovel-learn skill**:从会话提取成功模式写入 project_memory.json
-- **CLI 统一输出**:CLIResponse 标准化 JSON 输出格式
-- **Pydantic Schema**:DataAgentOutput 等结构化验证
-- **向量库安全迁移**:vectors.db 表结构变更时自动备份并执行事务迁移,失败可回滚
-
-### Context Contract v2(阶段 E)
-
-- `writing_guidance` 新增 `checklist`:可执行、可验收、可加权
-- checklist 项包含:`id/label/weight/required/source/verify_hint`
-- `extract_chapter_context.py` 文本输出新增“执行检查清单(可评分)”
-
-### Context Contract v2(阶段 F)
-
-- 新增 `writing_guidance.checklist_score`:章节执行评分与完成率
-- `index.db` 新增 `writing_checklist_scores` 持久化评分记录
-- 支持趋势查询:最近评分、评分均值、完成率均值
-
-### Context Contract v2(阶段 G)
-
-- `workflow_manager.py` 新增 Step 调用方标注(expected owner)
-- 新增步骤顺序违规追踪事件 `step_order_violation`
-- `call_trace.jsonl` 可用于定位“谁在何时调用了哪一步”
-
-### Context Contract v2(阶段 H)
-
-- 新增动态上下文预算:按章节阶段 early/mid/late 调整权重
-- `meta.context_weight_stage` 公开当前权重阶段
-- 目标:开篇重冲突与角色、中后期重世界与线索收束
-
-### Context Contract v2(阶段 I)
-
-- `genre_profile` 支持复合题材(如 `xuanhuan+realistic`)
-- 输出新增:`genres/composite/secondary_genres/composite_hints`
-- 写作建议自动加入“复合题材协同”提示
-
-### v5.4.1
-- **题材模板扩展**:新增 3 个题材模板(电竞 / 直播文 / 克苏鲁)
-- **题材映射增强**:init 支持同义输入映射(如“电竞文”“直播”“克系”)
-- **写作建议增强**:新增网文节奏基线与题材加权提示(章首目标阻力、微兑现密度、章末钩子)
-- **参数层同步**:补充 genre profile 与 reading taxonomy 的新题材规则
-
-### v5.3
-- **追读力分类标准**:钩子5类型、爽点8模式、微兑现7类型
-- **约束分层机制**:Hard Invariants (4条) + Soft Guidance (可Override)
-- **Override Contract**:违反软建议需记录理由和偿还计划
-- **追读力债务**:债务追踪、利息计算、逾期管理
-- **题材Profile**:11种内置题材配置(偏好钩子/爽点/微兑现要求)
-- **SQLite新表**:override_contracts、chase_debt、debt_events、chapter_reading_power
-- **Context Agent**:输出精简为7个板块(含追读力策略,债务状态按需输出)
-- **CLI新命令**:get-debt-summary、get-recent-reading-power、accrue-interest 等
-
-### v5.2
-- 创作任务书:Context Agent 输出人话版 8 章节格式(替代 JSON)
-- 追读力检查:新增 reader-pull-checker(第 6 个审查 Agent)
-- 章节设计:Step 1.5 选择开头/钩子/爽点模式,避免重复
-- 风格适配器:Step 2A/2B 拆分,先写剧情后网文化
-- 摘要分离:章节摘要存入 `.webnovel/summaries/ch{NNNN}.md`
-- chapter_meta:记录钩子/模式/结束状态到 state.json
-- 轻量模式:支持 `--fast` / `--minimal` 加速写作
-- 输出模板:7 个标准模板文件(state/index schema、设定集、大纲)
-
-### v5.1
-- SQLite 存储:entities/aliases/state_changes 迁移到 index.db
-- state.json 精简至 < 5KB
-- API 重试机制(指数退避)
-- 6 种爽点执行模式
-
-### v5.0
-- 双 Agent 架构 (Context + Data)
-- 纯正文写作,无需 XML 标签
-- 5 维并行审查
+详细阶段性变更请参考提交历史与 `.claude/references/` 下对应规范文档。
 
 ---