decision.js 2.64 KB
/**
 * 多来源判定合并 —— 零依赖纯函数(§4 职责:最易测)。
 *
 * 合并语义「取最严」:`block` > `flag` > `allow`。任一来源 block 即最终 block;
 * 无 block 但有 flag 即 flag;全 allow 才 allow。这是审核域唯一的优先级,
 * 显式可见、不藏隐式规则(§6 可读性)。
 *
 * `guardLevel` 标注用于让调用方区分「rules + llm 双护栏」与「仅本地规则降级」
 * 两种结果,避免假装做了 LLM 护栏(§5 review 关注点 4)。
 */

/**
 * 三档判定的严格序(数值越大越严)。
 * @type {Record<string, number>}
 */
const SEVERITY = Object.freeze({ allow: 0, flag: 1, block: 2 })

const ORDER = Object.freeze(['allow', 'flag', 'block'])

/**
 * 归一一个 decision 字符串;非法值保守落为 'block'(fail-closed,安全优先)。
 * @param {unknown} d
 * @returns {'allow'|'flag'|'block'}
 */
function normDecision(d) {
  if (d === 'allow' || d === 'flag' || d === 'block') return d
  return 'block'
}

/**
 * 取两个 decision 中更严的一个。
 * @param {'allow'|'flag'|'block'} a
 * @param {'allow'|'flag'|'block'} b
 * @returns {'allow'|'flag'|'block'}
 */
export function strictest(a, b) {
  const na = normDecision(a)
  const nb = normDecision(b)
  return SEVERITY[na] >= SEVERITY[nb] ? na : nb
}

/**
 * 合并多个来源的判定为最终决策(取最严)。
 *
 * @param {Array<{ decision?: string, reasons?: string[], categories?: string[] }>} sources
 *   各来源的子判定(本地 rules、llm 护栏等)。空数组视为无问题 → 'allow'。
 * @returns {{ decision:'allow'|'flag'|'block', reasons:string[], categories:string[] }}
 */
export function mergeDecisions(sources) {
  const list = Array.isArray(sources) ? sources : []
  let decision = /** @type {'allow'|'flag'|'block'} */ ('allow')
  /** @type {string[]} */
  const reasons = []
  /** @type {string[]} */
  const categories = []

  for (const s of list) {
    if (!s) continue
    decision = strictest(decision, normDecision(s.decision))
    if (Array.isArray(s.reasons)) {
      for (const r of s.reasons) if (r && !reasons.includes(r)) reasons.push(r)
    }
    if (Array.isArray(s.categories)) {
      for (const c of s.categories) if (c && !categories.includes(c)) categories.push(c)
    }
  }

  return { decision, reasons, categories }
}

/**
 * 标注护栏级别:注入了 llm 护栏 → 'rules+llm';否则降级 'rules-only'。
 * @param {boolean} hasLlmGuard
 * @returns {'rules+llm'|'rules-only'}
 */
export function guardLevelOf(hasLlmGuard) {
  return hasLlmGuard ? 'rules+llm' : 'rules-only'
}

export { SEVERITY as DECISION_SEVERITY, ORDER as DECISION_ORDER }