import { spawn } from "child_process"; import Anthropic from "@anthropic-ai/sdk"; import type { Band } from "./db.server"; export interface BandMatch { extracted: string; matched_id: string | null; matched_name: string | null; confidence: "exact" | "fuzzy" | "new"; } // ── Shared logic ────────────────────────────────────────────────────────────── function buildPrompt(liveText: string, existingBands: Band[]): string { const bandList = existingBands.map((b) => ` ${b.id}\t${b.name}`).join("\n"); return `ライブ情報テキストからバンド・アーティスト名を抽出し、既存バンドリストと照合してください。 ## ライブ情報テキスト ${liveText} ## 既存バンドリスト(UUID\t名前) ${bandList || " (なし)"} ## タスク 1. ライブ情報からバンド・アーティスト名をすべて抽出する 2. 各名前を既存バンドリストと照合する(表記揺れ・全角半角・英語/日本語表記の違いなどを考慮) 3. 以下のJSON配列のみを返す(説明文不要): [ { "extracted": "抽出した名前(元テキストのまま)", "matched_id": "一致した既存バンドのUUID(なければnull)", "matched_name": "一致した既存バンドの名前(なければnull)", "confidence": "exact | fuzzy | new" } ] confidenceの定義: - "exact": 完全一致または明らかに同一 - "fuzzy": 表記揺れの可能性が高い(人間の確認が必要) - "new": 既存リストに存在しない新しいバンド`; } function parseOutput(text: string): BandMatch[] { const jsonMatch = text.match(/\[[\s\S]*\]/); if (!jsonMatch) throw new Error("予期しない出力形式です"); return JSON.parse(jsonMatch[0]) as BandMatch[]; } // ── Adapter interface ───────────────────────────────────────────────────────── interface BandMatchAdapter { parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise; } // ── Anthropic API adapter ───────────────────────────────────────────────────── class AnthropicAPIAdapter implements BandMatchAdapter { async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise { const client = new Anthropic(); const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 2048, messages: [{ role: "user", content: buildPrompt(liveText, existingBands) }], }); const text = response.content[0].type === "text" ? response.content[0].text : ""; return parseOutput(text); } } // ── Claude Code CLI adapter ─────────────────────────────────────────────────── class ClaudeCodeCLIAdapter implements BandMatchAdapter { async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise { const text = await this.runCLI(buildPrompt(liveText, existingBands)); return parseOutput(text); } private runCLI(prompt: string): Promise { return new Promise((resolve, reject) => { const proc = spawn( "claude", ["--print", "--output-format", "json", "--no-session-persistence", "--model", "sonnet"], { stdio: ["pipe", "pipe", "pipe"] } ); let stdout = ""; let stderr = ""; proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); proc.on("error", (err) => reject(new Error(`claude CLI 起動失敗: ${err.message}`))); proc.on("close", (code) => { if (code !== 0) { reject(new Error(`claude CLI がコード ${code} で終了: ${stderr.trim()}`)); return; } try { // --output-format json wraps result in { "result": "..." } const json = JSON.parse(stdout) as { result?: string }; resolve(json.result ?? stdout); } catch { resolve(stdout); } }); proc.stdin.write(prompt); proc.stdin.end(); }); } } // ── Factory ─────────────────────────────────────────────────────────────────── function getAdapter(): BandMatchAdapter { const type = (process.env.CLAUDE_ADAPTER ?? "api").toLowerCase(); if (type === "cli") return new ClaudeCodeCLIAdapter(); return new AnthropicAPIAdapter(); } // ── Public API (unchanged for callers) ─────────────────────────────────────── export function parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise { return getAdapter().parseLiveInfoBands(liveText, existingBands); }