import type { OpenAICompatClient, Message } from '../llm/openai-compat.js'; import { logger } from '../logger.js'; export interface PieceDescription { name: string; description: string; keywords?: string[]; } export function buildClassificationPrompt( taskText: string, pieces: PieceDescription[], fileNames: string[], ): string { const pieceList = pieces .map(p => `- ${p.name}: ${p.description}`) .join('\n'); const filesLine = fileNames.length > 0 ? `\n添付ファイル: ${fileNames.join(', ')}` : ''; // キーワードマッチした piece をヒントとして追加 const keywordHints = pieces .filter(p => p.keywords && p.keywords.length > 0) .map(p => { const matched = p.keywords!.filter(kw => taskText.includes(kw)); return matched.length > 0 ? `- ${p.name}: マッチしたキーワード [${matched.join(', ')}]` : null; }) .filter(Boolean); const hintLine = keywordHints.length > 0 ? `\nキーワードマッチによる候補(参考):\n${keywordHints.join('\n')}\n` : ''; return `以下のタスクに最適な処理タイプを1つ選んでください。選択肢名のみ回答してください。 ## 選択ルール(重要) - **デフォルトは "chat"** — 特化型 piece に明確にマッチしない依頼はすべて "chat" を選ぶ - 特化型 piece を選ぶのは、タスク内容が以下のいずれかに **強く** 該当する場合のみ: - スライド/プレゼン作成依頼 → slide - データ加工・集計・分析依頼 → data-process - 構造化された調査レポート作成依頼 → research - ブレスト・アイデア出し依頼 → brainstorming - その他、piece description が依頼内容と直接対応する場合 - 単なる質問・対話・コード生成・文書執筆・短いタスクは "chat" を選ぶ - 迷ったら "chat" を選ぶこと 選択肢: ${pieceList} ${hintLine} タスク内容: ${taskText.slice(0, 800)}${filesLine}`; } export function parseClassificationResponse( response: string, validPieceNames: string[], ): string | null { const cleaned = response .replace(/[\s\S]*?<\/think>/g, '') .trim() .toLowerCase(); if (!cleaned) return null; // 完全一致を試す for (const name of validPieceNames) { if (cleaned === name) return name; } // 部分一致を試す(長い名前順にソートし、短い名前が先にマッチするのを防ぐ) const sorted = [...validPieceNames].sort((a, b) => b.length - a.length); for (const name of sorted) { if (cleaned.includes(name)) return name; } return null; } export async function classifyPiece( client: OpenAICompatClient, taskText: string, pieces: PieceDescription[], fileNames: string[], timeoutMs: number = 8000, userId?: string, ): Promise { const prompt = buildClassificationPrompt(taskText, pieces, fileNames); logger.debug(`[piece-classifier] candidates=[${pieces.map(p => p.name).join(', ')}] textLen=${taskText.length}`); const messages: Message[] = [{ role: 'user', content: prompt }]; const llmCall = async (): Promise => { let result = ''; try { for await (const event of client.chat(messages, undefined, undefined, { userId })) { if (event.type === 'text') result += event.text; else if (event.type === 'error') return null; else if (event.type === 'done') break; } } catch (err) { logger.warn(`[piece-classifier] LLM call failed: ${err}`); return null; } const validNames = pieces.map(p => p.name); const classified = parseClassificationResponse(result, validNames); if (classified) { logger.info(`[piece-classifier] classified piece=${classified} candidates=${validNames.length} textLen=${taskText.length}`); } else { logger.warn(`[piece-classifier] classification failed rawResponse="${result.slice(0, 100)}" validNames=[${validNames.join(', ')}]`); } return classified; }; return Promise.race([ llmCall(), new Promise((resolve) => setTimeout(() => { logger.warn(`[piece-classifier] LLM call timed out after ${timeoutMs}ms`); resolve(null); }, timeoutMs)), ]); }