跳到正文

合规风险智能体 ​

本页说明合规风险智能体如何在受控权限下巡检自有站点与营销物料中的表述是否触碰禁用宣传主张或监管红线,并结合公开监管公告输出带证据的风险清单。读完本页,你能理解这个场景需要哪些模块、什么时候才需要交互式确认,以及禁用清单和法规版本为什么必须来自受控规则库而不是 Memory。

适用场景 ​

合规团队需要关注监管公告、政策变化、行业风险和内部业务影响,同时确保自有官网、落地页和营销物料中的表述不触碰禁用宣传主张或监管红线。物料量大、更新频繁,人工逐页核对跟不上发布节奏;等监管函件到了再回查,往往连"当时页面写了什么"都难以还原。

典型触发时机:

  • 监管机构发布新的表述限制或行业指引,需要排查现有物料是否受影响。
  • 大型营销活动上线前,需要巡检全部落地页和物料表述。
  • 定期合规巡检周期到期,需要输出本期风险清单和证据存档。

工程挑战 ​

  • 判定依据必须受控:禁用宣传主张清单和法规条目是有版本、有生效时间的合规资产,任何"从记忆里召回"的规则都可能是过期口径——依据错误的清单产出结论,比漏检更危险。
  • 取证要求高于一般巡检:监管回查时需要还原"当时页面写了什么、依据哪一版清单判定";没有截图、原文摘录和时间戳的疑似项没有证据价值。
  • 输出定位边界敏感:巡检工具的输出一旦被当成合规结论,就会替代本应由合规团队作出的专业判断——输出必须严格限定为审计数据基础,不作合规符合性断言。

模块组合 ​

模块角色说明
GenAuth核心运行时以静默委托签发短时效凭证(所有产品调用必需);本场景默认无需交互式确认,接入登录态或写动作时才升级 interactive。凭证每期新签、可撤销。
Web Agent核心用 Track 巡检自有站点和物料页面,按确定性规则比对禁用宣传主张清单;通过 WebSearch 搜索监管网站和公开法规资料,保留全部来源。
GUMem不使用禁用宣传主张清单和法规版本是受控合规资产,必须来自你的受控规则库(policy store)并按版本引用;风险清单、证据和合规团队的最终判断归档到你的审计存储。都不属于 Memory。

合规风险智能体:场景架构

何时需要交互式确认 ​

所有产品调用都需要 GenAuth 委托令牌;公开只读场景用静默委托即可。这个场景的主要对象是自有站点公开页和公开监管资料,静默委托(delegateToken 不带 mode: 'interactive')即可覆盖:凭证短时效、可随时撤销,周期巡检每期新签发。只有以下情况需要升级为交互式确认,由合规负责人在 Qoni Console 确认:

  • 巡检需要登录的物料后台或 CMS 预览。
  • 委托范围要扩大到新的业务线或地区。

注意:本样例申请 doAnything 产品权限。业务线、地区和页面范围等业务边界需要由应用和下游服务配置、校验;产品 scope 和 prompt 中的规则不提供这些细粒度限制。本样例未展示该配置。授权语义见 Delegate Token 与缩权。

工作流程 ​

合规风险智能体:工作流程

  1. 合规团队提交巡检范围:业务线、地区、物料清单。

  2. GenAuth 下发本期巡检的只读委托凭证。

  3. 应用从受控规则库读取当前版本的禁用宣传主张清单和适用法规条目,注入任务描述。

  4. Web Agent 通过 WebSearch 查询相关监管公告和法规更新,记录来源。

  5. Web Agent 用 Track 巡检自有站点和物料页面,按确定性规则比对表述与禁用宣传主张清单。

    检查点:每个疑似触碰项都必须留存页面截图、原文摘录和来源 URL;无证据的疑似项不进入风险清单。

  6. 应用侧校验输出契约:缺少证据来源或规则编号的疑似项直接丢弃;风险清单与证据指针按清单版本归档到你的审计存储。

  7. Agent 输出风险清单、证据、待人工确认项和 audit id,移交合规团队。

    检查点:交付物应明确标注"提供审计数据基础,不作合规符合性断言";最终判断由合规团队作出。

示例代码 ​

本页使用已发布到 npm 的 @qoniai/qoni 0.9.0;下载包附带同版本 SDK,由 npm ci 安装。默认对照 Firefox 公开隐私页与“绝对化承诺需要证据”的演示规则进行检查。公开网页由真实 SDK 读取,业务输入在 scenarios.ts 中标记为 public-demo。

本演示不读取或写入 GUMem;任务输入来自页面清单和明确提供的业务数据。DoAnything 直接打开所给页面,按场景任务读取并形成输出。接入自己的数据时,用 --input 指定 JSON 文件;需要用户同意时用 --interactive 发起委托,站点登录与交互处理见 Qoni SDK。

下载完整可运行样例,或在文档仓库中执行:

bash
cd examples/qoni
npm ci
npm run case -- compliance-risk-agent
# 使用自己的输入
npm run case -- compliance-risk-agent --input /path/to/input.json

运行前设置服务端 QONI_ACCESS_KEY、QONI_SECRET_KEY。QONI_USER_ID 可指定业务已识别的 GenAuth 用户;未指定时,本地演示从绑定用户池取用户。涉及演示 Memory 写入时,程序创建独立用户以避免修改业务用户的偏好。

本场景的真实执行入口:

ts
import { cliOptions } from '../runtime.js'
import { inputFile, runScenario } from '../run-case.js'

// 逐条引用页面证据,形成供人工复核的规则检查草稿。
const report = await runScenario('compliance-risk-agent', cliOptions(), inputFile())
// report.items:页面、风险或观察、原文、规则 ID 和来源;不是法律意见。
// 校验字段:page, risk, quote, ruleId, sourceUrl.
console.log(JSON.stringify(report, null, 2))

入口按场景 ID 读取下面这段定义,代码直接引用实际执行的 scenarios.ts,注释按页面语言显示:任务描述、输出字段、来源字段、Web Search 查询(如有)、是否使用 Memory,以及演示输入。执行链会在任务描述后追加输入数据、搜索来源、召回的 Memory 和通用安全约束,拼成最终的任务描述,完整拼装逻辑见下方执行链。

ts
// 逐条引用页面证据,形成供人工复核的规则检查草稿。
browser('compliance-risk-agent',
  'Review the supplied public pages against the demonstration policy. Return [{page,risk,quote,ruleId,sourceUrl}]. This is a rule-checking draft for a reviewer, not a legal conclusion. Record inspected evidence when no issue is found. Do not edit content or make reports to third parties.',
  ['page','risk','quote','ruleId','sourceUrl'], 'sourceUrl', sample([privacy], { rules:[{id:'absolute-claims',text:'Absolute claims require explicit evidence.'}] })),

这里的 runScenario()、browser()、research() 和 sample() 是样例包的应用函数。实际 SDK 调用都在下面的执行链中:委托与权限查询 → 所需的 Memory/搜索 → 网页任务或监控 → 校验与保存。fields、sourceField 规定本场景如何校验业务输出;它们由应用使用。辅助函数的完整实现在样例包的 runtime.ts 中。

查看实际 SDK 执行链
ts
// 场景构造器:browser() 只调用 DoAnything;research() 先 Web Search 再 DoAnything;sample() 标记 public-demo 演示输入。
const firefox = 'https://www.mozilla.org/en-US/firefox/new/'
const manifesto = 'https://www.mozilla.org/en-US/about/manifesto/'
const privacy = 'https://www.mozilla.org/en-US/privacy/firefox/'
const support = 'https://support.mozilla.org/en-US/kb/get-started-firefox-overview-main-features'
// 演示文案规则会加入 prompt;它们不替代服务端权限或业务规则校验。
const policy = {
  version: 'demo-2026-09',
  approvedClaims: ['Describe only features supported by the cited page.'],
  forbiddenClaims: ['guaranteed security', '100% private', 'unverified pricing or performance'],
  voice: 'concise and warm',
}
const sample = (pages: string[], business: JsonObject = {}): JsonObject => ({
  dataset: 'public-demo', pages, policy, business,
  notice: 'Business records are synthetic demonstration inputs. Referenced websites and SDK execution are real.',
})
// fields 是必填输出字段;sourceField 指定需要校验 URL 格式的字段,memory 控制是否调用 GUMem。
const browser = (id: string, task: string, fields: string[], sourceField: string | undefined, input: JsonObject, memory = false): Scenario => ({
  id, products: ['doAnything'], task, fields, sourceField, input, memory,
})
const research = (id: string, task: string, fields: string[], sourceField: string, queries: string[], input: JsonObject, memory = false): Scenario => ({
  id, products: ['webSearch', 'doAnything'], task, fields, sourceField, queries, input, memory,
})
ts
import { QoniScopes, type JsonObject, type RunResult } from '@qoniai/qoni'
import { readFileSync } from 'node:fs'
import { getScenario, type Scenario } from './scenarios.js'
import { appendTrace, checkInputCoverage, cleanupDemoUser, createContext, delegate, handleInteraction, inputEntryCount, isolateDemoUser, object, readWithRetry, renderScreenshot,
  save, saveArtifacts, searchHits, settled, settleRun, validateItems, withCleanup, type Context, type Options } from './runtime.js'

export async function runScenario(id: string, options: Options = {}, input?: JsonObject) {
  // 按场景 ID 读取真实任务定义;--input 只替换业务输入。
  const scenario = getScenario(id)
  const data = input ?? scenario.input
  if (scenario.memory && data.dataset !== 'public-demo' && options.mode !== 'interactive' && !options.userId && !process.env.QONI_USER_ID) {
    throw new Error('Business Memory writes require the current QONI_USER_ID; do not select an arbitrary bound user')
  }
  const context = await createContext(id, { ...options,
    skipUserResolution: scenario.memory && data.dataset === 'public-demo' })
  return withCleanup(context, async register => {
    register('isolated demonstration user', () => cleanupDemoUser(context))
    // 演示偏好写入隔离用户;业务 Memory 必须属于明确识别的当前用户。
    if (scenario.memory && data.dataset === 'public-demo') await isolateDemoUser(context)
    return await executeScenario(context, scenario, data)
  })
}

export async function executeScenario(context: Context, scenario: Scenario, input: JsonObject) {
  const pages = input.pages
  if (!Array.isArray(pages) || !pages.length || pages.some(page => typeof page !== 'string' || !/^https:\/\//.test(page))) {
    throw new Error('Input pages must be an array of HTTPS URLs')
  }
  if (input.requiresLogin === true && context.mode !== 'interactive') {
    throw new Error('Targets that require sign-in need --interactive and user-controlled login')
  }
  const memoryScopes = scenario.memory
    ? [QoniScopes.GUMEM_MEMORY_READ, QoniScopes.GUMEM_MEMORY_WRITE, QoniScopes.GUMEM_MESSAGE_WRITE] : []
  // delegate() 是应用函数,内部调用 delegateToken / completeDelegateToken。
  const grant = await delegate(context, scenario.id, scenario.products, memoryScopes)
  // 查询实际授权范围;readWithRetry() 只对可重试的读取错误有限重试。
  const { data: tokenInfo } = await readWithRetry(context, 'delegation introspection',
    () => context.qoni.genauth.introspectDelegationToken({ token: grant.token }))
  const info = object(tokenInfo)
  if (info.active !== true) throw new Error('The delegation token is not active')
  const audit = { grantId: grant.grantId, auditId: grant.auditId, scopes: info.scope }
  let memory: unknown
  if (scenario.memory) {
    // Session 将本次对话关联到当前用户,sessionId 由应用生成。
    const sessionId = `${scenario.id}-${Date.now()}`
    await context.qoni.gumem.createSession({
      token: grant.token, userId: context.userId, sessionId, title: scenario.id,
    })
    const preferences = object(input.business ?? {}).confirmedPreferences
    if (Array.isArray(preferences) && preferences.length) {
      // 只写入业务已经确认的偏好;sync: true 请求同步处理这次写入。
      await context.qoni.gumem.addMessages({ token: grant.token, userId: context.userId, sessionId, sync: true,
        messages: [{ role: 'user', content: `Confirmed demonstration preferences: ${preferences.join('; ')}` }] })
    }
    // 召回与任务有关的偏好,作为后续 prompt 的上下文。
    memory = (await readWithRetry(context, 'GUMem recall', () => context.qoni.gumem.recall({ token: grant.token, sessionId,
      query: 'Confirmed preferences relevant to this task', details: true }))).data
    save(context, 'memory.json', { sessionId, context: memory })
    if (Array.isArray(preferences) && preferences.length && !preferences.every(value => JSON.stringify(memory).includes(String(value)))) {
      throw new Error('Recall did not include the confirmed preferences just written by this demo')
    }
  }

  if (scenario.products.includes('track')) return runMonitor(context, scenario, input, grant.token, audit)

  let hits: ReturnType<typeof searchHits> = []
  if (scenario.queries) {
    // Web Search 返回真实 results[];搜索来源将提供给 DoAnything 阅读。
    const search = await context.qoni.webSearch.run({ token: grant.token, prompt: scenario.queries, maxResultsPerQuery: 3 })
    save(context, 'search-ref.json', { runId: search.id, audit })
    await withCleanup(context, async register => {
      register('Web Search run', () => search.cancel('Documentation demonstration cleanup'))
      const result = await settleRun(context, search)
      save(context, 'search-result.json', result)
      settled(result)
      hits = searchHits(result.output)
    })
  }

  // 逐项对应输入的场景按输入项数要求条数,其余场景最多两项。
  const requiredItems = inputEntryCount(scenario.id, input)
  // 拼装场景任务、业务输入、搜索来源和 Memory;这些都是应用约定的上下文。
  const prompt = [scenario.task, `Task inputs: ${JSON.stringify(input)}`,
    `Search sources: ${JSON.stringify(hits)}`, `Confirmed memory: ${JSON.stringify(memory ?? null)}`,
    `Actual collection time: ${new Date().toISOString()}`,
    requiredItems === undefined
      ? 'Inspect the supplied sources. Return at most two items in the requested JSON array, without prose or Markdown.'
      : `Inspect the supplied sources. Return exactly ${requiredItems} item${requiredItems === 1 ? '' : 's'} in the requested JSON array, one per input entry, without prose or Markdown.`,
    'Keep synthetic demonstration data identified as synthetic. Do not send messages, publish, pay, edit accounts or submit forms.',
    input.requiresLogin === true ? 'Request user sign-in through an interaction when required; never enter credentials yourself.' : 'Public demonstration sources only; do not sign in.',
  ].join('\n\n')
  // 使用同一委托启动 Agent;capture 接收服务端交付的截图,不保证每步都有图。
  const run = await context.qoni.doAnything.run({ token: grant.token, prompt, capture: { screenshots: true } })
  save(context, 'run-ref.json', { runId: run.id, session: run.sessionRef, audit })
  let result: RunResult
  const trace = (event: { type: string; data: unknown }) => {
    context.eventCounts[event.type] = (context.eventCounts[event.type] ?? 0) + 1
    if (['progress','message','done'].includes(event.type)) appendTrace(context, event)
    if (event.type === 'browserLiveUrlChanged') {
      const liveUrl = object(event.data).liveUrl
      if (typeof liveUrl === 'string') context.browserUrl = liveUrl
    }
  }
  return withCleanup(context, async register => {
    register('DoAnything run', () => run.cancel('Documentation demonstration cleanup'))
    if (context.delivery === 'events') {
      // --events 实时读取事件;普通交互对象先转换为 SDK 句柄再交给用户处理。
      for await (const event of run.events({ signal: AbortSignal.any([context.abort.signal, AbortSignal.timeout(context.timeoutMs)]) })) {
        trace(event)
        if (event.type === 'screenshot') renderScreenshot(context, event.image)
        if (event.type === 'interaction') await handleInteraction(context, run.interactionHandle(event.data))
      }
      result = await settleRun(context, run)
    } else {
      // 回调模式在 wait 内接收同一次任务的事件;这里的辅助函数会保存图像和询问用户。
      result = await settleRun(context, run, { onEvent: trace,
        onScreenshot: (image, index) => renderScreenshot(context, image, index),
        onInteraction: interaction => handleInteraction(context, interaction) })
    }
    save(context, 'result.json', result)
    settled(result)
    await saveArtifacts(context, result)
    if (scenario.id === 'landing-page-audit-agent' && context.screenshots === 0) throw new Error('The landing-page audit did not deliver the requested screenshot')
    // 应用校验必填字段和来源 URL 格式;事实准确性仍需业务复核。
    const items = validateItems(result.output, scenario.fields, scenario.sourceField)
    // 要求逐项对应输入的场景,再核对每个输入项都有对应输出。
    checkInputCoverage(scenario.id, items, input)
    const report = { scenario: scenario.id, dataset: input.dataset, passed: true, runId: run.id,
      status: result.status, items, audit, screenshots: context.screenshots, interactions: context.interactions,
      events: context.eventCounts, artifactIds: result.artifacts.map(artifact => artifact.id) }
    save(context, 'report.json', report)
    return report
  })
}

async function runMonitor(context: Context, scenario: Scenario, input: JsonObject, token: string, audit: JsonObject) {
  // Track 单独创建 monitor:声明目标、抽取字段并请求每小时调度。
  const monitor = await context.qoni.track.create({ token, prompt: scenario.task,
    targetUrls: input.pages, extractionSchema: { heading: 'string', source_url: 'string' },
    tickInstructions: `Open the target URLs and read the actual visible heading. Return a JSON object with heading and source_url. ${scenario.task}`,
    triggerDsl: { on: 'change' }, schedule: { kind: 'interval', intervalSeconds: 3600 } })
  save(context, 'monitor-ref.json', { id: monitor.id, audit })
  return withCleanup(context, async register => {
    register('Track monitor', () => monitor.delete())
    const definition = await monitor.get()
    save(context, 'monitor-definition.json', definition)
    if (object(definition.schedule).intervalSeconds !== 3600) throw new Error('Track did not persist the requested schedule interval')
    // 立即运行一次,再读取该 runId 的实际抽取;completed 本身不足以证明取数成功。
    const tick = await monitor.runNow()
    save(context, 'tick.json', tick)
    if (tick.state !== 'completed') throw new Error(`Track execution failed: ${tick.state} / ${tick.error ?? ''}`)
    const runId = tick.runId
    if (typeof runId !== 'string') throw new Error('Track tick did not return a runId')
    const detail = await monitor.run(runId)
    save(context, 'tick-detail.json', detail)
    if (detail.state !== 'completed' || !detail.extracted || !Object.keys(object(detail.extracted)).length) {
      throw new Error('Track did not extract page data')
    }
    const extracted = object(detail.extracted)
    if (typeof extracted.heading !== 'string' || !extracted.heading.trim() ||
      typeof extracted.source_url !== 'string' || !/^https:\/\//.test(extracted.source_url)) {
      throw new Error('Track extraction is missing a heading or source URL')
    }
    const normalizeUrl = (value: string) => { const url = new URL(value); url.hash = ''; return url.href.replace(/\/$/, '') }
    if (!(input.pages as string[]).some(url => normalizeUrl(url) === normalizeUrl(String(extracted.source_url)))) {
      throw new Error('The Track source URL is not a configured target')
    }
    // 检查暂停/恢复是否保存;withCleanup() 在退出时调用 monitor.delete()。
    await monitor.pause()
    if ((await monitor.get()).status !== 'paused') throw new Error('Track did not persist the paused state')
    await monitor.resume()
    if ((await monitor.get()).status !== 'active') throw new Error('Track did not persist the active state')
    const report = { scenario: scenario.id, dataset: input.dataset, passed: true, monitorId: monitor.id,
      runId, state: detail.state, outcome: detail.outcome, extracted: detail.extracted, audit }
    save(context, 'report.json', report)
    return report
  })
}

export function inputFile(): JsonObject | undefined {
  const index = process.argv.indexOf('--input')
  return index >= 0 ? object(JSON.parse(readFileSync(process.argv[index + 1], 'utf8'))) : undefined
}

结果写入 output/compliance-risk-agent/report.json。report.items 包含页面、风险或观察、原文、规则 ID 和来源;不是法律意见,字段为 page, risk, quote, ruleId, sourceUrl;audit 保存委托 ID、审计 ID 和实际授权范围,http.json 记录脱敏请求状态。应用解析 DoAnything 输出,并检查必填字段和来源 URL 格式。内容判断仍需业务人员结合原始来源复核。

数据与记忆边界 ​

这个场景涉及四类数据,都不需要进入 GUMem:

  • 版本化规则:禁用宣传主张清单、法规条目及其生效时间——放在受控规则库(policy store)里按版本管理,每条风险项引用清单版本号。
  • 业务状态:风险清单、证据(截图、原文摘录、来源 URL)、合规团队的最终判断——按清单版本归档到你的审计存储,供监管回查。
  • 审计记录:grantId 与 auditId 构成的委托与行为链——由 GenAuth 维护。
  • 用户 Memory(可选):这个场景默认不召回也不写回;合规判定依据必须全部来自受控规则库,不接受来自 Memory 的规则。

失败处理 ​

情况推荐处理
物料页面需要登录且登录态失效任务挂起,通知合规专员重新登录,从断点继续巡检。
监管来源页面无法访问保留失效记录,相关风险项标注为依据不完整,不引用缓存内容替代。
规则库读取失败或清单版本缺失中止本期巡检并告警,不使用缓存或上一期清单继续。
表述疑似触碰但规则无法确定性判定保留证据并标注为待人工判断,不由 Agent 直接定性为违规或合规。

生产注意点 ​

合规结论应标注来源和不确定性,不能替代专业法律意见。Agent 输出的是审计数据基础——风险清单、证据和来源——而不是合规符合性断言;是否违规、如何整改的最终判断归合规团队。巡检只读,不修改或下线任何物料;每期风险清单都应绑定禁用清单版本号,清单更新后旧结论不追溯改判。

下一步 ​