feat: initial commit
This commit is contained in:
@@ -0,0 +1,968 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user