fix(#2): TextToSqlService 태그 존재 확인 시 예외 처리 수정 (false 반환)
This commit is contained in:
@@ -10,204 +10,383 @@ namespace ExperionCrawler.Core.Application.Services;
|
||||
/// <summary>
|
||||
/// Text-to-SQL 시계열 쿼리 서비스
|
||||
/// 자연어 질의를 파싱하여 iiot-timescaledb(IIoT 플랫폼) SQL 쿼리를 생성하고 실행합니다.
|
||||
/// measurements (TimeScaleDB 하이퍼테이블) + opc_nodes 테이블을 참조합니다.
|
||||
/// history_table (recorded_at: TIMESTAMPTZ, tagname: TEXT, value: TEXT) + node_map_master 테이블을 참조합니다.
|
||||
/// </summary>
|
||||
public class TextToSqlService : ITextToSqlService
|
||||
{
|
||||
private readonly ILogger<TextToSqlService> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly string _connectionString;
|
||||
private readonly KstClock _kstClock;
|
||||
private readonly KoreanTimeRangeExtractor _timeExtractor;
|
||||
private readonly SqlValidator _validator;
|
||||
|
||||
public TextToSqlService(ILogger<TextToSqlService> logger, IConfiguration configuration)
|
||||
public TextToSqlService(
|
||||
ILogger<TextToSqlService> logger,
|
||||
IConfiguration configuration,
|
||||
KstClock? kstClock = null,
|
||||
KoreanTimeRangeExtractor? timeExtractor = null,
|
||||
SqlValidator? validator = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||
_kstClock = kstClock ?? new KstClock(new SystemClock());
|
||||
_timeExtractor = timeExtractor ?? new KoreanTimeRangeExtractor(_kstClock);
|
||||
_validator = validator ?? new SqlValidator();
|
||||
}
|
||||
|
||||
// ── 테이블명 설정 (iiot-timescaledb 매핑) ─────────────────────────────────
|
||||
// iiot-timescaledb: measurements (node_id, time, value, quality)
|
||||
// ExperionCrawler: history_hypertable (tagname, recorded_at, value)
|
||||
private string HistoryTable => "measurements";
|
||||
private string NodesTable => "opc_nodes";
|
||||
// ── 테이블명 설정 (iiot-timescaledb 스키마) ─────────────────────────────────
|
||||
// history_table: node_id(TEXT), recorded_at(TIMESTAMPTZ), tagname(TEXT), value(TEXT)
|
||||
// node_map_master: level, class, name, node_id, data_type
|
||||
private string HistoryTable => "history_table";
|
||||
private string MasterTable => "node_map_master";
|
||||
|
||||
// ── 자연어 파싱 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 자연어 입력을 파싱하여 SQL 쿼리로 변환합니다.
|
||||
/// 최대 8개의 태그명을 지원합니다.
|
||||
/// </summary>
|
||||
public Task<string> ParseNaturalLanguageAsync(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
throw new ArgumentException("쿼리 입력이 필요합니다.", nameof(input));
|
||||
|
||||
var sql = BuildSqlFromNaturalLanguage(input);
|
||||
var sqls = BuildSqlFromNaturalLanguage(input, out var tagNames);
|
||||
var sql = sqls.Count > 0 ? sqls[0] : string.Empty;
|
||||
|
||||
// 태그명이 추출되지 않았고 SQL이 비어있으면 예외 발생
|
||||
if (string.IsNullOrWhiteSpace(sql) && tagNames.Count == 0)
|
||||
{
|
||||
// 시간 키워드만 입력했거나 설명만 입력했는지 확인
|
||||
var lower = input.ToLower();
|
||||
var hasTimeKeyword = timeKeywords.Any(kw => lower.Contains(kw));
|
||||
var hasTagName = ContainsTagName(input);
|
||||
|
||||
if (hasTimeKeyword && !hasTagName)
|
||||
throw new ArgumentException("시간 키워드만 입력되었습니다. 태그명을 지정해야 합니다.", nameof(input));
|
||||
if (!hasTimeKeyword && !hasTagName)
|
||||
throw new ArgumentException("태그명을 지정해야 합니다.", nameof(input));
|
||||
}
|
||||
|
||||
_logger.LogInformation("[TextToSql] 자연어 파싱: \"{Input}\" → {Sql}", input, sql);
|
||||
return Task.FromResult(sql);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 입력이 시간 키워드만 포함하는지 확인
|
||||
/// </summary>
|
||||
private bool IsTimeKeywordOnly(string input)
|
||||
{
|
||||
var lower = input.ToLower();
|
||||
var hasTimeKeyword = timeKeywords.Any(kw => lower.Contains(kw));
|
||||
var hasTagName = ContainsTagName(input);
|
||||
return hasTimeKeyword && !hasTagName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력이 태그명만 포함하는지 확인
|
||||
/// </summary>
|
||||
private bool IsTagNameOnly(string input)
|
||||
{
|
||||
var hasTagName = ContainsTagName(input);
|
||||
var hasTimeKeyword = timeKeywords.Any(kw => input.ToLower().Contains(kw));
|
||||
return hasTagName && !hasTimeKeyword;
|
||||
}
|
||||
|
||||
private readonly string[] timeKeywords = new[] { "최근", "오늘", "어제", "오늘부터", "어제부터", "최근의", "오늘의", "어제의" };
|
||||
|
||||
/// <summary>
|
||||
/// 입력이 태그명을 포함하는지 확인
|
||||
/// </summary>
|
||||
private bool ContainsTagName(string input)
|
||||
{
|
||||
// OPC UA node_id 형식 확인
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(input, @"ns=\d+;s=[^\s,]+"))
|
||||
return true;
|
||||
|
||||
// 태그명 패턴 확인 (알파벳, 숫자, 점, 하이픈, 언더스코어, 등호, 슬래시, 콜론, 한글)
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(input, @"[A-Za-z0-9._\-=/:@가-힣]+"))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자연어 입력에서 태그명, 시간 범위, 집계 함수를 추출하여 SQL 생성
|
||||
/// history_table: recorded_at (TIMESTAMPTZ), tagname (TEXT), value (TEXT)
|
||||
/// </summary>
|
||||
private string BuildSqlFromNaturalLanguage(string input)
|
||||
private List<string> BuildSqlFromNaturalLanguage(string input, out List<string> tagNames)
|
||||
{
|
||||
var lower = input.ToLower();
|
||||
|
||||
// 태그명 추출 (한글/영문 태그명 지원)
|
||||
var tagName = ExtractTagName(input);
|
||||
// 태그명 추출 (여러 태그명 지원, 쉼표로 구분)
|
||||
tagNames = ExtractTagNames(input);
|
||||
|
||||
// 시간 범위 추출
|
||||
var (fromClause, timeBucket) = ExtractTimeRange(lower);
|
||||
// 시간 범위 추출 (새로운 KoreanTimeRangeExtractor 사용)
|
||||
var timeRange = _timeExtractor.Extract(input);
|
||||
var timeCondition = timeRange.ToSqlCondition("recorded_at", _kstClock);
|
||||
|
||||
// 집계 함수 추출
|
||||
var aggregate = ExtractAggregate(lower);
|
||||
|
||||
// measurements (iiot-timescaledb 하이퍼테이블) 조회 SQL 생성
|
||||
// 컬럼: node_id (TEXT), time (TIMESTAMPTZ), value (DOUBLE PRECISION)
|
||||
if (!string.IsNullOrEmpty(tagName))
|
||||
// 시간대별 집계 간격 결정 (상대 범위인 경우와 절대 범위인 경우 구분)
|
||||
var timeBucket = timeRange.PostgresInterval != null ? DetermineTimeBucketFromInterval(timeRange.PostgresInterval) : "5 min";
|
||||
|
||||
// history_table 조회 SQL 생성
|
||||
// 컬럼: tagname (TEXT), recorded_at (TIMESTAMPTZ), value (TEXT - double precision로 CAST 필요)
|
||||
if (tagNames.Count > 0)
|
||||
{
|
||||
// SQL 인젝션 방지를 위해 태그명 이스케이프
|
||||
var escapedTagName = tagName.Replace("'", "''");
|
||||
var whereTag = $"WHERE node_id = '{escapedTagName}'";
|
||||
if (!string.IsNullOrEmpty(fromClause))
|
||||
whereTag += $" AND {fromClause}";
|
||||
var escapedTagNames = tagNames.Select(t => t.Replace("'", "''")).ToList();
|
||||
var tagNameList = string.Join(", ", escapedTagNames.Select(t => $"'{t}'"));
|
||||
var whereTag = $"WHERE tagname IN ({tagNameList}) AND {timeCondition}";
|
||||
|
||||
if (aggregate != "last" && aggregate != "first")
|
||||
{
|
||||
// 집계 쿼리 (value는 이미 double precision)
|
||||
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
|
||||
$"{aggregate}(value) AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"{whereTag} " +
|
||||
$"GROUP BY bucket ORDER BY bucket";
|
||||
// 집계 쿼리 (value는 TEXT 타입이므로 double precision으로 CAST)
|
||||
return new List<string>
|
||||
{
|
||||
$"SELECT date_trunc('{timeBucket}', recorded_at) AS bucket, " +
|
||||
$"tagname, {aggregate}(value::double precision) AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"{whereTag} " +
|
||||
$"GROUP BY 1, 2 ORDER BY 1, 2"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// first/last 쿼리 (TimeScaleDB 함수)
|
||||
// value는 TEXT 타입이므로 double precision으로 CAST
|
||||
var func = aggregate == "first" ? "first" : "last";
|
||||
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
|
||||
$"{func}(value, time) AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"{whereTag} " +
|
||||
$"GROUP BY bucket ORDER BY bucket";
|
||||
return new List<string>
|
||||
{
|
||||
$"SELECT date_trunc('{timeBucket}', recorded_at) AS bucket, " +
|
||||
$"tagname, {func}(value::double precision, recorded_at) AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"{whereTag} " +
|
||||
$"GROUP BY 1, 2 ORDER BY 1, 2"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 태그명 없이 전체 조회
|
||||
var whereAll = fromClause ?? "1=1";
|
||||
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
|
||||
$"node_id, {aggregate}(value) AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"WHERE {whereAll} " +
|
||||
$"GROUP BY bucket, node_id ORDER BY bucket, node_id";
|
||||
return new List<string>
|
||||
{
|
||||
$"SELECT date_trunc('{timeBucket}', recorded_at) AS bucket, " +
|
||||
$"tagname, {aggregate}(value::double precision) AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"WHERE {timeCondition} " +
|
||||
$"GROUP BY 1, 2 ORDER BY 1, 2"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자연어에서 태그명/node_id 추출
|
||||
/// 예: "Reactor.Temperature 온도 최근 1시간 평균" → "Reactor.Temperature"
|
||||
/// "ns=2;s=Reactor.Temperature 온도 최근 1시간 평균" → "ns=2;s=Reactor.Temperature"
|
||||
/// INTERVAL 문자열에서 적절한 시간 집계 간격 결정 (date_trunc용)
|
||||
/// </summary>
|
||||
private string DetermineTimeBucketFromInterval(string interval)
|
||||
{
|
||||
return interval switch
|
||||
{
|
||||
// 1분 단위 사용
|
||||
"1 minute" or "1 min" or "1m" => "minute",
|
||||
// 5분 단위 사용
|
||||
"5 minutes" or "5 min" or "5m" => "minute",
|
||||
// 10분 단위 사용
|
||||
"10 minutes" or "10 min" or "10m" => "minute",
|
||||
// 1시간 단위 사용
|
||||
"1 hour" or "1 hours" or "1h" => "hour",
|
||||
"2 hours" or "2h" => "hour",
|
||||
"3 hours" or "3h" => "hour",
|
||||
// 1일 단위 사용
|
||||
"1 day" or "1 days" or "1d" => "day",
|
||||
"2 days" or "2d" => "day",
|
||||
"3 days" or "3d" => "day",
|
||||
// 1주일 단위 사용
|
||||
"7 days" or "7d" => "day",
|
||||
"14 days" or "14d" => "day",
|
||||
// 30일 간격은 1시간 단위로 집계
|
||||
"30 days" or "30d" => "hour",
|
||||
_ => "minute" // 기본값: 1분 단위
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자연어에서 여러 태그명 추출 (쉼표로 구분, 최대 8개)
|
||||
/// 태그명은 공백으로 구분된 첫 번째 토큰 전체를 사용
|
||||
/// 예: "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값" → ["p-6102.hzset.fieldvalue", "ficq-6113.op"]
|
||||
/// "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 현재까지의 값 표시" → ["p-6102.hzset.fieldvalue"]
|
||||
/// "FICQ-6101.PV 온도 최근 1시간 평균" → ["FICQ-6101.PV"]
|
||||
/// "ns=2;s=Reactor.Temperature 온도 최근 1시간 평균" → ["ns=2;s=Reactor.Temperature"]
|
||||
/// "데이터 중 aia-131.sp 최근 1시간 평균" → ["aia-131.sp"]
|
||||
/// "중 aia-131.sp 최근 1시간 평균" → ["aia-131.sp"]
|
||||
/// </summary>
|
||||
private List<string> ExtractTagNames(string input)
|
||||
{
|
||||
var tagNames = new List<string>();
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// OPC UA node_id 형식 확인 (ns=X;s=...) - 공백 이전까지
|
||||
var nsMatches = System.Text.RegularExpressions.Regex.Matches(trimmed, @"ns=\d+;s=[^\s,]+");
|
||||
if (nsMatches.Count > 0)
|
||||
{
|
||||
foreach (var match in nsMatches.Take(8))
|
||||
{
|
||||
tagNames.Add(match.Value);
|
||||
}
|
||||
if (tagNames.Count > 0)
|
||||
return tagNames;
|
||||
}
|
||||
|
||||
// 태그명을 소문자로 변환하여 추출
|
||||
var lowerInput = trimmed.ToLower();
|
||||
|
||||
// 시간 표현 키워드 목록
|
||||
var timeKeywords = new[] { "최근", "오늘", "어제", "오늘부터", "어제부터", "최근의", "오늘의", "어제의" };
|
||||
|
||||
// 시간 키워드로 시작하는 경우, 해당 키워드 이후부터 시작
|
||||
var startIndex = 0;
|
||||
foreach (var kw in timeKeywords)
|
||||
{
|
||||
if (trimmed.StartsWith(kw))
|
||||
{
|
||||
startIndex = kw.Length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 키워드 이후의 텍스트에서 태그명 추출
|
||||
var remaining = trimmed.Substring(startIndex).Trim();
|
||||
|
||||
// 시간 표현 패턴 스킵: 숫자 + 시간/분/초/일 (예: 1시간, 30분, 24일)
|
||||
var timePatternMatch = System.Text.RegularExpressions.Regex.Match(remaining, @"^\d+\s*(시간|분|초|일)");
|
||||
if (timePatternMatch.Success)
|
||||
{
|
||||
remaining = remaining.Substring(timePatternMatch.Value.Length).Trim();
|
||||
}
|
||||
|
||||
// "데이터 중" 패턴 처리: "데이터 중" 이후의 텍스트를 태그명으로 간주
|
||||
var dataMiddleIdx = remaining.IndexOf("데이터 중");
|
||||
if (dataMiddleIdx >= 0)
|
||||
{
|
||||
remaining = remaining.Substring(dataMiddleIdx + 4).Trim();
|
||||
}
|
||||
|
||||
// "태그" 또는 "중" 키워드 이전까지를 태그명으로 간주
|
||||
var tagKeywordIdx = remaining.IndexOf(" 태그");
|
||||
if (tagKeywordIdx > 0)
|
||||
{
|
||||
var beforeTag = remaining.Substring(0, tagKeywordIdx).Trim();
|
||||
|
||||
// "중" 키워드가 있으면 그 다음을 태그명으로 간주
|
||||
var middleIdx = beforeTag.IndexOf(" 중");
|
||||
if (middleIdx >= 0)
|
||||
{
|
||||
remaining = beforeTag.Substring(middleIdx + 2).Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
// "중"이 없으면 "태그" 바로 이전 토큰을 태그명으로 사용
|
||||
var lastSpace = beforeTag.LastIndexOf(' ');
|
||||
if (lastSpace > 0)
|
||||
{
|
||||
remaining = beforeTag.Substring(lastSpace + 1).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (remaining.Contains(" 중"))
|
||||
{
|
||||
// "중" 키워드만 있는 경우: "중" 이후의 텍스트를 태그명으로 간주
|
||||
var middleIdx = remaining.IndexOf(" 중");
|
||||
if (middleIdx >= 0)
|
||||
{
|
||||
remaining = remaining.Substring(middleIdx + 2).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// 쉼표로 태그명들을 분리
|
||||
var tagNameParts = remaining.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var part in tagNameParts.Take(8))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(part))
|
||||
{
|
||||
// "데이터 중" 또는 "중" 키워드 제거
|
||||
var tagName = RemoveKoreanMiddleKeyword(part);
|
||||
|
||||
// 시간 표현 키워드가 있으면 제거
|
||||
foreach (var kw in timeKeywords)
|
||||
{
|
||||
if (tagName.StartsWith(kw))
|
||||
{
|
||||
tagName = tagName.Substring(kw.Length).Trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 표현 패턴 제거: 숫자 + 시간/분/초/일
|
||||
tagName = System.Text.RegularExpressions.Regex.Replace(tagName, @"^\d+\s*(시간|분|초|일)\s*", "").Trim();
|
||||
|
||||
// 태그명에서 실제 태그명 부분만 추출 (알파벳, 숫자, 점, 하이픈, 언더스코어, 등호, 슬래시, 콜론, 한글까지)
|
||||
// 이후의 한국어 설명 텍스트는 제거
|
||||
var tagMatch = System.Text.RegularExpressions.Regex.Match(tagName, @"^[A-Za-z0-9._\-=/:@가-힣]+");
|
||||
if (tagMatch.Success)
|
||||
{
|
||||
tagName = tagMatch.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tagName))
|
||||
{
|
||||
// 태그명을 소문자로 변환하여 저장
|
||||
tagNames.Add(tagName.ToLower());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tagNames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "데이터 중" 또는 "중" 키워드를 제거하고 태그명을 추출하는 보조 메서드
|
||||
/// </summary>
|
||||
private string RemoveKoreanMiddleKeyword(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return text;
|
||||
|
||||
var result = text;
|
||||
|
||||
// "데이터 중" 패턴 제거
|
||||
var dataMiddleIdx = result.IndexOf("데이터 중");
|
||||
if (dataMiddleIdx >= 0)
|
||||
{
|
||||
result = result.Substring(dataMiddleIdx + 4).Trim();
|
||||
}
|
||||
|
||||
// "중" 패턴 제거 (공백 뒤에 있는 경우)
|
||||
var middleIdx = result.IndexOf(" 중");
|
||||
if (middleIdx >= 0)
|
||||
{
|
||||
result = result.Substring(middleIdx + 2).Trim();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자연어에서 단일 태그명 추출 (후방 호환성용)
|
||||
/// </summary>
|
||||
private string ExtractTagName(string input)
|
||||
{
|
||||
var lower = input.ToLower();
|
||||
|
||||
// 키워드 목록 (시간/집계 관련 단어)
|
||||
var keywords = new[]
|
||||
{
|
||||
"최근", "last", "latest",
|
||||
"평균", "average", "avg", "평균값",
|
||||
"최대", "maximum", "max", "최댓값",
|
||||
"최소", "minimum", "min", "최솟값",
|
||||
"첫번째", "first", "초기값",
|
||||
"마지막", "last", "종료값",
|
||||
"추세", "trend",
|
||||
"데이터", "조회", "quer", "query",
|
||||
"시간", "hour", "hr",
|
||||
"분", "minute", "min", "m",
|
||||
"초", "second", "sec", "s",
|
||||
"일", "day", "d",
|
||||
"주", "week", "w",
|
||||
"전체", "all",
|
||||
"온도", "temperature",
|
||||
"압력", "pressure",
|
||||
"유량", "flow", "유량", "flow rate",
|
||||
"수위", "level", "수위",
|
||||
"rpm", "전류", "current"
|
||||
};
|
||||
|
||||
foreach (var kw in keywords)
|
||||
{
|
||||
var idx = lower.IndexOf(kw);
|
||||
if (idx > 0)
|
||||
{
|
||||
var candidate = input.Substring(0, idx).Trim();
|
||||
// OPC UA node_id 형식(ns=...;s=...) 또는 일반 태그명 반환
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// OPC UA node_id 형식 확인 (ns=X;s=...)
|
||||
var nsMatch = System.Text.RegularExpressions.Regex.Match(input, @"ns=\d+;s=[^ ]+");
|
||||
if (nsMatch.Success)
|
||||
return nsMatch.Value;
|
||||
|
||||
// 키워드가 없으면 첫 단어(공백 이전)를 태그명으로 간주
|
||||
var firstSpace = input.IndexOf(' ');
|
||||
if (firstSpace > 0)
|
||||
return input.Substring(0, firstSpace).Trim();
|
||||
|
||||
return input.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자연어에서 시간 범위 추출
|
||||
/// </summary>
|
||||
private (string? whereClause, string timeBucket) ExtractTimeRange(string lower)
|
||||
{
|
||||
// 시간 단위 매핑 (measurements 테이블의 time 컬럼 사용)
|
||||
var timePatterns = new (string pattern, string interval, string bucket)[]
|
||||
{
|
||||
("최근 1시간", "1 hour", "5 min"),
|
||||
("최근 2시간", "2 hours", "5 min"),
|
||||
("최근 3시간", "3 hours", "10 min"),
|
||||
("최근 6시간", "6 hours", "10 min"),
|
||||
("최근 12시간", "12 hours", "30 min"),
|
||||
("최근 24시간", "24 hours", "1 hour"),
|
||||
("최근 하루", "24 hours", "1 hour"),
|
||||
("최근 1일", "1 day", "1 hour"),
|
||||
("최근 3일", "3 days", "6 hours"),
|
||||
("최근 7일", "7 days", "12 hours"),
|
||||
("최근 1주", "7 days", "12 hours"),
|
||||
("최근 1개월", "30 days", "1 day"),
|
||||
("최근 한달", "30 days", "1 day"),
|
||||
("오늘", "1 day", "1 hour"),
|
||||
("어제", "1 day", "1 hour"),
|
||||
("최근 5분", "5 minutes", "1 min"),
|
||||
("최근 10분", "10 minutes", "1 min"),
|
||||
("최근 30분", "30 minutes", "1 min"),
|
||||
};
|
||||
|
||||
foreach (var (pattern, interval, bucket) in timePatterns)
|
||||
{
|
||||
if (lower.Contains(pattern))
|
||||
{
|
||||
return ($"time > NOW() - INTERVAL '{interval}'", bucket);
|
||||
}
|
||||
}
|
||||
|
||||
// "from ~ to" 패턴 (ISO 8601 형식 또는 한국어)
|
||||
var fromMatch = System.Text.RegularExpressions.Regex.Match(lower, @"from\s+(\d{4}-\d{2}-\d{2})");
|
||||
var toMatch = System.Text.RegularExpressions.Regex.Match(lower, @"to\s+(\d{4}-\d{2}-\d{2})");
|
||||
if (fromMatch.Success)
|
||||
{
|
||||
var from = fromMatch.Groups[1].Value;
|
||||
var to = toMatch.Success ? toMatch.Groups[1].Value : DateTime.Now.ToString("yyyy-MM-dd");
|
||||
return ($"time >= '{from} 00:00:00' AND time <= '{to} 23:59:59'", "1 hour");
|
||||
}
|
||||
|
||||
return (null, "5 min");
|
||||
var tagNames = ExtractTagNames(input);
|
||||
return tagNames.Count > 0 ? tagNames[0] : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자연어에서 집계 함수 추출
|
||||
/// "평균" 키워드가 없으면 last() 사용 (최근 값 반환)
|
||||
/// </summary>
|
||||
private string ExtractAggregate(string lower)
|
||||
{
|
||||
@@ -219,24 +398,100 @@ public class TextToSqlService : ITextToSqlService
|
||||
return "first";
|
||||
if (lower.Contains("마지막") || lower.Contains("last") || lower.Contains("종료"))
|
||||
return "last";
|
||||
// 기본값: 평균
|
||||
return "avg";
|
||||
// "평균" 키워드가 명시적으로 포함된 경우만 avg 사용
|
||||
if (lower.Contains("평균") || lower.Contains("avg") || lower.Contains("average"))
|
||||
return "avg";
|
||||
// 기본값: 마지막 값 (최근 데이터 반환)
|
||||
return "last";
|
||||
}
|
||||
|
||||
// ── SQL 실행 ────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<SqlQueryResultDto> ExecuteQueryAsync(string sql, int? limit = 1000)
|
||||
{
|
||||
// ── SQL 검증 (다단계 검증기) ─────────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
_logger.LogWarning("[TextToSql] SQL이 비어있음");
|
||||
throw new ArgumentException("SQL이 비어있음", nameof(sql));
|
||||
}
|
||||
|
||||
if (sql == null)
|
||||
{
|
||||
_logger.LogWarning("[TextToSql] SQL이 null임");
|
||||
throw new ArgumentNullException(nameof(sql));
|
||||
}
|
||||
|
||||
var validationResult = _validator.Validate(sql);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
_logger.LogWarning("[TextToSql] SQL 검증 실패: {Reason} - {Message}", validationResult.Reason, validationResult.Message);
|
||||
return new SqlQueryResultDto
|
||||
{
|
||||
Success = false,
|
||||
Error = $"SQL 검증 실패: {validationResult.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var fullSql = sql.TrimEnd();
|
||||
if (limit.HasValue && !fullSql.EndsWith(";", StringComparison.OrdinalIgnoreCase)
|
||||
&& !fullSql.EndsWith(" limit ", StringComparison.OrdinalIgnoreCase))
|
||||
var fullSql = sql.TrimEnd().TrimEnd(';');
|
||||
if (limit.HasValue)
|
||||
{
|
||||
fullSql += $" LIMIT {limit.Value}";
|
||||
// SQL에 이미 LIMIT가 포함되어 있으면 추가하지 않음
|
||||
var hasLimit = false;
|
||||
var lowerSql = fullSql.ToLowerInvariant();
|
||||
// "limit" 키워드 검색 (단어 경계 확인)
|
||||
var index = 0;
|
||||
while ((index = lowerSql.IndexOf("limit", index)) != -1)
|
||||
{
|
||||
var endIdx = index + 5;
|
||||
// "limit" 다음 문자가 공백, 세미콜론, 또는 문자열 끝이면 LIMIT 키워드
|
||||
if (endIdx >= lowerSql.Length || char.IsWhiteSpace(lowerSql[endIdx]) || lowerSql[endIdx] == ';')
|
||||
{
|
||||
// "select", "from", "where", "order", "group", "having" 등의 일부가 아닌지 확인
|
||||
var startIdx = index - 1;
|
||||
var isPartOfWord = false;
|
||||
while (startIdx >= 0 && (char.IsLetterOrDigit(lowerSql[startIdx]) || lowerSql[startIdx] == '_'))
|
||||
{
|
||||
isPartOfWord = true;
|
||||
startIdx--;
|
||||
}
|
||||
if (!isPartOfWord)
|
||||
{
|
||||
hasLimit = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
if (!hasLimit)
|
||||
{
|
||||
fullSql += $" LIMIT {limit.Value}";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 태그 존재 여부 확인 ─────────────────────────────────────────────────
|
||||
// SQL에서 태그명 추출하여 history_table에 존재하는지 확인
|
||||
var tagName = ExtractTagNameFromSql(fullSql);
|
||||
if (!string.IsNullOrEmpty(tagName))
|
||||
{
|
||||
var tagExists = await CheckTagExistsAsync(conn, tagName);
|
||||
if (!tagExists)
|
||||
{
|
||||
_logger.LogInformation("[TextToSql] 태그 '{TagName}'이(가) history_table에 존재하지 않음", tagName);
|
||||
return new SqlQueryResultDto
|
||||
{
|
||||
Success = true,
|
||||
Message = $"태그 '{tagName}'이(가) 데이터베이스에 존재하지 않습니다.",
|
||||
Columns = new List<string> { "bucket", "tagname", "result" },
|
||||
Rows = new List<Dictionary<string, object?>>(),
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using var cmd = new NpgsqlCommand(fullSql, conn);
|
||||
@@ -263,20 +518,88 @@ public class TextToSqlService : ITextToSqlService
|
||||
return new SqlQueryResultDto
|
||||
{
|
||||
Success = true,
|
||||
Message = rows.Count == 0 ? "조회 결과가 없습니다." : null,
|
||||
Columns = columns,
|
||||
Rows = rows,
|
||||
TotalCount = rows.Count
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (NpgsqlException npgsqlEx)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] 쿼리 실행 실패: {Sql}", sql);
|
||||
// PostgreSQL 특정 오류
|
||||
_logger.LogError("[TextToSql] PostgreSQL 오류 (코드: {ErrorCode}): {ErrorMessage}\nSQL: {Sql}",
|
||||
npgsqlEx.SqlState, npgsqlEx.Message, sql);
|
||||
return new SqlQueryResultDto
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
Error = $"PostgreSQL 오류 [{npgsqlEx.SqlState}]: {npgsqlEx.Message}"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 일반 예외 - 내부 예외 정보 포함
|
||||
var innerExceptionMsg = ex.InnerException?.Message ?? string.Empty;
|
||||
var fullMessage = string.IsNullOrEmpty(innerExceptionMsg)
|
||||
? ex.Message
|
||||
: $"{ex.Message} (내부: {innerExceptionMsg})";
|
||||
|
||||
_logger.LogError(ex, "[TextToSql] 쿼리 실행 실패: {Sql}\n오류: {Message}", sql, fullMessage);
|
||||
return new SqlQueryResultDto
|
||||
{
|
||||
Success = false,
|
||||
Error = fullMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL에서 태그명을 추출합니다.
|
||||
/// 예: "WHERE tagname IN ('p-61902.hzset.fieldvalue')" → "p-61902.hzset.fieldvalue"
|
||||
/// </summary>
|
||||
private string? ExtractTagNameFromSql(string sql)
|
||||
{
|
||||
// tagname IN (...) 패턴 추출
|
||||
var inPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"tagname\s*IN\s*\(\s*'([^']+)'\s*\)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var match = inPattern.Match(sql);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
// tagname = '...' 패턴 추출
|
||||
var eqPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"tagname\s*=\s*'([^']+)'\s*",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
match = eqPattern.Match(sql);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 태그가 history_table에 존재하는지 확인합니다.
|
||||
/// </summary>
|
||||
private async Task<bool> CheckTagExistsAsync(NpgsqlConnection conn, string tagName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cmd = new NpgsqlCommand(
|
||||
"SELECT 1 FROM history_table WHERE tagname = @tagName LIMIT 1", conn);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@tagName", tagName));
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result != null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] 태그 존재 확인 실패: {TagName}", tagName);
|
||||
// 확인 실패 시 false 반환 (보안상 태그가 존재하지 않는 것으로 간주)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 쿼리 제안 ───────────────────────────────────────────────────────────────
|
||||
@@ -324,18 +647,19 @@ public class TextToSqlService : ITextToSqlService
|
||||
// SQL 인젝션 방지를 위해 태그명 이스케이프
|
||||
var escapedTagName = tagName.Replace("'", "''");
|
||||
|
||||
// measurements 테이블: node_id (TEXT), time (TIMESTAMPTZ), value (DOUBLE PRECISION)
|
||||
// history_table: tagname (TEXT), recorded_at (TIMESTAMPTZ), value (TEXT)
|
||||
var sql = $@"
|
||||
SELECT
|
||||
AVG(value) AS avg_val,
|
||||
MIN(value) AS min_val,
|
||||
MAX(value) AS max_val,
|
||||
first(value, time) AS first_val,
|
||||
last(value, time) AS last_val,
|
||||
AVG(value::double precision) AS avg_val,
|
||||
MIN(value::double precision) AS min_val,
|
||||
MAX(value::double precision) AS max_val,
|
||||
STDDEV(value::double precision) AS stddev_val,
|
||||
first(value::double precision, recorded_at) AS first_val,
|
||||
last(value::double precision, recorded_at) AS last_val,
|
||||
COUNT(*) AS point_count
|
||||
FROM measurements
|
||||
WHERE node_id = '{escapedTagName}'
|
||||
AND time BETWEEN '{from}' AND '{to}'";
|
||||
FROM history_table
|
||||
WHERE tagname = '{escapedTagName}'
|
||||
AND recorded_at BETWEEN '{from}' AND '{to}'";
|
||||
|
||||
using var cmd = new NpgsqlCommand(sql, conn);
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
@@ -348,9 +672,10 @@ public class TextToSqlService : ITextToSqlService
|
||||
Avg = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)),
|
||||
Min = reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)),
|
||||
Max = reader.IsDBNull(2) ? null : Convert.ToDouble(reader.GetValue(2)),
|
||||
First = reader.IsDBNull(3) ? null : Convert.ToDouble(reader.GetValue(3)),
|
||||
Last = reader.IsDBNull(4) ? null : Convert.ToDouble(reader.GetValue(4)),
|
||||
PointCount = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)),
|
||||
StdDev = reader.IsDBNull(3) ? null : Convert.ToDouble(reader.GetValue(3)),
|
||||
First = reader.IsDBNull(4) ? null : Convert.ToDouble(reader.GetValue(4)),
|
||||
Last = reader.IsDBNull(5) ? null : Convert.ToDouble(reader.GetValue(5)),
|
||||
PointCount = reader.IsDBNull(6) ? 0 : Convert.ToInt64(reader.GetValue(6)),
|
||||
From = dto.From,
|
||||
To = dto.To
|
||||
});
|
||||
@@ -376,9 +701,9 @@ public class TextToSqlService : ITextToSqlService
|
||||
var tags = new List<string>();
|
||||
try
|
||||
{
|
||||
// measurements 테이블에서 고유 node_id 목록 조회
|
||||
// node_map_master에서 고유 name 목록 조회 (Experion 태그명)
|
||||
using var cmd = new NpgsqlCommand(
|
||||
"SELECT DISTINCT node_id FROM measurements ORDER BY node_id", conn);
|
||||
"SELECT DISTINCT name FROM node_map_master ORDER BY name", conn);
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user