guard.js 3.97 KB
/**
 * `guardGeneration` —— 生成链路**前置闸**编排(§4 职责)。
 *
 * 这是一期产品要点:护栏发生在生成产物落库/回流**之前**,不是事后扫描。
 * 流程:①先跑本地 `rules`(敏感词/长度/格式硬红线);②若注入了 `llmGuardPort`,
 * 再调它做基础安全类目(暴力/色情/仇恨/未成年)分类;③`decision.js` 取最严合并。
 *
 * 基础安全类目判定**只来自 `llmGuardPort`**,本包不自训/自写分类模型(验收 4/7)。
 * 未注入 `llmGuardPort` 时优雅降级为 `rules-only` 且如实标注,不假装做了 LLM 护栏
 * (验收 1,§5 review 关注点 4)。
 *
 * @typedef {object} GuardPort
 * @property {(args: { text:string, context?:any, region?:string }) =>
 *   Promise<{ categories?:string[], unsafe?:boolean, reasons?:string[] }> | { categories?:string[], unsafe?:boolean, reasons?:string[] }
 * } classify  薄封装厂商 moderation 端点 或 轻量提示词裁判
 *
 * @typedef {object} GuardResult
 * @property {'allow'|'flag'|'block'} decision
 * @property {string[]} reasons
 * @property {string[]} categories
 * @property {'rules+llm'|'rules-only'} guardLevel
 */

import { ModerationError, ModerationErrorCode } from './errors.js'
import { resolveRules, runRulesCheck } from './rules-check.js'
import { mergeDecisions, guardLevelOf } from './decision.js'

/**
 * 运行生成前置护栏。
 *
 * @param {object} deps
 * @param {import('./rules-check.js').Rules} deps.rules   注入的规则
 * @param {GuardPort} [deps.llmGuardPort]                 可选注入的 LLM 护栏 Port
 * @param {object} [args]
 * @param {string} [args.text]
 * @param {any}    [args.context]                         生成上下文(喂给 llmGuardPort)
 * @param {string} [args.policy]                          二期分级位(一期走默认 policy)
 * @param {string} [args.region]                          二期区域位(一期透传给 Port,可忽略)
 * @returns {Promise<GuardResult>}
 */
export async function runGuardGeneration({ rules, llmGuardPort }, { text, context, policy, region } = {}) {
  if (typeof text !== 'string') {
    throw new ModerationError({
      code: ModerationErrorCode.BAD_REQUEST,
      retryable: false,
      message: 'guardGeneration 需要 string 类型的 text',
    })
  }

  const resolved = resolveRules(rules, policy)

  // ① 本地规则(前置硬红线)。
  const local = runRulesCheck({ text, kind: 'generated', rules: resolved })

  /** @type {Array<{decision?:string, reasons?:string[], categories?:string[]}>} */
  const sources = [{ decision: local.decision, reasons: local.reasons, categories: [] }]

  const hasLlm = Boolean(llmGuardPort && typeof llmGuardPort.classify === 'function')

  // ② LLM 基础安全类目(仅当注入了 Port)。
  if (hasLlm && llmGuardPort) {
    let cls
    try {
      cls = await llmGuardPort.classify({ text, context, region })
    } catch (err) {
      throw new ModerationError({
        code: ModerationErrorCode.GUARD_PORT_FAILED,
        retryable: true,
        cause: err,
        message: `llmGuardPort.classify 调用失败: ${err instanceof Error ? err.message : String(err)}`,
      })
    }
    if (!cls || typeof cls !== 'object') {
      throw new ModerationError({
        code: ModerationErrorCode.GUARD_PORT_BAD_SHAPE,
        retryable: false,
        message: 'llmGuardPort.classify 必须返回 { categories, unsafe, reasons }',
      })
    }
    const categories = Array.isArray(cls.categories) ? cls.categories : []
    const llmReasons = Array.isArray(cls.reasons) ? cls.reasons : []
    // unsafe=true → block(前置阻断回流);否则该路 allow。
    sources.push({
      decision: cls.unsafe ? 'block' : 'allow',
      reasons: llmReasons,
      categories,
    })
  }

  // ③ 取最严合并。
  const merged = mergeDecisions(sources)
  return {
    decision: merged.decision,
    reasons: merged.reasons,
    categories: merged.categories,
    guardLevel: guardLevelOf(hasLlm),
  }
}