少数データソースへのプロンプト改善を加速させるCLIを作ってみた

少数データソースへのプロンプト改善を加速させるCLIを作ってみた

プロンプトの返答が想定されたものに近しいかのチェックは中々困難です。特に状況によっては、検証用途で立てたRAGのコストが無視しづらい時もあります。少数少量のデータソースという前提の元にサクッと検証するためのCLIを作ってみました。
2026.02.06

AIを使う上で、困難な課題の一つとしてプロンプトに対するレスポンスの改善があります。特定のファイルをデータソースとして登録した際に、冗長な表現で意図した回答が返ってくるのか検証するものの、結果をデータとしてまとめるのは意外に大変です。

Bedrockを使ってMarkdownの生成を行うプロセスを組み立てていた最中、ふと少し工夫すればデータソースとプロンプトとレスポンスの組み合わせを元にデータソースの頻繁な改善作業に集中できるCLIが作れると思いつき、試してみました。

設定

Claude Codeとの壁打ちによって出力されたコードになります。

実行にはAWS認証情報(AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) が必要です。モデルはClaude 3 Haiku が初期設定となりますが、必要に応じて変更しましょう。

環境は以下の通り。

  • Node.js v18以上
  • pnpm(または npm/yarn)
  • AWSアカウント(Bedrockアクセス権限付き)

環境設定

# 1. ディレクトリ作成と移動
mkdir -p bedrock-claude-cli && cd bedrock-claude-cli

# 2. package.json の作成 (pnpm 用に微調整)
cat > package.json << 'EOF'
{
  "name": "bedrock-claude-cli",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "bedrock-claude": "tsx src/bedrockClaudeCli.ts"
  }
}
EOF

# 3. pnpm でのインストール
pnpm add @aws-sdk/client-bedrock-runtime
pnpm add -D typescript tsx @types/node

# 4. tsconfig.json の作成
cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
EOF

mkdir -p src

# 5. src/bedrockClaude.ts の作成
cat > src/bedrockClaude.ts << 'EOFTS'
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import * as fs from "fs";
import * as path from "path";

const BEDROCK_REGION = process.env.AWS_REGION || "ap-northeast-1";
const DEFAULT_MODEL_ID = process.env.BEDROCK_MODEL_ID || "anthropic.claude-3-haiku-20240307-v1:0";

export type ContentType = "text" | "pdf" | "image";
export type ImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
export type PdfMediaType = "application/pdf";

export interface InputContent {
  type: ContentType;
  text?: string;
  base64Data?: string;
  mediaType?: ImageMediaType | PdfMediaType;
}

export interface BedrockClaudeOptions {
  systemPrompt?: string;
  userPrompt: string;
  contents?: InputContent[];
  modelId?: string;
  maxTokens?: number;
  region?: string;
  debug?: boolean;
}

export interface BedrockClaudeResult {
  text: string;
  inputTokens?: number;
  outputTokens?: number;
  stopReason?: string;
  rawRequest?: unknown;
  rawResponse?: unknown;
}

const clientCache = new Map<string, BedrockRuntimeClient>();

function getBedrockClient(region: string): BedrockRuntimeClient {
  if (!clientCache.has(region)) {
    clientCache.set(region, new BedrockRuntimeClient({ region }));
  }
  return clientCache.get(region)!;
}

export function encodeFileToBase64(filePath: string): string {
  const absolutePath = path.resolve(filePath);
  const fileBuffer = fs.readFileSync(absolutePath);
  return fileBuffer.toString("base64");
}

export function getMediaTypeFromPath(filePath: string): ImageMediaType | PdfMediaType | null {
  const ext = path.extname(filePath).toLowerCase();
  switch (ext) {
    case ".jpg": case ".jpeg": return "image/jpeg";
    case ".png": return "image/png";
    case ".gif": return "image/gif";
    case ".webp": return "image/webp";
    case ".pdf": return "application/pdf";
    default: return null;
  }
}

export function createContentFromFile(filePath: string): InputContent {
  const mediaType = getMediaTypeFromPath(filePath);
  if (!mediaType) throw new Error(`サポートされていないファイル形式: ${filePath}`);
  const base64Data = encodeFileToBase64(filePath);
  if (mediaType === "application/pdf") {
    return { type: "pdf", base64Data, mediaType };
  }
  return { type: "image", base64Data, mediaType };
}

export function createTextContent(text: string): InputContent {
  return { type: "text", text };
}

function buildContentBlocks(userPrompt: string, contents?: InputContent[]): unknown[] {
  const blocks: unknown[] = [];
  if (contents) {
    for (const content of contents) {
      if (content.type === "text" && content.text) {
        blocks.push({ type: "text", text: content.text });
      } else if (content.type === "image" && content.base64Data && content.mediaType) {
        blocks.push({
          type: "image",
          source: { type: "base64", media_type: content.mediaType, data: content.base64Data },
        });
      } else if (content.type === "pdf" && content.base64Data) {
        blocks.push({
          type: "document",
          source: { type: "base64", media_type: "application/pdf", data: content.base64Data },
        });
      }
    }
  }
  blocks.push({ type: "text", text: userPrompt });
  return blocks;
}

export async function invokeBedrockClaude(options: BedrockClaudeOptions): Promise<BedrockClaudeResult> {
  const {
    systemPrompt, userPrompt, contents,
    modelId = DEFAULT_MODEL_ID, maxTokens = 8192,
    region = BEDROCK_REGION, debug = false,
  } = options;

  const client = getBedrockClient(region);
  const requestBody: Record<string, unknown> = {
    anthropic_version: "bedrock-2023-05-31",
    max_tokens: maxTokens,
    messages: [{ role: "user", content: buildContentBlocks(userPrompt, contents) }],
  };
  if (systemPrompt) requestBody.system = systemPrompt;

  const command = new InvokeModelCommand({
    modelId, contentType: "application/json", accept: "application/json",
    body: JSON.stringify(requestBody),
  });

  const response = await client.send(command);
  const responseBody = JSON.parse(new TextDecoder().decode(response.body));
  if (!responseBody.content || !responseBody.content[0]?.text) {
    throw new Error("Bedrockからの応答が不正です");
  }

  const result: BedrockClaudeResult = {
    text: responseBody.content[0].text.trim(),
    inputTokens: responseBody.usage?.input_tokens,
    outputTokens: responseBody.usage?.output_tokens,
    stopReason: responseBody.stop_reason,
  };
  if (debug) {
    result.rawRequest = requestBody;
    result.rawResponse = responseBody;
  }
  return result;
}
EOFTS

# 6. src/bedrockClaudeCli.ts の作成
cat > src/bedrockClaudeCli.ts << 'EOFCLI'
#!/usr/bin/env node
import * as fs from "fs";
import { invokeBedrockClaude, createContentFromFile, createTextContent, type InputContent } from "./bedrockClaude.js";

function parseArgs(args: string[]) {
  const result = {
    prompt: undefined as string | undefined,
    system: undefined as string | undefined,
    files: [] as string[],
    text: undefined as string | undefined,
    model: "anthropic.claude-3-haiku-20240307-v1:0",
    json: false,
  };
  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    switch (arg) {
      case "-p": case "--prompt": result.prompt = args[++i]; break;
      case "-s": case "--system": result.system = args[++i]; break;
      case "-f": case "--file": result.files.push(args[++i]); break;
      case "-t": case "--text": result.text = args[++i]; break;
      case "-m": case "--model": result.model = args[++i]; break;
      case "--json": result.json = true; break;
    }
  }
  return result;
}

async function main(): Promise<void> {
  const opts = parseArgs(process.argv.slice(2));
  if (!opts.prompt) {
    console.error("Usage: pnpm bedrock-claude -- -p 'prompt' [-f file]");
    process.exit(1);
  }

  const contents: InputContent[] = [];
  if (opts.text) contents.push(createTextContent(opts.text));
  for (const filePath of opts.files) {
    if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
    contents.push(createContentFromFile(filePath));
  }

  try {
    const result = await invokeBedrockClaude({
      userPrompt: opts.prompt, systemPrompt: opts.system,
      contents: contents.length > 0 ? contents : undefined,
      modelId: opts.model,
    });
    console.log(opts.json ? JSON.stringify(result, null, 2) : result.text);
  } catch (error) {
    console.error(`Error: ${error}`);
    process.exit(1);
  }
}
main();
EOFCLI

echo "✓ pnpm セットアップ完了!"

使い方

プロンプトと分析を行いたいファイル名を指定します。必要に応じてモデルも指定しましょう。

$ pnpm bedrock-claude -p '要約' -f '/path/to/サービス仕様書集.pdf' --raw -m anthropic.claude-3-5-sonnet-20240620-v1:0 

> project@1.0.0 bedrock-claude /path/to/project
> tsx src/bedrockClaudeCli.ts -p '要約' -f '/path/to/サービス仕様書集.pdf' --raw -m anthropic.claude-3-5-sonnet-20240620-v1:0

{
  "request": {
    "anthropic_version": "bedrock-2023-05-31",
    "max_tokens": 8192,
    "messages": [
      {
        "role": "user",
        "content": [
          {
            "type": "document",
            "source": {
              "type": "base64",
              "media_type": "application/pdf",
              "data": "[BASE64: 3.1MB]"
            }
          },
          {
            "type": "text",
            "text": "要約"
          }
        ]
      }
    ]
  },
  "response": {
    "id": "msg_bdrk_xxxxxxxxxxxxxxxxxx",
    "type": "message",
    "role": "assistant",
    "model": "claude-3-5-sonnet-20240620",
    "content": [
      {
        "type": "text",
        "text": "...."
      }
    ],
    "stop_reason": "end_turn",
    "stop_sequence": null,
    "usage": {
      "input_tokens": 183780,
      "output_tokens": 420
    }
  },
  "result": "..."
}

データソースとしたいファイルは複数指定可能です。

pnpm bedrock-claude -p "これらの文書を比較してください" \
  -f doc1.pdf \
  -f doc2.pdf \
  -f image.png 

ファイル操作の動作フローとしては以下の通りです。ステートレスで、どこにも保存しません。

  1. ファイル指定
  2. Base64エンコード
  3. Bedrockに送信
  4. レスポンス表示

少数で小さいファイル向けです。目安としてはPDFの場合は4.5MBまで、多くとも100ページまでにとどめましょう。大きめなファイルはRAGを推奨します。

あとがき

1問1答の少数ファイルを対象として、様々な言い回しに対して特定ファイルから得られる回答を素早く調べたい状況において重宝しています。

ファイルに対するプロンプトの結果を知りたいものの、RAGを構築・維持する程まではない場合におすすめです。

この記事をシェアする

FacebookHatena blogX

関連記事