feat: MCP server RAG/NL2SQL/PID 개선
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,9 @@ KB_COLLECTIONS = {
|
|||||||
|
|
||||||
# PostgreSQL 연결
|
# PostgreSQL 연결
|
||||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||||
|
# 라이브 데이터는 hc900 스키마에 있음. search_path 미지정 시 기본 public(레거시/stale)로 해석되므로 강제 지정.
|
||||||
|
if "search_path" not in DB_CONNECTION_STRING and "options=" not in DB_CONNECTION_STRING:
|
||||||
|
DB_CONNECTION_STRING += ("&" if "?" in DB_CONNECTION_STRING else "?") + "options=-csearch_path%3Dhc900"
|
||||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||||
|
|
||||||
# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식
|
# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식
|
||||||
@@ -707,7 +710,8 @@ Views:
|
|||||||
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT, sub_area TEXT)
|
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT, sub_area TEXT)
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- SELECT only. tagname lowercase exact match.
|
- SELECT only. tagname UPPERCASE exact match (e.g. 'FICQ-6113.PV').
|
||||||
|
- If user input is lowercase, convert to UPPERCASE before querying.
|
||||||
- value is TEXT; cast ::double precision when aggregating.
|
- value is TEXT; cast ::double precision when aggregating.
|
||||||
- time_bucket() banned. For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60))
|
- time_bucket() banned. For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60))
|
||||||
- KST input = UTC-9 in DB.
|
- KST input = UTC-9 in DB.
|
||||||
@@ -1352,7 +1356,8 @@ async def query_with_nl(question: str) -> str:
|
|||||||
"You are a PostgreSQL SQL expert.\n"
|
"You are a PostgreSQL SQL expert.\n"
|
||||||
"Convert the user's question into a SELECT SQL.\n"
|
"Convert the user's question into a SELECT SQL.\n"
|
||||||
"Return ONLY the SQL. No explanation, no markdown, NO <think> tags.\n"
|
"Return ONLY the SQL. No explanation, no markdown, NO <think> tags.\n"
|
||||||
"Use PostgreSQL syntax. tagname lowercase exact match.\n"
|
"Use PostgreSQL syntax. tagname UPPERCASE exact match (e.g. 'FICQ-6113.PV').\n"
|
||||||
|
"If user input is lowercase, convert to UPPERCASE before querying.\n"
|
||||||
"value is TEXT; cast ::double precision when aggregating.\n"
|
"value is TEXT; cast ::double precision when aggregating.\n"
|
||||||
"KST input = UTC-9. Example: KST 12:00 = UTC 03:00.\n"
|
"KST input = UTC-9. Example: KST 12:00 = UTC 03:00.\n"
|
||||||
"For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)).\n"
|
"For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)).\n"
|
||||||
@@ -1622,7 +1627,7 @@ async def trace_connections(start_tag: str, direction: str = "downstream", max_d
|
|||||||
# 각 노드에 실시간 상태(pv) 부착 — 병렬 펌프 중 '실제 가동 중'인 것을 식별.
|
# 각 노드에 실시간 상태(pv) 부착 — 병렬 펌프 중 '실제 가동 중'인 것을 식별.
|
||||||
# 예: F-6101A/B 상류에 P-6102(R-RUN)·P-6201(L-STOP)이 병렬이면 현재 공급원은 P-6102.
|
# 예: F-6101A/B 상류에 P-6102(R-RUN)·P-6201(L-STOP)이 병렬이면 현재 공급원은 P-6102.
|
||||||
if path:
|
if path:
|
||||||
pv_tags = [p["tag_no"].lower() + ".pv" for p in path]
|
pv_tags = [p["tag_no"].upper() + ".PV" for p in path]
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT tagname, livevalue FROM realtime_table WHERE tagname = ANY(%s)",
|
"SELECT tagname, livevalue FROM realtime_table WHERE tagname = ANY(%s)",
|
||||||
(pv_tags,),
|
(pv_tags,),
|
||||||
@@ -1632,7 +1637,7 @@ async def trace_connections(start_tag: str, direction: str = "downstream", max_d
|
|||||||
m = re.match(r'\{\s*\d+\s*\|\s*([^|]+?)\s*\|', lv or '')
|
m = re.match(r'\{\s*\d+\s*\|\s*([^|]+?)\s*\|', lv or '')
|
||||||
pv_map[tn[:-3]] = (m.group(1).strip() if m else (lv or None))
|
pv_map[tn[:-3]] = (m.group(1).strip() if m else (lv or None))
|
||||||
for p in path:
|
for p in path:
|
||||||
p["live_state"] = pv_map.get(p["tag_no"].lower())
|
p["live_state"] = pv_map.get(p["tag_no"])
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import httpx
|
|||||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||||
|
# 라이브 데이터는 hc900 스키마에 있음. search_path 미지정 시 기본 public(레거시/stale)로 해석되므로 강제 지정.
|
||||||
|
if "search_path" not in DB_CONNECTION_STRING and "options=" not in DB_CONNECTION_STRING:
|
||||||
|
DB_CONNECTION_STRING += ("&" if "?" in DB_CONNECTION_STRING else "?") + "options=-csearch_path%3Dhc900"
|
||||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||||
|
|
||||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||||
@@ -101,6 +104,61 @@ def _llm_client():
|
|||||||
# DB 스키마 + SQL system 프롬프트 — worker/sql_prompt.py 로 단일화(production+eval 공유)
|
# DB 스키마 + SQL system 프롬프트 — worker/sql_prompt.py 로 단일화(production+eval 공유)
|
||||||
from sql_prompt import DB_SCHEMA, SQL_SYSTEM_PROMPT # noqa: E402,F401
|
from sql_prompt import DB_SCHEMA, SQL_SYSTEM_PROMPT # noqa: E402,F401
|
||||||
|
|
||||||
|
# ── 태그명 대소문자 정규화 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# DB에 실제로 저장된 태그명 목록 (대문자)
|
||||||
|
_tag_cache: dict | None = None
|
||||||
|
_tag_cache_lock: object | None = None # placeholder — sync path uses _get_db_connection
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tag_names() -> set[str]:
|
||||||
|
"""realtime_table에서 현재 태그명 목록을 로드 (대문자)."""
|
||||||
|
global _tag_cache
|
||||||
|
try:
|
||||||
|
conn = _get_db_connection()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT tagname FROM hc900.realtime_table WHERE tagname IS NOT NULL")
|
||||||
|
_tag_cache = {r[0] for r in cur.fetchall()}
|
||||||
|
conn.close()
|
||||||
|
return _tag_cache
|
||||||
|
except Exception:
|
||||||
|
if _tag_cache is None:
|
||||||
|
_tag_cache = set()
|
||||||
|
return _tag_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tag_case(sql: str) -> str:
|
||||||
|
"""SQL 내의 태그명을 실제 DB 태그명으로 정규화 (대소문자 불일치 해결).
|
||||||
|
|
||||||
|
LLM이 소문자('ficq-6118.pv')로 생성한 태그명을
|
||||||
|
실제 DB에 저장된 대문자('FICQ-6118.PV')로 변환.
|
||||||
|
"""
|
||||||
|
if not _tag_cache:
|
||||||
|
_load_tag_names()
|
||||||
|
|
||||||
|
if not _tag_cache:
|
||||||
|
return sql
|
||||||
|
|
||||||
|
# 태그명 패턴: 알파벳+숫자+하이픈+접미사(.pv/.sp/.op 등)
|
||||||
|
# 예: ficq-6118.pv, FICQ-6118.PV, xv-6105.pv
|
||||||
|
tag_pattern = re.compile(r"\b([a-zA-Z][a-zA-Z0-9]*-?[a-zA-Z0-9]*)(\.[a-zA-Z0-9_]+)?\b")
|
||||||
|
|
||||||
|
def _replace_tag(m: re.Match) -> str:
|
||||||
|
base = m.group(1)
|
||||||
|
suffix = m.group(2) or ""
|
||||||
|
candidate = base.upper() + suffix
|
||||||
|
if candidate in _tag_cache:
|
||||||
|
return candidate
|
||||||
|
# 접미사만 소문자일 수 있음 (예: FICQ-6118.pv → FICQ-6118.PV)
|
||||||
|
candidate2 = base.upper() + suffix.upper()
|
||||||
|
if candidate2 in _tag_cache:
|
||||||
|
return candidate2
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
normalized = tag_pattern.sub(_replace_tag, sql)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
async def _generate_sql(natural_language: str) -> str:
|
async def _generate_sql(natural_language: str) -> str:
|
||||||
"""자연어를 SQL로 변환."""
|
"""자연어를 SQL로 변환."""
|
||||||
client = _llm_client()
|
client = _llm_client()
|
||||||
@@ -121,6 +179,8 @@ async def _generate_sql(natural_language: str) -> str:
|
|||||||
if sql.startswith("```"):
|
if sql.startswith("```"):
|
||||||
lines = sql.splitlines()
|
lines = sql.splitlines()
|
||||||
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
||||||
|
# 태그명 대소문자 정규화
|
||||||
|
sql = _normalize_tag_case(sql)
|
||||||
return sql
|
return sql
|
||||||
|
|
||||||
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
|
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1
|
|||||||
from config import get_vllm_model
|
from config import get_vllm_model
|
||||||
VLLM_MODEL = get_vllm_model()
|
VLLM_MODEL = get_vllm_model()
|
||||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||||
|
# 라이브 데이터는 hc900 스키마에 있음. search_path 미지정 시 기본 public(레거시/stale)로 해석되므로 강제 지정.
|
||||||
|
if "search_path" not in DB_CONNECTION_STRING and "options=" not in DB_CONNECTION_STRING:
|
||||||
|
DB_CONNECTION_STRING += ("&" if "?" in DB_CONNECTION_STRING else "?") + "options=-csearch_path%3Dhc900"
|
||||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||||
|
|
||||||
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ PostgreSQL 시계열 데이터베이스 스키마
|
|||||||
※ DCS 태그: SELECT WHERE tag_dcs=TRUE, 현장 계기: WHERE tag_dcs=FALSE AND category='instrument'
|
※ DCS 태그: SELECT WHERE tag_dcs=TRUE, 현장 계기: WHERE tag_dcs=FALSE AND category='instrument'
|
||||||
|
|
||||||
테이블: history_table (시계열 이력)
|
테이블: history_table (시계열 이력)
|
||||||
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
|
tagname TEXT - 태그명 (대문자, 예: 'FICQ-6113.PV') — 대소문자 구분
|
||||||
node_id TEXT - OPC UA 노드 ID
|
node_id TEXT - OPC UA 노드 ID
|
||||||
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
|
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
|
||||||
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
|
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
|
||||||
|
|
||||||
테이블: realtime_table (실시간 최신값)
|
테이블: realtime_table (실시간 최신값)
|
||||||
tagname TEXT - 태그명 (모두 소문자)
|
tagname TEXT - 태그명 (대문자)
|
||||||
node_id TEXT - OPC UA 노드 ID
|
node_id TEXT - OPC UA 노드 ID
|
||||||
livevalue TEXT - 현재값
|
livevalue TEXT - 현재값
|
||||||
timestamp TIMESTAMPTZ - 최종 갱신 시각
|
timestamp TIMESTAMPTZ - 최종 갱신 시각
|
||||||
@@ -134,7 +134,7 @@ SQL_SYSTEM_PROMPT = (
|
|||||||
" SELECT to_timestamp(...) AT TIME ZONE 'Asia/Seoul' AS bucket, AVG(...) AS avg_val\n"
|
" SELECT to_timestamp(...) AT TIME ZONE 'Asia/Seoul' AS bucket, AVG(...) AS avg_val\n"
|
||||||
" FROM ... GROUP BY to_timestamp(...), tagname ORDER BY to_timestamp(...), tagname\n"
|
" FROM ... GROUP BY to_timestamp(...), tagname ORDER BY to_timestamp(...), tagname\n"
|
||||||
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
|
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
|
||||||
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
|
"- All tagnames are UPPERCASE (e.g. 'FICQ-6113.PV'). Match exactly.\n"
|
||||||
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
||||||
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
|
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
|
||||||
f"{DB_SCHEMA}"
|
f"{DB_SCHEMA}"
|
||||||
|
|||||||
Reference in New Issue
Block a user