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
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
# ---------------- 特征工程与规则归纳(矩阵 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"
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
def record(self, strategy: str, source: str, actor: str = "planner",
|
|
|
|
|
|
project_id: str | None = None, features: dict[str, Any] | None = None) -> None:
|
2026-07-21 11:05:57 +08:00
|
|
|
|
"""记录一次策略选择信号(P1:只追加样本文件)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
strategy: 本次使用的策略模板(DELIVERY_FIRST…)
|
|
|
|
|
|
source: 信号来源 schedule.run / scenario.apply(采用比试排权重高)
|
|
|
|
|
|
actor: 计划员标识
|
2026-08-20 11:39:21 +08:00
|
|
|
|
project_id: 项目作用域(矩阵 66 行:偏好按用户/项目隔离;None=个人全局)
|
|
|
|
|
|
features: 订单结构/客户等级/约束压力特征(extract_features 产出;
|
|
|
|
|
|
矩阵 66 行剩余项:多特征规则归纳的信号输入;None=旧样本兼容)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
"""
|
|
|
|
|
|
with self._lock: # 串行化
|
2026-08-20 11:39:21 +08:00
|
|
|
|
sample = { # 追加样本(PreferenceSample 子集)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
"strategy": strategy, "source": source, "actor": actor,
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"projectId": project_id, "at": fmt_dt(datetime.now()),
|
|
|
|
|
|
}
|
|
|
|
|
|
if features: # 特征样本(规则归纳输入)
|
|
|
|
|
|
sample["features"] = dict(features)
|
|
|
|
|
|
self.samples.append(sample)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if len(self.samples) > _MAX_SAMPLES: # 滚动窗口
|
|
|
|
|
|
self.samples = self.samples[-_MAX_SAMPLES:] # 保留最近 N 条
|
|
|
|
|
|
self._write() # 落盘
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
_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 行:按项目隔离 + 冷启动回退)。
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
规则:时间衰减加权(采用2×衰减 / 试排1×衰减),最高分策略即默认;
|
|
|
|
|
|
无样本时返回系统默认并带空得分表(冷启动由 explain/recommend 标注)。
|
2026-07-21 11:05:57 +08:00
|
|
|
|
Returns: (策略, 各策略得分表——透明可解释,进证据链)
|
|
|
|
|
|
"""
|
2026-08-20 11:39:21 +08:00
|
|
|
|
scores = self._decayed_scores(project_id)
|
|
|
|
|
|
if not scores: # 无信号(冷启动)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return default, scores
|
|
|
|
|
|
best = max(scores.items(), key=lambda kv: kv[1])[0] # 最高分策略
|
|
|
|
|
|
return best, scores # 带得分表(可解释)
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
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": []}
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
# ---------------- 按租户/用户隔离的实例 ----------------
|
|
|
|
|
|
_stores: dict[tuple[str, int], PreferenceStore] = {}
|
|
|
|
|
|
_stores_lock = threading.Lock()
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_preferences() -> PreferenceStore:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""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
|