# ============================================================ # 偏好学习 v1(moduleId: knowledge-preferences, 可重生 ✅, 黄金测试 tests/golden/test_m3_knowledge.py) # plan.md §8.3 三层递进的第一层落地: # 信号源:每次试排/采用方案的策略选择(PreferenceSample 的 M3 子集) # 产出:策略使用频次 → 个性化缺省策略("试排一版"不带策略时用你最常用的) # 权重回归/规则归纳留 M5(诚实声明)。 # ============================================================ from __future__ import annotations # 前向类型引用 import json # 序列化 import os # 路径 import tempfile # 原子写 import threading # 互斥 from datetime import datetime # 时间戳 from typing import Any # 类型标注 from server.timeutil import fmt_dt # 时间格式化 # 样本容量上限(滚动窗口:只保留最近 N 条,偏好随时间漂移) _MAX_SAMPLES = 200 class PreferenceStore: """偏好样本仓:记录策略选择信号 → 给出个性化缺省策略(P1 写样本 / P0 读)。""" def __init__(self, path: str | None = None) -> None: """初始化:加载既有样本。""" self.path = path or os.environ.get("APS_PREFERENCE_PATH", "server/data/preferences.json") # 路径 self._lock = threading.Lock() # 并发保护 self.samples: list[dict[str, Any]] = self._load() # 样本列表(时间升序) def _load(self) -> list[dict[str, Any]]: """加载样本;缺失/损坏返回空。""" try: with open(self.path, "r", encoding="utf-8") as f: # 读文件 return json.load(f).get("samples", []) # 样本数组 except (FileNotFoundError, json.JSONDecodeError): # 缺失/损坏 return [] def _write(self) -> None: """原子写盘。""" os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) # 确保目录 fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp") # 临时文件 try: with os.fdopen(fd, "w", encoding="utf-8") as f: # 写入 json.dump({"samples": self.samples}, f, ensure_ascii=False) # 序列化 os.replace(tmp, self.path) # 原子替换 except BaseException: # 失败清理 if os.path.exists(tmp): os.unlink(tmp) raise def record(self, strategy: str, source: str, actor: str = "planner") -> None: """记录一次策略选择信号(P1:只追加样本文件)。 Args: strategy: 本次使用的策略模板(DELIVERY_FIRST…) source: 信号来源 schedule.run / scenario.apply(采用比试排权重高) actor: 计划员标识 """ with self._lock: # 串行化 self.samples.append({ # 追加样本(PreferenceSample 子集) "strategy": strategy, "source": source, "actor": actor, "at": fmt_dt(datetime.now()), }) if len(self.samples) > _MAX_SAMPLES: # 滚动窗口 self.samples = self.samples[-_MAX_SAMPLES:] # 保留最近 N 条 self._write() # 落盘 def preferred_strategy(self, default: str = "COMPREHENSIVE") -> tuple[str, dict[str, int]]: """给出个性化缺省策略(P0 只读)。 规则:采用(scenario.apply)计 2 分、试排计 1 分;最高分策略即缺省; 无样本时返回系统默认。 Returns: (策略, 各策略得分表——透明可解释,进证据链) """ scores: dict[str, int] = {} # 策略得分 for s in self.samples: # 逐样本累计 w = 2 if s["source"] == "scenario.apply" else 1 # 采用权重更高(明确偏好信号) scores[s["strategy"]] = scores.get(s["strategy"], 0) + w if not scores: # 无信号 return default, scores best = max(scores.items(), key=lambda kv: kv[1])[0] # 最高分策略 return best, scores # 带得分表(可解释) # ---------------- 进程级单例 ---------------- _store: PreferenceStore | None = None # 单例槽 def get_preferences() -> PreferenceStore: """取偏好仓单例。""" global _store if _store is None: # 首次创建 _store = PreferenceStore() return _store