Compare commits
6 Commits
3926a33418
...
8f5dabbbc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5dabbbc7 | ||
|
|
543ce85af5 | ||
|
|
300dfe65a4 | ||
|
|
c1d228d1f6 | ||
|
|
95ec160e98 | ||
|
|
0eb598d411 |
@@ -79,6 +79,12 @@ from pipeline.analyzer import PidAnalysisEngine
|
||||
import networkx as nx
|
||||
import asyncio
|
||||
|
||||
# ── Verifier (Phase B MVP — R1·R2·R4) ──
|
||||
from verifier.validators import (
|
||||
validate_tag, validate_area, validate_direction, validate_max_depth,
|
||||
log_rejection, VerifierError,
|
||||
)
|
||||
|
||||
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _embed(text: str) -> list[float]:
|
||||
@@ -248,20 +254,37 @@ _PID_TAG_RE = re.compile(r'^([A-Z]{1,4})-(\d{3,6})([A-Z])?$')
|
||||
|
||||
|
||||
|
||||
# DCS 함수블록 prefix frozenset — compound형 포함 (ISA _systemFuncLetters 기준)
|
||||
# ⚠️ C# Boot DDL UPDATE 목록과 수동 동기화 필요 (향후 dcs_prefixes.py 분리 고려)
|
||||
_DCS_PREFIXES: frozenset[str] = frozenset({
|
||||
"FIC", "FICA", "FICQ", "FICR",
|
||||
"TIC", "TICA", "TICQ",
|
||||
"PIC", "PICA",
|
||||
"LIC", "LICA",
|
||||
"FY", "TY", "PY", "LY",
|
||||
"FV", "TV", "PV", "LV",
|
||||
})
|
||||
|
||||
|
||||
def _classify_pid_tag(tag_no: str) -> dict:
|
||||
"""tagNo의 prefix로 장비/계기 종류 분류 (좌표·LLM 사용 안 함)."""
|
||||
m = _PID_TAG_RE.match(tag_no)
|
||||
if not m:
|
||||
return {"kind": "unknown", "prefix": None, "type": None}
|
||||
return {"kind": "unknown", "prefix": None, "type": None, "tag_dcs": False}
|
||||
prefix = m.group(1)
|
||||
if prefix in _PID_EQUIPMENT_PREFIX:
|
||||
return {"kind": "equipment", "prefix": prefix, "type": _PID_EQUIPMENT_PREFIX[prefix]}
|
||||
return {"kind": "equipment", "prefix": prefix, "type": _PID_EQUIPMENT_PREFIX[prefix], "tag_dcs": False}
|
||||
if 2 <= len(prefix) <= 4 and prefix[0] in _PID_INSTRUMENT_FIRST:
|
||||
measure = _PID_INSTRUMENT_FIRST[prefix[0]]
|
||||
mods = [_PID_INSTRUMENT_MODIFIER[c] for c in prefix[1:] if c in _PID_INSTRUMENT_MODIFIER]
|
||||
type_name = (measure + " " + " ".join(mods)).strip()
|
||||
return {"kind": "instrument", "prefix": prefix, "type": type_name}
|
||||
return {"kind": "unknown", "prefix": prefix, "type": None}
|
||||
return {
|
||||
"kind": "instrument",
|
||||
"prefix": prefix,
|
||||
"type": type_name,
|
||||
"tag_dcs": prefix in _DCS_PREFIXES,
|
||||
}
|
||||
return {"kind": "unknown", "prefix": prefix, "type": None, "tag_dcs": False}
|
||||
|
||||
|
||||
def _parse_pid_lineno(token: str) -> dict | None:
|
||||
@@ -619,6 +642,21 @@ async def _get_db_connection():
|
||||
return await asyncio.to_thread(_connect)
|
||||
|
||||
|
||||
def _get_db_connection_sync():
|
||||
"""PostgreSQL DB 연결 획득 (sync — Verifier 내부용)."""
|
||||
import psycopg
|
||||
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
|
||||
|
||||
|
||||
def _check(tool: str, params: dict, *errs) -> str | None:
|
||||
"""첫 번째 비-None VerifierError를 로그 + JSON str 반환. 없으면 None."""
|
||||
for e in errs:
|
||||
if e:
|
||||
log_rejection(tool, params, e)
|
||||
return json.dumps(e.to_dict(), ensure_ascii=False)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_sql(sql: str) -> tuple[bool, str]:
|
||||
"""SQL 안전 검증 — SELECT/WITH만 허용, 위험 키워드 차단."""
|
||||
if len(sql) > 4000:
|
||||
@@ -660,6 +698,10 @@ Tables:
|
||||
realtime_table(tagname TEXT, livevalue TEXT, timestamp TIMESTAMPTZ)
|
||||
tag_metadata(base_tag TEXT, attribute TEXT, value TEXT)
|
||||
event_history_table(tagname TEXT, prev_value TEXT, curr_value TEXT, event_type TEXT, event_time TIMESTAMPTZ, duration_seconds INT)
|
||||
pid_equipment(tag_no TEXT, category TEXT, tag_dcs BOOL, tag_class TEXT, instrument_type TEXT, from_tag TEXT, to_tag TEXT)
|
||||
-- tag_dcs=TRUE: DCS 함수블록(FIC/TIC/PIC류), FALSE: 현장 물리 계기(FT/FCV류)
|
||||
-- tag_class: 'field'(현장) / 'system'(DCS) — tag_dcs 기반
|
||||
-- from_tag(상류) → tag_no → to_tag(하류) 연결 추적
|
||||
|
||||
Views:
|
||||
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT, sub_area TEXT)
|
||||
@@ -974,11 +1016,12 @@ async def upsert_pid_connection(
|
||||
role: str | None = None,
|
||||
category: str | None = None,
|
||||
tag_class: str | None = None,
|
||||
tag_dcs: bool | None = None,
|
||||
) -> str:
|
||||
"""운전자가 작성한 from/to 연결 1건을 pid_equipment 에 멱등(idempotent) 반영.
|
||||
|
||||
⚠️ 오직 pid_equipment 테이블만, 아래 컬럼만 변경한다(나머지는 절대 안 건드림):
|
||||
from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked, updated_at
|
||||
from_tag, to_tag, from_at, to_at, role, category, tag_class, tag_dcs, connection_locked, updated_at
|
||||
equipment_name/instrument_type/line_number/pid_drawing_no/pos_x/pos_y/confidence/is_active 는 보존.
|
||||
|
||||
매칭 규칙(문서규칙 §5):
|
||||
@@ -993,7 +1036,8 @@ async def upsert_pid_connection(
|
||||
Args:
|
||||
tag_no: 태그번호 (필수, 대소문자 무시 매칭).
|
||||
from_tag/to_tag: 상류/하류 태그. 병렬이면 호출 측에서 "A, B" 콤마 병합해 전달(이 도구는 분리하지 않음).
|
||||
from_at/to_at: 위치/서술 텍스트. role/category/tag_class: 그대로 저장(None이면 기존 유지).
|
||||
from_at/to_at: 위치/서술 텍스트. role/category/tag_class/tag_dcs: 그대로 저장(None이면 기존 유지).
|
||||
tag_dcs: TRUE=DCS 함수블록(FIC/TIC류), FALSE=현장 물리 계기(FT/FCV류). None이면 기존 유지.
|
||||
|
||||
Returns:
|
||||
JSON { success, action(update_existing|update_filled|insert), id, tag_no, before, after }
|
||||
@@ -1007,14 +1051,18 @@ async def upsert_pid_connection(
|
||||
tag_no = _n(tag_no)
|
||||
if not tag_no:
|
||||
return json.dumps({"success": False, "error": "tag_no 가 비었습니다."}, ensure_ascii=False)
|
||||
err = _check("upsert_pid_connection", {"tag_no": tag_no}, validate_tag(tag_no, _get_db_connection_sync))
|
||||
if err: return err
|
||||
from_tag, to_tag = _n(from_tag), _n(to_tag)
|
||||
from_at, to_at = _n(from_at), _n(to_at)
|
||||
role, category, tag_class = _n(role), _n(category), _n(tag_class)
|
||||
# bool은 _n() 미사용 — None이면 기존 유지, 값이면 bool로 강제 변환
|
||||
tag_dcs_val = bool(tag_dcs) if tag_dcs is not None else None
|
||||
locked = (from_tag is not None) or (to_tag is not None)
|
||||
_SNAP = ["tag_no", "from_tag", "to_tag", "from_at", "to_at", "role", "category", "tag_class", "connection_locked"]
|
||||
_SNAP = ["tag_no", "from_tag", "to_tag", "from_at", "to_at", "role", "category", "tag_class", "tag_dcs", "connection_locked"]
|
||||
|
||||
def _snap(cur, _id):
|
||||
cur.execute("""SELECT tag_no, from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked
|
||||
cur.execute("""SELECT tag_no, from_tag, to_tag, from_at, to_at, role, category, tag_class, tag_dcs, connection_locked
|
||||
FROM pid_equipment WHERE id=%s""", (_id,))
|
||||
r = cur.fetchone()
|
||||
return dict(zip(_SNAP, r)) if r else None
|
||||
@@ -1056,26 +1104,28 @@ async def upsert_pid_connection(
|
||||
from_tag=%s, to_tag=%s, from_at=%s, to_at=%s, role=%s,
|
||||
category=COALESCE(%s, category),
|
||||
tag_class=COALESCE(%s, tag_class),
|
||||
tag_dcs=COALESCE(%s, tag_dcs),
|
||||
connection_locked=%s, updated_at=now()
|
||||
WHERE id=%s""",
|
||||
(from_tag, to_tag, from_at, to_at, role, category, tag_class, locked, target_id))
|
||||
(from_tag, to_tag, from_at, to_at, role, category, tag_class, tag_dcs_val, locked, target_id))
|
||||
else:
|
||||
action = "insert"
|
||||
before = None
|
||||
# 같은 tag 기존 행에서 정적 메타 복사 (없으면 NULL)
|
||||
cur.execute("""SELECT equipment_name, instrument_type, line_number, pid_drawing_no, category
|
||||
cur.execute("""SELECT equipment_name, instrument_type, line_number, pid_drawing_no, category, tag_dcs
|
||||
FROM pid_equipment WHERE lower(tag_no)=lower(%s) ORDER BY id LIMIT 1""", (tag_no,))
|
||||
meta = cur.fetchone() or (None, None, None, None, None)
|
||||
eq_name, inst_type, line_no, draw_no, ex_cat = meta
|
||||
meta = cur.fetchone() or (None, None, None, None, None, None)
|
||||
eq_name, inst_type, line_no, draw_no, ex_cat, ex_dcs = meta
|
||||
cur.execute(
|
||||
"""INSERT INTO pid_equipment
|
||||
(tag_no, equipment_name, instrument_type, line_number, pid_drawing_no,
|
||||
from_tag, to_tag, from_at, to_at, role, category, tag_class,
|
||||
from_tag, to_tag, from_at, to_at, role, category, tag_class, tag_dcs,
|
||||
connection_locked, confidence, is_active, extracted_at, updated_at)
|
||||
VALUES (%s,%s,%s,%s,%s, %s,%s,%s,%s,%s, COALESCE(%s,%s),%s, %s, 0, TRUE, now(), now())
|
||||
VALUES (%s,%s,%s,%s,%s, %s,%s,%s,%s,%s, COALESCE(%s,%s),%s, COALESCE(%s,%s,FALSE), %s, 0, TRUE, now(), now())
|
||||
RETURNING id""",
|
||||
(tag_no, eq_name, inst_type, line_no, draw_no,
|
||||
from_tag, to_tag, from_at, to_at, role, category, ex_cat, tag_class, locked))
|
||||
from_tag, to_tag, from_at, to_at, role, category, ex_cat, tag_class,
|
||||
tag_dcs_val, ex_dcs, locked))
|
||||
target_id = cur.fetchone()[0]
|
||||
|
||||
after = _snap(cur, target_id)
|
||||
@@ -1108,6 +1158,13 @@ async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, l
|
||||
Returns:
|
||||
JSON: { success, tag_names, time_range, limit, data }
|
||||
"""
|
||||
_first_bad = None
|
||||
for t in (tag_names or []):
|
||||
_first_bad = validate_tag(t, _get_db_connection_sync)
|
||||
if _first_bad:
|
||||
break
|
||||
err = _check("query_pv_history", {"tag_names": tag_names}, _first_bad)
|
||||
if err: return err
|
||||
conn = None
|
||||
try:
|
||||
limit = min(limit, 5000)
|
||||
@@ -1359,35 +1416,44 @@ _VALID_EVENT_TYPES = ("ALARM", "TRIP", "NORMAL", "RUN", "CHANGE")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def find_tags(query: str, area: str | None = None, sub_area: str | None = None, top_k: int = 20) -> str:
|
||||
async def find_tags(query: str | None = None, area: str | None = None, sub_area: str | None = None, top_k: int = 20) -> str:
|
||||
"""태그 검색 — base_tag/설명(desc)/area/sub_area 통합 검색 (v_tag_summary 뷰 기반).
|
||||
|
||||
사용 시점: 사용자가 "온도", "Tower 1 압력", "운전 중인 펌프" 같은 자연어로
|
||||
태그를 지칭할 때 실제 base_tag(예: 'ti-6101', 'p-6102')를 역으로 찾기 위해.
|
||||
sub_area만 지정하면 해당 area 전체 태그를 반환한다.
|
||||
|
||||
get_tag_metadata와 차이: 단순 tagname LIKE만 보지 않고 description/area에도
|
||||
매칭하며, 현재 PV/SP/OP/description/area/sub_area를 함께 반환.
|
||||
|
||||
Args:
|
||||
query: 검색어 (base_tag 또는 description 부분 일치, 대소문자 무시)
|
||||
query: (선택) 검색어 (base_tag 또는 description 부분 일치, 대소문자 무시).
|
||||
생략하면 sub_area/area 조건만으로 전체 조회.
|
||||
area: (선택) area 필터 (예: 'P6'). "P6-1"처럼 '-'가 있으면 sub_area로 자동 처리.
|
||||
sub_area: (선택) sub_area 필터 (예: 'P6-1'). 공용 태그("P6-1,P6-2")도 매칭됨.
|
||||
query 생략 시 해당 sub_area 전체 태그 반환.
|
||||
top_k: 반환 태그 수 (기본 20, 최대 100)
|
||||
|
||||
Returns:
|
||||
JSON: { success, query, count, tags: [{base_tag, pv, sp, op, description, area, sub_area}] }
|
||||
"""
|
||||
err = _check("find_tags", {"query": query, "area": area, "sub_area": sub_area},
|
||||
validate_area(area), validate_area(sub_area, "sub_area"))
|
||||
if err: return err
|
||||
conn = None
|
||||
try:
|
||||
top_k = max(1, min(top_k, 100))
|
||||
q = f"%{query.strip()}%"
|
||||
|
||||
# sub_area 명시 파라미터 우선, 없으면 area가 "P6-1" 형식이면 sub_area로 간주
|
||||
effective_sub = sub_area or (area if (area and "-" in area) else None)
|
||||
effective_area = None if (area and "-" in area) else area
|
||||
|
||||
conds = ["(base_tag ILIKE %s OR description ILIKE %s)"]
|
||||
params: list = [q, q]
|
||||
conds: list[str] = []
|
||||
params: list = []
|
||||
if query:
|
||||
q = f"%{query.strip()}%"
|
||||
conds.append("(base_tag ILIKE %s OR description ILIKE %s)")
|
||||
params.extend([q, q])
|
||||
if effective_sub:
|
||||
conds.append("%s = ANY(string_to_array(sub_area, ','))")
|
||||
params.append(effective_sub)
|
||||
@@ -1395,6 +1461,8 @@ async def find_tags(query: str, area: str | None = None, sub_area: str | None =
|
||||
# v_tag_summary.area는 '{12 | P6 | }' 원본 형식이므로 코드만 추출해 비교
|
||||
conds.append("trim(split_part(area, '|', 2)) = %s")
|
||||
params.append(effective_area)
|
||||
if not conds:
|
||||
conds.append("TRUE")
|
||||
|
||||
sql = (
|
||||
"SELECT base_tag, pv, sp, op, description, area, sub_area "
|
||||
@@ -1441,6 +1509,11 @@ async def trace_connections(start_tag: str, direction: str = "downstream", max_d
|
||||
- from_tag/to_tag에 쉼표가 여러 개면 병렬 펌프·라인 → 모두 경로에 포함됨(누락 없음).
|
||||
- live_state: 해당 태그의 실시간 상태(예: R-RUN/L-STOP). 병렬 펌프 중 R-RUN/L-RUN이 현재 공급원.
|
||||
"""
|
||||
err = _check("trace_connections", {"start_tag": start_tag, "direction": direction, "max_depth": max_depth},
|
||||
validate_tag(start_tag, _get_db_connection_sync),
|
||||
validate_direction(direction),
|
||||
validate_max_depth(max_depth))
|
||||
if err: return err
|
||||
conn = None
|
||||
try:
|
||||
start_tag = start_tag.strip().upper()
|
||||
@@ -1596,6 +1669,10 @@ async def query_events(
|
||||
Returns:
|
||||
JSON: { success, count, time_range, events: [...] }
|
||||
"""
|
||||
err = _check("query_events", {"tag_name": tag_name, "area": area},
|
||||
validate_tag(tag_name, _get_db_connection_sync) if tag_name else None,
|
||||
validate_area(area))
|
||||
if err: return err
|
||||
if event_type and event_type.upper() not in _VALID_EVENT_TYPES:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
@@ -1669,6 +1746,8 @@ async def active_alarms(area: str | None = None, limit: int = 100) -> str:
|
||||
Returns:
|
||||
JSON: { success, count, alarms: [{tag_name, event_type, since, prev_state_duration_s, area, ...}] }
|
||||
"""
|
||||
err = _check("active_alarms", {"area": area}, validate_area(area))
|
||||
if err: return err
|
||||
conn = None
|
||||
try:
|
||||
limit = max(1, min(limit, 500))
|
||||
@@ -1737,6 +1816,8 @@ async def summarize_events(
|
||||
Returns:
|
||||
JSON: { success, summary, stats: {by_type, by_area, count} }
|
||||
"""
|
||||
err = _check("summarize_events", {"area": area}, validate_area(area))
|
||||
if err: return err
|
||||
max_events = max(10, min(max_events, 500))
|
||||
raw = await query_events(event_type=event_type, area=area, since=since, limit=max_events)
|
||||
parsed = json.loads(raw)
|
||||
@@ -1822,6 +1903,8 @@ async def generate_status_report(area: str | None = None, hours: int = 24) -> st
|
||||
Returns:
|
||||
JSON: { success, report, sections: {active_alarms, recent_events, by_type}, generated_at }
|
||||
"""
|
||||
err = _check("generate_status_report", {"area": area, "hours": hours}, validate_area(area))
|
||||
if err: return err
|
||||
hours = max(1, min(hours, 168))
|
||||
since_iso = None # query_events가 24h 기본을 쓰지만, hours로 명시 전달
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
141
mcp-server/worker/sql_prompt.py
Normal file
141
mcp-server/worker/sql_prompt.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""NL2SQL 프롬프트 단일 소스 (production + eval 공유).
|
||||
|
||||
`nl2sql_worker._generate_sql` 와 `eval/run_eval.py` 가 **동일 프롬프트**를 쓰도록 여기서 정의한다.
|
||||
순수 문자열 상수만 — 무거운 의존성 없음(import 안전). 프롬프트를 고칠 땐 여기만 고치면 됨.
|
||||
(참고: server.py 에도 별도 _DB_SCHEMA 사본이 있음 — 추후 통합 대상)
|
||||
"""
|
||||
|
||||
# DB 스키마
|
||||
DB_SCHEMA = """
|
||||
PostgreSQL 시계열 데이터베이스 스키마
|
||||
|
||||
테이블: pid_equipment (P&ID 추출 장비/계기)
|
||||
tag_no TEXT - 태그번호 (예: FIC-6113, FT-6113)
|
||||
category TEXT - 'instrument' / 'power_equipment' / 'storage_equipment' / ...
|
||||
tag_dcs BOOL - TRUE=DCS 함수블록(FIC/TIC/PIC류), FALSE=현장 물리 계기(FT/FCV류)
|
||||
tag_class TEXT - 'field'(현장) / 'system'(DCS) — tag_dcs 기반
|
||||
instrument_type TEXT - ISA prefix (FT/FIC/P 등)
|
||||
from_tag TEXT - 연결 상류 태그
|
||||
to_tag TEXT - 연결 하류 태그
|
||||
※ DCS 태그: SELECT WHERE tag_dcs=TRUE, 현장 계기: WHERE tag_dcs=FALSE AND category='instrument'
|
||||
|
||||
테이블: history_table (시계열 이력)
|
||||
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
|
||||
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
|
||||
|
||||
테이블: realtime_table (실시간 최신값)
|
||||
tagname TEXT - 태그명 (모두 소문자)
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
livevalue TEXT - 현재값
|
||||
timestamp TIMESTAMPTZ - 최종 갱신 시각
|
||||
|
||||
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
||||
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
||||
attribute TEXT - 속성명 ('desc', 'area')
|
||||
value TEXT - 메타데이터 값
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
||||
|
||||
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
|
||||
base_tag TEXT - 기본 태그명
|
||||
pv TEXT - 현재 프로세스 값
|
||||
sp TEXT - 설정값
|
||||
op TEXT - 출력값
|
||||
instate0 TEXT - 상태 비트 0 (true/false)
|
||||
instate1 TEXT - 상태 비트 1 (true/false)
|
||||
instate2 TEXT - 상태 비트 2 (true/false)
|
||||
description TEXT - 장비 설명 (tag_metadata.desc)
|
||||
area TEXT - 소속 플랜트 (tag_metadata.area)
|
||||
|
||||
뷰: v_plant_running_state (area별 펌프 운전 판정 — "어떤 플랜트가 운전 중" 질문 1순위)
|
||||
area_code TEXT - 정규화 area (예: P3, P4, P5, P6, P8)
|
||||
status TEXT - 'RUNNING' / 'TRIPPED' / 'STOPPED' (펌프 1대라도 RUN이면 RUNNING)
|
||||
running_pumps INT - R-RUN/L-RUN 펌프 수
|
||||
tripped_pumps INT - R-TRIP/L-TRIP 펌프 수
|
||||
stopped_pumps INT - R-STOP/L-STOP 펌프 수
|
||||
total_pumps INT - 펌프 enum 보유 태그 수
|
||||
running_pump_tags TEXT[] - 현재 RUN 상태 펌프 base_tag 배열
|
||||
※ "운전 중인 플랜트/펌프", "트립 펌프" 류 질문은 이 뷰를 직접 SELECT (펌프 상태 SQL 직접 작성 금지)
|
||||
※ 결과에 없는 area = 펌프 미등록 → 운전 여부 단정 금지. 이 뷰는 area 레벨(sub_area 없음)
|
||||
|
||||
뷰: v_plant_running_state_corroborated (펌프별 실질 운전 — 유량/진공 교차검증, sub_area 지원)
|
||||
base_tag TEXT - 펌프 base_tag (예: 'p-6102', 'vp-6117')
|
||||
area_code TEXT - 정규화 area
|
||||
sub_area TEXT - 세부 area (예: 'P6-1'; 공용은 'P6-1,P6-2'). 필터는 LIKE '%P6-1%'
|
||||
corroborated_status TEXT - CONFIRMED_RUNNING / SUSPICIOUS_RUNNING / STALE / INDETERMINATE_RUNNING / STOPPED / TRIPPED
|
||||
flow_kg_hr DOUBLE PRECISION - 연결 유량(kg/hr)
|
||||
vacuum_torr DOUBLE PRECISION - 연결 진공압(torr=mmHg)
|
||||
※ "6-1차/6-2차" 등 sub_area 필터가 필요한 질문은 **반드시 이 뷰** 사용 (아래 agg/기본뷰는 sub_area 없음)
|
||||
|
||||
뷰: v_instrument_range (계기 단위/레인지 — tag_metadata에서 추출)
|
||||
base_tag TEXT - 기본 태그명, 접미사 없음 (예: 'ficq-6113', 'pica-6111')
|
||||
unit TEXT - 단위 (예: 'kg/hr', 'mmHg')
|
||||
eu_lo DOUBLE PRECISION - 레인지 하한
|
||||
eu_hi DOUBLE PRECISION - 레인지 상한
|
||||
※ 계기 레인지/상하한/단위 질문에 사용. base_tag는 '.pv' 등 접미사를 떼고 매칭
|
||||
|
||||
참고(직접 쓰지 말 것): v_plant_running_state_agg 도 있으나 area 레벨 집계라 sub_area가 없음.
|
||||
sub_area 질문엔 위 v_plant_running_state_corroborated 를 사용.
|
||||
|
||||
새로운 태그 타입:
|
||||
- 아날로그: ficq-6101.pv/sp/op (Double)
|
||||
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
||||
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
|
||||
- 메타데이터: desc (String), area (Enum)
|
||||
|
||||
BCD 상태 조회 팁:
|
||||
- instate0~7은 Boolean (true/false)
|
||||
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
|
||||
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
||||
|
||||
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||
1분 버킷: date_trunc('minute', recorded_at) AS bucket
|
||||
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
|
||||
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
|
||||
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
|
||||
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
|
||||
|
||||
예시 (2분 간격, 여러 태그, KST 표시):
|
||||
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AT TIME ZONE 'Asia/Seoul' AS bucket,
|
||||
tagname, AVG(value::double precision) AS avg_val
|
||||
FROM history_table
|
||||
WHERE tagname IN ('tag1', 'tag2')
|
||||
AND recorded_at >= NOW() - INTERVAL '3 hours'
|
||||
GROUP BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname
|
||||
ORDER BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname
|
||||
|
||||
규칙:
|
||||
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
|
||||
- tagname은 모두 소문자로 정확히 입력
|
||||
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
|
||||
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
|
||||
"""
|
||||
|
||||
# SQL 생성 system 프롬프트 (nl2sql_worker._generate_sql 와 동일)
|
||||
SQL_SYSTEM_PROMPT = (
|
||||
"You are a PostgreSQL SQL expert.\n"
|
||||
"Convert the user's question into a SELECT SQL using the schema below.\n"
|
||||
"IMPORTANT rules:\n"
|
||||
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
|
||||
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
|
||||
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
|
||||
"- INTERVAL rule:\n"
|
||||
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
|
||||
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
|
||||
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
|
||||
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
|
||||
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
|
||||
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert KST→UTC for WHERE: KST 12:00 = UTC 03:00.\n"
|
||||
"- Display times in KST: always apply AT TIME ZONE 'Asia/Seoul' on time columns in SELECT.\n"
|
||||
" * Non-aggregated: SELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, ...\n"
|
||||
" * Aggregated bucket: GROUP BY the raw UTC expression, then convert only in SELECT:\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"
|
||||
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
|
||||
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
|
||||
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
||||
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
|
||||
f"{DB_SCHEMA}"
|
||||
)
|
||||
392
plans/P&ID-추출-PREFIX-DB-수정플랜-byBigPickle.md
Normal file
392
plans/P&ID-추출-PREFIX-DB-수정플랜-byBigPickle.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# P&ID 추출 PREFIX 분류 — `tag_dcs` 컬럼 도입 플랜
|
||||
|
||||
> **작성일**: 2026-05-27
|
||||
> **작성자**: BigPickle
|
||||
> **목적**: `pid_prefix_rules`와 `pid_equipment` 두 테이블에 `tag_dcs BOOLEAN` 컬럼을 추가해,
|
||||
> P&ID 추출 **시작 시점**부터 현장 계기(field instrument)와 DCS 태그(DCS function block)를 구별한다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 배경 및 문제
|
||||
|
||||
### 현재 구조의 문제
|
||||
|
||||
현재 `pid_prefix_rules.category = 'instrument'` 아래에 두 종류가 혼재:
|
||||
|
||||
| 종류 | 예시 prefix | 실제 의미 |
|
||||
|------|------------|---------|
|
||||
| **현장 계기** (field) | FT, PT, LT, TT, FCV, PCV, PSV, XV, FG, PG | 물리적 기기, 현장 설치 |
|
||||
| **DCS 함수블록** (system) | FIC, TIC, PIC, LIC, FY, TY, PY, LY | DCS/SCADA 내부 연산 블록, 물리 기기 없음 |
|
||||
|
||||
기존 `tag_class = 'field'/'system'` 컬럼이 이를 구별하려 했으나:
|
||||
- **추출 후 후처리**에서 판정 (ISA 후속문자 분석 + Experion 연결 여부)
|
||||
- **PREFIX 정의 UI**에서는 전혀 보이지 않아 운전원이 구별 불가
|
||||
- LLM이 pid_equipment 조회 시 instrument를 한꺼번에 가져와 혼동
|
||||
|
||||
### 목표
|
||||
|
||||
`pid_prefix_rules` 테이블에 `tag_dcs BOOLEAN` 추가 → PREFIX 분류 정의 시점부터 DCS 여부 명시.
|
||||
`pid_equipment` 테이블에도 동일 컬럼 전파 → 추출 결과 전체에 flag 유지.
|
||||
|
||||
---
|
||||
|
||||
## 1. DCS vs Field 분류 기준
|
||||
|
||||
### DCS 태그 (`tag_dcs = TRUE`) — DCS/Experion DB 포인트, 물리 기기 없음
|
||||
|
||||
| Prefix | 설명 | 비고 |
|
||||
|--------|------|------|
|
||||
| FIC | Flow Indicator Controller | 제어루프 함수블록 |
|
||||
| TIC | Temperature Indicator Controller | |
|
||||
| PIC | Pressure Indicator Controller | |
|
||||
| LIC | Level Indicator Controller | |
|
||||
| FY | Flow Relay/Converter/Computing | DCS 연산요소 |
|
||||
| TY | Temperature Relay/Converter | |
|
||||
| PY | Pressure Relay/Converter | |
|
||||
| LY | Level Relay/Converter | |
|
||||
| FV | Flow Valve (function block) | DCS 출력 함수블록 (주의: 물리 FCV와 구별) |
|
||||
| TV | Temperature Valve (function block) | |
|
||||
| PV | Pressure Valve (function block) | |
|
||||
| LV | Level Valve (function block) | |
|
||||
|
||||
> **주의**: FCV/PCV/LCV/TCV는 물리적 제어밸브 → `tag_dcs = FALSE` (field 유지)
|
||||
|
||||
### 현장 계기 (`tag_dcs = FALSE`) — 물리 기기
|
||||
|
||||
| Prefix | 설명 |
|
||||
|--------|------|
|
||||
| FT, TT, PT, LT | 1차 측정 전송기 (Transmitter) |
|
||||
| FG, TG, PG, LG | 게이지류 (Gauge) |
|
||||
| FCV, TCV, PCV, LCV | 제어밸브 (물리 기기) |
|
||||
| PSV | 안전밸브 |
|
||||
| XV | 차단밸브 |
|
||||
| VIP, VIT | 진동 프로브/전송기 |
|
||||
| DP | 차압계 |
|
||||
| BV | 볼/버터플라이 밸브 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 영향 범위 전체 목록
|
||||
|
||||
### 2.1 데이터베이스 (4곳)
|
||||
|
||||
| 대상 | 변경 내용 |
|
||||
|------|---------|
|
||||
| `pid_prefix_rules` 테이블 | `tag_dcs BOOLEAN NOT NULL DEFAULT FALSE` 컬럼 추가 |
|
||||
| `pid_prefix_rules` 시드 | DCS prefix에 `tag_dcs = TRUE` UPDATE |
|
||||
| `pid_equipment` 테이블 | `tag_dcs BOOLEAN NOT NULL DEFAULT FALSE` 컬럼 추가 |
|
||||
| `pid_equipment` 기존 행 | prefix rule로 backfill |
|
||||
|
||||
**마이그레이션 SQL**:
|
||||
```sql
|
||||
-- pid_prefix_rules 컬럼 추가
|
||||
ALTER TABLE pid_prefix_rules ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- DCS prefix 마킹
|
||||
UPDATE pid_prefix_rules
|
||||
SET tag_dcs = TRUE
|
||||
WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV');
|
||||
|
||||
-- pid_equipment 컬럼 추가
|
||||
ALTER TABLE pid_equipment ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- 기존 행 backfill (prefix rule 기반)
|
||||
UPDATE pid_equipment pe
|
||||
SET tag_dcs = pr.tag_dcs
|
||||
FROM pid_prefix_rules pr
|
||||
WHERE pr.prefix = pe.instrument_type
|
||||
AND pr.tag_dcs = TRUE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 C# 도메인 엔티티 (2파일)
|
||||
|
||||
#### `src/Core/Domain/Entities/PidPrefixRule.cs`
|
||||
```csharp
|
||||
// 추가
|
||||
[Column("tag_dcs")]
|
||||
public bool TagDcs { get; set; } = false;
|
||||
```
|
||||
|
||||
#### `src/Core/Domain/Entities/PidEquipment.cs`
|
||||
```csharp
|
||||
// 추가 (tag_class 아래)
|
||||
[Column("tag_dcs")]
|
||||
public bool TagDcs { get; set; } = false;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 DTOs (1파일, 3개 record)
|
||||
|
||||
#### `src/Core/Application/DTOs/PidPrefixRuleDto.cs`
|
||||
|
||||
```csharp
|
||||
// 기존
|
||||
public record PidPrefixRuleDto(int Id, string Prefix, string Category, string? Description, int SortOrder, DateTime CreatedAt);
|
||||
public record CreatePidPrefixRuleRequest(string Prefix, string Category, string? Description, int SortOrder = 0);
|
||||
public record UpdatePidPrefixRuleRequest(string Prefix, string Category, string? Description, int SortOrder = 0);
|
||||
|
||||
// 수정 후 (TagDcs 추가)
|
||||
public record PidPrefixRuleDto(int Id, string Prefix, string Category, bool TagDcs, string? Description, int SortOrder, DateTime CreatedAt);
|
||||
public record CreatePidPrefixRuleRequest(string Prefix, string Category, bool TagDcs = false, string? Description = null, int SortOrder = 0);
|
||||
public record UpdatePidPrefixRuleRequest(string Prefix, string Category, bool TagDcs = false, string? Description = null, int SortOrder = 0);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Application Services (1파일)
|
||||
|
||||
#### `src/Core/Application/Services/PidExtractorService.cs`
|
||||
|
||||
**변경 1**: `MatchCategoryAsync()` → prefix rule에서 `tag_dcs`도 반환
|
||||
현재는 `string? category`만 반환. `(string? category, bool tagDcs)` 튜플로 변경하거나,
|
||||
별도 `GetPrefixRuleByTagAsync(tagNo)` 호출로 tag_dcs 획득.
|
||||
|
||||
**변경 2**: `ClassifyTagClass()` 단순화
|
||||
기존 로직: `hasExperionLink → system`, ISA 후속문자 분석 → `system/field`
|
||||
수정 후: `tag_dcs = TRUE` → `TagClass = "system"` (prefix rule이 ground truth)
|
||||
Experion 연결 여부는 여전히 보완 신호로 유지 가능.
|
||||
|
||||
**변경 3**: 추출 저장 시 `TagDcs` 채우기
|
||||
```csharp
|
||||
// 기존
|
||||
item.Category = category;
|
||||
item.TagClass = tagClass;
|
||||
|
||||
// 수정
|
||||
item.Category = category;
|
||||
item.TagDcs = tagDcs; // prefix rule에서 가져온 값
|
||||
item.TagClass = tagDcs ? PidEquipment.TagClassSystem : tagClass; // 파생 또는 별도 로직
|
||||
```
|
||||
|
||||
**변경 4**: CSV/Excel export에 `TagDcs` 열 추가
|
||||
- CSV 헤더: `TagNo,...,TagClass,TagDcs`
|
||||
- Excel 열 추가 (17번 열): "DCS태그" 불리언 → "DCS"/"현장" 표시
|
||||
|
||||
**변경 5**: Excel import에서 `tag_dcs` 처리
|
||||
- Excel "DCS태그" 열 → `"DCS" → true, "현장" → false`
|
||||
|
||||
**변경 6**: `BackfillTagClassAsync()` → `BackfillTagDcsAsync()` 추가
|
||||
기존 backfill 로직에서 `tag_dcs` 미지정 행도 함께 backfill.
|
||||
|
||||
**변경 7**: `CreatePrefixRuleAsync` / `UpdatePrefixRuleAsync`
|
||||
`request.TagDcs` → `rule.TagDcs` 저장.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 인터페이스 (1파일)
|
||||
|
||||
#### `src/Core/Application/Interfaces/IExperionServices.cs`
|
||||
|
||||
`IPidExtractorService` 인터페이스 시그니처 수정:
|
||||
- `CreatePrefixRuleAsync(CreatePidPrefixRuleRequest)` — DTO 변경으로 자동 반영
|
||||
- `UpdatePrefixRuleAsync(int, UpdatePidPrefixRuleRequest)` — 동일
|
||||
|
||||
---
|
||||
|
||||
### 2.6 EF Core DbContext (1파일)
|
||||
|
||||
#### `src/Infrastructure/Database/ExperionDbContext.cs`
|
||||
|
||||
**변경 1**: Boot DDL에 `ALTER TABLE` 추가
|
||||
```csharp
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE pid_prefix_rules ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE");
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE pid_equipment ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE");
|
||||
```
|
||||
|
||||
**변경 2**: 시드 INSERT 수정
|
||||
기존 INSERT는 `ON CONFLICT DO NOTHING` → 기존 행에 반영 안 됨.
|
||||
마이그레이션 UPDATE 별도 실행 필요 (§2.1 마이그레이션 SQL).
|
||||
|
||||
**변경 3**: EF 모델 바인딩 (필요시)
|
||||
`modelBuilder.Entity<PidPrefixRule>()` 블록에 `tag_dcs` 명시 없어도 Column attribute로 자동 매핑.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Web Controllers (1파일)
|
||||
|
||||
#### `src/Web/Controllers/PidController.cs`
|
||||
|
||||
**변경 1**: `GetPrefixRules` 응답
|
||||
현재 `PidPrefixRule` 엔티티를 직접 직렬화 → `TagDcs` 필드 자동 포함 (Column attribute 추가로 충분).
|
||||
|
||||
**변경 2**: `CreatePrefixRule` / `UpdatePrefixRule`
|
||||
`request.TagDcs` 가 DTO에 추가되므로 컨트롤러 수정 불필요 (서비스에서 처리).
|
||||
|
||||
**변경 3**: `[JsonPropertyName("tagDcs")]` 확인
|
||||
익명객체 대신 DTO 반환 시 camelCase 보장 필요. (기존 패턴 확인 후 적용)
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Web UI (2파일)
|
||||
|
||||
#### `src/Web/wwwroot/js/pid.js`
|
||||
|
||||
**변경 1**: `CATEGORY_LABELS` / `CATEGORY_ORDER`
|
||||
```javascript
|
||||
// 기존
|
||||
instrument: { label: 'Instrument', badge: 'ok' },
|
||||
|
||||
// 수정 — DCS는 별도 배지
|
||||
// (카테고리가 'instrument'로 유지되고 tag_dcs로 구별하는 방식)
|
||||
```
|
||||
|
||||
**변경 2**: PREFIX 그룹 렌더링 (`pidRenderPrefixGroups`)
|
||||
각 prefix rule 행에 `tag_dcs` 체크박스/배지 추가:
|
||||
```javascript
|
||||
// 테이블 열 추가
|
||||
<td><span class="badge ${r.tagDcs ? 'warn' : 'ok'}">${r.tagDcs ? 'DCS' : '현장'}</span></td>
|
||||
// 편집 행에도 tag_dcs 토글 추가
|
||||
<input type="checkbox" ${r.tagDcs ? 'checked' : ''} data-field="tagDcs" />
|
||||
```
|
||||
|
||||
**변경 3**: `pidAddPrefixRule(category)` 요청 body에 `tagDcs` 추가
|
||||
**변경 4**: `pidUpdatePrefixRule(id)` 요청 body에 `tagDcs` 추가
|
||||
**변경 5**: 장비 목록 테이블에 `tag_dcs` 배지 추가 (선택사항)
|
||||
|
||||
#### `src/Web/wwwroot/panes/pid.html`
|
||||
|
||||
- PREFIX 분류 정의 패널에 열 헤더 "DCS태그" 추가
|
||||
- 도움말 텍스트 갱신
|
||||
|
||||
---
|
||||
|
||||
### 2.9 MCP Server Python (2파일)
|
||||
|
||||
#### `mcp-server/server.py`
|
||||
|
||||
**변경 1**: `_classify_pid_tag()` 반환에 `tag_dcs` 필드 추가
|
||||
```python
|
||||
# 기존
|
||||
return {"kind": "instrument", "prefix": prefix, "type": type_name}
|
||||
|
||||
# 수정 — DCS prefix 목록 상수 추가
|
||||
_DCS_PREFIXES = {"FIC","TIC","PIC","LIC","FY","TY","PY","LY","FV","TV","PV","LV"}
|
||||
|
||||
return {
|
||||
"kind": "instrument",
|
||||
"prefix": prefix,
|
||||
"type": type_name,
|
||||
"tag_dcs": prefix in _DCS_PREFIXES
|
||||
}
|
||||
```
|
||||
|
||||
**변경 2**: `_DB_SCHEMA` 상수에 `pid_equipment.tag_dcs` 컬럼 설명 추가
|
||||
```python
|
||||
_DB_SCHEMA = """
|
||||
...
|
||||
테이블: pid_equipment (P&ID 추출 태그/장비)
|
||||
tag_no TEXT - 태그번호
|
||||
category TEXT - 'instrument' / 'power_equipment' / ...
|
||||
tag_dcs BOOL - TRUE=DCS 함수블록(FIC/TIC/PIC 등), FALSE=현장 물리 계기(FT/PT/FCV 등)
|
||||
tag_class TEXT - 'field' / 'system' (tag_dcs 기반 + Experion 연결 보완)
|
||||
instrument_type TEXT - prefix (FT/FIC/P 등)
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
**변경 3**: `upsert_pid_connection` 함수
|
||||
현재 허용 컬럼 목록: `from_tag, to_tag, from_at, to_at, role, category, tag_class, connection_locked`
|
||||
→ `tag_dcs` 추가 허용 여부 검토 (운전원이 수동 override 가능하도록)
|
||||
|
||||
#### `mcp-server/worker/sql_prompt.py`
|
||||
|
||||
`DB_SCHEMA` 상수 — 현재 `pid_equipment`가 직접 언급되지 않으나,
|
||||
향후 NL2SQL에서 "DCS 태그인지" 질문 처리를 위해 다음 추가:
|
||||
```
|
||||
테이블: pid_equipment(tag_no TEXT, category TEXT, tag_dcs BOOL, tag_class TEXT, instrument_type TEXT, from_tag TEXT, to_tag TEXT)
|
||||
※ tag_dcs=TRUE: DCS 함수블록(FIC/TIC/PIC류), FALSE: 현장 물리 계기(FT/FCV류)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.10 프롬프트 / 지식 파일 (1파일)
|
||||
|
||||
#### `prompts/plant_context.md`
|
||||
|
||||
현재 계기/태그 분류 설명에 다음 추가:
|
||||
```markdown
|
||||
## pid_equipment.tag_dcs
|
||||
- tag_dcs = TRUE: DCS 내부 함수블록 (FIC, TIC, PIC, LIC, FY, TY, PY, LY 등)
|
||||
- 물리 기기 없음, Experion 데이터베이스 포인트로만 존재
|
||||
- tag_dcs = FALSE: 현장 물리 계기 (FT, PT, LT, FCV, PSV, XV 등)
|
||||
- P&ID 도면에 기기 심벌로 표시되는 실물
|
||||
- "DCS 태그 몇 개?" → pid_equipment WHERE tag_dcs=TRUE COUNT
|
||||
- "현장 계기 목록" → pid_equipment WHERE tag_dcs=FALSE AND category='instrument'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.11 instrument_inference (검토 필요, 1파일)
|
||||
|
||||
#### `mcp-server/instrument_inference/infer.py`
|
||||
|
||||
현재 `_dcs_internal_roles` 집합으로 내부적으로 DCS/field 구별 중.
|
||||
`tag_dcs` 컬럼 도입 후 이 로직이 중복될 수 있으나, infer.py는 **추론 단계**이므로
|
||||
`pid_prefix_rules.tag_dcs`를 직접 참조할 수 없음 (독립 실행 모듈).
|
||||
→ **변경 불필요** — `_dcs_internal_roles` 로직은 infer 내부 용도로 유지.
|
||||
|
||||
---
|
||||
|
||||
## 3. 단계별 구현 순서
|
||||
|
||||
### Phase 1: DB 스키마 (선행 필수)
|
||||
1. `ExperionDbContext.cs` Boot DDL에 `ALTER TABLE` 추가
|
||||
2. 마이그레이션 SQL 실행 (직접 또는 재기동 시 자동 적용)
|
||||
|
||||
### Phase 2: 도메인/DTO/서비스 (C# 코어)
|
||||
1. `PidPrefixRule.cs` — `TagDcs` 프로퍼티 추가
|
||||
2. `PidEquipment.cs` — `TagDcs` 프로퍼티 추가
|
||||
3. `PidPrefixRuleDto.cs` — 3개 record 수정
|
||||
4. `PidExtractorService.cs` — 추출/CRUD/export/import/backfill 수정
|
||||
|
||||
### Phase 3: Web Controller
|
||||
1. `PidController.cs` — camelCase 직렬화 확인 (필요시 `[JsonPropertyName]`)
|
||||
|
||||
### Phase 4: Web UI
|
||||
1. `pid.js` — PREFIX 그룹 렌더링 + Add/Update 폼
|
||||
2. `panes/pid.html` — 열 헤더
|
||||
|
||||
### Phase 5: MCP / LLM 경로
|
||||
1. `server.py` — `_classify_pid_tag` + `_DB_SCHEMA` + `upsert_pid_connection`
|
||||
2. `worker/sql_prompt.py` — DB_SCHEMA pid_equipment 항목
|
||||
3. `prompts/plant_context.md` — tag_dcs 설명
|
||||
|
||||
### Phase 6: 검증
|
||||
1. `dotnet build` — 경고 0/에러 0
|
||||
2. `python3 -m py_compile mcp-server/server.py` — OK
|
||||
3. 웹 UI: PREFIX 분류 탭에서 DCS/현장 배지 확인
|
||||
4. pid_equipment 추출 후 `SELECT tag_dcs, COUNT(*) FROM pid_equipment GROUP BY tag_dcs` 확인
|
||||
5. LLM 채팅: "FIC-6113이 DCS 태그야?" 질문 → 정상 답변 확인
|
||||
|
||||
---
|
||||
|
||||
## 4. 설계 결정
|
||||
|
||||
| 항목 | 결정 | 이유 |
|
||||
|------|------|------|
|
||||
| 컬럼 타입 | `tag_dcs BOOLEAN` (별도 카테고리 X) | 카테고리 변경 시 하위 의존(뷰·필터) 전파 범위 과도. Boolean이 최소 침습적 |
|
||||
| `tag_class` 유지 | 유지 (deprecated 아님) | Experion 연결 ground truth 포함, 더 정밀. `tag_dcs`는 prefix 기반 빠른 flag |
|
||||
| `tag_class` 파생 | `tag_dcs=TRUE → TagClass='system'` | 기존 ISA 분석 로직 보완이 아닌 override |
|
||||
| FCV/PCV/LCV/TCV | `tag_dcs = FALSE` (현장 유지) | 물리 제어밸브. DCS가 제어하지만 기기 자체는 현장 |
|
||||
| FV/TV/PV/LV | `tag_dcs = TRUE` | ISA 표준상 "Valve(function block output)" — 물리 기기 아닌 DCS 출력 |
|
||||
| UI 표시 | category 컬럼 유지, tag_dcs 배지 추가 | 카테고리 탭 구조(instrument/power_equipment…) 그대로 유지 |
|
||||
| seed UPDATE 시점 | Boot DDL 이후 별도 UPDATE | INSERT ON CONFLICT DO NOTHING은 기존 행 미반영 |
|
||||
| backfill | 재기동 시 자동 실행 (Boot DDL에 포함) | 수동 실행 의존성 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 잔여/고려사항
|
||||
|
||||
- **FICQ, TICQ 등 "Q" suffix prefix**: 현재 시드에 없음. 추출 시 FIC의 변형으로 처리되므로
|
||||
`instrument_type = 'FICQ'`로 저장되면 prefix rule 미매칭 → `tag_dcs = FALSE`(default) 오류 가능.
|
||||
→ 시드에 `('FICQ','instrument','Flow IC Totalizer',10, TRUE)` 등 추가 필요 여부 검토.
|
||||
|
||||
- **누락 prefix 처리**: `pid_equipment.instrument_type`이 prefix rule에 없으면 backfill 불가.
|
||||
→ `tag_class = 'system'`인 행으로 보조 매핑 가능.
|
||||
|
||||
- **P&ID 도면 재추출 여부**: 기존 추출 결과는 backfill SQL로 충분. 재추출 불필요.
|
||||
@@ -327,3 +327,28 @@ AND from_tag IN ('FT-6118','P-6118',...);
|
||||
- 설명할 때 "이 장비는 2개 경로가 있음"이라고 명시
|
||||
- OR 병합된 값("A, B" 같은)은 없음 — 각 행은 단일 경로
|
||||
- `from_at`, `to_at`은 연결 지점 상세 (예: "C-6111 중상부 제품 노즐")
|
||||
|
||||
---
|
||||
|
||||
## pid_equipment.tag_dcs — 현장 계기 vs DCS 함수블록 구별
|
||||
|
||||
- **tag_dcs = TRUE**: DCS 내부 함수블록 (FIC, TIC, PIC, LIC, FY, TY, PY, LY 등 compound형 포함)
|
||||
- 물리 기기 없음. Experion DB 포인트로만 존재
|
||||
- 예: FIC-6113(유량제어기), TIC-6201(온도제어기)
|
||||
- **tag_dcs = FALSE**: 현장 물리 계기 (FT, PT, LT, FCV, PSV, XV 등)
|
||||
- P&ID 도면에 기기 심벌로 표시되는 실물. Experion 연결 여부 무관하게 field
|
||||
- 예: FT-6113(유량전송기), FCV-6113(제어밸브)
|
||||
|
||||
### 쿼리 예시
|
||||
|
||||
```sql
|
||||
-- DCS 태그 수
|
||||
SELECT COUNT(*) FROM pid_equipment WHERE tag_dcs = TRUE;
|
||||
|
||||
-- 현장 계기 목록
|
||||
SELECT tag_no, instrument_type FROM pid_equipment
|
||||
WHERE tag_dcs = FALSE AND category = 'instrument';
|
||||
|
||||
-- 특정 태그 DCS 여부 확인
|
||||
SELECT tag_no, tag_dcs, tag_class FROM pid_equipment WHERE tag_no = 'FIC-6113';
|
||||
```
|
||||
|
||||
@@ -17,3 +17,25 @@ public record PidEquipmentDto(
|
||||
string? Role,
|
||||
string? FromTag,
|
||||
string? ToTag);
|
||||
|
||||
public record CreateEquipmentRequest(
|
||||
string TagNo,
|
||||
string? EquipmentName = null,
|
||||
string? InstrumentType = null,
|
||||
string? Category = null,
|
||||
bool? TagDcs = null,
|
||||
string? Role = null,
|
||||
string? FromTag = null,
|
||||
string? ToTag = null,
|
||||
string? TagClass = null);
|
||||
|
||||
public record UpdateEquipmentRequest(
|
||||
string? TagNo = null,
|
||||
string? EquipmentName = null,
|
||||
string? InstrumentType = null,
|
||||
string? Category = null,
|
||||
bool? TagDcs = null,
|
||||
string? Role = null,
|
||||
string? FromTag = null,
|
||||
string? ToTag = null,
|
||||
string? TagClass = null);
|
||||
|
||||
@@ -4,6 +4,7 @@ public record PidPrefixRuleDto(
|
||||
int Id,
|
||||
string Prefix,
|
||||
string Category,
|
||||
bool TagDcs,
|
||||
string? Description,
|
||||
int SortOrder,
|
||||
DateTime CreatedAt);
|
||||
@@ -11,11 +12,13 @@ public record PidPrefixRuleDto(
|
||||
public record CreatePidPrefixRuleRequest(
|
||||
string Prefix,
|
||||
string Category,
|
||||
string? Description,
|
||||
bool TagDcs = false,
|
||||
string? Description = null,
|
||||
int SortOrder = 0);
|
||||
|
||||
public record UpdatePidPrefixRuleRequest(
|
||||
string Prefix,
|
||||
string Category,
|
||||
string? Description,
|
||||
bool TagDcs = false,
|
||||
string? Description = null,
|
||||
int SortOrder = 0);
|
||||
|
||||
@@ -318,7 +318,8 @@ public interface IPidExtractorService
|
||||
|
||||
// 조회 (페이지네이션)
|
||||
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
|
||||
string? tagNo, int page, int pageSize);
|
||||
string? tagNo, string? category, int page, int pageSize,
|
||||
string? sortBy = null, bool sortDesc = false);
|
||||
|
||||
Task<PidEquipment?> GetByIdAsync(long id);
|
||||
|
||||
@@ -326,6 +327,9 @@ public interface IPidExtractorService
|
||||
Task UpdateConfidenceAsync(long id, double confidence);
|
||||
Task ActivateAsync(long id);
|
||||
Task DeactivateAsync(long id);
|
||||
Task<PidEquipment> CreateEquipmentAsync(CreateEquipmentRequest request);
|
||||
Task<bool> UpdateEquipmentAsync(long id, UpdateEquipmentRequest request);
|
||||
Task<(bool Found, string? TagName)> AutoMapTagAsync(long id);
|
||||
Task<bool> DeleteAsync(long id);
|
||||
|
||||
// 통계
|
||||
|
||||
@@ -88,7 +88,8 @@ public class PidExtractorService : IPidExtractorService
|
||||
: await FindFallbackTagAsync(item.TagNo);
|
||||
|
||||
var category = await MatchCategoryAsync(item.TagNo);
|
||||
var tagClass = ClassifyTagClass(item.TagNo, category, experionTag != null);
|
||||
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
|
||||
var tagClass = ClassifyTagClass(category, tagDcs);
|
||||
|
||||
var newItem = new PidEquipment
|
||||
{
|
||||
@@ -101,6 +102,7 @@ public class PidExtractorService : IPidExtractorService
|
||||
Confidence = item.Confidence,
|
||||
ExperionTagId = experionTag?.Id,
|
||||
Category = category,
|
||||
TagDcs = tagDcs,
|
||||
TagClass = tagClass,
|
||||
ExtractedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
@@ -376,24 +378,60 @@ public class PidExtractorService : IPidExtractorService
|
||||
|
||||
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
|
||||
{
|
||||
var normalized = tagNo.Split('.')[0];
|
||||
var normalized = tagNo.Split('.')[0].ToLowerInvariant();
|
||||
return await _dbContext.RealtimePoints
|
||||
.FirstOrDefaultAsync(t => t.TagName == normalized
|
||||
|| t.TagName.StartsWith(normalized + "."));
|
||||
.FirstOrDefaultAsync(t => t.TagName.ToLower() == normalized
|
||||
|| t.TagName.ToLower().StartsWith(normalized + "."));
|
||||
}
|
||||
|
||||
public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
|
||||
string? tagNo, int page, int pageSize)
|
||||
string? tagNo, string? category, int page, int pageSize,
|
||||
string? sortBy = null, bool sortDesc = false)
|
||||
{
|
||||
var q = _dbContext.PidEquipment.AsQueryable();
|
||||
if (!string.IsNullOrEmpty(tagNo))
|
||||
q = q.Where(e => e.TagNo.Contains(tagNo));
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
if (category == "__unmatched__")
|
||||
q = q.Where(e => e.Category == null);
|
||||
else if (category == "instrument_dcs")
|
||||
q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagDcs);
|
||||
else if (category == "instrument_field")
|
||||
q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && !e.TagDcs);
|
||||
else
|
||||
q = q.Where(e => e.Category == category);
|
||||
}
|
||||
|
||||
var total = await q.CountAsync();
|
||||
var items = await q.OrderByDescending(e => e.ExtractedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
List<PidEquipment> items;
|
||||
if (sortBy == "tagName")
|
||||
{
|
||||
// DB에서 기본 정렬로 가져온 뒤 C#에서 natural sort
|
||||
var all = await q.OrderBy(e => e.TagNo.Length).ThenBy(e => e.TagNo).ToListAsync();
|
||||
if (sortDesc)
|
||||
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ThenByDescending(e => e.TagNo.ToLowerInvariant()).ToList();
|
||||
else
|
||||
all = all.OrderBy(e => TagSortKey(e.TagNo)).ThenBy(e => e.TagNo.ToLowerInvariant()).ToList();
|
||||
items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
items = await q.OrderByDescending(e => e.ExtractedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
}
|
||||
return (total, items);
|
||||
}
|
||||
|
||||
private static string TagSortKey(string tagNo)
|
||||
{
|
||||
// prefix (첫 digit 앞까지의 문자) + 길이 + 원본 순으로 정렬 가능한 키 생성
|
||||
var m = Regex.Match(tagNo, @"^([A-Za-z-]+)");
|
||||
var prefix = m.Success ? m.Groups[1].Value.ToLowerInvariant() : tagNo.ToLowerInvariant();
|
||||
return $"{prefix}\x00{tagNo.Length:D4}\x00{tagNo.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public async Task<PidEquipment?> GetByIdAsync(long id)
|
||||
=> await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
@@ -405,6 +443,54 @@ public class PidExtractorService : IPidExtractorService
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<PidEquipment> CreateEquipmentAsync(CreateEquipmentRequest request)
|
||||
{
|
||||
var e = new PidEquipment
|
||||
{
|
||||
TagNo = request.TagNo.ToLowerInvariant(),
|
||||
EquipmentName = request.EquipmentName,
|
||||
InstrumentType = request.InstrumentType,
|
||||
Category = request.Category,
|
||||
TagDcs = request.TagDcs ?? false,
|
||||
Role = request.Role,
|
||||
FromTag = request.FromTag,
|
||||
ToTag = request.ToTag,
|
||||
TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
|
||||
IsActive = true,
|
||||
Confidence = 1.0,
|
||||
ExtractedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
if (request.FromTag != null || request.ToTag != null)
|
||||
e.ConnectionLocked = true;
|
||||
_dbContext.PidEquipment.Add(e);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
_logger.LogInformation("[PID] Created equipment id={Id} tagNo={Tag}", e.Id, e.TagNo);
|
||||
return e;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateEquipmentAsync(long id, UpdateEquipmentRequest request)
|
||||
{
|
||||
var e = await _dbContext.PidEquipment.FindAsync(id);
|
||||
if (e == null) return false;
|
||||
if (request.TagNo != null) e.TagNo = request.TagNo.ToLowerInvariant();
|
||||
if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName;
|
||||
if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType;
|
||||
if (request.Category != null) e.Category = request.Category;
|
||||
if (request.TagDcs.HasValue) e.TagDcs = request.TagDcs.Value;
|
||||
if (request.Role != null) e.Role = request.Role;
|
||||
if (request.FromTag != null) e.FromTag = request.FromTag;
|
||||
if (request.ToTag != null) e.ToTag = request.ToTag;
|
||||
if (request.TagClass != null) e.TagClass = request.TagClass;
|
||||
if (request.Category != null || request.TagDcs.HasValue)
|
||||
e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
|
||||
if (request.FromTag != null || request.ToTag != null)
|
||||
e.ConnectionLocked = true;
|
||||
e.UpdatedAt = DateTime.UtcNow;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task ActivateAsync(long id)
|
||||
{
|
||||
var e = await _dbContext.PidEquipment.FindAsync(id);
|
||||
@@ -430,6 +516,18 @@ public class PidExtractorService : IPidExtractorService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(bool Found, string? TagName)> AutoMapTagAsync(long id)
|
||||
{
|
||||
var e = await _dbContext.PidEquipment.FindAsync(id);
|
||||
if (e == null) return (false, null);
|
||||
var match = await FindFallbackTagAsync(e.TagNo ?? "");
|
||||
if (match == null) return (false, null);
|
||||
e.ExperionTagId = match.Id;
|
||||
e.UpdatedAt = DateTime.UtcNow;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return (true, match.TagName);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteAllEquipmentAsync()
|
||||
{
|
||||
var count = await _dbContext.PidEquipment.CountAsync();
|
||||
@@ -483,12 +581,19 @@ public class PidExtractorService : IPidExtractorService
|
||||
var rules = await GetRulesCachedAsync();
|
||||
|
||||
var grouped = items
|
||||
.GroupBy(i => string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!)
|
||||
.GroupBy(i =>
|
||||
{
|
||||
var cat = string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!;
|
||||
if (cat == PidEquipment.CategoryInstrument)
|
||||
return i.TagDcs ? "instrument_dcs" : "instrument_field";
|
||||
return cat;
|
||||
})
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var sheetOrder = new[]
|
||||
{
|
||||
PidEquipment.CategoryInstrument,
|
||||
"instrument_dcs",
|
||||
"instrument_field",
|
||||
PidEquipment.CategoryPowerEquipment,
|
||||
PidEquipment.CategoryStorageEquipment,
|
||||
PidEquipment.CategoryProcessEquipment,
|
||||
@@ -499,7 +604,8 @@ public class PidExtractorService : IPidExtractorService
|
||||
|
||||
var sheetNames = new Dictionary<string, string>
|
||||
{
|
||||
[PidEquipment.CategoryInstrument] = "Instrument",
|
||||
["instrument_dcs"] = "DCS 태그",
|
||||
["instrument_field"] = "현장 계기",
|
||||
[PidEquipment.CategoryPowerEquipment] = "Power Equipment",
|
||||
[PidEquipment.CategoryStorageEquipment] = "Storage Equipment",
|
||||
[PidEquipment.CategoryProcessEquipment] = "Process Equipment",
|
||||
@@ -532,9 +638,10 @@ public class PidExtractorService : IPidExtractorService
|
||||
worksheet.Cells[1, 14].Value = "From_at";
|
||||
worksheet.Cells[1, 15].Value = "To_at";
|
||||
worksheet.Cells[1, 16].Value = "태그분류";
|
||||
worksheet.Cells[1, 17].Value = "id";
|
||||
worksheet.Cells[1, 17].Value = "id"; // 안정 키(라운드트립 매칭용) — col17 고정
|
||||
worksheet.Cells[1, 18].Value = "DCS태그"; // tag_dcs: DCS 함수블록 여부
|
||||
|
||||
using var headerRange = worksheet.Cells[1, 1, 1, 17];
|
||||
using var headerRange = worksheet.Cells[1, 1, 1, 18];
|
||||
headerRange.Style.Font.Bold = true;
|
||||
headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid;
|
||||
headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);
|
||||
@@ -566,6 +673,7 @@ public class PidExtractorService : IPidExtractorService
|
||||
_ => ""
|
||||
};
|
||||
worksheet.Cells[row, 17].Value = item.Id; // 안정 키(라운드트립 매칭용)
|
||||
worksheet.Cells[row, 18].Value = item.TagDcs ? "DCS" : "현장";
|
||||
row++;
|
||||
}
|
||||
|
||||
@@ -620,8 +728,11 @@ public class PidExtractorService : IPidExtractorService
|
||||
StringComparison.Ordinal))
|
||||
continue;
|
||||
// col17 헤더가 "id" 면 안정 키 매칭, 아니면 옛 포맷(태그번호 매칭)으로 폴백
|
||||
bool hasIdCol = string.Equals(ws.Cells[1, 17].Text?.Trim(), "id",
|
||||
StringComparison.Ordinal);
|
||||
bool hasIdCol = string.Equals(ws.Cells[1, 17].Text?.Trim(), "id",
|
||||
StringComparison.Ordinal);
|
||||
// col18 헤더가 "DCS태그" 면 tag_dcs 읽기
|
||||
bool hasDcsCol = string.Equals(ws.Cells[1, 18].Text?.Trim(), "DCS태그",
|
||||
StringComparison.Ordinal);
|
||||
sheets++;
|
||||
|
||||
for (int r = 2; r <= ws.Dimension.End.Row; r++)
|
||||
@@ -673,6 +784,12 @@ public class PidExtractorService : IPidExtractorService
|
||||
e.FromAt = fromAt;
|
||||
e.ToAt = toAt;
|
||||
e.TagClass = tagClass;
|
||||
// col18: DCS태그 (DCS=true, 현장=false). 헤더 없는 옛 파일은 기존 값 유지.
|
||||
if (hasDcsCol)
|
||||
{
|
||||
var dcsVal = Norm(ws, r, 18);
|
||||
e.TagDcs = dcsVal == "DCS";
|
||||
}
|
||||
// From/To 를 채운 행만 잠금(사람이 연결을 교정한 행).
|
||||
// 둘 다 비우면 잠금 해제 → 연결분석이 다시 도출 가능.
|
||||
e.ConnectionLocked = fromTag != null || toTag != null;
|
||||
@@ -794,40 +911,31 @@ public class PidExtractorService : IPidExtractorService
|
||||
tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category;
|
||||
}
|
||||
|
||||
// 태그 선두 알파벳 (첫 비알파벳 이전) — ISA 기능코드 후보
|
||||
private static readonly Regex _tagPrefixRe = new(@"^([A-Za-z]+)", RegexOptions.Compiled);
|
||||
|
||||
// 제어시스템 함수 후속문자: I(지시) C(제어) A(알람) Q(적산) Y(연산) R(기록)
|
||||
// → 이 함수들은 현장 기기가 아니라 DCS/SCADA/PLC 내부에서 구현됨
|
||||
private static readonly HashSet<char> _systemFuncLetters =
|
||||
['I', 'C', 'A', 'Q', 'Y', 'R'];
|
||||
/// <summary>
|
||||
/// prefix rule에서 tag_dcs 값을 조회. StartsWith 매칭으로 compound형(FICQ/FICA 등) 자동 커버.
|
||||
/// 가장 긴 prefix 우선(FIC보다 FICQ가 더 구체적이면 FICQ rule 우선).
|
||||
/// </summary>
|
||||
private async Task<bool> ResolveTagDcsAsync(string tagNo)
|
||||
{
|
||||
var rules = await GetRulesCachedAsync();
|
||||
var upper = tagNo.ToUpperInvariant();
|
||||
var rule = rules
|
||||
.Where(r => upper.StartsWith(r.Prefix.ToUpperInvariant()))
|
||||
.OrderByDescending(r => r.Prefix.Length) // 가장 긴 prefix 우선
|
||||
.FirstOrDefault();
|
||||
return rule?.TagDcs ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 계기(instrument) 하위 분류. experion(realtime) 연결을 1순위 확정 신호로 사용.
|
||||
/// experion 연결됨 → system (DCS/SCADA DB 포인트, ground truth)
|
||||
/// ISA 후속문자에 I/C/A/Q/Y/R → system (제어시스템 함수 블록)
|
||||
/// 그 외(1차 측정요소·전송·게이지·기계식) → field (현장 실물 기기)
|
||||
/// instrument 가 아닌 카테고리는 null.
|
||||
/// 계기(instrument) 하위 분류. tag_dcs(prefix rule)가 단일 기준.
|
||||
/// tag_dcs=TRUE → system (DCS 함수블록: FIC/TIC/PIC류)
|
||||
/// tag_dcs=FALSE → field (현장 물리 계기: FT/FCV류)
|
||||
/// instrument 이외 카테고리는 null.
|
||||
/// </summary>
|
||||
private static string? ClassifyTagClass(string tagNo, string? category, bool hasExperionLink)
|
||||
private static string? ClassifyTagClass(string? category, bool tagDcs)
|
||||
{
|
||||
if (category != PidEquipment.CategoryInstrument)
|
||||
return null;
|
||||
|
||||
if (hasExperionLink)
|
||||
return PidEquipment.TagClassSystem;
|
||||
|
||||
var m = _tagPrefixRe.Match(tagNo);
|
||||
if (m.Success && m.Groups[1].Value.Length >= 2)
|
||||
{
|
||||
var prefix = m.Groups[1].Value.ToUpperInvariant();
|
||||
// 첫 글자(측정변수) 이후 후속문자에 제어시스템 함수가 있으면 system
|
||||
for (int i = 1; i < prefix.Length; i++)
|
||||
if (_systemFuncLetters.Contains(prefix[i]))
|
||||
return PidEquipment.TagClassSystem;
|
||||
}
|
||||
|
||||
return PidEquipment.TagClassField;
|
||||
if (category != PidEquipment.CategoryInstrument) return null;
|
||||
return tagDcs ? PidEquipment.TagClassSystem : PidEquipment.TagClassField;
|
||||
}
|
||||
|
||||
// ── Prefix Rule CRUD ───────────────────────────────────────────────────────
|
||||
@@ -846,6 +954,7 @@ public class PidExtractorService : IPidExtractorService
|
||||
{
|
||||
Prefix = request.Prefix.Trim(),
|
||||
Category = request.Category,
|
||||
TagDcs = request.TagDcs,
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -863,6 +972,7 @@ public class PidExtractorService : IPidExtractorService
|
||||
if (rule == null) return null;
|
||||
rule.Prefix = request.Prefix.Trim();
|
||||
rule.Category = request.Category;
|
||||
rule.TagDcs = request.TagDcs;
|
||||
rule.Description = request.Description?.Trim();
|
||||
rule.SortOrder = request.SortOrder;
|
||||
rule.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -885,10 +995,12 @@ public class PidExtractorService : IPidExtractorService
|
||||
{
|
||||
const int batchSize = 1000;
|
||||
int total = 0;
|
||||
long lastId = 0;
|
||||
while (true)
|
||||
{
|
||||
var batch = await _dbContext.PidEquipment
|
||||
.Where(e => e.Category == null)
|
||||
.Where(e => e.Id > lastId)
|
||||
.OrderBy(e => e.Id)
|
||||
.Take(batchSize)
|
||||
.ToListAsync();
|
||||
if (!batch.Any()) break;
|
||||
@@ -896,32 +1008,17 @@ public class PidExtractorService : IPidExtractorService
|
||||
foreach (var item in batch)
|
||||
{
|
||||
var category = await MatchCategoryAsync(item.TagNo);
|
||||
if (category != null)
|
||||
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
|
||||
var tagClass = ClassifyTagClass(category, tagDcs);
|
||||
if (item.Category != category || item.TagDcs != tagDcs || item.TagClass != tagClass)
|
||||
{
|
||||
item.Category = category;
|
||||
item.TagClass = ClassifyTagClass(item.TagNo, category, item.ExperionTagId != null);
|
||||
item.TagDcs = tagDcs;
|
||||
item.TagClass = tagClass;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
total++;
|
||||
}
|
||||
}
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 이미 instrument 로 분류됐으나 tag_class 미지정인 기존 행 backfill
|
||||
// (Unmatched/category 자체는 건드리지 않음)
|
||||
while (true)
|
||||
{
|
||||
var batch = await _dbContext.PidEquipment
|
||||
.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagClass == null)
|
||||
.Take(batchSize)
|
||||
.ToListAsync();
|
||||
if (!batch.Any()) break;
|
||||
|
||||
foreach (var item in batch)
|
||||
{
|
||||
item.TagClass = ClassifyTagClass(item.TagNo, item.Category, item.ExperionTagId != null);
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
total++;
|
||||
lastId = item.Id;
|
||||
}
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ public class PidEquipment
|
||||
[Column("tag_class")]
|
||||
public string? TagClass { get; set; }
|
||||
|
||||
/// <summary>prefix rule 기반 DCS 함수블록 여부: TRUE=FIC/TIC류 DCS 블록, FALSE=FT/FCV류 현장 계기</summary>
|
||||
[Column("tag_dcs")]
|
||||
public bool TagDcs { get; set; } = false;
|
||||
|
||||
[MaxLength(100)]
|
||||
[Column("role")]
|
||||
public string? Role { get; set; }
|
||||
|
||||
@@ -27,6 +27,10 @@ public class PidPrefixRule
|
||||
[Column("sort_order")]
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>DCS 함수블록 여부: TRUE=FIC/TIC/PIC/FY 등 DCS 내부 블록, FALSE=FT/FCV 등 현장 계기</summary>
|
||||
[Column("tag_dcs")]
|
||||
public bool TagDcs { get; set; } = false;
|
||||
|
||||
[Column("created_at")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ public class ExperionDbContext : DbContext
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
entity.HasIndex(e => e.TagNo);
|
||||
@@ -591,6 +591,44 @@ public class ExperionDbService : IExperionDbService
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE pid_equipment ADD COLUMN IF NOT EXISTS tag_class TEXT;");
|
||||
|
||||
// ── tag_dcs 마이그레이션 (DCS 함수블록 vs 현장 계기 구별) ─────────────────
|
||||
// Step 1: pid_prefix_rules 컬럼 추가
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
ALTER TABLE pid_prefix_rules
|
||||
ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
""");
|
||||
|
||||
// Step 2: DCS prefix 마킹 (기본형 — compound형은 Step 4 StartsWith로 커버)
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
UPDATE pid_prefix_rules
|
||||
SET tag_dcs = TRUE
|
||||
WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV');
|
||||
""");
|
||||
|
||||
// Step 3+4: pid_equipment 컬럼 추가 + backfill (최초 1회만 실행)
|
||||
// ADD COLUMN IF NOT EXISTS는 멱등하지만 뒤따르는 UPDATE backfill이
|
||||
// 매번 재실행되면 수동 변경(FIT-9128 tag_dcs=false)을 덮어쓰는 문제가 있음.
|
||||
// → PL/pgSQL 블록으로 컬럼 존재 여부를 검사하여 최초 1회만 실행
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='pid_equipment' AND column_name='tag_dcs'
|
||||
) THEN
|
||||
ALTER TABLE pid_equipment
|
||||
ADD COLUMN tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE pid_equipment pe
|
||||
SET tag_dcs = TRUE
|
||||
FROM pid_prefix_rules pr
|
||||
WHERE pe.instrument_type LIKE (pr.prefix || '%')
|
||||
AND pr.tag_dcs = TRUE;
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// pid_equipment 좌표/파일명 컬럼
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
DO $$
|
||||
|
||||
@@ -950,29 +950,56 @@ public class OllamaController : ControllerBase
|
||||
if (choice.TryGetProperty("message", out var stopMsgEl) && stopMsgEl.TryGetProperty("content", out var stopCntEl))
|
||||
stopContent = stopCntEl.GetString() ?? "";
|
||||
|
||||
// 모델 무관: content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞에 thinking 토큰, 설명 등이 붙어도 동작)
|
||||
var jsonCandidate = ExtractFirstJsonObject(stopContent);
|
||||
|
||||
if (!string.IsNullOrEmpty(jsonCandidate))
|
||||
// 배열 포맷 우선 시도: [{tool, params}, ...] — 모델이 여러 도구를 배열로 출력할 때
|
||||
var jsonArrayCandidate = ExtractFirstJsonArray(stopContent);
|
||||
var jsonObjectCandidates = new List<string>();
|
||||
if (!string.IsNullOrEmpty(jsonArrayCandidate))
|
||||
{
|
||||
bool toolExecuted = false;
|
||||
try
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(jsonCandidate);
|
||||
if (jsonDoc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
using var arrDoc = JsonDocument.Parse(jsonArrayCandidate);
|
||||
if (arrDoc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
foreach (var elem in arrDoc.RootElement.EnumerateArray())
|
||||
if (elem.ValueKind == JsonValueKind.Object)
|
||||
jsonObjectCandidates.Add(elem.GetRawText());
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
// 배열이 없거나 파싱 실패 → 단일 객체 추출
|
||||
if (jsonObjectCandidates.Count == 0)
|
||||
{
|
||||
var single = ExtractFirstJsonObject(stopContent);
|
||||
if (!string.IsNullOrEmpty(single)) jsonObjectCandidates.Add(single);
|
||||
}
|
||||
|
||||
if (jsonObjectCandidates.Count > 0)
|
||||
{
|
||||
bool toolExecuted = false;
|
||||
var toolResults = new List<string>();
|
||||
var toolNames = new List<string>();
|
||||
|
||||
foreach (var jsonCandidate in jsonObjectCandidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(jsonCandidate);
|
||||
if (jsonDoc.RootElement.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
var propNames = jsonDoc.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet();
|
||||
string? detectedTool = null;
|
||||
var args = new Dictionary<string, object>();
|
||||
|
||||
// 포맷 1: {"tool"|"tool_name": "toolName", "parameters"|"arguments": {...}}
|
||||
if ((propNames.Contains("tool") || propNames.Contains("tool_name")) &&
|
||||
(propNames.Contains("parameters") || propNames.Contains("arguments")))
|
||||
// 포맷 1: {"tool"|"tool_name": "...", "parameters"|"arguments"|"params"|"args": {...}}
|
||||
if (propNames.Contains("tool") || propNames.Contains("tool_name"))
|
||||
{
|
||||
var toolKey = propNames.Contains("tool") ? "tool" : "tool_name";
|
||||
detectedTool = jsonDoc.RootElement.GetProperty(toolKey).GetString();
|
||||
var paramsKey = propNames.Contains("parameters") ? "parameters" : "arguments";
|
||||
if (jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object)
|
||||
// params 키 우선순위: parameters > arguments > params > args
|
||||
var paramsKey = propNames.Contains("parameters") ? "parameters"
|
||||
: propNames.Contains("arguments") ? "arguments"
|
||||
: propNames.Contains("params") ? "params"
|
||||
: propNames.Contains("args") ? "args" : null;
|
||||
if (paramsKey != null && jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in paramsEl.EnumerateObject())
|
||||
{
|
||||
@@ -983,7 +1010,7 @@ public class OllamaController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
// 포맷 2: {"function": "toolName", "args": {...}} 또는 {"name": "toolName", "input": {...}}
|
||||
// 포맷 2: {"function": "...", "args"|"input": {...}} 또는 {"name": "...", "input"|"args": {...}}
|
||||
else if ((propNames.Contains("function") || propNames.Contains("name")) &&
|
||||
(propNames.Contains("args") || propNames.Contains("input")))
|
||||
{
|
||||
@@ -1031,10 +1058,9 @@ public class OllamaController : ControllerBase
|
||||
detectedTool, args, HttpContext.RequestAborted,
|
||||
onTimeoutExtended: info => EmitToolExtending(pseudoId, info.ToolName, info.SoftTimeoutSec, info.ExtendedTimeoutSec));
|
||||
await EmitToolResult(pseudoId, detectedTool, ok: true, payload: toolResult);
|
||||
messages.Add(new { role = "assistant", content = stopContent });
|
||||
messages.Add(new { role = "user", content = $"[{detectedTool} 실행 결과]\n{toolResult}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." });
|
||||
toolResults.Add($"[{detectedTool} 결과]\n{toolResult}");
|
||||
toolNames.Add(detectedTool);
|
||||
toolExecuted = true;
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1043,12 +1069,22 @@ public class OllamaController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패: {Candidate}", jsonCandidate);
|
||||
}
|
||||
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested) return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
if (toolExecuted)
|
||||
{
|
||||
_logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패, Text로 fallback: {Candidate}", jsonCandidate);
|
||||
// 모든 도구 결과를 합쳐서 한 번에 LLM에 전달
|
||||
var combinedResults = string.Join("\n\n", toolResults);
|
||||
messages.Add(new { role = "assistant", content = stopContent });
|
||||
messages.Add(new { role = "user", content = $"{combinedResults}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." });
|
||||
continue;
|
||||
}
|
||||
if (toolExecuted) continue;
|
||||
}
|
||||
|
||||
// 첫 번째 비스트리밍 응답의 content를 직접 전달 (두 번째 LLM 호출 없이)
|
||||
@@ -1147,6 +1183,18 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
var start = content.IndexOf('{');
|
||||
if (start < 0) return "";
|
||||
return ExtractBalanced(content, start, '{', '}');
|
||||
}
|
||||
|
||||
private static string ExtractFirstJsonArray(string content)
|
||||
{
|
||||
var start = content.IndexOf('[');
|
||||
if (start < 0) return "";
|
||||
return ExtractBalanced(content, start, '[', ']');
|
||||
}
|
||||
|
||||
private static string ExtractBalanced(string content, int start, char open, char close)
|
||||
{
|
||||
var depth = 0;
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
@@ -1158,8 +1206,8 @@ public class OllamaController : ControllerBase
|
||||
if (c == '"') { inString = !inString; continue; }
|
||||
if (!inString)
|
||||
{
|
||||
if (c == '{') depth++;
|
||||
else if (c == '}') { depth--; if (depth == 0) return content.Substring(start, i - start + 1); }
|
||||
if (c == open) depth++;
|
||||
else if (c == close) { depth--; if (depth == 0) return content.Substring(start, i - start + 1); }
|
||||
}
|
||||
}
|
||||
return "";
|
||||
|
||||
@@ -107,9 +107,9 @@ public class PidController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("equipment")]
|
||||
public async Task<IActionResult> GetEquipment(string? tagNo = null, int page = 1, int pageSize = 50)
|
||||
public async Task<IActionResult> GetEquipment(string? tagNo = null, string? category = null, int page = 1, int pageSize = 50, string? sortBy = null, bool sortDesc = false)
|
||||
{
|
||||
var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, page, pageSize);
|
||||
var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, category, page, pageSize, sortBy, sortDesc);
|
||||
var itemList = items.ToList();
|
||||
|
||||
var equipmentDtos = itemList.Select(e => new
|
||||
@@ -127,6 +127,8 @@ public class PidController : ControllerBase
|
||||
experionTagId = e.ExperionTagId,
|
||||
experionTagName = e.ExperionTag?.TagName,
|
||||
category = e.Category,
|
||||
tagClass = e.TagClass,
|
||||
tagDcs = e.TagDcs,
|
||||
role = e.Role,
|
||||
fromTag = e.FromTag,
|
||||
toTag = e.ToTag,
|
||||
@@ -144,7 +146,42 @@ public class PidController : ControllerBase
|
||||
items = equipmentDtos
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{id}/auto-map")]
|
||||
public async Task<IActionResult> AutoMap(long id)
|
||||
{
|
||||
var (found, tagName) = await _pidExtractor.AutoMapTagAsync(id);
|
||||
if (!found) return NotFound(new { error = "매칭되는 Experion 태그가 없습니다." });
|
||||
return Ok(new { tagName });
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateEquipment(long id, [FromBody] UpdateEquipmentRequest request)
|
||||
{
|
||||
var ok = await _pidExtractor.UpdateEquipmentAsync(id, request);
|
||||
if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateEquipment([FromBody] CreateEquipmentRequest request)
|
||||
{
|
||||
var e = await _pidExtractor.CreateEquipmentAsync(request);
|
||||
return CreatedAtAction(nameof(CreateEquipment), new { id = e.Id }, new
|
||||
{
|
||||
id = e.Id,
|
||||
tagName = e.TagNo,
|
||||
equipmentName = e.EquipmentName,
|
||||
instrumentType = e.InstrumentType,
|
||||
category = e.Category,
|
||||
tagDcs = e.TagDcs,
|
||||
tagClass = e.TagClass,
|
||||
role = e.Role,
|
||||
fromTag = e.FromTag,
|
||||
toTag = e.ToTag
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("statistics")]
|
||||
public async Task<IActionResult> GetStatistics()
|
||||
{
|
||||
@@ -274,6 +311,7 @@ public class PidController : ControllerBase
|
||||
id = r.Id,
|
||||
prefix = r.Prefix,
|
||||
category = r.Category,
|
||||
tagDcs = r.TagDcs,
|
||||
description = r.Description,
|
||||
sortOrder = r.SortOrder,
|
||||
createdAt = r.CreatedAt
|
||||
|
||||
@@ -20,6 +20,8 @@ var mvcBuilder = builder.Services.AddControllers()
|
||||
{
|
||||
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
|
||||
// Deserialize 시 camelCase 키를 C# PascalCase 속성에 매핑 (프론트엔드 호환)
|
||||
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
});
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
@@ -156,7 +158,7 @@ builder.Services.AddHttpClient("Ollama", c =>
|
||||
// ── vLLM HttpClient (OpenAI-compatible) ──────────────────────────────────────
|
||||
builder.Services.AddHttpClient("Vllm", c =>
|
||||
{
|
||||
c.BaseAddress = new Uri("http://localhost:8001");
|
||||
c.BaseAddress = new Uri("http://localhost:8000");
|
||||
c.Timeout = TimeSpan.FromSeconds(1800);
|
||||
}).SetHandlerLifetime(Timeout.InfiniteTimeSpan);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||
"ExperionCrawler.Infrastructure.OpcUa.ExperionHistoryService": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
|
||||
@@ -88,10 +88,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#pane-pid .pid-cat-count {
|
||||
font-size: 11px;
|
||||
color: var(--t2);
|
||||
}
|
||||
|
||||
#pane-pid .pid-cat-add {
|
||||
display: flex;
|
||||
|
||||
@@ -7,7 +7,11 @@ let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]');
|
||||
let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || '';
|
||||
let llmAbortController = null;
|
||||
let llmIsStreaming = false;
|
||||
let llmType = localStorage.getItem('llmType') || 'ollama';
|
||||
let llmType = localStorage.getItem('llmType') || 'vllm';
|
||||
if (llmType === 'ollama') {
|
||||
llmType = 'vllm';
|
||||
localStorage.setItem('llmType', 'vllm');
|
||||
}
|
||||
let llmUseTools = localStorage.getItem('llmUseTools') === 'true';
|
||||
let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true';
|
||||
let llmMcpTools = [];
|
||||
@@ -33,11 +37,15 @@ function llmUseChip(btn) {
|
||||
}
|
||||
|
||||
// ── 초기화 (탭 진입 시) ──────────────────────────────
|
||||
paneInit.llmchat = function() {
|
||||
paneInit.llmchat = async function() {
|
||||
// llm-type-select 를 JS 변수(llmType)와 동기화 (HTML 기본값 ollama 보정)
|
||||
const typeSel = document.getElementById('llm-type-select');
|
||||
if (typeSel) typeSel.value = llmType;
|
||||
|
||||
llmRenderSessionList();
|
||||
llmLoadActiveSession();
|
||||
llmLoadModels();
|
||||
llmLoadConfigToUI();
|
||||
await llmLoadModels(); // 모델 목록 먼저 채운 뒤
|
||||
llmLoadConfigToUI(); // llm-model.json 값 반영
|
||||
llmLoadMcpTools();
|
||||
};
|
||||
|
||||
@@ -494,8 +502,6 @@ async function llmLoadModels() {
|
||||
const sel = document.getElementById('llm-model-select');
|
||||
if (!sel) return;
|
||||
|
||||
const currentVal = sel.value;
|
||||
|
||||
try {
|
||||
const d = await api('GET', `${prefix}/models`);
|
||||
sel.innerHTML = '<option value="">-- 모델을 선택하세요 --</option>';
|
||||
@@ -507,9 +513,22 @@ async function llmLoadModels() {
|
||||
opt.textContent = m;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (currentVal && [...sel.options].some(o => o.value === currentVal)) {
|
||||
sel.value = currentVal;
|
||||
}
|
||||
}
|
||||
|
||||
// vLLM: llm-model.json 을 항상 단일 선택 기준으로 반영
|
||||
if (llmType === 'vllm') {
|
||||
try {
|
||||
const cfg = await api('GET', '/api/llm/config');
|
||||
if (cfg.success && cfg.vllmModel) {
|
||||
if (![...sel.options].some(o => o.value === cfg.vllmModel)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cfg.vllmModel;
|
||||
opt.textContent = cfg.vllmModel;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = cfg.vllmModel;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const dot = document.getElementById('llm-conn-status');
|
||||
@@ -518,7 +537,6 @@ async function llmLoadModels() {
|
||||
dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (currentVal) sel.value = currentVal;
|
||||
const dot = document.getElementById('llm-conn-status');
|
||||
if (dot) {
|
||||
dot.className = 'llm-conn-dot error';
|
||||
@@ -527,10 +545,11 @@ async function llmLoadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
function llmOnTypeChange() {
|
||||
async function llmOnTypeChange() {
|
||||
llmType = document.getElementById('llm-type-select').value;
|
||||
localStorage.setItem('llmType', llmType);
|
||||
llmLoadModels();
|
||||
await llmLoadModels(); // 목록 채운 뒤
|
||||
llmLoadConfigToUI(); // llm-model.json 값 반영 (vLLM 전환 시도 포함)
|
||||
llmLoadMcpTools();
|
||||
}
|
||||
|
||||
@@ -967,7 +986,15 @@ function llmLoadConfigToUI() {
|
||||
api('GET', '/api/llm/config').then(d => {
|
||||
if (d.success && d.vllmModel) {
|
||||
const sel = document.getElementById('llm-model-select');
|
||||
if (sel && !sel.value) sel.value = d.vllmModel;
|
||||
if (!sel) return;
|
||||
// llm-model.json 값이 드롭다운 옵션에 없으면 직접 추가
|
||||
if (![...sel.options].some(o => o.value === d.vllmModel)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.vllmModel;
|
||||
opt.textContent = d.vllmModel;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = d.vllmModel;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
let pidCurrentPage = 1;
|
||||
let pidPageSize = 20;
|
||||
let pidLastResult = null; // Excel export용
|
||||
let pidCatFilter = ''; // 카테고리 필터
|
||||
let pidSortBy = ''; // 정렬 필드
|
||||
let pidSortDesc = false; // 내림차순
|
||||
|
||||
async function pidUpload() {
|
||||
const fileInput = document.getElementById('pid-file-input');
|
||||
@@ -149,6 +152,23 @@ async function pidExtract() {
|
||||
}
|
||||
}
|
||||
|
||||
function pidOnCatFilter() {
|
||||
pidCatFilter = document.getElementById('pid-cat-filter').value;
|
||||
pidCurrentPage = 1;
|
||||
pidLoadTable(1);
|
||||
}
|
||||
|
||||
function pidToggleSort(field) {
|
||||
if (pidSortBy === field) {
|
||||
pidSortDesc = !pidSortDesc;
|
||||
} else {
|
||||
pidSortBy = field;
|
||||
pidSortDesc = false;
|
||||
}
|
||||
pidCurrentPage = 1;
|
||||
pidLoadTable(1);
|
||||
}
|
||||
|
||||
async function pidLoadTable(page = 1) {
|
||||
pidCurrentPage = page;
|
||||
const container = document.getElementById('pid-table-container');
|
||||
@@ -156,54 +176,71 @@ async function pidLoadTable(page = 1) {
|
||||
|
||||
if (!container || !tbody) return;
|
||||
|
||||
// tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가
|
||||
// 이후 tbody.innerHTML 할당이 화면에 반영되지 않음.
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">로딩 중...</td></tr>';
|
||||
const catParam = pidCatFilter ? `&category=${encodeURIComponent(pidCatFilter)}` : '';
|
||||
const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : '';
|
||||
const si = document.getElementById('pid-sort-indicator');
|
||||
if (si) si.textContent = pidSortBy === 'tagName' ? (pidSortDesc ? '▼' : '▲') : '';
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:20px">로딩 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`);
|
||||
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}${catParam}${sortParam}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
pidLastResult = data;
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
|
||||
document.getElementById('pid-pagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.items.map(item => `
|
||||
<tr>
|
||||
const catOpts = ['','instrument','power_equipment','storage_equipment','process_equipment','utility_equipment','pipings']
|
||||
.map(v => `<option value="${v}">${v ? v : '-'}</option>`).join('');
|
||||
|
||||
// 현재 카테고리 → 가상 키 변환 (instrument + tagDcs 조합)
|
||||
function pidVcat(item) {
|
||||
if (item.category === 'instrument') return item.tagDcs ? 'instrument_dcs' : 'instrument_field';
|
||||
return item.category || '';
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.items.map(item => {
|
||||
const vcat = pidVcat(item);
|
||||
const catOpts = CATEGORY_ORDER.map(k =>
|
||||
`<option value="${k}" ${vcat === k ? 'selected' : ''}>${CATEGORY_META[k]?.label || k}</option>`
|
||||
).join('');
|
||||
return `<tr>
|
||||
<td>${item.id}</td>
|
||||
<td><strong>${esc(item.tagName)}</strong></td>
|
||||
<td>${esc(item.equipmentName) || '-'}</td>
|
||||
<td>${esc(item.instrumentType) || '-'}</td>
|
||||
<td>${esc(item.lineNumber) || '-'}</td>
|
||||
<td>${esc(item.pidDrawingNo) || '-'}</td>
|
||||
<td style="text-align:center">${(item.confidence * 100).toFixed(1)}%</td>
|
||||
<td style="text-align:center">
|
||||
<span class="badge ${item.isActive ? 'ok' : 'warn'}">${item.isActive ? '활성' : '비활성'}</span>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="tagNo" value="${esc((item.tagName||'').toUpperCase())}" style="width:100%;font-family:var(--mono)"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="equipmentName" value="${esc(item.equipmentName) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="instrumentType" value="${esc((item.instrumentType||'').toUpperCase())}" style="width:100%"/></td>
|
||||
<td>
|
||||
<select class="inp pid-edit-vcat" data-id="${item.id}" style="width:100%">
|
||||
<option value="">-</option>
|
||||
${catOpts}
|
||||
</select>
|
||||
</td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="role" value="${esc(item.role) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromTag" value="${esc(item.fromTag) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toTag" value="${esc(item.toTag) || ''}" style="width:100%"/></td>
|
||||
<td>
|
||||
${item.experionTagId
|
||||
? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
|
||||
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
|
||||
? `<span class="badge ok">✅ ${esc((item.experionTagName || '').toUpperCase())}</span>`
|
||||
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
|
||||
}
|
||||
</td>
|
||||
<td>${item.category
|
||||
? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
|
||||
: '-'}
|
||||
<td style="text-align:center">
|
||||
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.id})">💾</button>
|
||||
</td>
|
||||
<td style="text-align:center">
|
||||
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">삭제</button>
|
||||
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
pidRenderPagination(data.total, page);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="11" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +255,8 @@ function pidRenderPagination(total, currentPage) {
|
||||
}
|
||||
|
||||
let html = '';
|
||||
const start = Math.max(1, currentPage - 3);
|
||||
const end = Math.min(totalPages, currentPage + 3);
|
||||
const start = Math.max(1, currentPage - 10);
|
||||
const end = Math.min(totalPages, currentPage + 10);
|
||||
|
||||
if (currentPage > 1) {
|
||||
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage - 1})">‹</button>`;
|
||||
@@ -237,6 +274,89 @@ function pidRenderPagination(total, currentPage) {
|
||||
pagination.innerHTML = html;
|
||||
}
|
||||
|
||||
function pidShowAddRow() {
|
||||
const tbody = document.getElementById('pid-table-body');
|
||||
if (!tbody) return;
|
||||
// 이미 추가 행이 있으면 제거
|
||||
const existing = tbody.querySelector('tr.pid-add-row');
|
||||
if (existing) { existing.remove(); return; }
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'pid-add-row';
|
||||
row.innerHTML = `
|
||||
<td style="color:var(--t2);font-size:11px">NEW</td>
|
||||
<td><input class="inp pid-add-input" data-field="tagNo" placeholder="예: FT-9999" style="width:100%;font-family:var(--mono)"/></td>
|
||||
<td><input class="inp pid-add-input" data-field="equipmentName" placeholder="장비명" style="width:100%"/></td>
|
||||
<td><input class="inp pid-add-input" data-field="instrumentType" placeholder="유형" style="width:100%"/></td>
|
||||
<td>
|
||||
<select class="inp pid-add-vcat" style="width:100%">
|
||||
<option value="">-</option>
|
||||
${CATEGORY_ORDER.map(k =>
|
||||
`<option value="${k}">${CATEGORY_META[k]?.label || k}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input class="inp pid-add-input" data-field="role" placeholder="Role" style="width:100%"/></td>
|
||||
<td><input class="inp pid-add-input" data-field="fromTag" placeholder="From" style="width:100%"/></td>
|
||||
<td><input class="inp pid-add-input" data-field="toTag" placeholder="To" style="width:100%"/></td>
|
||||
<td></td>
|
||||
<td style="text-align:center"><button class="btn-sm btn-a" onclick="pidCreateRow(this)">💾</button></td>
|
||||
<td style="text-align:center"><button class="btn-sm btn-b" onclick="this.closest('tr').remove()">✕</button></td>
|
||||
`;
|
||||
tbody.insertBefore(row, tbody.firstChild);
|
||||
row.querySelector('.pid-add-input[data-field="tagNo"]').focus();
|
||||
}
|
||||
|
||||
async function pidCreateRow(btn) {
|
||||
const row = btn.closest('tr');
|
||||
const inputs = row.querySelectorAll('.pid-add-input');
|
||||
const vcatEl = row.querySelector('.pid-add-vcat');
|
||||
const vcat = vcatEl ? vcatEl.value : '';
|
||||
const { category, tagDcs } = pidResolveCat(vcat);
|
||||
const body = { tagNo: '', category: category || null, tagDcs };
|
||||
for (const inp of inputs) {
|
||||
body[inp.dataset.field] = inp.value || null;
|
||||
}
|
||||
if (!body.tagNo) { alert('태그번호를 입력하세요.'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/pid', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
row.remove();
|
||||
await pidLoadTable(pidCurrentPage);
|
||||
pidUpdateStats();
|
||||
} catch (e) {
|
||||
alert(`추가 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pidSaveRow(id) {
|
||||
const inputs = document.querySelectorAll(`.pid-edit[data-id="${id}"]`);
|
||||
const vcatEl = document.querySelector(`.pid-edit-vcat[data-id="${id}"]`);
|
||||
const vcat = vcatEl ? vcatEl.value : '';
|
||||
const { category, tagDcs } = pidResolveCat(vcat);
|
||||
const body = { category: category || null, tagDcs };
|
||||
for (const inp of inputs) {
|
||||
body[inp.dataset.field] = inp.value || null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/pid/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
alert(`저장 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pidDeleteRow(id) {
|
||||
if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return;
|
||||
|
||||
@@ -354,14 +474,22 @@ function pidTogglePrefixPanel() {
|
||||
}
|
||||
|
||||
const CATEGORY_META = {
|
||||
instrument: { label: 'Instrument', badge: 'ok' },
|
||||
power_equipment: { label: 'Power Equipment', badge: 'warn' },
|
||||
storage_equipment: { label: 'Storage Equipment', badge: 'inf' },
|
||||
process_equipment: { label: 'Process Equipment', badge: '' },
|
||||
utility_equipment: { label: 'Utility Equipment', badge: 'warn' },
|
||||
pipings: { label: 'Pipings', badge: 'ok' }
|
||||
// ── instrument 가상 분할 (DB category='instrument', tag_dcs로 구분) ──
|
||||
instrument_dcs: { label: 'DCS 태그', badge: 'warn', dbCat: 'instrument', tagDcs: true },
|
||||
instrument_field: { label: '현장 계기', badge: 'ok', dbCat: 'instrument', tagDcs: false },
|
||||
// ── 그 외 equipment / pipings ──
|
||||
power_equipment: { label: 'Power Equipment', badge: 'warn', dbCat: 'power_equipment', tagDcs: false },
|
||||
storage_equipment:{ label: 'Storage Equipment', badge: 'inf', dbCat: 'storage_equipment', tagDcs: false },
|
||||
process_equipment:{ label: 'Process Equipment', badge: '', dbCat: 'process_equipment', tagDcs: false },
|
||||
utility_equipment:{ label: 'Utility Equipment', badge: 'warn', dbCat: 'utility_equipment', tagDcs: false },
|
||||
pipings: { label: 'Pipings', badge: 'ok', dbCat: 'pipings', tagDcs: false },
|
||||
// ── 장비 테이블 배지용 (equipment 목록의 r.category 값과 매핑) ──
|
||||
instrument: { label: 'Instrument', badge: 'ok' },
|
||||
};
|
||||
const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment', 'pipings'];
|
||||
const CATEGORY_ORDER = [
|
||||
'instrument_dcs', 'instrument_field',
|
||||
'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment', 'pipings'
|
||||
];
|
||||
|
||||
function pidCategoryBadge(cat) {
|
||||
return CATEGORY_META[cat] ? CATEGORY_META[cat].badge : '';
|
||||
@@ -383,42 +511,46 @@ async function pidRefreshPrefixRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
// instrument → tag_dcs 기준으로 가상 분할
|
||||
const grouped = {};
|
||||
for (const r of items) {
|
||||
if (!grouped[r.category]) grouped[r.category] = [];
|
||||
grouped[r.category].push(r);
|
||||
let key = r.category;
|
||||
if (r.category === 'instrument') {
|
||||
key = r.tagDcs ? 'instrument_dcs' : 'instrument_field';
|
||||
}
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(r);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
const rules = grouped[cat];
|
||||
if (!rules) continue;
|
||||
const meta = CATEGORY_META[cat] || { label: cat, badge: '' };
|
||||
rules.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
for (const vcat of CATEGORY_ORDER) {
|
||||
const rules = grouped[vcat];
|
||||
const meta = CATEGORY_META[vcat] || { label: vcat, badge: '', dbCat: vcat, tagDcs: false };
|
||||
rules?.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
html += `<div class="pid-cat-group">
|
||||
html += `<div class="pid-cat-group" data-vcat="${vcat}">
|
||||
<div class="pid-cat-header">
|
||||
<span class="badge ${meta.badge}">${meta.label}</span>
|
||||
<span class="pid-cat-count">${rules.length}</span>
|
||||
<span class="pid-cat-add" style="margin-left:auto">
|
||||
<input class="inp pid-cat-prefix-input" placeholder="예: FT" style="width:90px;font-family:var(--mono)" />
|
||||
<input class="inp pid-cat-prefix-input" placeholder="예: ${vcat === 'instrument_dcs' ? 'FIC' : vcat === 'instrument_field' ? 'FT' : 'P-'}" style="width:90px;font-family:var(--mono)" />
|
||||
<input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
|
||||
<input class="inp pid-cat-order-input" type="number" value="10" style="width:50px" />
|
||||
<button class="btn-a btn-sm" onclick="pidAddPrefixRule('${cat}')">추가</button>
|
||||
<button class="btn-a btn-sm" onclick="pidAddPrefixRule('${vcat}')">추가</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pid-cat-body">`;
|
||||
|
||||
for (const r of rules) {
|
||||
html += `<div class="pid-cat-row">
|
||||
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
|
||||
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
|
||||
<input class="inp pid-cat-order-input" type="number" value="${r.sortOrder}" style="width:44px" />
|
||||
<span class="pid-cat-actions">
|
||||
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
|
||||
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
|
||||
</span>
|
||||
</div>`;
|
||||
if (rules) {
|
||||
for (const r of rules) {
|
||||
html += `<div class="pid-cat-row" data-sort-order="${r.sortOrder}">
|
||||
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
|
||||
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
|
||||
<span class="pid-cat-actions">
|
||||
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
|
||||
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
@@ -430,11 +562,21 @@ async function pidRefreshPrefixRules() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pidAddPrefixRule(category) {
|
||||
// 가상 카테고리 키 → { DB category, tagDcs } 변환
|
||||
function pidResolveCat(vcat) {
|
||||
const meta = CATEGORY_META[vcat];
|
||||
return {
|
||||
category: meta?.dbCat ?? vcat,
|
||||
tagDcs: meta?.tagDcs ?? false
|
||||
};
|
||||
}
|
||||
|
||||
async function pidAddPrefixRule(vcat) {
|
||||
const row = event.target.closest('.pid-cat-add');
|
||||
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
|
||||
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
|
||||
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
|
||||
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
|
||||
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
|
||||
const { category, tagDcs } = pidResolveCat(vcat); // 그룹이 tagDcs 결정
|
||||
|
||||
if (!prefix) { alert('Prefix를 입력하세요.'); return; }
|
||||
|
||||
@@ -442,7 +584,7 @@ async function pidAddPrefixRule(category) {
|
||||
const res = await fetch('/api/pid/prefix-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prefix, category, description: desc, sortOrder: order })
|
||||
body: JSON.stringify({ prefix, category, tagDcs, description: desc, sortOrder: order })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
@@ -457,13 +599,16 @@ async function pidAddPrefixRule(category) {
|
||||
}
|
||||
|
||||
async function pidUpdatePrefixRule(id, btn) {
|
||||
const row = btn.closest('.pid-cat-row');
|
||||
const row = btn.closest('.pid-cat-row');
|
||||
const group = btn.closest('.pid-cat-group');
|
||||
const catIdx = [...group.parentElement.children].indexOf(group);
|
||||
const cat = CATEGORY_ORDER[catIdx];
|
||||
const vcat = group.dataset.vcat; // data-vcat 속성으로 가상 키 읽기
|
||||
const { category } = pidResolveCat(vcat);
|
||||
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
|
||||
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
|
||||
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
|
||||
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
|
||||
const order = parseInt(row.dataset.sortOrder) || 10; // data-sort-order 속성에서 읽기
|
||||
// tagDcs는 그룹(vcat)이 결정 — 행에 별도 체크박스 없음
|
||||
// 그룹 이동이 필요하면 삭제 후 반대 그룹에서 추가
|
||||
const tagDcs = CATEGORY_META[vcat]?.tagDcs ?? false;
|
||||
|
||||
if (!prefix) { alert('Prefix를 입력하세요.'); return; }
|
||||
|
||||
@@ -471,7 +616,7 @@ async function pidUpdatePrefixRule(id, btn) {
|
||||
const res = await fetch(`/api/pid/prefix-rules/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prefix, category: cat, description: desc, sortOrder: order })
|
||||
body: JSON.stringify({ prefix, category, tagDcs, description: desc, sortOrder: order })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
@@ -495,7 +640,7 @@ async function pidDeletePrefixRule(id, prefix) {
|
||||
}
|
||||
|
||||
async function pidApplyCategories() {
|
||||
if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
|
||||
if (!confirm('전체 레코드에 현재 PREFIX 분류 정의를 재적용하시겠습니까?\n수동으로 변경한 카테고리도 덮어쓰여집니다.')) return;
|
||||
try {
|
||||
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -508,9 +653,22 @@ async function pidApplyCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
function pidOpenMapping(id) {
|
||||
// 매핑 모달 열기 (추후 구현)
|
||||
console.log('pidOpenMapping:', id);
|
||||
async function pidOpenMapping(id, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳';
|
||||
try {
|
||||
const res = await fetch(`/api/pid/${id}/auto-map`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
btn.textContent = '❌';
|
||||
btn.title = '매칭 실패';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
btn.outerHTML = `<span class="badge ok">✅ ${esc((data.tagName||'').toUpperCase())}</span>`;
|
||||
} catch {
|
||||
btn.textContent = '❌';
|
||||
btn.title = '오류';
|
||||
}
|
||||
}
|
||||
|
||||
// 탭 진입 시 초기화
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:4px;flex-wrap:wrap;align-items:center">
|
||||
<button class="btn-sm btn-b" onclick="pidRefreshPrefixRules()">새로고침</button>
|
||||
<button class="btn-sm btn-b" onclick="pidApplyCategories()" title="기존 미분류 항목에 category 재적용">재적용</button>
|
||||
<button class="btn-sm btn-b" onclick="pidApplyCategories()" title="전체 레코드에 현재 PREFIX 분류 정의 재적용 (수동 변경도 덮어쓰기)">재적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,25 +64,42 @@
|
||||
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>추출 결과</span>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<button class="btn-b btn-sm" onclick="pidShowAddRow()">+ 추가</button>
|
||||
<button id="btn-pid-export-csv" class="btn-b btn-sm">CSV</button>
|
||||
<button id="btn-pid-export-excel" class="btn-a btn-sm">Excel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;padding:6px 0;flex-wrap:wrap;border-bottom:1px solid var(--b1)">
|
||||
<label style="font-size:12px;color:var(--t2)">카테고리:</label>
|
||||
<select id="pid-cat-filter" class="inp" style="width:auto;min-width:140px" onchange="pidOnCatFilter()">
|
||||
<option value="">전체</option>
|
||||
<option value="instrument_dcs">DCS 태그</option>
|
||||
<option value="instrument_field">현장 계기</option>
|
||||
<option value="power_equipment">Power Equipment</option>
|
||||
<option value="storage_equipment">Storage Equipment</option>
|
||||
<option value="process_equipment">Process Equipment</option>
|
||||
<option value="utility_equipment">Utility Equipment</option>
|
||||
<option value="pipings">Pipings</option>
|
||||
<option value="__unmatched__">미분류</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="pid-table-container" style="overflow-x:auto">
|
||||
<table class="table" id="pid-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th>태그번호</th>
|
||||
<th style="cursor:pointer;user-select:none" onclick="pidToggleSort('tagName')">
|
||||
태그번호 <span id="pid-sort-indicator" style="font-size:11px;color:var(--t2)"></span>
|
||||
</th>
|
||||
<th>장비명</th>
|
||||
<th>유형</th>
|
||||
<th>라인번호</th>
|
||||
<th>도면번호</th>
|
||||
<th style="width:80px">신뢰도</th>
|
||||
<th style="width:80px">상태</th>
|
||||
<th style="width:120px">매핑</th>
|
||||
<th style="width:120px">카테고리</th>
|
||||
<th style="width:60px">삭제</th>
|
||||
<th>카테고리</th>
|
||||
<th>Role</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th style="width:80px">매핑</th>
|
||||
<th style="width:60px">저장</th>
|
||||
<th style="width:40px">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pid-table-body"></tbody>
|
||||
|
||||
Reference in New Issue
Block a user