
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이며 실수할 수 있습니다. 응답을 다시 한번 확인해 주세요.
