Files
sam-manage/app/Services/Sales/AiInterviewService.php

739 lines
27 KiB
PHP

<?php
namespace App\Services\Sales;
use App\Helpers\AiTokenHelper;
use App\Models\Interview\InterviewAiConversation;
use App\Models\Interview\InterviewAnswer;
use App\Models\Interview\InterviewCategory;
use App\Models\Interview\InterviewKnowledge;
use App\Models\Interview\InterviewQuestion;
use App\Models\Interview\InterviewSession;
use App\Models\Interview\InterviewTemplate;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AiInterviewService
{
// ============================================================
// AI 세션 시작
// ============================================================
/**
* AI 가이드 인터뷰 세션 시작
*
* @param int $projectId 프로젝트 ID
* @param string $domain 인터뷰 도메인 (예: 'production', 'inventory')
* @return array session 정보 + AI 첫 메시지
*/
public function startAiSession(int $projectId, string $domain): array
{
$tenantId = session('selected_tenant_id', 1);
// 해당 도메인의 카테고리와 질문 로드
$category = InterviewCategory::where('interview_project_id', $projectId)
->where('domain', $domain)
->with(['templates' => function ($q) {
$q->where('is_active', true)
->orderBy('sort_order')
->with(['questions' => function ($q2) {
$q2->where('is_active', true)->orderBy('sort_order');
}]);
}])
->first();
if (! $category) {
// 도메인 매칭 카테고리가 없으면 이름으로 검색
$category = InterviewCategory::where('interview_project_id', $projectId)
->where('name', 'like', "%{$domain}%")
->with(['templates' => function ($q) {
$q->where('is_active', true)
->orderBy('sort_order')
->with(['questions' => function ($q2) {
$q2->where('is_active', true)->orderBy('sort_order');
}]);
}])
->first();
}
// 카테고리의 총 질문 수 계산
$totalQuestions = 0;
$allQuestions = [];
if ($category) {
foreach ($category->templates as $template) {
foreach ($template->questions as $question) {
$allQuestions[] = $question;
$totalQuestions++;
}
}
}
// AI 가이드 세션 생성
$session = DB::transaction(function () use ($tenantId, $projectId, $category, $totalQuestions) {
return InterviewSession::create([
'tenant_id' => $tenantId,
'interview_project_id' => $projectId,
'interview_category_id' => $category?->id,
'interviewer_id' => Auth::id(),
'interview_date' => now()->toDateString(),
'status' => 'in_progress',
'session_type' => 'ai_guided',
'total_questions' => $totalQuestions,
'answered_questions' => 0,
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
]);
});
// 시스템 프롬프트 생성 및 Gemini 호출
$systemPrompt = $this->buildSystemPrompt($domain, $category, $allQuestions);
$firstPrompt = $systemPrompt."\n\n지금 인터뷰를 시작해주세요. 자기소개와 함께 첫 번째 질문을 해주세요.";
$result = $this->callGemini([
['role' => 'user', 'parts' => [['text' => $firstPrompt]]],
]);
$aiMessage = $result['text'] ?? '안녕하세요! 인터뷰를 시작하겠습니다.';
$structuredData = $result['structured_data'] ?? null;
$tokensUsed = $result['tokens_used'] ?? 0;
$modelUsed = $result['model_used'] ?? '';
// 시스템 프롬프트 저장 (role: system)
InterviewAiConversation::create([
'tenant_id' => $tenantId,
'interview_session_id' => $session->id,
'interview_project_id' => $projectId,
'role' => 'system',
'content' => $systemPrompt,
'domain' => $domain,
'tokens_used' => 0,
'model_used' => $modelUsed,
]);
// AI 첫 응답 저장
InterviewAiConversation::create([
'tenant_id' => $tenantId,
'interview_session_id' => $session->id,
'interview_project_id' => $projectId,
'role' => 'assistant',
'content' => $aiMessage,
'structured_data' => $structuredData,
'domain' => $domain,
'tokens_used' => $tokensUsed,
'model_used' => $modelUsed,
]);
return [
'session' => $session->fresh(),
'ai_message' => $aiMessage,
'structured_data' => $structuredData,
'domain' => $domain,
'total_questions' => $totalQuestions,
];
}
// ============================================================
// 메시지 전송 및 AI 응답
// ============================================================
/**
* 사용자 메시지 전송 및 AI 응답 수신
*
* @param int $sessionId 세션 ID
* @param string $message 사용자 메시지
* @param string|null $domain 도메인 (null이면 세션에서 조회)
* @return array AI 응답 + 추출된 데이터 + 진행률
*/
public function sendMessage(int $sessionId, string $message, ?string $domain = null): array
{
$tenantId = session('selected_tenant_id', 1);
$session = InterviewSession::findOrFail($sessionId);
$projectId = $session->interview_project_id;
// 도메인 결정
if (! $domain) {
$lastConv = InterviewAiConversation::where('interview_session_id', $sessionId)
->whereNotNull('domain')
->latest('id')
->first();
$domain = $lastConv?->domain ?? '';
}
// 사용자 메시지 저장
InterviewAiConversation::create([
'tenant_id' => $tenantId,
'interview_session_id' => $sessionId,
'interview_project_id' => $projectId,
'role' => 'user',
'content' => $message,
'domain' => $domain,
'tokens_used' => 0,
'model_used' => null,
]);
// 이전 대화 히스토리 로드 (system 제외, user/assistant만)
$history = $this->buildConversationHistory($sessionId);
// Gemini API 호출
$result = $this->callGemini($history);
$aiMessage = $result['text'] ?? '';
$structuredData = $result['structured_data'] ?? null;
$coveredQuestions = $result['covered_questions'] ?? [];
$coveragePercent = $result['coverage_percent'] ?? 0;
$tokensUsed = $result['tokens_used'] ?? 0;
$modelUsed = $result['model_used'] ?? '';
// AI 응답 저장
InterviewAiConversation::create([
'tenant_id' => $tenantId,
'interview_session_id' => $sessionId,
'interview_project_id' => $projectId,
'role' => 'assistant',
'content' => $aiMessage,
'structured_data' => $structuredData,
'domain' => $domain,
'tokens_used' => $tokensUsed,
'model_used' => $modelUsed,
]);
// 구조화된 답변 자동 저장
if (! empty($extractedData = $result['extracted_data'] ?? null)) {
$this->saveExtractedAnswers($session, $domain, $extractedData, $coveredQuestions);
}
// 세션 진행률 갱신
$answeredCount = InterviewAnswer::where('interview_session_id', $sessionId)
->where('is_checked', true)
->count();
$session->update([
'answered_questions' => $answeredCount,
'updated_by' => Auth::id(),
]);
return [
'ai_message' => $aiMessage,
'extracted_data' => $structuredData,
'covered_questions' => $coveredQuestions,
'coverage_percent' => $coveragePercent,
'answered_questions' => $answeredCount,
'total_questions' => $session->total_questions,
];
}
// ============================================================
// 대화 히스토리 조회
// ============================================================
/**
* 세션의 전체 대화 기록 조회
*
* @param int $sessionId 세션 ID
* @return array 대화 기록 목록
*/
public function getConversationHistory(int $sessionId): array
{
$conversations = InterviewAiConversation::where('interview_session_id', $sessionId)
->orderBy('id')
->get();
return $conversations->map(function ($conv) {
return [
'id' => $conv->id,
'role' => $conv->role,
'content' => $conv->content,
'structured_data' => $conv->structured_data,
'domain' => $conv->domain,
'tokens_used' => $conv->tokens_used,
'model_used' => $conv->model_used,
'created_at' => $conv->created_at?->toIso8601String(),
];
})->toArray();
}
// ============================================================
// 진행률 분석
// ============================================================
/**
* 도메인별 질문 커버리지 분석
*
* @param int $sessionId 세션 ID
* @return array 도메인별 커버리지 + 누락 질문 + 완료도 백분율
*/
public function analyzeProgress(int $sessionId): array
{
$session = InterviewSession::findOrFail($sessionId);
$projectId = $session->interview_project_id;
// 프로젝트의 모든 카테고리/질문 로드
$categories = InterviewCategory::where('interview_project_id', $projectId)
->with(['templates' => function ($q) {
$q->where('is_active', true)
->with(['questions' => function ($q2) {
$q2->where('is_active', true)->orderBy('sort_order');
}]);
}])
->get();
// 세션의 체크된 답변 질문 ID 목록
$answeredQuestionIds = InterviewAnswer::where('interview_session_id', $sessionId)
->where('is_checked', true)
->pluck('interview_question_id')
->toArray();
// 대화에서 언급된 도메인 목록
$coveredDomains = InterviewAiConversation::where('interview_session_id', $sessionId)
->whereNotNull('domain')
->distinct()
->pluck('domain')
->toArray();
$domainAnalysis = [];
$totalQuestions = 0;
$totalAnswered = 0;
$missingQuestions = [];
foreach ($categories as $category) {
$categoryQuestions = [];
$categoryAnswered = 0;
foreach ($category->templates as $template) {
foreach ($template->questions as $question) {
$isAnswered = in_array($question->id, $answeredQuestionIds);
$categoryQuestions[] = [
'id' => $question->id,
'text' => $question->question_text,
'is_required' => $question->is_required,
'is_answered' => $isAnswered,
];
if ($isAnswered) {
$categoryAnswered++;
} elseif ($question->is_required) {
$missingQuestions[] = [
'category' => $category->name,
'domain' => $category->domain,
'question' => $question->question_text,
'template' => $template->name,
];
}
}
}
$categoryTotal = count($categoryQuestions);
$totalQuestions += $categoryTotal;
$totalAnswered += $categoryAnswered;
if ($categoryTotal > 0) {
$domainAnalysis[] = [
'category_id' => $category->id,
'category_name' => $category->name,
'domain' => $category->domain,
'total_questions' => $categoryTotal,
'answered_questions' => $categoryAnswered,
'coverage_percent' => (int) round(($categoryAnswered / $categoryTotal) * 100),
'is_covered_in_conversation' => in_array($category->domain, $coveredDomains),
'questions' => $categoryQuestions,
];
}
}
$overallPercent = $totalQuestions > 0
? (int) round(($totalAnswered / $totalQuestions) * 100)
: 0;
return [
'overall_coverage_percent' => $overallPercent,
'total_questions' => $totalQuestions,
'total_answered' => $totalAnswered,
'domain_analysis' => $domainAnalysis,
'missing_required_questions' => $missingQuestions,
];
}
// ============================================================
// 지식 추출
// ============================================================
/**
* 전체 대화에서 지식 추출 후 interview_knowledge 저장
*
* @param int $sessionId 세션 ID
* @return array 추출된 지식 목록
*/
public function extractKnowledge(int $sessionId): array
{
$session = InterviewSession::findOrFail($sessionId);
$projectId = $session->interview_project_id;
$tenantId = session('selected_tenant_id', 1);
// 전체 대화 히스토리 (user + assistant만)
$conversations = InterviewAiConversation::where('interview_session_id', $sessionId)
->whereIn('role', ['user', 'assistant'])
->orderBy('id')
->get();
if ($conversations->isEmpty()) {
return ['knowledge' => [], 'count' => 0];
}
// 대화 텍스트 구성
$conversationText = $conversations->map(function ($conv) {
$roleLabel = $conv->role === 'user' ? '고객' : 'AI';
return "[{$roleLabel}]: {$conv->content}";
})->implode("\n\n");
// 지식 추출 프롬프트
$extractPrompt = $this->buildKnowledgeExtractionPrompt($conversationText);
$result = $this->callGemini([
['role' => 'user', 'parts' => [['text' => $extractPrompt]]],
]);
$knowledgeList = $result['extracted_data']['knowledge_list'] ?? [];
$savedKnowledge = [];
foreach ($knowledgeList as $item) {
try {
$knowledge = InterviewKnowledge::create([
'tenant_id' => $tenantId,
'interview_project_id' => $projectId,
'domain' => $item['domain'] ?? 'general',
'knowledge_type' => $item['knowledge_type'] ?? 'process',
'title' => $item['title'] ?? '미분류 지식',
'content' => $item['content'] ?? [],
'source_type' => 'ai_interview',
'source_id' => $sessionId,
'confidence' => $item['confidence'] ?? 0.80,
'is_verified' => false,
'created_by' => Auth::id(),
]);
$savedKnowledge[] = $knowledge;
} catch (\Exception $e) {
Log::warning('지식 저장 실패', [
'session_id' => $sessionId,
'item' => $item,
'error' => $e->getMessage(),
]);
}
}
return [
'knowledge' => $savedKnowledge,
'count' => count($savedKnowledge),
];
}
// ============================================================
// Private: Gemini API 호출
// ============================================================
/**
* Gemini API 호출 (대화형 멀티턴)
*
* @param array $contents 대화 contents 배열 (role + parts 구조)
* @return array text, structured_data, extracted_data, covered_questions, coverage_percent, tokens_used, model_used
*/
private function callGemini(array $contents): array
{
$apiKey = config('services.gemini.api_key');
$model = config('services.gemini.model', 'gemini-2.5-flash');
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
try {
$response = Http::timeout(120)
->withHeaders(['Content-Type' => 'application/json'])
->post($url, [
'contents' => $contents,
'generationConfig' => [
'temperature' => 0.7,
'maxOutputTokens' => 8192,
],
]);
if (! $response->successful()) {
Log::error('Gemini API 오류', [
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \RuntimeException('Gemini API 호출 실패: '.$response->status());
}
$apiResult = $response->json();
// 토큰 사용량 기록
AiTokenHelper::saveGeminiUsage(
$apiResult,
$apiResult['modelVersion'] ?? $model,
'AI인터뷰'
);
$rawText = $apiResult['candidates'][0]['content']['parts'][0]['text'] ?? '';
$tokensUsed = $apiResult['usageMetadata']['totalTokenCount'] ?? 0;
$modelUsed = $apiResult['modelVersion'] ?? $model;
// JSON 블록 파싱 (```json ... ``` 사이)
$structuredData = $this->parseJsonBlock($rawText);
// AI 응답 텍스트에서 JSON 블록 제거
$cleanText = preg_replace('/```json[\s\S]*?```/i', '', $rawText);
$cleanText = trim($cleanText);
return [
'text' => $cleanText ?: $rawText,
'structured_data' => $structuredData,
'extracted_data' => $structuredData['extracted_data'] ?? null,
'covered_questions' => $structuredData['covered_questions'] ?? [],
'coverage_percent' => $structuredData['coverage_percent'] ?? 0,
'tokens_used' => $tokensUsed,
'model_used' => $modelUsed,
];
} catch (\Exception $e) {
Log::error('Gemini 호출 예외', [
'error' => $e->getMessage(),
]);
return [
'text' => 'AI 응답 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
'structured_data' => null,
'extracted_data' => null,
'covered_questions' => [],
'coverage_percent' => 0,
'tokens_used' => 0,
'model_used' => $model,
];
}
}
// ============================================================
// Private: 프롬프트 생성
// ============================================================
/**
* 시스템 프롬프트 생성
*/
private function buildSystemPrompt(string $domain, ?InterviewCategory $category, array $questions): string
{
$domainName = $category?->name ?? $domain;
$domainDescription = $category?->description ?? "{$domain} 도메인 관련 업무 파악";
$questionList = '';
foreach ($questions as $index => $question) {
$hint = $question->ai_hint ? " (힌트: {$question->ai_hint})" : '';
$required = $question->is_required ? ' [필수]' : '';
$type = $question->question_type ?? 'text';
$questionList .= ($index + 1).". [{$type}]{$required} {$question->question_text}{$hint}\n";
}
if (empty($questionList)) {
$questionList = "- 해당 도메인의 전반적인 업무 프로세스와 요구사항을 파악합니다.\n";
}
return <<<PROMPT
당신은 제조업 ERP/MES 시스템 구축을 위한 전문 인터뷰어입니다.
현재 도메인: {$domainName}
수집 목표: {$domainDescription}
아래는 이 도메인에서 수집해야 할 핵심 질문 목록입니다:
{$questionList}
지침:
1. 자연스러운 대화체로 한 번에 1-2개 질문을 합니다
2. 답변이 불충분하면 구체적인 예시를 요청합니다
3. 답변에서 구조화 가능한 데이터를 발견하면 JSON으로 추출합니다
4. 모든 핵심 질문이 커버될 때까지 대화를 이어갑니다
5. 응답 마지막에 반드시 아래 형식의 JSON 블록을 포함합니다:
```json
{
"extracted_data": {},
"covered_questions": [],
"coverage_percent": 0,
"next_focus": "다음에 물어볼 영역"
}
```
PROMPT;
}
/**
* 지식 추출 프롬프트 생성
*/
private function buildKnowledgeExtractionPrompt(string $conversationText): string
{
return <<<PROMPT
다음은 제조업 ERP/MES 구축을 위한 고객 인터뷰 대화 내용입니다.
이 대화에서 구조화된 지식 항목을 추출해주세요.
[대화 내용]
{$conversationText}
각 지식 항목은 다음 형식으로 추출하세요:
- domain: 해당 도메인 (예: production, inventory, sales, quality, hr, finance, general)
- knowledge_type: 지식 유형 (process, rule, constraint, requirement, custom)
- title: 지식 제목 (간결하게)
- content: 상세 내용 (JSON 객체, key-value 구조)
- confidence: 신뢰도 (0.0~1.0)
응답은 반드시 아래 JSON 형식으로만 반환하세요:
```json
{
"extracted_data": {
"knowledge_list": [
{
"domain": "production",
"knowledge_type": "process",
"title": "생산 계획 수립 주기",
"content": {
"summary": "주 단위로 생산 계획 수립",
"details": "매주 월요일 생산팀 회의에서 확정"
},
"confidence": 0.90
}
]
},
"covered_questions": [],
"coverage_percent": 100,
"next_focus": "완료"
}
```
PROMPT;
}
// ============================================================
// Private: 대화 히스토리 구성
// ============================================================
/**
* Gemini 멀티턴 대화를 위한 contents 배열 구성
*/
private function buildConversationHistory(int $sessionId): array
{
$conversations = InterviewAiConversation::where('interview_session_id', $sessionId)
->whereIn('role', ['system', 'user', 'assistant'])
->orderBy('id')
->get();
$contents = [];
foreach ($conversations as $conv) {
if ($conv->role === 'system') {
// 시스템 프롬프트는 첫 user 메시지에 합산
$systemText = $conv->content;
$contents[] = [
'role' => 'user',
'parts' => [['text' => $systemText]],
];
// 시스템 다음에 assistant 더미 응답 삽입 (Gemini 멀티턴 규칙: user/model 교대)
$contents[] = [
'role' => 'model',
'parts' => [['text' => '네, 이해했습니다. 인터뷰를 시작하겠습니다.']],
];
} elseif ($conv->role === 'user') {
$contents[] = [
'role' => 'user',
'parts' => [['text' => $conv->content]],
];
} elseif ($conv->role === 'assistant') {
$contents[] = [
'role' => 'model',
'parts' => [['text' => $conv->content]],
];
}
}
return $contents;
}
// ============================================================
// Private: JSON 블록 파싱
// ============================================================
/**
* 응답 텍스트에서 ```json ... ``` 블록 파싱
*/
private function parseJsonBlock(string $text): ?array
{
if (preg_match('/```json\s*([\s\S]*?)\s*```/i', $text, $matches)) {
$jsonString = trim($matches[1]);
$decoded = json_decode($jsonString, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
Log::warning('AI 응답 JSON 파싱 실패', [
'json_error' => json_last_error_msg(),
'raw' => $jsonString,
]);
}
return null;
}
// ============================================================
// Private: 추출된 답변 자동 저장
// ============================================================
/**
* AI가 추출한 데이터를 interview_answers에 자동 저장
*/
private function saveExtractedAnswers(
InterviewSession $session,
string $domain,
array $extractedData,
array $coveredQuestionIndexes
): void {
if (empty($coveredQuestionIndexes) || ! $session->interview_category_id) {
return;
}
// 도메인 카테고리의 질문 목록
$questions = InterviewQuestion::whereHas('template', function ($q) use ($session) {
$q->whereHas('category', function ($q2) use ($session) {
$q2->where('id', $session->interview_category_id);
});
})
->where('is_active', true)
->orderBy('sort_order')
->get();
foreach ($coveredQuestionIndexes as $questionIndex) {
$idx = (int) $questionIndex - 1; // 1-based → 0-based
if (! isset($questions[$idx])) {
continue;
}
$question = $questions[$idx];
// 기존 답변 레코드 조회 또는 생성
$answer = InterviewAnswer::where('interview_session_id', $session->id)
->where('interview_question_id', $question->id)
->first();
if ($answer) {
$answer->update([
'is_checked' => true,
'answer_data' => $extractedData,
]);
} else {
InterviewAnswer::create([
'tenant_id' => $session->tenant_id,
'interview_session_id' => $session->id,
'interview_question_id' => $question->id,
'interview_template_id' => $question->interview_template_id,
'is_checked' => true,
'answer_data' => $extractedData,
]);
}
}
}
}