Skip to content

Newsletter Curation Agent

本页说明 Newsletter Curation Agent 如何以静默委托巡检公开行业来源,按内容数据库中的已收录清单去重,生成待编辑确认的策展草稿;巡检是长任务,用 run.id 随时重连。读完本页,你能理解这个场景什么时候才需要交互式确认、候选内容如何收集与去重,以及已收录 URL 这类数据为什么应该放在内容数据库而不是 Memory。

适用场景

Marketing 团队需要定期从产品更新、博客、活动、行业新闻和客户故事中策划 newsletter。候选内容来源分散且更新频繁,人工逐源巡检既慢又容易漏;同一篇内容经不同渠道转载后还会重复出现,需要按来源去重。

典型触发时机:

  • 新一期 newsletter 截稿日临近,需要产出候选内容清单和栏目草稿。
  • 行业出现值得报道的动态,需要快速判断是否纳入本期。
  • 栏目结构或选材标准调整后,需要按新配置重新筛选候选。

工程挑战

  • 巡检是跨小时甚至跨天的长任务:候选来源多、更新时间不定,一次收集可能横跨部署重启和定时任务窗口,任务句柄必须可保存、可重连,不能因为进程退出就从头再来。
  • 转载去重需要确定性依据:同一篇内容在不同渠道有不同 URL 和标题,去重要按规范化 URL 判定并保留原始来源,否则同一内容会在多期反复出现。
  • 无来源内容混入候选池:聚合页和转载页经常丢失原始出处,缺少可访问链接和采集时间的候选无法支撑编辑决策,必须在入池前剔除。

模块组合

模块角色说明
GenAuth核心运行时以静默委托签发短时效凭证(所有产品调用必需);本场景默认无需交互式确认,接入登录态或写动作时才升级 interactive。凭证短时效、可撤销、留审计链。
Web Agent核心通过 WebSearch 巡检公开博客、新闻和行业来源,每条候选保留链接和采集时间;长任务保存 run.id 随时重连。
GUMem不使用已收录 URL 清单、栏目结构属于业务状态,放在你的内容数据库里按期次管理,不属于 Memory。本场景没有需要跨任务沉淀的用户个人偏好。

Newsletter Curation Agent 场景架构

何时需要交互式确认

所有产品调用都需要 GenAuth 委托令牌;公开只读场景用静默委托即可。本场景的默认路径只读取公开网页:应用用与 Qoni 凭证绑定的 GenAuth 用户 ID 直接换取运行时凭证,不需要用户跳转确认。凭证是显式、短时效、可撤销的,只覆盖公开读取与任务执行;发送、群发和订阅者操作不在任何委托范围内。

需要升级为交互式确认(mode: 'interactive')的情况:

  • 候选收集要接入登录态的内容池、CMS 或付费订阅源。
  • 任务要执行写动作,例如把策展草稿写进 CMS 草稿箱。

升级方式与其他场景相同:mode: 'interactive' + redirectUri,用户在 Qoni Console 确认后由服务端 completeDelegateToken 兑换凭证,完整流程见 快速开始

工作流程

Newsletter Curation Agent 工作流程

  1. 用户选择 newsletter 期次、主题和候选来源范围。

  2. 应用以静默委托换取运行时凭证(公开读取,无需用户跳转确认)。

  3. Web Agent 启动公开来源巡检长任务;应用保存 run.id,进程重启或定时任务窗口到来时用 attach 重连。

  4. 巡检返回候选清单,每条候选带可访问链接和采集时间。

    检查点:每条候选都应有可访问的来源链接和采集时间;无来源的条目不进入候选池。

  5. 应用从内容数据库读取已收录 URL 清单和栏目结构,注入策展任务。

  6. Agent 按规范化 URL 对候选去重、合并转载并保留原始来源,再按栏目结构生成建议、摘要和标题草稿。

  7. Agent 输出策展草稿和来源清单,附 audit id;编辑确认后,采纳与排除决定写回你的内容数据库。

    检查点:草稿只到"待确认"为止;任何指向发送或群发的动作都应被拒绝并留痕。

示例代码

下面的示例使用官方 Qoni SDK@qoniai/qoni)把这个场景接到你的服务端:静默委托 → webSearch.run() 启动巡检长任务并保存 run.id → 之后用 attach 重连取结果 → 一次 doAnything.run() 按内容数据库配置去重建稿 → 解析并校验候选。

ts
import { Qoni, QoniScopes } from '@qoniai/qoni'

const qoni = new Qoni({
  accessKey: process.env.QONI_ACCESS_KEY!,
  secretKey: process.env.QONI_SECRET_KEY!,
})

export async function startCurationSweep(
  userId: string,
  newsletterId: string,
  topics: string[],
) {
  // 1. 静默委托:公开内容收集不涉及站点登录
  //    (接入登录态内容池或写动作时才升级 interactive)
  const { data: grant } = await qoni.delegateToken({
    user: { id: userId },
    agent: 'newsletter-curation',
    products: ['webSearch'],
    scopes: [QoniScopes.DO_ANYTHING_READ, QoniScopes.DO_ANYTHING_MANAGE],
  })

  // 2. 启动公开来源巡检长任务,保存 run.id 以便随时重连
  const sweep = await qoni.webSearch.run({
    token: grant.token,
    prompt: `
      Find recent public posts and industry news about
      ${topics.join(', ')}. Record the link and collection time
      for every candidate.
    `,
    maxResultsPerQuery: 8,
  })
  await saveSweepState(newsletterId, {
    runId: sweep.id,
    token: grant.token,
    auditId: grant.auditId,
    grantedScopes: grant.grantedScopes,
  }) // 你的任务存储
  return sweep.id
}

// 之后——进程重启、定时任务窗口或另一台实例——用 run.id 重连同一个长任务
export async function resumeCurationSweep(newsletterId: string) {
  const state = await loadSweepState(newsletterId)
  const sweep = qoni.webSearch.attach(state.runId, { token: state.token })
  const candidates = await sweep.wait()

  // 3. 从你的内容数据库读取已收录 URL 与栏目结构(业务数据,不是 Memory)
  const config = await loadCurationConfig(newsletterId) // 例如 { publishedUrls: [...], sections: [...] }

  // 4. 一次 doAnything 去重、筛选并生成栏目草稿
  const run = await qoni.doAnything.run({
    token: state.token,
    prompt: `
      Curate the next newsletter issue from these candidates.
      Deduplicate by normalized URL, merging syndicated copies while
      keeping the original source; skip anything already in the
      published list. Draft section ideas, summaries and headlines
      following the section layout. Return entries as a JSON array of
      { section, title, summary, sourceUrl, collectedAt } objects.
      Do not send, bulk mail, or modify any subscriber list — stop at
      a draft for review.

      Public candidates: ${JSON.stringify(candidates.output)}
      Published URLs: ${JSON.stringify(config.publishedUrls)}
      Section layout: ${JSON.stringify(config.sections)}
    `,
    capture: { screenshots: true },
  })
  const result = await run.wait()

  // 5. 应用侧解析并校验输出契约:缺少来源链接或采集时间的条目不进草稿
  const entries = parseCurationDraft(result.output).filter(
    (e) => e.sourceUrl && e.collectedAt,
  )

  return {
    entries,
    artifacts: result.artifacts,
    audit: { auditId: state.auditId, permissionBoundary: state.grantedScopes },
  }
}

输出结构由任务描述约定:这里约定返回 { section, title, summary, sourceUrl, collectedAt } 数组,应用侧 parseCurationDraft 负责解析与校验,缺少 sourceUrlcollectedAt 的条目被直接丢弃。委托凭证短时效:如果重连时凭证已过期(QoniTokenExpiredError),先重新静默委托,再 attach 同一个 run.id

数据与记忆边界

这个场景涉及四类数据,本场景不使用 GUMem:

  • 版本化配置:栏目结构、选材标准——放在你的内容数据库或配置库里按期次管理,草稿引用配置版本。
  • 业务状态:候选清单、已收录 URL、编辑的采纳与排除决定——写回你的内容数据库,用于下一期去重与复盘。
  • 审计记录:grantIdauditId 构成的委托与行为链——由 GenAuth 维护。
  • 用户 Memory(可选):只有用户明确确认的长期个人偏好才属于 GUMem;选材标准和栏目结构是团队级配置,不是个人记忆,本场景默认不召回也不写回。

失败处理

情况推荐处理
进程重启或 wait() 超时用保存的 run.idattach 重连;任务在服务端继续执行,不重复启动。
重连时委托凭证已过期重新静默委托换取新凭证,再 attach 同一个 run.id
候选来源无法访问或内容已下线从候选池剔除并记录原因,不引用无法核实的内容。
输出条目缺少来源链接或采集时间应用侧校验直接丢弃该条目,并在草稿中标注丢弃数量。

生产注意点

不要自动发布或群发。外部来源需要保留链接和采集时间。策展草稿必须经编辑确认后才能进入发送流程;对外部来源的巡检频率应设置上限,避免对目标站点造成压力。长任务的 run.id 与凭证应存放在服务端任务存储中,不要下发给浏览器。

下一步