"""Hermes CLI — click 壳.""" import json import os import signal import subprocess import sys import textwrap import time from pathlib import Path # Fix Unicode display on Windows (GBK terminal) if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") import click from hermes_core.querier import HermesClient from hermes_core.refiner import refine_scene from hermes_core.db import init_db, get_record, update_record_state, get_active_records from hermes_core.types import RecordState, HERMES_DATA_DIR from hermes_core.config import get_config, save_config, ensure_config_exists, HermesConfig, CONFIG_FILE # ═══════════════════════════════════════════════════════════════ # 工具函数 # ═══════════════════════════════════════════════════════════════ def _echo_json(data: dict) -> None: click.echo(json.dumps(data, ensure_ascii=False, indent=2)) def _check_model_cached() -> bool: """检查 embedding 模型是否已下载。""" from hermes_core.embedder import Embedder return Embedder.is_available() # ═══════════════════════════════════════════════════════════════ # 主命令组 # ═══════════════════════════════════════════════════════════════ @click.group() def cli(): """Hermes 联想引擎 — Agent 偏好学习与共享.""" # ═══════════════════════════════════════════════════════════════ # hermes init — 一键初始化 # ═══════════════════════════════════════════════════════════════ @cli.command() @click.option("--force", "-f", is_flag=True, help="强制重新初始化") def init(force): """初始化 Hermes:创建配置 → 下载模型 → 验证环境。 首次使用只需运行一次。幂等操作,可安全重复执行。""" config = ensure_config_exists() click.echo("╔══════════════════════════════════════════╗") click.echo("║ Hermes 一键初始化向导 ║") click.echo("╚══════════════════════════════════════════╝\n") # Step 1: 配置 click.echo(f"[1/4] 配置文件: {CONFIG_FILE}") click.echo(f" 数据目录: {config.data_dir}") click.echo(f" 扫描间隔: {config.scan_interval_seconds}s") click.echo(f" 匹配阈值: {config.match_threshold}") # Step 2: 下载模型 click.echo(f"\n[2/4] Embedding 模型: {config.embedding_model}") if _check_model_cached() and not force: click.echo(" ✓ 已缓存") else: click.echo(" 正在下载(约 420MB,首次约 2-5 分钟)...") try: from hermes_core.embedder import Embedder e = Embedder(config.embedding_model) _ = e.encode("test") # 触发加载 click.echo(" ✓ 下载完成") except Exception as exc: click.echo(f" ✗ 下载失败: {exc}") click.echo(" 可稍后手动运行: hermes download-models") # Step 3: 检查训练依赖 click.echo("\n[3/4] QLoRA 训练依赖 (torch + transformers)") try: import torch import transformers import peft click.echo(f" ✓ torch {torch.__version__}, transformers {transformers.__version__}") except ImportError: click.echo(" ! 未安装(跳过训练的推理模式仍可用)") click.echo(" 安装训练依赖: pip install -e '.[train]'") # Step 4: 启动 daemon click.echo(f"\n[4/4] 下一步") click.echo(f" hermes start # 后台启动 daemon") click.echo(f" hermes status # 查看运行状态") click.echo(f" hermes demo # 运行交互演示") click.echo(f"\n✅ 初始化完成!") # ═══════════════════════════════════════════════════════════════ # hermes download-models — 预下载模型 # ═══════════════════════════════════════════════════════════════ @cli.command("download-models") def download_models(): """预下载 embedding 模型(避免首次使用时等待)。""" config = get_config() click.echo(f"下载 embedding 模型: {config.embedding_model}") click.echo("(约 420MB,可能需要 2-5 分钟)\n") try: from sentence_transformers import SentenceTransformer # SentenceTransformer 自带进度条 model = SentenceTransformer(config.embedding_model) _ = model.encode("test") click.echo("\n✅ 模型下载完成") except Exception as e: click.echo(f"\n❌ 下载失败: {e}") click.echo("请检查网络连接,或设置 HF_ENDPOINT=https://hf-mirror.com 使用镜像") sys.exit(1) # ═══════════════════════════════════════════════════════════════ # hermes start / stop / status — daemon 生命周期 # ═══════════════════════════════════════════════════════════════ @cli.command() def start(): """后台启动 Hermes Daemon。""" config = get_config() pid_file = Path(config.pid_file) log_file = Path(config.log_file) if pid_file.exists(): pid = int(pid_file.read_text().strip()) try: os.kill(pid, 0) click.echo(f"Daemon 已在运行 (PID: {pid})") return except OSError: pid_file.unlink() pid_file.parent.mkdir(parents=True, exist_ok=True) log_file.parent.mkdir(parents=True, exist_ok=True) log_f = open(str(log_file), "a") proc = subprocess.Popen( [sys.executable, "-m", "daemon.daemon"], stdout=log_f, stderr=log_f, start_new_session=True, ) pid_file.write_text(str(proc.pid)) time.sleep(1) if proc.poll() is None: click.echo(f"✅ Daemon 已启动 (PID: {proc.pid})") click.echo(f" 日志: {log_file}") click.echo(f" 查看状态: hermes status") else: click.echo(f"❌ Daemon 启动失败,查看日志: {log_file}") sys.exit(1) @cli.command() def stop(): """停止 Hermes Daemon。""" config = get_config() pid_file = Path(config.pid_file) if not pid_file.exists(): click.echo("Daemon 未在运行") return pid = int(pid_file.read_text().strip()) try: os.kill(pid, signal.SIGTERM) for _ in range(10): try: os.kill(pid, 0) time.sleep(0.3) except OSError: break else: os.kill(pid, signal.SIGKILL) pid_file.unlink() click.echo(f"✅ Daemon 已停止 (PID: {pid})") except OSError: pid_file.unlink() click.echo("Daemon 已不在运行") @cli.command() @click.option("--user", "-u", default=None, help="按用户过滤") def status(user): """查看 Hermes 运行状态。""" config = get_config() pid_file = Path(config.pid_file) output = {"daemon": "stopped", "pid": None} if pid_file.exists(): try: pid = int(pid_file.read_text().strip()) os.kill(pid, 0) output["daemon"] = "running" output["pid"] = pid except OSError: pid_file.unlink() # 汇总统计 try: db_path = Path(config.data_dir) / "users" if db_path.exists(): users = [d.name for d in db_path.iterdir() if d.is_dir()] total_records = 0 total_scopes = 0 for uid in users: conn = init_db(uid) scopes = conn.execute("SELECT COUNT(*) FROM scopes WHERE status='active'").fetchone()[0] records = conn.execute("SELECT COUNT(*) FROM records WHERE state='active'").fetchone()[0] conn.close() total_scopes += scopes total_records += records output["users"] = len(users) output["active_scopes"] = total_scopes output["active_records"] = total_records except Exception: pass if user: conn = init_db(user) rows = conn.execute( "SELECT id, label, record_count, coherence, status FROM scopes ORDER BY last_activity DESC" ).fetchall() conn.close() output["scopes"] = [dict(r) for r in rows] _echo_json(output) # ═══════════════════════════════════════════════════════════════ # hermes demo — 交互体验 # ═══════════════════════════════════════════════════════════════ DEMO_SCENARIOS = [ { "desc": "后端API开发", "dims": [{"key": "language", "value": "TypeScript", "context": "未指定语言时默认"}, {"key": "framework", "value": "Express", "context": "默认后端框架"}], }, { "desc": "后端API开发", "dims": [{"key": "language", "value": "TypeScript", "context": "未指定语言时默认"}, {"key": "framework", "value": "Express", "context": "默认后端框架"}, {"key": "database", "value": "PostgreSQL", "context": "默认数据库"}], }, { "desc": "前端开发", "dims": [{"key": "framework", "value": "React", "context": "默认前端框架"}, {"key": "language", "value": "TypeScript", "context": "默认语言"}], }, { "desc": "数据分析", "dims": [{"key": "language", "value": "Python", "context": "默认"}, {"key": "lib", "value": "Pandas", "context": "数据处理"}, {"key": "style", "value": "functional", "context": "函数式风格"}], }, { "desc": "周末活动", "dims": [{"key": "activity", "value": "户外徒步", "context": "周末偏好"}], }, ] @cli.command() @click.option("--user", "-u", default="demo_user", help="演示用户 ID") def demo(user): """运行交互演示:模拟 Agent 记录偏好 → 查询偏好。 这是一个沙箱演示,数据写入临时目录,不影响真实数据。""" import tempfile config = get_config() tmp_dir = tempfile.mkdtemp(prefix="hermes_demo_") # 用临时目录隔离演示数据 os.environ["HERMES_DATA_DIR"] = str(tmp_dir) from hermes_core.types import HERMES_DATA_DIR as _H import hermes_core.db as db_m import hermes_core.trainer as tr_m db_m.HERMES_DATA_DIR = Path(tmp_dir) tr_m.HERMES_DATA_DIR = Path(tmp_dir) click.echo("╔══════════════════════════════════════════════════════╗") click.echo("║ Hermes 联想引擎 — 交互演示 ║") click.echo("╚══════════════════════════════════════════════════════╝\n") click.echo("模拟场景:一个 AI Agent 在与用户对话中记录偏好,") click.echo("并在后续对话中自动联想这些偏好。\n") click.echo(f"数据目录: {tmp_dir}\n") client = HermesClient(user_id=user, agent_id="demo-agent") # 阶段 1:记录 click.echo("━" * 50) click.echo("阶段 1:Agent 记录用户偏好 (record_detail)") click.echo("━" * 50) for i, s in enumerate(DEMO_SCENARIOS, 1): click.echo(f"\n 对话 #{i}: 用户提到「{s['desc']}」相关需求") r = client.record(s["desc"], s["dims"]) dims_str = ", ".join(f"{d['key']}={d['value']}" for d in s["dims"]) click.echo(f" → Agent 记录: [{dims_str}]") if r["status"] == "recorded": click.echo(f" 状态: ✓ 已记录 (scope: {r['scope_id'][:12]}...)") elif r["status"] == "rejected": click.echo(f" 状态: ✗ 被拒绝 ({r['reason']})") else: click.echo(f" 状态: ↻ {r['status']}") # 阶段 2:查询 click.echo("\n" + "━" * 50) click.echo("阶段 2:Agent 推理前查询偏好 (query)") click.echo("━" * 50) queries = [ "帮我写一个用户登录的REST API", "做一个数据可视化的Dashboard", "这周末想出去玩", ] for q in queries: click.echo(f"\n 用户: 「{q}」") result = client.query(q) if result.matched_scope: click.echo(f" → 匹配场景: {result.matched_scope.scope_label} " f"(置信度 {result.matched_scope.confidence:.0%})") for pref in result.related_preferences: click.echo(f" - {pref.key}: {pref.value}") else: click.echo(f" → 未匹配到已知场景(将使用默认行为)") if result.alternative_scopes: alt = result.alternative_scopes[0] click.echo(f" 最接近: {alt.scope_label} (置信度 {alt.confidence:.0%})") # 阶段 3:查看数据 click.echo("\n" + "━" * 50) click.echo("阶段 3:查看训练集状态") click.echo("━" * 50) conn = init_db(user) scopes = conn.execute("SELECT id, label, record_count, coherence FROM scopes WHERE status='active'").fetchall() conn.close() click.echo(f"\n 共有 {len(scopes)} 个动态场景:") for s_c in scopes: click.echo(f" {s_c['id']} — {s_c['label']} " f"({s_c['record_count']} 条记录, 内聚度 {s_c['coherence']:.2f})") click.echo(f"\n 📊 数据保存在: {tmp_dir}(可手动删除)") click.echo(f" 💡 下次演示: hermes demo --user {user}") import shutil shutil.rmtree(tmp_dir, ignore_errors=True) # ═══════════════════════════════════════════════════════════════ # hermes record — 写入训练样本 # ═══════════════════════════════════════════════════════════════ @cli.command() @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--scope-desc", required=True, help="场景描述") @click.option("--dimensions", required=True, help='JSON: [{"key":"language","value":"TS"}]') @click.option("--source-conv", default="", help="来源会话 ID") @click.option("--source-agent", default="cli", help="来源 agent ID") def record(user, scope_desc, dimensions, source_conv, source_agent): """写入一条训练样本。""" try: dims = json.loads(dimensions) except json.JSONDecodeError: _echo_json({"status": "error", "reason": "Invalid dimensions JSON"}) sys.exit(1) client = HermesClient(user_id=user, agent_id=source_agent) result = client.record(scope_desc, dims, source_conv=source_conv) _echo_json(result) # ═══════════════════════════════════════════════════════════════ # hermes refine — 调整场景粒度 # ═══════════════════════════════════════════════════════════════ @cli.command() @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--id", "record_id", required=True, help="记录 ID") @click.option("--scope-desc", required=True, help="新的场景描述") @click.option("--direction", type=click.Choice(["narrow", "broaden"]), required=True) def refine(user, record_id, scope_desc, direction): """调整记录的场景粒度。""" from hermes_core.embedder import Embedder embedder = Embedder() result = refine_scene(user, record_id, scope_desc, direction, embedder) _echo_json(result) # ═══════════════════════════════════════════════════════════════ # hermes query — 场景识别 + 偏好查询 # ═══════════════════════════════════════════════════════════════ @cli.command() @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--text", "-t", required=True, help="用户输入文本") def query(user, text): """场景识别 + 偏好查询。""" client = HermesClient(user_id=user, agent_id="cli") result = client.query(text) output = { "matched_scope": None, "active_loras": [], "related_preferences": [], "training_outdated": result.training_outdated, } if result.matched_scope: output["matched_scope"] = { "scope_id": result.matched_scope.scope_id, "scope_label": result.matched_scope.scope_label, "confidence": round(result.matched_scope.confidence, 4), } if result.alternative_scopes: output["alternative_scopes"] = [ {"scope_id": s.scope_id, "scope_label": s.scope_label, "confidence": round(s.confidence, 4)} for s in result.alternative_scopes[:3] ] output["active_loras"] = [ {"scope_id": l.scope_id, "version": l.version, "priority": l.priority} for l in result.active_loras ] output["related_preferences"] = [ {"key": p.key, "value": p.value, "source": p.source} for p in result.related_preferences ] _echo_json(output) # ═══════════════════════════════════════════════════════════════ # hermes review — 训练集审核 # ═══════════════════════════════════════════════════════════════ @cli.group() def review(): """训练集审核。""" @review.command("pending") @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--scope", "scope_id", default=None, help="按 scope 过滤") def review_pending(user, scope_id): """列出待审核记录。""" conn = init_db(user) if scope_id: records = get_active_records(conn, scope_id) else: from hermes_core.db import get_active_scopes scopes = get_active_scopes(conn) records = [] for s in scopes: records.extend(get_active_records(conn, s.id)) conn.close() click.echo(json.dumps([ { "id": r.id, "scope_id": r.scope_id, "scope_label": r.scope_label, "dimensions": [{"key": d.key, "value": d.value} for d in r.dimensions], "confidence": r.confidence, "occurrences": r.occurrences, } for r in records ], ensure_ascii=False, indent=2)) @review.command("accept") @click.option("--user", "-u", required=True, help="用户 ID") @click.argument("record_ids", nargs=-1) def review_accept(user, record_ids): """批量通过记录。""" conn = init_db(user) for rid in record_ids: update_record_state(conn, rid, RecordState.active) conn.close() _echo_json({"status": "accepted", "count": len(record_ids)}) @review.command("reject") @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--reason", default="", help="拒绝原因") @click.argument("record_ids", nargs=-1) def review_reject(user, reason, record_ids): """批量拒绝记录。""" conn = init_db(user) for rid in record_ids: update_record_state(conn, rid, RecordState.rejected) conn.close() _echo_json({"status": "rejected", "count": len(record_ids), "reason": reason}) # ═══════════════════════════════════════════════════════════════ # hermes train-status / train — 训练管理 # ═══════════════════════════════════════════════════════════════ @cli.command("train-status") @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--scope", "scope_id", default=None, help="scope ID") def train_status(user, scope_id): """查询训练任务状态。""" conn = init_db(user) if scope_id: rows = conn.execute( "SELECT * FROM training_runs WHERE scope_id=? ORDER BY version DESC LIMIT 3", (scope_id,) ).fetchall() else: rows = conn.execute( "SELECT * FROM training_runs ORDER BY started_at DESC LIMIT 10" ).fetchall() conn.close() click.echo(json.dumps([ {"id": r["id"], "scope_id": r["scope_id"], "version": r["version"], "status": r["status"], "started_at": r["started_at"] or "N/A", "finished_at": r["finished_at"] or "N/A"} for r in rows ], ensure_ascii=False, indent=2)) @cli.command("train") @click.option("--user", "-u", required=True, help="用户 ID") @click.option("--scope", "scope_id", required=True, help="scope ID") def train(user, scope_id): """手动触发训练。""" _echo_json({ "status": "queued", "message": f"Training for {scope_id} queued. Use 'train-status' to check progress." }) # ═══════════════════════════════════════════════════════════════ # hermes config — 查看/修改配置 # ═══════════════════════════════════════════════════════════════ @cli.group() def config_cmd(): """查看和修改配置。""" pass @config_cmd.command("show") def config_show(): """显示当前配置。""" config = get_config() _echo_json(config.to_dict()) @config_cmd.command("set") @click.argument("key") @click.argument("value") def config_set(key, value): """修改配置项。例如: hermes config set match_threshold 0.65""" config = get_config() if key not in HermesConfig.__dataclass_fields__: click.echo(f"未知配置项: {key}") click.echo(f"可用配置项: {', '.join(HermesConfig.__dataclass_fields__.keys())}") sys.exit(1) field_type = type(getattr(config, key)) try: setattr(config, key, field_type(value)) except (ValueError, TypeError) as e: click.echo(f"值类型错误: {e}") sys.exit(1) save_config(config) click.echo(f"✅ {key} = {getattr(config, key)}") # ═══════════════════════════════════════════════════════════════ # hermes daemon — 启动/停止(兼容旧接口) # ═══════════════════════════════════════════════════════════════ @cli.command("daemon") @click.option("--foreground", "-f", is_flag=True, help="前台运行模式") def daemon(foreground): """启动 Daemon(前台),建议用 hermes start 后台运行。""" if foreground: from daemon.daemon import main as daemon_main click.echo("Hermes Daemon 前台运行中 (Ctrl+C 停止)") daemon_main() else: click.echo("请用 hermes start 后台启动,或 hermes daemon --foreground 前台运行") if __name__ == "__main__": cli()