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