summaryrefslogtreecommitdiff
path: root/app/lib/claude.server.ts
blob: 4dcce6e0c67a2216dc472e41868e82a31d85cf58 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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);
}