Initial commit: HC900 Crawler

Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL.
기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체.

- industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버)
- src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer)
- mcp-server: Python FastMCP (RAG/NL2SQL/P&ID)
- 다중 컨트롤러(N-Controller) 지원

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-03 20:28:14 +09:00
commit 16fc7a2598
325 changed files with 126583 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
# Verifier — Phase B MVP (R1·R2·R4)
MCP tool 인자 결정적 검증 모듈. LLM이 생성한 잘못된 인자를 reject + hint + 로그.
## 룰 카탈로그
| 룰 | 검증 | 적용 tool | 실패 코드 |
|----|------|-----------|-----------|
| R1 | tag-existence (base_tag ∈ DB) | trace_connections, query_pv_history, upsert_pid_connection, query_events(tag_name) | R1.invalid_tag_format / R1.tag_not_found |
| R2 | area-format (`P\d+(-\d+)?` + valid area) | find_tags, active_alarms, query_events, summarize_events, generate_status_report | R2.invalid_area_format / R2.unknown_area |
| R4 | trace_connections 보강 (direction, max_depth) | trace_connections | R4.invalid_direction / R4.invalid_max_depth / R4.max_depth_out_of_range |
| R3 | 응답 텍스트 후처리 | — | Phase B.2 stub |
| R5 | LLM-judge | — | Phase B.2 stub |
## 로그 포맷 (`logs/YYYY-MM-DD.jsonl`)
```jsonl
{"ts": 1748220000.0, "tool": "trace_connections", "params": {"start_tag": "raw_material_input"}, "verifier_error": {"verifier_error": "R1.tag_not_found", "hint": "태그 'raw_material_input' 는 DB에 존재하지 않습니다...", "suggested": []}}
```
- Phase C(LoRA) 학습 데이터 자동 수집 용도
- 실패 → hint → 올바른 재호출 trip 기록

View File

View File

View File

@@ -0,0 +1,7 @@
{"ts": 1779776275.6252136, "tool": "trace_connections", "params": {"start_tag": "raw_material_input", "direction": "downstream", "max_depth": 20}, "verifier_error": {"verifier_error": "R1.invalid_tag_format", "hint": "태그 형식 비정상: 'raw_material_input'. 예시: ficq-6113.pv, p-6102"}}
{"ts": 1779776279.6914687, "tool": "trace_connections", "params": {"start_tag": "RM-6101", "direction": "downstream", "max_depth": 20}, "verifier_error": {"verifier_error": "R1.tag_not_found", "hint": "태그 'RM-6101' 는 DB에 존재하지 않습니다. find_tags(query=..., sub_area=...) 로 먼저 검색하세요.", "suggested": ["f-6101a", "f-6101b", "fcv-6101"]}}
{"ts": 1779776280.7989774, "tool": "generate_status_report", "params": {"area": "6-1", "hours": 24}, "verifier_error": {"verifier_error": "R2.invalid_area_format", "hint": "area='6-1' 형식 오류. 'P6' 또는 'P6-1' 형식 사용.", "valid_areas": ["P1", "P10", "P2", "P3", "P4", "P5", "P6", "P8", "P9", "PACKING", "UTIL"]}}
{"ts": 1779790097.6274157, "tool": "trace_connections", "params": {"start_tag": "6-1", "direction": "downstream", "max_depth": 20}, "verifier_error": {"verifier_error": "R1.invalid_tag_format", "hint": "태그 형식 비정상: '6-1'. 예시: ficq-6113.pv, p-6102"}}
{"ts": 1779800867.6059122, "tool": "trace_connections", "params": {"start_tag": "raw_material_input", "direction": "downstream", "max_depth": 20}, "verifier_error": {"verifier_error": "R1.invalid_tag_format", "hint": "태그 형식 비정상: 'raw_material_input'. 예시: ficq-6113.pv, p-6102"}}
{"ts": 1779800868.96126, "tool": "trace_connections", "params": {"start_tag": "RM-6101", "direction": "downstream", "max_depth": 20}, "verifier_error": {"verifier_error": "R1.tag_not_found", "hint": "태그 'RM-6101' 는 DB에 존재하지 않습니다. find_tags(query=..., sub_area=...) 로 먼저 검색하세요.", "suggested": ["f-6101a", "f-6101b", "fcv-6101"]}}
{"ts": 1779800870.227573, "tool": "generate_status_report", "params": {"area": "6-1", "hours": 24}, "verifier_error": {"verifier_error": "R2.invalid_area_format", "hint": "area='6-1' 형식 오류. 'P6' 또는 'P6-1' 형식 사용.", "valid_areas": ["P1", "P10", "P2", "P3", "P4", "P5", "P6", "P8", "P9", "PACKING", "UTIL"]}}

View File

@@ -0,0 +1,3 @@
{"ts": 1779837676.3567111, "tool": "query_events", "params": {"tag_name": "P-6102,FT-6101,FCV-6101,E-6103", "area": "P6-1"}, "verifier_error": {"verifier_error": "R1.invalid_tag_format", "hint": "태그 형식 비정상: 'P-6102,FT-6101,FCV-6101,E-6103'. 예시: ficq-6113.pv, p-6102"}}
{"ts": 1779837823.7985373, "tool": "trace_connections", "params": {"start_tag": "F-101", "direction": "downstream", "max_depth": 10}, "verifier_error": {"verifier_error": "R1.tag_not_found", "hint": "태그 'F-101' 는 DB에 존재하지 않습니다. find_tags(query=..., sub_area=...) 로 먼저 검색하세요.", "suggested": ["10100-esd", "10100-man-esd", "bv-10100"]}}
{"ts": 1779842916.7172987, "tool": "query_events", "params": {"tag_name": "P6-1", "area": "P6-1"}, "verifier_error": {"verifier_error": "R1.tag_not_found", "hint": "태그 'P6-1' 는 DB에 존재하지 않습니다. find_tags(query=..., sub_area=...) 로 먼저 검색하세요.", "suggested": []}}

View File

@@ -0,0 +1 @@
{"ts": 1779965714.0945354, "tool": "find_tags", "params": {"query": null, "area": "P6-1,P6-2", "sub_area": null}, "verifier_error": {"verifier_error": "R2.invalid_area_format", "hint": "area='P6-1,P6-2' 형식 오류. 'P6' 또는 'P6-1' 형식 사용.", "valid_areas": ["P1", "P10", "P2", "P3", "P4", "P5", "P6", "P8", "P9", "PACKING", "UTIL"]}}

View File

@@ -0,0 +1 @@
{"ts": 1780099383.3920546, "tool": "query_events", "params": {"tag_name": "ficq-611%", "area": null}, "verifier_error": {"verifier_error": "R1.invalid_tag_format", "hint": "태그 형식 비정상: 'ficq-611%'. 예시: ficq-6113.pv, p-6102"}}

View File

@@ -0,0 +1,154 @@
"""Phase B Verifier MVP — 단위 테스트 (최소 10케이스)."""
from __future__ import annotations
import json, pytest
from verifier import validators
# ── 헬퍼 ──
def _noop_conn():
return None # _load_tag_set 내부에서 호출되지 않도록 monkeypatch로 대체
# ═══════════════════════════════════════════════════════════════════
# R1 — tag-existence
# ═══════════════════════════════════════════════════════════════════
def test_R1_valid_tag_format_pass(monkeypatch):
"""PASS: ficq-6113.pv → 올바른 형식 + DB 존재."""
monkeypatch.setattr(validators, "_load_tag_set", lambda gc: {"ficq-6113", "p-6102"})
assert validators.validate_tag("ficq-6113.pv", _noop_conn) is None
def test_R1_valid_tag_no_suffix_pass(monkeypatch):
"""PASS: p-6102 → suffix 없어도 OK."""
monkeypatch.setattr(validators, "_load_tag_set", lambda gc: {"ficq-6113", "p-6102"})
assert validators.validate_tag("p-6102", _noop_conn) is None
def test_R1_tags_dot_suffix_pass(monkeypatch):
"""PASS: vp-6117a.pv → 문자 포함 태그 번호."""
monkeypatch.setattr(validators, "_load_tag_set", lambda gc: {"vp-6117a", "vp-6117b"})
assert validators.validate_tag("vp-6117a.pv", _noop_conn) is None
def test_R1_invalid_tag_format_fail():
"""FAIL: raw_material_input → 형식 위반 (대문자 + 밑줄)."""
err = validators.validate_tag("raw_material_input", _noop_conn)
assert err is not None
assert err.code == "invalid_tag_format"
assert "R1" in err.rule
def test_R1_unknown_tag_fail(monkeypatch):
"""FAIL: RM-6101 → 형식은 맞지만 DB에 없음."""
monkeypatch.setattr(validators, "_load_tag_set", lambda gc: {"ficq-6113", "p-6102"})
err = validators.validate_tag("RM-6101", _noop_conn)
assert err is not None
assert err.code == "tag_not_found"
assert "suggested" in err.extra
def test_R1_none_or_empty_pass():
"""PASS: None/빈 문자열 → 검증 스킵."""
assert validators.validate_tag(None, _noop_conn) is None
assert validators.validate_tag("", _noop_conn) is None
# ═══════════════════════════════════════════════════════════════════
# R2 — area-format
# ═══════════════════════════════════════════════════════════════════
def test_R2_valid_area_pass():
"""PASS: P6, P6-1, P10, UTIL."""
for a in ("P6", "P6-1", "P10", "UTIL", "PACKING"):
assert validators.validate_area(a) is None, f"area={a} expected PASS"
def test_R2_invalid_area_format_fail():
"""FAIL: 6-1 → P prefix 없음."""
err = validators.validate_area("6-1")
assert err is not None
assert err.code == "invalid_area_format"
def test_R2_unknown_area_fail():
"""FAIL: P7 → 존재하지 않는 area."""
err = validators.validate_area("P7")
assert err is not None
assert err.code == "unknown_area"
def test_R2_empty_or_none_pass():
"""PASS: None/빈 문자열 → 선택 필터."""
assert validators.validate_area(None) is None
assert validators.validate_area("") is None
def test_R2_sub_area_pass():
"""PASS: P6-2 → 형식+base 모두 유효."""
assert validators.validate_area("P6-2") is None
# ═══════════════════════════════════════════════════════════════════
# R4 — trace_connections 보강
# ═══════════════════════════════════════════════════════════════════
def test_R4_valid_direction_pass():
"""PASS: upstream, downstream."""
assert validators.validate_direction("upstream") is None
assert validators.validate_direction("downstream") is None
assert validators.validate_direction(None) is None
def test_R4_invalid_direction_fail():
"""FAIL: sideways → 허용되지 않은 방향."""
err = validators.validate_direction("sideways")
assert err is not None
assert err.code == "invalid_direction"
def test_R4_valid_max_depth_pass():
"""PASS: 1, 50, 20."""
for n in (1, 50, 20, None):
assert validators.validate_max_depth(n) is None, f"max_depth={n} expected PASS"
def test_R4_invalid_max_depth_out_of_range():
"""FAIL: 0, 51 → 범위 초과."""
err0 = validators.validate_max_depth(0)
assert err0 is not None and err0.code == "max_depth_out_of_range"
err51 = validators.validate_max_depth(51)
assert err51 is not None and err51.code == "max_depth_out_of_range"
def test_R4_invalid_max_depth_not_int():
"""FAIL: 'abc' → 정수 아님."""
err = validators.validate_max_depth("abc")
assert err is not None and err.code == "invalid_max_depth"
# ═══════════════════════════════════════════════════════════════════
# R3 / R5 stub
# ═══════════════════════════════════════════════════════════════════
def test_R3_stub_returns_none():
"""Phase B.2 전까지는 항상 None."""
assert validators.validate_response_text("anything") is None
# ═══════════════════════════════════════════════════════════════════
# VerifierError 직렬화
# ═══════════════════════════════════════════════════════════════════
def test_verifier_error_to_dict():
err = validators.VerifierError("R1", "tag_not_found", "hint msg", suggested=["a", "b"])
d = err.to_dict()
assert d["verifier_error"] == "R1.tag_not_found"
assert d["hint"] == "hint msg"
assert d["suggested"] == ["a", "b"]
# ═══════════════════════════════════════════════════════════════════
# log_rejection — 파일 적재
# ═══════════════════════════════════════════════════════════════════
def test_log_rejection_writes_jsonl(tmp_path, monkeypatch):
monkeypatch.setattr(validators, "_LOG_DIR", tmp_path)
err = validators.VerifierError("R2", "invalid_area_format", "bad area", valid_areas=["P6"])
validators.log_rejection("find_tags", {"area": "6-1"}, err)
files = list(tmp_path.iterdir())
assert len(files) == 1
content = files[0].read_text(encoding="utf-8")
rec = json.loads(content)
assert rec["tool"] == "find_tags"
assert rec["params"] == {"area": "6-1"}
assert rec["verifier_error"]["verifier_error"] == "R2.invalid_area_format"

View File

@@ -0,0 +1,105 @@
"""Phase B Verifier MVP — tool 인자 결정적 검증.
룰: R1(tag-existence), R2(area-format), R4(trace_connections 보강).
응답 텍스트 검증(R3, R5)은 Phase B.2 — stub만.
"""
from __future__ import annotations
import json, re, time, pathlib
from typing import Optional, Any
TAG_RE = re.compile(r'^[a-z][a-z0-9]*-\d+[a-z]?(\.[a-z0-9]+)?$')
AREA_RE = re.compile(r'^[A-Z][A-Z0-9]+(-\d+)?$')
VALID_AREAS = {"P1","P2","P3","P4","P5","P6","P8","P9","P10","UTIL","PACKING"}
VALID_DIRECTIONS = {"upstream","downstream"}
_LOG_DIR = pathlib.Path(__file__).parent / "logs"
class VerifierError(Exception):
def __init__(self, rule: str, code: str, hint: str, **extra):
self.rule, self.code, self.hint, self.extra = rule, code, hint, extra
def to_dict(self) -> dict:
return {"verifier_error": f"{self.rule}.{self.code}", "hint": self.hint, **self.extra}
# ── 태그 캐시 ──
_tag_cache: set[str] | None = None
_tag_cache_at: float = 0.0
_TAG_CACHE_TTL: float = 300.0 # 5분
def _load_tag_set(get_conn) -> set[str]:
global _tag_cache, _tag_cache_at
if _tag_cache is not None and (time.time() - _tag_cache_at) < _TAG_CACHE_TTL:
return _tag_cache
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT base_tag FROM tag_metadata WHERE base_tag IS NOT NULL")
s = {r[0].lower() for r in cur.fetchall() if r[0]}
cur.execute("SELECT DISTINCT tag_no FROM pid_equipment WHERE tag_no IS NOT NULL")
s |= {r[0].lower() for r in cur.fetchall() if r[0]}
finally:
conn.close()
_tag_cache, _tag_cache_at = s, time.time()
return s
# ── R1 ──
def validate_tag(tag: str | None, get_conn) -> Optional[VerifierError]:
if not tag:
return None
t = tag.lower()
if not TAG_RE.match(t):
return VerifierError("R1","invalid_tag_format",
hint=f"태그 형식 비정상: '{tag}'. 예시: ficq-6113.pv, p-6102")
base = t.split('.')[0]
tags = _load_tag_set(get_conn)
if base in tags:
return None
toks = [p for p in base.split('-') if len(p) > 2]
suggested = sorted({x for x in tags if any(p in x for p in toks)})[:3]
return VerifierError("R1","tag_not_found",
hint=f"태그 '{tag}' 는 DB에 존재하지 않습니다. find_tags(query=..., sub_area=...) 로 먼저 검색하세요.",
suggested=suggested)
# ── R2 ──
def validate_area(area: str | None, field: str = "area") -> Optional[VerifierError]:
if not area:
return None
area = area.upper()
if not AREA_RE.match(area):
return VerifierError("R2","invalid_area_format",
hint=f"{field}='{area}' 형식 오류. 'P6' 또는 'P6-1' 형식 사용.",
valid_areas=sorted(VALID_AREAS))
base = area.split('-')[0]
if base not in VALID_AREAS:
return VerifierError("R2","unknown_area",
hint=f"{field}='{area}' 미존재. valid: {sorted(VALID_AREAS)} (P7 없음)")
return None
# ── R4 ──
def validate_direction(d: str | None) -> Optional[VerifierError]:
if d and d not in VALID_DIRECTIONS:
return VerifierError("R4","invalid_direction",
hint=f"direction='{d}' 잘못. 'upstream' 또는 'downstream' 만 허용")
return None
def validate_max_depth(n: Any) -> Optional[VerifierError]:
if n is None: return None
try:
v = int(n)
except Exception:
return VerifierError("R4","invalid_max_depth", hint=f"max_depth='{n}' 은 정수여야 함")
if not (1 <= v <= 50):
return VerifierError("R4","max_depth_out_of_range", hint=f"max_depth={v} 범위 외 (1~50)")
return None
# ── R3, R5 stub (Phase B.2) ──
def validate_response_text(text: str) -> Optional[VerifierError]:
return None # Phase B.2 구현 예정
# ── 로그 적재 (Phase C LoRA 입력) ──
def log_rejection(tool: str, params: dict, err: VerifierError) -> None:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
today = time.strftime("%Y-%m-%d")
rec = {"ts": time.time(), "tool": tool, "params": params,
"verifier_error": err.to_dict()}
with (_LOG_DIR / f"{today}.jsonl").open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")