项目文件夹

文件
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 行
13 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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()