Skip to content

Commit bb64c55

Browse files
feat(analysis): add filename-guided Claude analysis
Enrich Claude metadata analysis with a lightweight filename hint: - Prefer source filename from source.originalPath for imported assets - Fall back to managed asset slug for generated assets - Filter placeholder names like original.jpg or asset automatically - Append guidance at runtime, applies to bundled and custom prompts Treat as soft hint: visible image content takes precedence if filename conflicts. Co-Authored-By: Hagicode <noreply@hagicode.com>
1 parent b6407ab commit bb64c55

10 files changed

Lines changed: 371 additions & 12 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ library/
1111
.env.*
1212
!.env.example
1313
*.tsbuildinfo
14+
15+
temp/

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ Notes:
148148
If `IMGBIN_ANALYSIS_PROMPT_PATH` is not set, ImgBin falls back to `prompts/default-analysis-prompt.txt`.
149149
If `IMGBIN_ANALYSIS_API_MODEL` is empty, ImgBin falls back to `ANTHROPIC_MODEL`.
150150

151+
ImgBin also adds a runtime filename-guidance block to every Claude analysis request. Imported assets prefer the original source filename, generated assets fall back to the managed slug, and placeholder names such as `original.png` or `asset` are ignored. The filename is treated as a soft scene hint only; visible image evidence still wins when they disagree.
152+
151153
### General runtime
152154

153155
- `IMGBIN_DEFAULT_OUTPUT_DIR`: optional default output root, defaults to `./library`
@@ -206,6 +208,16 @@ imgbin annotate ./library/2026-03/orange-dashboard-hero --overwrite
206208

207209
This is useful after changing `IMGBIN_ANALYSIS_API_MODEL` or updating the analysis prompt.
208210

211+
### Filename-guided analysis
212+
213+
ImgBin now enriches Claude metadata analysis with a lightweight filename hint:
214+
215+
- imported assets prefer the source filename from `source.originalPath`,
216+
- generated assets fall back to the managed asset slug or directory name, and
217+
- placeholder names such as `original.jpg` or `asset` are skipped automatically.
218+
219+
This guidance is appended at runtime, so it applies to both the bundled default prompt and any `--analysis-prompt` override. Treat it as a soft hint: if the filename conflicts with the image itself, the visible image content should take precedence.
220+
209221
### Annotate an existing managed asset
210222

211223
```bash

hagindex.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
version: "1.0"
2+
name: "imgbin"
3+
displayName: "ImgBin"
4+
description: "CLI 工具 - 图像资源生成、标注和索引工具"
5+
indexedAt: "2026-03-12"
6+
generatedBy: "claude-code"
7+
8+
quickRef:
9+
catalogs:
10+
- id: "entryPoints"
11+
title: "入口点"
12+
desc: "主要入口点和可执行程序"
13+
- id: "configFiles"
14+
title: "配置文件"
15+
desc: "关键配置文件"
16+
- id: "directories"
17+
title: "目录结构"
18+
desc: "重要目录"
19+
- id: "features"
20+
title: "功能模块"
21+
desc: "主要功能模块"
22+
23+
entryPoints:
24+
- title: "CLI 入口"
25+
desc: "ImgBin CLI 主程序"
26+
path: "src/cli.ts"
27+
28+
configFiles:
29+
- title: "Package 配置"
30+
desc: "NPM 包配置"
31+
path: "package.json"
32+
- title: "TypeScript 配置"
33+
desc: "TypeScript 配置"
34+
path: "tsconfig.json"
35+
36+
directories:
37+
- title: "源代码"
38+
desc: "源代码目录"
39+
path: "src"
40+
- title: "Library"
41+
desc: "图像资源库"
42+
path: "library"
43+
- title: "Scripts"
44+
desc: "自动化脚本"
45+
path: "scripts"
46+
- title: "Prompts"
47+
desc: "提示词文件"
48+
path: "prompts"
49+
50+
features:
51+
- title: "图像生成"
52+
desc: "图像资源生成功能"
53+
path: "src"
54+
- title: "图像标注"
55+
desc: "图像元数据标注"
56+
path: "src"
57+
- title: "图像索引"
58+
desc: "图像资源索引管理"
59+
path: "library"
60+
- title: "缩略图生成"
61+
desc: "自动生成图像缩略图"
62+
path: "src"

prompts/default-analysis-prompt.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ Rules:
1111
- Use 2 to 8 lowercase kebab-case tags.
1212
- Keep the title under 80 characters.
1313
- Keep the description under 200 characters.
14+
- Filenames may be provided as auxiliary scene clues, but visible image evidence always takes precedence.
1415
- Do not include markdown fences or extra commentary.

src/__tests__/cli.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import os from 'node:os';
2+
import path from 'node:path';
3+
import { promises as fs } from 'node:fs';
14
import { describe, expect, it, vi } from 'vitest';
25
import type { CliRuntime } from '../lib/runtime.js';
3-
import { buildCli, runCli } from '../cli.js';
6+
import { buildCli, isExecutedAsScript, runCli } from '../cli.js';
47

58
function createRuntimeStub(): CliRuntime {
69
return {
@@ -121,4 +124,22 @@ describe('CLI parsing', () => {
121124
reindex: true
122125
});
123126
});
127+
128+
it('treats a symlinked CLI entrypoint as the executed script', async () => {
129+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'imgbin-cli-test-'));
130+
const realScriptPath = path.join(tempDir, 'dist', 'cli.js');
131+
const linkPath = path.join(tempDir, 'node_modules', '@hagicode', 'imgbin', 'dist', 'cli.js');
132+
133+
await fs.mkdir(path.dirname(realScriptPath), { recursive: true });
134+
await fs.mkdir(path.dirname(linkPath), { recursive: true });
135+
await fs.writeFile(realScriptPath, 'console.log("cli");\n', 'utf8');
136+
await fs.symlink(realScriptPath, linkPath);
137+
138+
try {
139+
const result = await isExecutedAsScript(new URL(`file://${realScriptPath}`).href, linkPath);
140+
expect(result).toBe(true);
141+
} finally {
142+
await fs.rm(tempDir, { recursive: true, force: true });
143+
}
144+
});
124145
});

src/__tests__/integration.test.ts

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from 'node:path';
22
import { promises as fs } from 'node:fs';
33
import { afterEach, describe, expect, it } from 'vitest';
4+
import { ClaudeMetadataProvider } from '../providers/claude-metadata-provider.js';
45
import { AssetWriter } from '../services/asset-writer.js';
56
import { JobRunner } from '../services/job-runner.js';
67
import { ManagedAssetScanner } from '../services/managed-asset-scanner.js';
@@ -21,7 +22,7 @@ afterEach(async () => {
2122

2223
function createRunner(options: {
2324
imageProvider?: FakeImageProvider;
24-
visionProvider?: FakeVisionProvider;
25+
visionProvider?: FakeVisionProvider | ClaudeMetadataProvider;
2526
now?: Date;
2627
defaultAnalysisPromptPath?: string;
2728
}) {
@@ -45,6 +46,37 @@ function createRunner(options: {
4546
});
4647
}
4748

49+
async function createRecordingClaudeProvider(recordedPromptPath: string): Promise<ClaudeMetadataProvider> {
50+
const binDir = await createTempDir('imgbin-claude-bin-');
51+
cleanupDirs.push(binDir);
52+
const scriptPath = path.join(binDir, 'fake-claude.mjs');
53+
await fs.writeFile(
54+
scriptPath,
55+
`#!/usr/bin/env node
56+
import { promises as fs } from 'node:fs';
57+
58+
const args = process.argv.slice(2);
59+
const promptIndex = args.indexOf('-p');
60+
const prompt = promptIndex >= 0 ? args[promptIndex + 1] ?? '' : '';
61+
const outputPath = ${JSON.stringify(recordedPromptPath)};
62+
await fs.writeFile(outputPath, prompt, 'utf8');
63+
64+
process.stdout.write(JSON.stringify({
65+
title: 'Recorded Claude Title',
66+
tags: ['recorded', 'claude'],
67+
description: 'Prompt recorder response.'
68+
}));
69+
`,
70+
'utf8'
71+
);
72+
await fs.chmod(scriptPath, 0o755);
73+
return new ClaudeMetadataProvider({
74+
executable: scriptPath,
75+
model: 'fake-claude-model',
76+
timeoutMs: 5_000
77+
});
78+
}
79+
4880
describe('integration flows', () => {
4981
it('generates from a docs prompt file, writes metadata, and records prompt provenance', async () => {
5082
const dir = await createTempDir();
@@ -87,6 +119,8 @@ describe('integration flows', () => {
87119
expect(metadata.status.recognition).toBe('succeeded');
88120
expect(metadata.status.thumbnail).toBe('succeeded');
89121
expect(visionProvider.calls[0]?.prompt.toLowerCase()).toContain('return strict json');
122+
expect(visionProvider.calls[0]?.filenameHint).toBe(path.basename(result.assetDir!));
123+
expect(visionProvider.calls[0]?.filenameHintSource).toBe('slug');
90124
});
91125

92126
it('imports a standalone image before analysis and preserves manual metadata on later annotate runs', async () => {
@@ -98,9 +132,10 @@ describe('integration flows', () => {
98132
await fs.writeFile(sourcePath, await createPngBuffer());
99133

100134
const metadataService = new MetadataService();
135+
const visionProvider = new FakeVisionProvider();
101136
const runner = new JobRunner({
102137
imageProvider: new FakeImageProvider(),
103-
visionProvider: new FakeVisionProvider(),
138+
visionProvider,
104139
assetWriter: new AssetWriter(),
105140
metadataService,
106141
thumbnailService: new ThumbnailService(),
@@ -130,6 +165,8 @@ describe('integration flows', () => {
130165
const metadata = await metadataService.load(imported.assetDir!);
131166
expect(metadata.source?.type).toBe('imported');
132167
expect(metadata.source?.originalPath).toBe(sourcePath);
168+
expect(visionProvider.calls[0]?.filenameHint).toBe('standalone.png');
169+
expect(visionProvider.calls[0]?.filenameHintSource).toBe('source.originalPath');
133170
metadata.manual = {
134171
title: 'Manual Title',
135172
tags: ['manual-tag']
@@ -152,9 +189,10 @@ describe('integration flows', () => {
152189
it('scans pending assets in the library and retries analysis', async () => {
153190
const dir = await createTempDir();
154191
cleanupDirs.push(dir);
192+
const visionProvider = new FakeVisionProvider();
155193
const runner = createRunner({
156194
imageProvider: new FakeImageProvider(),
157-
visionProvider: new FakeVisionProvider()
195+
visionProvider
158196
});
159197

160198
const generated = await runner.generate({
@@ -171,6 +209,8 @@ describe('integration flows', () => {
171209
expect(generated.success).toBe(true);
172210
expect(result.success).toBe(true);
173211
expect(result.total).toBe(1);
212+
expect(visionProvider.calls[0]?.filenameHint).toBe('needs-later-analysis');
213+
expect(visionProvider.calls[0]?.filenameHintSource).toBe('slug');
174214
const metadata = JSON.parse(await fs.readFile(path.join(generated.assetDir!, 'metadata.json'), 'utf8')) as { status: { recognition: string } };
175215
expect(metadata.status.recognition).toBe('succeeded');
176216
});
@@ -224,6 +264,116 @@ describe('integration flows', () => {
224264
expect(visionProvider.calls[0]?.promptMetadata.path).toContain('custom-analysis-prompt.txt');
225265
});
226266

267+
it('includes imported source filenames in the final Claude request', async () => {
268+
const dir = await createTempDir();
269+
cleanupDirs.push(dir);
270+
const sourceDir = await createTempDir('imgbin-source-');
271+
cleanupDirs.push(sourceDir);
272+
const sourcePath = path.join(sourceDir, 'launch-hero.png');
273+
const recordedPromptPath = path.join(dir, 'claude-imported-prompt.txt');
274+
await fs.writeFile(sourcePath, await createPngBuffer());
275+
276+
const runner = createRunner({
277+
visionProvider: await createRecordingClaudeProvider(recordedPromptPath)
278+
});
279+
280+
const result = await runner.annotate({
281+
assetPath: sourcePath,
282+
importTo: dir,
283+
overwrite: false,
284+
dryRun: false
285+
});
286+
287+
expect(result.success).toBe(true);
288+
const recordedPrompt = await fs.readFile(recordedPromptPath, 'utf8');
289+
expect(recordedPrompt).toContain('Filename guidance (soft scene hint):');
290+
expect(recordedPrompt).toContain('launch-hero.png');
291+
expect(recordedPrompt).toContain('imported source filename');
292+
expect(recordedPrompt).toContain('trust the image');
293+
});
294+
295+
it('falls back to the managed slug in the final Claude request when no source filename exists', async () => {
296+
const dir = await createTempDir();
297+
cleanupDirs.push(dir);
298+
const recordedPromptPath = path.join(dir, 'claude-generated-prompt.txt');
299+
300+
const runner = createRunner({
301+
imageProvider: new FakeImageProvider(),
302+
visionProvider: await createRecordingClaudeProvider(recordedPromptPath)
303+
});
304+
305+
const result = await runner.generate({
306+
prompt: 'orange dashboard hero',
307+
output: dir,
308+
tags: [],
309+
annotate: true,
310+
thumbnail: false,
311+
dryRun: false
312+
});
313+
314+
expect(result.success).toBe(true);
315+
const recordedPrompt = await fs.readFile(recordedPromptPath, 'utf8');
316+
expect(recordedPrompt).toContain('Filename guidance (soft scene hint):');
317+
expect(recordedPrompt).toContain('orange-dashboard-hero');
318+
expect(recordedPrompt).toContain('managed asset slug');
319+
});
320+
321+
it('filters placeholder filename hints out of the final Claude request', async () => {
322+
const dir = await createTempDir();
323+
cleanupDirs.push(dir);
324+
const sourceDir = await createTempDir('imgbin-source-');
325+
cleanupDirs.push(sourceDir);
326+
const sourcePath = path.join(sourceDir, 'original.png');
327+
const recordedPromptPath = path.join(dir, 'claude-placeholder-prompt.txt');
328+
await fs.writeFile(sourcePath, await createPngBuffer());
329+
330+
const runner = createRunner({
331+
visionProvider: await createRecordingClaudeProvider(recordedPromptPath)
332+
});
333+
334+
const result = await runner.annotate({
335+
assetPath: sourcePath,
336+
importTo: dir,
337+
slug: 'asset',
338+
overwrite: false,
339+
dryRun: false
340+
});
341+
342+
expect(result.success).toBe(true);
343+
const recordedPrompt = await fs.readFile(recordedPromptPath, 'utf8');
344+
expect(recordedPrompt).not.toContain('Filename guidance (soft scene hint):');
345+
expect(recordedPrompt).not.toContain('Candidate hint from');
346+
});
347+
348+
it('keeps runtime filename guidance when using a custom analysis prompt', async () => {
349+
const dir = await createTempDir();
350+
cleanupDirs.push(dir);
351+
const recordedPromptPath = path.join(dir, 'claude-custom-prompt.txt');
352+
353+
const runner = createRunner({
354+
imageProvider: new FakeImageProvider(),
355+
visionProvider: await createRecordingClaudeProvider(recordedPromptPath)
356+
});
357+
358+
const result = await runner.generate({
359+
prompt: 'custom prompt asset',
360+
output: dir,
361+
slug: 'marketing-hero',
362+
tags: [],
363+
annotate: true,
364+
thumbnail: false,
365+
dryRun: false,
366+
analysisPromptPath: path.join(fixtureDir, 'custom-analysis-prompt.txt')
367+
});
368+
369+
expect(result.success).toBe(true);
370+
const recordedPrompt = await fs.readFile(recordedPromptPath, 'utf8');
371+
expect(recordedPrompt).toContain('imported marketing image');
372+
expect(recordedPrompt).toContain('Filename guidance (soft scene hint):');
373+
expect(recordedPrompt).toContain('marketing-hero');
374+
expect(recordedPrompt).toContain('managed asset slug');
375+
});
376+
227377
it('searches generated and imported assets through the persisted library index', async () => {
228378
const dir = await createTempDir();
229379
cleanupDirs.push(dir);

src/cli.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
#!/usr/bin/env node
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { realpath } from 'node:fs/promises';
25
import { Command, CommanderError } from 'commander';
36
import { registerAnnotateCommand } from './commands/annotate.js';
47
import { registerBatchCommand } from './commands/batch.js';
@@ -52,7 +55,30 @@ export async function runCli(argv = process.argv, options: RuntimeOptions = {}):
5255
}
5356
}
5457

55-
if (import.meta.url === `file://${process.argv[1]}`) {
58+
export async function isExecutedAsScript(importMetaUrl: string, argv1 = process.argv[1]): Promise<boolean> {
59+
if (!argv1) {
60+
return false;
61+
}
62+
63+
const [scriptPath, invokedPath] = await Promise.all([
64+
resolveExecutionPath(fileURLToPath(importMetaUrl)),
65+
resolveExecutionPath(argv1)
66+
]);
67+
68+
return scriptPath === invokedPath;
69+
}
70+
71+
async function resolveExecutionPath(targetPath: string): Promise<string> {
72+
const resolvedPath = path.resolve(targetPath);
73+
74+
try {
75+
return await realpath(resolvedPath);
76+
} catch {
77+
return resolvedPath;
78+
}
79+
}
80+
81+
if (await isExecutedAsScript(import.meta.url)) {
5682
const exitCode = await runCli();
5783
process.exit(exitCode);
5884
}

0 commit comments

Comments
 (0)