with('creator:id,name') ->orderBy('created_at', 'desc'); if (! empty($params['status'])) { $query->where('status', $params['status']); } if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('input_text', 'like', "%{$search}%"); }); } return $query->paginate($params['per_page'] ?? 10); } /** * 상세 조회 */ public function getById(int $id): ?AiQuotation { return AiQuotation::with(['items', 'creator:id,name'])->find($id); } /** * 견적 요청 생성 + AI 분석 실행 */ public function createAndAnalyze(array $data): array { $provider = $data['ai_provider'] ?? 'gemini'; $quotation = AiQuotation::create([ 'tenant_id' => session('selected_tenant_id', 1), 'title' => $data['title'], 'input_type' => $data['input_type'] ?? 'text', 'input_text' => $data['input_text'] ?? null, 'ai_provider' => $provider, 'status' => AiQuotation::STATUS_PENDING, 'created_by' => Auth::id(), ]); return $this->runAnalysis($quotation); } /** * AI 분석 실행 (재분석 가능) */ public function runAnalysis(AiQuotation $quotation): array { try { $quotation->update(['status' => AiQuotation::STATUS_PROCESSING]); $provider = $quotation->ai_provider; $config = AiConfig::getActive($provider); if (! $config) { throw new \RuntimeException("{$provider} API 설정이 없습니다."); } // 1차 호출: 업무 분석 $modules = AiQuotationModule::getActiveModulesForPrompt(); $analysisPrompt = $this->buildAnalysisPrompt($quotation->input_text, $modules); $analysisRaw = $this->callAi($config, $provider, $analysisPrompt, 'AI견적-업무분석'); $analysisResult = $this->parseJsonResponse($analysisRaw); if (! $analysisResult) { throw new \RuntimeException('AI 업무 분석 결과 파싱 실패'); } $quotation->update(['analysis_result' => $analysisResult]); // 2차 호출: 견적 생성 $quotationPrompt = $this->buildQuotationPrompt($analysisResult, $modules); $quotationRaw = $this->callAi($config, $provider, $quotationPrompt, 'AI견적-견적생성'); $quotationResult = $this->parseJsonResponse($quotationRaw); if (! $quotationResult) { throw new \RuntimeException('AI 견적 생성 결과 파싱 실패'); } // 추천 모듈 아이템 저장 $this->saveQuotationItems($quotation, $quotationResult, $modules); // 합계 계산 $totals = $quotation->items()->selectRaw( 'SUM(dev_cost) as total_dev, SUM(monthly_fee) as total_monthly' )->first(); $quotation->update([ 'quotation_result' => $quotationResult, 'ai_model' => $config->model, 'total_dev_cost' => $totals->total_dev ?? 0, 'total_monthly_fee' => $totals->total_monthly ?? 0, 'status' => AiQuotation::STATUS_COMPLETED, ]); return [ 'ok' => true, 'quotation' => $quotation->fresh(['items', 'creator:id,name']), ]; } catch (\Exception $e) { Log::error('AI 견적 분석 실패', [ 'quotation_id' => $quotation->id, 'error' => $e->getMessage(), ]); $quotation->update(['status' => AiQuotation::STATUS_FAILED]); return [ 'ok' => false, 'error' => $e->getMessage(), 'quotation' => $quotation->fresh(), ]; } } /** * AI API 호출 (Gemini / Claude) */ private function callAi(AiConfig $config, string $provider, string $prompt, string $menuName): ?string { return match ($provider) { 'gemini' => $this->callGemini($config, $prompt, $menuName), 'claude' => $this->callClaude($config, $prompt, $menuName), default => throw new \RuntimeException("지원하지 않는 AI Provider: {$provider}"), }; } /** * Gemini API 호출 */ private function callGemini(AiConfig $config, string $prompt, string $menuName): ?string { $model = $config->model; $apiKey = $config->api_key; $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; $response = Http::timeout(120) ->withHeaders(['Content-Type' => 'application/json']) ->post($url, [ 'contents' => [ ['parts' => [['text' => $prompt]]], ], 'generationConfig' => [ 'temperature' => 0.3, 'maxOutputTokens' => 8192, 'responseMimeType' => 'application/json', ], ]); if (! $response->successful()) { Log::error('Gemini API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException('Gemini API 호출 실패: '.$response->status()); } $result = $response->json(); AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? $model, $menuName); return $result['candidates'][0]['content']['parts'][0]['text'] ?? null; } /** * Claude API 호출 */ private function callClaude(AiConfig $config, string $prompt, string $menuName): ?string { $response = Http::timeout(120) ->withHeaders([ 'x-api-key' => $config->api_key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ]) ->post($config->base_url.'/messages', [ 'model' => $config->model, 'max_tokens' => 8192, 'temperature' => 0.3, 'messages' => [ ['role' => 'user', 'content' => $prompt], ], ]); if (! $response->successful()) { Log::error('Claude API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException('Claude API 호출 실패: '.$response->status()); } $result = $response->json(); AiTokenHelper::saveClaudeUsage($result, $config->model, $menuName); return $result['content'][0]['text'] ?? null; } /** * 1차 프롬프트: 업무 분석 */ private function buildAnalysisPrompt(string $interviewText, array $modules): string { $modulesJson = json_encode($modules, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return << json_last_error_msg(), 'response_preview' => mb_substr($response, 0, 500), ]); return null; } return $decoded; } /** * AI 견적 결과를 아이템으로 저장 */ private function saveQuotationItems(AiQuotation $quotation, array $quotationResult, array $modules): void { // 기존 아이템 삭제 (재분석 시) $quotation->items()->delete(); $items = $quotationResult['items'] ?? []; $moduleMap = collect($modules)->keyBy('module_code'); foreach ($items as $index => $item) { $moduleCode = $item['module_code'] ?? ''; $catalogModule = $moduleMap->get($moduleCode); // 카탈로그에 있는 모듈이면 DB의 가격 사용 (AI hallucination 방지) $devCost = $catalogModule ? $catalogModule['dev_cost'] : ($item['dev_cost'] ?? 0); $monthlyFee = $catalogModule ? $catalogModule['monthly_fee'] : ($item['monthly_fee'] ?? 0); // DB에서 module_id 조회 $dbModule = AiQuotationModule::where('module_code', $moduleCode)->first(); AiQuotationItem::create([ 'ai_quotation_id' => $quotation->id, 'module_id' => $dbModule?->id, 'module_code' => $moduleCode, 'module_name' => $item['module_name'] ?? $moduleCode, 'is_required' => $item['is_required'] ?? false, 'reason' => $item['reason'] ?? null, 'dev_cost' => $devCost, 'monthly_fee' => $monthlyFee, 'sort_order' => $index, ]); } } /** * 대시보드 통계 */ public function getDashboardStats(): array { $stats = AiQuotation::query() ->selectRaw(" COUNT(*) as total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending ") ->first(); $recent = AiQuotation::with('creator:id,name') ->orderBy('created_at', 'desc') ->limit(5) ->get(); return [ 'stats' => [ 'total' => (int) $stats->total, 'completed' => (int) $stats->completed, 'processing' => (int) $stats->processing, 'failed' => (int) $stats->failed, 'pending' => (int) $stats->pending, ], 'recent' => $recent, ]; } }