diff options
Diffstat (limited to 'app/lib')
| -rw-r--r-- | app/lib/claude.server.ts | 125 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 79 | ||||
| -rw-r--r-- | app/lib/worker.server.ts | 32 |
3 files changed, 236 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); +} diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index c4b11b4..37d819b 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -142,7 +142,22 @@ function initSchema(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_artist_revisions_artist_id ON artist_revisions(artist_id); CREATE INDEX IF NOT EXISTS idx_list_entries_list_id ON list_entries(list_id); CREATE INDEX IF NOT EXISTS idx_list_revisions_list_id ON list_revisions(list_id); + + CREATE TABLE IF NOT EXISTS parse_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + input_text TEXT NOT NULL, + result TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_parse_jobs_status ON parse_jobs(status, created_at); `); + + // reset stuck jobs from previous server run + db.prepare("UPDATE parse_jobs SET status = 'pending', updated_at = datetime('now') WHERE status = 'processing'").run(); } // ── Interfaces ──────────────────────────────────────────────────────────────── @@ -330,6 +345,14 @@ export function listBands(): Band[] { return getDb().prepare("SELECT * FROM bands ORDER BY slug").all() as Band[]; } +export function listRecentBands(limit = 8): Band[] { + return getDb().prepare("SELECT * FROM bands ORDER BY created_at DESC LIMIT ?").all(limit) as Band[]; +} + +export function listRecentArtists(limit = 8): Artist[] { + return getDb().prepare("SELECT * FROM artists ORDER BY created_at DESC LIMIT ?").all(limit) as Artist[]; +} + export function getBandById(id: string): Band | null { return getDb().prepare("SELECT * FROM bands WHERE id = ?").get(id) as Band | null; } @@ -796,3 +819,59 @@ export function importDb(data: DbExport): ImportResult { }; })() as ImportResult; } + +// ── Parse job queue ─────────────────────────────────────────────────────────── + +export interface ParseJob { + id: string; + status: "pending" | "processing" | "done" | "error"; + input_text: string; + result: string | null; + error_message: string | null; + created_at: string; + updated_at: string; +} + +export function createParseJob(inputText: string): ParseJob { + const id = crypto.randomUUID(); + getDb().prepare( + "INSERT INTO parse_jobs (id, input_text) VALUES (?, ?)" + ).run(id, inputText); + return getDb().prepare("SELECT * FROM parse_jobs WHERE id = ?").get(id) as ParseJob; +} + +export function getParseJob(id: string): ParseJob | null { + return getDb().prepare("SELECT * FROM parse_jobs WHERE id = ?").get(id) as ParseJob | null; +} + +export function listRecentParseJobs(limit = 20): ParseJob[] { + return getDb() + .prepare("SELECT * FROM parse_jobs ORDER BY created_at DESC LIMIT ?") + .all(limit) as ParseJob[]; +} + +export function claimNextParseJob(): ParseJob | null { + const db = getDb(); + return db.transaction(() => { + const job = db.prepare( + "SELECT * FROM parse_jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1" + ).get() as ParseJob | null; + if (!job) return null; + db.prepare( + "UPDATE parse_jobs SET status = 'processing', updated_at = datetime('now') WHERE id = ?" + ).run(job.id); + return { ...job, status: "processing" as const }; + })(); +} + +export function completeParseJob(id: string, result: string): void { + getDb().prepare( + "UPDATE parse_jobs SET status = 'done', result = ?, updated_at = datetime('now') WHERE id = ?" + ).run(result, id); +} + +export function failParseJob(id: string, errorMessage: string): void { + getDb().prepare( + "UPDATE parse_jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE id = ?" + ).run(errorMessage, id); +} diff --git a/app/lib/worker.server.ts b/app/lib/worker.server.ts new file mode 100644 index 0000000..f71ea40 --- /dev/null +++ b/app/lib/worker.server.ts @@ -0,0 +1,32 @@ +import { claimNextParseJob, completeParseJob, failParseJob, listBands } from "./db.server"; +import { parseLiveInfoBands } from "./claude.server"; + +const POLL_INTERVAL_MS = 2000; + +let started = false; + +function startWorker() { + if (started) return; + started = true; + + let busy = false; + + setInterval(async () => { + if (busy) return; + const job = claimNextParseJob(); + if (!job) return; + + busy = true; + try { + const bands = listBands(); + const matches = await parseLiveInfoBands(job.input_text, bands); + completeParseJob(job.id, JSON.stringify(matches)); + } catch (e) { + failParseJob(job.id, e instanceof Error ? e.message : "不明なエラー"); + } finally { + busy = false; + } + }, POLL_INTERVAL_MS); +} + +startWorker(); |
