fix(#2): TextToSqlService 태그 존재 확인 시 예외 처리 수정 (false 반환)

This commit is contained in:
windpacer
2026-04-26 11:21:15 +09:00
parent 39f6138f9d
commit 6f0aba4b04

View File

@@ -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())
{