272 lines
9.8 KiB
JavaScript
Executable File
272 lines
9.8 KiB
JavaScript
Executable File
#!/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;
|