aps-agent/server/knowledge/assets.py

143 lines
8.8 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.

# ============================================================
# 知识资产库(moduleId: knowledge-assets, 可重生 ✅, 黄金测试 tests/golden/test_m3_knowledge.py)
# plan.md §8.1:四类资产(算法知识/企业 SOP/历史案例/工艺知识),
# 版本化 + 入库审批标记 + 检索强制带出处。
# M3 形态:单文件 JSON + 种子资产;接口稳定,后续可换向量库不改调用方。
# ============================================================
from __future__ import annotations # 前向类型引用
import json # 序列化
import os # 路径
import tempfile # 原子写
import threading # 互斥
import uuid # 资产 ID
from datetime import datetime # 时间戳
from typing import Any # 类型标注
from server.timeutil import fmt_dt # 时间格式化
# 存储路径(与世界状态同目录)
_PATH_ENV = "APS_KNOWLEDGE_PATH"
def _seed_assets() -> list[dict[str, Any]]:
"""种子知识资产(演示语料;真实项目由导入管线 §8.4 入库)。"""
now = fmt_dt(datetime.now()) # 统一时间戳
def asset(kind: str, title: str, content: str, tags: list[str]) -> dict[str, Any]:
return { # 资产结构(DatasetAsset 的知识侧近亲)
"assetId": uuid.uuid4().hex[:10], # 资产 ID(@知识 引用用)
"kind": kind, # algorithm / sop / case / process
"title": title, # 标题(引用与出处展示)
"content": content, # 正文(M3 单块;后续分 chunk)
"tags": tags, # 检索加权标签
"version": "v1", # 版本号(重复入库递增)
"approved": True, # 审批标记(§8.1 入库需审批)
"createdAt": now, # 入库时间
}
return [
asset("sop", "换线标准SOP",
"产线换型必须遵守:① 同产品族连续生产优先,减少换线;② 换线作业标准 30 分钟,"
"跨产品族换线 60 分钟;③ 每日 16:00 后不安排换线(夜班技师不足);"
"④ 换线前必须完成上一批次尾检,未尾检不得拆线。",
["换线", "SOP", "产品族", "夜班"]),
asset("sop", "插单审批流程",
"VIP 客户插单:计划员评估影响(延迟/成本)→ 主管审批 → 发布新版本。"
"非 VIP 插单原则上排入下一个计划周期;确需插入的走例外审批,须附影响评估报告。",
["插单", "审批", "VIP", "例外"]),
asset("algorithm", "交期优先策略说明",
"DELIVERY_FIRST 按交期升序 + 客户等级加权排序订单,优先保障最紧交期。"
"适用:交付压力大、订单交期集中。代价:可能牺牲产能均衡与换线次数。"
"参数:客户等级权重(VIP=3/重要=2/普通=1),同交期按下单时间先到先排。",
["交期优先", "DELIVERY_FIRST", "策略", "权重"]),
asset("algorithm", "产能均衡策略说明",
"CAPACITY_BALANCE 以产线负荷方差最小为目标分配订单,避免忙闲不均。"
"适用:多产线可互替、瓶颈不固定。代价:个别紧急订单可能被排后。"
"建议与交期承诺看板联用,均衡后逐单核对交付风险。",
["产能均衡", "CAPACITY_BALANCE", "负荷", "方差"]),
asset("process", "产线A设备能力",
"产线A(L001)适合小批量多品种:SMT 贴片 + 波峰焊;单班产能 400 标准件/日;"
"维保窗口每周三 08:00-10:00 固定停机;夜班仅一班技师,复杂换型不排夜班。",
["产线A", "L001", "产能", "维保"]),
asset("case", "6月底比亚迪插单复盘",
"6月28日比亚迪 VIP 插单 2000 件:采用交期优先重排,牺牲产线B均衡度 12%,"
"全部订单按期交付。经验:VIP 插单后应立即跑一次沙盒对比,确认对存量订单影响 <5% 再发布。",
["比亚迪", "插单", "复盘", "沙盒"]),
]
class KnowledgeStore:
"""知识资产仓:加载/列出/取回/新增(单文件 JSON + 原子写;P0 读 / P1 新增)。"""
def __init__(self, path: str | None = None) -> None:
"""初始化:加载资产,空库自动播种。权力等级 P0(构造只读+首次播种)。"""
self.path = path or os.environ.get(_PATH_ENV, "server/data/knowledge.json") # 存储路径
self._lock = threading.Lock() # 并发保护
self.assets: list[dict[str, Any]] = self._load() # 资产列表
if not self.assets: # 空库播种
self.assets = _seed_assets() # 种子资产
self._write() # 落盘
def _load(self) -> list[dict[str, Any]]:
"""从磁盘加载;缺失/损坏返回空(触发播种)。"""
try:
with open(self.path, "r", encoding="utf-8") as f: # 读文件
return json.load(f).get("assets", []) # 取资产数组
except (FileNotFoundError, json.JSONDecodeError): # 缺失/损坏
return []
def _write(self) -> None:
"""原子写盘(tmp + os.replace,与 WorldStore 同法)。"""
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({"assets": self.assets}, f, ensure_ascii=False, indent=1) # 序列化
os.replace(tmp, self.path) # 原子替换
except BaseException: # 失败清理
if os.path.exists(tmp):
os.unlink(tmp)
raise
def list_meta(self) -> list[dict[str, Any]]:
"""资产元信息清单(不含正文;知识面板/命令面板数据源,P0)。"""
return [{k: a[k] for k in ("assetId", "kind", "title", "version", "createdAt")}
for a in self.assets]
def get(self, asset_id: str) -> dict[str, Any] | None:
"""按 ID 取完整资产(@知识 引用解析用,P0)。"""
return next((a for a in self.assets if a["assetId"] == asset_id), None)
def find_by_title(self, title: str) -> dict[str, Any] | None:
"""按标题模糊取资产(@知识:换线标准SOP 的解析路径,P0)。"""
t = title.strip() # 清理输入
return next((a for a in self.assets if t and t in a["title"]), None)
def add(self, kind: str, title: str, content: str, tags: list[str] | None = None) -> dict[str, Any]:
"""新增资产(P1:只新增知识,不碰世界状态;报告入库走此口 §9.10 规则3)。"""
with self._lock: # 串行化写
existing = self.find_by_title(title) # 同名视为新版本
version = "v1" # 默认首版
if existing and existing["title"] == title: # 完全同名
version = f"v{int(existing['version'].lstrip('v')) + 1}" # 版本递增
asset = { # 组装资产
"assetId": uuid.uuid4().hex[:10], "kind": kind, "title": title,
"content": content, "tags": tags or [], "version": version,
"approved": kind != "report", # 报告类默认待审(对外须人工批 §9.10 规则4)
"createdAt": fmt_dt(datetime.now()),
}
self.assets.append(asset) # 入库
self._write() # 落盘
return asset # 返回
# ---------------- 进程级单例 ----------------
_store: KnowledgeStore | None = None # 单例槽
def get_knowledge() -> KnowledgeStore:
"""取知识库单例(gateway/workflow 共用)。"""
global _store
if _store is None: # 首次创建
_store = KnowledgeStore()
return _store