progress-dashboard/scripts/generate-ai-suggestions.js
2026-04-03 19:28:30 +09:00

272 lines
9.8 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

#!/usr/bin/env node
import 'dotenv/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { STATUS_LABELS } from '../config/settings.js';
import { generateFallbackSuggestions } from './ai-suggestion-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function generateAISuggestions() {
console.log('🤖 Gemini APIでAI提案を生成中...');
// API Key確認
const apiKey = process.env.MAGAZINE_GEMINI_API_KEY;
let healthData;
try {
const healthDataPath = path.join(__dirname, '..', 'data', 'health-data.json');
healthData = JSON.parse(await fs.readFile(healthDataPath, 'utf-8'));
} catch (error) {
console.error('❌ 健康度データの読み込みに失敗しました:', error.message);
console.log('💡 先に npm run calculate-health を実行してください');
process.exit(1);
}
console.log('📊 健康度データ読み込み完了:', {
企画案ストック: healthData.planStockHealth?.stockCount ?? 0,
構成作成中: healthData.summary['2.構成作成中'] ?? 0,
原稿執筆中: healthData.summary['3.原稿執筆中'] ?? 0,
動画編集中: healthData.summary['4.動画編集中'] ?? 0,
原稿執筆健康度: healthData.manuscriptHealth?.status,
動画編集健康度: healthData.videoHealth?.status
});
let suggestions;
let source = 'gemini';
let modelName = null;
let fallbackReason = null;
if (!apiKey) {
console.warn('⚠️ MAGAZINE_GEMINI_API_KEY が設定されていません。フォールバック提案を生成します。');
suggestions = generateFallbackSuggestions(healthData);
source = 'fallback';
fallbackReason = 'APIキー未設定';
} else {
try {
const promptPath = path.join(__dirname, '..', 'config', 'ai-prompts', 'unified.md');
const promptTemplate = await fs.readFile(promptPath, 'utf-8');
const context = buildContext(healthData);
const prompt = promptTemplate.replace('{{CONTEXT}}', context);
console.log('📝 プロンプト生成完了(文字数:', prompt.length, '');
const genAI = new GoogleGenerativeAI(apiKey);
const preferredModel = process.env.MAGAZINE_GEMINI_MODEL;
const modelCandidates = [];
if (preferredModel) {
modelCandidates.push(preferredModel);
}
const defaultModels = [
'models/gemini-3-flash-preview',
'models/gemini-2.5-flash',
];
defaultModels.forEach(model => {
if (!modelCandidates.includes(model)) {
modelCandidates.push(model);
}
});
let model;
let lastError;
for (const candidate of modelCandidates) {
try {
modelName = candidate;
console.log(`📦 モデル候補を初期化: ${modelName}`);
model = genAI.getGenerativeModel({ model: modelName });
console.log('🚀 Gemini API にリクエスト送信中...');
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
console.log('✅ Geminiからレスポンスを受信文字数:', text.length, '');
const parsedSuggestions = parseAISuggestions(text);
if (!Array.isArray(parsedSuggestions) || parsedSuggestions.length !== 3) {
throw new Error(`AI提案の形式が不正です。3つの提案が必要ですが、${parsedSuggestions?.length ?? 0}個でした。`);
}
suggestions = parsedSuggestions;
lastError = null;
break;
} catch (error) {
console.warn(`⚠️ モデル ${modelName} の呼び出しに失敗しました: ${error.message}`);
lastError = error;
model = null;
}
}
if (!suggestions) {
throw lastError || new Error('利用可能なGeminiモデルでの生成に失敗しました');
}
} catch (error) {
console.error('⚠️ Gemini APIの呼び出しに失敗しました:', error.message);
if (error.stack) {
console.error('スタックトレース:', error.stack);
}
suggestions = generateFallbackSuggestions(healthData);
source = 'fallback';
fallbackReason = error.message;
}
}
if (!Array.isArray(suggestions) || suggestions.length !== 3) {
console.warn('⚠️ フォールバック生成の結果が不足していたため、デフォルト提案を再生成します。');
suggestions = generateFallbackSuggestions(healthData);
source = 'fallback';
fallbackReason = fallbackReason || 'フォールバック再生成';
}
const outputDir = path.join(__dirname, '..', 'data');
await fs.mkdir(outputDir, { recursive: true });
const outputData = {
generatedAt: new Date().toISOString(),
source,
model: source === 'gemini' ? modelName : null,
note: fallbackReason || undefined,
suggestions
};
const outputPath = path.join(outputDir, 'ai-suggestions.json');
await fs.writeFile(outputPath, JSON.stringify(outputData, null, 2), 'utf-8');
if (source === 'fallback') {
console.log('✅ フォールバック提案を保存しました:', outputPath);
if (fallbackReason) {
console.log(' フォールバック理由:', fallbackReason);
}
} else {
console.log('✅ Gemini生成のAI提案を保存しました:', outputPath);
}
console.log('\n🎯 生成されたAI提案:');
suggestions.forEach((suggestion, index) => {
console.log(`\n${index + 1}. ${suggestion.priorityLabel}`);
console.log(` 問題: ${suggestion.problem}`);
console.log(` アクション:\n${suggestion.action.split('\n').map(line => ` ${line}`).join('\n')}`);
});
console.log('');
return outputPath;
}
/**
* 健康度データから AI用のコンテキストを生成
*/
function buildContext(healthData) {
const lines = [];
const planStock = healthData.planStockHealth || {};
const composition = healthData.compositionHealth || {};
lines.push('### 企画案ストック');
lines.push(`- **ストック数**: ${planStock.stockCount ?? 0}`);
lines.push(`- **今週の新規追加**: ${planStock.weeklyNewCount ?? 0} / ${planStock.weeklyTarget ?? 2}`);
lines.push(`- **健康度**: ${planStock.status} ${planStock.label}`);
if (planStock.shortReason) {
lines.push(`- **詳細**: ${planStock.shortReason}`);
}
lines.push('');
lines.push('### 構成作成');
lines.push(`- **完了数**: ${composition.completedCount ?? 0} / ${composition.target ?? 3}`);
lines.push(`- **健康度**: ${composition.status} ${composition.label}`);
if (composition.shortReason) {
lines.push(`- **詳細**: ${composition.shortReason}`);
}
lines.push('');
lines.push('### 原稿執筆中のマガジン');
const manuscriptCount = healthData.summary?.['3.原稿執筆中'] || 0;
lines.push(`- **件数**: ${manuscriptCount}`);
lines.push(`- **健康度**: ${healthData.manuscriptHealth?.status} ${healthData.manuscriptHealth?.label}`);
if (healthData.manuscriptHealth?.details) {
lines.push(`- **詳細**: ${healthData.manuscriptHealth.details}`);
}
const manuscripts = healthData.magazines.filter(m =>
m.label === STATUS_LABELS.manuscript && m.state?.type !== 'completed'
);
if (manuscripts.length > 0) {
lines.push('- **マガジン一覧**:');
manuscripts.forEach(mag => {
const statusInfo = mag.displayHealthStatus?.message || '期限内';
lines.push(` - 【${mag.title}${mag.displayHealthStatus?.status || '🟢'} ${statusInfo}`);
});
}
lines.push('');
lines.push('### 動画編集中のマガジン');
const videoCount = healthData.summary?.['4.動画編集中'] || 0;
lines.push(`- **件数**: ${videoCount}`);
lines.push(`- **健康度**: ${healthData.videoHealth?.status} ${healthData.videoHealth?.label}`);
if (healthData.videoHealth?.details) {
lines.push(`- **詳細**: ${healthData.videoHealth.details}`);
}
const videos = healthData.magazines.filter(m =>
m.label === STATUS_LABELS.video && m.state?.type !== 'completed'
);
if (videos.length > 0) {
lines.push('- **マガジン一覧**:');
videos.forEach(mag => {
const statusInfo = mag.displayHealthStatus?.message || '期限内';
lines.push(` - 【${mag.title}${mag.displayHealthStatus?.status || '🟢'} ${statusInfo}`);
});
}
lines.push('');
lines.push('### その他の統計');
lines.push(`- **全マガジン数**: ${healthData.magazines.length}`);
lines.push(`- **生成日時**: ${new Date(healthData.calculatedAt).toLocaleString('ja-JP')}`);
return lines.join('\n');
}
/**
* Geminiのレスポンスから JSON を抽出してパース
*/
function parseAISuggestions(text) {
// コードブロックを除去
let jsonText = text.trim();
// ```json ... ``` または ``` ... ``` を除去
jsonText = jsonText.replace(/^```json?\s*\n?/gm, '');
jsonText = jsonText.replace(/\n?```\s*$/gm, '');
// 余計な前後のテキストを除去JSON配列の開始/終了を見つける)
const jsonStart = jsonText.indexOf('[');
const jsonEnd = jsonText.lastIndexOf(']');
if (jsonStart === -1 || jsonEnd === -1) {
throw new Error('JSONが見つかりませんでした。レスポンス: ' + text.substring(0, 500));
}
jsonText = jsonText.substring(jsonStart, jsonEnd + 1);
try {
const parsed = JSON.parse(jsonText);
return parsed;
} catch (error) {
console.error('JSON パースエラー:', error.message);
console.error('パース対象のテキスト:', jsonText.substring(0, 1000));
throw new Error('JSONのパースに失敗しました: ' + error.message);
}
}
// 実行
if (import.meta.url === `file://${process.argv[1]}`) {
generateAISuggestions();
}
export default generateAISuggestions;