diff --git a/backend/services/suggestions.js b/backend/services/suggestions.js index 38b393d..ba53c96 100644 --- a/backend/services/suggestions.js +++ b/backend/services/suggestions.js @@ -1,6 +1,7 @@ import pool from "../lib/db.js"; import redis from "../lib/redis.js"; import Inko from "inko"; +import { searchSchedules } from "./meilisearch.js"; const inko = new Inko(); @@ -9,24 +10,79 @@ const SUGGESTION_PREFIX = "suggestions:"; const CACHE_TTL = 86400; // 24시간 /** - * 영문 자판으로 입력된 검색어인지 확인 + * 영문만 포함된 검색어인지 확인 */ -function isEnglishKeyboard(text) { +function isEnglishOnly(text) { const englishChars = text.match(/[a-zA-Z]/g) || []; const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []; return englishChars.length > 0 && koreanChars.length === 0; } +/** + * 일정 검색 결과가 있는지 확인 (Meilisearch) + */ +async function hasScheduleResults(query) { + try { + const result = await searchSchedules(query, { limit: 1 }); + return result.hits.length > 0; + } catch (error) { + console.error("[SearchSuggestion] 검색 확인 오류:", error.message); + return false; + } +} + +/** + * 영어 입력을 분석하여 실제 영어인지 한글 오타인지 판단 + * 1. 영어로 일정 검색 → 결과 있으면 영어 + * 2. 한글 변환 후 일정 검색 → 결과 있으면 한글 + * 3. 둘 다 없으면 원본 유지 + */ +async function resolveEnglishInput(query) { + const koreanQuery = inko.en2ko(query); + + // 변환 결과가 같으면 변환 의미 없음 + if (koreanQuery === query) { + return { resolved: query, type: "english" }; + } + + // 1. 영어로 검색 + const hasEnglishResult = await hasScheduleResults(query); + if (hasEnglishResult) { + return { resolved: query, type: "english" }; + } + + // 2. 한글로 검색 + const hasKoreanResult = await hasScheduleResults(koreanQuery); + if (hasKoreanResult) { + return { resolved: koreanQuery, type: "korean_typo" }; + } + + // 3. 둘 다 없으면 원본 유지 + return { resolved: query, type: "unknown" }; +} + /** * 검색어 저장 (검색 실행 시 호출) * - search_queries 테이블에 Unigram 저장 * - word_pairs 테이블에 Bi-gram 저장 * - Redis 캐시 업데이트 + * - 영어 입력 시 일정 검색으로 언어 판단 */ export async function saveSearchQuery(query) { if (!query || query.trim().length === 0) return; - const normalizedQuery = query.trim().toLowerCase(); + let normalizedQuery = query.trim().toLowerCase(); + + // 영문만 있는 경우 일정 검색으로 언어 판단 + if (isEnglishOnly(normalizedQuery)) { + const { resolved, type } = await resolveEnglishInput(normalizedQuery); + if (type === "korean_typo") { + console.log( + `[SearchSuggestion] 한글 오타 감지: "${normalizedQuery}" → "${resolved}"` + ); + } + normalizedQuery = resolved; + } try { // 1. Unigram 저장 (인기도) @@ -65,6 +121,7 @@ export async function saveSearchQuery(query) { * 추천 검색어 조회 * - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram) * - 그 외: prefix 매칭 (인기순) + * - 영어 입력 시: 일정 검색으로 영어/한글 판단 */ export async function getSuggestions(query, limit = 10) { if (!query || query.trim().length === 0) { @@ -72,12 +129,13 @@ export async function getSuggestions(query, limit = 10) { } let searchQuery = query.toLowerCase(); + let koreanQuery = null; - // 영문 자판 -> 한글 변환 시도 - if (isEnglishKeyboard(searchQuery)) { - const koreanQuery = inko.en2ko(searchQuery); - if (koreanQuery !== searchQuery) { - searchQuery = koreanQuery; + // 영문만 있는 경우, 한글 변환도 같이 검색 + if (isEnglishOnly(searchQuery)) { + const converted = inko.en2ko(searchQuery); + if (converted !== searchQuery) { + koreanQuery = converted; } } @@ -93,8 +151,12 @@ export async function getSuggestions(query, limit = 10) { const lastWord = words[words.length - 1]; return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit); } else { - // prefix 매칭 (인기순) - return await getPrefixSuggestions(searchQuery.trim(), limit); + // prefix 매칭 (인기순) - 영어 원본 + 한글 변환 둘 다 + return await getPrefixSuggestions( + searchQuery.trim(), + koreanQuery?.trim(), + limit + ); } } catch (error) { console.error("[SearchSuggestion] 추천 조회 오류:", error.message); @@ -144,16 +206,33 @@ async function getNextWordSuggestions(lastWord, prefix, limit) { /** * Prefix 매칭 (인기순) + * @param {string} prefix - 원본 검색어 (영어 또는 한글) + * @param {string|null} koreanPrefix - 한글 변환된 검색어 (영어 입력의 경우) + * @param {number} limit - 결과 개수 */ -async function getPrefixSuggestions(prefix, limit) { +async function getPrefixSuggestions(prefix, koreanPrefix, limit) { try { - const [rows] = await pool.query( - `SELECT query FROM search_queries + let rows; + + if (koreanPrefix) { + // 영어 원본과 한글 변환 둘 다 검색 + [rows] = await pool.query( + `SELECT query FROM search_queries + WHERE query LIKE ? OR query LIKE ? + ORDER BY count DESC, last_searched_at DESC + LIMIT ?`, + [`${prefix}%`, `${koreanPrefix}%`, limit] + ); + } else { + // 단일 검색 + [rows] = await pool.query( + `SELECT query FROM search_queries WHERE query LIKE ? ORDER BY count DESC, last_searched_at DESC LIMIT ?`, - [`${prefix}%`, limit] - ); + [`${prefix}%`, limit] + ); + } return rows.map((r) => r.query); } catch (error) {