97 lines
4.9 KiB
Python
97 lines
4.9 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 偏好学习 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
|