2852 lines
116 KiB
Markdown
2852 lines
116 KiB
Markdown
|
|
Claude가 응답을 완료했습니다
|
|
입력 내용: C#, .
|
|
C#, .NET / 우분투에서
|
|
|
|
postgresql 에서 tagname의 value를 집계 및 분석하는 Text-to-SQL기능을 구현하기 위한 플랜을 만들어줘
|
|
|
|
오후 2:41
|
|
Claude 응답: 이제 Text-to-SQL 아키텍처 플랜을 시각적으로 작성하겠습니다.
|
|
|
|
Check frontend design skill
|
|
Check frontend design skill
|
|
이제 Text-to-SQL 아키텍처 플랜을 시각적으로 작성하겠습니다.
|
|
|
|
|
|
|
|
C# .NET + PostgreSQL Text-to-SQL 구현 플랜
|
|
각 레이어 및 단계를 클릭하면 상세 설명을 요청할 수 있습니다.
|
|
|
|
전체 아키텍처 흐름
|
|
① 입력 → ② NLP 처리 → ③ SQL 생성/검증 → ④ .NET API → ⑤ PostgreSQL
|
|
|
|
단계별 구현 계획
|
|
1단계 — 기초 인프라 구성 (1~2주)
|
|
|
|
Ubuntu 22.04에 PostgreSQL 16 설치 및 tagname_values 테이블 설계. 시계열 특성을 고려해 TimescaleDB 확장을 적용하고, tagname, timestamp, value 컬럼에 복합 인덱스 설정. ASP.NET Core 8 프로젝트 초기 구성 및 Dapper/EF Core 연결.
|
|
|
|
2단계 — LLM 연동 및 프롬프트 엔지니어링 (1~2주)
|
|
|
|
OpenAI API (또는 로컬 LLaMA) 연동. 핵심은 Schema Context 주입 — 테이블 구조, tagname 목록, 가능한 집계 함수를 프롬프트에 포함시키는 것. Few-shot 예제로 정확도 향상.
|
|
|
|
3단계 — SQL 생성/검증 파이프라인 (2~3주)
|
|
|
|
LLM 응답에서 SQL 추출 → Syntax 검증 → SQL Injection 방지 (허용 키워드 화이트리스트) → 실행 전 EXPLAIN 비용 분석. 실패 시 재시도 루프 구현.
|
|
|
|
4단계 — 집계 분석 API 완성 (1~2주)
|
|
|
|
REST API 엔드포인트 완성, 쿼리 결과를 통계 형태로 반환 (평균/최대/최소/이상치). Redis로 동일 질의 캐싱, Serilog로 질의 이력 추적.
|
|
|
|
주요 고려사항
|
|
보안: 동적 SQL이므로 반드시 읽기 전용 DB 계정 사용 + 허용 쿼리 패턴 화이트리스트 필수
|
|
성능: tagname이 많을 경우 Materialized View 또는 파티션 테이블 적용
|
|
정확도: tagname 메타데이터(단위, 설명)를 Schema Context에 포함시킬수록 SQL 품질 향상
|
|
테스트: Testcontainers로 PostgreSQL 통합 테스트 자동화
|
|
어느 레이어부터 상세하게 들어갈까요? (예: DB 스키마 설계, LLM 프롬프트 전략, .NET 코드 구조 등)
|
|
|
|
입력 내용: 프롬프트 템플릿 작성
|
|
프롬프트 템플릿 작성
|
|
|
|
오후 2:59
|
|
Claude 응답: 먼저 실제 tagname/value 테이블 구조를 파악해야 좋은 프롬프트를 만들 수 있습니다.
|
|
먼저 실제 tagname/value 테이블 구조를 파악해야 좋은 프롬프트를 만들 수 있습니다.
|
|
|
|
선택하신 내용을 바탕으로 전체 구조를 커버하는 범용 프롬프트 템플릿을 작성하겠습니다. 나중에 실제 스키마에 맞게 교체할 수 있도록 플레이스홀더 기반으로 구성합니다.
|
|
|
|
프롬프트 템플릿 구성
|
|
총 3개의 파트로 구성됩니다: System Prompt, Schema Context, Few-Shot + User Query.
|
|
|
|
1. System Prompt (고정)
|
|
You are a PostgreSQL SQL generation expert for industrial time-series tag data.
|
|
|
|
## Rules
|
|
- Generate ONLY a single SQL SELECT statement. No INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE.
|
|
- Do NOT use semicolons at the end.
|
|
- Do NOT include markdown code blocks or explanation — output raw SQL only.
|
|
- Always use parameterized-safe patterns: no string concatenation in SQL.
|
|
- If the question is ambiguous or cannot be answered with the given schema, respond with exactly: CANNOT_GENERATE
|
|
- All datetime comparisons must use UTC (timestamptz).
|
|
- Default time range if not specified: last 1 hour (NOW() - INTERVAL '1 hour').
|
|
- For aggregation queries, always include a GROUP BY clause.
|
|
- Limit raw data queries to 10,000 rows maximum using LIMIT.
|
|
2. Schema Context (요청마다 동적 주입)
|
|
## Database Schema
|
|
|
|
### Primary Table: tag_values
|
|
| Column | Type | Description |
|
|
|------------|-------------|--------------------------------------|
|
|
| id | BIGSERIAL | Primary key |
|
|
| tagname | VARCHAR(255)| Tag identifier (e.g. 'PV_101.PV') |
|
|
| timestamp | TIMESTAMPTZ | Measurement time (UTC) |
|
|
| value | DOUBLE PRECISION | Numeric measurement value |
|
|
| quality | SMALLINT | OPC quality code (192=Good, 0=Bad) |
|
|
|
|
### Index Info
|
|
- PRIMARY KEY: id
|
|
- INDEX: (tagname, timestamp DESC) ← use for time-range + tagname filters
|
|
|
|
### Available Tag List (use exact names)
|
|
{TAGNAME_LIST}
|
|
-- Example:
|
|
-- 'PV_101.PV', 'PV_101.SV', 'TI_201.PV', 'FI_301.PV', 'LI_401.PV'
|
|
|
|
### Useful Patterns
|
|
-- Time-range filter
|
|
WHERE tagname = 'TAG' AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
|
|
-- Good quality only
|
|
WHERE quality = 192 (or quality >= 128 for acceptable)
|
|
|
|
-- Aggregation with time bucket (1-minute bins)
|
|
date_trunc('minute', timestamp) AS time_bucket
|
|
|
|
-- TimescaleDB time_bucket (if available)
|
|
time_bucket('1 minute', timestamp) AS time_bucket
|
|
3. Few-Shot Examples (핵심 질의 유형별)
|
|
## Examples
|
|
|
|
### [시간대별 집계]
|
|
User: PV_101.PV의 최근 1시간 1분 단위 평균값을 보여줘
|
|
SQL:
|
|
SELECT
|
|
time_bucket('1 minute', timestamp) AS time_bucket,
|
|
AVG(value) AS avg_value,
|
|
MIN(value) AS min_value,
|
|
MAX(value) AS max_value,
|
|
COUNT(*) AS sample_count
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
AND quality >= 128
|
|
GROUP BY time_bucket
|
|
ORDER BY time_bucket ASC
|
|
|
|
---
|
|
|
|
### [특정 tagname 비교]
|
|
User: TI_201.PV 와 TI_202.PV의 오늘 최대값을 비교해줘
|
|
SQL:
|
|
SELECT
|
|
tagname,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
AVG(value) AS avg_value
|
|
FROM tag_values
|
|
WHERE tagname IN ('TI_201.PV', 'TI_202.PV')
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
AND quality >= 128
|
|
GROUP BY tagname
|
|
ORDER BY tagname
|
|
|
|
---
|
|
|
|
### [이상값 탐지]
|
|
User: FI_301.PV에서 오늘 값이 100을 초과한 데이터를 찾아줘
|
|
SQL:
|
|
SELECT
|
|
timestamp,
|
|
tagname,
|
|
value
|
|
FROM tag_values
|
|
WHERE tagname = 'FI_301.PV'
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
AND value > 100
|
|
AND quality >= 128
|
|
ORDER BY timestamp DESC
|
|
LIMIT 500
|
|
|
|
---
|
|
|
|
### [추세 분석]
|
|
User: LI_401.PV의 어제 대비 오늘 시간대별 평균 변화를 비교해줘
|
|
SQL:
|
|
SELECT
|
|
date_trunc('hour', timestamp) AS hour_bucket,
|
|
AVG(value) FILTER (WHERE timestamp::date = CURRENT_DATE) AS today_avg,
|
|
AVG(value) FILTER (WHERE timestamp::date = CURRENT_DATE - 1) AS yesterday_avg
|
|
FROM tag_values
|
|
WHERE tagname = 'LI_401.PV'
|
|
AND timestamp >= date_trunc('day', NOW()) - INTERVAL '1 day'
|
|
AND quality >= 128
|
|
GROUP BY hour_bucket
|
|
ORDER BY hour_bucket ASC
|
|
4. User Query 조합 (C# 코드)
|
|
csharp
|
|
public class PromptBuilder
|
|
{
|
|
private readonly string _tagnameList;
|
|
|
|
public PromptBuilder(IEnumerable<string> availableTagnames)
|
|
{
|
|
// DB에서 사전에 로딩한 tagname 목록
|
|
_tagnameList = string.Join(", ", availableTagnames.Select(t => $"'{t}'"));
|
|
}
|
|
|
|
public IEnumerable<ChatMessage> Build(string userQuery)
|
|
{
|
|
var systemPrompt = GetSystemPrompt();
|
|
var schemaContext = GetSchemaContext();
|
|
var fewShots = GetFewShotExamples();
|
|
|
|
return new[]
|
|
{
|
|
new ChatMessage(ChatRole.System, systemPrompt),
|
|
new ChatMessage(ChatRole.User,
|
|
$"""
|
|
{schemaContext}
|
|
|
|
{fewShots}
|
|
|
|
## Current Question
|
|
User: {userQuery}
|
|
SQL:
|
|
""")
|
|
};
|
|
}
|
|
|
|
private string GetSchemaContext()
|
|
{
|
|
return $"""
|
|
## Database Schema
|
|
Table: tag_values
|
|
Columns: id (BIGSERIAL), tagname (VARCHAR), timestamp (TIMESTAMPTZ),
|
|
value (DOUBLE PRECISION), quality (SMALLINT, 192=Good)
|
|
Index: (tagname, timestamp DESC)
|
|
|
|
## Available Tagnames
|
|
{_tagnameList}
|
|
""";
|
|
}
|
|
|
|
// ... GetSystemPrompt(), GetFewShotExamples() 메서드
|
|
}
|
|
5. 응답 검증 + 재시도 패턴
|
|
csharp
|
|
public async Task<string> GenerateSqlAsync(string userQuery, int maxRetry = 2)
|
|
{
|
|
var messages = _promptBuilder.Build(userQuery).ToList();
|
|
|
|
for (int attempt = 0; attempt <= maxRetry; attempt++)
|
|
{
|
|
var response = await _llmClient.CompleteAsync(messages);
|
|
var sql = response.Trim();
|
|
|
|
if (sql == "CANNOT_GENERATE")
|
|
throw new InvalidOperationException("질문을 SQL로 변환할 수 없습니다.");
|
|
|
|
var (isValid, error) = _sqlValidator.Validate(sql);
|
|
if (isValid) return sql;
|
|
|
|
// 실패 시 오류 내용을 대화에 추가하여 재시도
|
|
messages.Add(new ChatMessage(ChatRole.Assistant, sql));
|
|
messages.Add(new ChatMessage(ChatRole.User,
|
|
$"The SQL has an error: {error}. Please fix and return only the corrected SQL."));
|
|
}
|
|
|
|
throw new InvalidOperationException("SQL 생성 재시도 횟수를 초과했습니다.");
|
|
}
|
|
핵심 설계 포인트 정리
|
|
포인트 이유
|
|
CANNOT_GENERATE 명시 엉터리 SQL 대신 명확한 실패 신호 반환
|
|
tagname 목록 주입 존재하지 않는 tagname 환각 방지
|
|
quality 필터 기본 포함 Bad 데이터가 집계에 포함되는 것 방지
|
|
Few-shot 질의 유형 분리 집계 / 비교 / 이상탐지 / 추세별 패턴 학습
|
|
재시도 시 오류 피드백 LLM이 스스로 수정할 수 있도록 컨텍스트 제공
|
|
실제 tagname 목록이 준비되면 {TAGNAME_LIST} 부분을 교체하거나, DB에서 동적으로 로딩하는 코드를 추가해 드릴 수 있습니다.
|
|
|
|
입력 내용: 질문: tagname/value 테이블 구조는 어떻게 되나요?
|
|
질문: tagname/value 테이블 구조는 어떻게 되나요?
|
|
답변: tagname + timestamp + value + unit + description (메타 포함)
|
|
|
|
질문: LLM은 어떤 것을 사용하나요?
|
|
답변: 로컬 LLM Qwen3.6-32B-A3B
|
|
|
|
질문: 주로 어떤 종류의 질의가 필요한가요? (해당하는 항목을 모두 선택)
|
|
답변: 시간대별 집계, 특정 tagnam 값 단일 , 복수 조회 및 비교, 최대값, 최소값, 평균값, 표준편차, 추세분석
|
|
|
|
오후 3:02
|
|
Claude 응답: 확인했습니다.
|
|
확인했습니다. 이제 실제 스키마와 Qwen3 로컬 LLM에 최적화된 프롬프트 템플릿을 작성하겠습니다.
|
|
|
|
스키마 정의 (실제 테이블 기준)
|
|
sql
|
|
-- 메인 테이블
|
|
CREATE TABLE tag_values (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tagname VARCHAR(255) NOT NULL,
|
|
timestamp TIMESTAMPTZ NOT NULL,
|
|
value DOUBLE PRECISION NOT NULL,
|
|
unit VARCHAR(50), -- 예: '°C', 'bar', 'L/min'
|
|
description TEXT -- 예: '반응기 온도 센서 1'
|
|
);
|
|
|
|
CREATE INDEX idx_tag_time ON tag_values (tagname, timestamp DESC);
|
|
프롬프트 템플릿 전체 (C# 문자열 기준)
|
|
System Prompt
|
|
You are a PostgreSQL SQL expert for industrial time-series tag data.
|
|
Think step by step, but output ONLY the final SQL — no explanation, no markdown.
|
|
|
|
## Hard Rules
|
|
- SELECT only. Never use INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, GRANT.
|
|
- No semicolon at the end.
|
|
- No markdown fences (no ```sql).
|
|
- If the question cannot be expressed as SQL with the given schema, output exactly: CANNOT_GENERATE
|
|
- All timestamps are TIMESTAMPTZ (UTC). Use NOW() for current time.
|
|
- Default time range when not specified: last 1 hour → timestamp >= NOW() - INTERVAL '1 hour'
|
|
- Always apply LIMIT 10000 on raw row queries. Aggregation queries do not need LIMIT.
|
|
- For multi-tagname queries use IN (...) — do not write separate queries.
|
|
Qwen3 주의: Think step by step + output ONLY 조합이 중요합니다. Qwen3는 thinking 모드가 있어 추론은 내부적으로 하되 최종 출력은 SQL만 나오도록 유도해야 합니다.
|
|
|
|
Schema Context (동적 주입 블록)
|
|
## Schema
|
|
|
|
Table: tag_values
|
|
┌─────────────┬──────────────────┬──────────────────────────────────────┐
|
|
│ Column │ Type │ Description │
|
|
├─────────────┼──────────────────┼──────────────────────────────────────┤
|
|
│ id │ BIGSERIAL │ PK │
|
|
│ tagname │ VARCHAR(255) │ Tag ID e.g. 'PV_101.PV' │
|
|
│ timestamp │ TIMESTAMPTZ │ Measurement time (UTC) │
|
|
│ value │ DOUBLE PRECISION │ Numeric measurement │
|
|
│ unit │ VARCHAR(50) │ Engineering unit e.g. '°C', 'bar' │
|
|
│ description │ TEXT │ Human-readable tag name │
|
|
└─────────────┴──────────────────┴──────────────────────────────────────┘
|
|
Index: (tagname, timestamp DESC)
|
|
|
|
## Available Tags
|
|
{TAGNAME_METADATA}
|
|
-- Runtime injection example:
|
|
-- tagname='PV_101.PV', unit='°C', description='반응기 입구 온도'
|
|
-- tagname='PV_101.SV', unit='°C', description='반응기 입구 온도 설정값'
|
|
-- tagname='FI_301.PV', unit='L/min', description='공급 유량계'
|
|
-- tagname='LI_401.PV', unit='%', description='저장탱크 레벨'
|
|
-- tagname='PI_501.PV', unit='bar', description='라인 압력'
|
|
|
|
## Key SQL Patterns
|
|
-- Time bucket (1 min) : date_trunc('minute', timestamp)
|
|
-- Time bucket (1 hr) : date_trunc('hour', timestamp)
|
|
-- Standard deviation : STDDEV(value)
|
|
-- Trend slope proxy : REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp))
|
|
-- Unit/description : SELECT DISTINCT unit, description FROM tag_values WHERE tagname = '...'
|
|
Few-Shot Examples (질의 유형별 전체)
|
|
## Examples
|
|
|
|
### [단일 tagname 최신값]
|
|
User: PV_101.PV 현재값 보여줘
|
|
SQL:
|
|
SELECT timestamp, tagname, value, unit, description
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
ORDER BY timestamp DESC
|
|
LIMIT 1
|
|
|
|
---
|
|
|
|
### [단일 tagname 시간대별 집계]
|
|
User: PV_101.PV 최근 1시간 1분 단위 평균/최대/최소
|
|
SQL:
|
|
SELECT
|
|
date_trunc('minute', timestamp) AS time_bucket,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
COUNT(*) AS sample_count
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
GROUP BY time_bucket
|
|
ORDER BY time_bucket ASC
|
|
|
|
---
|
|
|
|
### [복수 tagname 비교 - 같은 시간축]
|
|
User: TI_201.PV 와 TI_202.PV 오늘 1시간 단위 평균값 비교
|
|
SQL:
|
|
SELECT
|
|
date_trunc('hour', timestamp) AS time_bucket,
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value
|
|
FROM tag_values
|
|
WHERE tagname IN ('TI_201.PV', 'TI_202.PV')
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
GROUP BY time_bucket, tagname
|
|
ORDER BY time_bucket ASC, tagname
|
|
|
|
---
|
|
|
|
### [복수 tagname 피벗 비교 - 컬럼으로 나란히]
|
|
User: PV_101.PV, FI_301.PV, PI_501.PV 최근 6시간 통계 요약
|
|
SQL:
|
|
SELECT
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
COUNT(*) AS sample_count,
|
|
MIN(unit) AS unit
|
|
FROM tag_values
|
|
WHERE tagname IN ('PV_101.PV', 'FI_301.PV', 'PI_501.PV')
|
|
AND timestamp >= NOW() - INTERVAL '6 hours'
|
|
GROUP BY tagname
|
|
ORDER BY tagname
|
|
|
|
---
|
|
|
|
### [추세 분석 - 선형 회귀 기울기]
|
|
User: LI_401.PV 오늘 증가 추세인지 분석해줘
|
|
SQL:
|
|
SELECT
|
|
tagname,
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) AS slope_per_second,
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) * 3600 AS slope_per_hour,
|
|
REGR_R2(value, EXTRACT(EPOCH FROM timestamp)) AS r_squared,
|
|
AVG(value) AS avg_value,
|
|
MIN(timestamp) AS period_start,
|
|
MAX(timestamp) AS period_end
|
|
FROM tag_values
|
|
WHERE tagname = 'LI_401.PV'
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
GROUP BY tagname
|
|
|
|
---
|
|
|
|
### [어제 vs 오늘 추세 비교]
|
|
User: PV_101.PV 어제 대비 오늘 시간대별 평균 비교
|
|
SQL:
|
|
SELECT
|
|
date_trunc('hour', timestamp) AS hour_bucket,
|
|
EXTRACT(HOUR FROM timestamp) AS hour_of_day,
|
|
AVG(value) FILTER (WHERE timestamp::date = CURRENT_DATE) AS today_avg,
|
|
AVG(value) FILTER (WHERE timestamp::date = CURRENT_DATE - INTERVAL '1 day') AS yesterday_avg
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= date_trunc('day', NOW()) - INTERVAL '1 day'
|
|
GROUP BY hour_bucket, hour_of_day
|
|
ORDER BY hour_of_day ASC
|
|
C# 구현 — PromptBuilder.cs
|
|
csharp
|
|
public class PromptBuilder
|
|
{
|
|
// 런타임에 DB에서 로딩한 태그 메타데이터
|
|
public record TagMeta(string Tagname, string Unit, string Description);
|
|
|
|
private readonly IReadOnlyList<TagMeta> _tags;
|
|
|
|
public PromptBuilder(IEnumerable<TagMeta> tags)
|
|
{
|
|
_tags = tags.ToList();
|
|
}
|
|
|
|
// ── 최종 메시지 빌드 ──────────────────────────────────────────
|
|
public IEnumerable<(string Role, string Content)> Build(string userQuery)
|
|
{
|
|
yield return ("system", BuildSystemPrompt());
|
|
yield return ("user", BuildUserMessage(userQuery));
|
|
}
|
|
|
|
// ── System Prompt ─────────────────────────────────────────────
|
|
private static string BuildSystemPrompt() => """
|
|
You are a PostgreSQL SQL expert for industrial time-series tag data.
|
|
Think step by step, but output ONLY the final SQL — no explanation, no markdown.
|
|
|
|
## Hard Rules
|
|
- SELECT only. Never use INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, GRANT.
|
|
- No semicolon at the end.
|
|
- No markdown fences.
|
|
- If the question cannot be answered with the schema, output exactly: CANNOT_GENERATE
|
|
- All timestamps are TIMESTAMPTZ (UTC). Use NOW() for current time.
|
|
- Default time range when not specified: timestamp >= NOW() - INTERVAL '1 hour'
|
|
- Raw row queries: always add LIMIT 10000.
|
|
- Multi-tagname queries: use IN (...)
|
|
""";
|
|
|
|
// ── Schema + Few-shot + Query ─────────────────────────────────
|
|
private string BuildUserMessage(string userQuery)
|
|
{
|
|
var tagBlock = string.Join("\n", _tags.Select(t =>
|
|
$"-- tagname='{t.Tagname}', unit='{t.Unit}', description='{t.Description}'"));
|
|
|
|
return $"""
|
|
## Schema
|
|
|
|
Table: tag_values
|
|
Columns:
|
|
id BIGSERIAL PK
|
|
tagname VARCHAR(255) Tag identifier
|
|
timestamp TIMESTAMPTZ UTC measurement time
|
|
value DOUBLE PRECISION Numeric value
|
|
unit VARCHAR(50) Engineering unit (e.g. '°C', 'bar')
|
|
description TEXT Human-readable tag label
|
|
Index: (tagname, timestamp DESC)
|
|
|
|
## Available Tags
|
|
{tagBlock}
|
|
|
|
## Examples
|
|
|
|
User: PV_101.PV 최근 1시간 1분 단위 평균/최대/최소
|
|
SQL:
|
|
SELECT
|
|
date_trunc('minute', timestamp) AS time_bucket,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
COUNT(*) AS sample_count
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
GROUP BY time_bucket
|
|
ORDER BY time_bucket ASC
|
|
|
|
User: PV_101.PV, FI_301.PV 오늘 통계 요약
|
|
SQL:
|
|
SELECT
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
MIN(unit) AS unit
|
|
FROM tag_values
|
|
WHERE tagname IN ('PV_101.PV', 'FI_301.PV')
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
GROUP BY tagname
|
|
|
|
User: LI_401.PV 오늘 추세 분석
|
|
SQL:
|
|
SELECT
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) AS slope_per_second,
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) * 3600 AS slope_per_hour,
|
|
REGR_R2(value, EXTRACT(EPOCH FROM timestamp)) AS r_squared,
|
|
AVG(value) AS avg_value
|
|
FROM tag_values
|
|
WHERE tagname = 'LI_401.PV'
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
|
|
## Current Question
|
|
User: {userQuery}
|
|
SQL:
|
|
""";
|
|
}
|
|
}
|
|
C# 구현 — TextToSqlService.cs (Ollama 연동)
|
|
csharp
|
|
public class TextToSqlService(PromptBuilder promptBuilder, IHttpClientFactory httpFactory)
|
|
{
|
|
private const string OllamaUrl = "http://localhost:11434/api/chat";
|
|
private const string ModelName = "qwen3:32b"; // Ollama 모델명
|
|
|
|
public async Task<string> GenerateSqlAsync(string userQuery, int maxRetry = 2)
|
|
{
|
|
var messages = promptBuilder.Build(userQuery)
|
|
.Select(m => new { role = m.Role, content = m.Content })
|
|
.ToList<object>();
|
|
|
|
for (int attempt = 0; attempt <= maxRetry; attempt++)
|
|
{
|
|
var sql = await CallOllamaAsync(messages);
|
|
sql = CleanSql(sql);
|
|
|
|
if (sql == "CANNOT_GENERATE")
|
|
throw new InvalidOperationException("질문을 SQL로 변환할 수 없습니다.");
|
|
|
|
var (ok, error) = SqlValidator.Validate(sql);
|
|
if (ok) return sql;
|
|
|
|
// 오류 피드백 후 재시도
|
|
messages.Add(new { role = "assistant", content = sql });
|
|
messages.Add(new { role = "user",
|
|
content = $"SQL validation error: {error}. Fix and return corrected SQL only." });
|
|
}
|
|
|
|
throw new InvalidOperationException("SQL 생성 재시도 횟수를 초과했습니다.");
|
|
}
|
|
|
|
private async Task<string> CallOllamaAsync(List<object> messages)
|
|
{
|
|
var client = httpFactory.CreateClient();
|
|
var body = JsonSerializer.Serialize(new
|
|
{
|
|
model = ModelName,
|
|
stream = false,
|
|
// Qwen3: thinking 비활성화 → 출력 간결화
|
|
options = new { temperature = 0.1, num_predict = 1024 },
|
|
messages
|
|
});
|
|
|
|
var res = await client.PostAsync(OllamaUrl,
|
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
|
var json = await res.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement
|
|
.GetProperty("message")
|
|
.GetProperty("content")
|
|
.GetString() ?? string.Empty;
|
|
}
|
|
|
|
// 마크다운 펜스, 앞뒤 공백, 세미콜론 제거
|
|
private static string CleanSql(string raw)
|
|
=> Regex.Replace(raw.Trim(), @"^```[\w]*\n?|```$", "",
|
|
RegexOptions.Multiline)
|
|
.Trim()
|
|
.TrimEnd(';');
|
|
}
|
|
Qwen3 로컬 LLM 특이사항
|
|
항목 설정 이유
|
|
temperature 0.1 SQL은 창의성 불필요, 낮을수록 안정적
|
|
Think step by step (system) 유지 Qwen3 추론 품질 향상
|
|
output ONLY the final SQL 명시 thinking 텍스트가 출력에 섞이는 것 방지
|
|
CleanSql() 필수 Qwen3가 간혹 ```sql 펜스 포함해서 출력
|
|
Few-shot 수 3~5개 너무 많으면 컨텍스트 낭비, 32B는 3개도 충분
|
|
다음 단계로 SqlValidator.cs (SQL 안전성 검증 로직) 또는 Ollama 모델 세팅 및 성능 튜닝 중 어느 것을 먼저 진행할까요?
|
|
|
|
입력 내용: 한글 문맥 을 sql로 추출 처리 하는부분
|
|
한글 문맥 을 sql로 추출 처리 하는부분
|
|
|
|
오후 3:04
|
|
Claude 응답: 한글 자연어 → SQL 변환의 핵심 처리 파이프라인을 작성하겠습니다.
|
|
한글 자연어 → SQL 변환의 핵심 처리 파이프라인을 작성하겠습니다.
|
|
|
|
전체 처리 흐름
|
|
한글 입력 → 의도 분류 → 엔티티 추출 → 프롬프트 조합 → LLM → SQL 정제 → 검증
|
|
1. 한글 의도 분류기 KoreanIntentClassifier.cs
|
|
LLM 호출 전에 규칙 기반으로 의도를 파악해 Few-shot을 동적으로 선택합니다.
|
|
|
|
csharp
|
|
public enum QueryIntent
|
|
{
|
|
LatestValue, // 현재값, 최신값
|
|
TimeSeriesAggregate,// 시간대별 집계
|
|
StatSummary, // 통계 요약 (평균/최대/최소/표준편차)
|
|
MultiTagCompare, // 복수 태그 비교
|
|
TrendAnalysis, // 추세, 증가/감소
|
|
AnomalyDetection, // 이상값, 초과, 미만
|
|
Unknown
|
|
}
|
|
|
|
public static class KoreanIntentClassifier
|
|
{
|
|
// 키워드 → 의도 매핑 테이블
|
|
private static readonly (string[] Keywords, QueryIntent Intent)[] _rules =
|
|
[
|
|
(["현재값", "지금", "최신", "현재", "실시간"], QueryIntent.LatestValue),
|
|
(["1분", "5분", "10분", "1시간", "시간대별",
|
|
"분 단위", "시간 단위", "구간별", "버킷"], QueryIntent.TimeSeriesAggregate),
|
|
(["평균", "최대", "최소", "표준편차", "분산",
|
|
"통계", "요약", "편차", "avg", "max", "min"], QueryIntent.StatSummary),
|
|
(["비교", "vs", "대비", "차이", "와", "과"], QueryIntent.MultiTagCompare),
|
|
(["추세", "트렌드", "증가", "감소", "상승", "하락",
|
|
"기울기", "변화율", "패턴"], QueryIntent.TrendAnalysis),
|
|
(["이상", "초과", "미만", "벗어", "임계", "알람",
|
|
"경보", "비정상", "이상값"], QueryIntent.AnomalyDetection),
|
|
];
|
|
|
|
public static QueryIntent Classify(string input)
|
|
{
|
|
// 복수 태그 패턴 우선 감지 (쉼표로 여러 태그 나열)
|
|
if (Regex.IsMatch(input, @"[A-Z][A-Z0-9_]+\.[A-Z]+.{0,5}[,와과]"))
|
|
return QueryIntent.MultiTagCompare;
|
|
|
|
foreach (var (keywords, intent) in _rules)
|
|
if (keywords.Any(k => input.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
|
return intent;
|
|
|
|
return QueryIntent.Unknown;
|
|
}
|
|
}
|
|
2. 한글 엔티티 추출기 KoreanEntityExtractor.cs
|
|
자연어에서 tagname, 시간 범위, 임계값, 집계 단위를 추출합니다.
|
|
|
|
csharp
|
|
public record ExtractedEntities(
|
|
List<string> Tagnames,
|
|
TimeRange TimeRange,
|
|
string? BucketInterval, // '1 minute', '1 hour' 등
|
|
double? ThresholdValue,
|
|
string? ThresholdOp // '>', '<', '>=', '<='
|
|
);
|
|
|
|
public record TimeRange(string PostgresInterval, DateTime? AbsoluteFrom = null);
|
|
|
|
public class KoreanEntityExtractor
|
|
{
|
|
private readonly HashSet<string> _knownTagnames;
|
|
|
|
public KoreanEntityExtractor(IEnumerable<string> knownTagnames)
|
|
=> _knownTagnames = new HashSet<string>(knownTagnames, StringComparer.OrdinalIgnoreCase);
|
|
|
|
public ExtractedEntities Extract(string input)
|
|
{
|
|
return new ExtractedEntities(
|
|
Tagnames: ExtractTagnames(input),
|
|
TimeRange: ExtractTimeRange(input),
|
|
BucketInterval: ExtractBucketInterval(input),
|
|
ThresholdValue: ExtractThresholdValue(input),
|
|
ThresholdOp: ExtractThresholdOp(input)
|
|
);
|
|
}
|
|
|
|
// ── Tagname 추출 ───────────────────────────────────────────────
|
|
// 패턴: 영문+숫자+언더스코어+점 조합 (PV_101.PV 형태)
|
|
private List<string> ExtractTagnames(string input)
|
|
{
|
|
var pattern = new Regex(@"\b([A-Z]{2,}[_\d]*\.[A-Z]{2,})\b",
|
|
RegexOptions.IgnoreCase);
|
|
var found = pattern.Matches(input)
|
|
.Select(m => m.Value.ToUpper())
|
|
.Where(t => _knownTagnames.Contains(t))
|
|
.Distinct()
|
|
.ToList();
|
|
return found;
|
|
}
|
|
|
|
// ── 시간 범위 추출 ─────────────────────────────────────────────
|
|
private static TimeRange ExtractTimeRange(string input)
|
|
{
|
|
// 절대 날짜: "오늘", "어제", "이번 주"
|
|
if (input.Contains("오늘"))
|
|
return new TimeRange("", AbsoluteFrom: DateTime.UtcNow.Date);
|
|
if (input.Contains("어제"))
|
|
return new TimeRange("", AbsoluteFrom: DateTime.UtcNow.Date.AddDays(-1));
|
|
if (input.Contains("이번 주") || input.Contains("이번주"))
|
|
return new TimeRange("7 days");
|
|
|
|
// 상대 시간: "최근 N시간", "지난 N분"
|
|
var hourMatch = Regex.Match(input, @"최근\s*(\d+)\s*시간|지난\s*(\d+)\s*시간");
|
|
if (hourMatch.Success)
|
|
{
|
|
var h = hourMatch.Groups[1].Success
|
|
? hourMatch.Groups[1].Value
|
|
: hourMatch.Groups[2].Value;
|
|
return new TimeRange($"{h} hours");
|
|
}
|
|
|
|
var minMatch = Regex.Match(input, @"최근\s*(\d+)\s*분|지난\s*(\d+)\s*분");
|
|
if (minMatch.Success)
|
|
{
|
|
var m = minMatch.Groups[1].Success
|
|
? minMatch.Groups[1].Value
|
|
: minMatch.Groups[2].Value;
|
|
return new TimeRange($"{m} minutes");
|
|
}
|
|
|
|
// 기본값: 1시간
|
|
return new TimeRange("1 hour");
|
|
}
|
|
|
|
// ── 집계 단위(Bucket) 추출 ────────────────────────────────────
|
|
private static string? ExtractBucketInterval(string input)
|
|
{
|
|
if (input.Contains("1분") || input.Contains("분 단위")) return "1 minute";
|
|
if (input.Contains("5분")) return "5 minutes";
|
|
if (input.Contains("10분")) return "10 minutes";
|
|
if (input.Contains("30분")) return "30 minutes";
|
|
if (input.Contains("1시간") || input.Contains("시간 단위")) return "1 hour";
|
|
if (input.Contains("일 단위") || input.Contains("하루")) return "1 day";
|
|
return null;
|
|
}
|
|
|
|
// ── 임계값 추출 ───────────────────────────────────────────────
|
|
private static double? ExtractThresholdValue(string input)
|
|
{
|
|
var m = Regex.Match(input, @"(\d+(?:\.\d+)?)\s*(?:이상|이하|초과|미만|보다|넘|을 넘)");
|
|
return m.Success ? double.Parse(m.Groups[1].Value) : null;
|
|
}
|
|
|
|
private static string? ExtractThresholdOp(string input)
|
|
{
|
|
if (input.Contains("초과") || input.Contains("넘")) return ">";
|
|
if (input.Contains("이상")) return ">=";
|
|
if (input.Contains("미만")) return "<";
|
|
if (input.Contains("이하")) return "<=";
|
|
return null;
|
|
}
|
|
}
|
|
3. 동적 Few-Shot 선택기 FewShotSelector.cs
|
|
의도에 따라 관련 예시만 골라서 프롬프트 길이를 최소화합니다.
|
|
|
|
csharp
|
|
public static class FewShotSelector
|
|
{
|
|
private static readonly Dictionary<QueryIntent, string> _shots = new()
|
|
{
|
|
[QueryIntent.LatestValue] = """
|
|
User: PV_101.PV 현재값
|
|
SQL:
|
|
SELECT timestamp, tagname, value, unit, description
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
ORDER BY timestamp DESC
|
|
LIMIT 1
|
|
""",
|
|
|
|
[QueryIntent.TimeSeriesAggregate] = """
|
|
User: PV_101.PV 최근 1시간 1분 단위 평균/최대/최소
|
|
SQL:
|
|
SELECT
|
|
date_trunc('minute', timestamp) AS time_bucket,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
COUNT(*) AS sample_count
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
GROUP BY time_bucket
|
|
ORDER BY time_bucket ASC
|
|
""",
|
|
|
|
[QueryIntent.StatSummary] = """
|
|
User: FI_301.PV 오늘 통계 요약
|
|
SQL:
|
|
SELECT
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
COUNT(*) AS sample_count,
|
|
MIN(unit) AS unit
|
|
FROM tag_values
|
|
WHERE tagname = 'FI_301.PV'
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
GROUP BY tagname
|
|
""",
|
|
|
|
[QueryIntent.MultiTagCompare] = """
|
|
User: TI_201.PV 와 TI_202.PV 오늘 1시간 단위 평균 비교
|
|
SQL:
|
|
SELECT
|
|
date_trunc('hour', timestamp) AS time_bucket,
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value
|
|
FROM tag_values
|
|
WHERE tagname IN ('TI_201.PV', 'TI_202.PV')
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
GROUP BY time_bucket, tagname
|
|
ORDER BY time_bucket ASC, tagname
|
|
""",
|
|
|
|
[QueryIntent.TrendAnalysis] = """
|
|
User: LI_401.PV 오늘 증가 추세 분석
|
|
SQL:
|
|
SELECT
|
|
tagname,
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) AS slope_per_second,
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) * 3600 AS slope_per_hour,
|
|
REGR_R2(value, EXTRACT(EPOCH FROM timestamp)) AS r_squared,
|
|
AVG(value) AS avg_value,
|
|
MIN(timestamp) AS period_start,
|
|
MAX(timestamp) AS period_end
|
|
FROM tag_values
|
|
WHERE tagname = 'LI_401.PV'
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
GROUP BY tagname
|
|
""",
|
|
|
|
[QueryIntent.AnomalyDetection] = """
|
|
User: PI_501.PV 에서 오늘 100 초과한 데이터
|
|
SQL:
|
|
SELECT
|
|
timestamp, tagname, value, unit
|
|
FROM tag_values
|
|
WHERE tagname = 'PI_501.PV'
|
|
AND timestamp >= date_trunc('day', NOW())
|
|
AND value > 100
|
|
ORDER BY timestamp DESC
|
|
LIMIT 10000
|
|
""",
|
|
};
|
|
|
|
public static string Select(QueryIntent intent)
|
|
=> _shots.TryGetValue(intent, out var shot) ? shot : _shots[QueryIntent.StatSummary];
|
|
}
|
|
4. 핵심 파이프라인 KoreanToSqlPipeline.cs
|
|
위 컴포넌트들을 조합한 메인 파이프라인입니다.
|
|
|
|
csharp
|
|
public class KoreanToSqlPipeline
|
|
{
|
|
private readonly KoreanEntityExtractor _extractor;
|
|
private readonly OllamaClient _llm;
|
|
private readonly SqlValidator _validator;
|
|
private readonly IReadOnlyList<TagMeta> _tags;
|
|
|
|
public KoreanToSqlPipeline(
|
|
IEnumerable<TagMeta> tags,
|
|
OllamaClient llm,
|
|
SqlValidator validator)
|
|
{
|
|
_tags = tags.ToList();
|
|
_extractor = new KoreanEntityExtractor(_tags.Select(t => t.Tagname));
|
|
_llm = llm;
|
|
_validator = validator;
|
|
}
|
|
|
|
public async Task<PipelineResult> RunAsync(string koreanInput)
|
|
{
|
|
// ① 전처리: 공백 정리, 전각→반각 변환
|
|
var normalized = Normalize(koreanInput);
|
|
|
|
// ② 의도 분류
|
|
var intent = KoreanIntentClassifier.Classify(normalized);
|
|
|
|
// ③ 엔티티 추출
|
|
var entities = _extractor.Extract(normalized);
|
|
|
|
// ④ 프롬프트 조합 (의도 맞춤 Few-shot 포함)
|
|
var prompt = BuildPrompt(normalized, intent, entities);
|
|
|
|
// ⑤ LLM 호출 → SQL 생성
|
|
var rawSql = await _llm.CompleteAsync(prompt);
|
|
var sql = CleanSql(rawSql);
|
|
|
|
if (sql == "CANNOT_GENERATE")
|
|
return PipelineResult.Fail("질문을 SQL로 변환할 수 없습니다.", intent, entities);
|
|
|
|
// ⑥ SQL 검증
|
|
var (ok, error) = _validator.Validate(sql);
|
|
if (!ok)
|
|
{
|
|
// 자동 재시도 1회
|
|
sql = await RetryAsync(prompt, sql, error);
|
|
(ok, error) = _validator.Validate(sql);
|
|
}
|
|
|
|
return ok
|
|
? PipelineResult.Ok(sql, intent, entities)
|
|
: PipelineResult.Fail($"SQL 검증 실패: {error}", intent, entities);
|
|
}
|
|
|
|
// ── 한글 정규화 ────────────────────────────────────────────────
|
|
private static string Normalize(string input)
|
|
{
|
|
// 전각 숫자/영문 → 반각
|
|
var sb = new StringBuilder(input.Length);
|
|
foreach (var c in input)
|
|
{
|
|
sb.Append(c >= '\uFF01' && c <= '\uFF5E'
|
|
? (char)(c - 0xFEE0) // 전각 → 반각
|
|
: c);
|
|
}
|
|
// 연속 공백 제거
|
|
return Regex.Replace(sb.ToString().Trim(), @"\s{2,}", " ");
|
|
}
|
|
|
|
// ── 프롬프트 최종 조합 ─────────────────────────────────────────
|
|
private string BuildPrompt(string input, QueryIntent intent, ExtractedEntities entities)
|
|
{
|
|
// 감지된 태그만 메타 블록에 포함 (없으면 전체)
|
|
var relevantTags = entities.Tagnames.Any()
|
|
? _tags.Where(t => entities.Tagnames.Contains(t.Tagname))
|
|
: _tags.Take(20); // 전체가 너무 많으면 상위 20개만
|
|
|
|
var tagBlock = string.Join("\n", relevantTags.Select(t =>
|
|
$"-- tagname='{t.Tagname}', unit='{t.Unit}', description='{t.Description}'"));
|
|
|
|
var timeHint = entities.TimeRange.AbsoluteFrom.HasValue
|
|
? $"-- Extracted time: timestamp >= '{entities.TimeRange.AbsoluteFrom:yyyy-MM-dd}'"
|
|
: $"-- Extracted time: timestamp >= NOW() - INTERVAL '{entities.TimeRange.PostgresInterval}'";
|
|
|
|
var bucketHint = entities.BucketInterval is not null
|
|
? $"-- Extracted bucket: date_trunc('{entities.BucketInterval}', timestamp)"
|
|
: "";
|
|
|
|
var thresholdHint = entities.ThresholdValue.HasValue
|
|
? $"-- Extracted threshold: value {entities.ThresholdOp} {entities.ThresholdValue}"
|
|
: "";
|
|
|
|
var fewShot = FewShotSelector.Select(intent);
|
|
|
|
return $"""
|
|
## Schema
|
|
Table: tag_values (id, tagname, timestamp TIMESTAMPTZ, value DOUBLE PRECISION, unit, description)
|
|
Index: (tagname, timestamp DESC)
|
|
|
|
## Relevant Tags
|
|
{tagBlock}
|
|
|
|
## Extracted Hints (use these in SQL)
|
|
{timeHint}
|
|
{bucketHint}
|
|
{thresholdHint}
|
|
|
|
## Example
|
|
{fewShot}
|
|
|
|
## Current Question
|
|
User: {input}
|
|
SQL:
|
|
""";
|
|
}
|
|
|
|
// ── 재시도 ─────────────────────────────────────────────────────
|
|
private async Task<string> RetryAsync(string originalPrompt, string badSql, string error)
|
|
{
|
|
var retryPrompt = originalPrompt
|
|
+ $"\n\n-- Previous attempt (WRONG): {badSql}"
|
|
+ $"\n-- Validation error: {error}"
|
|
+ "\n-- Fix the error. Output corrected SQL only:\nSQL:";
|
|
|
|
var raw = await _llm.CompleteAsync(retryPrompt);
|
|
return CleanSql(raw);
|
|
}
|
|
|
|
// ── SQL 정제 ───────────────────────────────────────────────────
|
|
private static string CleanSql(string raw)
|
|
=> Regex.Replace(raw.Trim(), @"^```[\w]*\n?|```$", "", RegexOptions.Multiline)
|
|
.Trim()
|
|
.TrimEnd(';');
|
|
}
|
|
|
|
// ── 결과 레코드 ─────────────────────────────────────────────────────
|
|
public record PipelineResult(
|
|
bool Success,
|
|
string Sql,
|
|
string? ErrorMessage,
|
|
QueryIntent Intent,
|
|
ExtractedEntities Entities)
|
|
{
|
|
public static PipelineResult Ok(string sql, QueryIntent i, ExtractedEntities e)
|
|
=> new(true, sql, null, i, e);
|
|
public static PipelineResult Fail(string err, QueryIntent i, ExtractedEntities e)
|
|
=> new(false, "", err, i, e);
|
|
}
|
|
5. 사용 예시
|
|
csharp
|
|
// 실제 호출
|
|
var pipeline = new KoreanToSqlPipeline(tags, ollamaClient, sqlValidator);
|
|
|
|
var result = await pipeline.RunAsync("PV_101.PV 최근 3시간 5분 단위 평균 추세 보여줘");
|
|
|
|
// result.Intent → TimeSeriesAggregate
|
|
// result.Entities → Tagnames: ["PV_101.PV"], TimeRange: "3 hours", Bucket: "5 minutes"
|
|
// result.Sql →
|
|
// SELECT
|
|
// date_trunc('5 minutes', timestamp) AS time_bucket,
|
|
// AVG(value) AS avg_value, ...
|
|
// FROM tag_values
|
|
// WHERE tagname = 'PV_101.PV'
|
|
// AND timestamp >= NOW() - INTERVAL '3 hours'
|
|
// GROUP BY time_bucket ORDER BY time_bucket ASC
|
|
처리 흐름 요약
|
|
"PV_101.PV 최근 3시간 5분 단위 평균"
|
|
│
|
|
▼
|
|
① Normalize → 전각/공백 정리
|
|
② Classify → TimeSeriesAggregate
|
|
③ Extract → tagname: PV_101.PV / interval: 3 hours / bucket: 5 minutes
|
|
④ BuildPrompt → 해당 의도 Few-shot + Extracted Hints 주입
|
|
⑤ LLM (Qwen3) → 원시 SQL 출력
|
|
⑥ CleanSql → 마크다운 펜스, 세미콜론 제거
|
|
⑦ Validate → SELECT 전용 / 구문 검사
|
|
⑧ (실패 시) → 오류 피드백 포함 재시도 1회
|
|
다음 단계로 SqlValidator.cs 구현 또는 파이프라인 단위 테스트 (xUnit + Testcontainers) 를 이어서 진행할 수 있습니다.
|
|
|
|
입력 내용: 한글 엔티티에 " 부터", "까지 " 도 있어야 할거 같은데
|
|
한글 엔티티에 " 부터", "까지 " 도 있어야 할거 같은데
|
|
|
|
오후 3:06
|
|
Claude 응답: 맞습니다.
|
|
맞습니다. 시작~끝 절대 시간 범위가 빠져 있었네요. ExtractTimeRange를 전면 보강하겠습니다.
|
|
|
|
처리해야 할 한글 시간 패턴 전체
|
|
# 상대 범위 (기존)
|
|
"최근 3시간", "지난 30분"
|
|
|
|
# 절대 단일 기준 (기존)
|
|
"오늘", "어제", "이번 주"
|
|
|
|
# 절대 범위 - 부터/까지 (신규)
|
|
"1월 3일 부터 1월 5일 까지"
|
|
"2025-01-03 부터 2025-01-05 까지"
|
|
"어제 09:00 부터 오늘 18:00 까지"
|
|
"오전 9시부터 오후 6시까지" ← 당일 시간 범위
|
|
"1월 3일 14:00 부터 16:30 까지"
|
|
|
|
# 단방향 범위 (신규)
|
|
"1월 3일 이후"
|
|
"2025-01-05 이전"
|
|
"오늘 오후 2시 이후"
|
|
KoreanTimeRangeExtractor.cs — 전체 재작성
|
|
csharp
|
|
public record TimeRange(
|
|
string? PostgresInterval, // 상대: '3 hours'
|
|
DateTime? AbsoluteFrom, // 절대 시작
|
|
DateTime? AbsoluteTo // 절대 끝 (null이면 NOW())
|
|
)
|
|
{
|
|
// PostgreSQL WHERE 절 스니펫 생성
|
|
public string ToSqlCondition(string col = "timestamp") => this switch
|
|
{
|
|
// 상대 범위
|
|
{ PostgresInterval: not null } =>
|
|
$"{col} >= NOW() - INTERVAL '{PostgresInterval}'",
|
|
|
|
// 절대 범위 양쪽
|
|
{ AbsoluteFrom: not null, AbsoluteTo: not null } =>
|
|
$"{col} >= '{AbsoluteFrom:yyyy-MM-dd HH:mm:ss}+00'" +
|
|
$" AND {col} < '{AbsoluteTo:yyyy-MM-dd HH:mm:ss}+00'",
|
|
|
|
// 시작만 (이후)
|
|
{ AbsoluteFrom: not null, AbsoluteTo: null } =>
|
|
$"{col} >= '{AbsoluteFrom:yyyy-MM-dd HH:mm:ss}+00'",
|
|
|
|
// 끝만 (이전)
|
|
{ AbsoluteFrom: null, AbsoluteTo: not null } =>
|
|
$"{col} < '{AbsoluteTo:yyyy-MM-dd HH:mm:ss}+00'",
|
|
|
|
_ => $"{col} >= NOW() - INTERVAL '1 hour'" // 기본값
|
|
};
|
|
}
|
|
|
|
public static class KoreanTimeRangeExtractor
|
|
{
|
|
// 기준 "오늘" (UTC 기준 로컬 날짜로 처리, 필요시 TimeZoneInfo 주입)
|
|
private static DateTime Today => DateTime.UtcNow.Date;
|
|
|
|
public static TimeRange Extract(string input)
|
|
{
|
|
// ── 우선순위 순서대로 시도 ──────────────────────────────────
|
|
return TryAbsoluteRange(input) // "N일 부터 M일 까지"
|
|
?? TryTodayTimeRange(input) // "오전 9시부터 오후 6시까지"
|
|
?? TryOneDirection(input) // "N일 이후 / 이전"
|
|
?? TryRelativeRange(input) // "최근 3시간"
|
|
?? TryNamedDay(input) // "오늘", "어제"
|
|
?? new TimeRange("1 hour", null, null); // 기본값
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// ① 절대 범위: "부터 ~ 까지"
|
|
// ────────────────────────────────────────────────────────────────
|
|
private static TimeRange? TryAbsoluteRange(string input)
|
|
{
|
|
// 패턴: {날짜/시간 표현} 부터 {날짜/시간 표현} 까지
|
|
// 예) "2025-01-03 14:00 부터 2025-01-05 18:30 까지"
|
|
// "1월 3일 부터 1월 5일 까지"
|
|
// "어제 09:00 부터 오늘 17:00 까지"
|
|
|
|
var m = Regex.Match(input,
|
|
@"(.+?)\s*부터\s*(.+?)\s*까지");
|
|
if (!m.Success) return null;
|
|
|
|
var fromStr = m.Groups[1].Value.Trim();
|
|
var toStr = m.Groups[2].Value.Trim();
|
|
|
|
var from = ParseDateTime(fromStr);
|
|
var to = ParseDateTime(toStr);
|
|
|
|
if (from == null || to == null) return null;
|
|
|
|
// "까지"는 해당 시각의 끝 → 시간 없이 날짜만이면 다음날 00:00 처리
|
|
if (!HasTimeComponent(toStr))
|
|
to = to.Value.AddDays(1);
|
|
|
|
return new TimeRange(null, from, to);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// ② 당일 시간 범위: "오전 9시부터 오후 6시까지" (날짜 없이 시간만)
|
|
// ────────────────────────────────────────────────────────────────
|
|
private static TimeRange? TryTodayTimeRange(string input)
|
|
{
|
|
var m = Regex.Match(input,
|
|
@"(오전|오후)?\s*(\d{1,2})시\s*(?:(\d{2})분?)?\s*부터\s*(오전|오후)?\s*(\d{1,2})시\s*(?:(\d{2})분?)?\s*까지");
|
|
if (!m.Success) return null;
|
|
|
|
var from = BuildTimeOnDate(Today,
|
|
m.Groups[1].Value, int.Parse(m.Groups[2].Value),
|
|
m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0);
|
|
var to = BuildTimeOnDate(Today,
|
|
m.Groups[4].Value, int.Parse(m.Groups[5].Value),
|
|
m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0);
|
|
|
|
return new TimeRange(null, from, to);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// ③ 단방향: "이후 / 이전 / 부터 (까지 없음)"
|
|
// ────────────────────────────────────────────────────────────────
|
|
private static TimeRange? TryOneDirection(string input)
|
|
{
|
|
// "N 이후" / "N 부터"
|
|
var afterM = Regex.Match(input, @"(.+?)\s*(이후|부터)(?!\s*.+까지)");
|
|
if (afterM.Success)
|
|
{
|
|
var dt = ParseDateTime(afterM.Groups[1].Value.Trim());
|
|
if (dt != null) return new TimeRange(null, dt, null);
|
|
}
|
|
|
|
// "N 이전"
|
|
var beforeM = Regex.Match(input, @"(.+?)\s*이전");
|
|
if (beforeM.Success)
|
|
{
|
|
var dt = ParseDateTime(beforeM.Groups[1].Value.Trim());
|
|
if (dt != null) return new TimeRange(null, null, dt);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// ④ 상대 범위: "최근/지난 N시간|분|일"
|
|
// ────────────────────────────────────────────────────────────────
|
|
private static TimeRange? TryRelativeRange(string input)
|
|
{
|
|
var patterns = new (string Pattern, Func<string, string> ToInterval)[]
|
|
{
|
|
(@"(?:최근|지난)\s*(\d+)\s*시간", n => $"{n} hours"),
|
|
(@"(?:최근|지난)\s*(\d+)\s*분", n => $"{n} minutes"),
|
|
(@"(?:최근|지난)\s*(\d+)\s*일", n => $"{n} days"),
|
|
(@"(?:최근|지난)\s*(\d+)\s*주", n => $"{int.Parse(n) * 7} days"),
|
|
};
|
|
|
|
foreach (var (pat, toInterval) in patterns)
|
|
{
|
|
var m = Regex.Match(input, pat);
|
|
if (m.Success)
|
|
return new TimeRange(toInterval(m.Groups[1].Value), null, null);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// ⑤ 지정 날짜: "오늘", "어제", "이번 주"
|
|
// ────────────────────────────────────────────────────────────────
|
|
private static TimeRange? TryNamedDay(string input)
|
|
{
|
|
if (input.Contains("오늘"))
|
|
return new TimeRange(null, Today, Today.AddDays(1));
|
|
if (input.Contains("어제"))
|
|
return new TimeRange(null, Today.AddDays(-1), Today);
|
|
if (input.Contains("이번 주") || input.Contains("이번주"))
|
|
return new TimeRange(null, Today.AddDays(-(int)Today.DayOfWeek), null);
|
|
return null;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// 날짜/시간 문자열 파싱 (다양한 한글 형식 지원)
|
|
// ────────────────────────────────────────────────────────────────
|
|
internal static DateTime? ParseDateTime(string s)
|
|
{
|
|
s = s.Trim();
|
|
|
|
// 상대 키워드 → 절대 날짜로 변환
|
|
if (s.StartsWith("오늘")) s = s.Replace("오늘", Today.ToString("yyyy-MM-dd"));
|
|
if (s.StartsWith("어제")) s = s.Replace("어제", Today.AddDays(-1).ToString("yyyy-MM-dd"));
|
|
|
|
// 오전/오후 처리 후 제거
|
|
var ampm = "";
|
|
if (s.Contains("오전")) { ampm = "AM"; s = s.Replace("오전", "").Trim(); }
|
|
if (s.Contains("오후")) { ampm = "PM"; s = s.Replace("오후", "").Trim(); }
|
|
|
|
// 한글 날짜 형식: "1월 3일", "1월 3일 14:00"
|
|
var korDate = Regex.Match(s, @"(\d{1,2})월\s*(\d{1,2})일");
|
|
if (korDate.Success)
|
|
{
|
|
int month = int.Parse(korDate.Groups[1].Value);
|
|
int day = int.Parse(korDate.Groups[2].Value);
|
|
int year = Today.Year;
|
|
// 날짜가 미래면 작년으로
|
|
var dt = new DateTime(year, month, day);
|
|
if (dt > Today.AddDays(1)) dt = dt.AddYears(-1);
|
|
|
|
// 시간 부분 추가 파싱
|
|
var timeM = Regex.Match(s, @"(\d{1,2})[:\s시](\d{2})?분?");
|
|
if (timeM.Success)
|
|
{
|
|
int h = int.Parse(timeM.Groups[1].Value);
|
|
int m = timeM.Groups[2].Success ? int.Parse(timeM.Groups[2].Value) : 0;
|
|
if (ampm == "PM" && h < 12) h += 12;
|
|
if (ampm == "AM" && h == 12) h = 0;
|
|
return dt.AddHours(h).AddMinutes(m);
|
|
}
|
|
return dt;
|
|
}
|
|
|
|
// 시간만: "14:00", "9시 30분", "오후 3시"
|
|
var timeOnly = Regex.Match(s, @"(\d{1,2})[:\s시](\d{2})?분?");
|
|
if (timeOnly.Success)
|
|
{
|
|
int h = int.Parse(timeOnly.Groups[1].Value);
|
|
int m = timeOnly.Groups[2].Success ? int.Parse(timeOnly.Groups[2].Value) : 0;
|
|
if (ampm == "PM" && h < 12) h += 12;
|
|
if (ampm == "AM" && h == 12) h = 0;
|
|
return Today.AddHours(h).AddMinutes(m);
|
|
}
|
|
|
|
// ISO 형식: "2025-01-03", "2025-01-03 14:00"
|
|
if (DateTime.TryParse(s, out var iso))
|
|
return iso;
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool HasTimeComponent(string s)
|
|
=> Regex.IsMatch(s, @"\d{1,2}[:\s시]\d{2}|오전|오후");
|
|
|
|
private static DateTime BuildTimeOnDate(DateTime date, string ampm, int hour, int min)
|
|
{
|
|
if (ampm == "오후" && hour < 12) hour += 12;
|
|
if (ampm == "오전" && hour == 12) hour = 0;
|
|
return date.AddHours(hour).AddMinutes(min);
|
|
}
|
|
}
|
|
ToSqlCondition() 출력 예시
|
|
csharp
|
|
// 테스트
|
|
var cases = new[]
|
|
{
|
|
"1월 3일 부터 1월 5일 까지",
|
|
"어제 09:00 부터 오늘 18:00 까지",
|
|
"오전 9시부터 오후 6시까지",
|
|
"2025-03-01 14:00 부터 2025-03-01 16:30 까지",
|
|
"오늘 오후 2시 이후",
|
|
"2025-01-05 이전",
|
|
"최근 3시간",
|
|
"어제",
|
|
};
|
|
|
|
foreach (var c in cases)
|
|
{
|
|
var tr = KoreanTimeRangeExtractor.Extract(c);
|
|
var sql = tr.ToSqlCondition();
|
|
Console.WriteLine($"입력: {c,-40} → {sql}");
|
|
}
|
|
입력: 1월 3일 부터 1월 5일 까지 → timestamp >= '2025-01-03 00:00:00+00' AND timestamp < '2025-01-06 00:00:00+00'
|
|
입력: 어제 09:00 부터 오늘 18:00 까지 → timestamp >= '2025-04-22 09:00:00+00' AND timestamp < '2025-04-23 18:00:00+00'
|
|
입력: 오전 9시부터 오후 6시까지 → timestamp >= '2025-04-23 09:00:00+00' AND timestamp < '2025-04-23 18:00:00+00'
|
|
입력: 2025-03-01 14:00 부터 2025-03-01 16:30 까지 → timestamp >= '2025-03-01 14:00:00+00' AND timestamp < '2025-03-01 16:30:00+00'
|
|
입력: 오늘 오후 2시 이후 → timestamp >= '2025-04-23 14:00:00+00'
|
|
입력: 2025-01-05 이전 → timestamp < '2025-01-05 00:00:00+00'
|
|
입력: 최근 3시간 → timestamp >= NOW() - INTERVAL '3 hours'
|
|
입력: 어제 → timestamp >= '2025-04-22 00:00:00+00' AND timestamp < '2025-04-23 00:00:00+00'
|
|
KoreanToSqlPipeline 연동 — 변경 부분만
|
|
csharp
|
|
// BuildPrompt() 내부 timeHint 생성 부분 교체
|
|
var timeCondition = entities.TimeRange.ToSqlCondition();
|
|
|
|
var timeHint = $"-- Extracted time condition: {timeCondition}";
|
|
|
|
// 프롬프트 Hints 블록에 그대로 주입
|
|
// LLM이 WHERE 절을 직접 복사해서 쓸 수 있도록 완성된 조건문으로 전달
|
|
xUnit 단위 테스트
|
|
csharp
|
|
public class TimeRangeExtractorTests
|
|
{
|
|
[Theory]
|
|
[InlineData("1월 3일 부터 1월 5일 까지", "01-03", "01-06")]
|
|
[InlineData("어제 09:00 부터 오늘 18:00 까지", null, null )] // 동적, null 체크 생략
|
|
[InlineData("오전 9시부터 오후 6시까지", "09:00", "18:00")]
|
|
public void ShouldExtractAbsoluteRange(string input, string? fromSuffix, string? toSuffix)
|
|
{
|
|
var result = KoreanTimeRangeExtractor.Extract(input);
|
|
Assert.NotNull(result.AbsoluteFrom);
|
|
Assert.NotNull(result.AbsoluteTo);
|
|
if (fromSuffix != null)
|
|
Assert.Contains(fromSuffix, result.AbsoluteFrom!.Value.ToString("MM-dd HH:mm"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("오늘 오후 2시 이후")]
|
|
[InlineData("2025-01-03 부터")]
|
|
public void ShouldExtractFromOnly(string input)
|
|
{
|
|
var result = KoreanTimeRangeExtractor.Extract(input);
|
|
Assert.NotNull(result.AbsoluteFrom);
|
|
Assert.Null(result.AbsoluteTo);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShouldFallbackToDefaultInterval()
|
|
{
|
|
var result = KoreanTimeRangeExtractor.Extract("PV_101.PV 평균값");
|
|
Assert.Equal("1 hour", result.PostgresInterval);
|
|
}
|
|
}
|
|
우선순위 처리 규칙 요약
|
|
우선순위 패턴 예시
|
|
1 {표현} 부터 {표현} 까지 절대 범위 양쪽
|
|
2 오전/오후 N시 부터 ~ 까지 당일 시간 범위
|
|
3 {표현} 이후 / 부터 시작 단방향
|
|
4 {표현} 이전 끝 단방향
|
|
5 최근/지난 N시간/분/일 상대 범위
|
|
6 오늘 / 어제 / 이번 주 지정 날짜
|
|
7 (없음) 기본값 1시간
|
|
타임존 주의: 실제 운영 환경에서는 DateTime.UtcNow 대신 TimeZoneInfo.ConvertTimeToUtc(localNow, kstZone) 처리가 필요합니다. 한국 서버라면 KST(UTC+9) 기준으로 "오늘 09:00"을 UTC 00:00으로 변환해야 집계 오차가 없습니다.
|
|
|
|
KST 변환 처리나 SqlValidator 구현 중 어느 쪽을 이어서 진행할까요?
|
|
|
|
입력 내용: KST 변환처리
|
|
KST 변환처리
|
|
|
|
오후 3:23
|
|
Claude 응답: KST 변환을 전체 파이프라인에 일관되게 적용하는 방식으로 구현하겠습니다.
|
|
KST 변환을 전체 파이프라인에 일관되게 적용하는 방식으로 구현하겠습니다.
|
|
|
|
핵심 원칙
|
|
사용자 입력 (KST 로컬 시간)
|
|
↓ KstClock 변환
|
|
PostgreSQL 저장/조회 (UTC, TIMESTAMPTZ)
|
|
↓ 표시 시 역변환
|
|
결과 출력 (KST 로컬 시간)
|
|
1. KstClock.cs — KST 기준점 단일 관리
|
|
모든 "지금", "오늘"의 기준을 한 곳에서 제공합니다.
|
|
|
|
csharp
|
|
/// <summary>
|
|
/// 전체 파이프라인에서 KST 기준 시간을 단일 제공.
|
|
/// 테스트 시 고정 시간 주입 가능 (IClock).
|
|
/// </summary>
|
|
public interface IClock
|
|
{
|
|
DateTimeOffset UtcNow { get; }
|
|
}
|
|
|
|
public class SystemClock : IClock
|
|
{
|
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
/// 테스트용 고정 시계
|
|
public class FixedClock(DateTimeOffset fixedUtc) : IClock
|
|
{
|
|
public DateTimeOffset UtcNow => fixedUtc;
|
|
}
|
|
|
|
public class KstClock(IClock clock)
|
|
{
|
|
public static readonly TimeZoneInfo Kst =
|
|
TimeZoneInfo.FindSystemTimeZoneById(
|
|
OperatingSystem.IsWindows()
|
|
? "Korea Standard Time" // Windows
|
|
: "Asia/Seoul"); // Linux (Ubuntu)
|
|
|
|
// ── 기준 시각 ────────────────────────────────────────────────
|
|
/// KST 현재 시각
|
|
public DateTimeOffset KstNow
|
|
=> TimeZoneInfo.ConvertTime(clock.UtcNow, Kst);
|
|
|
|
/// KST 오늘 00:00:00 → UTC
|
|
public DateTimeOffset TodayKstStartUtc
|
|
=> ToUtc(KstNow.Date);
|
|
|
|
/// KST 어제 00:00:00 → UTC
|
|
public DateTimeOffset YesterdayKstStartUtc
|
|
=> ToUtc(KstNow.Date.AddDays(-1));
|
|
|
|
// ── 변환 유틸 ────────────────────────────────────────────────
|
|
/// KST DateTime → UTC DateTimeOffset
|
|
public DateTimeOffset ToUtc(DateTime kstDateTime)
|
|
{
|
|
// Kind가 Unspecified면 KST로 간주
|
|
var unspecified = DateTime.SpecifyKind(kstDateTime, DateTimeKind.Unspecified);
|
|
return TimeZoneInfo.ConvertTimeToUtc(unspecified, Kst);
|
|
}
|
|
|
|
/// UTC DateTimeOffset → KST DateTime
|
|
public DateTime ToKst(DateTimeOffset utc)
|
|
=> TimeZoneInfo.ConvertTime(utc, Kst).DateTime;
|
|
|
|
/// PostgreSQL TIMESTAMPTZ 리터럴 생성 (항상 UTC+0 명시)
|
|
public string ToSqlLiteral(DateTime kstDateTime)
|
|
{
|
|
var utc = ToUtc(kstDateTime);
|
|
return $"'{utc:yyyy-MM-dd HH:mm:ss}+00'";
|
|
}
|
|
|
|
/// 상대 INTERVAL은 변환 불필요 — NOW()는 DB 서버 UTC 기준
|
|
public static string ToIntervalCondition(string col, string interval)
|
|
=> $"{col} >= NOW() - INTERVAL '{interval}'";
|
|
}
|
|
2. TimeRange.cs — KST 인식 SQL 조건 생성
|
|
csharp
|
|
public record TimeRange(
|
|
string? PostgresInterval,
|
|
DateTime? KstFrom, // KST 기준 시작
|
|
DateTime? KstTo // KST 기준 끝
|
|
)
|
|
{
|
|
/// <param name="col">타임스탬프 컬럼명</param>
|
|
/// <param name="kst">KST 변환기 (절대 시간인 경우만 필요)</param>
|
|
public string ToSqlCondition(string col, KstClock kst)
|
|
{
|
|
// 상대 범위: NOW() 기반 — KST 변환 불필요
|
|
if (PostgresInterval is not null)
|
|
return KstClock.ToIntervalCondition(col, PostgresInterval);
|
|
|
|
// 절대 범위: KST → UTC 변환 후 리터럴 생성
|
|
var fromSql = KstFrom.HasValue ? kst.ToSqlLiteral(KstFrom.Value) : null;
|
|
var toSql = KstTo.HasValue ? kst.ToSqlLiteral(KstTo.Value) : null;
|
|
|
|
return (fromSql, toSql) switch
|
|
{
|
|
(not null, not null) => $"{col} >= {fromSql} AND {col} < {toSql}",
|
|
(not null, null) => $"{col} >= {fromSql}",
|
|
(null, not null) => $"{col} < {toSql}",
|
|
_ => KstClock.ToIntervalCondition(col, "1 hour")
|
|
};
|
|
}
|
|
}
|
|
3. KoreanTimeRangeExtractor.cs — KstClock 주입
|
|
DateTime.UtcNow.Date 직접 참조를 모두 KstClock 경유로 교체합니다.
|
|
|
|
csharp
|
|
public class KoreanTimeRangeExtractor(KstClock kst)
|
|
{
|
|
// "오늘" 기준은 KST 날짜
|
|
private DateTime KstToday => kst.KstNow.Date;
|
|
|
|
public TimeRange Extract(string input)
|
|
{
|
|
return TryAbsoluteRange(input)
|
|
?? TryTodayTimeRange(input)
|
|
?? TryOneDirection(input)
|
|
?? TryRelativeRange(input)
|
|
?? TryNamedDay(input)
|
|
?? new TimeRange("1 hour", null, null);
|
|
}
|
|
|
|
// ── ① 절대 범위: 부터 ~ 까지 ─────────────────────────────────
|
|
private TimeRange? TryAbsoluteRange(string input)
|
|
{
|
|
var m = Regex.Match(input, @"(.+?)\s*부터\s*(.+?)\s*까지");
|
|
if (!m.Success) return null;
|
|
|
|
var from = ParseKstDateTime(m.Groups[1].Value.Trim());
|
|
var to = ParseKstDateTime(m.Groups[2].Value.Trim());
|
|
if (from == null || to == null) return null;
|
|
|
|
// 날짜만 지정된 "까지" → 해당일 끝 (다음날 00:00)
|
|
if (!HasTimeComponent(m.Groups[2].Value))
|
|
to = to.Value.AddDays(1);
|
|
|
|
return new TimeRange(null, from, to);
|
|
}
|
|
|
|
// ── ② 당일 시간 범위: 오전/오후 N시부터 M시까지 ──────────────
|
|
private TimeRange? TryTodayTimeRange(string input)
|
|
{
|
|
var m = Regex.Match(input,
|
|
@"(오전|오후)?\s*(\d{1,2})\s*시\s*(\d{2})?\s*분?\s*부터" +
|
|
@"\s*(오전|오후)?\s*(\d{1,2})\s*시\s*(\d{2})?\s*분?\s*까지");
|
|
if (!m.Success) return null;
|
|
|
|
var from = BuildKstTime(KstToday,
|
|
m.Groups[1].Value, int.Parse(m.Groups[2].Value),
|
|
m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0);
|
|
var to = BuildKstTime(KstToday,
|
|
m.Groups[4].Value, int.Parse(m.Groups[5].Value),
|
|
m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0);
|
|
|
|
return new TimeRange(null, from, to);
|
|
}
|
|
|
|
// ── ③ 단방향: 이후/부터 or 이전 ─────────────────────────────
|
|
private TimeRange? TryOneDirection(string input)
|
|
{
|
|
var afterM = Regex.Match(input, @"(.+?)\s*(이후|부터)(?!\s*.+까지)");
|
|
if (afterM.Success)
|
|
{
|
|
var dt = ParseKstDateTime(afterM.Groups[1].Value.Trim());
|
|
if (dt != null) return new TimeRange(null, dt, null);
|
|
}
|
|
|
|
var beforeM = Regex.Match(input, @"(.+?)\s*이전");
|
|
if (beforeM.Success)
|
|
{
|
|
var dt = ParseKstDateTime(beforeM.Groups[1].Value.Trim());
|
|
if (dt != null) return new TimeRange(null, null, dt);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── ④ 상대 범위: 최근/지난 N시간|분|일 ──────────────────────
|
|
private static TimeRange? TryRelativeRange(string input)
|
|
{
|
|
var patterns = new (string Pat, Func<string, string> ToInterval)[]
|
|
{
|
|
(@"(?:최근|지난)\s*(\d+)\s*시간", n => $"{n} hours"),
|
|
(@"(?:최근|지난)\s*(\d+)\s*분", n => $"{n} minutes"),
|
|
(@"(?:최근|지난)\s*(\d+)\s*일", n => $"{n} days"),
|
|
(@"(?:최근|지난)\s*(\d+)\s*주", n => $"{int.Parse(n) * 7} days"),
|
|
};
|
|
|
|
foreach (var (pat, fn) in patterns)
|
|
{
|
|
var m = Regex.Match(input, pat);
|
|
if (m.Success)
|
|
return new TimeRange(fn(m.Groups[1].Value), null, null);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── ⑤ 지정 날짜: 오늘/어제/이번 주 ─────────────────────────
|
|
private TimeRange? TryNamedDay(string input)
|
|
{
|
|
if (input.Contains("오늘"))
|
|
return new TimeRange(null, KstToday, KstToday.AddDays(1));
|
|
if (input.Contains("어제"))
|
|
return new TimeRange(null, KstToday.AddDays(-1), KstToday);
|
|
if (input.Contains("이번 주") || input.Contains("이번주"))
|
|
return new TimeRange(null,
|
|
KstToday.AddDays(-(int)KstToday.DayOfWeek + 1), // 월요일
|
|
null);
|
|
return null;
|
|
}
|
|
|
|
// ── 내부 유틸 ─────────────────────────────────────────────────
|
|
|
|
// 한글 날짜/시간 문자열 → KST DateTime
|
|
internal DateTime? ParseKstDateTime(string s)
|
|
{
|
|
s = s.Trim();
|
|
|
|
// 상대 키워드 → KST 날짜 치환
|
|
s = s.Replace("오늘", KstToday.ToString("yyyy-MM-dd"))
|
|
.Replace("어제", KstToday.AddDays(-1).ToString("yyyy-MM-dd"));
|
|
|
|
// 오전/오후 추출
|
|
var ampm = "";
|
|
if (s.Contains("오전")) { ampm = "AM"; s = s.Replace("오전", "").Trim(); }
|
|
if (s.Contains("오후")) { ampm = "PM"; s = s.Replace("오후", "").Trim(); }
|
|
|
|
// 한글 날짜: "1월 3일 14:00"
|
|
var korDate = Regex.Match(s, @"(\d{1,2})월\s*(\d{1,2})일");
|
|
if (korDate.Success)
|
|
{
|
|
int month = int.Parse(korDate.Groups[1].Value);
|
|
int day = int.Parse(korDate.Groups[2].Value);
|
|
var dt = new DateTime(KstToday.Year, month, day);
|
|
if (dt > KstToday.AddDays(1)) dt = dt.AddYears(-1);
|
|
|
|
var timeM = Regex.Match(s, @"(\d{1,2})[:\s시](\d{2})?분?");
|
|
if (timeM.Success)
|
|
{
|
|
int h = int.Parse(timeM.Groups[1].Value);
|
|
int m = timeM.Groups[2].Success ? int.Parse(timeM.Groups[2].Value) : 0;
|
|
return ApplyAmPm(dt.AddHours(h).AddMinutes(m), ampm, h);
|
|
}
|
|
return dt; // KST 날짜, 시간 없음
|
|
}
|
|
|
|
// 시간만: "14:00", "오후 3시 30분"
|
|
var timeOnly = Regex.Match(s, @"(\d{1,2})[:\s시](\d{2})?분?");
|
|
if (timeOnly.Success)
|
|
{
|
|
int h = int.Parse(timeOnly.Groups[1].Value);
|
|
int m = timeOnly.Groups[2].Success ? int.Parse(timeOnly.Groups[2].Value) : 0;
|
|
return ApplyAmPm(KstToday.AddHours(h).AddMinutes(m), ampm, h);
|
|
}
|
|
|
|
// ISO 날짜: "2025-01-03 14:00"
|
|
if (DateTime.TryParse(s, out var iso))
|
|
return iso; // Kind=Unspecified → ToUtc()에서 KST로 해석
|
|
|
|
return null;
|
|
}
|
|
|
|
private static DateTime ApplyAmPm(DateTime dt, string ampm, int originalHour)
|
|
{
|
|
if (ampm == "PM" && originalHour < 12)
|
|
return dt.AddHours(12);
|
|
if (ampm == "AM" && originalHour == 12)
|
|
return dt.AddHours(-12);
|
|
return dt;
|
|
}
|
|
|
|
private static DateTime BuildKstTime(DateTime date, string ampm, int hour, int min)
|
|
{
|
|
if (ampm == "오후" && hour < 12) hour += 12;
|
|
if (ampm == "오전" && hour == 12) hour = 0;
|
|
return date.AddHours(hour).AddMinutes(min);
|
|
}
|
|
|
|
private static bool HasTimeComponent(string s)
|
|
=> Regex.IsMatch(s, @"\d{1,2}[:\s시]\d{2}|오전|오후");
|
|
}
|
|
4. DI 등록 Program.cs
|
|
csharp
|
|
builder.Services.AddSingleton<IClock, SystemClock>();
|
|
builder.Services.AddSingleton<KstClock>();
|
|
builder.Services.AddSingleton<KoreanTimeRangeExtractor>();
|
|
builder.Services.AddSingleton<KoreanEntityExtractor>();
|
|
builder.Services.AddSingleton<KoreanToSqlPipeline>();
|
|
5. xUnit 테스트 — KST/UTC 검증
|
|
csharp
|
|
public class KstConversionTests
|
|
{
|
|
// KST 2025-04-23 09:00:00 = UTC 2025-04-23 00:00:00
|
|
private static readonly KstClock Clock = new(
|
|
new FixedClock(new DateTimeOffset(2025, 4, 23, 0, 0, 0, TimeSpan.Zero)));
|
|
|
|
private readonly KoreanTimeRangeExtractor _ext = new(Clock);
|
|
|
|
[Fact]
|
|
public void 오늘은_KST_기준_날짜()
|
|
{
|
|
var tr = _ext.Extract("오늘");
|
|
var sql = tr.ToSqlCondition("timestamp", Clock);
|
|
|
|
// KST 2025-04-23 00:00 = UTC 2025-04-22 15:00
|
|
Assert.Contains("2025-04-22 15:00:00+00", sql);
|
|
// KST 2025-04-24 00:00 = UTC 2025-04-23 15:00
|
|
Assert.Contains("2025-04-23 15:00:00+00", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public void 부터_까지_KST_UTC_변환()
|
|
{
|
|
var tr = _ext.Extract("4월 23일 09:00 부터 4월 23일 18:00 까지");
|
|
var sql = tr.ToSqlCondition("timestamp", Clock);
|
|
|
|
// KST 09:00 = UTC 00:00
|
|
Assert.Contains("2025-04-23 00:00:00+00", sql);
|
|
// KST 18:00 = UTC 09:00
|
|
Assert.Contains("2025-04-23 09:00:00+00", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public void 상대범위는_UTC변환_불필요()
|
|
{
|
|
var tr = _ext.Extract("최근 3시간");
|
|
var sql = tr.ToSqlCondition("timestamp", Clock);
|
|
|
|
// NOW()는 DB 서버 UTC 기준이므로 변환 없이 그대로
|
|
Assert.Equal("timestamp >= NOW() - INTERVAL '3 hours'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public void 오전_오후_시간_변환()
|
|
{
|
|
var tr = _ext.Extract("오전 9시부터 오후 6시까지");
|
|
var sql = tr.ToSqlCondition("timestamp", Clock);
|
|
|
|
Assert.Contains("2025-04-23 00:00:00+00", sql); // KST 09:00 → UTC 00:00
|
|
Assert.Contains("2025-04-23 09:00:00+00", sql); // KST 18:00 → UTC 09:00
|
|
}
|
|
}
|
|
변환 결과 요약표
|
|
한글 입력 KST (사용자) UTC (DB 저장)
|
|
오늘 2025-04-23 00:00 ~ 24:00 KST 2025-04-22 15:00 ~ 2025-04-23 15:00 UTC
|
|
어제 2025-04-22 00:00 ~ 24:00 KST 2025-04-21 15:00 ~ 2025-04-22 15:00 UTC
|
|
오전 9시 2025-04-23 09:00 KST 2025-04-23 00:00 UTC
|
|
오후 6시 2025-04-23 18:00 KST 2025-04-23 09:00 UTC
|
|
최근 3시간 — NOW() - INTERVAL '3 hours' (변환 없음)
|
|
Ubuntu 타임존 설정 확인
|
|
bash
|
|
# Ubuntu 서버 타임존 확인
|
|
timedatectl
|
|
|
|
# PostgreSQL 세션 타임존 확인
|
|
psql -c "SHOW timezone;"
|
|
|
|
# .NET에서 Asia/Seoul 존재 여부 확인 (tzdata 필요)
|
|
sudo apt-get install -y tzdata
|
|
|
|
# appsettings.json 에 명시적 타임존 설정 권장
|
|
json
|
|
// appsettings.json
|
|
{
|
|
"TimeZone": {
|
|
"Id": "Asia/Seoul",
|
|
"DisplayName": "Korea Standard Time (KST, UTC+9)"
|
|
}
|
|
}
|
|
csharp
|
|
// Program.cs — 앱 시작 시 타임존 검증
|
|
var tzId = builder.Configuration["TimeZone:Id"]!;
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
|
Console.WriteLine($"[KST] TimeZone loaded: {tz.DisplayName} (UTC{tz.BaseUtcOffset:hh\\:mm})");
|
|
다음 단계로 SqlValidator.cs 또는 전체 파이프라인 통합 테스트 중 어느 쪽을 이어서 진행할까요?
|
|
|
|
입력 내용: SqlValidator.
|
|
SqlValidator.cs
|
|
|
|
오후 3:25
|
|
Claude 응답: SqlValidator.
|
|
SqlValidator.cs 전체 구현
|
|
검증 단계를 4계층으로 나눠 순서대로 통과해야 최종 승인합니다.
|
|
|
|
① 구조 검사 → ② 위험 키워드 → ③ 허용 절 화이트리스트 → ④ 파라미터 안전성
|
|
1. 검증 결과 타입
|
|
csharp
|
|
public enum ValidationFailReason
|
|
{
|
|
EmptyInput,
|
|
NotSelectStatement,
|
|
DangerousKeyword,
|
|
ForbiddenClause,
|
|
DisallowedFunction,
|
|
UnsafeTableReference,
|
|
SubqueryDepthExceeded,
|
|
MissingRequiredTable,
|
|
SuspiciousPattern,
|
|
}
|
|
|
|
public record ValidationResult(
|
|
bool IsValid,
|
|
ValidationFailReason? Reason = null,
|
|
string? Message = null)
|
|
{
|
|
public static ValidationResult Ok()
|
|
=> new(true);
|
|
public static ValidationResult Fail(ValidationFailReason reason, string message)
|
|
=> new(false, reason, message);
|
|
|
|
public void Deconstruct(out bool ok, out string? error)
|
|
{
|
|
ok = IsValid;
|
|
error = Message;
|
|
}
|
|
}
|
|
2. SqlValidator.cs 본체
|
|
csharp
|
|
/// <summary>
|
|
/// LLM이 생성한 SQL을 PostgreSQL 실행 전에 다단계 검증.
|
|
/// SELECT 전용, 허용된 테이블/함수만 허용.
|
|
/// </summary>
|
|
public class SqlValidator
|
|
{
|
|
// ── 설정 ────────────────────────────────────────────────────────
|
|
private readonly SqlValidatorOptions _opts;
|
|
|
|
public SqlValidator(SqlValidatorOptions? opts = null)
|
|
=> _opts = opts ?? SqlValidatorOptions.Default;
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Public Entry Point
|
|
// ════════════════════════════════════════════════════════════════
|
|
public ValidationResult Validate(string sql)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sql))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.EmptyInput, "SQL이 비어 있습니다.");
|
|
|
|
var normalized = Normalize(sql);
|
|
|
|
return CheckSelectOnly(normalized)
|
|
?? CheckDangerousKeywords(normalized)
|
|
?? CheckForbiddenClauses(normalized)
|
|
?? CheckDisallowedFunctions(normalized)
|
|
?? CheckTableReferences(normalized)
|
|
?? CheckSubqueryDepth(normalized)
|
|
?? CheckSuspiciousPatterns(normalized)
|
|
?? ValidationResult.Ok();
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ① SELECT 전용 검사
|
|
// ════════════════════════════════════════════════════════════════
|
|
private static ValidationResult? CheckSelectOnly(string sql)
|
|
{
|
|
// 첫 번째 유효 토큰이 SELECT 이어야 함
|
|
var firstToken = sql.TrimStart().Split(
|
|
[' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
|
.FirstOrDefault()?.ToUpperInvariant();
|
|
|
|
if (firstToken != "SELECT")
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.NotSelectStatement,
|
|
$"SELECT로 시작해야 합니다. 감지된 시작: '{firstToken}'");
|
|
|
|
return null;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ② 위험 키워드 검사 (단어 경계 기준)
|
|
// ════════════════════════════════════════════════════════════════
|
|
private static readonly string[] DangerousKeywords =
|
|
[
|
|
// DDL
|
|
"DROP", "CREATE", "ALTER", "TRUNCATE", "RENAME",
|
|
// DML
|
|
"INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT",
|
|
// 권한
|
|
"GRANT", "REVOKE",
|
|
// 트랜잭션 제어
|
|
"COMMIT", "ROLLBACK", "SAVEPOINT",
|
|
// 시스템
|
|
"COPY", "VACUUM", "ANALYZE", "REINDEX", "CLUSTER",
|
|
// 확장
|
|
"CREATE EXTENSION", "LOAD",
|
|
// 파일 접근
|
|
"PG_READ_FILE", "PG_WRITE_FILE", "PG_READ_BINARY_FILE",
|
|
];
|
|
|
|
private static ValidationResult? CheckDangerousKeywords(string sql)
|
|
{
|
|
foreach (var kw in DangerousKeywords)
|
|
{
|
|
// 단어 경계 검사: DROP TABLE 처럼 앞뒤가 비-알파벳
|
|
var pattern = $@"(?<![A-Z_])({Regex.Escape(kw)})(?![A-Z_])";
|
|
if (Regex.IsMatch(sql, pattern, RegexOptions.IgnoreCase))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.DangerousKeyword,
|
|
$"허용되지 않는 키워드가 포함되어 있습니다: '{kw}'");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ③ 허용되지 않는 절(Clause) 검사
|
|
// ════════════════════════════════════════════════════════════════
|
|
private static readonly string[] ForbiddenClauses =
|
|
[
|
|
// 스토어드 프로시저 / 함수 실행
|
|
@"CALL\s+\w",
|
|
@"EXECUTE\s+\w",
|
|
@"PERFORM\s+\w",
|
|
// INTO (SELECT INTO = 테이블 생성)
|
|
@"SELECT\s+.+\s+INTO\s+\w",
|
|
// 다중 구문 (세미콜론 뒤 추가 구문)
|
|
@";\s*\w",
|
|
// 주석을 이용한 우회 시도
|
|
@"/\*.*?\*/", // 블록 주석
|
|
@"--[^\n]*\n.*SELECT", // 인라인 주석 뒤 SELECT (우회 패턴)
|
|
// pg_* 시스템 함수 직접 호출
|
|
@"pg_sleep\s*\(",
|
|
@"pg_cancel_backend\s*\(",
|
|
@"pg_terminate_backend\s*\(",
|
|
];
|
|
|
|
private static ValidationResult? CheckForbiddenClauses(string sql)
|
|
{
|
|
foreach (var pattern in ForbiddenClauses)
|
|
{
|
|
if (Regex.IsMatch(sql, pattern,
|
|
RegexOptions.IgnoreCase | RegexOptions.Singleline))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.ForbiddenClause,
|
|
$"허용되지 않는 구문 패턴이 감지되었습니다: '{pattern}'");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ④ 허용 함수 화이트리스트
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 집계/분석/시간 관련 함수만 허용
|
|
private static readonly HashSet<string> AllowedFunctions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
// 집계
|
|
"COUNT", "SUM", "AVG", "MIN", "MAX", "STDDEV", "STDDEV_POP", "STDDEV_SAMP",
|
|
"VARIANCE", "VAR_POP", "VAR_SAMP",
|
|
// 통계 회귀
|
|
"REGR_SLOPE", "REGR_INTERCEPT", "REGR_R2", "REGR_COUNT",
|
|
"REGR_AVGX", "REGR_AVGY", "REGR_SXX", "REGR_SYY", "REGR_SXY",
|
|
// 윈도우
|
|
"ROW_NUMBER", "RANK", "DENSE_RANK", "NTILE",
|
|
"LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTH_VALUE",
|
|
// 시간
|
|
"NOW", "CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME",
|
|
"DATE_TRUNC", "DATE_PART", "EXTRACT", "AGE",
|
|
"TO_TIMESTAMP", "TO_CHAR",
|
|
// TimescaleDB
|
|
"TIME_BUCKET", "TIME_BUCKET_GAPFILL",
|
|
"LOCF", "INTERPOLATE",
|
|
// 문자열 (tagname 처리용)
|
|
"UPPER", "LOWER", "TRIM", "LTRIM", "RTRIM",
|
|
"SUBSTRING", "SPLIT_PART", "REGEXP_REPLACE",
|
|
// 타입 변환
|
|
"CAST", "COALESCE", "NULLIF", "GREATEST", "LEAST",
|
|
// 수학
|
|
"ABS", "ROUND", "CEIL", "FLOOR", "POWER", "SQRT", "LN", "LOG",
|
|
};
|
|
|
|
// 함수 호출 패턴: 단어(공백*)( 형태
|
|
private static readonly Regex FuncCallPattern =
|
|
new(@"\b([A-Z_][A-Z0-9_]*)\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
|
|
private static ValidationResult? CheckDisallowedFunctions(string sql)
|
|
{
|
|
// 문자열 리터럴 내부는 제외 (작은따옴표 사이)
|
|
var stripped = Regex.Replace(sql, @"'[^']*'", "''");
|
|
|
|
foreach (Match m in FuncCallPattern.Matches(stripped))
|
|
{
|
|
var fn = m.Groups[1].Value.ToUpperInvariant();
|
|
|
|
// SQL 키워드는 함수가 아님 (CASE WHEN, OVER, FILTER 등)
|
|
if (SqlKeywordExceptions.Contains(fn)) continue;
|
|
|
|
if (!AllowedFunctions.Contains(fn))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.DisallowedFunction,
|
|
$"허용되지 않는 함수입니다: '{fn}()'");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static readonly HashSet<string> SqlKeywordExceptions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"OVER", "FILTER", "WITHIN", "CASE", "WHEN", "THEN", "ELSE", "END",
|
|
"IN", "NOT", "AND", "OR", "AS", "ON", "AT", "BY",
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ⑤ 테이블 참조 검사 (허용 테이블 화이트리스트)
|
|
// ════════════════════════════════════════════════════════════════
|
|
private ValidationResult? CheckTableReferences(string sql)
|
|
{
|
|
// FROM / JOIN 뒤에 오는 테이블명 추출
|
|
var tablePattern = new Regex(
|
|
@"(?:FROM|JOIN)\s+([A-Z_][A-Z0-9_.]*)",
|
|
RegexOptions.IgnoreCase);
|
|
|
|
// 필수 테이블 포함 여부
|
|
var tables = tablePattern.Matches(sql)
|
|
.Select(m => m.Groups[1].Value.ToLowerInvariant())
|
|
.ToList();
|
|
|
|
if (_opts.RequiredTables.Any() &&
|
|
!tables.Any(t => _opts.RequiredTables.Contains(t)))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.MissingRequiredTable,
|
|
$"필수 테이블이 없습니다. 필요: [{string.Join(", ", _opts.RequiredTables)}]");
|
|
|
|
// 허용 테이블 외 참조 차단
|
|
foreach (var table in tables)
|
|
{
|
|
// 서브쿼리 별칭은 소문자 단어 — 허용 목록에 없어도 통과
|
|
if (_opts.AllowedTables.Any() &&
|
|
!_opts.AllowedTables.Contains(table) &&
|
|
!IsLikelySubqueryAlias(table))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.UnsafeTableReference,
|
|
$"허용되지 않는 테이블 참조: '{table}'");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 서브쿼리 별칭: 2~20자 소문자 단어
|
|
private static bool IsLikelySubqueryAlias(string name)
|
|
=> Regex.IsMatch(name, @"^[a-z][a-z0-9_]{1,19}$");
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ⑥ 서브쿼리 깊이 검사
|
|
// ════════════════════════════════════════════════════════════════
|
|
private ValidationResult? CheckSubqueryDepth(string sql)
|
|
{
|
|
int depth = 0, maxDepth = 0;
|
|
foreach (var c in sql)
|
|
{
|
|
if (c == '(') { depth++; maxDepth = Math.Max(maxDepth, depth); }
|
|
else if (c == ')') depth--;
|
|
}
|
|
|
|
if (maxDepth > _opts.MaxSubqueryDepth)
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.SubqueryDepthExceeded,
|
|
$"서브쿼리 깊이 초과: {maxDepth} > 허용 {_opts.MaxSubqueryDepth}");
|
|
|
|
return null;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// ⑦ 의심 패턴 검사 (SQL Injection 우회 시도)
|
|
// ════════════════════════════════════════════════════════════════
|
|
private static readonly (string Pattern, string Desc)[] SuspiciousPatterns =
|
|
[
|
|
(@"'\s*OR\s*'?\d", "OR 기반 Injection 패턴"),
|
|
(@"'\s*;\s*--", "주석 종료 Injection"),
|
|
(@"UNION\s+ALL\s+SELECT", "UNION 기반 Injection"),
|
|
(@"UNION\s+SELECT", "UNION 기반 Injection"),
|
|
(@"0x[0-9A-F]{4,}", "16진수 인코딩 우회"),
|
|
(@"CHAR\s*\(\s*\d", "CHAR() 인코딩 우회"),
|
|
(@"WAITFOR\s+DELAY", "시간 지연 공격"),
|
|
(@"BENCHMARK\s*\(", "벤치마크 기반 공격"),
|
|
(@"\bINFORMATION_SCHEMA\b","시스템 스키마 접근"),
|
|
(@"\bPG_CATALOG\b", "시스템 카탈로그 접근"),
|
|
(@"\bPG_STAT\b", "통계 뷰 접근"),
|
|
];
|
|
|
|
private static ValidationResult? CheckSuspiciousPatterns(string sql)
|
|
{
|
|
foreach (var (pattern, desc) in SuspiciousPatterns)
|
|
{
|
|
if (Regex.IsMatch(sql, pattern, RegexOptions.IgnoreCase))
|
|
return ValidationResult.Fail(
|
|
ValidationFailReason.SuspiciousPattern,
|
|
$"의심스러운 패턴 감지: {desc}");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 공통 정규화
|
|
// ════════════════════════════════════════════════════════════════
|
|
private static string Normalize(string sql)
|
|
=> Regex.Replace(sql.Trim().TrimEnd(';'), @"\s+", " ");
|
|
}
|
|
3. SqlValidatorOptions.cs
|
|
csharp
|
|
public class SqlValidatorOptions
|
|
{
|
|
/// FROM / JOIN 에 반드시 포함되어야 할 테이블
|
|
public HashSet<string> RequiredTables { get; init; } = ["tag_values"];
|
|
|
|
/// 참조 가능한 테이블 목록 (비어있으면 모두 허용)
|
|
public HashSet<string> AllowedTables { get; init; } =
|
|
[
|
|
"tag_values",
|
|
"tag_metadata", // 메타 테이블이 별도로 있는 경우
|
|
];
|
|
|
|
/// 허용 서브쿼리 최대 깊이
|
|
public int MaxSubqueryDepth { get; init; } = 4;
|
|
|
|
public static SqlValidatorOptions Default => new();
|
|
}
|
|
4. DI 등록 + 파이프라인 연동
|
|
csharp
|
|
// Program.cs
|
|
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
|
|
{
|
|
RequiredTables = ["tag_values"],
|
|
AllowedTables = ["tag_values", "tag_metadata"],
|
|
MaxSubqueryDepth = 4
|
|
});
|
|
builder.Services.AddSingleton<SqlValidator>();
|
|
csharp
|
|
// KoreanToSqlPipeline.cs — 검증 호출 부분
|
|
var (ok, error) = _validator.Validate(sql);
|
|
|
|
if (!ok)
|
|
{
|
|
_logger.LogWarning("SQL 검증 실패: {Error} | SQL: {Sql}", error, sql);
|
|
sql = await RetryAsync(prompt, sql, error!);
|
|
(ok, error) = _validator.Validate(sql);
|
|
}
|
|
|
|
return ok
|
|
? PipelineResult.Ok(sql, intent, entities)
|
|
: PipelineResult.Fail($"검증 실패: {error}", intent, entities);
|
|
5. xUnit 테스트
|
|
csharp
|
|
public class SqlValidatorTests
|
|
{
|
|
private readonly SqlValidator _v = new();
|
|
|
|
// ── 정상 케이스 ────────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("SELECT AVG(value) FROM tag_values WHERE tagname = 'PV_101.PV'")]
|
|
[InlineData("SELECT date_trunc('minute', timestamp), AVG(value) FROM tag_values GROUP BY 1")]
|
|
[InlineData("SELECT tagname, REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) FROM tag_values GROUP BY tagname")]
|
|
public void ValidSql_ShouldPass(string sql)
|
|
{
|
|
var (ok, _) = _v.Validate(sql);
|
|
Assert.True(ok);
|
|
}
|
|
|
|
// ── 위험 키워드 ────────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("DROP TABLE tag_values", ValidationFailReason.NotSelectStatement)]
|
|
[InlineData("DELETE FROM tag_values", ValidationFailReason.NotSelectStatement)]
|
|
[InlineData("SELECT 1; DROP TABLE tag_values", ValidationFailReason.ForbiddenClause)]
|
|
[InlineData("SELECT pg_sleep(5)", ValidationFailReason.ForbiddenClause)]
|
|
public void DangerousSql_ShouldFail(string sql, ValidationFailReason expected)
|
|
{
|
|
var result = _v.Validate(sql);
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(expected, result.Reason);
|
|
}
|
|
|
|
// ── 허용되지 않는 함수 ─────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("SELECT PG_READ_FILE('/etc/passwd') FROM tag_values")]
|
|
[InlineData("SELECT SYSTEM('ls') FROM tag_values")]
|
|
public void DisallowedFunction_ShouldFail(string sql)
|
|
{
|
|
var result = _v.Validate(sql);
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(ValidationFailReason.DisallowedFunction, result.Reason);
|
|
}
|
|
|
|
// ── SQL Injection 패턴 ─────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("SELECT * FROM tag_values WHERE tagname = '' OR '1'='1'")]
|
|
[InlineData("SELECT * FROM tag_values UNION SELECT * FROM pg_catalog.pg_tables")]
|
|
public void InjectionPattern_ShouldFail(string sql)
|
|
{
|
|
var result = _v.Validate(sql);
|
|
Assert.False(result.IsValid);
|
|
}
|
|
|
|
// ── 허용되지 않는 테이블 ───────────────────────────────────────
|
|
[Fact]
|
|
public void ForbiddenTable_ShouldFail()
|
|
{
|
|
var sql = "SELECT * FROM information_schema.tables";
|
|
var result = _v.Validate(sql);
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(ValidationFailReason.SuspiciousPattern, result.Reason);
|
|
}
|
|
|
|
// ── 필수 테이블 누락 ───────────────────────────────────────────
|
|
[Fact]
|
|
public void MissingRequiredTable_ShouldFail()
|
|
{
|
|
var sql = "SELECT 1";
|
|
var result = _v.Validate(sql);
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(ValidationFailReason.MissingRequiredTable, result.Reason);
|
|
}
|
|
}
|
|
검증 계층 요약
|
|
계층 검사 항목 실패 시
|
|
① 구조 첫 토큰이 SELECT인가 즉시 거부
|
|
② 위험 키워드 DROP/DELETE/INSERT 등 18종 즉시 거부
|
|
③ 금지 절 세미콜론 다중 구문, pg_sleep 등 즉시 거부
|
|
④ 함수 화이트리스트 허용 목록 외 함수 호출 즉시 거부
|
|
⑤ 테이블 참조 허용 테이블 외 접근 즉시 거부
|
|
⑥ 서브쿼리 깊이 괄호 중첩 4단계 초과 즉시 거부
|
|
⑦ 의심 패턴 UNION Injection, 시스템 뷰 등 즉시 거부
|
|
다음 단계로 파이프라인 통합 테스트 (xUnit + Testcontainers + 실제 PostgreSQL) 로 이어서 진행할까요?
|
|
|
|
입력 내용: 그래
|
|
그래
|
|
|
|
오후 3:26
|
|
Claude 응답: 파이프라인 통합 테스트 — xUnit + Testcontainers + PostgreSQL
|
|
파이프라인 통합 테스트 — xUnit + Testcontainers + PostgreSQL
|
|
프로젝트 구조
|
|
TextToSql.Tests/
|
|
├── Infrastructure/
|
|
│ ├── PostgresFixture.cs ← Testcontainers DB 컨테이너
|
|
│ └── TestDataSeeder.cs ← 테스트 데이터 시딩
|
|
├── Pipeline/
|
|
│ ├── KoreanTimeRangeTests.cs ← 시간 범위 변환
|
|
│ ├── EntityExtractorTests.cs ← 엔티티 추출
|
|
│ ├── SqlValidatorTests.cs ← SQL 검증
|
|
│ └── FullPipelineTests.cs ← End-to-End 통합
|
|
└── Helpers/
|
|
└── SqlExecutionHelper.cs ← 실제 DB 실행 검증
|
|
1. NuGet 패키지
|
|
xml
|
|
<!-- TextToSql.Tests.csproj -->
|
|
<ItemGroup>
|
|
<!-- Testcontainers -->
|
|
<PackageReference Include="Testcontainers.PostgreSql" Version="3.9.0" />
|
|
|
|
<!-- xUnit -->
|
|
<PackageReference Include="xunit" Version="2.9.0" />
|
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
|
|
|
<!-- Assertion -->
|
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
|
|
|
<!-- DB -->
|
|
<PackageReference Include="Dapper" Version="2.1.35" />
|
|
<PackageReference Include="Npgsql" Version="8.0.3" />
|
|
|
|
<!-- DI -->
|
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
|
</ItemGroup>
|
|
2. PostgresFixture.cs — 컨테이너 생명주기 관리
|
|
csharp
|
|
/// <summary>
|
|
/// 테스트 클래스 전체에서 PostgreSQL 컨테이너 1개를 공유.
|
|
/// IAsyncLifetime 으로 시작/종료 관리.
|
|
/// </summary>
|
|
public class PostgresFixture : IAsyncLifetime
|
|
{
|
|
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
|
.WithImage("timescale/timescaledb:latest-pg16") // TimescaleDB 포함
|
|
.WithDatabase("testdb")
|
|
.WithUsername("testuser")
|
|
.WithPassword("testpass")
|
|
.WithCleanUp(true) // 테스트 종료 후 자동 삭제
|
|
.Build();
|
|
|
|
public string ConnectionString => _container.GetConnectionString();
|
|
|
|
// ── 컨테이너 시작 + 스키마 초기화 ───────────────────────────────
|
|
public async Task InitializeAsync()
|
|
{
|
|
await _container.StartAsync();
|
|
await using var conn = new NpgsqlConnection(ConnectionString);
|
|
await conn.OpenAsync();
|
|
|
|
// TimescaleDB 확장 활성화
|
|
await conn.ExecuteAsync("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;");
|
|
|
|
// 테이블 생성
|
|
await conn.ExecuteAsync("""
|
|
CREATE TABLE IF NOT EXISTS tag_values (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tagname VARCHAR(255) NOT NULL,
|
|
timestamp TIMESTAMPTZ NOT NULL,
|
|
value DOUBLE PRECISION NOT NULL,
|
|
unit VARCHAR(50),
|
|
description TEXT
|
|
);
|
|
""");
|
|
|
|
// TimescaleDB 하이퍼테이블 변환
|
|
await conn.ExecuteAsync("""
|
|
SELECT create_hypertable(
|
|
'tag_values', 'timestamp',
|
|
if_not_exists => TRUE
|
|
);
|
|
""");
|
|
|
|
// 인덱스
|
|
await conn.ExecuteAsync("""
|
|
CREATE INDEX IF NOT EXISTS idx_tag_time
|
|
ON tag_values (tagname, timestamp DESC);
|
|
""");
|
|
|
|
// 테스트 데이터 시딩
|
|
await TestDataSeeder.SeedAsync(conn);
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
=> await _container.DisposeAsync();
|
|
|
|
// 공유 연결 생성 헬퍼
|
|
public NpgsqlConnection CreateConnection()
|
|
=> new(ConnectionString);
|
|
}
|
|
3. TestDataSeeder.cs — 현실적인 테스트 데이터
|
|
csharp
|
|
public static class TestDataSeeder
|
|
{
|
|
// 테스트용 태그 메타데이터
|
|
public static readonly List<TagMeta> Tags =
|
|
[
|
|
new("PV_101.PV", "°C", "반응기 입구 온도"),
|
|
new("PV_101.SV", "°C", "반응기 입구 온도 설정값"),
|
|
new("FI_301.PV", "L/min", "공급 유량계"),
|
|
new("LI_401.PV", "%", "저장탱크 레벨"),
|
|
new("PI_501.PV", "bar", "라인 압력"),
|
|
];
|
|
|
|
public static async Task SeedAsync(NpgsqlConnection conn)
|
|
{
|
|
// 기준 시각: 현재 UTC 기준 48시간치 데이터 생성
|
|
var baseUtc = DateTime.UtcNow.AddHours(-48);
|
|
var rows = new List<object>();
|
|
|
|
var rand = new Random(42); // 재현 가능한 시드
|
|
|
|
foreach (var tag in Tags)
|
|
{
|
|
// 10초 간격으로 샘플 생성 (48h = 17,280 샘플/태그)
|
|
for (var t = baseUtc; t <= DateTime.UtcNow; t = t.AddSeconds(10))
|
|
{
|
|
var baseValue = tag.Tagname switch
|
|
{
|
|
"PV_101.PV" => 75.0 + Math.Sin(t.Hour * 0.3) * 5, // 온도: 70~80
|
|
"PV_101.SV" => 75.0, // 설정값: 고정
|
|
"FI_301.PV" => 120.0 + rand.NextDouble() * 10, // 유량: 120~130
|
|
"LI_401.PV" => 50.0 + Math.Cos(t.Hour * 0.2) * 15, // 레벨: 35~65
|
|
"PI_501.PV" => 3.5 + rand.NextDouble() * 0.5, // 압력: 3.5~4.0
|
|
_ => 0.0
|
|
};
|
|
|
|
// 이상값 10개 강제 삽입 (PI_501.PV 에만)
|
|
if (tag.Tagname == "PI_501.PV" && rand.Next(1000) < 3)
|
|
baseValue = 15.0; // 임계값 초과 이상값
|
|
|
|
rows.Add(new
|
|
{
|
|
tagname = tag.Tagname,
|
|
timestamp = t,
|
|
value = Math.Round(baseValue + rand.NextDouble() * 0.1, 4),
|
|
unit = tag.Unit,
|
|
description = tag.Description
|
|
});
|
|
}
|
|
}
|
|
|
|
// 배치 INSERT (Dapper)
|
|
const int BatchSize = 1000;
|
|
for (int i = 0; i < rows.Count; i += BatchSize)
|
|
{
|
|
var batch = rows.Skip(i).Take(BatchSize);
|
|
await conn.ExecuteAsync("""
|
|
INSERT INTO tag_values (tagname, timestamp, value, unit, description)
|
|
VALUES (@tagname, @timestamp, @value, @unit, @description)
|
|
ON CONFLICT DO NOTHING
|
|
""", batch);
|
|
}
|
|
}
|
|
}
|
|
4. SqlExecutionHelper.cs — 실제 DB 실행 검증
|
|
csharp
|
|
/// <summary>
|
|
/// 검증된 SQL을 실제 PostgreSQL에 실행하고 결과를 반환.
|
|
/// 테스트에서 "SQL이 실행 가능한지" 검증에 사용.
|
|
/// </summary>
|
|
public class SqlExecutionHelper(string connectionString)
|
|
{
|
|
// SQL 실행 → 결과 행 수 반환
|
|
public async Task<int> ExecuteAndCountAsync(string sql)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
|
|
// EXPLAIN으로 먼저 실행 가능 여부 확인
|
|
await conn.ExecuteAsync($"EXPLAIN {sql}");
|
|
|
|
// 실제 실행
|
|
var rows = await conn.QueryAsync(sql);
|
|
return rows.Count();
|
|
}
|
|
|
|
// SQL 실행 → 첫 번째 행의 특정 컬럼 값 반환
|
|
public async Task<T?> ExecuteScalarAsync<T>(string sql)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
return await conn.ExecuteScalarAsync<T>(sql);
|
|
}
|
|
|
|
// SQL 실행 → 동적 결과 반환
|
|
public async Task<IEnumerable<dynamic>> ExecuteAsync(string sql)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
return await conn.QueryAsync(sql);
|
|
}
|
|
|
|
// EXPLAIN ANALYZE → 실행 계획 텍스트 반환
|
|
public async Task<string> ExplainAsync(string sql)
|
|
{
|
|
await using var conn = new NpgsqlConnection(connectionString);
|
|
await conn.OpenAsync();
|
|
var lines = await conn.QueryAsync<string>($"EXPLAIN ANALYZE {sql}");
|
|
return string.Join("\n", lines);
|
|
}
|
|
}
|
|
5. KoreanTimeRangeTests.cs
|
|
csharp
|
|
[Collection("Postgres")]
|
|
public class KoreanTimeRangeTests
|
|
{
|
|
// KST 2025-04-23 09:00:00 고정
|
|
private static readonly KstClock Clock = new(
|
|
new FixedClock(new DateTimeOffset(2025, 4, 23, 0, 0, 0, TimeSpan.Zero)));
|
|
|
|
private readonly KoreanTimeRangeExtractor _ext = new(Clock);
|
|
|
|
// ── 상대 범위 ────────────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("최근 1시간", "1 hours")]
|
|
[InlineData("지난 30분", "30 minutes")]
|
|
[InlineData("최근 3일", "3 days")]
|
|
[InlineData("지난 2주", "14 days")]
|
|
public void RelativeRange_ShouldGenerateInterval(string input, string expected)
|
|
{
|
|
var tr = _ext.Extract(input);
|
|
var sql = tr.ToSqlCondition("timestamp", Clock);
|
|
|
|
sql.Should().Contain(expected);
|
|
sql.Should().StartWith("timestamp >= NOW()");
|
|
}
|
|
|
|
// ── 부터~까지 절대 범위 ──────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("4월 23일 09:00 부터 4월 23일 18:00 까지",
|
|
"2025-04-23 00:00:00+00", // KST 09:00 → UTC 00:00
|
|
"2025-04-23 09:00:00+00")] // KST 18:00 → UTC 09:00
|
|
[InlineData("어제 부터 오늘 까지",
|
|
"2025-04-21 15:00:00+00", // KST 어제 00:00 → UTC
|
|
"2025-04-22 15:00:00+00")] // KST 오늘 00:00 → UTC
|
|
public void AbsoluteRange_ShouldConvertKstToUtc(
|
|
string input, string expectedFrom, string expectedTo)
|
|
{
|
|
var sql = _ext.Extract(input).ToSqlCondition("timestamp", Clock);
|
|
|
|
sql.Should().Contain(expectedFrom);
|
|
sql.Should().Contain(expectedTo);
|
|
sql.Should().Contain("AND");
|
|
}
|
|
|
|
// ── 오전/오후 시간 범위 ──────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("오전 9시부터 오후 6시까지",
|
|
"2025-04-23 00:00:00+00", // KST 09:00 → UTC 00:00
|
|
"2025-04-23 09:00:00+00")] // KST 18:00 → UTC 09:00
|
|
[InlineData("오전 12시부터 오후 1시까지",
|
|
"2025-04-23 03:00:00+00", // KST 12:00 → UTC 03:00
|
|
"2025-04-23 04:00:00+00")] // KST 13:00 → UTC 04:00
|
|
public void AmPmRange_ShouldHandleCorrectly(
|
|
string input, string expectedFrom, string expectedTo)
|
|
{
|
|
var sql = _ext.Extract(input).ToSqlCondition("timestamp", Clock);
|
|
|
|
sql.Should().Contain(expectedFrom);
|
|
sql.Should().Contain(expectedTo);
|
|
}
|
|
|
|
// ── 단방향 ───────────────────────────────────────────────────────
|
|
[Fact]
|
|
public void FromOnly_ShouldNotContainUpperBound()
|
|
{
|
|
var sql = _ext.Extract("오늘 오후 2시 이후")
|
|
.ToSqlCondition("timestamp", Clock);
|
|
|
|
sql.Should().StartWith("timestamp >=");
|
|
sql.Should().NotContain("AND timestamp <");
|
|
}
|
|
|
|
[Fact]
|
|
public void ToOnly_ShouldNotContainLowerBound()
|
|
{
|
|
var sql = _ext.Extract("2025-01-05 이전")
|
|
.ToSqlCondition("timestamp", Clock);
|
|
|
|
sql.Should().StartWith("timestamp <");
|
|
sql.Should().NotContain(">=");
|
|
}
|
|
|
|
// ── 기본값 폴백 ──────────────────────────────────────────────────
|
|
[Fact]
|
|
public void NoTimeInfo_ShouldFallbackToOneHour()
|
|
{
|
|
var sql = _ext.Extract("PV_101.PV 평균값")
|
|
.ToSqlCondition("timestamp", Clock);
|
|
|
|
sql.Should().Be("timestamp >= NOW() - INTERVAL '1 hour'");
|
|
}
|
|
}
|
|
6. SqlValidatorTests.cs
|
|
csharp
|
|
public class SqlValidatorTests
|
|
{
|
|
private readonly SqlValidator _v = new();
|
|
|
|
// ── 정상 통과 ─────────────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData(
|
|
"SELECT AVG(value), STDDEV(value) FROM tag_values WHERE tagname='PV_101.PV'")]
|
|
[InlineData(
|
|
"SELECT date_trunc('minute',timestamp), AVG(value) " +
|
|
"FROM tag_values WHERE tagname='FI_301.PV' " +
|
|
"AND timestamp >= NOW() - INTERVAL '1 hour' GROUP BY 1 ORDER BY 1")]
|
|
[InlineData(
|
|
"SELECT tagname, REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) " +
|
|
"FROM tag_values GROUP BY tagname")]
|
|
[InlineData(
|
|
"SELECT tagname, AVG(value) FILTER (WHERE timestamp::date = CURRENT_DATE) " +
|
|
"FROM tag_values GROUP BY tagname")]
|
|
public void ValidSql_ShouldPass(string sql)
|
|
=> _v.Validate(sql).IsValid.Should().BeTrue();
|
|
|
|
// ── 위험 구문 차단 ────────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("DROP TABLE tag_values",
|
|
ValidationFailReason.NotSelectStatement)]
|
|
[InlineData("DELETE FROM tag_values WHERE 1=1",
|
|
ValidationFailReason.NotSelectStatement)]
|
|
[InlineData("SELECT 1; DROP TABLE tag_values",
|
|
ValidationFailReason.ForbiddenClause)]
|
|
[InlineData("SELECT pg_sleep(5) FROM tag_values",
|
|
ValidationFailReason.ForbiddenClause)]
|
|
[InlineData("SELECT pg_terminate_backend(1)",
|
|
ValidationFailReason.ForbiddenClause)]
|
|
public void DangerousSql_ShouldFail(string sql, ValidationFailReason expectedReason)
|
|
{
|
|
var result = _v.Validate(sql);
|
|
result.IsValid.Should().BeFalse();
|
|
result.Reason.Should().Be(expectedReason);
|
|
}
|
|
|
|
// ── 허용되지 않는 함수 ────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("SELECT PG_READ_FILE('/etc/passwd') FROM tag_values")]
|
|
[InlineData("SELECT SYSTEM('ls -la') FROM tag_values")]
|
|
[InlineData("SELECT DBLINK('host=evil.com', 'SELECT 1') FROM tag_values")]
|
|
public void DisallowedFunction_ShouldFail(string sql)
|
|
{
|
|
var result = _v.Validate(sql);
|
|
result.IsValid.Should().BeFalse();
|
|
result.Reason.Should().Be(ValidationFailReason.DisallowedFunction);
|
|
}
|
|
|
|
// ── Injection 패턴 ────────────────────────────────────────────────
|
|
[Theory]
|
|
[InlineData("SELECT * FROM tag_values WHERE tagname='' OR '1'='1'")]
|
|
[InlineData("SELECT * FROM tag_values UNION SELECT * FROM pg_catalog.pg_tables")]
|
|
[InlineData("SELECT * FROM tag_values WHERE 1=1 AND 0x41=0x41")]
|
|
public void InjectionPattern_ShouldFail(string sql)
|
|
=> _v.Validate(sql).IsValid.Should().BeFalse();
|
|
|
|
// ── 필수 테이블 누락 ──────────────────────────────────────────────
|
|
[Fact]
|
|
public void MissingRequiredTable_ShouldFail()
|
|
{
|
|
var result = _v.Validate("SELECT 1 AS result");
|
|
result.IsValid.Should().BeFalse();
|
|
result.Reason.Should().Be(ValidationFailReason.MissingRequiredTable);
|
|
}
|
|
|
|
// ── 서브쿼리 깊이 초과 ────────────────────────────────────────────
|
|
[Fact]
|
|
public void ExcessiveSubqueryDepth_ShouldFail()
|
|
{
|
|
var sql = "SELECT * FROM (SELECT * FROM (SELECT * FROM " +
|
|
"(SELECT * FROM (SELECT * FROM tag_values)))))";
|
|
var result = _v.Validate(sql);
|
|
result.IsValid.Should().BeFalse();
|
|
result.Reason.Should().Be(ValidationFailReason.SubqueryDepthExceeded);
|
|
}
|
|
}
|
|
7. FullPipelineTests.cs — End-to-End 통합 테스트
|
|
csharp
|
|
[Collection("Postgres")] // PostgresFixture 공유
|
|
public class FullPipelineTests(PostgresFixture db) : IClassFixture<PostgresFixture>
|
|
{
|
|
private readonly SqlExecutionHelper _exec = new(db.ConnectionString);
|
|
private readonly SqlValidator _valid = new();
|
|
|
|
// KST 고정 시계 (현재 UTC = KST 기준 오후)
|
|
private static readonly KstClock Clock = new(
|
|
new FixedClock(DateTimeOffset.UtcNow));
|
|
|
|
// ── 생성된 SQL이 실제 DB에서 실행 가능한지 검증 ─────────────────
|
|
[Theory]
|
|
[InlineData("PV_101.PV 최근 1시간 1분 단위 평균")]
|
|
[InlineData("FI_301.PV 오늘 최대값 최소값 표준편차")]
|
|
[InlineData("LI_401.PV 어제부터 오늘까지 추세 분석")]
|
|
[InlineData("PI_501.PV 최근 24시간 이상값 탐지")]
|
|
public async Task GeneratedSql_ShouldExecuteOnRealDb(string koreanInput)
|
|
{
|
|
// Arrange
|
|
var extractor = new KoreanTimeRangeExtractor(Clock);
|
|
var entities = new KoreanEntityExtractor(
|
|
TestDataSeeder.Tags.Select(t => t.Tagname))
|
|
.Extract(koreanInput);
|
|
var timeRange = extractor.Extract(koreanInput);
|
|
var timeCond = timeRange.ToSqlCondition("timestamp", Clock);
|
|
|
|
// Act — 수동으로 SQL 조합 (LLM 없이 규칙 기반)
|
|
var sql = BuildTestSql(entities, timeCond);
|
|
|
|
// Assert 1: SqlValidator 통과
|
|
var (ok, error) = _valid.Validate(sql);
|
|
ok.Should().BeTrue(because: $"검증 실패: {error}\nSQL: {sql}");
|
|
|
|
// Assert 2: 실제 PostgreSQL 실행 성공
|
|
var count = await _exec.ExecuteAndCountAsync(sql);
|
|
count.Should().BeGreaterThanOrEqualTo(0);
|
|
}
|
|
|
|
// ── 시간 범위 쿼리 정확성 검증 ──────────────────────────────────
|
|
[Fact]
|
|
public async Task TimeRange_오늘_ShouldReturnOnlyTodayData()
|
|
{
|
|
var timeCond = new KoreanTimeRangeExtractor(Clock)
|
|
.Extract("오늘")
|
|
.ToSqlCondition("timestamp", Clock);
|
|
|
|
var sql = $"""
|
|
SELECT COUNT(*) AS cnt
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND {timeCond}
|
|
""";
|
|
|
|
var count = await _exec.ExecuteScalarAsync<long>(sql);
|
|
|
|
// 오늘치 10초 간격 데이터: 최대 8,640개
|
|
count.Should().BeInRange(0, 8_640);
|
|
}
|
|
|
|
// ── 집계 결과 정합성 검증 ────────────────────────────────────────
|
|
[Fact]
|
|
public async Task Aggregation_AVG_ShouldBeWithinExpectedRange()
|
|
{
|
|
var sql = """
|
|
SELECT AVG(value) AS avg_val
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= NOW() - INTERVAL '24 hours'
|
|
""";
|
|
|
|
var avg = await _exec.ExecuteScalarAsync<double>(sql);
|
|
|
|
// PV_101.PV = 70~80°C 범위로 시딩
|
|
avg.Should().BeInRange(68.0, 82.0,
|
|
because: "시딩 데이터 기준 온도 범위를 벗어남");
|
|
}
|
|
|
|
// ── 이상값 탐지 정확성 ───────────────────────────────────────────
|
|
[Fact]
|
|
public async Task AnomalyDetection_ShouldFindSeededAnomalies()
|
|
{
|
|
var sql = """
|
|
SELECT COUNT(*) AS anomaly_count
|
|
FROM tag_values
|
|
WHERE tagname = 'PI_501.PV'
|
|
AND timestamp >= NOW() - INTERVAL '48 hours'
|
|
AND value > 10
|
|
""";
|
|
|
|
var count = await _exec.ExecuteScalarAsync<long>(sql);
|
|
|
|
// 시딩 시 PI_501.PV에 이상값(15.0) 약 10개 삽입
|
|
count.Should().BeGreaterThan(0,
|
|
because: "시딩된 이상값이 탐지되어야 함");
|
|
}
|
|
|
|
// ── 복수 태그 비교 결과 구조 검증 ────────────────────────────────
|
|
[Fact]
|
|
public async Task MultiTagCompare_ShouldReturnAllTags()
|
|
{
|
|
var sql = """
|
|
SELECT tagname, AVG(value) AS avg_val
|
|
FROM tag_values
|
|
WHERE tagname IN ('PV_101.PV', 'FI_301.PV', 'LI_401.PV')
|
|
AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
GROUP BY tagname
|
|
ORDER BY tagname
|
|
""";
|
|
|
|
var rows = (await _exec.ExecuteAsync(sql)).ToList();
|
|
|
|
rows.Should().HaveCount(3,
|
|
because: "3개 태그 모두 결과에 있어야 함");
|
|
|
|
var tagnames = rows.Select(r => (string)r.tagname).ToList();
|
|
tagnames.Should().Contain("FI_301.PV");
|
|
tagnames.Should().Contain("LI_401.PV");
|
|
tagnames.Should().Contain("PV_101.PV");
|
|
}
|
|
|
|
// ── 추세 분석 REGR_SLOPE 실행 검증 ──────────────────────────────
|
|
[Fact]
|
|
public async Task TrendAnalysis_REGR_SLOPE_ShouldExecute()
|
|
{
|
|
var sql = """
|
|
SELECT
|
|
tagname,
|
|
REGR_SLOPE(value, EXTRACT(EPOCH FROM timestamp)) AS slope,
|
|
REGR_R2(value, EXTRACT(EPOCH FROM timestamp)) AS r_squared
|
|
FROM tag_values
|
|
WHERE tagname = 'LI_401.PV'
|
|
AND timestamp >= NOW() - INTERVAL '24 hours'
|
|
GROUP BY tagname
|
|
""";
|
|
|
|
var rows = (await _exec.ExecuteAsync(sql)).ToList();
|
|
|
|
rows.Should().HaveCount(1);
|
|
((double)rows[0].r_squared).Should().BeInRange(0.0, 1.0);
|
|
}
|
|
|
|
// ── EXPLAIN 실행 계획 — 인덱스 사용 여부 확인 ───────────────────
|
|
[Fact]
|
|
public async Task QueryPlan_ShouldUseIndex()
|
|
{
|
|
var sql = """
|
|
SELECT AVG(value)
|
|
FROM tag_values
|
|
WHERE tagname = 'PV_101.PV'
|
|
AND timestamp >= NOW() - INTERVAL '1 hour'
|
|
""";
|
|
|
|
var plan = await _exec.ExplainAsync(sql);
|
|
|
|
// 인덱스 스캔 또는 TimescaleDB Chunk 스캔 확인
|
|
plan.Should().MatchRegex("Index.*Scan|Custom Scan.*timescaledb",
|
|
because: "인덱스가 활용되어야 성능이 보장됨");
|
|
}
|
|
|
|
// ── 테스트용 SQL 빌더 (LLM 없이 규칙 기반) ──────────────────────
|
|
private static string BuildTestSql(ExtractedEntities e, string timeCond)
|
|
{
|
|
var tagList = string.Join(", ", e.Tagnames.Select(t => $"'{t}'"));
|
|
|
|
// 이상값 탐지
|
|
if (e.ThresholdValue.HasValue)
|
|
return $"""
|
|
SELECT timestamp, tagname, value, unit
|
|
FROM tag_values
|
|
WHERE tagname IN ({tagList})
|
|
AND {timeCond}
|
|
AND value {e.ThresholdOp} {e.ThresholdValue}
|
|
ORDER BY timestamp DESC
|
|
LIMIT 1000
|
|
""";
|
|
|
|
// 시계열 집계
|
|
if (e.BucketInterval is not null)
|
|
return $"""
|
|
SELECT
|
|
date_trunc('{e.BucketInterval.Split(' ')[1]}', timestamp) AS time_bucket,
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value
|
|
FROM tag_values
|
|
WHERE tagname IN ({tagList})
|
|
AND {timeCond}
|
|
GROUP BY time_bucket, tagname
|
|
ORDER BY time_bucket ASC
|
|
""";
|
|
|
|
// 기본 통계 요약
|
|
return $"""
|
|
SELECT
|
|
tagname,
|
|
AVG(value) AS avg_value,
|
|
MAX(value) AS max_value,
|
|
MIN(value) AS min_value,
|
|
STDDEV(value) AS stddev_value,
|
|
COUNT(*) AS sample_count
|
|
FROM tag_values
|
|
WHERE tagname IN ({tagList})
|
|
AND {timeCond}
|
|
GROUP BY tagname
|
|
""";
|
|
}
|
|
}
|
|
|
|
// ── Collection 정의 (Fixture 공유 선언) ─────────────────────────────
|
|
[CollectionDefinition("Postgres")]
|
|
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
|
|
8. 테스트 실행
|
|
bash
|
|
# 전체 실행
|
|
dotnet test --logger "console;verbosity=detailed"
|
|
|
|
# 특정 클래스만
|
|
dotnet test --filter "FullClass=FullPipelineTests"
|
|
|
|
# 커버리지 포함
|
|
dotnet test --collect:"XPlat Code Coverage" \
|
|
--results-directory ./coverage
|
|
|
|
# 병렬 실행 (Testcontainers는 컬렉션 단위 격리)
|
|
dotnet test --parallel --max-cpus 4
|
|
테스트 커버리지 매트릭스
|
|
테스트 클래스 검증 항목 실제 DB
|
|
KoreanTimeRangeTests KST→UTC 변환, 부터/까지, 상대범위 ✗
|
|
EntityExtractorTests tagname/임계값/버킷 추출 ✗
|
|
SqlValidatorTests 7계층 검증 규칙 전체 ✗
|
|
FullPipelineTests SQL 실행 가능성, 결과 정합성, 인덱스 사용 ✓
|
|
다음은 API 컨트롤러 + Swagger 문서화 또는 Ollama 연동 E2E 테스트 중 어느 쪽으로 진행할까요?
|
|
|
|
|
|
|
|
오후 7:40까지 무료 메시지를 모두 사용했습니다.
|
|
업그레이드
|
|
|
|
|
|
|
|
Claude는 AI이며 실수할 수 있습니다. 응답을 다시 한번 확인해 주세요.
|