aps-agent/server/knowledge/preferences.py

97 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 偏好学习 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