summaryrefslogtreecommitdiff
path: root/app/lib
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
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')
-rw-r--r--app/lib/claude.server.ts125
-rw-r--r--app/lib/db.server.ts79
-rw-r--r--app/lib/worker.server.ts32
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();