Files
novelmaster/noma/scripts/data_modules/wiki_manager.py
T
2026-06-23 20:29:02 +08:00

969 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
wiki_manager — 结构化 Wiki/Notebook 知识管理器
职责:
- 实体档案维护(从 index.db 全量同步到 .noma/wiki/entities/
- 伏笔/剧情线索管理(从 state.json 同步到 .noma/wiki/plot/
- 关系图谱维护(从 index.db relationships 同步到 .noma/wiki/relationships/
- 写作模式记录(替代 project_memory.json 的死胡同,写入 .noma/wiki/patterns/
- 纯 grep 搜索(无 embedding 依赖)
设计原则:
- Wiki = 地面真相(ground truth),从 index.db/state.json 全量重写
- RAG = 语义检索(fuzzy context),负责向量/BM25 搜索
- 两者互补:Wiki 提供确定性事实,RAG 提供语义相关上下文
"""
from __future__ import annotations
import argparse
import json
import logging
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from runtime_compat import normalize_windows_path
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# YAML frontmatter parser (lightweight, no PyYAML dependency)
# ---------------------------------------------------------------------------
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter from markdown text.
Returns (frontmatter_dict, body_without_frontmatter).
Only supports simple key: value and key: [list] syntax.
"""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
fm_text = m.group(1)
body = text[m.end():]
result: Dict[str, Any] = {}
for line in fm_text.split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip()
if not key:
continue
# Handle list values: [a, b, c]
if val.startswith("[") and val.endswith("]"):
items = [x.strip().strip("\"'") for x in val[1:-1].split(",") if x.strip()]
result[key] = items
# Handle quoted strings
elif (val.startswith('"') and val.endswith('"')) or (
val.startswith("'") and val.endswith("'")
):
result[key] = val[1:-1]
# Handle booleans
elif val.lower() in ("true", "yes"):
result[key] = True
elif val.lower() in ("false", "no"):
result[key] = False
# Handle numbers
elif val.isdigit():
result[key] = int(val)
else:
try:
result[key] = float(val)
except ValueError:
result[key] = val
return result, body
def _serialize_frontmatter(data: Dict[str, Any]) -> str:
"""Serialize dict to YAML frontmatter string."""
lines = ["---"]
for key, val in data.items():
if isinstance(val, list):
items = ", ".join(str(v) for v in val)
lines.append(f"{key}: [{items}]")
elif isinstance(val, bool):
lines.append(f"{key}: {'true' if val else 'false'}")
elif isinstance(val, (int, float)):
lines.append(f"{key}: {val}")
elif val is None:
lines.append(f"{key}:")
else:
lines.append(f"{key}: \"{val}\"")
lines.append("---")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# WikiManager
# ---------------------------------------------------------------------------
class WikiManager:
"""Wiki/Notebook manager for structured knowledge storage."""
def __init__(self, config: Any = None):
if config is None:
from .config import get_config
config = get_config()
self.config = config
@property
def wiki_dir(self) -> Path:
return self.config.noma_dir / "wiki"
def ensure_wiki_dirs(self) -> None:
"""Create wiki directory structure if it doesn't exist."""
for subdir in ("entities", "plot", "relationships", "patterns"):
(self.wiki_dir / subdir).mkdir(parents=True, exist_ok=True)
def _now_iso(self) -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# -----------------------------------------------------------------------
# Entity Wiki
# -----------------------------------------------------------------------
def update_entity_wiki(
self,
entity_id: str,
entity_data: Dict[str, Any],
state_changes: Optional[List[Dict[str, Any]]] = None,
aliases: Optional[List[str]] = None,
relationships: Optional[List[Dict[str, Any]]] = None,
) -> Path:
"""Create or update an entity wiki file from index.db data.
Performs a full rewrite (wiki = latest state snapshot).
"""
self.ensure_wiki_dirs()
entity_id = str(entity_id or "").strip()
if not entity_id:
raise ValueError("entity_id is required")
canonical_name = str(entity_data.get("canonical_name") or entity_id)
entity_type = str(entity_data.get("type") or "未知")
tier = str(entity_data.get("tier") or "装饰")
desc = str(entity_data.get("desc") or "")
current = entity_data.get("current") or {}
if isinstance(current, str):
try:
current = json.loads(current)
except (json.JSONDecodeError, TypeError):
current = {}
first_appearance = entity_data.get("first_appearance") or 0
last_appearance = entity_data.get("last_appearance") or 0
is_protagonist = bool(entity_data.get("is_protagonist"))
# Build frontmatter
frontmatter: Dict[str, Any] = {
"id": entity_id,
"type": entity_type,
"canonical_name": canonical_name,
"tier": tier,
"first_appearance": first_appearance,
"last_appearance": last_appearance,
"updated_at": self._now_iso(),
}
if is_protagonist:
frontmatter["is_protagonist"] = True
# Build body
lines: List[str] = []
lines.append(f"# {canonical_name}")
lines.append("")
# Basic info
lines.append("## 基本信息")
lines.append(f"- **类型**: {entity_type} / {tier}")
if aliases:
lines.append(f"- **别名**: {', '.join(aliases)}")
lines.append(f"- **首次出场**: 第{first_appearance}章")
lines.append(f"- **最近出场**: 第{last_appearance}章")
if desc:
lines.append(f"- **描述**: {desc}")
lines.append("")
# Current state
if current:
lines.append("## 当前状态")
for k, v in current.items():
if isinstance(v, dict):
lines.append(f"- **{k}**:")
for sk, sv in v.items():
lines.append(f" - {sk}: {sv}")
elif isinstance(v, list):
lines.append(f"- **{k}**: {', '.join(str(x) for x in v)}")
else:
lines.append(f"- **{k}**: {v}")
lines.append("")
# Relationships
if relationships:
lines.append("## 关系")
for rel in relationships:
from_e = str(rel.get("from_entity") or rel.get("from") or "")
to_e = str(rel.get("to_entity") or rel.get("to") or "")
rel_type = str(rel.get("type") or "关联")
desc_text = str(rel.get("description") or "")
other = to_e if from_e == entity_id else from_e
suffix = f" ({desc_text})" if desc_text else ""
lines.append(f"- {other}: {rel_type}{suffix}")
lines.append("")
# State change history
if state_changes:
lines.append("## 状态变化历史")
lines.append("| 章节 | 字段 | 旧值 | 新值 | 原因 |")
lines.append("|------|------|------|------|------|")
for sc in state_changes[:50]: # Cap at 50 rows
ch = sc.get("chapter", "?")
field = sc.get("field", "?")
old = sc.get("old_value", "")
new = sc.get("new_value", "")
reason = sc.get("reason", "")
lines.append(f"| {ch} | {field} | {old} | {new} | {reason} |")
lines.append("")
# Write file
content = _serialize_frontmatter(frontmatter) + "\n\n" + "\n".join(lines)
file_path = self.wiki_dir / "entities" / f"{entity_id}.md"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return file_path
def get_entity_wiki(self, entity_id: str) -> Optional[Dict[str, Any]]:
"""Read an entity wiki file and return parsed frontmatter + body."""
file_path = self.wiki_dir / "entities" / f"{entity_id}.md"
if not file_path.exists():
return None
text = file_path.read_text(encoding="utf-8")
fm, body = _parse_frontmatter(text)
return {"frontmatter": fm, "body": body, "path": str(file_path)}
def list_entity_wiki(
self,
entity_type: Optional[str] = None,
tier: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""List entity wiki entries with optional filters."""
entities_dir = self.wiki_dir / "entities"
if not entities_dir.exists():
return []
results: List[Dict[str, Any]] = []
for f in sorted(entities_dir.glob("*.md")):
text = f.read_text(encoding="utf-8")
fm, _ = _parse_frontmatter(text)
if entity_type and fm.get("type") != entity_type:
continue
if tier and fm.get("tier") != tier:
continue
results.append({
"id": fm.get("id", f.stem),
"canonical_name": fm.get("canonical_name", f.stem),
"type": fm.get("type", "未知"),
"tier": fm.get("tier", "装饰"),
"first_appearance": fm.get("first_appearance", 0),
"last_appearance": fm.get("last_appearance", 0),
"path": str(f),
})
return results
# -----------------------------------------------------------------------
# Plot Wiki
# -----------------------------------------------------------------------
def update_plot_threads(
self,
foreshadowing: Optional[List[Dict[str, Any]]] = None,
constraints: Optional[Dict[str, Any]] = None,
strand_tracker: Optional[Dict[str, Any]] = None,
) -> Path:
"""Update the plot/threads.md file."""
self.ensure_wiki_dirs()
frontmatter: Dict[str, Any] = {
"type": "plot_threads",
"updated_at": self._now_iso(),
}
lines: List[str] = []
lines.append("# 伏笔与剧情线索")
lines.append("")
# Foreshadowing (may be list of dicts or list of strings)
if foreshadowing:
normalized: List[Dict[str, Any]] = []
for f in foreshadowing:
if isinstance(f, str):
normalized.append({"content": f, "status": "进行中"})
elif isinstance(f, dict):
normalized.append(f)
active = [f for f in normalized if f.get("status") != "已回收"]
resolved = [f for f in normalized if f.get("status") == "已回收"]
if active:
lines.append("## 活跃伏笔")
lines.append("")
for i, ft in enumerate(active, 1):
fid = ft.get("id", f"FT-{i:03d}")
title = ft.get("title") or ft.get("content", "未命名")
planted = ft.get("planted_chapter") or ft.get("chapter", "?")
target = ft.get("target_chapter", "?")
tier_val = ft.get("tier", "支线")
content = ft.get("content", "")
lines.append(f"### {fid}: {title}")
lines.append(f"- **埋设章节**: 第{planted}章")
lines.append(f"- **目标章节**: 第{target}章")
lines.append(f"- **状态**: 进行中")
lines.append(f"- **层级**: {tier_val}")
if content:
lines.append(f"- **内容**: {content}")
lines.append("")
if resolved:
lines.append("## 已回收伏笔")
lines.append("")
for ft in resolved:
title = ft.get("title") or ft.get("content", "未命名")
planted = ft.get("planted_chapter") or ft.get("chapter", "?")
lines.append(f"- 第{planted}章: {title}")
lines.append("")
# Strand tracker
if strand_tracker:
lines.append("## 节奏追踪 (Strand Weave)")
lines.append(f"- **当前主导**: {strand_tracker.get('current_dominant', 'quest')}")
lines.append(f"- **距上次切换**: {strand_tracker.get('chapters_since_switch', 0)}章")
last_q = strand_tracker.get("last_quest_chapter", 0)
last_f = strand_tracker.get("last_fire_chapter", 0)
last_c = strand_tracker.get("last_constellation_chapter", 0)
lines.append(f"- **最近Quest**: 第{last_q}章")
lines.append(f"- **最近Fire**: 第{last_f}章")
lines.append(f"- **最近Constellation**: 第{last_c}章")
lines.append("")
# Constraints
if constraints:
lines.append("## 创作约束")
if constraints.get("anti_trope"):
lines.append(f"- **反套路**: {constraints['anti_trope']}")
if constraints.get("hard_constraints"):
for hc in constraints["hard_constraints"]:
lines.append(f"- **硬约束**: {hc}")
if constraints.get("protagonist_flaw"):
lines.append(f"- **主角缺陷**: {constraints['protagonist_flaw']}")
if constraints.get("antagonist_mirror"):
lines.append(f"- **反派镜像**: {constraints['antagonist_mirror']}")
lines.append("")
content = _serialize_frontmatter(frontmatter) + "\n\n" + "\n".join(lines)
file_path = self.wiki_dir / "plot" / "threads.md"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return file_path
def get_plot_threads(self) -> Optional[Dict[str, Any]]:
"""Read and parse plot threads wiki."""
file_path = self.wiki_dir / "plot" / "threads.md"
if not file_path.exists():
return None
text = file_path.read_text(encoding="utf-8")
fm, body = _parse_frontmatter(text)
return {"frontmatter": fm, "body": body, "path": str(file_path)}
# -----------------------------------------------------------------------
# Relationship Wiki
# -----------------------------------------------------------------------
def update_relationship_graph(
self,
relationships: List[Dict[str, Any]],
entity_names: Optional[Dict[str, str]] = None,
) -> Path:
"""Update relationships/graph.md from index.db relationships table."""
self.ensure_wiki_dirs()
entity_names = entity_names or {}
frontmatter: Dict[str, Any] = {
"type": "relationship_graph",
"updated_at": self._now_iso(),
"edge_count": len(relationships),
}
lines: List[str] = []
lines.append("# 关系图谱")
lines.append("")
if not relationships:
lines.append("暂无关系数据。")
else:
# Group by entity
by_entity: Dict[str, List[Dict[str, Any]]] = {}
for rel in relationships:
from_e = str(rel.get("from_entity") or rel.get("from") or "")
to_e = str(rel.get("to_entity") or rel.get("to") or "")
if from_e:
by_entity.setdefault(from_e, []).append(rel)
if to_e:
by_entity.setdefault(to_e, []).append(rel)
for entity_id in sorted(by_entity.keys()):
name = entity_names.get(entity_id, entity_id)
lines.append(f"## {name}")
lines.append("")
for rel in by_entity[entity_id]:
from_e = str(rel.get("from_entity") or rel.get("from") or "")
to_e = str(rel.get("to_entity") or rel.get("to") or "")
rel_type = str(rel.get("type") or "关联")
desc = str(rel.get("description") or "")
ch = rel.get("chapter", "?")
other_name = entity_names.get(to_e if from_e == entity_id else from_e, to_e if from_e == entity_id else from_e)
suffix = f" — {desc}" if desc else ""
lines.append(f"- {other_name}: {rel_type} (第{ch}章){suffix}")
lines.append("")
content = _serialize_frontmatter(frontmatter) + "\n\n" + "\n".join(lines)
file_path = self.wiki_dir / "relationships" / "graph.md"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return file_path
# -----------------------------------------------------------------------
# Writing Patterns (replaces project_memory.json dead-end)
# -----------------------------------------------------------------------
def append_writing_pattern(
self,
pattern_type: str,
description: str,
source_chapter: int,
details: Optional[str] = None,
) -> Path:
"""Append a writing pattern to wiki/patterns/writing-patterns.md."""
self.ensure_wiki_dirs()
file_path = self.wiki_dir / "patterns" / "writing-patterns.md"
# Read existing or initialize
if file_path.exists():
text = file_path.read_text(encoding="utf-8")
fm, body = _parse_frontmatter(text)
else:
fm = {"type": "writing_patterns"}
body = "# 写作模式库\n\n## 模式列表\n"
# Update frontmatter
fm["updated_at"] = self._now_iso()
pattern_count = fm.get("pattern_count", 0) + 1
fm["pattern_count"] = pattern_count
# Append new pattern
now = self._now_iso()
body = body.rstrip() + "\n\n"
body += f"### P-{pattern_count:03d}\n"
body += f"- **类型**: {pattern_type}\n"
body += f"- **描述**: {description}\n"
body += f"- **来源章节**: 第{source_chapter}\n"
body += f"- **记录时间**: {now}\n"
if details:
body += f"- **详情**: {details}\n"
content = _serialize_frontmatter(fm) + "\n\n" + body
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return file_path
def get_writing_patterns(
self, pattern_type: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Read writing patterns, optionally filtered by type."""
file_path = self.wiki_dir / "patterns" / "writing-patterns.md"
if not file_path.exists():
return []
text = file_path.read_text(encoding="utf-8")
_, body = _parse_frontmatter(text)
patterns: List[Dict[str, Any]] = []
current_pattern: Dict[str, Any] = {}
for line in body.split("\n"):
line = line.strip()
if line.startswith("### P-"):
if current_pattern:
patterns.append(current_pattern)
current_pattern = {"id": line[4:]}
elif line.startswith("- **类型**:"):
current_pattern["pattern_type"] = line.split(":", 1)[1].strip()
elif line.startswith("- **描述**:"):
current_pattern["description"] = line.split(":", 1)[1].strip()
elif line.startswith("- **来源章节**:"):
ch_text = line.split(":", 1)[1].strip()
ch_match = re.search(r"\d+", ch_text)
current_pattern["source_chapter"] = int(ch_match.group()) if ch_match else 0
elif line.startswith("- **记录时间**:"):
current_pattern["learned_at"] = line.split(":", 1)[1].strip()
elif line.startswith("- **详情**:"):
current_pattern["details"] = line.split(":", 1)[1].strip()
if current_pattern:
patterns.append(current_pattern)
if pattern_type:
patterns = [p for p in patterns if p.get("pattern_type") == pattern_type]
return patterns
# -----------------------------------------------------------------------
# Search (pure grep)
# -----------------------------------------------------------------------
def search_wiki(
self, query: str, wiki_type: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Grep-based search across all wiki files.
Args:
query: Search text (supports Chinese and English)
wiki_type: Optional filter: "entity", "plot", "relationship", "pattern"
"""
if not query or not query.strip():
return []
self.ensure_wiki_dirs()
query_lower = query.lower().strip()
type_dirs = {
"entity": "entities",
"plot": "plot",
"relationship": "relationships",
"pattern": "patterns",
}
search_dirs: List[Path] = []
if wiki_type and wiki_type in type_dirs:
search_dirs.append(self.wiki_dir / type_dirs[wiki_type])
else:
for subdir in type_dirs.values():
search_dirs.append(self.wiki_dir / subdir)
results: List[Dict[str, Any]] = []
for search_dir in search_dirs:
if not search_dir.exists():
continue
for md_file in sorted(search_dir.glob("*.md")):
try:
text = md_file.read_text(encoding="utf-8")
except Exception:
continue
if query_lower not in text.lower():
continue
fm, body = _parse_frontmatter(text)
# Find matching lines
matching_lines: List[str] = []
for line in text.split("\n"):
if query_lower in line.lower():
matching_lines.append(line.strip())
results.append({
"file": str(md_file.relative_to(self.wiki_dir)),
"type": fm.get("type", md_file.parent.name),
"id": fm.get("id", md_file.stem),
"name": fm.get("canonical_name", fm.get("id", md_file.stem)),
"matches": matching_lines[:5], # Cap at 5 matching lines
"match_count": len(matching_lines),
})
return results
# -----------------------------------------------------------------------
# Sync from index.db / state.json
# -----------------------------------------------------------------------
def sync_from_index(
self, entity_ids: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Bulk sync wiki entries from index.db.
If entity_ids is None, sync all non-archived entities.
"""
from .index_manager import IndexManager
idx = IndexManager(self.config)
self.ensure_wiki_dirs()
if entity_ids:
entities = []
for eid in entity_ids:
e = idx.get_entity(eid)
if e:
entities.append(e)
else:
entities = idx.get_core_entities()
synced = 0
errors: List[str] = []
for entity in entities:
try:
eid = entity.get("id", "")
if not eid:
continue
aliases = idx.get_entity_aliases(eid)
relationships = idx.get_entity_relationships(eid, direction="both")
state_changes = idx.get_entity_state_changes(eid, limit=50)
self.update_entity_wiki(
entity_id=eid,
entity_data=entity,
state_changes=state_changes,
aliases=aliases,
relationships=relationships,
)
synced += 1
except Exception as exc:
errors.append(f"{entity.get('id', '?')}: {exc}")
logger.warning("wiki sync error for entity %s: %s", entity.get("id"), exc)
# Sync relationship graph
try:
all_relationships = idx.get_recent_relationships(limit=500)
entity_names = {
e["id"]: e.get("canonical_name", e["id"])
for e in entities
}
self.update_relationship_graph(all_relationships, entity_names=entity_names)
except Exception as exc:
errors.append(f"relationships: {exc}")
logger.warning("wiki sync error for relationships: %s", exc)
return {"synced": synced, "total": len(entities), "errors": errors}
def sync_from_state(self) -> Dict[str, Any]:
"""Sync plot threads and constraints from state.json + genesis_contract.json."""
self.ensure_wiki_dirs()
state_file = self.config.state_file
if not state_file.exists():
return {"error": "state.json not found"}
state = json.loads(state_file.read_text(encoding="utf-8"))
# Foreshadowing
plot_threads = state.get("plot_threads", {})
foreshadowing = plot_threads.get("foreshadowing", [])
# Strand tracker
strand_tracker = state.get("strand_tracker", {})
# Genesis contract (constraints)
genesis_path = self.config.noma_dir / "genesis_contract.json"
constraints: Dict[str, Any] = {}
if genesis_path.exists():
try:
genesis = json.loads(genesis_path.read_text(encoding="utf-8"))
core_desire = genesis.get("core_desire", {})
constraints = {
"anti_trope": genesis.get("anti_trope", ""),
"hard_constraints": core_desire.get("taboos", []),
"protagonist_flaw": core_desire.get("flaw", ""),
"antagonist_mirror": genesis.get("antagonist_mirror", ""),
}
except Exception as exc:
logger.warning("failed to read genesis_contract.json: %s", exc)
# Idea bank
idea_bank_path = self.config.noma_dir / "novel_data" / "idea_bank.json"
if idea_bank_path.exists():
try:
idea_bank = json.loads(idea_bank_path.read_text(encoding="utf-8"))
inherited = idea_bank.get("constraints_inherited", {})
if not constraints.get("anti_trope"):
constraints["anti_trope"] = inherited.get("anti_trope", "")
if not constraints.get("hard_constraints"):
constraints["hard_constraints"] = inherited.get("hard_constraints", [])
if not constraints.get("protagonist_flaw"):
constraints["protagonist_flaw"] = inherited.get("protagonist_flaw", "")
except Exception as exc:
logger.warning("failed to read idea_bank.json: %s", exc)
self.update_plot_threads(
foreshadowing=foreshadowing,
constraints=constraints or None,
strand_tracker=strand_tracker or None,
)
return {
"foreshadowing_count": len(foreshadowing),
"has_constraints": bool(constraints),
"has_strand_tracker": bool(strand_tracker),
}
# -----------------------------------------------------------------------
# Migration
# -----------------------------------------------------------------------
def migrate_from_project_memory(self) -> Dict[str, Any]:
"""Migrate existing project_memory.json patterns to wiki."""
self.ensure_wiki_dirs()
# Look for project_memory.json in various locations
candidates = [
self.config.noma_dir / "novel_data" / "project_memory.json",
self.config.project_root / "novelmaster" / "project_memory.json",
self.config.project_root / "project_memory.json",
]
migrated = 0
for pm_path in candidates:
if not pm_path.exists():
continue
try:
data = json.loads(pm_path.read_text(encoding="utf-8"))
patterns = data.get("patterns", [])
for p in patterns:
self.append_writing_pattern(
pattern_type=p.get("pattern_type", "unknown"),
description=p.get("description", ""),
source_chapter=p.get("source_chapter", 0),
)
migrated += 1
if migrated > 0:
logger.info("migrated %d patterns from %s", migrated, pm_path)
break
except Exception as exc:
logger.warning("failed to migrate from %s: %s", pm_path, exc)
return {"migrated": migrated}
def rebuild_index(self) -> Path:
"""Regenerate _index.md from all wiki files."""
self.ensure_wiki_dirs()
lines: List[str] = []
lines.append("# Wiki 索引")
lines.append("")
lines.append(f"更新时间: {self._now_iso()}")
lines.append("")
# Entities
entities = self.list_entity_wiki()
lines.append(f"## 实体 ({len(entities)})")
lines.append("")
for e in entities:
lines.append(f"- [{e['canonical_name']}](entities/{e['id']}.md) — {e['type']} / {e['tier']}")
lines.append("")
# Plot
plot = self.get_plot_threads()
if plot:
lines.append("## 伏笔与剧情线索")
lines.append(f"- [threads.md](plot/threads.md)")
lines.append("")
# Relationships
rel_path = self.wiki_dir / "relationships" / "graph.md"
if rel_path.exists():
lines.append("## 关系图谱")
lines.append(f"- [graph.md](relationships/graph.md)")
lines.append("")
# Patterns
patterns = self.get_writing_patterns()
lines.append(f"## 写作模式 ({len(patterns)})")
lines.append(f"- [writing-patterns.md](patterns/writing-patterns.md)")
lines.append("")
index_path = self.wiki_dir / "_index.md"
index_path.write_text("\n".join(lines), encoding="utf-8")
return index_path
# ---------------------------------------------------------------------------
# CLI interface
# ---------------------------------------------------------------------------
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="wiki_manager CLI")
parser.add_argument("--project-root", required=True, help="项目根目录")
sub = parser.add_subparsers(dest="command", required=True)
# update-entity
p_ue = sub.add_parser("update-entity", help="同步单个实体到 wiki")
p_ue.add_argument("--id", required=True, help="实体 ID")
# update-plot
sub.add_parser("update-plot", help="同步伏笔/剧情线索到 wiki")
# update-relationship
sub.add_parser("update-relationship", help="同步关系图谱到 wiki")
# update-patterns
p_up = sub.add_parser("update-patterns", help="添加写作模式")
p_up.add_argument("--data", required=True, help="JSON 格式模式数据")
# search
p_s = sub.add_parser("search", help="搜索 wiki")
p_s.add_argument("--query", required=True, help="搜索关键词")
p_s.add_argument("--type", dest="wiki_type", help="类型过滤: entity|plot|relationship|pattern")
# sync-from-index
p_sfi = sub.add_parser("sync-from-index", help="从 index.db 批量同步实体")
p_sfi.add_argument("--entity-ids", help="JSON 格式实体 ID 列表(可选,默认同步所有核心实体)")
# sync-from-state
sub.add_parser("sync-from-state", help="从 state.json 同步伏笔/约束")
# migrate-project-memory
sub.add_parser("migrate-project-memory", help="迁移 project_memory.json 到 wiki")
# rebuild-index
sub.add_parser("rebuild-index", help="重建 _index.md")
# list
p_l = sub.add_parser("list", help="列出 wiki 条目")
p_l.add_argument("--type", dest="wiki_type", help="类型过滤: entity|plot|relationship|pattern")
# get
p_g = sub.add_parser("get", help="获取单个 wiki 条目")
p_g.add_argument("--id", required=True, help="条目 ID")
p_g.add_argument("--type", dest="wiki_type", default="entity", help="类型: entity|plot|pattern")
return parser.parse_args(argv)
def main() -> None:
args = _parse_args(sys.argv[1:])
from .config import DataModulesConfig
config = DataModulesConfig.from_project_root(args.project_root)
wiki = WikiManager(config)
if args.command == "update-entity":
from .index_manager import IndexManager
idx = IndexManager(config)
entity = idx.get_entity(args.id)
if not entity:
print(json.dumps({"error": f"entity not found: {args.id}"}, ensure_ascii=False))
raise SystemExit(1)
aliases = idx.get_entity_aliases(args.id)
relationships = idx.get_entity_relationships(args.id, direction="both")
state_changes = idx.get_entity_state_changes(args.id, limit=50)
path = wiki.update_entity_wiki(
entity_id=args.id,
entity_data=entity,
state_changes=state_changes,
aliases=aliases,
relationships=relationships,
)
print(json.dumps({"ok": True, "path": str(path)}, ensure_ascii=False))
elif args.command == "update-plot":
result = wiki.sync_from_state()
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
elif args.command == "update-relationship":
from .index_manager import IndexManager
idx = IndexManager(config)
relationships = idx.get_recent_relationships(limit=500)
entities = idx.get_core_entities()
entity_names = {e["id"]: e.get("canonical_name", e["id"]) for e in entities}
path = wiki.update_relationship_graph(relationships, entity_names=entity_names)
print(json.dumps({"ok": True, "path": str(path)}, ensure_ascii=False))
elif args.command == "update-patterns":
data = json.loads(args.data)
path = wiki.append_writing_pattern(
pattern_type=data.get("pattern_type", "unknown"),
description=data.get("description", ""),
source_chapter=data.get("source_chapter", 0),
details=data.get("details"),
)
print(json.dumps({"ok": True, "path": str(path)}, ensure_ascii=False))
elif args.command == "search":
results = wiki.search_wiki(args.query, wiki_type=args.wiki_type)
print(json.dumps({"results": results, "count": len(results)}, ensure_ascii=False, indent=2))
elif args.command == "sync-from-index":
entity_ids = None
if args.entity_ids:
entity_ids = json.loads(args.entity_ids)
result = wiki.sync_from_index(entity_ids=entity_ids)
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
elif args.command == "sync-from-state":
result = wiki.sync_from_state()
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
elif args.command == "migrate-project-memory":
result = wiki.migrate_from_project_memory()
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
elif args.command == "rebuild-index":
path = wiki.rebuild_index()
print(json.dumps({"ok": True, "path": str(path)}, ensure_ascii=False))
elif args.command == "list":
if args.wiki_type == "entity" or not args.wiki_type:
entities = wiki.list_entity_wiki()
for e in entities:
print(json.dumps(e, ensure_ascii=False))
if args.wiki_type == "pattern" or not args.wiki_type:
patterns = wiki.get_writing_patterns()
for p in patterns:
print(json.dumps(p, ensure_ascii=False))
elif args.command == "get":
if args.wiki_type == "entity":
result = wiki.get_entity_wiki(args.id)
elif args.wiki_type == "plot":
result = wiki.get_plot_threads()
elif args.wiki_type == "pattern":
patterns = wiki.get_writing_patterns()
result = next((p for p in patterns if p.get("id") == args.id), None)
else:
result = None
if result:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(json.dumps({"error": "not found"}, ensure_ascii=False))
raise SystemExit(1)
if __name__ == "__main__":
main()