Compare commits

...

7 Commits

Author SHA1 Message Date
windpacer
32a442abd6 docs: 세션 작업 문서 추가 — 웹UI 개선플랜(감리정정) + 대화모음
- 웹UI-개선플랜-byQwen27B.md: app.js/index.html 분리 리팩토링 계획.
  실코드 대조 감리 결과 §0.5 추가 — 치명 결함 3건(모듈레벨 상태/최상위
  실행문 누락, 로더 기동 부재, async 파셜 배선 타이밍) 정정 및 정정 로더 포함
- 메타데이터업데이트/문서뷰어/커밋-브랜치정리 대화모음

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:47:27 +09:00
windpacer
2e844abf11 feat: 운전판정 고도화 — realtime stall 수정 + 교차검증 + 단위/레인지
- ExperionRealtimeService를 단일 SuperviseAsync supervisor로 재설계:
  비블로킹 부팅, PublishingStopped/KeepAliveStopped 워치독으로 silent
  stall 감지, 30초 주기 무한 재연결, flush 루프 단일화
- RealtimeServiceStatus에 LastDataAgeSeconds/Stalled 추가, History는
  Stalled 시 스냅샷 skip
- v_plant_running_state에 진공펌프(vp-) 포함 + 교차검증 4객체
  (pump_corroboration_manual, v_pump_signal_map,
  v_plant_running_state_corroborated, v_plant_running_state_agg)
  + v_instrument_range 뷰 (boot DDL)
- MetadataLoaderService에 euhi/eulo/units 메타속성 추가
- generate_status_report에 agg 조회 연동 + sample/focus 버그 수정
- plant_context.md에 펌프 prefix(p-/vp-) + 교차검증 뷰 사용법

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:47:20 +09:00
windpacer
7dbeb36218 fix: box-drawing GFM 변환 — 행별 자체 │ 위치 사용으로 컬럼 경계 불일치 대응 2026-05-24 13:20:33 +09:00
windpacer
762b4a1e7d fix: box-drawing table multi-line cell merge
Continuation rows (≤1 non-empty cell, e.g. wrapped cell text)
were being treated as separate data rows, shifting columns.
Now merged into the previous row by appending to the same cell.
2026-05-24 13:11:28 +09:00
windpacer
c31a2cf2e7 feat: box-drawing table to GFM pipe table conversion 2026-05-24 13:08:07 +09:00
windpacer
1a4666e339 fix: box-drawing 테이블 행 분리 문제 — 정규식 prefix만 검사하도록 수정
- BOX_RE: 전체 라인 검사 → 선두 box-drawing 문자만 확인
- 셀 내부 ' % ( ) 등 모든 문자 허용되어 행 분리 방지
- CSS 백업: .md-body pre에 font-family: var(--fm) 추가
2026-05-24 12:47:29 +09:00
windpacer
94a4b10f41 fix: 문서 탐색기 raw HTML escape + 높이 보정
- marked.js html renderer 오버라이드: 터미널 덤프에 포함된
  <div class="hidden"> 등 raw HTML이 DOM을 깨는 현상 방지
- .docs-layout height calc(100vh - 116px) → calc(100vh - 126px)
  (pane-hdr 높이 정확히 반영)
2026-05-24 12:34:32 +09:00
15 changed files with 7554 additions and 184 deletions

View File

@@ -7,6 +7,58 @@
## 완료된 작업
### 운전판정 고도화 — realtime writer stall 수정 + 교차검증(corroboration) + 단위/레인지 (2026-05-24)
#### 배경
"6-1차 펌프 4대 vs 5대" 질문에서 출발 → ① v_plant_running_state가 `p-%`만 집계해 진공펌프(`vp-`) 누락, ② 펌프 enum(RUN)만으론 허위 운전(deadhead·센서이상·frozen) 미검출, ③ 진단 중 realtime_table이 09:58 KST에 **silent stall**(수집 멈춤)된 운영 장애 발견.
#### 구현 내역
| # | 항목 | 핵심 |
|---|------|------|
| 1 | 진공펌프 포함 | `v_plant_running_state` 필터 `p-%``(p-% OR vp-%)`. VP-6117 등 진공펌프도 운전 집계 |
| 2 | **realtime writer stall 수정** | `ExperionRealtimeService`를 단일 `SuperviseAsync` supervisor로 통일: 부팅 비블로킹(`StartAsync(ct)` 즉시 반환), `GetLinkFault()``Subscription.PublishingStopped`/`Session.KeepAliveStopped`/`Connected`로 silent stall 감지 → 30초 주기 무한 재연결(3회-후-포기 제거), flush 루프 1회만 기동. `RealtimeServiceStatus``LastDataAgeSeconds`/`Stalled` 추가, History는 Stalled 시 스냅샷 skip |
| 3 | 교차검증 뷰 | `pump_corroboration_manual`(수동 매핑) + `v_pump_signal_map`(토폴로지 `FT.from_tag=펌프`→FICQ 1:N + 수동) + `v_plant_running_state_corroborated`(신선도 게이트 120s + STALE + 유량·진공 임계) + `v_plant_running_state_agg`(CONFIRMED 기준 RUNNING, suspicious/stale 부가 카운트) |
| 4 | 단위/레인지 메타데이터 | 별도 테이블 없이 `tag_metadata` 재사용 — `MetadataLoaderService.MetaAttributes``euhi/eulo/units` 추가(메타갱신 트리거 자동 편승). 타입 접근 `v_instrument_range` 뷰 |
| 5 | 유량 임계 보정 | FS 5%가 과대사이징 계기(FS 2000, 운전 ~11~57)엔 부적합(정상→SUSPICIOUS 오판) 발견 → `GREATEST(1.0, LEAST(eu_hi*0.05, 5.0))`로 [1~5 kg/hr] deadhead 밴드 캡 |
| 6 | MCP 연동 | `generate_status_report``v_plant_running_state_agg` 조회 추가(응답 `pump_corroboration` + LLM 프롬프트). 기존 `sample`/`focus` NameError 버그도 수정 |
#### 수정 파일
| 파일 | 변경 |
|------|------|
| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | supervisor 재설계(비블로킹·워치독·무한재시도·flush 단일화) |
| `src/Infrastructure/OpcUa/ExperionHistoryService.cs` | Stalled 시 스냅샷 skip |
| `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | MetaAttributes에 euhi/eulo/units |
| `src/Core/Application/Interfaces/IExperionServices.cs` | RealtimeServiceStatus에 LastDataAgeSeconds·Stalled |
| `src/Infrastructure/Database/ExperionDbContext.cs` | v_plant_running_state(vp- 포함) + 교차검증 4객체 + v_instrument_range (boot DDL) |
| `mcp-server/server.py` | generate_status_report agg 연동 + 버그수정 |
| `prompts/plant_context.md` | 펌프 prefix(p-/vp-) + 교차검증 뷰 사용법 |
| `plans/운전판정-고도화-플랜.md` | §0 감리 진단 결과(초안 정정·구현현황) |
#### 설계 결정
| 항목 | 결정 |
|------|------|
| stall 진단 신호 | realtime.timestamp frozen + history(realtime 복사본)는 신선·값 frozen → 수집기 정지. UTC/KST 무관 |
| 생존 판정 | `_session.Connected` 단독 → SDK `PublishingStopped`/`KeepAliveStopped` 추가(silent stall 감지) |
| 단위/레인지 저장 | 별도 테이블 X — `euhi/eulo/units`가 OPC 자식노드라 `tag_metadata` EAV에 적합. 트리거 배선 0 |
| 진공 의미 | pica-6111 = mmHg(≈torr), 0~760(760=대기압). 깊은진공=저압 → `< 300` CONFIRMED |
| 유량 임계 | %FS 부적합(계기 과대사이징) → deadhead 절대 밴드 캡 |
| MCP 재적용 | MCP 서버 재시작 필요(미반영) |
#### 검증 (라이브 :5000, Web 재기동 2회)
- `dotnet build` 경고 0/에러 0, `py_compile` OK
- 재기동 후 supervisor 자동재개(929 포인트), realtime stall 복구(lag 1:13→<2s, fresh 0→929)
- 메타갱신 2946건 적재(euhi/eulo/units), `v_instrument_range` 채워짐(ficq-6113 0~2000 kg/hr, pica-6111 0~760 mmHg)
- P6 운전 5대(진공 vp-6117 포함) 전부 **CONFIRMED_RUNNING**(유량 43 kg/hr, 진공 43 mmHg), agg confirmed=5/suspicious=0
#### 잔여
- MCP 서버 재시작해야 `generate_status_report` 변경 반영
- 신버전 Web 앱은 백그라운드 `dotnet run`으로 기동 중 — 영속화는 운전원 터미널/`deploy.sh`(systemd) 권장
- 진공 임계(300)·유량 deadhead 밴드는 운전 데이터로 추가 튜닝 여지
- `active_alarms`에 SUSPICIOUS 주입은 운전원 검증 후(보류)
### 문서 탐색기 (Tab 16) 구현 (2026-05-24)
#### 배경

View File

@@ -1708,6 +1708,25 @@ async def generate_status_report(area: str | None = None, hours: int = 24) -> st
for ev in events:
by_type[ev["event_type"]] = by_type.get(ev["event_type"], 0) + 1
# 2.5) 펌프 운전 교차검증 (유량/진공 기반) — v_plant_running_state_agg
pump_corr: list[dict] = []
try:
corr_raw = await _execute_sql_internal(
"SELECT area_code, status, total_pumps, confirmed_running, suspicious_running, "
"stale_running, indeterminate_running, tripped_pumps, "
"confirmed_tags, suspicious_tags, stale_tags "
"FROM v_plant_running_state_agg ORDER BY area_code"
)
corr_parsed = json.loads(corr_raw)
if corr_parsed.get("success"):
pump_corr = corr_parsed.get("data", [])
if area:
_f = [r for r in pump_corr if (r.get("area_code") or "").upper() == area.upper()]
if _f:
pump_corr = _f
except Exception:
pump_corr = []
# 3) LLM 보고서
alarm_lines = [
f"- [{a['event_type']}] {a['tag_name']} since {_kst_str(a['since'])} "
@@ -1718,9 +1737,16 @@ async def generate_status_report(area: str | None = None, hours: int = 24) -> st
f"- [{ev['event_type']}] {ev['tag_name']} @ {_kst_str(ev['event_time'])} "
f"({ev.get('prev_value')}{ev.get('curr_value')})"
f" (직전상태유지={ev.get('prev_state_duration_s', '?')}s)"
for ev in sample
for ev in events[:40]
]
focus_line = f"\n특히 다음 관점을 우선해 설명하세요: {focus}\n" if focus else ""
corr_lines = [
f"- {r.get('area_code')}: {r.get('status')} "
f"(확인 {r.get('confirmed_running', 0)}, 의심 {r.get('suspicious_running', 0)}, "
f"정체 {r.get('stale_running', 0)}, 트립 {r.get('tripped_pumps', 0)})"
+ (f" 의심펌프={r.get('suspicious_tags')}" if r.get('suspicious_running') else "")
+ (f" 정체펌프={r.get('stale_tags')}" if r.get('stale_running') else "")
for r in pump_corr
] or ["- 펌프 교차검증 데이터 없음"]
system = (
"당신은 IIoT/공장 운전 분석 전문가입니다. 디지털 포인트의 상태 변경 이벤트 로그를 보고 "
@@ -1731,13 +1757,17 @@ async def generate_status_report(area: str | None = None, hours: int = 24) -> st
"4) 다음 점검 권고 (있다면)\n"
"구체적인 태그명과 시각을 포함하되 추측은 자제합니다.\n\n"
"참고 - 모든 시각은 KST(UTC+9, Asia/Seoul)입니다. "
"`직전상태유지`는 이 이벤트 직전에 태그가 머물렀던 시간(초)입니다."
"`직전상태유지`는 이 이벤트 직전에 태그가 머물렀던 시간(초)입니다.\n"
"펌프 운전 교차검증: 펌프 RUN 상태를 유량(kg/hr)·진공압(torr)으로 확인한 결과입니다. "
"'확인'=실질 운전, '의심'=RUN인데 유량/진공 없음(deadhead·센서이상 가능), "
"'정체'=실시간 수집 지연으로 판정 보류. 의심/정체가 있으면 보고서에 우선 명시하세요."
)
user_msg = (
f"대상 area: {area or '전체'}\n"
f"분석 윈도우: 최근 {hours}시간\n"
f"이벤트 통계 (type별): {by_type}\n"
f"활성 알람 {len(alarms)}건:\n" + "\n".join(alarm_lines) + "\n\n"
f"펌프 운전 교차검증 (area별):\n" + "\n".join(corr_lines) + "\n\n"
f"최근 이벤트 표본 (최대 40건):\n" + "\n".join(recent_lines)
)
@@ -1766,6 +1796,7 @@ async def generate_status_report(area: str | None = None, hours: int = 24) -> st
"active_alarms_count": len(alarms),
"recent_events_count": len(events),
"by_type": by_type,
"pump_corroboration": pump_corr,
"window_hours": hours,
"area": area,
},

View File

@@ -0,0 +1,604 @@
# 운전판정 고도화 플랜 — 유량계·진공압 교차검증(Corroboration) 도입
> **문서 상태**: 초안 + 감리 진단 반영 (2026-05-24) — **§0이 §1~§8 초안보다 우선**
> **작성일**: 2026-05-24
> **관련 시스템**: ExperionCrawler v_plant_running_state, MCP server, pid_equipment
---
## 0. 감리 진단 결과 (2026-05-24, 실 DB·pid_equipment·코드 검증)
> 초안(§1~§8)을 실제 데이터로 검증한 결과다. 본문과 충돌 시 **이 절의 정정/보완이 우선**한다.
### 0.1 검증된 사실 (초안 대비 정정)
| 항목 | 초안 주장 | 실측 결과 | 판정 |
|---|---|---|---|
| 유량 데이터 성격 | "dummy 20.27 고정" (risk #1) | `ficq-6113`이 하루 동안 **38~55로 실측 변동**(history_table). 단 **수집기 stall 시 전 태그 frozen**(2026-05-24 09:58 KST 실제 발생, 별도 수정 완료) | 라이브 ✅ / **신선도 게이트 필수** |
| 펌프번호=유량번호 | "일관됨" (obs #1) | P-6102→ficq-**6101**, P-6114→ficq-6113+**6114**(1:N) — 번호 불일치·1:N | ❌ 오류 |
| `pica-6111.pv` 값 | "20.8" (§2.1) | 쿼리마다 변동(5.87/20.8…) — 고정값 아님 | ❌ 값 오류 |
| VP↔진공압 매핑 | "pid_equipment 기반" (§4.1.1 Step3) | VP-6117: from=D-6113, to=SC-6128 → **압력계와 토폴로지 연결 없음**. Step3는 사실상 하드코딩 manual | ⚠️ 명칭 정정 |
| 진공압 태그 의미 | "PICA.pv<50=진공유지" | `pi-6111`="VACUUM PRESSURE", `pica-6111`은 PT-6111→PCV-6111 **압력제어 루프**(다른 점일 수). 센서 sense(절대압/진공계)·단위 미확정 | ⚠️ 임계 근거 부족 |
| 실시간 유량 태그 | FT 기준 매핑 (§4.1.1) | realtime에 `ft-6113` **없음**(count=0). 값은 **FICQ 컨트롤러**(`ficq-6113.pv`)에 존재 | ❌ 매핑 대상 오류 |
### 0.2 치명적 문제 — Phase 1 SQL 그대로면 동작하지 않음
1. **[높음] 하이픈 제거 버그**: `LOWER(REPLACE(ft.tag_no,'-',''))``'ft6113'`. realtime base_tag는 하이픈 유지(`ficq-6113`) → **매칭 0건** → 전부 INDETERMINATE.
2. **[높음] FT vs FICQ 대상 오류**: 유량값은 `ft-*`가 아니라 `ficq-*.pv`. FT 태그로는 realtime 조인 불가.
3. **[높음] heuristic 오매핑**: `'ficq-'||SUBSTRING(base_tag FROM 3)` → P-6102는 `ficq-6102`(없음, 실제 6101), vp-6117은 `ficq--6117`(깨짐). 번호 가정 자체가 틀림.
4. **[중간] 1:N 손실**: flow_tag 단일 컬럼 → P-6114(6113+6114) 한쪽만 저장.
5. **[중간] forward JOIN 실패**: P-6102.to_tag=필터, P-6116.to_tag=C-6111/FCV → to_tag에 FT 없음. 역방향(FT.from_tag=pump)만 일부 성립.
### 0.3 설계 공백
6. **[최우선] 데이터 신선도 게이트 부재** — realtime 값이 stale/frozen이어도 임계 비교 → frozen `20.2667`을 "유량 정상=CONFIRMED"로 **오판**. (이번 수집기 stall이 정확히 이 시나리오.) 판정 전 `NOW()-timestamp` 확인, stale면 판정 보류.
7. **[중간] 임계 0.5 절대값** — 계기별 Full Scale 무시(작은 레인지 과대·큰 레인지 과소).
8. **[중간] 집계가 1대 의심에 전체 오염** — suspicious 1대로 area가 RUNNING_WITH_SUSPICIOUS. 정상 standby/kickback(sp=0) 펌프 상시 의심 위험. corroborated_pct 분모에 INDETERMINATE 혼입.
9. **[중간] active_alarms 주입 위험** — 미검증 휴리스틱(+데이터 품질 이슈)을 운전원 알람화 → 알람 피로. 검증 전 advisory만.
10. **[낮음] VP 신호 선택** — `pica-6111`(압력제어)보다 `pi-6111b`("VACUUM PRESSURE", C-6111 직결)가 진공 판정에 적합해 보임. sense/단위 확인 후 결정.
### 0.4 권장 보완
**(a) 매핑 — 토폴로지 규칙(번호 heuristic 폐기)**
- 1차: `FT.from_tag`가 펌프를 참조하는 행 수집(역방향, 1:N 자연 지원). 값 태그 = 같은 번호 **FICQ 컨트롤러** `ficq-<FT번호>.pv`. 하이픈 **유지**(`lower(tag_no)`만, REPLACE 금지).
- 2차: 펌프-FT 사이 중간설비(P-6102→F-6101A/B→FT-6101)는 토폴로지 2-hop 또는 **manual** 항목.
- map 테이블은 1:N 허용(펌프당 flow_tag 복수 행) + `mapping_source ∈ {topology, manual}`.
**(b) 신선도 게이트 (신규·최우선)**
```sql
-- 값 신뢰 조건: 수집 후 N초 이내(예: 120s). realtime_table.timestamp 기준.
(NOW() - rt.timestamp) < interval '120 seconds'
```
신선하지 않으면 `STALE`(운전 여부 판정 보류). 수집기 측은 supervisor가 stall을 30초 내 자동 복구하고 `RealtimeServiceStatus.Stalled`로도 노출(2026-05-24 적용).
**(c) 판정 상태에 STALE 추가**
| 판정 | 조건 |
|---|---|
| CONFIRMED_RUNNING | RUN + 유량 **신선** + PV>임계 |
| SUSPICIOUS_RUNNING | RUN + 유량 **신선** + PV≤임계 |
| **STALE** (신규) | RUN + 유량 **stale/frozen** (수집 지연·stall) |
| INDETERMINATE_RUNNING | RUN + 유량 매핑/데이터 없음 |
| STOPPED / TRIPPED | enum 기준 |
**(d) 임계** — Phase 1은 절대 0.5 단독 대신 "신선 AND PV<계기군 기본임계"로 무유량/frozen만 포착. Full Scale 5%는 Phase 2 `instrument_range`로.
**(e) 집계·알람** — overall_status는 CONFIRMED 기준으로 RUNNING 유지하고 suspicious/stale는 **부가 카운트**로 노출(전체 상태 오염 금지). active_alarms 주입은 **운전원 검증(§6.4) 통과 후**로 보류.
### 0.5 갱신된 의사결정 체크리스트
- [x] 데이터 성격: **라이브**(dummy 아님), 단 stale 가능 → 신선도 게이트 필수
- [ ] 매핑: FT.from_tag 역방향 + FICQ 값태그 + manual 보강 (번호 heuristic 폐기) ← **재결정**
- [ ] 임계: 계기군 기본값 + 신선도 동반 (절대 0.5 단독 폐기) ← **재결정**
- [ ] VP 신호: `pi-6111b` vs `pica-6111` + sense/단위 확인 ← **미결**
- [ ] STALE 상태 도입 ← **신규 결정 필요**
- [ ] active_alarms 주입: 검증 전 보류 ← **재결정**
### 0.6 구현 현황 (2026-05-24)
-**DB 뷰 계층 구현·검증** (`ExperionDbContext` boot DDL):
- `pump_corroboration_manual` (수동 매핑 테이블, P6 예외 시드: p-6102→ficq-6101, vp-6117→pica-6111, vp-6217→pica-6211)
- `v_pump_signal_map` (토폴로지 `FT.from_tag=펌프`→FICQ 1:N + 수동 UNION)
- `v_plant_running_state_corroborated` (신선도 게이트 120s + STALE + 유량 > 0.5 kg/hr · 진공 < 300 torr)
- `v_plant_running_state_agg` (overall은 CONFIRMED 기준 RUNNING, suspicious/stale는 부가 카운트)
- 빌드 0/0. 라이브 검증: 현재 frozen 데이터가 전부 `STALE`로 분류됨 확인(게이트 정상 작동).
-**plant_context.md** 교차검증 사용법 추가.
-**단위/레인지 메타데이터화** (별도 테이블 X — 복잡도 최소화 결정): `MetadataLoaderService.MetaAttributes``euhi/eulo/units` 추가 → `tag_metadata`(EAV) 재사용. node_map_master에 점 레벨 `euhi`(Double FS-Hi)·`eulo`·`units`(String) 노드 존재 확인. **PointBuilder 작성·수동 메타갱신 트리거에 자동 편승**(스코프=구독 아날로그 ⓐ). 타입 접근은 `v_instrument_range` 뷰(피벗+캐스트). 값은 단위 torr / 유량 kg/hr.
-**유량 임계 FS 5%**: corroborated가 `flow > COALESCE(eu_hi*0.05, 0.5 kg/hr)` — 레인지 적재되면 자동 FS 5% 승급, 없으면 절대 fallback. 진공은 300 torr 절대(실 레인지 확인 후 보정).
-**OPC 복구 후 실값 적재**: 현재 수집 stall이라 `euhi/eulo/units` 값 미적재 → 복구 후 메타갱신 1회 시 채워지며 FS 5% 자동 적용.
-**MCP 연동 보류**: `generate_status_report``v_plant_running_state_agg` 노출은 후속. `active_alarms` 주입은 운전원 검증 후(§0.4e).
---
## 1. 배경 및 문제 정의
### 1.1 현재 상황
현재 공장 운전 판정(`v_plant_running_state` 뷰)은 **펌프의 상태 워드(enum 값)만**으로 이루어짐:
```sql
-- 현재 로직 (의사코드)
CASE
WHEN pv ~ '[LR]-RUN' THEN 'RUNNING'
WHEN pv ~ '[LR]-TRIP' THEN 'TRIPPED'
ELSE 'STOPPED'
END AS status
```
예: P-6102의 PV = `{5 | R-RUN | }`**RUNNING**
### 1.2 문제점 — 허위 운전 미검출
펌프 상태 워드가 `R-RUN`이어도 **실질적 운전**이 아닌 경우가 있음:
| 상황 | 펌프 상태 | 유량계 | 실질 운전? |
|------|----------|--------|-----------|
| 정상 운전 | R-RUN | > 0 | ✅ |
| 밸브 닫힘/Deadhead | R-RUN | ≈ 0 | ❌ (기계 손상 위험) |
| 커플링 파손 | R-RUN | ≈ 0 | ❌ (무부하 운전) |
| 센서 오류 | R-RUN | ≈ 0 | ❌ (신호 끊김) |
| Kickback 순환 | R-RUN | 0 (메인) | ⚠️ (의도된 운전, main line은 닫힘) |
**현재는 이 4가지 케이스를 모두 동일하게 `RUNNING`으로 판정 → 허위 정보 제공**
### 1.3 진공 펌프의 특수성
진공 펌프(VP)는 유량계가 없고 **진공압(PI/PICA)** 으로 운전 상태를 검증:
| 상황 | VP 상태 | 진공압 | 실질 운전? |
|------|---------|--------|-----------|
| 정상 진공 유지 | R-RUN | 목표압 도달 | ✅ |
| 펌프 고장/RUN 신호 오류 | R-RUN | 대기압 (≈0) | ❌ |
| 계통 누설 | R-RUN | 대기압 (≈0) | ❌ |
---
## 2. 조사 결과 — P6 데이터 기반 분석
### 2.1 Pump↔Flow Meter 매핑 (pid_equipment 기반)
**P6-1 (C-6111 증류탑):**
| Pump | 상태 | P&ID 연결 | Experion 유량계 | SETPOINT |
|------|------|----------|----------------|----------|
| P-6101 | L-STOP | (미매핑, 번호 일치) | ficq-6101.pv=20.3 | sp=36.0 |
| P-6102 | **R-RUN** | →F-6101A/B→FT-6101→FCV-6101 | ficq-6101.pv=20.3 | sp=36.0 |
| P-6114 | **R-RUN** | →FT-6113( reflux) + FT-6114(light ends) | ficq-6113.pv=20.3, ficq-6114.pv=20.3 | sp=36.4 / 0 |
| P-6116 | **R-RUN** | →FT-6116→FCV-6116 | ficq-6116.pv=20.3 | sp=0 |
| P-6118 | **R-RUN** | →FT-6118→FCV-6118 | ficq-6118.pv=20.3 | sp=0 |
| VP-6117 | **R-RUN** | C-6111 진공 유지 | pi-6111.pv=0, pica-6111.pv=20.8 | — |
| P-6120 | OFF | (미매핑) | fiq-6120.pv=0 | — |
| P-6123 | L-STOP | (미매핑) | — | — |
| P-6128a/b | L-STOP | (미매핑) | — | — |
**P6-2 (C-6211 증류탑):**
| Pump | 상태 | Experion 유량계 | 비고 |
|------|------|----------------|------|
| P-6201 | L-STOP | ficq-6201.pv=20.8 | P6-1/P6-2 공용 |
| P-6202~6223 | 전부 STOP | ficq-62XX.pv=20.3 | — |
| VP-6217 | L-STOP | pi-6211.pv=0, pica-6211.pv=20.8 | — |
### 2.2 key observations
1. ⚠️ **(정정 §0.1) 펌프 번호 ≠ 유량계 번호**: P-6102→ficq-6101, P-6114→ficq-6113+6114(1:N). 유량 번호는 stream/line 번호 → pid_equipment 토폴로지(`FT.from_tag=pump`)로 매핑해야 함
2. **pid_equipment.from_tag/to_tag 토폴로지**로 1:N 매핑 추적 가능 (예: P-6114→FT-6113 + FT-6114)
3. **Setpoint(SP) 데이터 존재**: ficq-XXXX.sp 사용 가능 — SP=0은 밸브 닫힘(킥백) 신호
4. **VP는 유량계 없음**: 대신 pica-6111/sp, pi-6111로 진공압 교차검증 필요
5. **FCV-XXXX.op(밸브 위치) 데이터 없음**: 현재 realtime_table에 미등록
---
## 3. 설계 결정
### 3.1 매핑 전략: pid_equipment 기반 + 번호 heuristic fallback
> ⚠️ **§0.4(a) 우선** — 번호 heuristic은 오매핑(P-6102→6101 어긋남, vp- 깨짐)이라 폐기. `FT.from_tag` 역방향(1:N) + FICQ 값태그 + manual 보강으로 대체.
```
[1차] pid_equipment.from_tag/to_tag 정방향/역방향 조회
└─ P-6114의 to_tag = FT-6113, FT-6114 → ficq-6113, ficq-6114 매핑
[2차] 번호 heuristic fallback (pid_equipment 커버 안 되는 경우)
└─ P-6101 (pid_equipment 미존재) → ficq-6101 (번호 일치)
```
**pump_corroboration_map 테이블 (신규):**
```sql
CREATE TABLE pump_corroboration_map (
pump_base_tag TEXT PRIMARY KEY,
flow_tag TEXT, -- ficq-XXXX 또는 fiq-XXXX
flow_sp_tag TEXT, -- ficq-XXXX.sp (threshold 계산용)
vacuum_tag TEXT, -- VP용: pica-XXXX.pv 또는 pi-XXXX.pv
vacuum_sp_tag TEXT, -- VP용: pica-XXXX.sp
mapping_source TEXT DEFAULT 'auto', -- 'pid_equipment' | 'heuristic' | 'manual'
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
> **MetadataLoaderService가 건드리지 않음** — sub_area와 동일한 원칙 (자동 덮어쓰기 금지)
### 3.2 Threshold 기준
| 구분 | Phase 1 기준 | Phase 2 기준 |
|------|-------------|-------------|
| **유량계 (FICQ/FIQ)** | PV > **0.5** (절대값) | PV > **5% of Full Scale** (instrument_range 테이블 도입 시) |
| **진공압 (PICA)** | PV < **50** (mmHg 절대압 가정) | PV < **5% of Full Scale** |
| **진공압 (PI)** | PV ≈ **0** (≈대기압이면 의심) | 동일 |
**Phase 1에서 SP(Setpoint)를 threshold 기준으로 사용하지 않는 이유:**
운전원은 잦은 수동 밸브 조작으로 SP를 변경하지 않고 Control Valve만 조작함.
예: ficq-6101.sp=36으로 설정되어 있지만, PV가 1.2로 수동 조작 중 → SP*5%=1.8 > PV=1.2 → false SUSPICIOUS.
절대값 0.5를 기준으로 하면 PV=1.2는 정상 판정 → **false positive 방지**.
### 3.3 3단계 판정 로직
> ⚠️ **§0.4(c): `STALE` 상태 추가로 4단계+STALE. 신선도 미확인 시 frozen 데이터 오판(CONFIRMED) 차단.**
킥백 라인 상황(펌프 RUN + 메인 밸브 닫힘 + kickback만 순환) 고려:
| 판정 | 조건 | 의미 |
|------|------|------|
| **CONFIRMED_RUNNING** | pump RUN + flow PV > threshold | 유량 있음, 실질 운전 중 |
| **SUSPICIOUS_RUNNING** | pump RUN + flow PV ≤ threshold | RUN인데 유량 없음 (deadhead / 센서오류 / 커플링파손) |
| **INDETERMINATE_RUNNING** | pump RUN + flow 데이터 없음 | kickback 가능성, 추가 정보 필요 |
| **STOPPED** | pump STOP/TRIP/OFF | 정지 또는 트립 |
킥백 상황(P-6114 RUN + ficq-6114.sp=0 → valve closed)은 유량 PV=0이어도 PV=0이라 SUSPICIOUS 대상이지만, **이건 의도된 운전이므로 사용자가 해석 시 고려**. Phase 2에서 FCV-XXXX.op 데이터 추가 시 자동 구분 가능.
### 3.4 진공 펌프(VP) 교차검증
VP는 유량계가 없으므로 **진공압(PICA/PI)** 으로 검증:
- **VP R-RUN** + PICA.pv < threshold (진공 유지 중) → CONFIRMED
- **VP R-RUN** + PI.pv ≈ 0 (대기압, 진공 안 잡힘) → SUSPICIOUS
- **VP STOP/TRIP** → STOPPED
---
## 4. 구현 계획 — 3 Phase
### Phase 1: SQL View 확장 (즉시)
#### 4.1.1 pump_corroboration_map 생성 및 시딩
> ⚠️ **아래 시딩 SQL은 현 상태로 동작하지 않음** (§0.2: ① 하이픈 제거 `REPLACE(...,'-','')` → 매칭 0건, ② FT/FICQ 대상 혼동, ③ 번호 heuristic 오류, ④ 1:N 손실). **§0.4(a)의 토폴로지 규칙으로 대체할 것.**
```sql
-- Step 1: pid_equipment 기반 pump→FT 매핑 (from_tag/to_tag 정/역방향)
INSERT INTO pump_corroboration_map (pump_base_tag, flow_tag, flow_sp_tag, mapping_source)
SELECT DISTINCT
LOWER(REPLACE(p.tag_no, '-', '')) AS pump_base_tag,
LOWER(REPLACE(ft.tag_no, '-', '')) || '.pv' AS flow_tag,
LOWER(REPLACE(ft.tag_no, '-', '')) || '.sp' AS flow_sp_tag,
'pid_equipment'
FROM pid_equipment p
JOIN pid_equipment ft ON (
-- 정방향: pump.to_tag = FT
p.to_tag LIKE '%' || ft.tag_no || '%'
-- 역방향: FT.from_tag = pump
OR ft.from_tag LIKE '%' || p.tag_no || '%'
)
WHERE p.category = '펌프'
AND ft.category IN ('계기', '제어')
AND ft.tag_no ~ '^FT-|^FIC-';
-- Step 2: 번호 heuristic fallback (pid_equipment에 없는 pump)
INSERT INTO pump_corroboration_map (pump_base_tag, flow_tag, flow_sp_tag, mapping_source)
SELECT DISTINCT
v.base_tag,
'ficq-' || SUBSTRING(v.base_tag FROM 3) || '.pv',
'ficq-' || SUBSTRING(v.base_tag FROM 3) || '.sp',
'heuristic'
FROM v_tag_summary v
WHERE (v.base_tag LIKE 'p-6%' OR v.base_tag LIKE 'vp-6%')
AND v.pv ~ '[LR]-RUN|L-STOP|R-STOP|OFF'
AND NOT EXISTS (
SELECT 1 FROM pump_corroboration_map m WHERE m.pump_base_tag = v.base_tag
);
-- Step 3: VP 전용 vacuum 태그 매핑
INSERT INTO pump_corroboration_map (pump_base_tag, vacuum_tag, vacuum_sp_tag, mapping_source)
SELECT
LOWER(REPLACE(vp.tag_no, '-', '')),
LOWER(REPLACE(pi.tag_no, '-', '')) || '.pv',
LOWER(REPLACE(pi.tag_no, '-', '')) || '.sp',
'pid_equipment'
FROM pid_equipment vp
CROSS JOIN pid_equipment pi
WHERE vp.tag_no LIKE 'VP-%'
AND pi.tag_no IN ('PICA-6111', 'PICA-6211', 'PI-6111B', 'PI-6211B');
```
#### 4.1.2 v_plant_running_state_corroborated 뷰
> ⚠️ **신선도 게이트(§0.4b)·STALE 분기(§0.4c) 미반영.** 아래 뷰에 `(NOW() - rt.timestamp) < interval '120 seconds'` 조건과 `STALE` 분기를 추가하고, flow 값은 `ft-*`가 아니라 `ficq-*.pv`(컨트롤러)에서 조인할 것.
```sql
CREATE OR REPLACE VIEW v_plant_running_state_corroborated AS
WITH pump_base AS (
-- 기존 pump_state 로직 + corroboration 매핑 LEFT JOIN
SELECT
trim(split_part(v.area, '|', 2)) AS area_code,
v.area AS area_raw,
v.base_tag,
v.pv,
v.description,
v.sub_area,
m.flow_tag,
m.flow_sp_tag,
m.vacuum_tag,
m.vacuum_sp_tag,
m.mapping_source,
-- 유량계 PV/SP 값 조회 (realtime_table에서)
flow_rt.livevalue AS flow_pv,
flow_sp_rt.livevalue AS flow_sp,
vac_rt.livevalue AS vacuum_pv,
vac_sp_rt.livevalue AS vacuum_sp
FROM v_tag_summary v
LEFT JOIN pump_corroboration_map m ON m.pump_base_tag = v.base_tag
LEFT JOIN realtime_table flow_rt ON flow_rt.tagname = m.flow_tag
LEFT JOIN realtime_table flow_sp_rt ON flow_sp_rt.tagname = m.flow_sp_tag
LEFT JOIN realtime_table vac_rt ON vac_rt.tagname = m.vacuum_tag
LEFT JOIN realtime_table vac_sp_rt ON vac_sp_rt.tagname = m.vacuum_sp_tag
WHERE v.area IS NOT NULL
AND (v.base_tag LIKE 'p-%' OR v.base_tag LIKE 'vp-%')
AND v.pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
),
pump_with_corroboration AS (
SELECT *,
CASE
-- pump STOP/TRIP/OFF
WHEN pv ~ '\|\s*[LR]-TRIP\s*\|' THEN 'TRIPPED'
WHEN pv ~ '\|\s*(L-STOP|R-STOP|OFF|STOP)\s*\|' THEN 'STOPPED'
-- pump RUN - vacuum pump (VP)
WHEN base_tag LIKE 'vp-%' THEN
CASE
WHEN vacuum_pv IS NOT NULL
AND vacuum_pv ~ '^\d+\.?\d*$'
AND CAST(vacuum_pv AS DOUBLE PRECISION) < 50
THEN 'CONFIRMED_RUNNING'
WHEN vacuum_pv IS NOT NULL
AND (vacuum_pv ~ '^\{' OR CAST(vacuum_pv AS DOUBLE PRECISION) >= 50)
THEN 'SUSPICIOUS_RUNNING'
ELSE 'INDETERMINATE_RUNNING'
END
-- pump RUN - 유량계 있음
WHEN flow_pv IS NOT NULL AND flow_pv ~ '^\d+\.?\d*$' THEN
CASE
WHEN CAST(flow_pv AS DOUBLE PRECISION) > 0.5 THEN 'CONFIRMED_RUNNING'
ELSE 'SUSPICIOUS_RUNNING'
END
-- pump RUN - 유량계 없음
ELSE 'INDETERMINATE_RUNNING'
END AS corroborated_status
FROM pump_base
)
SELECT
area_code,
area_raw,
base_tag,
pv AS raw_pv,
description,
sub_area,
flow_tag,
flow_pv,
flow_sp,
vacuum_tag,
vacuum_pv,
vacuum_sp,
mapping_source,
corroborated_status,
CASE
WHEN corroborated_status = 'CONFIRMED_RUNNING' THEN TRUE
ELSE FALSE
END AS is_corroborated_running,
CASE
WHEN corroborated_status = 'SUSPICIOUS_RUNNING' THEN TRUE
ELSE FALSE
END AS is_suspicious_running,
CASE
WHEN corroborated_status = 'INDETERMINATE_RUNNING' THEN TRUE
ELSE FALSE
END AS is_indeterminate_running
FROM pump_with_corroboration
WHERE area_code IS NOT NULL AND area_code <> '';
```
#### 4.1.3 v_plant_running_state_agg 뷰 (area별 집계)
```sql
CREATE OR REPLACE VIEW v_plant_running_state_agg AS
SELECT
area_code,
MAX(area_raw) AS area_raw,
COUNT(*) AS total_pumps,
COUNT(*) FILTER (WHERE corroborated_status = 'CONFIRMED_RUNNING') AS confirmed_running,
COUNT(*) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') AS suspicious_running,
COUNT(*) FILTER (WHERE corroborated_status = 'INDETERMINATE_RUNNING') AS indeterminate_running,
COUNT(*) FILTER (WHERE corroborated_status = 'TRIPPED') AS tripped_pumps,
COUNT(*) FILTER (WHERE corroborated_status = 'STOPPED') AS stopped_pumps,
CASE
WHEN COUNT(*) FILTER (WHERE corroborated_status IN ('CONFIRMED_RUNNING', 'SUSPICIOUS_RUNNING', 'INDETERMINATE_RUNNING')) > 0
AND COUNT(*) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') = 0
THEN 'RUNNING'
WHEN COUNT(*) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') > 0
THEN 'RUNNING_WITH_SUSPICIOUS'
WHEN COUNT(*) FILTER (WHERE corroborated_status = 'TRIPPED') > 0
THEN 'TRIPPED'
ELSE 'STOPPED'
END AS overall_status,
-- corroborated_rate: 전체 RUN 펌프 중 확인된 비율
CASE
WHEN COUNT(*) FILTER (WHERE corroborated_status IN ('CONFIRMED_RUNNING', 'SUSPICIOUS_RUNNING', 'INDETERMINATE_RUNNING')) > 0
THEN ROUND(
COUNT(*) FILTER (WHERE corroborated_status = 'CONFIRMED_RUNNING')::NUMERIC
/ COUNT(*) FILTER (WHERE corroborated_status IN ('CONFIRMED_RUNNING', 'SUSPICIOUS_RUNNING', 'INDETERMINATE_RUNNING'))
* 100, 1
)
ELSE NULL
END AS corroborated_pct,
array_agg(base_tag) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') AS suspicious_pump_tags,
array_agg(base_tag) FILTER (WHERE corroborated_status = 'CONFIRMED_RUNNING') AS confirmed_running_tags
FROM v_plant_running_state_corroborated
WHERE area_code IS NOT NULL AND area_code <> ''
GROUP BY area_code
ORDER BY area_code;
```
### Phase 1: MCP Server 통합
#### 4.1.4 server.py — 새 뷰 조회 추가
`generate_status_report` 함수 내에서 `v_plant_running_state_agg` 조회:
```python
# server.py — generate_status_report 내부
cur.execute("""
SELECT area_code, overall_status, total_pumps, confirmed_running,
suspicious_running, suspicious_pump_tags, corroborated_pct
FROM v_plant_running_state_agg
WHERE (%s IS NULL OR area_code = %s)
ORDER BY area_code
""", (area, area))
```
응답 JSON에 추가:
```python
{
"active_alarms": [...],
"recent_events": [...],
"by_type": {...},
"pump_corroboration": {
"by_area": [
{
"area": "P6",
"status": "RUNNING_WITH_SUSPICIOUS",
"total_pumps": 22,
"confirmed_running": 4,
"suspicious_running": 1,
"corroborated_pct": 80.0,
"suspicious_pumps": ["p-6114"]
}
]
}
}
```
#### 4.1.5 active_alarms — SUSPICIOUS_RUNNING 의심 알람 추가
```sql
-- active_alarms에 suspicious pump 추가
SELECT base_tag AS tag_name, 'SUSPICIOUS_RUNNING' AS event_type,
'펌프 RUN 상태이나 유량 없음' AS description,
area_code
FROM v_plant_running_state_corroborated
WHERE is_suspicious_running = TRUE;
```
#### 4.1.6 trace_connections — flow_pv/run_status 노출
```python
# 각 path 노드에 flow_pv, corroborated_status 추가
# pid_equipment tag_no → base_tag 변환 후 v_plant_running_state_corroborated 조회
```
### Phase 1: plant_context.md 업데이트
프롬프트에 교차검증 관련 컨텍스트 추가:
```
## 운전 판정 교차검증 (Corroboration)
펌프의 상태 워드(R-RUN/L-RUN)만으로 운전을 판정하지 않고,
연결된 유량계(FICQ/FIQ)의 PV 값을 교차검증하여 3단계 판정:
| 판정 | 의미 |
|------|------|
| CONFIRMED_RUNNING | 펌프 RUN + 유량계 PV > 0.5 (실질 운전) |
| SUSPICIOUS_RUNNING | 펌프 RUN + 유량계 PV ≤ 0.5 (의심: deadhead, 센서오류) |
| INDETERMINATE_RUNNING | 펌프 RUN + 유량계 데이터 없음 (킥백 가능성) |
진공펌프(VP)는 유량계 대신 진공압(PICA.pv < 50)으로 판정.
- `v_plant_running_state_corroborated`: 태그별 상세 판정
- `v_plant_running_state_agg`: area별 집계 (corroborated_pct 포함)
```
---
### Phase 2: 정밀화 (Phase 1 검증 후)
| 항목 | 내용 | 우선순위 |
|------|------|---------|
| **instrument_range 테이블** | 각 유량계의 Full Scale / Unit 저장 → 5% threshold 계산 | 높음 |
| **Plant Load Rate 기반 검증** | 원료투입량(FICQ-6101.pv) 대비 각 유량계 비율 계산 → 수율(Throughput) 추정 | 중간 |
| **FCV-XXXX.op 태그 추가** | Control Valve position 실시간 감시 → kickback 자동 인식 | 중간 |
| **AI/통계 threshold** | 정상 운전 기간의 평균±3σ로 이상 감지 | 낮음 |
| **PumpCorroborationService** | C# BackgroundService로 주기적 검증 → pump_corroboration_history 테이블에 이벤트 기록 | 낮음 |
#### instrument_range 테이블 설계 (Phase 2)
```sql
CREATE TABLE instrument_range (
base_tag TEXT PRIMARY KEY,
full_scale DOUBLE PRECISION NOT NULL,
unit TEXT,
source TEXT DEFAULT 'manual', -- 'pid_equipment' | 'opc_ua' | 'manual'
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Phase 2 threshold: PV > 0.05 * full_scale (5% of Full Scale)
```
Phase 2에서 기존 view의 threshold만 변경:
```sql
-- Phase 2 수정안
WHEN CAST(flow_pv AS DOUBLE PRECISION) > 0.05 * ir.full_scale THEN 'CONFIRMED_RUNNING'
```
---
### Phase 3: 프론트엔드 대시보드
- Area Overview에 `corroborated_pct` 게이지 표시
- SUSPICIOUS_RUNNING 펌프 빨간색 하이라이트
- 클릭 시 flow_pv / vacuum_pv 상세 표시
---
## 5. 리스크 분석
| 리스크 | 영향 | 대응 |
|--------|------|------|
| ~~Flow PV가 dummy 값~~**(정정 §0.1) 라이브 데이터** (ficq-6113 38~55 변동 확인). 단 **수집기 stall 시 frozen** | frozen을 정상으로 오판(CONFIRMED) | **신선도 게이트(§0.4b)** + 수집기 supervisor 수정(2026-05-24 적용) |
| **pid_equipment 미완성** | 일부 pump 교차검증 불가 | 번호 heuristic fallback으로 커버. 이후 수동 보강 |
| **킥백 상황 오판** | SUSPICIOUS_RUNNING 과다 | 0.5 threshold로 PV=0 케이스만 포착. Phase 2에서 FCV-XXXX.op 추가 |
| **진공압 범위/단위 불명** | threshold 값 부정확 | 현재 PICA.pv=20.8(임의값). 실제 단위 확인 필요 (mmHg / kPa / bar) |
| **성능: realtime_table LEFT JOIN** | 뷰 조회 속도 저하 | pump_corroboration_map에 인덱스. 실운영 모니터링 |
## 6. 검증 계획
1. **유닛 테스트**: 각 판정 CASE별 샘플 데이터 생성 → 예상 결과와 일치 확인
2. **DB 뷰 검증**: production replica에서 `v_plant_running_state_corroborated` 조회, 수동 확인
3. **MCP 응답 체크**: `generate_status_report`에 suspicious 필드 정상 포함 확인
4. **운전원 피드백**: SUSPICIOUS_RUNNING 케이스 실제 상황과 일치하는지 확인
---
## 7. 일정 (예상)
| Phase | 작업 | 예상 기간 | 비고 |
|-------|------|----------|------|
| **Phase 1** | pump_corroboration_map 시딩 스크립트 | 1일 | |
| | v_plant_running_state_corroborated 뷰 | 0.5일 | |
| | v_plant_running_state_agg 뷰 | 0.5일 | |
| | MCP server.py 통합 | 1일 | |
| | plant_context.md 업데이트 | 0.5일 | |
| | **소계** | **3.5일** | |
| **Phase 2** | instrument_range 테이블 + 시딩 | 1일 | Phase 1 검증 후 |
| | Plant Load Rate 계산 로직 | 2일 | |
| | FCV-XXXX.op 추가 | 0.5일 | |
| | 통계 threshold | 2일 | |
| | **소계** | **5.5일** | |
| **Phase 3** | 프론트엔드 대시보드 | 2일 | |
| | **총계** | **11일** | |
---
## 8. 결론
현재 펌프 상태 워드 단일 판정을 유량계·진공압 교차검증으로 고도화하여 **허위 운전 정보 제공을 방지**하고, **실질 운전 여부를 정확히 판정**할 수 있음.
Phase 1은 SQL view 확장만으로 즉시 적용 가능하며, OPC UA 실제 데이터 연결 후 검증 즉시 가동 가능.
**핵심 의사결정 사항:**
- [ ] Pump↔유량계 매핑 방식 → pid_equipment 기반 + 번호 heuristic fallback (✅ 결정)
- [ ] Threshold 기준 → 절대값 0.5 (Phase 1), Full Scale 5% (Phase 2) (✅ 결정)
- [ ] 킥백 처리 → Phase 1에서 별도 미처리, Phase 2에서 valve position 추가 (✅ 결정)
- [ ] VP 교차검증 → PICA.pv < 50 기준 (단위 확인 필요)

View File

@@ -128,10 +128,15 @@ WHERE livevalue ~ '\|\s*[LR]-TRIP\s*\|'
WHERE livevalue ~ '\|\s*(L-STOP|R-STOP|STOP|OFF)\s*\|'
```
### ⚠️ p-prefix 주의사항
### ⚠️ 펌프 prefix 주의사항 (p- / vp-)
`p-NNNN` prefix는 펌프 전용이 아닙니다. panel point, alarm point 등 다른 디지털 포인트도 같은 prefix를 공유합니다.
- 운전 판정 시 enum이 위 6종(L-STOP/L-RUN/L-TRIP/R-STOP/R-RUN/R-TRIP) 중 하나인 태그만 펌프로 취급.
펌프 prefix는 **두 종류**입니다:
- `p-NNNN` = 공정 펌프 (원료/리플럭스/제품 이송 등)
- `vp-NNNN` = **진공 펌프** (Vacuum Pump, 감압 컬럼용). 예: `vp-6117`(C-6111 진공), `vp-3204`, `vp-5117`, `vp-6217`, `vp-8117`
`p-NNNN` prefix는 펌프 전용이 아닙니다. panel point, alarm point 등 다른 디지털 포인트(`p-201_hs`, `p-2202_run` 등)도 같은 prefix를 공유합니다.
- 운전 판정 시 enum이 위 6종(L-STOP/L-RUN/L-TRIP/R-STOP/R-RUN/R-TRIP) 중 하나인 태그만 펌프로 취급. 패널/피드백 포인트는 단순 `STOP`/`OFF` enum이라 자동 제외됨.
- `v_plant_running_state` 뷰는 `p-``vp-`**모두** 펌프로 집계하므로 진공 펌프도 `running_pumps`/`running_pump_tags`에 포함됨.
- 또는 `node_map_master.description` / `v_tag_summary.description`으로 펌프 여부 추가 확인.
### "운전 중인 플랜트" 판정 — `v_plant_running_state` 뷰 사용 (1순위)
@@ -173,6 +178,24 @@ ORDER BY area_code;
- "P6 펌프 어떤 게 돌아가?" → `SELECT running_pump_tags FROM v_plant_running_state WHERE area_code='P6'`
- "트립 펌프 있어?" → `SELECT area_code, tripped_pumps FROM v_plant_running_state WHERE tripped_pumps > 0`
### 실질 운전 판정 — 교차검증 뷰 (정밀, 선택)
펌프 상태 워드(RUN)만으로는 deadhead·센서오류·수집 stall(frozen 데이터) 등 **허위 운전**을 못 거른다. 연결된 유량계(kg/hr)·진공압(torr)을 **신선도 게이트(120초)** 와 함께 교차검증한 뷰:
- `v_plant_running_state_corroborated` — 펌프별 상세: `corroborated_status`, `flow_kg_hr`, `vacuum_torr`
- `v_plant_running_state_agg` — area별 집계: `confirmed_running`/`suspicious_running`/`stale_running`/`indeterminate_running` + `status`
| corroborated_status | 의미 |
|---|---|
| `CONFIRMED_RUNNING` | RUN + 신선한 유량 > 0.5 kg/hr (진공펌프: 진공압 < 300 torr) — **실질 운전** |
| `SUSPICIOUS_RUNNING` | RUN + 신선한데 유량 없음 / 진공 안 잡힘 — deadhead·센서오류·standby 의심 |
| `STALE` | RUN + 유량/진공 값이 **stale·frozen**(수집 지연/중단) — **판정 보류, 운전 단정 금지** |
| `INDETERMINATE_RUNNING` | RUN + 신호 매핑/데이터 없음 |
| `STOPPED` / `TRIPPED` | enum 기준 |
- "지금 6차 진짜 돌아가?" → `SELECT base_tag, corroborated_status, flow_kg_hr FROM v_plant_running_state_corroborated WHERE area_code='P6'`
- `STALE`가 많으면 "실시간 수집이 지연/중단된 상태"로 안내(운전 여부 단정 금지). 펌프-신호 매핑 보강은 `pump_corroboration_manual`(수동)·`v_pump_signal_map`(토폴로지).
## 시간대 및 날짜 처리
- DB 저장은 **UTC** 입니다 (`recorded_at`, `event_time` 모두 TIMESTAMPTZ).

View File

@@ -180,7 +180,11 @@ public interface IExperionRealtimeService
Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId);
}
public record RealtimeServiceStatus(bool Running, int SubscribedCount, string Message);
/// <param name="LastDataAgeSeconds">마지막 데이터 수신 후 경과초 (-1=수신 이력 없음). 신선도 진단용.</param>
/// <param name="Stalled">링크 이상(연결끊김/KeepAlive 중단/구독 publish 중단) 감지 후 재연결 대기 상태.</param>
public record RealtimeServiceStatus(
bool Running, int SubscribedCount, string Message,
int LastDataAgeSeconds = -1, bool Stalled = false);
// ── OPC UA Server ─────────────────────────────────────────────────────────────

View File

@@ -439,7 +439,8 @@ public class ExperionDbService : IExperionDbService
// v_plant_running_state 뷰 — area별 펌프 RUN/STOP/TRIP 집계.
// "데이터 갱신 = 운전 중"이라는 잘못된 휴리스틱 차단을 위해 펌프 enum 기반으로 판정.
// - 운전 중 판정: pv 값이 '{? | L-RUN | }' 또는 '{? | R-RUN | }'
// - 펌프 식별: base_tag가 'p-'로 시작하면서 pv가 펌프 enum 6종 중 하나 (panel/alarm 포인트 제외)
// - 펌프 식별: base_tag가 'p-'(공정펌프) 또는 'vp-'(진공펌프)로 시작하면서 pv가 펌프 enum 6종 중 하나
// (panel/alarm 포인트는 단순 STOP/OFF enum이라 L/R enum 정규식에서 자동 제외됨)
// - area_code: v_tag_summary.area의 '{12 | P6 | }' 형식에서 'P6' 추출
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state");
await _ctx.Database.ExecuteSqlRawAsync("""
@@ -452,7 +453,7 @@ public class ExperionDbService : IExperionDbService
pv
FROM v_tag_summary
WHERE area IS NOT NULL
AND base_tag LIKE 'p-%'
AND (base_tag LIKE 'p-%' OR base_tag LIKE 'vp-%')
AND pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
)
SELECT
@@ -820,6 +821,172 @@ public class ExperionDbService : IExperionDbService
""");
}
// ── 운전판정 교차검증(Corroboration) — 펌프 RUN을 유량(kg/hr)·진공압(torr)으로 검증 ──
// 펌프 enum(RUN)만으론 deadhead·센서오류·frozen 데이터 등 '허위 운전'을 못 거른다.
// 신선도 게이트: realtime_table.timestamp가 NOW()-120s 이내일 때만 값 신뢰(아니면 STALE=판정보류)
// 매핑: 토폴로지(FT.from_tag=펌프 → 같은 번호 FICQ 컨트롤러, 1:N) + 수동 예외 테이블
// (pid_equipment·from_tag/category 컬럼이 모두 보장된 이 지점 이후에 생성)
// 수동 매핑 — 토폴로지로 못 잡는 예외 (펌프-FT 사이 중간설비, VP↔진공압). 운전원 편집 가능.
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS pump_corroboration_manual (
id BIGSERIAL PRIMARY KEY,
pump_base_tag TEXT NOT NULL,
signal_tag TEXT NOT NULL,
signal_kind TEXT NOT NULL CHECK (signal_kind IN ('flow','vacuum')),
unit TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (pump_base_tag, signal_tag)
)
""");
// 수동 매핑 시드 (P6 알려진 예외). ON CONFLICT DO NOTHING → 부팅 시 운전원 수정 보존.
await _ctx.Database.ExecuteSqlRawAsync("""
INSERT INTO pump_corroboration_manual (pump_base_tag, signal_tag, signal_kind, unit, note) VALUES
('p-6102', 'ficq-6101.pv', 'flow', 'kg/hr', ' (P-6102FT-6101), 2-hop'),
('vp-6117', 'pica-6111.pv', 'vacuum', 'torr', 'C-6111 (PT-6111 PV)'),
('vp-6217', 'pica-6211.pv', 'vacuum', 'torr', 'C-6211 ')
ON CONFLICT (pump_base_tag, signal_tag) DO NOTHING
""");
// 펌프→신호 매핑 뷰 (토폴로지 유량 1:N + 수동)
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_pump_signal_map");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE VIEW v_pump_signal_map AS
SELECT lower(ft.from_tag) AS pump_base_tag,
'ficq-' || split_part(ft.tag_no, '-', 2) || '.pv' AS signal_tag,
'flow'::text AS signal_kind,
'kg/hr'::text AS unit,
'topology'::text AS mapping_source
FROM pid_equipment ft
WHERE ft.category = ''
AND ft.tag_no LIKE 'FT-%'
AND lower(ft.from_tag) LIKE 'p-%'
AND ft.from_tag NOT LIKE '%,%'
UNION ALL
SELECT pump_base_tag, signal_tag, signal_kind, unit, 'manual'
FROM pump_corroboration_manual
""");
// 계기 단위/레인지 — tag_metadata(EAV) 피벗·타입캐스트 (별도 테이블 없이 재사용).
// corroboration FS 5% 임계 + 향후 SP/제어 계산용. 단위/레인지는 메타갱신 시 자동 적재됨.
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_instrument_range");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE VIEW v_instrument_range AS
SELECT base_tag,
max(value) FILTER (WHERE attribute='units') AS unit,
max(CASE WHEN attribute='eulo' AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
THEN value::double precision END) AS eu_lo,
max(CASE WHEN attribute='euhi' AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
THEN value::double precision END) AS eu_hi
FROM tag_metadata
WHERE attribute IN ('units','eulo','euhi')
GROUP BY base_tag
""");
// 펌프별 교차검증 상세 — 신선도 게이트(120s) + STALE + 유량(FS 5%, fallback 0.5 kg/hr)·진공(300 torr)
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state_corroborated");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE VIEW v_plant_running_state_corroborated AS
WITH pump_base AS (
SELECT trim(split_part(area, '|', 2)) AS area_code,
area AS area_raw, base_tag, pv, description, sub_area
FROM v_tag_summary
WHERE area IS NOT NULL
AND (base_tag LIKE 'p-%' OR base_tag LIKE 'vp-%')
AND pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
),
sig_eval AS (
SELECT m.pump_base_tag, m.signal_kind,
(rt.tagname IS NOT NULL) AS has_row,
(rt.tagname IS NOT NULL
AND (NOW() - rt.timestamp) < interval '120 seconds') AS fresh,
CASE WHEN rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
THEN rt.livevalue::double precision END AS val,
-- '': + deadhead floor . = FS 5% [1~5 kg/hr]
-- ( : FS 2000 ~11 raw 5%FS=100 SUSPICIOUS로 )
(m.signal_kind='flow'
AND rt.tagname IS NOT NULL
AND (NOW() - rt.timestamp) < interval '120 seconds'
AND rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
AND rt.livevalue::double precision > GREATEST(1.0, LEAST(COALESCE(ir.eu_hi, 100) * 0.05, 5.0))) AS flow_ok,
-- '': + (torr; , 300 fallback)
(m.signal_kind='vacuum'
AND rt.tagname IS NOT NULL
AND (NOW() - rt.timestamp) < interval '120 seconds'
AND rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
AND rt.livevalue::double precision < 300) AS vac_ok
FROM v_pump_signal_map m
LEFT JOIN realtime_table rt ON rt.tagname = m.signal_tag
LEFT JOIN v_instrument_range ir ON ir.base_tag = split_part(m.signal_tag, '.', 1)
),
pump_sig AS (
SELECT pump_base_tag,
count(*) FILTER (WHERE signal_kind='flow') AS flow_mapped,
count(*) FILTER (WHERE signal_kind='flow' AND has_row) AS flow_have,
count(*) FILTER (WHERE signal_kind='flow' AND fresh) AS flow_fresh,
bool_or(flow_ok) AS any_flow_ok,
max(val) FILTER (WHERE signal_kind='flow' AND fresh) AS flow_max,
count(*) FILTER (WHERE signal_kind='vacuum') AS vac_mapped,
count(*) FILTER (WHERE signal_kind='vacuum' AND has_row) AS vac_have,
count(*) FILTER (WHERE signal_kind='vacuum' AND fresh) AS vac_fresh,
bool_or(vac_ok) AS any_vac_ok,
min(val) FILTER (WHERE signal_kind='vacuum' AND fresh) AS vac_min
FROM sig_eval GROUP BY pump_base_tag
)
SELECT
b.area_code, b.area_raw, b.base_tag, b.pv AS raw_pv, b.description, b.sub_area,
ps.flow_max AS flow_kg_hr, ps.vac_min AS vacuum_torr,
CASE
WHEN b.pv ~ '\|\s*[LR]-TRIP\s*\|' THEN 'TRIPPED'
WHEN b.pv ~ '\|\s*(L-STOP|R-STOP)\s*\|' THEN 'STOPPED'
WHEN b.base_tag LIKE 'vp-%' THEN
CASE
WHEN COALESCE(ps.vac_mapped,0)=0 OR COALESCE(ps.vac_have,0)=0 THEN 'INDETERMINATE_RUNNING'
WHEN ps.vac_fresh=0 THEN 'STALE'
WHEN ps.any_vac_ok THEN 'CONFIRMED_RUNNING'
ELSE 'SUSPICIOUS_RUNNING'
END
ELSE
CASE
WHEN COALESCE(ps.flow_mapped,0)=0 OR COALESCE(ps.flow_have,0)=0 THEN 'INDETERMINATE_RUNNING'
WHEN ps.flow_fresh=0 THEN 'STALE'
WHEN ps.any_flow_ok THEN 'CONFIRMED_RUNNING'
ELSE 'SUSPICIOUS_RUNNING'
END
END AS corroborated_status
FROM pump_base b
LEFT JOIN pump_sig ps ON ps.pump_base_tag = b.base_tag
WHERE b.area_code IS NOT NULL AND b.area_code <> ''
""");
// area별 집계 — overall은 CONFIRMED 기준 RUNNING, suspicious/stale는 부가 카운트(전체 오염 금지)
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state_agg");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE VIEW v_plant_running_state_agg AS
SELECT
area_code, MAX(area_raw) AS area_raw, COUNT(*) AS total_pumps,
COUNT(*) FILTER (WHERE corroborated_status='CONFIRMED_RUNNING') AS confirmed_running,
COUNT(*) FILTER (WHERE corroborated_status='SUSPICIOUS_RUNNING') AS suspicious_running,
COUNT(*) FILTER (WHERE corroborated_status='STALE') AS stale_running,
COUNT(*) FILTER (WHERE corroborated_status='INDETERMINATE_RUNNING') AS indeterminate_running,
COUNT(*) FILTER (WHERE corroborated_status='TRIPPED') AS tripped_pumps,
COUNT(*) FILTER (WHERE corroborated_status='STOPPED') AS stopped_pumps,
CASE
WHEN COUNT(*) FILTER (WHERE corroborated_status IN
('CONFIRMED_RUNNING','SUSPICIOUS_RUNNING','STALE','INDETERMINATE_RUNNING')) > 0 THEN 'RUNNING'
WHEN COUNT(*) FILTER (WHERE corroborated_status='TRIPPED') > 0 THEN 'TRIPPED'
ELSE 'STOPPED'
END AS status,
array_agg(base_tag) FILTER (WHERE corroborated_status='CONFIRMED_RUNNING') AS confirmed_tags,
array_agg(base_tag) FILTER (WHERE corroborated_status='SUSPICIOUS_RUNNING') AS suspicious_tags,
array_agg(base_tag) FILTER (WHERE corroborated_status='STALE') AS stale_tags
FROM v_plant_running_state_corroborated
WHERE area_code IS NOT NULL AND area_code <> ''
GROUP BY area_code ORDER BY area_code
""");
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
return true;
}

View File

@@ -39,10 +39,12 @@ public class ExperionHistoryService : BackgroundService
{
await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), stoppingToken);
// 실시간 구독이 OFF 상태이면 스냅샷 건너뜀
if (!_realtimeService.GetStatus().Running)
// 실시간 구독이 OFF이거나 링크 이상(stall)이면 스냅샷 건너뜀
// — stall 시 frozen 값을 정상 이력처럼 적재하던 문제 방지 (데이터 품질)
var rtStatus = _realtimeService.GetStatus();
if (!rtStatus.Running || rtStatus.Stalled)
{
_logger.LogDebug("[HistoryService] 구독 중지 상태 — 스냅샷 건너뜀");
_logger.LogDebug("[HistoryService] 구독 중지/링크 이상(Stalled={Stalled}) — 스냅샷 건너뜀", rtStatus.Stalled);
continue;
}

View File

@@ -45,7 +45,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
private int _subscribedCount;
private string _statusMsg = "중지됨";
private ExperionServerConfig? _currentCfg;
private volatile bool _restarting = false; // 재진입 방지 플래그
private volatile bool _stalled = false; // 링크 비정상(silent stall) 감지 여부
private long _lastNotificationTicks; // 마지막 데이터 수신 시각(UTC ticks) — 신선도 워치독
private volatile string _linkFault = ""; // 최근 감지된 링크 이상 사유
// 자동 재시작 플래그 파일 경로
private static readonly string FlagPath =
@@ -65,212 +67,134 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
public Task StartAsync(CancellationToken cancellationToken)
{
// 앱 기동 시 플래그 파일이 있으면 자동 구독 시작 (최대 3회 재시도, 5초 간격)
if (!File.Exists(FlagPath)) return;
const int maxRetries = 3;
const int retryDelayMs = 5_000;
bool connected = false;
// 부팅 비블로킹: 자동시작 플래그가 있으면 백그라운드 supervisor만 띄우고 즉시 반환.
// 연결/재구독/재시도는 supervisor가 앱 부팅과 무관하게 전담한다 (부팅 지연 0).
if (!File.Exists(FlagPath)) return Task.CompletedTask;
try
{
var json = await File.ReadAllTextAsync(FlagPath, cancellationToken);
var json = File.ReadAllText(FlagPath);
var cfg = JsonSerializer.Deserialize<ExperionServerConfig>(json);
if (cfg == null) return;
if (cfg == null) return Task.CompletedTask;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
_logger.LogInformation("[Realtime] 자동 재시도 {Attempt}/{Max} — {Url}", attempt, maxRetries, cfg.EndpointUrl);
try
{
await TryConnectAsync(cfg, cancellationToken);
connected = true;
_logger.LogInformation("[Realtime] 자동 연결 성공 (시도 {Attempt}/{Max})", attempt, maxRetries);
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 자동 연결 실패 (시도 {Attempt}/{Max})", attempt, maxRetries);
if (attempt < maxRetries)
{
try { await Task.Delay(retryDelayMs, cancellationToken); } catch { return; }
}
}
}
if (!connected)
{
_logger.LogWarning("[Realtime] {Max}회 시도 후 연결 실패 — 자동 재시작 플래그 삭제", maxRetries);
if (File.Exists(FlagPath)) File.Delete(FlagPath);
_statusMsg = $"연결 실패 ({maxRetries}회 시도) — 수동 시작 필요";
}
StartSupervisor(cfg);
_logger.LogInformation("[Realtime] 자동시작 — 백그라운드 supervisor 기동 (부팅 비블로킹): {Url}", cfg.EndpointUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 자동시작 플래그 읽기 실패 — 무시");
}
}
/// <summary>OPC UA 연결 및 구독 설정 시도 (예외 발생 시 정리 후 재던지기)</summary>
private async Task TryConnectAsync(ExperionServerConfig cfg, CancellationToken ct)
{
try
{
_currentCfg = cfg;
_cts = new CancellationTokenSource();
_monitorTask = Task.Run(() => RunLoopOnceAsync(_cts.Token), _cts.Token);
// 플래그 파일 저장 (앱 재기동 시 자동 재시작용)
try
{
var json = JsonSerializer.Serialize(cfg);
await File.WriteAllTextAsync(FlagPath, json);
}
catch (Exception ex) { _logger.LogWarning(ex, "[Realtime] 플래그 파일 저장 실패 (무시)"); }
// 연결 성공 확인을 위해 잠시 대기
await Task.Delay(2_000, ct);
if (!_running) throw new InvalidOperationException("연결이 설정되지 않았습니다.");
}
catch
{
// 연결 실패 시 정리
_cts?.Cancel();
_cts = null;
_currentCfg = null;
throw;
}
}
/// <summary>단일 연결-구독-유지 루프 (재연결 없이 한 번만 시도)</summary>
private async Task RunLoopOnceAsync(CancellationToken ct)
{
try
{
await ConnectAndSubscribeAsync(ct);
// 세션이 살아있는 동안 KeepAlive 대기
while (!ct.IsCancellationRequested &&
_session != null && _session.Connected)
{
await Task.Delay(5_000, ct);
}
}
catch (OperationCanceledException) { /* 정상 종료 */ }
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 연결 오류 — 재연결 안 함 (자동 시작 모드)");
await CleanupSessionAsync();
throw; // 호출자에게 실패 전달
}
finally
{
_running = false;
if (_statusMsg != "중지됨") _statusMsg = "중지됨";
_logger.LogWarning(ex, "[Realtime] 자동시작 플래그 읽기 실패 — 무시");
}
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
// 앱 종료(Ctrl+C 등) 시: 플래그 파일은 유지 → 재기동 시 자동 재시작
_cts?.Cancel();
var tasks = new[] { _monitorTask, _flushTask }
.Where(t => t != null).Select(t => t!).ToArray();
if (tasks.Length > 0)
{
try
{
// 종료 시 대기 시간을 2초로 단축하여 빠른 셧다운 유도
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) { _logger.LogDebug(ex, "[Realtime] StopAsync 대기 중 타임아웃 또는 취소 발생"); }
}
_running = false;
_logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)");
// 앱 종료(Ctrl+C 등): supervisor만 정지하고 플래그는 유지 → 재기동 시 자동 재개.
await StopInternalAsync(deleteFlag: false, ct: cancellationToken);
_logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동시작 플래그 유지)");
}
/// <summary>의도=ON: 백그라운드 supervisor 루프를 새로 기동한다 (호출 전 기존 것을 정지해 둘 것).</summary>
private void StartSupervisor(ExperionServerConfig cfg)
{
_currentCfg = cfg;
_cts = new CancellationTokenSource();
_monitorTask = Task.Run(() => SuperviseAsync(_cts.Token));
}
// ── IExperionRealtimeService ──────────────────────────────────────────────
public async Task StartAsync(ExperionServerConfig cfg)
{
if (_running || _restarting)
// 이미 supervisor가 돌고 있으면 정지 후 교체 (플래그는 아래서 새 cfg로 덮어씀)
if (_monitorTask is { IsCompleted: false })
{
_logger.LogWarning("[Realtime] 이미 실행 중 또는 재시작 중. 무시합니다.");
return;
}
_restarting = true;
try
{
if (_running)
{
_logger.LogWarning("[Realtime] 이미 실행 중. 재시작합니다.");
await StopAsync();
}
}
finally
{
_restarting = false;
_logger.LogInformation("[Realtime] 이미 실행 중 — 정지 후 재시작합니다.");
await StopInternalAsync(deleteFlag: false);
}
// 플래그 파일 저장 (앱 재기동 시 자동 재시작용)
// 플래그 저장 (의도=ON, 부팅 시 자동 재용)
try
{
var json = JsonSerializer.Serialize(cfg);
await File.WriteAllTextAsync(FlagPath, json);
_logger.LogInformation("[Realtime] 자동시작 플래그 저장: {Path}", FlagPath);
_logger.LogInformation("[Realtime] 자동시작 플래그 저장: {Path}", FlagPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 플래그 파일 저장 실패 (무시)");
}
_currentCfg = cfg;
_cts = new CancellationTokenSource();
_monitorTask = Task.Run(() => RunLoopAsync(_cts.Token));
_logger.LogInformation("[Realtime] 구독 시작 요청: {Url}", cfg.EndpointUrl);
StartSupervisor(cfg);
_logger.LogInformation("[Realtime] 구독 supervisor 시작 요청: {Url}", cfg.EndpointUrl);
}
public async Task StopAsync()
{
if (_restarting)
// 운전원 '구독중지': 의도=OFF → 플래그 삭제 + supervisor 정지 (자동 재개 안 함)
await StopInternalAsync(deleteFlag: true);
_logger.LogInformation("[Realtime] 구독 중지 완료 (운전원 정지 — 자동시작 비활성화)");
}
/// <summary>supervisor·flush 정지 및 세션 정리. deleteFlag=true면 자동시작 플래그도 삭제(의도=OFF).</summary>
private async Task StopInternalAsync(bool deleteFlag, CancellationToken ct = default)
{
if (deleteFlag)
{
_logger.LogWarning("[Realtime] 재시작 중이므로 StopAsync 무시 (restarting 플래그 취소)");
return;
}
// 플래그 파일 삭제 (자동 재시작 비활성화)
try
{
if (File.Exists(FlagPath)) File.Delete(FlagPath);
_logger.LogInformation("[Realtime] 자동 재시작 플래그 삭제");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 플래그 파일 삭제 실패 (무시)");
try
{
if (File.Exists(FlagPath)) File.Delete(FlagPath);
_logger.LogInformation("[Realtime] 자동시작 플래그 삭제");
}
catch (Exception ex) { _logger.LogWarning(ex, "[Realtime] 플래그 파일 삭제 실패 (무시)"); }
}
_cts?.Cancel();
var tasks = new List<Task>();
if (_monitorTask != null) tasks.Add(_monitorTask);
if (_flushTask != null) tasks.Add(_flushTask);
if (tasks.Count > 0)
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
var tasks = new[] { _monitorTask, _flushTask }
.Where(t => t != null).Select(t => t!).ToArray();
if (tasks.Length > 0)
{
try
{
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
}
catch (Exception ex) { _logger.LogDebug(ex, "[Realtime] 정지 대기 중 타임아웃/취소"); }
}
await CleanupSessionAsync();
_pendingUpdates.Clear();
_cts?.Dispose();
_cts = null;
_monitorTask = null;
_flushTask = null;
_running = false;
_stalled = false;
_subscribedCount = 0;
_statusMsg = "중지됨";
_logger.LogInformation("[Realtime] 구독 중지 완료");
}
/// <summary>링크 건강 점검 — 정상이면 null, 이상이면 사유. silent stall(publish 중단)까지 감지.</summary>
private string? GetLinkFault()
{
var s = _session;
if (s == null || !s.Connected) return "세션 끊김";
if (s.KeepAliveStopped) return "KeepAlive 중단";
var sub = _subscription;
if (sub != null && sub.PublishingStopped) return "구독 publish 중단";
return null;
}
public RealtimeServiceStatus GetStatus()
=> new(_running, _subscribedCount, _statusMsg);
{
var ticks = Interlocked.Read(ref _lastNotificationTicks);
int ageSec = ticks == 0
? -1
: (int)Math.Max(0, (DateTime.UtcNow.Ticks - ticks) / TimeSpan.TicksPerSecond);
return new(_running, _subscribedCount, _statusMsg, ageSec, _stalled);
}
public async Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId)
{
@@ -327,31 +251,50 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
// ── 내부 루프 ─────────────────────────────────────────────────────────────
private async Task RunLoopAsync(CancellationToken ct)
// supervisor 루프 — 부팅과 무관하게 의도=ON 동안 연결·재구독·재시도를 전담.
// 성공 → 건강 감시(연결끊김/KeepAlive 중단/구독 publish 중단=silent stall) → 이상 시 재연결
// 실패/이상 → RetryDelay 후 무한 재시도 (3회-후-포기 없음, 주기적 자동 재개)
private async Task SuperviseAsync(CancellationToken ct)
{
const int RetryDelayMs = 30_000; // 연결 실패/이상 후 재시도 주기
const int HealthPollMs = 5_000; // 건강 점검 주기
// flush 루프는 supervisor 수명 동안 단 1회만 기동 (재연결마다 누적되던 버그 차단)
_flushTask = Task.Run(() => FlushLoopAsync(ct), ct);
while (!ct.IsCancellationRequested)
{
try
{
await ConnectAndSubscribeAsync(ct);
// 세션이 살아있는 동안 KeepAlive 대기
while (!ct.IsCancellationRequested &&
_session != null && _session.Connected)
{
await Task.Delay(5_000, ct);
}
// 연결 유지 동안 건강 감시
string? fault = null;
while (!ct.IsCancellationRequested && (fault = GetLinkFault()) == null)
await Task.Delay(HealthPollMs, ct);
if (ct.IsCancellationRequested) break;
_running = false;
_stalled = true;
_linkFault = fault ?? "알 수 없음";
_statusMsg = $"재연결 대기 중: {_linkFault}";
_logger.LogWarning("[Realtime] 링크 이상 감지 ({Fault}) — {Sec}초 후 재연결", _linkFault, RetryDelayMs / 1000);
await CleanupSessionAsync();
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_running = false;
_stalled = true;
_linkFault = ex.Message;
_statusMsg = $"재연결 대기 중: {ex.Message}";
_logger.LogWarning(ex, "[Realtime] 연결 오류, 30초 후 재시도");
_logger.LogWarning(ex, "[Realtime] 연결 오류 — {Sec}초 후 재시도", RetryDelayMs / 1000);
await CleanupSessionAsync();
try { await Task.Delay(30_000, ct); }
catch (OperationCanceledException) { break; }
}
try { await Task.Delay(RetryDelayMs, ct); }
catch (OperationCanceledException) { break; }
}
_running = false;
@@ -423,16 +366,19 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
_subscribedCount = points.Count;
_running = true;
_stalled = false;
_linkFault = "";
Interlocked.Exchange(ref _lastNotificationTicks, DateTime.UtcNow.Ticks);
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
_logger.LogInformation("[Realtime] 구독 완료: {Count}개 포인트", _subscribedCount);
// 배치 flush 태스크 시작 (콜백 → dictionary → 500ms 단위 배치 DB 업데이트)
_flushTask = Task.Run(() => FlushLoopAsync(ct), ct);
// flush 루프는 SuperviseAsync에서 1회만 기동한다 (여기서 시작하지 않음 — 재연결 누적 방지)
}
// 콜백: Task.Run 없이 dictionary에만 기록 (최신 값 덮어쓰기)
private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
{
// 데이터 수신 시각 기록 — 신선도/상태 노출용 (GetLinkFault는 SDK의 PublishingStopped를 사용)
Interlocked.Exchange(ref _lastNotificationTicks, DateTime.UtcNow.Ticks);
foreach (var val in item.DequeueValues())
{
var nodeId = item.DisplayName;

View File

@@ -8,7 +8,9 @@ using Npgsql;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area)를 OPC UA에서 읽어 tag_metadata 테이블에 저장/갱신
/// 메타데이터(desc, area, 단위/레인지 euhi·eulo·units)를 OPC UA에서 읽어 tag_metadata에 저장/갱신.
/// 단위·레인지는 corroboration 임계(FS 5%)·향후 SP/제어 계산용. 별도 테이블 없이 EAV(tag_metadata) 재사용 →
/// PointBuilder 작성·수동 메타갱신 트리거에 자동 편승. 타입 접근은 v_instrument_range 뷰.
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
@@ -17,9 +19,10 @@ public class MetadataLoaderService : IMetadataLoaderService
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록 (state0~7descriptor 제거 — pv 값에서 파싱)
// euhi/eulo/units = 점 레벨 EU 레인지/단위 (node_map_master의 자식 노드명과 일치). 'qv.euhi' 등 서브속성은 정확매칭으로 제외.
private static readonly string[] MetaAttributes =
{
"desc", "area"
"desc", "area", "euhi", "eulo", "units"
};
public MetadataLoaderService(

View File

@@ -16,7 +16,7 @@
.docs-layout {
display: flex;
gap: 0;
height: calc(100vh - 116px);
height: calc(100vh - 126px);
min-height: 560px;
border: 1px solid var(--bd);
border-radius: var(--r);
@@ -277,6 +277,7 @@
border-radius: 8px;
overflow: auto;
line-height: 1.5;
font-family: var(--fm);
}
.md-body pre code {
background: transparent;

View File

@@ -332,7 +332,76 @@ function docsExportPdf() {
}
/* ── 마크다운 렌더 파이프라인 ──────────────────────────────── */
/// box-drawing 문자(┌─┬┐│├─┼┤└─┴┘) 라인을 감지하여 표는 GFM pipe table(|---|)로,
/// 일반 box line은 ``` fence로 감싸 고정폭 렌더링 보장
function docsWrapBoxDrawing(text) {
const BOX_RE = /^[ \t]*[┌┐└┘├┤┬┴┼│─━]/;
const lines = text.split('\n');
const out = [];
let inBlock = false;
let buf = [];
for (const line of lines) {
const isBox = BOX_RE.test(line);
if (isBox && !inBlock) { inBlock = true; buf = [line]; }
else if (isBox && inBlock) { buf.push(line); }
else {
if (inBlock) { out.push(docsBoxBlockToGfm(buf)); inBlock = false; buf = []; }
out.push(line);
}
}
if (inBlock) out.push(docsBoxBlockToGfm(buf));
return out.join('\n');
}
/// box-drawing block이 표(┬┴┼ + │)면 GFM pipe table로, 아니면 ``` fence로
function docsBoxBlockToGfm(buf) {
var joined = buf.join('');
if (!/[┬┴┼]/.test(joined) || !/│/.test(joined))
return '```\n' + buf.join('\n') + '\n```';
// 각 데이터 행별로 자신의 │ 위치로 셀 추출 (행 간 컬럼 경계 불일치 대응)
var allRows = [];
for (var i = 0; i < buf.length; i++) {
if (!/^[ \t]*│/.test(buf[i])) continue;
var poses = [];
for (var j = 0; j < buf[i].length; j++) {
if (buf[i][j] === '│') poses.push(j);
}
if (poses.length < 2) continue;
var cells = [];
for (var c = 0; c < poses.length - 1; c++)
cells.push(buf[i].substring(poses[c] + 1, poses[c + 1]).trim());
allRows.push(cells);
}
if (allRows.length === 0) return '```\n' + buf.join('\n') + '\n```';
// 여분 줄 병합 (≤1 non-empty cell)
var merged = [allRows[0]];
for (var r = 1; r < allRows.length; r++) {
var nonEmpty = 0, lastNec = -1;
var numCols = allRows[r].length;
for (var c = 0; c < numCols; c++) { if (allRows[r][c] !== '') { nonEmpty++; lastNec = c; } }
if (nonEmpty <= 1) {
if (lastNec >= 0 && merged[merged.length - 1][lastNec] !== '')
merged[merged.length - 1][lastNec] += ' ' + allRows[r][lastNec];
} else {
merged.push(allRows[r]);
}
}
var result = [];
var hdr = merged[0];
result.push('| ' + hdr.join(' | ') + ' |');
result.push('|' + ' --- |'.repeat(hdr.length));
for (var r = 1; r < merged.length; r++) {
result.push('| ' + merged[r].join(' | ') + ' |');
}
return result.join('\n');
}
function docsRenderMarkdownInto(el, text) {
text = docsWrapBoxDrawing(text);
const rawHtml = marked.parse(text, { gfm: true, breaks: false });
el.innerHTML = DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['target'] });
docsExternalLinks(el);
@@ -430,6 +499,10 @@ function docsEnsureMdLibs() {
docsLoadCss('/lib/highlight-github.min.css');
docsLoadCss('/lib/katex/katex.min.css');
await docsLoadScript('/lib/marked.min.js');
// raw HTML escape — 터미널 덤프에 포함된 <div class="hidden"> 등이 DOM을 깨는 것 방지
marked.use({
renderer: { html: s => s.replace(/</g, '&lt;').replace(/>/g, '&gt;') }
});
await docsLoadScript('/lib/purify.min.js');
await docsLoadScript('/lib/highlight.min.js');
await docsLoadScript('/lib/katex/katex.min.js');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,491 @@
# 웹UI 개선 — 코딩 실행 계획 (by Qwen27B)
> 작성: 2026-05-24 · 상태: **감리 정정 반영 — 실행 전 §0.5 필독** · 대상: `src/Web/wwwroot/`
> 기반: `웹UI-개선플랜-byOPUS.md` + `diagnosis-checklist.md` 규칙 준수
> 감리: 2026-05-24 실코드 대조 — 치명 결함 3건 발견·정정(§0.5)
---
## 0. 진단 요약 (Checklist STEP 1~4 적용)
### STEP 1 — 맥락 파악
| 항목 | 내용 |
|------|------|
| 파일 역할 | ASP.NET Static Files로 서빙되는 바닐라 JS SPA 프론트엔드 |
| 아키텍처 레이어 | 진입점 (UI). 백엔드 API(`/api/*`)에 fetch로 호출 |
| 관련 문서 | AGENTS.md(빌드/런/배포), CODING_CONVENTIONS.md(JSON camelCase 규칙) |
| 제약 | 빌드 스텝 0, 정적 파일만, `dotnet publish`로 배포 |
### STEP 2 — 구조 탐색
```
wwwroot/
├── index.html (1,761줄, pane 16개 + 모달 2개) ← 초안 15개는 오기(docs=Tab16 누락)
├── js/
│ ├── app.js (5,148줄, 전 탭 로직)
│ ├── docs.js (712줄, ✅ 분리된 모범 사례) ← 계획 초안 571줄은 오기(D6)
│ ├── pid-viewer.js (P&ID 뷰어)
│ └── xlsx.full.min.js
├── css/
│ ├── style.css (2,231줄)
│ ├── docs.css
│ └── pid_graph.css
└── lib/ (uPlot 등 외부 라이브러리)
```
### STEP 3 — 코드 읽기 (핵심 발견)
1. **탭 라우터**: `app.js:5-20``document.querySelectorAll('.nav-item')` click listener. 진입 훅 4개: `opcsvr`, `t2s`, `fast`, `docs`
2. **docs.js 패턴**: `docsInit()` 진입 함수 + `docsState.inited` 플래그로 1회 초기화. 전역 `esc()`/`kbToken` 재사용
3. **모달 2개**: fastRecord 신규 세션 모달(`index.html:1679-1724`), 날짜 선택 팝업(`index.html:1727-1754`) — pane 외부에 위치
4. **datepicker**: `app.js:1660-1779` — 여러 탭에서 공유하는 공용 컴포넌트
### STEP 4 — 호출 계층 지도
```
사용자 클릭 (nav-item)
→ app.js:5 click listener
→ tab 클래스 토글
→ if (tab === 'opcsvr') srvLoad() ← API 호출
→ if (tab === 't2s') t2sInitMode() ← UI 모드 설정
→ if (tab === 'fast') fastSessionsLoad() ← API 호출
→ if (tab === 'docs') docsInit() ← API 호출 (docs.js)
```
---
## 0.5 감리 정정 (실코드 대조 — 2026-05-24) ⚠️ 실행 전 필독
> 본 계획을 `src/Web/wwwroot/` 실제 코드와 1:1 대조한 결과, **실행을 막는 치명 결함 3건** + 정합성 결함 3건 발견. 아래 정정을 반영한 후 실행할 것. 헤더/Phase 본문의 라인 번호는 대부분 정확하나, 분리 전제와 로더 코드가 틀렸다.
### 결함 요약
| # | 심각도 | 위치 | 문제 | 정정 |
|---|--------|------|------|------|
| D1 | 🔴 치명 | §4.2, §4.3 | app.js는 함수 선언만 있지 않음 — **모듈레벨 상태변수 ~25개**와 **로드 시 실행되는 최상위 문**(`certStatus()` 2593, `btn-fast-*`/`btn-pid-*` addEventListener 3001~3640)이 산재. "함수 단위 이동 + `_t2sLastResult` 1개만 이전"으로는 누락/오작동 | 상태변수·최상위 문 전수 이전(아래 인벤토리). DOM 배선은 init 훅 내부로 |
| D2 | 🔴 치명 | §2.2, §3.3, §5.1 | 로더에 **기동 코드 부재**`loadPane`/`activateTab`이 click에만 묶여, 페이지 로드 시 기본 활성 pane(`pane-cert`)이 빈 채로 남음. §3.3의 "eager 주입"도 §2.2 코드엔 없음 | DOMContentLoaded에서 기본 pane 주입+init(아래 정정 로더) |
| D3 | 🔴 치명 | §2.2, §5.2 | pane이 async fetch 파셜이 되면, fast/pid 탭의 **최상위 addEventListener 배선이 pane DOM 주입보다 먼저 실행**돼 무력화(`?.`로 silent no-op) → 버튼 사망. eager(Phase1)에서도 fetch 비동기라 동일 발생 | 해당 배선을 fast/pid의 **1회 setup 훅**으로 이동 |
| D4 | 🟡 정합성 | §2.3 ↔ §4.2#3 | 로드 순서 모순 — §4.2#3 "datepicker가 hist/evt보다 먼저" vs §2.3 datepicker 맨 뒤. 실제로는 모두 `function` 선언(호이스팅)이라 **순서 무관** | §4.2#3 폐기, §2.3 순서 유지 |
| D5 | 🟡 정합성 | §4.1 | "원본 라인" 범위가 **상호 겹침**(분할표가 아님): hist `1120-1658` ⊃ evt `1349-1503`; t2s `1868-2643` ⊃ core helper `2599-2643`; conn의 `llm*` 2개(160,177)가 llmchat `llm*`와 충돌 | 범위는 참고용. prefix grep 기계 분리 금지, 함수명 단위 판단 |
| D6 | ⚪ 경미 | §0 STEP2, §6.1 | 수치 오기 — `docs.js` 571줄 → **712줄**; pane **15개 → 16개**(docs=Tab16); "15개 fetch" → 16개 | 정정(완료) |
### 실제 함수 배치 — 탭별 연속 블록이 아님 (D5 근거)
app.js 함수는 탭별로 깔끔히 연속되지 **않으며** 교차(interleave)된다:
- hist/ht: `1120-1332` + **(evt `1349-1503` 끼어듦)** + `1505-1658`
- core helper `fmtTs`/`fmtVal`/`parseEnumPv`: `2599-2643` (t2s 영역 한가운데)
- `llmLoadConfig`/`llmSaveModelConfig`: `160`/`177` (conn 영역 — **llmchat 아님**)
- `certStatus();` 호출: `2593` (t2s 영역 — **cert 아님**)
**`grep '^function llm'` 같은 기계 분리 금지.** 함수명 단위로 소속 탭을 직접 판단할 것.
### 모듈레벨 상태변수 전수 인벤토리 (각 소속 파일 상단으로 이전 — D1)
| 변수 | 라인 | 소속 파일 |
|------|------|-----------|
| `_t2sLastResult` | 2 | t2s.js |
| `_nmOffset` / `_nmTotal` / `_nmLimit` | 492-494 | nm-dash.js |
| `PB_GROUPS` / `pbPreviewData` / `SUBAREA_OPTIONS` | 613-614, 1022 | pb.js |
| `HIST_TAG_IDS` | 1117 | hist.js |
| `_dtp` / `_DTP_DAYS` | 1654, 1658 | datepicker.js |
| `_srvPollTimer` | 1777 | opcsvr.js |
| `t2sMode` | 1863 | t2s.js |
| `fastCurrentSessionId` / `fastChart` / `fastLivePollTimer` / `fastChartTagNames` / `fastDbConnected` | 2640-2644 | fast.js |
| `pidCurrentPage` / `pidPageSize` / `pidLastResult` / `pidExtracting` / `pidElapsedInterval` / `pidPrefixPanelVisible` / `CATEGORY_META` / `CATEGORY_ORDER` | 3100-3460 | pid.js |
| `llmSessions` / `llmActiveSessionId` / `llmAbortController` / `llmIsStreaming` / `llmType` / `llmUseTools` / `llmAgentMode` / `llmMcpTools` / `LLM_STARTER_CHIPS` / `llmKbDocMap` / `_llmSparkSeq` / `_llmSparkCache` / `LLM_MAX_HISTORY` / `LLM_SUMMARY_KEEP` | 3670-4262 | llmchat.js |
| `kbToken` / `kbExpiresAt` / `kbCollections` / `kbPollTimer` | 4673-4676 | kbadmin.js |
### 로드 시 실행되는 최상위 문 (init 훅으로 이전 — D1/D3)
| 문 | 라인 | 조치 |
|----|------|------|
| `.nav-item` 탭 라우터 | 5 | core.js (계획대로) |
| `certStatus();` | 2593 | **cert setup 훅 신설**(1회) |
| `btn-fast-*` addEventListener ×7 | 3001-3079 | fast setup 훅(1회, 중복배선 가드) |
| `[href="#pane-fast"]` | 3093 | fast setup 훅 |
| `btn-pid-export-*` ×2 | 3613-3626 | pid setup 훅(1회) |
| `[data-tab="pid"/"llmchat"/"kbadmin"]` | 3640, 3700, 4694 | **nav 요소 대상이라 shell에 잔존** → core.js에 통합 가능(pane DOM 비의존) |
### 정정 로더 (§2.2 · §5.1 대체 — D2/D3 반영)
핵심: ① 진입 시 **DOM 주입 완료 후 init**, ② **1회 setup(배선)****매 진입 enter(데이터 로드)** 분리(원동작 보존), ③ **기동 코드로 기본 pane 초기화**.
```javascript
// core.js — 정정판 로더
const paneCache = new Map(); // src → html
const paneReady = new Set(); // pane DOM 주입 완료
const paneSetup = new Set(); // 1회 배선 완료(중복 addEventListener 방지)
// 1회만: pane DOM에 의존하는 최상위 배선 (D1/D3에서 이전)
const paneWire = {
cert: () => certStatus(), // 기존 최상위 호출(2593)
fast: () => fastWire(), // btn-fast-* 배선(3001-3093)을 담은 함수
pid: () => pidWire(), // btn-pid-export-* 배선(3613-3626)
};
// 매 진입: 기존 탭 라우터의 init (데이터 갱신 등)
const paneEnter = {
opcsvr: () => srvLoad(),
t2s: () => t2sInitMode(),
fast: () => fastSessionsLoad(),
docs: () => docsInit(), // docsState.inited 내부 가드 있음
};
async function loadPane(tab) {
const el = document.getElementById(`pane-${tab}`);
if (!el || !el.dataset.src || paneReady.has(tab)) return;
if (!paneCache.has(tab)) {
try { paneCache.set(tab, await (await fetch(el.dataset.src)).text()); }
catch { el.innerHTML = `<div class="card"><p style="color:var(--red)">패널 로딩 실패: ${esc(el.dataset.src)}</p></div>`; return; }
}
el.innerHTML = paneCache.get(tab);
paneReady.add(tab);
}
async function activateTab(tab) {
await loadPane(tab); // ① DOM 주입 완료까지 대기
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
document.getElementById(`pane-${tab}`)?.classList.add('active');
if (!paneSetup.has(tab)) { paneWire[tab]?.(); paneSetup.add(tab); } // ② 1회 배선
paneEnter[tab]?.(); // 매 진입
}
document.querySelectorAll('.nav-item').forEach(item =>
item.addEventListener('click', () => activateTab(item.dataset.tab)));
// ③ 기동(D2): 기본 활성 pane 초기화 — 없으면 첫 화면이 빈 채로 뜸
document.addEventListener('DOMContentLoaded', () => {
const def = document.querySelector('.nav-item.active')?.dataset.tab || 'cert';
activateTab(def);
// Phase 1(eager): 나머지도 미리 주입하려면 ↓ 주석 해제
// document.querySelectorAll('.pane[data-src]').forEach(p => loadPane(p.id.slice(5)));
});
```
> 주의: `fastWire`/`pidWire`는 기존 최상위 `addEventListener` 블록을 그대로 감싼 함수다. `?.` 옵셔널 체이닝은 유지하되, **반드시 pane DOM 주입 후**(=`paneWire`에서) 호출돼야 배선이 성립한다.
---
## 1. 실행 전략
**A안(HTML 파셜 fetch) + app.js 탭별 분리** 채택.
| 단계 | 내용 | 파일 변경 |
|------|------|-----------|
| Phase 0 | `core.js` 생성 + 파셜 로더 | `js/core.js`(신규), `index.html` |
| Phase 1 | pane HTML을 `panes/*.html`으로 eager 분리 | `panes/*.html`(15개 신규), `index.html` 수정 |
| Phase 2 | app.js를 탭별 JS로 분리 | `js/*.js`(15개 신규), `app.js` 삭제 |
| Phase 3 | 지연로딩 전환 | `core.js` 수정 |
---
## 2. Phase 0 — `core.js` + 파셜 로더
### 2.1 `js/core.js` 생성
`app.js`의 공용 함수를 추출하여 `core.js`에 배치:
| 함수 | 현재 위치 | 역할 |
|------|-----------|------|
| `esc(s)` | app.js:23 | HTML 이스케이프 |
| `setGlobal(state, text)` | app.js:28 | 전역 상태 표시 |
| `log(id, lines)` | app.js:33 | 로그박스 출력 |
| `api(method, path, body)` | app.js:41 | fetch 래퍼 |
| `fmtTs(v)` | app.js:2599 | 타임스탬프 포맷 |
| `fmtVal(v)` | app.js:2616 | 값 포맷 |
| `parseEnumPv(v)` | app.js:2630 | Enum PV 파싱 |
### 2.2 파셜 로더 구현
> ⚠️ **아래 초안 로더는 결함(D2 기동 부재 · D3 배선 타이밍)** 있음. 실제 구현은 §0.5 "정정 로더"를 사용할 것. 초안은 설계 의도 참고용으로만 남김.
```javascript
// core.js — 파셜 로더 (초안 · §0.5 정정판으로 대체)
const paneCache = new Map();
const paneInit = {
opcsvr: () => srvLoad(),
t2s: () => t2sInitMode(),
fast: () => fastSessionsLoad(),
docs: () => docsInit(),
};
async function loadPane(tab) {
const el = document.getElementById(`pane-${tab}`);
if (!el || !el.dataset.src) return;
if (!paneCache.has(tab)) {
try {
paneCache.set(tab, await (await fetch(el.dataset.src)).text());
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">패널 로딩 실패: ${esc(el.dataset.src)}</p></div>`;
return;
}
}
el.innerHTML = paneCache.get(tab);
}
async function activateTab(tab) {
await loadPane(tab);
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
const el = document.getElementById(`pane-${tab}`);
if (el) el.classList.add('active');
paneInit[tab]?.();
}
document.querySelectorAll('.nav-item').forEach(item =>
item.addEventListener('click', () => activateTab(item.dataset.tab)));
```
### 2.3 `index.html` 스크립트 로드 순서
```html
<script src="/lib/uPlot.iife.min.js"></script>
<script src="/js/xlsx.full.min.js"></script>
<script src="/js/core.js"></script> <!-- 1. 공용 + 파셜 로더 -->
<script src="/js/cert.js"></script> <!-- 2. 탭별 JS들 -->
<script src="/js/conn.js"></script>
<script src="/js/crawl.js"></script>
<script src="/js/db.js"></script>
<script src="/js/nm-dash.js"></script>
<script src="/js/pb.js"></script>
<script src="/js/hist.js"></script>
<script src="/js/opcsvr.js"></script>
<script src="/js/t2s.js"></script>
<script src="/js/fast.js"></script>
<script src="/js/pid.js"></script>
<script src="/js/evt.js"></script>
<script src="/js/llmchat.js"></script>
<script src="/js/kbadmin.js"></script>
<script src="/js/write.js"></script>
<script src="/js/datepicker.js"></script> <!-- 공용 컴포넌트 -->
<script src="/js/docs.js"></script> <!-- 이미 분리됨 -->
```
---
## 3. Phase 1 — pane HTML eager 분리
### 3.1 `index.html` 셸화
1,761줄 → ~120줄로 축소. `<main>` 내 pane을 `data-src` 셸로 대체:
```html
<main class="content">
<section class="pane active" id="pane-cert" data-src="/panes/cert.html"></section>
<section class="pane" id="pane-conn" data-src="/panes/conn.html"></section>
<section class="pane" id="pane-crawl" data-src="/panes/crawl.html"></section>
<section class="pane" id="pane-db" data-src="/panes/db.html"></section>
<section class="pane" id="pane-nm-dash" data-src="/panes/nm-dash.html"></section>
<section class="pane" id="pane-pb" data-src="/panes/pb.html"></section>
<section class="pane" id="pane-hist" data-src="/panes/hist.html"></section>
<section class="pane" id="pane-opcsvr" data-src="/panes/opcsvr.html"></section>
<section class="pane" id="pane-t2s" data-src="/panes/t2s.html"></section>
<section class="pane" id="pane-fast" data-src="/panes/fast.html"></section>
<section class="pane" id="pane-pid" data-src="/panes/pid.html"></section>
<section class="pane" id="pane-evt" data-src="/panes/evt.html"></section>
<section class="pane" id="pane-llmchat" data-src="/panes/llmchat.html"></section>
<section class="pane" id="pane-kbadmin" data-src="/panes/kbadmin.html"></section>
<section class="pane" id="pane-write" data-src="/panes/write.html"></section>
<section class="pane" id="pane-docs" data-src="/panes/docs.html"></section>
</main>
```
모달 2개(fastRecord, datepicker)는 `index.html`에 유지(pane 외부 공유 컴포넌트).
### 3.2 `panes/*.html` 파일 생성
각 pane의 `<section>` 내용을 그대로 추출. `<section>` 태그는 제거(로더가 innerHTML로 주입).
| 파일 | 원본 라인 | 내용 |
|------|-----------|------|
| `panes/cert.html` | 111-151 | 인증서 관리 |
| `panes/conn.html` | 156-208 | 서버 접속 테스트 |
| `panes/crawl.html` | 213-296 | 데이터 크롤링 + 노드맵 수집 |
| `panes/db.html` | 301-352 | DB 저장 |
| `panes/nm-dash.html` | 357-431 | 노드맵 대시보드 |
| `panes/pb.html` | 436-748 | 포인트빌더 |
| `panes/hist.html` | 753-902 | 이력 조회 |
| `panes/opcsvr.html` | 907-933 | OPC UA 서버 |
| `panes/t2s.html` | 938-1028 | Text-to-SQL |
| `panes/fast.html` | 1033-1078 | fastRecord |
| `panes/pid.html` | 1083-1196 | P&ID 추출 |
| `panes/evt.html` | 1201-1280 | 이벤트 히스토리 |
| `panes/llmchat.html` | 1285-1379 | 로컬 LLM 채팅 |
| `panes/kbadmin.html` | 1384-1543 | RAG 관리 |
| `panes/write.html` | 1548-1629 | OPC UA Write |
| `panes/docs.html` | 1634-1672 | 문서 탐색기 |
### 3.3 Phase 1 완료 시 동작
- `core.js`의 기동 코드가 페이지 로드 시 **16개 pane을 모두 eager 주입**(§0.5 정정 로더의 DOMContentLoaded에서 `loadPane` 일괄 호출 — 초안 §2.2엔 이 루프가 없음, D2)
- 단, fetch는 **비동기**이므로 "DOM 즉시 존재"는 성립하지 않음 → pane DOM에 의존하는 배선/init은 주입 완료 후(`paneWire`/`paneEnter`)에만 실행해야 함(D3)
- 동작은 기존과 동일하게 보이되, 파일만 분리됨
---
## 4. Phase 2 — app.js 탭별 분리
### 4.1 분리 매핑표
> ⚠️ **"원본 라인"은 분할표가 아니라 참고용** — 함수가 교차 배치돼 범위가 서로 겹친다(hist⊃evt, t2s⊃core helper). 라인 기준 일괄 추출 금지, 함수명 단위로 이동(§0.5 D5).
| 파일 | 함수 접두어 | 함수 수 | 원본 라인 |
|------|-------------|---------|-----------|
| `js/core.js` | `esc`, `setGlobal`, `log`, `api`, `fmtTs`, `fmtVal`, `parseEnumPv` | 7 | 23-60, 2599-2643 |
| `js/cert.js` | `cert*` | 2 | 52-104 |
| `js/conn.js` | `conn*`, `getServerCfg`, `llmLoadConfig`, `llmSaveModelConfig` | 5 | 106-261 |
| `js/crawl.js` | `crawl*`, `nodeMapCrawl` | 2 | 263-387 |
| `js/db.js` | `db*`, `selectFile` | 4 | 389-495 |
| `js/nm-dash.js` | `nm*` | 5 | 497-614 |
| `js/pb.js` | `pb*`, `rt*`, `meta*`, `subArea*` | 31 | 616-1118 |
| `js/hist.js` | `hist*`, `ht*`, `renderHistoryTable` | 10 | 1120-1658 |
| `js/opcsvr.js` | `srv*`, `_srv*` | 9 | 1779-1866 |
| `js/t2s.js` | `t2s*`, `toggleMcpMode`, `loadMcpTools`, `renderToolsChips`, `callTool`, `apiChat*`, `apiRenderResponse` | 28 | 1868-2643 |
| `js/fast.js` | `fast*` | 25 | 2646-3102 |
| `js/pid.js` | `pid*` | 23 | 3104-3688 |
| `js/llmchat.js` | `llm*` | 46 | 3690-4676 |
| `js/kbadmin.js` | `kb*` | 32 | 4678-5070 |
| `js/write.js` | `wr*` | 4 | 5072-5148 |
| `js/evt.js` | `evt*`, `_evt*` | 8 | 1349-1503 |
| `js/datepicker.js` | `dt*`, `_dtp` | 16 | 1660-1779 |
### 4.2 분리 규칙
1. **함수 단위 이동**: 함수 정의 전체를 복사. import 없음 (전역 스코프). ⚠️ prefix grep 기계 분리 금지 — `llmLoadConfig`/`llmSaveModelConfig`(160/177)는 `llm*`지만 conn 소속, `certStatus()`/core helper는 다른 영역에 끼어있음(§0.5 참조)
2. **전역 변수**: `_t2sLastResult` 하나가 아님 — **모듈레벨 상태변수 ~25개 전부** 이전 필수. §0.5 "모듈레벨 상태변수 전수 인벤토리" 표 기준
3. **로드 순서**: `core.js` 먼저, 그다음 탭별 JS. ~~datepicker가 hist/evt보다 먼저~~**불필요**(전부 `function` 선언 호이스팅, 순서 무관). 단 로드 시 실행되는 최상위 문은 §0.5대로 init 훅으로 이전해야 함(D4)
4. **docs.js**: 이미 분리됨. 변경 없음
### 4.3 `app.js` 삭제
모든 함수가 분리된 후 `app.js` 삭제. `app.js.backup`는 유지(롤백용).
---
## 5. Phase 3 — 지연로딩 전환
### 5.1 eager → lazy 변경
`core.js``loadPane()`을 수정하여 **탭 진입 시에만 fetch**. ⚠️ §0.5 정정 로더는 이미 lazy(진입 시 `loadPane`) + 기동 코드(기본 pane) 포함 — 별도 전환 불필요. 아래 초안은 기동·배선 누락(D2/D3) 상태이므로 §0.5판을 사용:
```javascript
const paneLoaded = new Set();
async function loadPane(tab) {
if (paneLoaded.has(tab)) return;
const el = document.getElementById(`pane-${tab}`);
if (!el || !el.dataset.src) return;
try {
el.innerHTML = await (await fetch(el.dataset.src)).text();
paneLoaded.add(tab);
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">패널 로딩 실패</p></div>`;
}
}
```
### 5.2 init 타이밍 검증
지연로딩 전환 시 반드시 검증할 항목:
| 탭 | init 함수 | 검증 사항 |
|----|-----------|-----------|
| opcsvr | `srvLoad()` | `srvLoad()`가 pane 내부 DOM을 참조하는지 확인 |
| t2s | `t2sInitMode()` |同上 |
| fast | `fastSessionsLoad()` |同上 |
| docs | `docsInit()` | `docsState.inited` 플래그로 안전 |
**리스크**: `index.html`에 인라인되어 있던 `onclick="..."`은 innerHTML 파싱 시 정상 바인딩됨. `<script>` 태그는 innerHTML로 실행되지 않으므로 pane 파일에는 `<script>` 포함 금지.
> 🔴 **추가 리스크(D3)**: pane 내부 버튼을 `onclick` 인라인이 아니라 **최상위 `addEventListener`로 배선**하는 탭이 있음 — fast(`btn-fast-*` 3001-3093), pid(`btn-pid-export-*` 3613-3626). 이 배선은 스크립트 로드 시점에 실행되는데, 파셜 pane은 그 시점에 DOM에 없어 `?.`로 **조용히 무력화**된다(eager·lazy 공통). 반드시 §0.5 정정 로더의 **`paneWire`(1회 setup 훅)** 안에서, pane DOM 주입 후 배선할 것. `[data-tab="..."]`/`.nav-item` 대상 배선은 shell에 남는 nav 요소라 안전.
---
## 6. 실행 순서 및 검증
### 6.1 Phase별 실행 순서
```
Phase 0: core.js 생성 + 파셜 로더
↓ 검증: 빌드 성공, 탭 전환 동작
Phase 1: panes/*.html eager 분리
↓ 검증: 모든 탭 동작 동일, 네트워크 탭에서 16개 fetch 확인(panes/*.html)
Phase 2: app.js 탭별 분리
↓ 검증: 모든 탭 동작 동일, app.js 삭제
Phase 3: 지연로딩 전환
↓ 검증: 탭 진입 시 fetch, init 타이밍 정상
```
### 6.2 검증 명령
```bash
# 빌드
dotnet build src/Web/ExperionCrawler.csproj
# 테스트
dotnet test
```
### 6.3 롤백 전략
| Phase | 롤백 방법 |
|-------|-----------|
| 0 | `core.js` 삭제, `index.html` 스크립트 태그 복원 |
| 1 | `panes/*.html` 내용을 `index.html`에 복붙, `data-src` 제거 |
| 2 | `app.js.backup``app.js` 복원, 탭별 JS 삭제 |
| 3 | `core.js``paneLoaded` 제거, eager 주입으로 복귀 |
---
## 7. 체크리스트 교차 검증 (STEP 6)
### Q1. 이미 수정된 문제인가?
- `docs.js`는 이미 분리되어 있음 → 중복 작업 아님 ✅
- `app.js`의 탭 라우터는 `app.js:5-20`에 존재 → 아직 분리되지 않음 ✅
### Q2. 다른 레이어에서 처리되고 있는가?
- `UseStaticFiles()``panes/*.html`을 그대로 서빙 → 추가 설정 불필요 ✅
- `MapFallbackToFile("index.html")`은 SPA 라우팅용 → panes fetch와 충돌 없음 ✅
### Q3. 의도적 설계인가?
- "빌드 스텝 없음"은 AGENTS.md에 명시된 의도적 설계 → 준수 ✅
- `docs.js`의 분리 패턴은 이미 입증된 모범 사례 ✅
### Q4. 실제 장애 시나리오가 있는가?
- 1,761줄 HTML 파일에서 한 탭 수정 시 전체 파일 열어야 함 → 편집 충돌 발생 가능 ✅
- 5,148줄 JS에서 함수 검색이 어려움 → 디버깅 시간 증가 ✅
---
## 8. CSS 분리 (Phase 4, 선택)
우선순위 낮음. HTML/JS 분리 완료 후 진행.
| 파일 | 분리 대상 |
|------|-----------|
| `css/style.css` (2,231줄) | 공용 토큰/레이아웃만 유지 |
| `css/llmchat.css` (신규) | `.llm-*` 클래스 추출 |
| `css/kbadmin.css` (신규) | `.kb-*` 클래스 추출 |
| `css/pb.css` (신규) | `.pb-*` 클래스 추출 |
---
## 9. 자가 검증 (STEP 8)
- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킬 수 있는가? → `app.js:5-20`, `index.html:111-1672`
- [x] HIGH 항목은 재현 가능한 시나리오를 한 문장으로 말할 수 있는가? → "한 탭 수정 시 1,761줄 파일 전체를 열어야 해서 두 사람이 동시에 편집 시 충돌 발생"
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함되어 있는가? → 위 §7 참조
- [x] 보고서의 수정 예시가 현재 코드에 아직 적용되지 않은 내용인가? → `core.js`/`panes/*.html`은 아직 존재하지 않음
- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다"를 혼동하지 않았는가? → 현재 코드는 정상 동작. 유지보수성 개선 제안임

View File

@@ -0,0 +1,823 @@
● 👍 잘 작동한다니 다행입니다!
정리하면 이번에 들어간 것:
남은 것 하나만 참고로: 검증하느라 띄운 백그라운드 서버(:5000, pid 1346075)가 아직 돌고 있습니다. 본인 터미널에서 직접
dotnet run으로 관리하시려면 kill 1346075 후 다시 실행하시면 됩니다.
그리고 웹 UI 구조 개선(HTML/JS 분리) 은 웹UI-개선플랜-byOPUS.md에 정리된 상태로 보류 중이니, 생각 정리되면 언제든
이어서 진행하면 됩니다. 수고하셨습니다!
✻ Cogitated for 8s · 1 shell still running
문서탐색기 변경 사항 커밋해줘
● 커밋 전에 상황을 정확히 파악하겠습니다. 세션 시작 시점에 index.html과 app.js는 이미 다른 작업으로 수정돼 있던 상태라,
문서탐색기 변경만 골라 커밋하려면 먼저 확인이 필요합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 현재 브랜치 ===" && git branch --show-current…)
 === 현재 브랜치 ===
sim/line-connection-analysis
… +207 lines (ctrl+o to expand)
● app.js엔 제 변경(딱 1줄: docsInit())과 무관한 기존 작업(포인트빌더 이력삭제, Sub-Area 관리 124줄)이 섞여 있습니다.
통째로 커밋하면 안 됩니다. index.html 상황과, 새 라이브러리 파일들이 .gitignore에 걸리는지(중요!) 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== index.html diff ==="…)
 === index.html diff ===
diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html
index 759f7f8..33f9d8d 100644
… +119 lines (ctrl+o to expand)
● 상황 정리됨. 두 가지 핵심 이슈:
1. index.html/app.js에 무관한 기존작업 섞임 — index.html엔 "Sub-Area 관리" 카드(남의 작업), app.js엔
포인트빌더/Sub-Area 124줄. → 내 hunk만 선별 스테이징 필요.
2. wwwroot/lib/가 .gitignore에 걸림 — 기존 uPlot조차 untracked. 새 라이브러리(marked/mermaid/katex…)도 git에 안
들어감. → 코드 먼저 커밋하고 이 부분은 따로 여쭙겠습니다.
내 hunk만 골라 index에 스테이징합니다 (작업트리는 안 건드림 — --cached).
● Bash(cd /home/windpacer/projects/ExperionCrawler
# CLAUDE.md 가 정말 내 docs 변경만인지 먼저 확인…)
 Waiting…
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Bash command
● app.js엔 제 변경(딱 1줄: docsInit())과 무관한 기존 작업(포인트빌더 이력삭제, Sub-Area 관리 124줄)이 섞여 있습니다.
통째로 커밋하면 안 됩니다. index.html 상황과, 새 라이브러리 파일들이 .gitignore에 걸리는지(중요!) 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== index.html diff ==="…)
 === index.html diff ===
diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html
index 759f7f8..33f9d8d 100644
… +119 lines (ctrl+o to expand)
● 상황 정리됨. 두 가지 핵심 이슈:
1. index.html/app.js에 무관한 기존작업 섞임 — index.html엔 "Sub-Area 관리" 카드(남의 작업), app.js엔
포인트빌더/Sub-Area 124줄. → 내 hunk만 선별 스테이징 필요.
2. wwwroot/lib/가 .gitignore에 걸림 — 기존 uPlot조차 untracked. 새 라이브러리(marked/mermaid/katex…)도 git에 안
들어감. → 코드 먼저 커밋하고 이 부분은 따로 여쭙겠습니다.
내 hunk만 골라 index에 스테이징합니다 (작업트리는 안 건드림 — --cached).
● Bash(cd /home/windpacer/projects/ExperionCrawler
# CLAUDE.md 가 정말 내 docs 변경만인지 먼저 확인…)
 Waiting…
 Interrupted · What should Claude do instead?
각각 핵심 작업 커밋 안된것들 별도로 커밋해줘 그리고 문서탐색기 부분도 순서대로 커밋
● 스코프를 확장하시는군요 — 미커밋 작업들을 기능별 논리 단위로 각각 커밋하고, 문서탐색기도 순서에 맞춰 커밋.
그러려면 먼저 각 변경이 어떤 작업에 속하는지 파악해야 합니다(특히 index.html/app.js는 여러 기능이 섞여 있어 hunk를
갈라야 함). 방금 거부된 건 아무것도 스테이징 안 됐을 테니 확인부터 하고, 전체 diff를 조사하겠습니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 스테이징 상태 (비어있어야) ==="…)
 === 스테이징 상태 (비어있어야) ===
=== 전체 변경 통계 ===
… +30 lines (ctrl+o to expand)
● 인덱스 비어있음 확인. 각 변경을 기능별로 분류하기 위해 백엔드/문서 diff의 핵심 식별자를 봅니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
for f in src/Web/Controllers/ExperionControllers.cs src/Core/Application/Interfaces/IExperionServices.cs
src/Infrast…)
⎿  ════════ src/Web/Controllers/ExperionControllers.cs (추가 라인 키워드) ════════
+ /// <summary>포인트 삭제. purgeHistory=true 면 해당 tagname 이력(history_table)까지 영구 삭제(복구 불가).
+ /// 기본은 realtime 행만 삭제하고, 그 base_tag 의 잔여 행이 0이면 고아 메타데이터(sub_area 포함)도 정리.<
… +64 lines (ctrl+o to expand)
● 백엔드 3종은 전부 Sub-Area 기능 + 포인트삭제/메타데이터 정리(서로 결합)로 명확합니다. 나머지 모호한 것들(mcp
server.py, plant_context.md, AGENTS.md, hc900/)을 분류합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "════ mcp-server/server.py (추가 키워드) ════"…)
 Waiting…
* Architecting… (56s · ↓ 6.5k tokens)
════ mcp-server/server.py (추가 키워드) ════
─────+──v_tag_summary(base_tag─TEXT,─pv─TEXT,─sp─TEXT,─op─TEXT,─description─TEXT,─area─TEXT,─sub_area─TEXT)─────────────
  + - sub_area는 "P6-1" 또는 공용 "P6-1,P6-2" 형식. 매칭은 항상 토큰 비교: 'P6-1' = ANY(string_to_array(sub_are
─────… +55 lines (ctrl+o to expand)─────────────────────────────────────────────────────────────────────────────────────
● 이제 분류가 명확해졌습니다. mcp server.py·plant_context.md·AGENTS.md 모두 Sub-Area 기능이고, 측류추출 문서들은 별도
그룹, hc900은 HC900 컨트롤러 바이너리(.cde, ~10MB)입니다. 마지막으로 CLAUDE.md가 docs 항목만인지와 hc900 ignore 여부만
확인하고 계획을 제시하겠습니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== CLAUDE.md 추가 내용이 docs 항목만인가 (Sub-Area 없어야) ==="…)
 === CLAUDE.md 추가 내용이 docs 항목만인가 (Sub-Area 없어야) ===
+### 문서 탐색기 (Tab 16) 구현 (2026-05-24)
+#### 배경
… +19 lines (ctrl+o to expand)
● 분류 완료. 전체 그림이 명확합니다. 커밋 계획을 제시합니다 (순서대로):
커밋 1 — Sub-Area(세부 Area) 분류 + 포인트 삭제 개선 (같은 파일·로직으로 결합되어 한 커밋)
- SubAreaDtos.cs, IExperionServices.cs, ExperionDbContext.cs, ExperionControllers.cs, mcp-server/server.py,
prompts/plant_context.md, AGENTS.md, SubArea-추가플랜.md
- index.html(Sub-Area 카드 hunk), app.js(pbDelete·subArea hunk 4개)
커밋 2 — 측류추출 자동운전 플랜 문서
- 측류추출-시간지연-적용방식.md(수정) + 측류추출-관계식.md, 측류추출-자동운전-플랜-byOpus.md,
측류추출-자동운전-플랜-컨셉회의.md/.docx, PGMEA_측류추출운전방식_주의점.md
커밋 3 — 문서 탐색기 (Tab 16)
- DocBrowserService.cs, DocsController.cs, docs.css, docs.js, Program.cs, appsettings.json, style.css(nav 스크롤),
CLAUDE.md
- index.html(docs hunk 4개), app.js(docsInit hunk 1개)
index.html/app.js는 hunk를 갈라 커밋 1과 3에 나눠 넣습니다(작업트리는 그대로).
남은 3개 항목은 애매해서 처리 방식만 확인합니다:
 User answered Claude's questions:
⎿  · hc900/ (HC900 컨트롤러 .cde 바이너리 7개+ · 약 10MB + PNG) 은 어떻게 할까요? → 이번엔 제외
· 단독 문서 2개(구조설명-6-1차플랜트.xlsx, 웹UI-개선플랜-byOPUS.md)는? → 각각 문서 커밋 추가
● 처리 방식 확정:
- hc900 → 제외
- 구조설명 xlsx, 웹UI 플랜 → 각각 문서 커밋
최종 5개 커밋(순서대로): ① Sub-Area ② 측류추출 문서 ③ 구조설명 xlsx ④ 문서 탐색기 ⑤ 웹UI 플랜.
먼저 커밋 1용으로 app.js(docs 1줄 제외)와 index.html(Sub-Area hunk만) 패치를 만들어 내용 검증 후 적용하겠습니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
# 커밋1: app.js 에서 docs hunk(@@ -15) 제외한 나머지(sub-area)만…)
 Waiting…
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
CLAUDE.md
- index.html(docs hunk 4개), app.js(docsInit hunk 1개)
index.html/app.js는 hunk를 갈라 커밋 1과 3에 나눠 넣습니다(작업트리는 그대로).
남은 3개 항목은 애매해서 처리 방식만 확인합니다:
 User answered Claude's questions:
⎿  · hc900/ (HC900 컨트롤러 .cde 바이너리 7개+ · 약 10MB + PNG) 은 어떻게 할까요? → 이번엔 제외
· 단독 문서 2개(구조설명-6-1차플랜트.xlsx, 웹UI-개선플랜-byOPUS.md)는? → 각각 문서 커밋 추가
● 처리 방식 확정:
- hc900 → 제외
- 구조설명 xlsx, 웹UI 플랜 → 각각 문서 커밋
최종 5개 커밋(순서대로): ① Sub-Area ② 측류추출 문서 ③ 구조설명 xlsx ④ 문서 탐색기 ⑤ 웹UI 플랜.
먼저 커밋 1용으로 app.js(docs 1줄 제외)와 index.html(Sub-Area hunk만) 패치를 만들어 내용 검증 후 적용하겠습니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
# 커밋1: app.js 에서 docs hunk(@@ -15) 제외한 나머지(sub-area)만…)
 === c1_appjs.patch 에 든 hunk (docs -15 없어야) ===
@@ -857,8 +858,8 @@ function pbRender(points) {
@@ -868,7 +869,12 @@ function pbRender(points) {
… +8 lines (ctrl+o to expand)
● 패치 검증 완료 (정확한 hunk만 포함, 적용 가능). 커밋 1을 스테이징합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
# 부분 hunk 적용…)
 === 커밋1 스테이징 내역 ===
AGENTS.md | 12 +
...266\224\352\260\200\355\224\214\353\236\234.md" | 1380 ++++++++++++++++++++
… +9 lines (ctrl+o to expand)
● 커밋 1 스테이징 정확합니다 (index.html 24줄=Sub-Area 카드만, app.js 132=docs 1줄 제외). 커밋합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git commit -q -F - <<'EOF'…)
 === 커밋1 완료 ===
f81044c feat: Sub-Area(세부 Area) 분류 기능 + 포인트 삭제 시 메타데이터/이력 정리
… +4 lines (ctrl+o to expand)
● 커밋 1 완료. 잔여는 정확히 docs hunk만(app.js 1줄, index.html 49줄). 이제 커밋 2 — 측류추출 문서.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git add "측류추출-시간지연-적용방식.md" \…)
 === 커밋2 스테이징 ===
...13\235_\354\243\274\354\235\230\354\240\220.md" | 75 +
...66\234-\352\264\200\352\263\204\354\213\235.md" | 100 +
… +7 lines (ctrl+o to expand)
● 커밋 2 완료. 커밋 3 — 구조설명 xlsx.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git add "구조설명-6-1차플랜트-byPBK.xlsx"…)
 === 커밋3 스테이징 ===
...\250\355\224\214\353\236\234\355\212\270-byPBK.xlsx" | Bin 0 -> 16967 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
… +2 lines (ctrl+o to expand)
● 커밋 3 완료. 커밋 4 — 문서 탐색기 (Tab 16). 남은 docs hunk(index.html/app.js)와 신규 파일들을 스테이징합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git add src/Infrastructure/Docs/DocBrowserService.cs \…)
 === 커밋4 스테이징 내역 ===
CLAUDE.md | 53 +++
src/Infrastructure/Docs/DocBrowserService.cs | 350 ++++++++++++++++
… +13 lines (ctrl+o to expand)
● 커밋 4 스테이징 정확 (app.js=docsInit 1줄, 모든 docs 파일 포함). 커밋합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git commit -q -F - <<'EOF'…)
 === 커밋4 완료 ===
9cc359b feat: 문서 탐색기 (Tab 16) — 프로젝트 폴더 트리 탐색 + txt/md/pdf 뷰어
● 커밋 4 완료. 커밋 5 — 웹UI 개선 플랜.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git add "웹UI-개선플랜-byOPUS.md"…)
 === 커밋5 스테이징 ===
...\354\204\240\355\224\214\353\236\234-byOPUS.md" | 237 +++++++++++++++++++++
1 file changed, 237 insertions(+)
… +2 lines (ctrl+o to expand)
● 5개 커밋 완료. 최종 상태를 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 이번에 만든 5개 커밋 ==="…)
 === 이번에 만든 5개 커밋 ===
eb9ce9a docs: 웹 UI 구조 개선 플랜 추가 (HTML/JS 모놀리식 분리)
9cc359b feat: 문서 탐색기 (Tab 16) — 프로젝트 폴더 트리 탐색 + txt/md/pdf 뷰어
… +8 lines (ctrl+o to expand)
● 5개 커밋이 순서대로 깔끔하게 완료됐습니다. 작업트리 깨짐 없고(추적 파일 미스테이징 0), hc900/만 의도대로 미커밋으로
남았습니다.
커밋 결과 (오래된 순)
┌─────┬──────────────┬──────────────────────────────────────────────────────────────────────────────────────────┐
│ # │ 커밋 │ 내용 │
├─────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
│ 1 │ f81044c feat │ Sub-Area 분류 + 포인트삭제 개선 — 백엔드 4종 + mcp/prompts/AGENTS + UI 카드·이력체크박스 │
├─────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
│ 2 │ 87ab8ad docs │ 측류추출 자동운전 플랜 문서 6종 │
├─────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
│ 3 │ ce72e25 docs │ 6-1차 플랜트 구조설명 xlsx │
├─────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
│ 4 │ 9cc359b feat │ 문서 탐색기 (Tab 16) │
├─────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
│ 5 │ eb9ce9a docs │ 웹 UI 개선 플랜 │
└─────┴──────────────┴──────────────────────────────────────────────────────────────────────────────────────────┘
index.html/app.js의 섞인 변경은 hunk 단위로 갈라 커밋 1(Sub-Area)과 커밋 4(문서탐색기)에 정확히 분배했습니다
(작업트리는 그대로).
⚠️ 하나 결정 필요 — 프론트 라이브러리 추적
문서탐색기가 쓰는 wwwroot/lib/의 marked·mermaid·katex 등이 .gitignore의 lib/ 규칙에 걸려 커밋 안 됨 (기존 uPlot도
동일하게 미추적). 즉 새로 clone하면 마크다운 렌더가 깨집니다 (pdf/txt는 정상, 현재 배포는 로컬 publish라 동작).
처리 방안:
┌───────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 방안 │ 내용 │
├───────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ A. lib 추적 │ .gitignore에 !src/Web/wwwroot/lib/ 예외 추가 후 lib 파일들 커밋 → clone 시도 정상 동작 (uPlot │
│ (권장) │ 누락도 같이 해결). 단 ~4MB 바이너리 git 편입 │
├───────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ B. 현행 유지 │ 기존 uPlot과 동일하게 미추적 — 로컬 vendoring + publish 의존 │
└───────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────┘
A로 정리할까요? (그러면 feat: 문서탐색기 뒤에 chore: 프론트 라이브러리 추적 커밋 하나 추가하겠습니다.) 아니면 B로
그대로 둘까요?
✻ Worked for 3m 50s · 1 shell still running
A로 정리해줘
● A로 진행합니다. .gitignore에 예외를 추가하고 lib 파일을 추적합니다. 먼저 현재 .gitignore를 확인합니다.
Read 1 file (ctrl+o to expand)
● lib/(line 41, Python용)이 wwwroot/lib도 잡고 있습니다. 끝에 예외를 추가합니다.
● Update(.gitignore)
 Added 4 lines
61
62 # KB 업로드 원본 파일 (런타임 데이터)
63 storage/
64 +
65 +# wwwroot 프론트 라이브러리는 추적 (위 Python용 lib/ 규칙의 예외)
66 +!src/Web/wwwroot/lib/
67 +!src/Web/wwwroot/lib/**
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 예외 적용 확인 (ok=추적가능 이어야) ==="…)
 === 예외 적용 확인 (ok=추적가능 이어야) ===
ok: src/Web/wwwroot/lib/marked.min.js
ok: src/Web/wwwroot/lib/mermaid.min.js
… +37 lines (ctrl+o to expand)
● 예외 적용 성공 — 새 라이브러리 전부 + 기존 누락이던 uPlot까지 함께 추적됩니다. 커밋합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git commit -q -F - <<'EOF'…)
 === 완료 — 전체 6커밋 ===
3556739 chore: 프론트 라이브러리 wwwroot/lib 추적 (.gitignore 예외)
eb9ce9a docs: 웹 UI 구조 개선 플랜 추가 (HTML/JS 모놀리식 분리)
… +7 lines (ctrl+o to expand)
● 완료했습니다. 총 6개 커밋으로 정리됐고, hc900/만 의도대로 미커밋으로 남았습니다.
최종 커밋 (오래된 순)
┌─────┬──────────────────────────────────────────────────┬───────┐
│ # │ 커밋 │ 종류 │
├─────┼──────────────────────────────────────────────────┼───────┤
│ 1 │ f81044c Sub-Area 분류 + 포인트삭제 개선 │ feat │
├─────┼──────────────────────────────────────────────────┼───────┤
│ 2 │ 87ab8ad 측류추출 자동운전 플랜 문서 │ docs │
├─────┼──────────────────────────────────────────────────┼───────┤
│ 3 │ ce72e25 6-1차 플랜트 구조설명 │ docs │
├─────┼──────────────────────────────────────────────────┼───────┤
│ 4 │ 9cc359b 문서 탐색기 (Tab 16) │ feat │
├─────┼──────────────────────────────────────────────────┼───────┤
│ 5 │ eb9ce9a 웹 UI 개선 플랜 │ docs │
├─────┼──────────────────────────────────────────────────┼───────┤
│ 6 │ 3556739 프론트 라이브러리 추적 (.gitignore 예외) │ chore │
└─────┴──────────────────────────────────────────────────┴───────┘
마지막 커밋으로 lib/ 문제를 해결했습니다:
- .gitignore에 !src/Web/wwwroot/lib/ 예외 추가
- 새 라이브러리(marked·dompurify·highlight·katex+폰트·mermaid) 추적
- 덤으로 기존 누락이던 uPlot도 함께 추적 — 이제 fresh clone에서도 마크다운 렌더·차트가 정상 동작합니다
작업트리 깨짐 없이 깔끔하며, 푸시는 요청하지 않으셔서 로컬 커밋까지만 했습니다. 푸시할까요? (현재 브랜치
sim/line-connection-analysis)
✻ Churned for 40s · 1 shell still running
origin https://windpacer@git.hanmocnn.co.kr/windpacer/ExperionCrawler.git (fetch)
origin https://windpacer@git.hanmocnn.co.kr/windpacer/ExperionCrawler.git (push)
… +4 lines (ctrl+o to expand)
● 업스트림(origin/sim/line-connection-analysis) 추적 중이고, 푸시 대기 6커밋입니다. 푸시합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
git push origin sim/line-connection-analysis 2>&1 | tail -15)
 remote:
remote: Create a new pull request for 'sim/line-connection-analysis':
remote: http://localhost:3000/windpacer/ExperionCrawler/pulls/new/sim/line-connection-analysis
… +5 lines (ctrl+o to expand)
● 푸시 완료했습니다. ✅
302183c..3556739 sim/line-connection-analysis -> origin/sim/line-connection-analysis
6개 커밋이 모두 origin에 올라갔습니다. 원격에서 PR을 만들고 싶으면 안내된 링크를 쓰시면 됩니다:
http://.../windpacer/ExperionCrawler/pulls/new/sim/line-connection-analysis
정리하면 — 문서 탐색기 기능 구현 → 기존 미커밋 작업들을 기능별 논리 단위로 분리 커밋 → 라이브러리 추적 정리 → 푸시까지
마무리됐습니다. 수고하셨습니다!
✻ Crunched for 15s · 1 shell still running
아 지금 llm이 자기 마음대로 git branch를 만들어서, 헷갈리는 상태인데 git 트리가 어떻게 구성되어있는지 확인해줄래?
● 브랜치 구조를 읽기 전용으로 전체 파악하겠습니다. 브랜치 목록, 커밋 그래프, 각 브랜치 위치를 봅니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ 1) 로컬 브랜치 (현재=*, 마지막커밋) ═══════"…)
⎿  ═══════ 1) 로컬 브랜치 (현재=*, 마지막커밋) ═══════
master 960bda4 [origin/master: ahead 5] fix: P&ID 배관번호 분류 오류 수정 (power_equip
ment → pipings)
… +10 lines (ctrl+o to expand)
● 로컬에 master와 sim/... 두 개, 원격엔 main·master·sim/... 세 개가 있네요 (main/master 공존이 혼란의 핵심으로
보입니다). 이들이 어떻게 갈라지고 합쳐지는지 그래프로 봅니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ 커밋 그래프 (모든 브랜치, 최근 25) ═══════"…)
⎿  ═══════ 커밋 그래프 (모든 브랜치, 최근 25) ═══════
* 3556739 (HEAD -> sim/line-connection-analysis, origin/sim/line-connection-analysis) chore: 프론트 라이브러리
wwwroot/lib 추적 (.gitignore 예외)
… +35 lines (ctrl+o to expand)
● 전체 그림이 보입니다 — 실제로는 갈라진 게 아니라 한 줄(선형) 입니다. 정확한 위치 관계만 마지막으로 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 각 브랜치 tip ==="…)
 Waiting…
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Bash command
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ 1) 로컬 브랜치 (현재=*, 마지막커밋) ═══════"…)
⎿  ═══════ 1) 로컬 브랜치 (현재=*, 마지막커밋) ═══════
master 960bda4 [origin/master: ahead 5] fix: P&ID 배관번호 분류 오류 수정 (power_equip
ment → pipings)
… +10 lines (ctrl+o to expand)
● 로컬에 master와 sim/... 두 개, 원격엔 main·master·sim/... 세 개가 있네요 (main/master 공존이 혼란의 핵심으로
보입니다). 이들이 어떻게 갈라지고 합쳐지는지 그래프로 봅니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ 커밋 그래프 (모든 브랜치, 최근 25) ═══════"…)
⎿  ═══════ 커밋 그래프 (모든 브랜치, 최근 25) ═══════
* 3556739 (HEAD -> sim/line-connection-analysis, origin/sim/line-connection-analysis) chore: 프론트 라이브러리
wwwroot/lib 추적 (.gitignore 예외)
… +35 lines (ctrl+o to expand)
● 전체 그림이 보입니다 — 실제로는 갈라진 게 아니라 한 줄(선형) 입니다. 정확한 위치 관계만 마지막으로 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 각 브랜치 tip ==="…)
 === 각 브랜치 tip ===
origin/main d9f5bfd # 5. OPC UA 서버 기능 (Phase 1) — 완료
origin/master 35136ba feat: 로컬 LLM 채팅 기능 추가 (Ollama + vL
… +13 lines (ctrl+o to expand)
● 확인 완료. 결론부터: 브랜치가 얽힌 게 아니라, 한 줄(완전 선형)에 라벨 3개가 서로 다른 위치에 찍혀 있을 뿐입니다. 머지
충돌이나 갈라짐은 없습니다.
실제 구조 (오래된 ↓ 최신)
d9f5bfd ● origin/main ← ⚠️ 39커밋 뒤처진 "유물" 브랜치
⋮ (39 commits)
35136ba ● origin/master ← 원격 master
⋮ (5 commits, 아직 origin/master엔 push 안 됨)
960bda4 ● master (로컬) ← origin/master + 5
302183c ● ← 이번 세션 시작 시점
⋮ (내가 만든 6 commits)
3556739 ● sim/line-connection-analysis = origin/sim/... = HEAD(현재)
전부 --is-ancestor 관계가 성립 → 직선입니다:
origin/main ⊂ origin/master ⊂ master(로컬) ⊂ sim
브랜치별 정체
┌──────────────────────────────┬─────────┬─────────────────────────────────────────────────────────────────────────┐
│ 브랜치 │ 위치 │ 상태 │
├──────────────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
│ sim/line-connection-analysis │ 3556739 │ 현재 작업 브랜치, 가장 최신. 로컬=원격 동기화됨 ✅ │
├──────────────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
│ master (로컬) │ 960bda4 │ origin/master보다 5 앞섬. 단, 이 5커밋은 sim에도 포함돼 이미 │
│ │ │ origin/sim에 push됨 → 유실 없음 │
├──────────────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
│ origin/master │ 35136ba │ 원격 master. 로컬보다 5 뒤처짐 │
├──────────────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
│ origin/main │ d9f5bfd │ 39커밋 뒤처진 옛날 브랜치. 저장소 "기본=main" 설정이지만 실제 개발은 │
│ │ │ master→sim로 이동함 │
└──────────────────────────────┴─────────┴─────────────────────────────────────────────────────────────────────────┘
혼란의 원인 2가지
1. main과 master가 공존 — main은 옛날에 멈춘 상태(39 뒤처짐)인데 저장소 기본 브랜치로 지정돼 있어 헷갈림. 실제 최신은
main이 아니라 sim.
2. 로컬 master가 origin/master보다 앞서 있음 — 하지만 그 커밋들은 sim 경유로 이미 원격에 올라가 있어 안전.
정리하고 싶다면 (지금은 아무것도 안 건드렸습니다)
흔한 마무리 방법:
- A. master를 최신으로 맞추기: master를 sim 위치로 fast-forward 후 origin/master에 push → master가 진짜 최신이 됨
- B. sim을 main/master에 병합: 작업이 끝났으면 sim → master(또는 main) 머지/PR 후 sim 삭제
- C. 유물 main 정리: 안 쓰면 origin/main을 master 위치로 갱신하거나 삭제 (단, "기본 브랜치"라 삭제 전 저장소 설정에서
기본을 바꿔야 함)
어떻게 정리하고 싶으신가요? 원하는 방향 알려주시면 그대로 진행하겠습니다. (선형이라 어느 쪽이든 깔끔하게 됩니다.)
✻ Cooked for 55s · 1 shell still running
sim 내용이 어디까지 되어 있는지 파악해줘
● sim 브랜치가 담고 있는 작업 범위를 파악하겠습니다 — 마지막 원격 기준선(origin/master)보다 앞선 커밋들과 변경 영역을
봅니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ sim 에만 있는 커밋 (origin/master..sim, 최신↓) ═══════"…)
⎿  ═══════ sim 에만 있는 커밋 (origin/master..sim, 최신↓) ═══════
3556739 (HEAD -> sim/line-connection-analysis, origin/sim/line-connection-analysis) chore: 프론트 라이브러리 w
wwroot/lib 추적 (.gitignore 예외)
… +36 lines (ctrl+o to expand)
● sim이 담은 범위가 파악됐습니다. 핵심: 브랜치 이름은 "line-connection-analysis"지만, 실제로는 사실상 개발 트렁크로 그
이상이 잔뜩 쌓여 있습니다.
sim = origin/master + 12커밋
마지막 원격 master(35136ba 로컬LLM채팅)를 기준으로 sim에만 있는 12개를 성격별로 묶으면:
A. KB/RAG + 채팅 LLM (Phase 0~6) — 이미 로컬 master에 포함
┌─────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│ 커밋 │ 내용 │
├─────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 908bfe1 │ Knowledge Base RAG 시스템 + 채팅 LLM 개선 (Phase 0~5) │
├─────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 5a9d60e │ Phase 5 진단 핫픽스 + Phase 6 run_sql 안전 가드 │
├─────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ d09ef95 │ Phase 6 도구 5종 (find_tags, query_events, active_alarms, summarize_events, status_report) │
├─────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0ccec38 │ CLAUDE.md 정리 │
├─────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ 960bda4 │ P&ID 배관번호 분류 오류 수정 ← 로컬 master tip │
└─────────┴────────────────────────────────────────────────────────────────────────────────────────────┘
B. P&ID 연결분석 + 에이전트 — sim 본체 (master보다 앞, 세션 시작점)
┌─────────┬─────────────────────────────────────────────────────────────────────────────────────┐
│ 커밋 │ 내용 │
├─────────┼─────────────────────────────────────────────────────────────────────────────────────┤
│ 302183c │ P&ID 연결 분석, LLM 에이전트 모드, KB 확장, MCP 서버 리팩토링 ← 이번 세션 시작 지점 │
└─────────┴─────────────────────────────────────────────────────────────────────────────────────┘
C. 이번 세션 내 작업 (내가 추가한 6개)
┌─────────┬─────────────────────────────────┐
│ 커밋 │ 내용 │
├─────────┼─────────────────────────────────┤
│ f81044c │ Sub-Area 분류 + 포인트삭제 개선 │
├─────────┼─────────────────────────────────┤
│ 87ab8ad │ 측류추출 자동운전 플랜 문서 │
├─────────┼─────────────────────────────────┤
│ ce72e25 │ 6-1차 플랜트 구조설명 │
├─────────┼─────────────────────────────────┤
│ 9cc359b │ 문서 탐색기 (Tab 16) │
├─────────┼─────────────────────────────────┤
│ eb9ce9a │ 웹 UI 개선 플랜 │
├─────────┼─────────────────────────────────┤
│ 3556739 │ 프론트 라이브러리 추적 │
└─────────┴─────────────────────────────────┘
변경 영역 (origin/master..sim, 206 파일)
▎ 삽입 라인 2.4M은 대부분 데이터/바이너리(dxf-graph 시트 26개, katex 폰트 20개, mermaid.min.js 등)라 코드 규모와는
▎ 별개입니다.
주요 영역: dxf-graph/(P&ID 그래프·라인연결 데이터), mcp-server/(파서·도구), src/Infrastructure/Kb/(RAG),
src/Web/Controllers·wwwroot/(UI·API), src/Core/.../DTOs·Entities, plans/(기획 문서).
정리하면
- sim에 담긴 "내용의 끝(최신)" = 3556739(프론트 라이브러리 추적), 즉 이번 세션까지 전부. 로컬=원격(origin/sim) 동기화
완료 ✅
- sim은 "라인 연결 분석" 한 가지가 아니라 KB/RAG → P&ID 연결분석/에이전트 → Sub-Area → 문서탐색기까지 누적된 실질 메인
라인
- origin/master는 그 한참 앞(KB RAG 이전)에 멈춰 있고, origin/main은 더 옛날 → 현재 진짜 최신은 sim 뿐
특정 커밋의 변경 상세(예: 302183c 연결분석이 구체적으로 뭘 했는지)나, sim을 master/main에 어떻게 반영할지 정리 방안이
필요하면 이어서 봐 드리겠습니다.
✻ Cooked for 36s · 1 shell still running
sim을 master에 반영하고 main 정리해줘
● sim이 master·main의 직계 자손이라 머지커밋 없이 깔끔하게 fast-forward로 맞출 수 있습니다(이력 손실·force 없음). 다만
main은 저장소 기본 브랜치이고 원격을 바꾸는 작업이라, "main 정리"의 의미만 확정하고 진행하겠습니다.
 User declined to answer questions
⎿  · sim을 master에 반영한 뒤 main을 어떻게 정리할까요? (모두 fast-forward—이력 손실 없음, 원격 push 포함)
(master·main 모두 최신으로 (권장) / master만 반영, main은 그대로 / master·main 맞추고 sim 삭제)
● 네, 무엇을 더 명확히 하고 싶으신지 말씀해 주세요. 결정에 도움이 되도록, 제가 가진 정보와 짚어둘 점을 먼저 정리하면:
현재 사실관계
- sim은 master·main의 직계 자손(완전 선형) → 셋 다 fast-forward로 손실 없이 맞출 수 있음
- origin/main은 origin/master보다 39커밋, sim보다 12커밋 뒤처진 옛날 브랜치
- git.hanmocnn.co.kr(Gitea) 저장소이고, 설정상 기본 브랜치가 main 일 가능성이 높음
제가 모르는/확인하고 싶은 점 (이런 걸 정해주시면 됩니다)
1. "main 정리"의 의미 — main을 최신으로 끌어올리기(FF) / 그냥 두기 / 삭제 중 무엇인지?
2. 기본 브랜치 정책 — 이 저장소의 "진짜 트렁크"를 앞으로 main으로 갈지, master로 갈지? (지금은 main/master 공존이
혼란의 원인)
3. 원격 push 여부 — master/main 변경을 origin에 push할지, 로컬만 정리할지?
4. sim 브랜치 운명 — 반영 후 유지할지, 삭제할지?
5. 공유 저장소 영향 — 다른 사람이 main/master를 쓰고 있는지 (있다면 원격 기본 브랜치 변경은 영향이 큼)
궁금하거나 다르게 생각하시는 부분을 알려주시면, 거기에 맞춰 다시 정리해 드리겠습니다.
✻ Cogitated for 48s · 1 shell still running
master main을 둘다 유지할 필요성이 있나?
● 결론부터: 아니요, 둘 다 유지할 이유가 없습니다. main/master 공존은 거의 항상 의도치 않은 잔재이고, 지금 겪는 혼란의
원인 그 자체입니다. 트렁크는 하나로 통일하는 게 정석입니다.
원격의 실제 기본 브랜치가 뭔지 확인해서 더 정확히 답하겠습니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 원격 기본(HEAD) 브랜치 + 브랜치 상태 ==="…)
 === 원격 기본(HEAD) 브랜치 + 브랜치 상태 ===
HEAD branch: main
main tracked
… +9 lines (ctrl+o to expand)
● 확인 결과가 명확합니다:
- 원격 기본 브랜치(HEAD) = main
- origin/main 고유 커밋 = 0개 → main에는 master/sim에 없는 내용이 전혀 없음. 순수하게 "과거 한 지점"을 가리키는 낡은
포인터일 뿐입니다.
답: 둘 다 유지할 이유 없음 → 트렁크 하나로 통일
main/master를 둘 다 두는 건 표준이 아니고, 지금 혼란의 원인입니다. 셋 다 선형이라 하나로 합치는 게 깔끔합니다.
권장 — main으로 통일 (이미 저장소 기본 브랜치라 서버 설정 변경 불필요):
┌──────┬───────────────────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ 단계 │ 명령 │ 효과 │
├──────┼───────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 1 │ git push origin │ 원격 main을 최신(3556739)으로 fast-forward (force 아님, │
│ │ sim/line-connection-analysis:main │ 손실 없음) │
├──────┼───────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 2 │ git push origin --delete master │ 원격 master 삭제 (고유 내용 0 → 안전) │
├──────┼───────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 3 │ git branch -D master │ 로컬 master 삭제 │
├──────┼───────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 결과 │ │ 트렁크 = main(최신) 하나. master 제거 │
└──────┴───────────────────────────────────────────────┴──────────────────────────────────────────────────────────┘
이러면 main이 다시 진짜 최신 트렁크가 되고, master라는 헷갈리는 중복이 사라집니다.
남은 결정 한 가지 — sim은?
- 위 작업 후 main == sim(같은 커밋)이 됩니다.
- (a) sim 유지: 앞으로도 sim에서 작업하다 가끔 main에 반영 (feature 브랜치 방식)
- (b) sim도 삭제: 앞으로 main에서 직접 작업 (트렁크 단일화)
▎ ⚠️ 공유 저장소(Gitea)라, 혹시 다른 분이 master를 쓰고 있다면 삭제 전 알려야 합니다. 혼자 쓰시면 무방합니다.
이 방향(main으로 통일, master 삭제)으로 진행할까요? sim은 (a) 유지 / (b) 삭제 중 어느 쪽으로 할지도 알려주세요.
✻ Cogitated for 37s · 1 shell still running
main으로 통일
● main으로 통일하겠습니다. 안전하게 단계별로 — 먼저 원격 main을 최신으로 fast-forward(force 아님)하고 결과를 확인한 뒤
master를 삭제합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 1) 원격 main 을 sim 최신(3556739)으로 fast-forward ==="…)
 === 1) 원격 main 을 sim 최신(3556739)으로 fast-forward ===
remote: . Processing 1 references
remote: Processed 1 references in total
✻ Cogitated for 37s · 1 shell still running
main으로 통일
● main으로 통일하겠습니다. 안전하게 단계별로 — 먼저 원격 main을 최신으로 fast-forward(force 아님)하고 결과를 확인한 뒤
master를 삭제합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 1) 원격 main 을 sim 최신(3556739)으로 fast-forward ==="…)
 === 1) 원격 main 을 sim 최신(3556739)으로 fast-forward ===
remote: . Processing 1 references
remote: Processed 1 references in total
… +5 lines (ctrl+o to expand)
● main이 최신(3556739)으로 fast-forward 됐습니다 (d9f5bfd..3556739, force 없음). 이제 중복인 master를 삭제합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 2) 원격 master 삭제 ==="…)
 === 2) 원격 master 삭제 ===
remote: . Processing 1 references
remote: Processed 1 references in total
… +8 lines (ctrl+o to expand)
● master 삭제 완료 (원격 + 로컬 + prune). 최종 상태를 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ 최종 브랜치 상태 ═══════"…)
⎿  ═══════ 최종 브랜치 상태 ═══════
--- 로컬 ---
* sim/line-connection-analysis 3556739 [origin/sim/line-connection-analysis] chore: 프론트 라이브러리 wwwroot/
… +14 lines (ctrl+o to expand)
● main 통일 완료했습니다. ✅
정리 결과
┌──────────────────────────────────────┬──────────────────┐
│ 전 │ 후 │
├──────────────────────────────────────┼──────────────────┤
│ main(39 뒤처짐) + master(중복) + sim │ main(최신) + sim │
└──────────────────────────────────────┴──────────────────┘
- 원격 main: d9f5bfd → 3556739 으로 fast-forward (force 없음, 이력 보존)
- master: 원격·로컬 모두 삭제 (고유 커밋 0 → 손실 없음)
- 현재 origin/main == sim == 3556739 (동일 지점)
* 3556739 (HEAD -> sim/..., origin/sim/..., origin/main) ← 셋 다 같은 최신
* eb9ce9a 웹 UI 개선 플랜
* 9cc359b 문서 탐색기 (Tab 16)
남은 것 — sim 처리
지금 main과 sim이 완전히 같은 커밋입니다. 마지막으로 정할 것:
- (a) sim 유지 — 앞으로 sim에서 작업하다 main에 반영하는 feature-branch 방식
- (b) sim 삭제 — main 단일 브랜치로 완전 통일. 앞으로 main에서 직접 작업 (commit 유실 없음, 전부 main에 있음)
"통일"이 목적이셨으니 (b)가 자연스럽지만, 워크플로우 취향이라 확인합니다. (b)로 하면:
- git checkout main (로컬 main 생성·전환) → git push origin --delete sim/line-connection-analysis → 로컬 sim 삭제
어느 쪽으로 할까요?
✻ Churned for 46s · 1 shell still running
(b) sim 삭제
● (b)로 진행 — main 단일 브랜치로 통일합니다. 현재 sim에 체크아웃돼 있어 먼저 main으로 전환한 뒤 sim을 삭제합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== 1) 로컬 main 생성 + 전환 (origin/main 추적) ==="…)
 Waiting…
● Germinating… (16s · ↓ 1.6k tokens)
─────=== 1) 로컬─main─생성─+─전환─(origin/main─추적)─===────────────────────────────────────────────────────────────────
  Switched to a new branch 'main'
─ branch 'main' set up to track 'origin/main'.───────────────────────────────────────────────────────────────────────
⏵⏵ … +13 lines (ctrl+o to expand)c to interrupt · ↓ to manage ◉ xhigh · /effort
● 통일 완료. 최종 상태를 확인합니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "═══════ 최종 — 로컬/원격 브랜치 ═══════"…)
⎿  ═══════ 최종 — 로컬/원격 브랜치 ═══════
--- 로컬 ---
* main 3556739 [origin/main] chore: 프론트 라이브러리 wwwroot/lib 추적 (.gitignore 예외)
… +10 lines (ctrl+o to expand)
● main 단일 브랜치로 통일 완료했습니다. ✅
최종 상태
로컬: * main 3556739 [origin/main]
원격: origin/main
동기화: ## main...origin/main (로컬=원격, 완전 일치)
* 3556739 (HEAD -> main, origin/main) chore: 프론트 라이브러리 추적
* eb9ce9a 웹 UI 개선 플랜
* 9cc359b 문서 탐색기 (Tab 16)
전체 정리 요약
┌───────────────────────────────────┬───────────────────────────────────────────┐
│ 작업 │ 결과 │
├───────────────────────────────────┼───────────────────────────────────────────┤
│ 원격 main fast-forward │ d9f5bfd → 3556739 (이력 보존, force 없음) │
├───────────────────────────────────┼───────────────────────────────────────────┤
│ master 삭제 │ 원격·로컬 모두 제거 (고유 커밋 0) │
├───────────────────────────────────┼───────────────────────────────────────────┤
│ sim/line-connection-analysis 삭제 │ 원격·로컬 모두 제거 │
├───────────────────────────────────┼───────────────────────────────────────────┤
│ 현재 브랜치 │ main (HEAD), origin/main과 동기화 │
└───────────────────────────────────┴───────────────────────────────────────────┘
이제 브랜치는 main 하나뿐이고, 그동안의 모든 작업(KB/RAG, P&ID 연결분석, Sub-Area, 문서탐색기 등)이 전부 main에 들어
있습니다. 커밋 유실 없습니다. 앞으로는 main에서 바로 작업하시면 됩니다.
혹시 헷갈리시던 부분이 정리됐는지 확인해 보시고, 더 필요한 게 있으면 알려주세요.
✻ Cooked for 28s · 1 shell still running
현재 로컬과 차이점 모두 커밋하고 푸시해줘
● 현재 미커밋 차이를 먼저 확인하겠습니다.
● Bash(cd /home/windpacer/projects/ExperionCrawler
echo "=== git status ===" && git status --short…)
 === git status ===
?? hc900/
… +14 lines (ctrl+o to expand)
● 확인 결과, 미커밋 차이는 hc900/ 하나뿐입니다 (추적 파일 변경 0). 그런데 이건 조금 전에 직접 "이번엔 제외"로 빼셨던
HC900 컨트롤러 바이너리(.cde 등 11MB)입니다.
- 추적 파일(코드/문서)은 전부 이미 커밋·푸시 완료 → 남은 차이 = hc900/ 뿐
- "모든 차이 커밋"이면 이 11MB 바이너리를 git 이력에 넣게 됩니다 (한 번 들어가면 되돌리기 번거로움)
마음이 바뀌어 hc900도 포함하시는 거면 바로 커밋·푸시하겠습니다. 맞나요?
- 예 → chore: HC900 컨트롤러 캡처/설정 파일 추가로 커밋 후 push
- 아니오 → 그대로 두면 커밋할 게 없습니다 (이미 다 동기화됨)
어떻게 할까요?
✻ Worked for 20s · 1 shell still running
아니오, 그대로 둬, 이거 하다가 실패한거라서 의미가 없어서
● 알겠습니다. hc900/는 그대로 두겠습니다 — 실패한 시도라 의미 없다고 하셨으니.
현재 상태: 추적 파일은 전부 커밋·푸시 완료, main과 origin/main 완전 동기화. 커밋할 차이가 없습니다 (hc900/만
의도적으로 미추적 상태로 남음).
더 할 일은 없습니다. 혹시 hc900/가 git status에 계속 뜨는 게 거슬리면 .gitignore에 한 줄 추가해 조용히 만들 수 있는데,
필요하면 말씀해 주세요.
✻ Baked for 6s · 1 shell still running