"""遥测最小化 + 训练退出开关(feat-chv-muse-p9,叶子模块)。 对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见 docs/chv-muse-p9-gap.md):VM 发送有限数据用于推理与遥测 (安全文未给字段级清单——本模块的字段清单按最小化原则自定, 见 `TELEMETRY_FIELDS`);推理轨迹在去除关键个人身份信息后可 用于训练新检查点,用户可一键退出训练。 诚实边界(见差距表 §4): - 遥测目前只落盘 `/telemetry.jsonl`,没有向外部遥测 端点发送的路径;落盘失败也不影响任务(`emit_telemetry` 永不抛错)。 - 训练轨迹目前没有上传路径:开关管的是“轨迹类事件是否产生”; 上传通道本身不存在,开关是为上传通道预留的门禁。 - 字段白名单是构造性保证:不在白名单里的键一律丢弃;token 数 只取 host.py 回传的 usage,未回传记 `null`,不估算、不编造。 - 训练开关默认开是**本项目决定,非 Meta 官方值**(报告注明 帮助中心原文未逐字确认默认开启)。 本模块只用标准库 + `atomic_file`(开关状态文件原子写)。 """ import hashlib import json import logging import os import time from cloud.chv.atomic_file import atomic_write_text _LOGGER = logging.getLogger(__name__) # 遥测字段白名单(自定,按最小化原则):只含任务级元数据。 # 绝不含对话原文、文件内容、密钥、个人身份信息——白名单外 # 的键在 sanitize 时一律丢弃,不是“记下来再脱敏”。 TELEMETRY_FIELDS = frozenset({ "task_id", "vm_id", "provider", "model", "status", "started_at", "ended_at", "duration_ms", "tokens_in", "tokens_out", "tokens_total", "turns", "tool_calls", "sampled", }) # 轨迹类事件字段白名单(自定):只含回合级元数据,不含任何文本。 TRAJECTORY_FIELDS = frozenset({ "task_id", "turns", "tools", "duration_ms", }) TRAINING_OPT_FILENAME = "training-opt.json" TRAINING_OPT_AUDIT_FILENAME = "training-opt-audit.jsonl" TELEMETRY_FILENAME = "telemetry.jsonl" DEFAULT_TELEMETRY_SAMPLE_RATE = 1.0 def telemetry_sample_rate(env: dict | None = None) -> float: """遥测采样率:`CHV_TELEMETRY_SAMPLE_RATE`,0.0–1.0,默认 1.0 (全量,任务级元数据量小)。非法值即 ValueError。""" values = env if env is not None else os.environ # type: ignore[assignment] raw = values.get("CHV_TELEMETRY_SAMPLE_RATE") if raw is None or str(raw).strip() == "": return DEFAULT_TELEMETRY_SAMPLE_RATE try: rate = float(str(raw).strip()) except ValueError: raise ValueError( "CHV_TELEMETRY_SAMPLE_RATE 必须是数字: %r" % raw) if not 0.0 <= rate <= 1.0: raise ValueError( "CHV_TELEMETRY_SAMPLE_RATE 必须在 0.0-1.0 之间: %s" % rate) return rate def sampled(task_id: str, rate: float) -> bool: """确定性采样:同一 task_id 每次判定一致(可测试);rate=0 全丢,rate=1 全留。""" digest = hashlib.sha256(task_id.encode("utf-8")).hexdigest() return int(digest[:8], 16) % 10000 < rate * 10000 def _is_scalar(value: object) -> bool: """遥测值只认标量(str/int/float/bool/None);dict/list 一律丢弃——不把结构化内容带进遥测。""" return value is None or isinstance(value, (str, int, float, bool)) def sanitize_telemetry_event(event: dict) -> dict: """按白名单过滤遥测事件:不在白名单里的键丢弃;值不是标量 的丢弃;`tools` 字段例外——只认 str 列表(工具名),其它 元素一律去掉。返回新 dict,不改原 event。""" clean: dict = {} for key, value in (event or {}).items(): if key not in TELEMETRY_FIELDS and key not in TRAJECTORY_FIELDS: continue if key == "tools": if isinstance(value, (list, tuple)): clean[key] = [t for t in value if isinstance(t, str)] continue if _is_scalar(value): clean[key] = value return clean def build_task_telemetry(task_id: str, vm_id: str, provider: str, model: str | None, status: str, started_at: float, ended_at: float, duration_ms: int, tokens: dict | None = None, turns: int | None = None, tool_calls: int | None = None, env: dict | None = None) -> dict | None: """建一条任务级遥测事件;采样未命中返回 None(不产生事件, 不是产生后丢弃)。tokens 取 host.py 回传的 usage,未回传 时三个 token 字段记 None,不编造。""" rate = telemetry_sample_rate(env) if not sampled(task_id, rate): return None tokens = tokens or {} event = { "task_id": task_id, "vm_id": vm_id, "provider": provider, "model": model, "status": status, "started_at": started_at, "ended_at": ended_at, "duration_ms": duration_ms, "tokens_in": tokens.get("input_tokens"), "tokens_out": tokens.get("output_tokens"), "tokens_total": tokens.get("total_tokens"), "turns": turns, "tool_calls": tool_calls, "sampled": True, } return sanitize_telemetry_event(event) def build_trajectory_event(task_id: str, turns: int, tools: list, duration_ms: int, state_dir: str) -> dict | None: """建一条轨迹类事件(训练用):只含回合级元数据(回合数、 工具名、耗时),不含任何文本。训练退出开关关时返回 None (事件不再产生)。""" if not training_enabled(state_dir): return None return sanitize_telemetry_event({ "task_id": task_id, "turns": turns, "tools": list(tools or []), "duration_ms": duration_ms, }) def _append_jsonl(path: str, record: dict) -> bool: """best-effort 追加一行 JSONL:失败返回 False,永不抛错 (遥测/审计落盘失败绝不影响任务)。""" try: line = json.dumps(record, ensure_ascii=False) + "\n" with open(path, "a", encoding="utf-8") as fh: fh.write(line) fh.flush() return True except Exception as exc: # noqa: BLE001 - 落盘失败只记日志 _LOGGER.warning("telemetry_append_failed path=%s error=%s", path, exc) return False def emit_telemetry(state_dir: str, event: dict) -> bool: """发一条遥测事件:先过白名单再落盘 `/telemetry.jsonl`。 落盘失败返回 False,永不抛错、不影响任务。""" if not state_dir or not isinstance(event, dict): return False clean = sanitize_telemetry_event(event) if not clean: return False return _append_jsonl( os.path.join(state_dir, TELEMETRY_FILENAME), clean) def training_enabled(state_dir: str) -> bool: """训练退出开关状态:默认开(本项目决定,非 Meta 官方值)。 状态文件缺失即默认开;损坏/不可解析/缺 `enabled` 键时 fail-closed 当作关处理并记日志(损坏不等于同意上传)。""" path = os.path.join(state_dir, TRAINING_OPT_FILENAME) try: with open(path, encoding="utf-8") as fh: data = json.load(fh) except FileNotFoundError: return True except Exception as exc: # noqa: BLE001 - 损坏即关 _LOGGER.warning("training_opt_corrupt path=%s error=%s", path, exc) return False if not isinstance(data, dict) or "enabled" not in data: return False return bool(data["enabled"]) def set_training_enabled(state_dir: str, enabled: bool, actor: str = "user") -> bool: """一键退出/恢复训练:状态原子写 `/training-opt.json` (0600),变更追加进 `training-opt-audit.jsonl`。`enabled` 不是 bool 即 TypeError;写失败返回 False,永不抛错。 关后立即生效:后续轨迹事件不再产生。""" if not isinstance(enabled, bool): raise TypeError("enabled 必须是 bool: %r" % (enabled,)) record = {"enabled": enabled, "updated_at": time.time(), "actor": str(actor)[:64]} try: atomic_write_text( os.path.join(state_dir, TRAINING_OPT_FILENAME), json.dumps(record, ensure_ascii=False)) except Exception as exc: # noqa: BLE001 - 写失败只记日志 _LOGGER.warning("training_opt_write_failed error=%s", exc) return False audit = dict(record) audit["event"] = "training_opt_changed" _append_jsonl( os.path.join(state_dir, TRAINING_OPT_AUDIT_FILENAME), audit) return True