feiertu's picture
Upload cli/main.py with huggingface_hub
98105a3 verified
Raw
History Blame Contribute Delete
25.5 kB
"""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()