aps-agent/server/knowledge/preferences.py

97 lines
4.9 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 偏好学习 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