Files
open-design/apps/daemon/tests/media-stock-search.test.ts
8d820a6989 [OPEND-3799] feat(od-next): use configurable company stock search and HTML safeguards (#8691)
* feat(od-next): 按图片类型选择搜图或生图,搜图限定 Pexels / Pixabay

- 核心提示词:「真实图片优先」改为「按图片类型取图」:真实事物用真实图片,
  示意/虚构/氛围图默认生图,图表、图标、大段文字用代码实现
- prototype / marketing 任务说明与编排文件同步;方案中为每张图写明取图方式
- 素材 skill:新增按类型选路与搜图规则(Pexels 优先、Pixabay 其次,禁用
  Wikipedia/Wikimedia),一次批量下载、单请求超时与总时长上限、按显示尺寸下载、
  记录来源与许可;用来源文字描述判断是否贴合,不再把图片读回对话
- 策略包版本 2.0.6 → 2.0.7,并同步相关资产版本与测试断言

* feat(od-next): 新增 od media stock-search,一次调用完成图库搜图与下载

- 有 PEXELS_API_KEY / PIXABAY_API_KEY 时,按槽位并发搜索 Pexels(优先)与
  Pixabay(兜底),单请求超时 12 s、总预算 180 s,按显示宽度下载到
  assets/stock/,并把来源、作者、许可写入 credits.json
- 每个槽位取 20 张候选,按 alt 文字对检索主体重排:主体出现在描述开头加分,
  未要求人物时降权以人物开头的照片(例:搜眼镜框不再返回戴眼镜的老人)
- 无 key 时退出码 5,agent 回到无 key 路径;参数错误退出码 2
- 素材 skill:有 key 时改用该命令,查询词用英文、主体在前

* refactor(od-next): stock-search 重排改为通用规则,去掉人物词表

- 删除人物词表与中文专用打分分支
- 按 alt 命中的查询词数量排序,数量相同时主体出现越靠前越优先
- 查询里没有英文词时不做比对,保持图库原始顺序

* feat(od-next): 核心提示词写明搜图来源与取图命令,并厘清菜名等品类的取图边界

- 核心提示词的「按图片类型取图」新增一条:搜图只用 Pexels、其次 Pixabay,
  禁用 Wikipedia / Wikimedia Commons 和随机图服务;有图库 key 时用
  od media stock-search 一次取齐。agent 不加载素材 skill 也能照做
  (ODEval 批次 89565639 中 4/17 次搜图跳过 skill,全部退回 Wikipedia)
- 菜名、菜系、商品品类再出名也算泛指,走生图;只有具体实例(某家真实餐馆
  自己的菜、某品牌的商品)才算真实指代物。核心提示词、prototype 任务说明、
  素材 skill 同步
- 「示例内容默认用真实指代物」限定为决定出现哪些具名实体,不改变泛指品类的
  取图方式
- 策略包 2.0.7 → 2.0.8,core 2.3.2 → 2.3.3,prototype 2.2.3 → 2.2.4,
  测试断言同步

* refactor(od-next): 取图规则只留核心提示词一份,素材 skill 只保留操作步骤

- 选路规则、搜图图源、禁用来源、官网兜底只写在核心提示词;素材 skill 与
  prototype 任务说明改为引用,不再各写一份
- 「不要把下载或生成的图读回对话」移入核心提示词:它是影响耗时最大的一条,
  不应依赖 skill 是否加载
- 编排文件的加载条件从「只在需要素材时」改为「第一次搜图、下载、生图等
  素材动作之前」
- 策略包 2.0.8 → 2.0.9,core 2.3.3 → 2.3.4,orchestration 2.3.2 → 2.3.3,
  prototype 2.2.4 → 2.2.5

* fix(od-next): 默认搜图,仅在用户明确请求后考虑生图

* fix(od-next): guard phone dimensions and hidden visibility

* fix(od-next): guide dynamic DOM structure and data separation

* fix(od-next): prefer official assets for named real subjects

* fix(od-next): align combined strategy assets with main contracts

* fix(media): validate stock search inputs and preserve slot identity

* fix(media): retain stock-search quota and fallback diagnostics

(cherry picked from commit 9db3314f3f)

* feat(media): collect and batch stock-search telemetry

(cherry picked from commit df99282ab8)

* fix(media): execute stock search through the authenticated daemon

* test(media): cover daemon stock search fallback through the CLI

* feat(media): use company stock search with bounded web fallback

* feat(media): support service-configured stock search channels

* fix(media): bound public image DNS validation by download deadline

* fix(od-next): keep image credits out of artifacts by default

* fix(pack): align bundled Vela CLI for stock search

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-10-21 11:55:23 +00:00

152 lines
10 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promises as dns } from 'node:dns';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { imageSize, stockSearch, StockSearchServiceError } from '../src/media/stock-search.js';
import { searchVelaStockPhotos, type StockSearchServiceReport } from '../src/media/vela-stock-search.js';
const JPEG = Buffer.from([255,216,255,192,0,17,8,3,32,4,176,3,1,34,0]);
const candidate = (id = 'hero') => ({ id, status: 'ok' as const, provider: 'pexels' as const,
downloadUrl: 'https://images.pexels.com/photos/1/test.jpeg', alt: 'Dog in a park', sourceUrl: 'https://www.pexels.com/photo/1/',
author: 'Ann', license: 'Pexels License', attempts: [] });
const report = (ids = ['hero']): StockSearchServiceReport => ({ providers: ['pexels'], slots: ids.map(candidate) });
const fetchImage = () => (async () => new Response(new Uint8Array(JPEG))) as typeof fetch;
describe('company stock search and authorized local downloads', () => {
let cwd: string;
beforeEach(async () => { cwd = await mkdtemp(path.join(os.tmpdir(), 'od-stock-')); });
afterEach(async () => { await rm(cwd, { recursive: true, force: true }); });
it('downloads through the company service without reading a provider key and records attribution', async () => {
const search = vi.fn(async () => report());
const fetcher = vi.fn(fetchImage());
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search, fetch: fetcher });
expect(result.slots[0]).toMatchObject({ status: 'ok', path: 'assets/stock/hero.jpg', width: 1200, height: 800 });
expect(search).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ redirect: 'manual' });
expect(fetcher.mock.calls[0]?.[1]?.headers).toBeUndefined();
expect(JSON.parse(await readFile(path.join(cwd, 'assets/stock/credits.json'), 'utf8'))[0]).toMatchObject({ author: 'Ann', license: 'Pexels License' });
});
it('rejects an HTML error page returned with HTTP 200 instead of saving a successful image', async () => {
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search: async () => report(),
fetch: (async () => new Response('<html>upstream unavailable</html>')) as typeof fetch });
expect(result.slots[0]?.status).toBe('error');
await expect(readFile(path.join(cwd, 'assets/stock/hero.jpg'))).rejects.toThrow();
});
it('returns terminal failed and missing slots without retrying the service or redownloading successes', async () => {
const search = vi.fn(async (): Promise<StockSearchServiceReport> => ({ providers: ['pexels', 'pixabay'], slots: [candidate('a'),
{ id: 'b', status: 'error', error: 'provider_unavailable', attempts: [] }, { id: 'c', status: 'not_found', attempts: [] }] }));
const fetcher = vi.fn(fetchImage());
const result = await stockSearch({ cwd, slots: ['a','b','c'].map(id => ({ id, query: 'dog' })), search, fetch: fetcher });
expect(result.slots.map(slot => slot.status)).toEqual(['ok', 'error', 'not_found']);
expect(search).toHaveBeenCalledTimes(1); expect(fetcher).toHaveBeenCalledTimes(1);
});
it('sanitizes an unavailable service and does not repeat its request', async () => {
const search = vi.fn(async () => { throw new Error('key=sensitive-upstream-key'); });
await expect(stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search })).rejects.toThrow(StockSearchServiceError);
expect(search).toHaveBeenCalledTimes(1);
});
it('downloads a future channel from a public HTTPS CDN without a client supplier allowlist', async () => {
const fetcher = vi.fn(fetchImage());
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }],
search: async () => ({ providers: ['future-library'], slots: [{ ...candidate(), provider: 'future-library', downloadUrl: 'https://93.184.216.34/future.jpg' }] }), fetch: fetcher });
expect(result.slots[0]).toMatchObject({ status: 'ok', provider: 'future-library' });
expect(fetcher.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
});
it('blocks private and HTTP redirect targets before contacting them', async () => {
for (const location of ['https://169.254.169.254/latest/meta-data/', 'http://93.184.216.35/image.jpg']) {
const fetcher = vi.fn(async () => new Response(null, { status: 302, headers: { location } }));
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search: async () => report(), fetch: fetcher as typeof fetch });
expect(result.slots[0]?.status).toBe('error');
expect(fetcher).toHaveBeenCalledTimes(1);
}
});
it('bounds DNS pre-validation by the same image-download timeout', async () => {
const lookup = vi.spyOn(dns, 'lookup').mockImplementation(() => new Promise(() => {}));
const fetcher = vi.fn(fetchImage());
try {
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search: async () => report(), fetch: fetcher, requestTimeoutMs: 5 });
expect(result.slots[0]).toMatchObject({ status: 'error', error: 'stock image download timed out' });
expect(fetcher).not.toHaveBeenCalled();
} finally { lookup.mockRestore(); }
});
it('bounds the whole acquisition and retains server results after the budget expires', async () => {
let now = 0;
const search = vi.fn(async () => { now = 20; return report(); });
const fetcher = vi.fn(fetchImage());
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search, fetch: fetcher, now: () => now, budgetMs: 10 });
expect(result.slots[0]?.status).toBe('budget_exceeded'); expect(fetcher).not.toHaveBeenCalled();
});
it.each(['http://127.0.0.1/internal', 'https://127.0.0.1/internal', 'https://user:password@images.pexels.com/x'])('rejects unsafe download URL %s', async (downloadUrl) => {
const fetcher = vi.fn(fetchImage());
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }],
search: async () => ({ providers: ['pexels'], slots: [{ ...candidate(), downloadUrl }] }), fetch: fetcher });
expect(result.slots[0]?.status).toBe('error'); expect(fetcher).not.toHaveBeenCalled();
});
it('rejects a declared oversized download before reading its body', async () => {
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search: async () => report(),
fetch: (async () => new Response(new Uint8Array(JPEG), { headers: { 'content-length': String(11 * 1024 * 1024) } })) as typeof fetch });
expect(result.slots[0]?.status).toBe('error');
});
it('keeps normalized IDs distinct and merges attribution across calls', async () => {
const ids = ['Hero', 'hero', '狗狗', '猫咪', 'hero image', 'hero-image'];
const result = await stockSearch({ cwd, slots: ids.map(id => ({ id, query: 'dog' })), search: async () => report(ids), fetch: fetchImage() });
expect(new Set(result.slots.map(slot => slot.path)).size).toBe(ids.length);
await stockSearch({ cwd, slots: [{ id: 'extra', query: 'dog' }], search: async () => report(['extra']), fetch: fetchImage() });
expect(JSON.parse(await readFile(path.join(cwd, 'assets/stock/credits.json'), 'utf8'))).toHaveLength(7);
});
it.each([{ concurrency: 0 }, { budgetMs: Infinity }, { requestTimeoutMs: -1 }])('rejects invalid execution limits %j', async (limits) => {
const search = vi.fn(async () => report());
await expect(stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search, ...limits })).rejects.toThrow('positive integer');
expect(search).not.toHaveBeenCalled();
});
it('rejects duplicate IDs before executing', async () => {
const search = vi.fn(async () => report());
await expect(stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }, { id: 'hero', query: 'cat' }], search })).rejects.toThrow('distinct');
expect(search).not.toHaveBeenCalled();
});
it('keeps acquisition successful when optional logging fails', async () => {
const result = await stockSearch({ cwd, slots: [{ id: 'hero', query: 'dog' }], search: async () => report(), fetch: fetchImage(), onAttempt: () => { throw new Error('logging unavailable'); } });
expect(result.slots[0]?.status).toBe('ok'); expect(imageSize(JPEG)).toEqual({ width: 1200, height: 800 });
});
});
it('Vela stock adapter sends one stdin request using the existing workspace/login environment', async () => {
const runner = vi.fn(async () => JSON.stringify({ providers: ['pexels'], slots: [{ id: 'hero', status: 'not_found', attempts: [] }] }));
const result = await searchVelaStockPhotos({ slots: [{ id: 'hero', query: 'dog' }], requestTimeoutMs: 12000, budgetMs: 180000, concurrency: 6, workspaceId: 'workspace-one' }, runner);
expect(result.slots[0]?.status).toBe('not_found');
expect(runner).toHaveBeenCalledTimes(1);
expect(runner.mock.calls[0]).toMatchObject([['media', 'stock-search', '--request-file', '-', '--json'], { configuredEnv: { VELA_WORKSPACE_ID: 'workspace-one' }, timeoutMs: 185000 }]);
});
it('rejects a malformed Vela response before trusting it', async () => {
const input = { slots: [{ id: 'hero', query: 'dog' }], requestTimeoutMs: 12000, budgetMs: 180000, concurrency: 6 };
for (const body of [{ slots: [] }, { providers: ['unknown'], slots: [] }, { providers: ['pexels'], slots: [{ id: 'different', status: 'ok', attempts: [] }] }]) {
await expect(searchVelaStockPhotos(input, async () => JSON.stringify(body))).rejects.toThrow('invalid stock service');
}
});
it('accepts three configured channels while rejecting repeated channel attempts', async () => {
const input = { slots: [{ id: 'hero', query: 'dog' }], requestTimeoutMs: 12000, budgetMs: 180000, concurrency: 6 };
const providers = ['company-pixabay', 'company-pexels', 'future-library'];
const attempts = providers.map(provider => ({ provider, stage: 'search', status: 'error', timestamp: new Date().toISOString(), duration_ms: 1, http_status: 503 }));
const body = { providers, slots: [{ id: 'hero', status: 'error', attempts }] };
const runner = vi.fn(async (..._args: Parameters<typeof import("../src/integrations/vela-command.js").runVelaCommand>) => JSON.stringify(body));
expect((await searchVelaStockPhotos(input, runner)).slots[0]?.attempts).toHaveLength(3);
expect(JSON.parse(String(runner.mock.calls[0]![1]!.input))).toMatchObject({ max_attempts: 6 });
body.slots[0]!.attempts[1] = attempts[0]!;
await expect(searchVelaStockPhotos(input, async () => JSON.stringify(body))).rejects.toThrow('invalid stock service slot');
});