maestro/src/engine/piece-classifier.ts
oss-sync 3b1645cc91
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (d31b280)
2026-06-11 11:28:40 +00:00

118 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)),
]);
}