118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
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(/<think>[\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<string | null> {
|
||
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<string | null> => {
|
||
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<null>((resolve) => setTimeout(() => {
|
||
logger.warn(`[piece-classifier] LLM call timed out after ${timeoutMs}ms`);
|
||
resolve(null);
|
||
}, timeoutMs)),
|
||
]);
|
||
}
|