aps-agent/server/knowledge/preferences.py

348 lines
17 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
# ---------------- 特征工程与规则归纳(矩阵 66 行剩余项) ----------------
# 多特征回归:订单结构 / 客户等级 / 排产约束 → 策略。
# 务实落地为「案例推理式规则归纳」:把样本按特征分箱统计条件偏好,
# 小样本也可解释(plan.md §8.3 小样本学习:层次借力/案例推理)。
_FEATURE_BINS: dict[str, tuple[float, float]] = { # 分箱阈值 (t1, t2) → low / med / high
"orderCount": (5, 20), # 订单量:<5 少,5~19 中,>=20 多
"vipRatio": (0.2, 0.5), # VIP/高优先占比
"urgencyRatio": (0.25, 0.5), # 交期紧迫占比(半周期内到期)
"mixCount": (3, 8), # 品种数
"hardConflictCount": (5, 20), # 硬冲突条数
}
def _days_until(date_str: str) -> float | None:
"""'YYYY-MM-DD' → 距今天数;不可解析返回 None。"""
try:
d = datetime.strptime(str(date_str)[:10], "%Y-%m-%d").date()
return (d - datetime.now().date()).days
except (ValueError, TypeError):
return None
def extract_features(world: dict[str, Any]) -> dict[str, Any]:
"""从世界状态抽取订单结构/客户等级/约束压力特征(矩阵 66 行)。
特征集(全部可解释、无需外部依赖):
orderCount 可排产订单数
vipRatio VIP 或高优先(priority<=2) 订单占比
urgencyRatio 交期在半周期内(急单)占比
mixCount 不同产品品种数
hardConflictCount 当前硬冲突条数(约束压力)
"""
orders = world.get("productionOrders") or world.get("salesOrders") or []
horizon = int((world.get("scheduleParams") or {}).get("planningHorizonDays") or 14)
half = max(1, horizon // 2)
products: set[str] = set()
vip = 0
urgent = 0
for o in orders:
pc = str(o.get("productCode") or o.get("productId") or "").strip()
if pc:
products.add(pc)
try:
prio = int(o.get("priority") or 5)
except (TypeError, ValueError):
prio = 5
if str(o.get("customerLevel") or "").upper() == "VIP" or prio <= 2:
vip += 1
due = str(o.get("dueDate") or o.get("deliveryDate") or "")
days = _days_until(due)
if days is not None and 0 <= days <= half:
urgent += 1
n = max(1, len(orders))
conflicts = world.get("conflicts") or []
return {
"orderCount": len(orders),
"vipRatio": round(vip / n, 3),
"urgencyRatio": round(urgent / n, 3),
"mixCount": len(products),
"hardConflictCount": len(conflicts),
}
def _bin_feature(feature: str, value: Any) -> str | None:
"""连续特征分箱 → 'low' / 'med' / 'high';未知特征返回 None。"""
thresholds = _FEATURE_BINS.get(feature)
if thresholds is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
t1, t2 = thresholds
if v < t1:
return "low"
if v < t2:
return "med"
return "high"
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",
project_id: str | None = None, features: dict[str, Any] | None = None) -> None:
"""记录一次策略选择信号(P1:只追加样本文件)。
Args:
strategy: 本次使用的策略模板(DELIVERY_FIRST…)
source: 信号来源 schedule.run / scenario.apply(采用比试排权重高)
actor: 计划员标识
project_id: 项目作用域(矩阵 66 行:偏好按用户/项目隔离;None=个人全局)
features: 订单结构/客户等级/约束压力特征(extract_features 产出;
矩阵 66 行剩余项:多特征规则归纳的信号输入;None=旧样本兼容)
"""
with self._lock: # 串行化
sample = { # 追加样本(PreferenceSample 子集)
"strategy": strategy, "source": source, "actor": actor,
"projectId": project_id, "at": fmt_dt(datetime.now()),
}
if features: # 特征样本(规则归纳输入)
sample["features"] = dict(features)
self.samples.append(sample)
if len(self.samples) > _MAX_SAMPLES: # 滚动窗口
self.samples = self.samples[-_MAX_SAMPLES:] # 保留最近 N 条
self._write() # 落盘
_COLD_START_MIN = 3
def _samples_for(self, project_id: str | None) -> list[dict[str, Any]]:
"""按项目作用域过滤样本;project_id=None 取个人全局样本。"""
if project_id is None:
return [s for s in self.samples if not s.get("projectId")]
return [s for s in self.samples if s.get("projectId") == project_id]
# 时间衰减半衰期(天):偏好随最近样本漂移(权重回归的务实落地,矩阵 66 行)
_DECAY_HALF_LIFE_DAYS = 14.0
def _decayed_scores(self, project_id: str | None) -> dict[str, float]:
"""时间衰减加权评分(矩阵 66 行:权重回归/规则归纳替代简单计数)。
每个样本得分 = 来源权重(采用2/试排1) × 时间衰减(2^(-age_days/半衰期));
越近的信号越重要(偏好漂移),历史久远样本权重趋近于 0。
Returns: {策略: 加权总分}
"""
from datetime import datetime
scores: dict[str, float] = {}
now = datetime.now()
for s in self._samples_for(project_id):
base_w = 2 if s["source"] == "scenario.apply" else 1
try:
at = datetime.strptime(str(s.get("at") or "")[:16], "%Y-%m-%d %H:%M")
age_days = max(0.0, (now - at).total_seconds() / 86400.0)
except (ValueError, TypeError):
age_days = 0.0 # 无时间戳按最近处理
decay = 2.0 ** (-age_days / self._DECAY_HALF_LIFE_DAYS)
scores[s["strategy"]] = scores.get(s["strategy"], 0.0) + base_w * decay
# 舍入到 2 位小数:当天信号≈整数(兼容既有测试语义),久远信号保留衰减精度
return {k: round(v, 2) for k, v in scores.items()}
def preferred_strategy(self, default: str = "COMPREHENSIVE",
project_id: str | None = None) -> tuple[str, dict[str, float]]:
"""给出个性化默认策略(P0 只读;矩阵 66 行:按项目隔离 + 冷启动回退)。
规则:时间衰减加权(采用2×衰减 / 试排1×衰减),最高分策略即默认;
无样本时返回系统默认并带空得分表(冷启动由 explain/recommend 标注)。
Returns: (策略, 各策略得分表——透明可解释,进证据链)
"""
scores = self._decayed_scores(project_id)
if not scores: # 无信号(冷启动)
return default, scores
best = max(scores.items(), key=lambda kv: kv[1])[0] # 最高分策略
return best, scores # 带得分表(可解释)
def explain(self, default: str = "COMPREHENSIVE",
project_id: str | None = None) -> dict[str, Any]:
"""偏好解释(矩阵 66 行):得分明细 + 冷启动/不确定度标注 + 依据样本。
Returns: {"strategy", "scores", "confidence": high|medium|cold,
"reason", "sampleCount", "totalCount"}
"""
samples = self._samples_for(project_id)
strategy, scores = self.preferred_strategy(default, project_id=project_id)
total = len(samples)
if total == 0:
return {"strategy": default, "scores": scores, "confidence": "cold",
"reason": "暂无偏好信号,使用系统默认策略", "sampleCount": 0,
"totalCount": len(self.samples)}
ranked = sorted(scores.items(), key=lambda kv: -kv[1])
if len(ranked) >= 2 and abs(ranked[0][1] - ranked[1][1]) < 1e-9:
confidence, reason = "medium", "存在并列最高分策略,偏好尚不明确"
elif total < self._COLD_START_MIN:
confidence, reason = "cold", f"偏好信号不足({total} 条,阈值 {self._COLD_START_MIN})"
elif len(ranked) >= 2 and (ranked[0][1] - ranked[1][1]) < 0.5:
confidence, reason = "medium", "最高分与次高分接近,偏好方向尚不十分明确"
else:
confidence, reason = "high", "偏好信号充足,最高分策略明确"
return {"strategy": strategy, "scores": scores, "confidence": confidence,
"reason": reason, "sampleCount": total, "totalCount": len(self.samples)}
def reset(self, project_id: str | None = None) -> int:
"""重置偏好(矩阵 66 行):清除指定项目(None=个人全局)的样本,返回清除条数。"""
with self._lock: # 串行化
before = len(self.samples)
if project_id is None:
self.samples = [s for s in self.samples if s.get("projectId")]
else:
self.samples = [s for s in self.samples if s.get("projectId") != project_id]
removed = before - len(self.samples)
self._write() # 落盘
return removed
# ---------------- 多特征规则归纳(矩阵 66 行剩余项) ----------------
def induced_rules(self, project_id: str | None = None) -> dict[str, dict[str, dict[str, Any]]]:
"""从带特征的样本归纳条件偏好规则。
对每个 (特征, 分箱) 统计「采用2/试排1」条件得分与样本数,
选出该箱最高分策略;支持量=最高分/总分。规则可直接解释:
如 {orderCount: {high: {strategy: DELIVERY_FIRST, score: 6.0, count: 3, support: 0.75}}}。
Returns: {feature: {bin: {"strategy", "score", "count", "support"}}}
"""
samples = [s for s in self._samples_for(project_id) if s.get("features")]
if not samples:
return {}
acc: dict[str, dict[str, dict[str, Any]]] = {}
for s in samples:
for feature, value in (s.get("features") or {}).items():
b = _bin_feature(feature, value)
if b is None:
continue
key = acc.setdefault(feature, {}).setdefault(b, {"scores": {}, "count": 0})
key["count"] += 1
base = 2 if s.get("source") == "scenario.apply" else 1
strat = str(s.get("strategy") or "?")
key["scores"][strat] = key["scores"].get(strat, 0.0) + base
rules: dict[str, dict[str, dict[str, Any]]] = {}
for feature, bins in acc.items():
rules[feature] = {}
for b, agg in bins.items():
best_strat, best_score = max(agg["scores"].items(), key=lambda kv: kv[1])
total = sum(agg["scores"].values())
rules[feature][b] = {
"strategy": best_strat,
"score": round(best_score, 2),
"count": agg["count"],
"support": round(best_score / max(1.0, total), 2),
}
return rules
def preferred_strategy_with_features(
self, features: dict[str, Any], default: str = "COMPREHENSIVE",
project_id: str | None = None,
) -> tuple[str, dict[str, float], list[dict[str, Any]]]:
"""按特征匹配规则给出策略建议(矩阵 66 行剩余项)。
每个命中的 (特征, 分箱) 规则把其得分(按样本数收敛到置信系数)投给对应策略;
无任何规则命中时回退全局时间衰减偏好。
Returns: (策略, 投票得分表, 命中的规则明细——可进证据链/解释)
"""
rules = self.induced_rules(project_id)
votes: dict[str, float] = {}
used: list[dict[str, Any]] = []
for feature, value in (features or {}).items():
b = _bin_feature(feature, value)
if b is None or feature not in rules or b not in rules[feature]:
continue
rule = rules[feature][b]
confidence = min(1.0, rule["count"] / 3.0) # 小样本收敛系数
votes[rule["strategy"]] = votes.get(rule["strategy"], 0.0) + rule["score"] * confidence
used.append({"feature": feature, "bin": b, **rule})
if votes:
best = max(votes.items(), key=lambda kv: kv[1])[0]
return best, votes, used
strategy, scores = self.preferred_strategy(default, project_id=project_id)
return strategy, scores, []
def explain_features(
self, features: dict[str, Any], default: str = "COMPREHENSIVE",
project_id: str | None = None,
) -> dict[str, Any]:
"""特征版偏好解释:规则命中明细 + 回退原因(中文,计划员可读)。"""
strategy, votes, used = self.preferred_strategy_with_features(
features, default=default, project_id=project_id)
total = len(self._samples_for(project_id))
if used:
lines = [f"依据订单结构与约束压力({len(used)} 条规则命中)建议 {strategy}:"]
for r in used[:6]:
lines.append(
f"· {r['feature']}[{r['bin']}] → {r['strategy']}"
f"({r['count']} 条样本,支持度 {r['support']})")
reason = ";".join(lines)
return {"strategy": strategy, "scores": votes, "confidence": "medium",
"reason": reason, "sampleCount": total, "totalCount": len(self.samples),
"rules": used}
return {**self.explain(default=default, project_id=project_id), "rules": []}
# ---------------- 按租户/用户隔离的实例 ----------------
_stores: dict[tuple[str, int], PreferenceStore] = {}
_stores_lock = threading.Lock()
def get_preferences() -> PreferenceStore:
"""Return the current user's private preference store."""
from server.auth.context import get_identity
from server.state.store import world_path_for
identity = get_identity()
key = (identity.tenant_uuid, identity.user_id)
with _stores_lock:
store = _stores.get(key)
if store is None:
if identity.tenant_uuid == "platform" and not identity.user_id:
path = os.environ.get("APS_PREFERENCE_PATH", "server/data/preferences.json")
else:
world_path = world_path_for(f"personal-{identity.user_id}", identity.tenant_uuid)
path = os.path.join(os.path.dirname(world_path), "preferences.json")
store = PreferenceStore(path)
_stores[key] = store
return store