Files
wehub-resource-sync 1d6fd29cda
tests / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

349 lines
13 KiB
Python
Raw Permalink 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
"""
simp-skill · Skill Writer
管理心上人档案的创建、列表和版本控制
用法:
python3 skill_writer.py --action list
python3 skill_writer.py --action init --slug xiaomei
python3 skill_writer.py --action backup --slug xiaomei
python3 skill_writer.py --action rollback --slug xiaomei --version v1
"""
import json
import logging
import shutil
import argparse
from datetime import datetime
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
DEFAULT_BASE_DIR = Path("crushes")
SIGNAL_SCORE_MIN = -15
SIGNAL_SCORE_MAX = 25
def init_crush(slug: str, base_dir: Path = DEFAULT_BASE_DIR) -> None:
"""初始化心上人档案目录结构"""
crush_dir = base_dir / slug
dirs = [
crush_dir,
crush_dir / "memories" / "chats",
crush_dir / "memories" / "social",
crush_dir / "memories" / "photos",
crush_dir / "versions",
crush_dir / "snapshots",
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
now = datetime.now()
profile_path = crush_dir / "profile.md"
if not profile_path.exists():
profile_path.write_text(
f"---\n"
f"nickname: \"[待填写]\"\n"
f"slug: {slug}\n"
f"gender: \"[待填写]\"\n"
f"age: \"[待填写]\"\n"
f"occupation: \"[待填写]\"\n"
f"city: \"[待填写]\"\n"
f"mbti: \"[待填写]\"\n"
f"zodiac: \"[待填写]\"\n"
f"personality_type: \"[感性型/理性型/傲娇型/温柔型]\"\n"
f"how_met: \"[待填写]\"\n"
f"created_at: \"{now.strftime('%Y-%m-%d')}\"\n"
f"---\n\n"
f"## 性格画像\n\n"
f"[在这里描述ta的性格特征]\n\n"
f"## 最打动ta的方式\n\n"
f"[基于性格分析,什么样的话/行为最有效]\n\n"
f"## 用户自身风格\n\n"
f"[用户的说话风格、消息习惯、偏好模式]\n\n"
f"## 注意事项\n\n"
f"[ta特别在意或反感的事]\n",
encoding="utf-8",
)
state_path = crush_dir / "state.md"
if not state_path.exists():
state_path.write_text(
f"---\n"
f"current_stage: 未知\n"
f"signal_score: null\n"
f"last_signal_score: null\n"
f"score_trend: stable\n"
f"recommended_mode: hybrid\n"
f"last_updated: \"{now.strftime('%Y-%m-%dT%H:%M:%S')}\"\n"
f"milestones_done: 0\n"
f"---\n\n"
f"## 当前状态(一句话)\n\n"
f"[运行 /simp analyze 后自动生成]\n\n"
f"## 最近信号(最新3条)\n\n"
f"[暂无信号记录]\n\n"
f"## 当前策略方向\n\n"
f"[运行 /simp analyze 后生成]\n\n"
f"## 下一步建议\n\n"
f"[运行 /simp analyze 后生成]\n",
encoding="utf-8",
)
events_path = crush_dir / "events.jsonl"
if not events_path.exists():
events_path.touch()
interactions_path = crush_dir / "interactions.jsonl"
if not interactions_path.exists():
interactions_path.touch()
strategy_path = crush_dir / "strategy.md"
if not strategy_path.exists():
strategy_path.write_text(
f"# 追求策略\n\n"
f"> 由 simp-skill 生成 | 最后更新:{now.strftime('%Y-%m-%d')}\n\n"
f"## 当前阶段\n\n"
f"[待评估]\n\n"
f"## 推荐模式\n\n"
f"[纯情模式/策略模式/混合模式]\n\n"
f"## 本阶段重点\n\n"
f"[待生成]\n\n"
f"## 近期行动计划\n\n"
f"[待生成]\n",
encoding="utf-8",
)
meta_path = crush_dir / "meta.json"
if not meta_path.exists():
meta = {
"slug": slug,
"nickname": "[待填写]",
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
"version": "v1",
"current_stage": "未知",
"signal_score": None,
"mode": "hybrid",
"event_count": 0,
"last_snapshot": None,
"interaction_count": 0,
"last_interaction": None,
"consecutive_days": 0,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("✅ 档案目录创建成功:%s/", crush_dir)
logger.info(" ├── profile.md (心上人基本信息)")
logger.info(" ├── state.md (当前状态快照)")
logger.info(" ├── events.jsonl (事件日志)")
logger.info(" ├── strategy.md (追求策略)")
logger.info(" ├── meta.json (元数据)")
logger.info(" ├── snapshots/ (定期快照)")
logger.info(" └── memories/")
logger.info(" ├── chats/ (放聊天记录)")
logger.info(" ├── social/ (放社交媒体截图)")
logger.info(" └── photos/ (放照片)")
logger.info("")
logger.info("下一步:")
logger.info(" 1. 编辑 %s/profile.md 填写心上人信息", crush_dir)
logger.info(" 2. 运行 /simp analyze 开始分析信号")
logger.info(" 3. 把聊天记录放到 %s/memories/chats/ 并运行 chat_parser.py", crush_dir)
def list_crushes(base_dir: Path = DEFAULT_BASE_DIR) -> None:
"""列出所有心上人档案"""
if not base_dir.exists():
logger.info("还没有任何心上人档案。运行 /simp create <名字> 开始吧!")
return
crushes = [d for d in base_dir.iterdir() if d.is_dir()]
if not crushes:
logger.info("还没有任何心上人档案。运行 /simp create <名字> 开始吧!")
return
logger.info("💝 心上人档案列表(共 %d 个)", len(crushes))
logger.info("")
for crush_dir in sorted(crushes):
meta_path = crush_dir / "meta.json"
if meta_path.exists():
meta = json.loads(meta_path.read_text(encoding="utf-8"))
score = meta.get("signal_score")
score_str = f"{score}/25" if score is not None else "未评估"
stage = meta.get("current_stage", "未知")
updated = meta.get("updated_at", "")[:10]
logger.info(" 📁 %s", crush_dir.name)
logger.info(" 阶段:%s | 信号评分:%s | 最后更新:%s", stage, score_str, updated)
else:
logger.info(" 📁 %s", crush_dir.name)
logger.info("")
def backup_crush(slug: str, base_dir: Path = DEFAULT_BASE_DIR) -> str:
"""备份当前版本,返回版本名称;档案不存在时返回空字符串"""
crush_dir = base_dir / slug
if not crush_dir.exists():
logger.error("❌ 档案不存在:%s", slug)
return ""
meta_path = crush_dir / "meta.json"
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
current_version = meta.get("version", "v1")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
version_name = f"{current_version}_{timestamp}"
version_dir = crush_dir / "versions" / version_name
version_dir.mkdir(parents=True, exist_ok=True)
for filename in ["profile.md", "state.md", "strategy.md", "meta.json"]:
src = crush_dir / filename
if src.exists():
shutil.copy2(src, version_dir / filename)
v_num = int(current_version[1:]) + 1
new_version = f"v{v_num}"
updated_meta = {**meta, "version": new_version, "updated_at": datetime.now().isoformat()}
meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("✅ 已备份版本 %s%s", current_version, version_name)
logger.info(" 当前版本升级为 %s", new_version)
return version_name
def rollback_crush(slug: str, version: str, base_dir: Path = DEFAULT_BASE_DIR) -> None:
"""回滚到指定版本"""
crush_dir = base_dir / slug
versions_dir = crush_dir / "versions"
if not versions_dir.exists() or not any(versions_dir.iterdir()):
logger.error("❌ 没有找到版本历史")
return
matching = [d for d in versions_dir.iterdir() if d.name.startswith(version)]
if not matching:
available = [d.name for d in versions_dir.iterdir()]
logger.error("❌ 版本 %s 不存在", version)
logger.error(" 可用版本:%s", ", ".join(available))
return
backup_crush(slug, base_dir)
target_dir = sorted(matching)[-1]
# events.jsonl 是不可变历史,不参与回滚
for filename in ["profile.md", "state.md", "strategy.md", "meta.json"]:
src = target_dir / filename
if src.exists():
shutil.copy2(src, crush_dir / filename)
logger.info("✅ 已回滚到版本 %s", target_dir.name)
def list_versions(slug: str, base_dir: Path = DEFAULT_BASE_DIR) -> None:
"""列出版本历史"""
crush_dir = base_dir / slug
versions_dir = crush_dir / "versions"
if not versions_dir.exists() or not list(versions_dir.iterdir()):
logger.info("档案 %s 没有版本历史", slug)
return
versions = sorted(versions_dir.iterdir())
logger.info("📚 %s 的版本历史(共 %d 个版本)", slug, len(versions))
for v in versions:
meta_path = v / "meta.json"
if meta_path.exists():
meta = json.loads(meta_path.read_text(encoding="utf-8"))
stage = meta.get("current_stage", "")
score = meta.get("signal_score", "")
logger.info(" - %s 阶段:%s 评分:%s", v.name, stage, score)
else:
logger.info(" - %s", v.name)
def update_meta(slug: str, base_dir: Path = DEFAULT_BASE_DIR, **kwargs: object) -> None:
"""更新档案元数据"""
crush_dir = base_dir / slug
meta_path = crush_dir / "meta.json"
if not meta_path.exists():
logger.error("❌ 档案不存在:%s", slug)
return
if "signal_score" in kwargs:
score = kwargs["signal_score"]
if score is not None and not (SIGNAL_SCORE_MIN <= int(score) <= SIGNAL_SCORE_MAX):
logger.error(
"❌ 信号评分必须在 %d-%d 之间,当前值:%s",
SIGNAL_SCORE_MIN,
SIGNAL_SCORE_MAX,
score,
)
return
meta = json.loads(meta_path.read_text(encoding="utf-8"))
updated_meta = {**meta, **kwargs, "updated_at": datetime.now().isoformat()}
meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("✅ 档案元数据已更新")
def main() -> None:
parser = argparse.ArgumentParser(
description="simp-skill · 档案管理器",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--action",
required=True,
choices=["list", "init", "backup", "rollback", "versions", "update-meta"],
help="操作类型",
)
parser.add_argument("--slug", help="心上人档案名(拼音或英文)")
parser.add_argument("--version", help="版本号(rollback 时使用)")
parser.add_argument("--stage", help="更新当前阶段")
parser.add_argument("--score", type=int, help=f"更新信号评分({SIGNAL_SCORE_MIN}~{SIGNAL_SCORE_MAX}")
parser.add_argument("--mode", choices=["sweet", "strategic", "hybrid"], help="更新追求模式")
parser.add_argument("--base-dir", default="crushes", help="档案根目录(默认:crushes/")
args = parser.parse_args()
base_dir = Path(args.base_dir)
if args.action == "list":
list_crushes(base_dir)
elif args.action == "init":
if not args.slug:
logger.error("❌ 请提供 --slug 参数")
return
init_crush(args.slug, base_dir)
elif args.action == "backup":
if not args.slug:
logger.error("❌ 请提供 --slug 参数")
return
backup_crush(args.slug, base_dir)
elif args.action == "rollback":
if not args.slug or not args.version:
logger.error("❌ 请提供 --slug 和 --version 参数")
return
rollback_crush(args.slug, args.version, base_dir)
elif args.action == "versions":
if not args.slug:
logger.error("❌ 请提供 --slug 参数")
return
list_versions(args.slug, base_dir)
elif args.action == "update-meta":
if not args.slug:
logger.error("❌ 请提供 --slug 参数")
return
kwargs: dict[str, object] = {}
if args.stage:
kwargs["current_stage"] = args.stage
if args.score is not None:
kwargs["signal_score"] = args.score
if args.mode:
kwargs["mode"] = args.mode
update_meta(args.slug, base_dir, **kwargs)
if __name__ == "__main__":
main()