diff --git a/.env.example b/.env.example index cc453fd..ebad8d2 100644 --- a/.env.example +++ b/.env.example @@ -58,4 +58,3 @@ ZHIPU_API_KEY= # CP_SCHEDULER_MAX_RETRIES=2 # CP_SHUTDOWN_BUDGET_MS=90000 # 优雅停机收口预算(compose stop_grace_period 需覆盖) # CP_PETGEN_INTERVAL_MS=5000 -# CP_MEME_ENABLED=true diff --git a/packages/agent/src/tools/registry/image-meme.test.ts b/packages/agent/src/tools/registry/image-meme.test.ts index 42e25f2..d68440e 100644 --- a/packages/agent/src/tools/registry/image-meme.test.ts +++ b/packages/agent/src/tools/registry/image-meme.test.ts @@ -1,7 +1,8 @@ /** - * image_meme 工具测试(#96) + * image_meme 工具测试(#96 / #423) * * 工具契约:注入 fake 管线依赖 + fake 文案 runner,验证: + * - 心情概率门:未命中软跳过(不碰管线);心情档位决定概率 * - 成功路径:recorded → 返回 ok:true + imageUrl * - 质检不过:rejected → ok:false + 原因 * - 配额超限:skipped → ok:false + 配额原因 @@ -36,7 +37,7 @@ async function runTool( return exec(input, { toolCallId: 'test-call', messages: [] }); } -function makeCtx(): ToolContext { +function makeCtx(overrides: Partial = {}): ToolContext { return { state: makeState(), traceId: 'trace-1', @@ -48,6 +49,7 @@ function makeCtx(): ToolContext { endReason: 'max_steps', startTime: Date.now(), searchQueries: [], + ...overrides, }; } @@ -85,9 +87,11 @@ describe('image_meme 工具', () => { version: 2, spec: { specText: '蓝色小狗,白耳朵' }, })); writeFileSync(join(dir, 'pet-assets', 'adopt-reference.jpg'), Buffer.from('REFERENCE')); + // rng 恒 0 = 概率门必过(0 >= p 为假),管线路径测试不受随机影响 setImageMemeToolDeps({ buildDeps: () => makeDeps({ dataDir: dir }), buildCopy: () => COPY_GEN, + rng: () => 0, }); }); @@ -97,6 +101,39 @@ describe('image_meme 工具', () => { rmSync(dir, { recursive: true, force: true }); }); + it('#423 心情概率门未命中 → 软跳过,不触管线不耗配额', async () => { + let pipelineTouched = false; + setImageMemeToolDeps({ + buildDeps: () => { + pipelineTouched = true; + return makeDeps({ dataDir: dir }); + }, + buildCopy: () => COPY_GEN, + rng: () => 0.99, // curious p=0.3,0.99 必未命中 + }); + const ctx = makeCtx(); + const tool = imageMemeToolDef.createTool(ctx); + const result = (await runTool(tool, { topic: '量子计算' })) as { ok: boolean; reason?: string }; + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/心情/); + expect(pipelineTouched).toBe(false); + // 软跳过也留游荡痕迹(可观测) + expect(ctx.wanderHistory.some((s) => s.tool === 'image_meme' && s.thought?.includes('[gated]'))).toBe(true); + }); + + it('#423 心情档位映射:excited 概率高于 emo(档位表约束)', async () => { + // deps 必须在 createTool 前设好——工具创建时捕获依赖快照 + setImageMemeToolDeps({ buildDeps: () => makeDeps({ dataDir: dir }), buildCopy: () => COPY_GEN, rng: () => 0.5 }); + // emo p=0.1:rng=0.5 未命中 + const emoTool = imageMemeToolDef.createTool(makeCtx({ state: makeState({ mood: 'emo' }) })); + let result = (await runTool(emoTool, { topic: '量子计算' })) as { ok: boolean; reason?: string }; + expect(result.ok).toBe(false); + // excited p=0.6:同一 rng 命中,走完整管线 + const excitedTool = imageMemeToolDef.createTool(makeCtx({ state: makeState({ mood: 'excited' }) })); + result = (await runTool(excitedTool, { topic: '量子计算' })) as { ok: boolean; reason?: string }; + expect(result.ok).toBe(true); + }); + it('租户专属形象成功 → ok:true + imageUrl', async () => { const tool = imageMemeToolDef.createTool(makeCtx()); // AI SDK tool:直接调用 execute(input 已 zod 校验) @@ -113,6 +150,7 @@ describe('image_meme 工具', () => { it('质检不过 → ok:false + 原因(不进图鉴)', async () => { setImageMemeToolDeps({ + rng: () => 0, buildDeps: () => makeDeps({ dataDir: dir, @@ -137,6 +175,7 @@ describe('image_meme 工具', () => { ]), ); setImageMemeToolDeps({ + rng: () => 0, buildDeps: () => makeDeps({ dataDir: dir, dailyLimit: 3 }), buildCopy: () => COPY_GEN, }); @@ -157,6 +196,7 @@ describe('image_meme 工具', () => { it('生图失败 → ok:false', async () => { setImageMemeToolDeps({ + rng: () => 0, buildDeps: () => makeDeps({ dataDir: dir, diff --git a/packages/agent/src/tools/registry/image-meme.ts b/packages/agent/src/tools/registry/image-meme.ts index 859f5b2..e1467c1 100644 --- a/packages/agent/src/tools/registry/image-meme.ts +++ b/packages/agent/src/tools/registry/image-meme.ts @@ -1,9 +1,11 @@ /** - * image_meme 工具(#96)—— 宠物自主生成表情包 + * image_meme 工具(#96 / #423)—— 宠物游荡中自主生成表情包 * - * 工具契约:LLM 判断"话题适合做表情包"时调用,传入话题。工具内部:LLM 出文案 → 画面生图 → 程序 - * 叠加文字 → 质检 → 收录(过质检才进图鉴)。生图/视觉/LLM 全部可 mock - * (真实依赖走 meme/factory.ts,测试直接注入 fake 走 runMemePipeline)。 + * 工具契约:LLM 判断"话题适合做表情包"时调用,传入话题。工具内部先过 + * **心情概率门**(心情越好越想画;未命中不耗配额、不产生任何付费调用, + * 直接软跳过),命中后:LLM 出文案 → 画面生图 → 程序叠加文字 → 质检 → + * 收录(过质检才进图鉴)。生图/视觉/LLM/随机源全部可 mock(真实依赖走 + * meme/factory.ts,测试直接注入 fake 走 runMemePipeline)。 * * 只用当前租户已交付的宠物 manifest 角色参考图;无定制形象则显式跳过。 * @@ -14,6 +16,7 @@ import { tool } from 'ai'; import { z } from 'zod'; import { getMemeImageUrl } from '@cyber-stray/shared/push'; +import type { PetMood } from '@cyber-stray/shared/pet-stats'; import { consola } from '../../logger.js'; import { getConfig, getDataPath } from '../../config.js'; import { createDeepSeek } from '@ai-sdk/deepseek'; @@ -36,14 +39,31 @@ const IMAGE_MEME_DESCRIPTION = `用你当前已交付的宠物形象作为参考 - 想给有趣内容配图时先调用本工具,再把返回的 id 作为 speak 的 memeId - 想给今天的日记/游荡配一张梗图 -**注意:** 只画当前租户的宠物,不生成通用角色或 SVG 宠物。没有已交付形象时不要调用;每日配额默认 3 张,质检不合格不会进图鉴。`; +**注意:** 只画当前租户的宠物,不生成通用角色或 SVG 宠物。没有已交付形象时不要调用;画不画看心情——心情好时更容易成,被拒绝就换个话题别纠缠;每日配额默认 3 张,质检不合格不会进图鉴。`; -/** 工具执行上下文:真实依赖 + 文案 runner(可注入 fake 测试) */ +/** + * 心情 → 生成概率(#423:游荡中小概率触发,心情好概率更高)。 + * 档位对齐 strategy 的 speakInclination 映射:excited/playful 高、 + * curious/grumpy 中、lazy/emo 低。按每天 ~1-3 次调用估算,期望产出 + * 0.5-2 张/天,配额 3 张/天仍是硬顶。 + */ +const MEME_PROBABILITY_BY_MOOD: Record = { + excited: 0.6, + playful: 0.5, + curious: 0.3, + grumpy: 0.25, + lazy: 0.12, + emo: 0.1, +}; + +/** 工具执行上下文:真实依赖 + 文案 runner + 随机源(可注入 fake 测试) */ export interface ImageMemeToolDeps { /** 组装管线依赖(缺省用真实 factory;测试注入) */ buildDeps?: (dataDir: string) => MemePipelineDeps; /** 组装文案 runner(缺省用真实 model;测试注入 fake) */ buildCopy?: (ctx: ToolContext) => MemeCopyGenerator; + /** [0,1) 随机数源(缺省 Math.random;测试注入确定性序列) */ + rng?: () => number; } /** 模块级可覆盖依赖(测试注入 fake;生产用真实实现) */ @@ -89,6 +109,28 @@ export const imageMemeToolDef: ToolDefinition = { execute: async ({ topic }, options) => { ctx.stepCount++; const stepStart = Date.now(); + // 心情概率门(#423):未命中即软跳过——不查参考图、不碰管线, + // 不耗每日配额、不产生任何付费调用 + const rng = deps.rng ?? Math.random; + const mood = ctx.state.mood; + const probability = MEME_PROBABILITY_BY_MOOD[mood]; + if (probability === undefined) { + return { ok: false, reason: `未知心情,跳过表情包生成:${String(mood)}` }; + } + if (rng() >= probability) { + logger.info( + `[${ctx.traceId}] TOOL image_meme [mood=${mood} p=${probability} gated]`, + ); + pushWanderStep(ctx, { + timestamp: new Date().toISOString(), + tool: 'image_meme', + thought: `[gated] 心情不够好(${mood}),没给「${topic}」画表情包`, + }); + return { + ok: false, + reason: `现在没什么画表情包的心情(心情:${mood}),这次先不画了`, + }; + } const dataDir = getDataPath(''); const reference = await preparePetMemeReference(dataDir, options.abortSignal); if (!reference) { diff --git a/packages/agent/src/worker/diary-cli.ts b/packages/agent/src/worker/diary-cli.ts index 47bc7db..daef083 100644 --- a/packages/agent/src/worker/diary-cli.ts +++ b/packages/agent/src/worker/diary-cli.ts @@ -88,7 +88,6 @@ async function main(): Promise { personality, diaryStyle, pushEnabled: isTrue(parseArg('push-enabled')), - memeEnabled: isTrue(parseArg('meme-enabled')), secrets, planArgs, }); diff --git a/packages/agent/src/worker/generate-diary.test.ts b/packages/agent/src/worker/generate-diary.test.ts index 7b9cefe..9b1542f 100644 --- a/packages/agent/src/worker/generate-diary.test.ts +++ b/packages/agent/src/worker/generate-diary.test.ts @@ -268,7 +268,9 @@ describe('runDiaryWorker 端到端', () => { expect(getTenantContext()).toBeNull(); }); - it('#96 没有已交付宠物形象时跳过表情包,不画通用角色', async () => { + // #423:表情包改由游荡中 image_meme 工具心情概率门触发,日记不再生成 + // 表情包——这里守住「日记本体不受影响」的回归面。 + it('#423 日记不再触发表情包:结果无 meme 字段,LLM 只调 2 次', async () => { await seedTodayData(dataDir, '2026-08-20'); mockNarratives(['日记正文', '梦境正文']); const result = await runDiaryWorker({ @@ -277,27 +279,10 @@ describe('runDiaryWorker 端到端', () => { petName: '小七', date: '2026-08-20', personality: 'curious', - memeEnabled: true, }); - // 日记与梦境仍成功落盘 + expect('meme' in result).toBe(false); expect(result.file).toBe(join(dataDir, 'diary', '2026-08-20.md')); expect(result.dreamFile).toBe(join(dataDir, 'diary', 'dreams', '2026-08-20.md')); expect(generateText).toHaveBeenCalledTimes(2); - expect(result.meme?.status).toBe('skipped'); - expect(result.meme?.reason).toMatch(/没有已交付/); - }); - - it('#96 memeEnabled=false(默认)→ 不触发表情包(只 2 次 generateText)', async () => { - await seedTodayData(dataDir, '2026-08-20'); - mockNarratives(['日记正文', '梦境正文']); - const result = await runDiaryWorker({ - tenantId: 'a', - dataDir, - petName: '小七', - date: '2026-08-20', - personality: 'curious', - }); - expect(result.meme).toBeUndefined(); - expect(generateText).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/agent/src/worker/generate-diary.ts b/packages/agent/src/worker/generate-diary.ts index 349c7e8..ba61644 100644 --- a/packages/agent/src/worker/generate-diary.ts +++ b/packages/agent/src/worker/generate-diary.ts @@ -34,11 +34,6 @@ import { renderDreamMarkdown, writeDreamMarkdown, } from '../memory/diary/dream-generator.js'; -import { createMemePipelineDeps } from '../meme/factory.js'; -import { createMemeCopyRunner } from '../meme/copy-runner.js'; -import { runMemePipeline } from '../meme/pipeline.js'; -import { preparePetMemeReference } from '../meme/reference.js'; -import { recordMemeForPush } from '../meme/push.js'; /** runDiaryWorker 入参 */ export interface DiaryWorkerOptions { @@ -56,8 +51,6 @@ export interface DiaryWorkerOptions { petName: string; /** 是否推送日记(开启则写 notifiable speak 记录,Web Push 送达) */ pushEnabled?: boolean; - /** 是否日记写完触发表情包生成(#96;缺省 false——控制面经 CLI 注入开关) */ - memeEnabled?: boolean; /** per-tenant 敏感信息(控制面解密后注入;缺省回退进程 env) */ secrets?: AgentSecrets; /** 套餐执行参数(门控;缺省 = 单用户模式) */ @@ -75,12 +68,6 @@ export interface DiaryWorkerResult { file?: string; /** 当晚梦境落盘路径(#93:与日记同刻生成;素材 = 兴趣+足迹非空时有值) */ dreamFile?: string; - /** 表情包生成结果(#96:日记写完触发生成;含 status/错误,非致命) */ - meme?: { - status: string; - reason?: string; - file?: string; - }; /** 是否推送(开启且有内容时 true) */ pushed?: boolean; /** 使用的最终风格 prompt(可观测:不同性格/风格可感知差异) */ @@ -163,46 +150,8 @@ export async function runDiaryWorker(options: DiaryWorkerOptions): Promise 0) { - try { - const reference = await preparePetMemeReference(options.dataDir); - if (!reference) { - meme = { status: 'skipped', reason: '当前租户没有已交付的宠物形象' }; - } else { - const memeDeps = createMemePipelineDeps(options.dataDir); - const copyGenerator = createMemeCopyRunner({ - petName: options.petName, - personalityName: personality.name, - model, - }); - const topic = data.interests[0] ?? ''; - const result = await runMemePipeline(memeDeps, { - topic, mode: 'ip', referencePath: reference.path, petSpecText: reference.specText, - }, copyGenerator); - // 推送补发:过质检且推送开启 → 写 notifiable speak(Web Push 送达) - if (result.status === 'recorded' && options.pushEnabled) { - await recordMemeForPush(result.meta); - } - meme = - result.status === 'recorded' - ? { status: 'recorded', file: result.meta.file } - : result.status === 'rejected' - ? { status: 'rejected', reason: result.issues.join(';') || '质检未通过' } - : result.status === 'skipped' - ? { status: 'skipped', reason: result.reason } - : { status: 'failed', reason: result.error }; - } - } catch (error) { - meme = { - status: 'failed', - reason: error instanceof Error ? error.message : String(error), - }; - } - } + // 表情包不再由日记收尾触发(#423:改为游荡中 image_meme 工具心情概率门, + // 见 tools/registry/image-meme.ts);日记只负责日记与梦境。 // 推送:开启则写 notifiable speak 记录(Web Push 经 diary_generated 事件送达) let pushed = false; @@ -216,7 +165,6 @@ export async function runDiaryWorker(options: DiaryWorkerOptions): Promise { personality: 'lazy', diaryStyle: 'literary', pushEnabled: true, - memeEnabled: true, }; } @@ -97,13 +96,6 @@ describe('diary runner(#92)', () => { expect(spawned[0]!.args).not.toContain('--push-enabled'); }); - it('memeEnabled=true 传 --meme-enabled true;false 不传', async () => { - await makeRunner(fakeSpawn(0))(makeJob()); - expect(spawned[0]!.args).toContain('--meme-enabled'); - await makeRunner(fakeSpawn(0))({ ...makeJob(), memeEnabled: false }); - expect(spawned[1]!.args).not.toContain('--meme-enabled'); - }); - it('非零退出码 → ok:false', async () => { const result = await makeRunner(fakeSpawn(1))(makeJob()); expect(result).toEqual({ ok: false, exitCode: 1 }); diff --git a/packages/control-plane/src/scheduler/diary-runner.ts b/packages/control-plane/src/scheduler/diary-runner.ts index d5753a4..9cf0bb3 100644 --- a/packages/control-plane/src/scheduler/diary-runner.ts +++ b/packages/control-plane/src/scheduler/diary-runner.ts @@ -90,8 +90,6 @@ export interface DiaryJob { diaryStyle: DiaryStyleChoice; /** 是否推送日记(Web Push) */ pushEnabled: boolean; - /** 是否日记写完触发表情包生成(#96) */ - memeEnabled: boolean; } /** 结果:ok = 完成或跳过(exit 0) */ @@ -136,7 +134,6 @@ export function createDiaryRunner(deps: DiaryRunnerDeps): DiaryRunner { job.diaryStyle, ]; if (job.pushEnabled) args.push('--push-enabled', 'true'); - if (job.memeEnabled) args.push('--meme-enabled', 'true'); if (secretsPath) args.push('--secrets-file', secretsPath); const logFile = workerLogPath(deps.dataDir, 'diary', job.tenantId); const { exitCode } = await spawnFn(command, args, { timeoutMs: deps.timeoutMs, logFile }); diff --git a/packages/control-plane/src/scheduler/scheduler.ts b/packages/control-plane/src/scheduler/scheduler.ts index eac4a73..5868e2d 100644 --- a/packages/control-plane/src/scheduler/scheduler.ts +++ b/packages/control-plane/src/scheduler/scheduler.ts @@ -159,8 +159,6 @@ export interface SchedulerDeps { diaryRunner: DiaryRunner; now: () => number; config: SchedulerConfig; - /** 是否日记写完触发表情包生成(#96;缺省 true) */ - memeEnabled?: boolean; } /** 失败重试租约(内存;进程重启清零,重试上限由 DB 冷却兜底) */ @@ -188,8 +186,6 @@ export class Scheduler { private readonly diaryClaimedThisTick = new Set(); /** #92 日记:每宠上次 tick 是否睡眠中(跨 tick 记忆,检测睡眠开始) */ private readonly wasSleeping = new Map(); - /** #96 表情包:日记写完是否触发生成(缺省 true) */ - private readonly memeEnabled: boolean; /** ADR-0013:数值未迁移已告警过的宠物(进程内去抖) */ private readonly unmigratedWarned = new Set(); /** #265 预算闸:预算耗尽停派中的宠物(转变沿发 exhausted/resumed 事件) */ @@ -203,9 +199,7 @@ export class Scheduler { private timer?: ReturnType; private activeTick?: Promise; - constructor(private readonly deps: SchedulerDeps) { - this.memeEnabled = deps.memeEnabled ?? true; - } + constructor(private readonly deps: SchedulerDeps) {} /** 周期启动(intervalMs = 0 不启动,测试/关闭用)。tick 错误 log-and-continue。 */ start(intervalMs: number): void { @@ -502,7 +496,6 @@ export class Scheduler { personality: pet.personality as PersonalityId, diaryStyle, pushEnabled: Boolean(pet.diaryPushEnabled), - memeEnabled: this.memeEnabled, }); if (result.exitCode === USAGE_ACCOUNTING_FAILURE_EXIT_CODE) { await this.pauseForAccountingFailure(petId, tenantId);