Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 42 additions & 2 deletions packages/agent/src/tools/registry/image-meme.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* image_meme 工具测试(#96)
* image_meme 工具测试(#96 / #423)
*
* 工具契约:注入 fake 管线依赖 + fake 文案 runner,验证:
* - 心情概率门:未命中软跳过(不碰管线);心情档位决定概率
* - 成功路径:recorded → 返回 ok:true + imageUrl
* - 质检不过:rejected → ok:false + 原因
* - 配额超限:skipped → ok:false + 配额原因
Expand Down Expand Up @@ -36,7 +37,7 @@ async function runTool(
return exec(input, { toolCallId: 'test-call', messages: [] });
}

function makeCtx(): ToolContext {
function makeCtx(overrides: Partial<ToolContext> = {}): ToolContext {
return {
state: makeState(),
traceId: 'trace-1',
Expand All @@ -48,6 +49,7 @@ function makeCtx(): ToolContext {
endReason: 'max_steps',
startTime: Date.now(),
searchQueries: [],
...overrides,
};
}

Expand Down Expand Up @@ -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,
});
});

Expand All @@ -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 校验)
Expand All @@ -113,6 +150,7 @@ describe('image_meme 工具', () => {

it('质检不过 → ok:false + 原因(不进图鉴)', async () => {
setImageMemeToolDeps({
rng: () => 0,
buildDeps: () =>
makeDeps({
dataDir: dir,
Expand All @@ -137,6 +175,7 @@ describe('image_meme 工具', () => {
]),
);
setImageMemeToolDeps({
rng: () => 0,
buildDeps: () => makeDeps({ dataDir: dir, dailyLimit: 3 }),
buildCopy: () => COPY_GEN,
});
Expand All @@ -157,6 +196,7 @@ describe('image_meme 工具', () => {

it('生图失败 → ok:false', async () => {
setImageMemeToolDeps({
rng: () => 0,
buildDeps: () =>
makeDeps({
dataDir: dir,
Expand Down
54 changes: 48 additions & 6 deletions packages/agent/src/tools/registry/image-meme.ts
Original file line number Diff line number Diff line change
@@ -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 角色参考图;无定制形象则显式跳过。
*
Expand All @@ -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';
Expand All @@ -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<PetMood, number> = {
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;生产用真实实现) */
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion packages/agent/src/worker/diary-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ async function main(): Promise<void> {
personality,
diaryStyle,
pushEnabled: isTrue(parseArg('push-enabled')),
memeEnabled: isTrue(parseArg('meme-enabled')),
secrets,
planArgs,
});
Expand Down
23 changes: 4 additions & 19 deletions packages/agent/src/worker/generate-diary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
});
});
56 changes: 2 additions & 54 deletions packages/agent/src/worker/generate-diary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
/** 套餐执行参数(门控;缺省 = 单用户模式) */
Expand All @@ -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(可观测:不同性格/风格可感知差异) */
Expand Down Expand Up @@ -163,46 +150,8 @@ export async function runDiaryWorker(options: DiaryWorkerOptions): Promise<Diary
dreamFile = await writeDreamMarkdown(date, dreamMarkdown);
}

// #96 表情包:日记写完触发生成(睡前任务尾部)。话题取当天兴趣之首
// (有则用之,无则用日记第一行标题)。生图/质检失败不致命——表情包是
// 增强特性,不能拖垮核心日记任务(与"日记失败重试"解耦,失败留痕不抛)。
let meme: DiaryWorkerResult['meme'];
if (options.memeEnabled && data.interests.length > 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;
Expand All @@ -216,7 +165,6 @@ export async function runDiaryWorker(options: DiaryWorkerOptions): Promise<Diary
skipped: false,
file,
dreamFile,
meme,
pushed,
stylePrompt: resolveDiaryStylePrompt(styleChoice, personality.diaryStyle),
personality: personality.name,
Expand Down
3 changes: 0 additions & 3 deletions packages/control-plane/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ export interface ControlPlaneConfig {
adoptLlmModel: string;
/** 生成任务处理器 tick 间隔 ms(0 = 关闭;#94) */
petGenIntervalMs: number;
/** 是否日记写完触发表情包生成(#96;env CP_MEME_ENABLED,缺省 true) */
memeEnabled: boolean;
/** 优雅停机收口预算 ms(#138:60–90s;compose stop_grace_period 需覆盖) */
shutdownBudgetMs: number;
/** 每租户每日 LLM 预算闸总开关(#265;env CP_LLM_BUDGET_ENABLED,缺省 true) */
Expand Down Expand Up @@ -142,7 +140,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ControlPlaneCo
adoptLlmModel: env.CP_ADOPT_LLM_MODEL ?? 'deepseek-chat',
petGenIntervalMs: Number(env.CP_PETGEN_INTERVAL_MS ?? 5_000),
shutdownBudgetMs: Number(env.CP_SHUTDOWN_BUDGET_MS ?? 90_000),
memeEnabled: env.CP_MEME_ENABLED !== 'false',
llmBudgetEnabled: env.CP_LLM_BUDGET_ENABLED !== 'false',
llmBudgetYuan: {
free: llmBudgetFreeYuan,
Expand Down
1 change: 0 additions & 1 deletion packages/control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ const scheduler = new Scheduler({
},
alertWebhookUrl: config.opsAlertWebhookUrl,
},
memeEnabled: config.memeEnabled,
});
scheduler.start(config.schedulerIntervalMs);

Expand Down
8 changes: 0 additions & 8 deletions packages/control-plane/src/scheduler/diary-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ describe('diary runner(#92)', () => {
personality: 'lazy',
diaryStyle: 'literary',
pushEnabled: true,
memeEnabled: true,
};
}

Expand Down Expand Up @@ -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 });
Expand Down
3 changes: 0 additions & 3 deletions packages/control-plane/src/scheduler/diary-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ export interface DiaryJob {
diaryStyle: DiaryStyleChoice;
/** 是否推送日记(Web Push) */
pushEnabled: boolean;
/** 是否日记写完触发表情包生成(#96) */
memeEnabled: boolean;
}

/** 结果:ok = 完成或跳过(exit 0) */
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading