summaryrefslogtreecommitdiff
path: root/app/lib/claude.server.ts
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-15 18:57:59 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-15 18:57:59 +0900
commit59a9f4984c3acd29bc746a78e30849f50fa555ee (patch)
treeb9dab024c0c5d1d68e023b8f49eb0716b97965bd /app/lib/claude.server.ts
parenta100fb28f92fa7c5d2cf701d118c4c7b898c9bf2 (diff)
Add Claude-powered band auto-registration from live info text
Extracts band/artist names from pasted live info via Claude (API or CLI adapter), matches against existing bands, and lets the user review and bulk-register new ones through a polling job queue. Also adds a livehouse events import script and recent-bands/artists home page sections. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'app/lib/claude.server.ts')
-rw-r--r--app/lib/claude.server.ts125
1 files changed, 125 insertions, 0 deletions
diff --git a/app/lib/claude.server.ts b/app/lib/claude.server.ts
new file mode 100644
index 0000000..4dcce6e
--- /dev/null
+++ b/app/lib/claude.server.ts
@@ -0,0 +1,125 @@
+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<BandMatch[]>;
+}
+
+// ── Anthropic API adapter ─────────────────────────────────────────────────────
+
+class AnthropicAPIAdapter implements BandMatchAdapter {
+ async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> {
+ 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<BandMatch[]> {
+ const text = await this.runCLI(buildPrompt(liveText, existingBands));
+ return parseOutput(text);
+ }
+
+ private runCLI(prompt: string): Promise<string> {
+ 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<BandMatch[]> {
+ return getAdapter().parseLiveInfoBands(liveText, existingBands);
+}