Compare commits

...

6 Commits

Author SHA1 Message Date
windpacer
302183c97e feat: P&ID 연결 분석, LLM 에이전트 모드, KB 확장, MCP 서버 리팩토링
- P&ID: 연결 분석 API, Prefix 규칙 관리, 카테고리 분류, DXF 그래프 빌드
- LLM: 대화 요약, tool card 영구 보존, 시계열 차트(uPlot), 에이전트 모드
- KB: 청크 미리보기, Field Instrument Inference, 인증/Qdrant 클라이언트
- MCP: 서버 기능 확장, 파이프라인 수정, timeout 개선
- Frontend: P&ID UI, LLM UI, KB UI, OPC UA Write 탭 추가
- 설정: AGENTS.md, plant_context, README, opencode.json 업데이트
- 정리: 진단 체크리스트 문서 삭제
2026-05-21 23:36:57 +09:00
windpacer
960bda4a3c fix: P&ID 배관번호 분류 오류 수정 (power_equipment → pipings)
- _PID_LINENO_FULL_RE: 7필드 고정 regex → 5~7필드 통합 (9차 P-9107-25A-F-n 등 미매칭 수정)
- _extract_pid_dxf_fast: 레이어 이름 하드코딩 제거 → FULL_RE 매칭 우선, LINENO 계열 레이어 힌트 보조
- MatchCategoryAsync: 배관번호 regex(_pipeLineNoRe) 체크를 prefix 룰보다 먼저 실행 → P-9117-20A-F-n 등이 power_equipment로 오분류되던 문제 수정
- pump extractor 프롬프트: 배관번호 SKIP/INCLUDE 예시 추가
- DB 기존 레코드 435건 pipings로 재분류 (직접 SQL)
- .claude/settings.json: LLM 모델명 하드코딩 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:36:30 +09:00
windpacer
0ccec38c18 docs: CLAUDE.md 정리 — 변경 이력 압축 (963→81줄)
git 히스토리에 이미 있는 Phase 0~6 changelog, OPC UA 서버,
버그 수정 이력, 성능 분석, 구현 계획(Task 1~4) 전부 삭제.
작업 규칙 + 최신 1건(Phase 7)만 유지해 세션 컨텍스트 로드량 축소.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 07:13:39 +09:00
windpacer
d09ef95869 feat: Phase 6 보강 도구 5종 (find_tags, query_events, active_alarms, summarize_events, generate_status_report)
이벤트 중심 도구와 LLM 요약/보고서 도구를 추가해 채팅에서
"활성 알람", "교대 보고서", "이벤트 요약" 같은 운전원 요청을 처리.

신규 MCP 도구 (mcp-server/server.py):
- find_tags(query, area?, top_k):
    v_tag_summary 뷰 기반. base_tag 또는 description ILIKE 매칭.
    PV/SP/OP/설명/area 함께 반환.
- query_events(tag_name?, event_type?, area?, since?, until?, limit):
    event_history_table 필터 조회. since/until 미지정 시 최근 24h.
    event_type은 ALARM/TRIP/NORMAL/RUN/CHANGE 5종.
- active_alarms(area?, limit):
    DISTINCT ON (tagname)으로 태그별 최신 이벤트 추출 후
    ALARM/TRIP만 반환 (NORMAL 들어왔으면 자동 해제).
- summarize_events(since?, area?, event_type?, max_events, focus?):
    query_events 결과를 LLM에 넣어 한국어 6~10줄 구조화 요약
    (현황/알람/패턴/권고) + by_type/by_area 통계.
- generate_status_report(area?, hours):
    활성 알람 + 최근 이벤트 통계/표본을 LLM에 넘겨
    교대 보고서 형식(요약/알람/이벤트분석/권고) 마크다운 생성.
    윈도우 1~168시간, 기본 24시간.

공통:
- prepared statement(파라미터 바인딩)로 SQL 인젝션 방지
- SET statement_timeout = SQL_STATEMENT_TIMEOUT_MS 적용
- _DB_SCHEMA에 event_history_table 정의 추가 (NL2SQL 인지용)

시스템 프롬프트 (OllamaController):
- ToolGuideKo에 신규 5종 + search_kb + event_type 5종 명시
- run_sql 자동 가드(LIMIT/timeout) 안내 추가

검증:
- dotnet build: 경고 0건, 에러 0건
- python3 -m py_compile: OK
- import server 후 5개 도구 attribute 확인

런타임:
- mcp-server 재시작 시 신규 도구 자동 인식
- 클라이언트는 ListToolsAsync로 동적 수집 — 추가 작업 불필요

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 05:24:36 +09:00
windpacer
5a9d60e8a8 fix: Phase 5 진단 핫픽스 + Phase 6 run_sql 안전 가드
진단 보고서(plans/...phase5-사용자체크리스트.md) 기반 7건 코드 이슈
수정 + Phase 6 잔여 항목 중 최우선인 run_sql 가드 구현.

핫픽스:
- nl2sql_worker.py: _list_drawings 파싱 버그(문자열 분리) HIGH
- nl2sql_worker.py: 5개 async 함수 blocking DB 연결 → to_thread MED
- ExperionDbContext.cs: KB DDL의 {} 문자가 String.Format placeholder로
  오인되어 부팅 실패 → 별도 NpgsqlCommand 사용 HIGH
- KbIngestWorker: 단일 청크 임베딩 실패 시 전체 abort → 부분 인덱싱 LOW
- KbAuthService: 초기 비번 로그 평문 → 마스킹 + 콘솔 분리 출력 LOW
- KbQdrantClient: new HttpClient → IHttpClientFactory LOW
- OllamaController: plant_context.md 매 요청 파일 읽기 → mtime 캐시 LOW

Phase 6 — run_sql 가드:
- _validate_sql 강화: \b 단어 경계로 updated_at 오탐 제거, WITH 허용,
  TRUNCATE/COPY 추가, 다중 세미콜론 차단
- _apply_sql_guards: LIMIT 미지정 시 SELECT * FROM (...) _capped LIMIT 1000
- _execute_sql_internal: 매 호출 SET statement_timeout = 30000
- SQL_MAX_ROWS / SQL_STATEMENT_TIMEOUT_MS 환경변수화
- 응답 JSON에 row_limit 필드 추가
- nl2sql_worker.py의 _run_sql / _query_with_nl에도 동일 적용

기타:
- .gitignore: storage/ 추가 (KB 업로드 원본 디렉토리)
- opencode.json: 모델 항목을 실제 서빙 모델(Qwen3.6-27B-FP8 / 256K)로 동기화

검증:
- dotnet build: 경고 0건, 에러 0건
- python3 -m py_compile: OK
- _apply_sql_guards / _validate_sql 스모크 테스트 통과

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 05:18:06 +09:00
windpacer
908bfe151f feat: Knowledge Base RAG 시스템 + 채팅 LLM 개선 (Phase 0~5 완료)
- KB RAG 전체 파이프라인: 업로드, 파싱(xlsx/pdf/docx/text), 임베딩, Qdrant 인덱싱
- KB 관리 UI(14번 탭): 로그인, 문서 목록, 업로드, 삭제, 재인덱스
- OllamaController: 한글 시스템 프롬프트, plant_context.md 외부 파일화, SSE tool_start/tool_result 이벤트
- 프론트: 툴 실행 카드, KB 인용 링크, 표 자동 렌더, 추천 질문 칩
- nl2sql_worker: history_table.recorded_at 사용, tag_metadata 응답 개선
- DB: KB 테이블 5개 DDL + 시드, pgcrypto 확장
2026-05-13 20:22:27 +09:00
163 changed files with 2436897 additions and 2071 deletions

View File

@@ -0,0 +1,477 @@
/model
⎿ Set model to Opus 4.7
현재 엑셀 출력된 파일을 보면, 시스템 태그명 (예:FICQ, PIA,LIA,TIA,TI,FI,LI,등등)이 instrument,Unmatched 카테고리에
포함되어 있는데, 이걸 어떻게 하는게 나중에 RAG 청킹하는데 유리할까? 유리한 방법에 따라서, 추출 로직을 개선해야
할것같은데?
● 좋은 질문입니다. 먼저 현재 데이터에서 계기 태그가 실제로 어떻게 분산돼 있는지 확인하겠습니다.
● Bash(mcp-server/.venv/bin/python - <<'EOF'
import psycopg, re…)
 === instrument: 474건 ===
prefix 분포: {'PG': 127, 'TG': 54, 'FCV': 48, 'PSV': 40, 'TE': 29, 'LT': 28, 'FICQ': 22, 'BV': 18, 'XV': 18,
'PT': 16, 'LG': 14, 'PCV': 11, 'LCV': 10, 'TCV': 9, 'PRV': 7, 'DP': 5, 'TR': 5, 'PIC': 3, 'TIC': 2, 'LIC': 2,
… +9 lines (ctrl+o to expand)
● 진단 (정량 확인됨)
┌───────────────────────────┬──────┬────────────────────┐
│ 시트 │ 건수 │ 그중 ISA 계기 구조 │
├───────────────────────────┼──────┼────────────────────┤
│ instrument │ 474 │ 469 │
├───────────────────────────┼──────┼────────────────────┤
│ Unmatched (category=NULL) │ 118 │ 99건이 사실 계기 │
└───────────────────────────┴──────┴────────────────────┘
Unmatched 118건 중 99건이 계기인데 빠진 것: FIT(27), LI(19), TI(16), PI(8), TIA(7), FI(4), LIA(4), FIQ(3), PIA(3),
FIA(2)…
진짜 비계기는 19건뿐: UFD, NBD, KF, PFD, BT, CT, KA, KD, PID 등.
원인: PidExtractorService.MatchCategoryAsync가 정적 pid_prefix_rules 테이블 + StartsWith만 사용. FICQ/TICA는 FIC/TIC
룰에 우연히 StartsWith 매칭돼 instrument로, 반면 FIT/LI/TI/PI/TIA/PIA는 해당 룰이 없어 Unmatched로 누락. ISA 조합은
수십~수백 가지라 정적 테이블 열거로는 구조적으로 못 덮습니다. (MCP 서버엔 이미 _classify_pid_tag ISA 구조 분류기가
있는데 C# 분류는 그걸 안 씀.)
RAG 청킹 관점에서 무엇이 유리한가
xlsx_parser는 row 청크를 "{시트명}: 컬럼=값…"으로 임베딩합니다. 즉 시트명이 벡터의 의미 앵커입니다.
- "Unmatched: 태그번호=PIA-9101" → "압력 지시 알람 계기"라는 도메인 의미가 0, "Unmatched"라는 노이즈가 벡터를 계기
클러스터에서 끌어냄 → "압력 알람 계기 목록" 쿼리에서 검색 누락
- 계기가 두 시트로 쪼개지면 sheet 청크도 둘로 갈라져 "계기 전체" 쿼리에 한 청크가 답을 못 담음
- 장비타입=FICQ처럼 raw prefix만 있으면 자연어 쿼리("적산 유량계")와 벡터 거리가 멂
→ 유리한 구성: ① 모든 ISA 계기를 instrument 한 시트로 통합(Unmatched엔 진짜 미상만), ② row 청크에 ISA 디코딩된 한국어
의미를 채워 임베딩 의미 강화 (FICQ → 장비명 "유량 지시 제어 적산기").
→ 그에 따른 로직 개선: MatchCategoryAsync를 배관 regex → ISA 구조 분류기(1순위) → 정적 prefix 룰(장비/유틸 fallback) →
null 순으로. 동시에 ISA 디코딩 결과로 EquipmentName/InstrumentType 자동 채움.
트레이드오프: ISA 분류기를 1순위로 두면 [측정변수][수식문자]-숫자 패턴과 우연히 겹치는 비계기 코드가 계기로 오분류될
수 있음 — 단 P&ID 관례상 이 패턴은 거의 100% 계기라 위험은 낮고, 정적 prefix 룰을 override로 남기면 회사별 예외 처리
가능.
구현 범위를 정해 주세요.
 User declined to answer questions
⎿  · MatchCategoryAsync 분류 로직을 어디까지 개선할까요? (ISA 분류기 1순위 + 타입 자동 채움 (권장) / ISA 분류기
1순위만 (타입 채움 제외) / 정적 prefix 룰만 보강)
· 기존 DB의 Unmatched 99건(이미 적재된 계기)도 재분류할까요? (신규 추출부터만 적용 / 기존 행도 일괄 재분류 (권장))
● 무엇을 명확히 하고 싶으신지 듣기 전에, 말씀하신 "리얼타임 테이블 연결고리" 관점이 실제 데이터로 성립하는지 빠르게
확인하겠습니다.
● Bash(mcp-server/.venv/bin/python - <<'EOF'
import psycopg, re…)
 realtime 관련 테이블: ['realtime_table']
pid_equipment 컬럼: ['id', 'tag_no', 'equipment_name', 'instrument_type', 'line_number', 'pid_drawing_no', 'co
nfidence', 'is_active', 'extracted_at', 'updated_at', 'experion_tag_id', 'category', 'role', 'from_tag', 'to_t
ag']
 Interrupted · What should Claude do instead?
이미 매핑 정보로 연결고리가 살아있어
● Bash(mcp-server/.venv/bin/python - <<'EOF'
import psycopg, re…)
⎿  전체 1161건 중 realtime 연결 114건
Unmatched 118건 중 realtime 연결 44건
instrument 474건 중 realtime 연결 40건
… +9 lines (ctrl+o to expand)
● 맞습니다. 데이터로 확인하니 말씀하신 "매핑 연결고리"가 핵심 신호였습니다.
데이터가 말하는 것
┌────────────────────────┬──────┬──────────────────────────────────────────────────────────────────────────┐
│ 구분 │ 건수 │ realtime 연결 │
├────────────────────────┼──────┼──────────────────────────────────────────────────────────────────────────┤
│ Unmatched 전체 │ 118 │ 44건 연결됨 │
├────────────────────────┼──────┼──────────────────────────────────────────────────────────────────────────┤
│ Unmatched + 연결됨 │ 44 │ LI(17) TI(16) PI(6) FIQ(3) LIA(2) — 전부 확실한 계기 │
├────────────────────────┼──────┼──────────────────────────────────────────────────────────────────────────┤
│ Unmatched + 연결 안 됨 │ 74 │ 계기(FIT27 TIA7 FI4 PIA3) + 진짜 미상(UFD6 NBD5 PFD2 KF2 BT CT PID) 혼재 │
├────────────────────────┼──────┼──────────────────────────────────────────────────────────────────────────┤
│ instrument 474건 중 │ — │ 40건만 연결 (PG/TG/PSV/BV 등 로컬·기계식은 DCS에 없음 — 정상) │
└────────────────────────┴──────┴──────────────────────────────────────────────────────────────────────────┘
핵심: experion_tag_id IS NOT NULL = DCS에 실재하는 시스템 태그 = ground truth 계기 신호. regex보다 강한 확정
신호입니다. 다만 DCS에 안 올라오는 로컬 계기(PG/TG/PSV)는 연결이 없으므로, 연결 신호만으로는 부족하고 ISA 구조 분류가
보완해야 합니다.
그래서 제안하는 분류 우선순위
MatchCategoryAsync를:
1. 배관 regex → pipings
2. experion_tag_id 있음 → instrument ← 매핑 연결고리를 1순위 확정 신호로 사용 (사용자 지적점)
3. ISA 구조 분류기 → instrument (연결 없는 로컬 계기 FIT/TIA/FI/PIA/PG/TG 커버) + 타입 디코딩
4. 정적 prefix 룰 → 장비/유틸 fallback
5. 그 외 → null (진짜 미상 UFD/NBD/PFD/KF/BT/PID만 Unmatched에 남음)
RAG 관점에서: 연결된 행은 row 청크에 이미 Experion 태그=LI-9111.PV가 들어가 있어 "P&ID 심볼 → 라이브 태그" 다리가
살아있습니다. 이 로직은 그 행들을 Unmatched(의미 없는 시트명 노이즈)에서 instrument(의미 앵커)로 옮겨, 연결고리는
유지하면서 임베딩 의미만 강화합니다.
트레이드오프: 2번(연결=계기) 규칙은, 만약 매핑 단계에서 계기가 아닌 태그가 잘못 연결됐다면 그 오류를 instrument
카테고리로 전파시킵니다. 단 매핑은 confidence ≥ 0.7 + 폴백이라 비교적 보수적이라 위험은 낮습니다.
이 우선순위(연결고리 1순위 + ISA 보완)로 진행할지, 그리고 적용 범위를 정해 주세요.
 User declined to answer questions
⎿  · 분류 우선순위 설계를 확정할까요? (연결고리 1순위 + ISA 보완 + 타입 디코딩 (권장) / 연결고리 1순위 + ISA 보완
(타입 채움 제외) / ISA 구조만 (연결고리 미사용))
· 적용 범위는요? (로직 + 기존 행 일괄 재분류 (권장) / 로직만 개선 (신규 추출부터))
● 설계를 확정하기 전에, 어떤 점을 더 논의하고 싶으신지 듣고 싶습니다. 짚어주실 만한 지점을 몇 가지 미리 정리하면:
- 연결고리의 의미 — "매핑 정보로 연결고리가 살아있다"는 게, 카테고리 분류 자체를 바꾸자는 게 아니라 RAG 청크에
P&ID↔Experion 매핑이 잘 실리는지가 핵심이라는 뜻인가요? (즉 분류보다 청크 내용/연결 표현이 본질인지)
- Unmatched를 그대로 둘지 — 연결고리가 이미 살아있으니 굳이 instrument로 옮기지 말고, 시스템 태그(FICQ/PIA/LI/TI…)는
Unmatched에 두되 RAG에서 realtime 연결로 찾게 하자는 구상인지?
- 분류 vs 매핑의 역할 분담 — 카테고리는 엑셀 시트 정리용일 뿐이고, 실제 가치는 experion_tag_id 매핑이라 거기에
집중하자는 의미인지?
- 추출 로직 개선의 진짜 목표 — 처음 말씀하신 "추출 로직 개선"이 카테고리 분류 정확도인지, 아니면 매핑(experion_tag_id)
적중률인지, 아니면 RAG 청크 품질인지?
어떤 부분을 명확히 하고 싶으신가요? 편하게 말씀해 주세요.
✻ Cogitated for 1m 48s
위의 실현계획 다 좋은데, 추출해서 ISA 규칙에 따라서, FICQ, TIC, TICA, FI,FIA, TIA, 등등을 계기로 분류하는게
맞냐는거지, 이건 ISA RULE에서도 SYSTEM TAG (DCS, PLC)등으로 구분하거든, (현장,재래식 PID 컨트롤러, 적산계 등등은)
기호가 달라서, 그건 실물 기기가 존재하므로, INSTRUMENT로 분류해도 되는데, 이건 DCS, SCADA 시스템 내에 존재하는
데이터베이스의 개념이거든, 이걸 그냥 계기로 취급하는게 나을까? 좋은생각 있어?
● 좋은 지적입니다 — 그건 단순 분류 버그가 아니라 개념 모델링 문제네요. 정리하면:
핵심: 이 둘은 ISA에서도 다른 종류입니다
┌────────────────────────┬───────────────────────────────────────┬─────────────────┬──────────────────────────────┐
│ 구분 │ 예 │ ISA 심볼 │ 실체 │
├────────────────────────┼───────────────────────────────────────┼─────────────────┼──────────────────────────────┤
│ 현장 실물 계기 │ PG, TG, PSV, 재래식 로컬 컨트롤러, │ 원(field) │ 물리적 기기가 실재 │
│ │ 기계식 적산계, 현장 트랜스미터 │ │ │
├────────────────────────┼───────────────────────────────────────┼─────────────────┼──────────────────────────────┤
│ 시스템 │ FICQ, TIC, TICA, FI, FIA, TIA, │ 사각형 안 원 / │ 제어시스템 DB 안의 소프트 │
│ 태그(DCS/SCADA/PLC) │ LI/TI/PI │ 육각형 │ 함수블록·포인트 │
└────────────────────────┴───────────────────────────────────────┴─────────────────┴──────────────────────────────┘
그래서 FICQ/TIC/TICA/FIA/TIA를 그냥 "계기(실물)"로 뭉뚱그리는 건 의미상 틀립니다. 이것들은 DCS 데이터베이스
포인트이고, 정확히 그래서 realtime_table에 존재하는 것들입니다. 앞 데이터가 이를 증명합니다 — Unmatched+realtime연결
44건이 전부 LI/TI/PI/FIQ/LIA(= DCS 포인트), 반대로 instrument 474건 중 realtime 연결은 40건뿐(나머지는 PG/TG/PSV 실물
현장계기, DCS에 없음).
그래서 제 생각: 둘을 나누는 게 ISA적으로도 RAG에도 유리
realtime_table 연결(experion_tag_id)이 곧 "DCS/SCADA DB에 실재하는 시스템 태그"의 ground truth입니다(realtime 테이블
자체가 DCS DB니까). regex 추정이 아니라 확정 신호죠. RAG에서도 시트명이 임베딩 앵커라, "시스템태그(DCS): ..." vs
"현장계기: ..."로 갈리면 "DCS 적산 데이터" / "현장 압력게이지" 같은 자연어 쿼리가 정확히 다른 클러스터로 매칭됩니다 —
도메인 전문가가 실제로 질의하는 방식과도 일치.
다만 구조적 회색지대가 있습니다: FT-9101(현장 트랜스미터)은 물리 기기이면서 동시에 DCS 입력 포인트이기도 함. 이건 ISA
함수문자로 가릅니다 — 1차 측정요소/순수 전송(FT,PT,TE,FE)은 현장계기, 지시·제어·알람·적산 함수(I/C/A/Q/Y/R 포함:
FIC,TICA,FIA,FICQ,LI)는 시스템 함수블록.
두 가지 구현 방향 (트레이드오프)
A안 — 새 카테고리/시트 분리: system_tag("시스템태그(DCS/SCADA)") 추가. 분류: 배관→pipings / experion_tag_id있음 or
ISA함수에 I·C·A·Q·Y 포함 → system_tag / 1차요소·전송만 → instrument / 장비 prefix → 장비 / 그 외 → Unmatched.
- 장점: 의미적으로 가장 정확, RAG 시트 앵커가 명확
- 단점: 스키마 표면 증가(enum/시트/룰), 회색지대 태그가 시트 단위로 갈려 한 번 틀리면 시트째 오분류
B안 — 단일 instrument 유지 + 하위 분류 컬럼: 카테고리는 instrument 하나로 두되 tag_class = field | system 컬럼 추가.
row 청크 텍스트에 태그분류=시스템(DCS)이 자동으로 실려 임베딩은 구분되지만 시트는 안 쪼갬.
- 장점: 최소 침습, 회색지대 오분류가 시트를 망치지 않음, RAG 임베딩엔 동일하게 구분 신호 들어감
- 단점: 엑셀에서 시각적 시트 분리는 안 됨(필터/정렬로만)
제 추천은 RAG가 목표라면 B안입니다 — xlsx_parser가 컬럼값을 row 청크 텍스트에 그대로 넣으므로 임베딩 의미 분리 효과는
A안과 동일하면서, 회색지대 태그를 잘못 갈라 sheet 청크를 오염시킬 위험이 없습니다. 시각적 시트 분리가 운영상 꼭
필요하면 A안.
어느 방향으로 갈지, 그리고 회색지대(FT 등 전송기) 처리 기준을 "experion 연결 우선" vs "ISA 함수문자 우선" 중 무엇으로
둘지 정해주시면 그대로 구현하겠습니다.
✻ Brewed for 1m 18s
B안으로 진행해줘, experion 연결 우선 기준으로
● B안 + Experion 연결 우선으로 구현하겠습니다. 먼저 관련 파일을 확인합니다.
Read 1 file (ctrl+o to expand)
● Bash(cd /home/windpacer/projects/ExperionCrawler; sed -n '545,576p' src/Infrastructure/Database/ExperionDbContext.cs;
echo "=== PidController endpoints ==="; grep -n…)
  progress DOUBLE PRECISION NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'Pending',
message VARCHAR(500) NOT NULL DEFAULT '',
… +25 lines (ctrl+o to expand)
Read 1 file (ctrl+o to expand)
 Interrupted · What should Claude do instead?
현재 Unmatched로 분류된 시스템 태그는 내가 추가하면 되니까 그건 놔둬
● 이해했습니다. Unmatched 카테고리 로직은 건드리지 않고(직접 prefix 룰 추가하실 거니까), B안 tag_class 서브분류만
experion 연결 우선으로 구현하겠습니다. Excel 출력 부분을 확인합니다.
Read 1 file (ctrl+o to expand)
● 모든 정보를 확보했습니다. 구현하겠습니다.
1. PidEquipment 엔티티에 TagClass 추가
● Update(src/Core/Domain/Entities/PidEquipment.cs)
 Added 11 lines
66 [Column("category")]
67 public string? Category { get; set; }
68
69 + // ── 계기 하위 분류 (B안: instrument 내 현장 vs 시스템) ──
70 + // field = 현장 실물 계기 (PG/TG/PSV/1차 측정요소·전송기 등 물리 기기)
71 + // system = DCS/SCADA/PLC DB 포인트 (지시·제어·알람·적산 함수 블록)
72 + // instrument 카테고리에만 의미, 그 외는 null
73 + public const string TagClassField = "field";
74 + public const string TagClassSystem = "system";
75 +
76 + [MaxLength(10)]
77 + [Column("tag_class")]
78 + public string? TagClass { get; set; }
79 +
80 [MaxLength(100)]
81 [Column("role")]
82 public string? Role { get; set; }
● 2. 스키마 마이그레이션 — tag_class 컬럼 추가
● Update(src/Infrastructure/Database/ExperionDbContext.cs)
 Added 4 lines
573 END $$;
574 """);
575
576 + // pid_equipment 계기 하위분류 컬럼 (field/system) — 기존 DB에도 안전 추가
577 + await _ctx.Database.ExecuteSqlRawAsync(
578 + "ALTER TABLE pid_equipment ADD COLUMN IF NOT EXISTS tag_class TEXT;");
579 +
580 // pid_prefix_rules 테이블
581 await _ctx.Database.ExecuteSqlRawAsync("""
582 CREATE TABLE IF NOT EXISTS pid_prefix_rules (
● 3. ClassifyTagClass 추가 (experion 연결 우선, MatchCategoryAsync는 그대로 유지)
● Update(src/Core/Application/Services/PidExtractorService.cs)
 Added 36 lines
524 tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category;
525 }
526
527 + // 태그 선두 알파벳 (첫 비알파벳 이전) — ISA 기능코드 후보
528 + private static readonly Regex _tagPrefixRe = new(@"^([A-Za-z]+)", RegexOptions.Compiled);
529 +
530 + // 제어시스템 함수 후속문자: I(지시) C(제어) A(알람) Q(적산) Y(연산) R(기록)
531 + // → 이 함수들은 현장 기기가 아니라 DCS/SCADA/PLC 내부에서 구현됨
532 + private static readonly HashSet<char> _systemFuncLetters =
533 + ['I', 'C', 'A', 'Q', 'Y', 'R'];
534 +
535 + /// <summary>
536 + /// 계기(instrument) 하위 분류. experion(realtime) 연결을 1순위 확정 신호로 사용.
537 + /// experion 연결됨 → system (DCS/SCADA DB 포인트, ground truth)
538 + /// ISA 후속문자에 I/C/A/Q/Y/R → system (제어시스템 함수 블록)
539 + /// 그 외(1차 측정요소·전송·게이지·기계식) → field (현장 실물 기기)
540 + /// instrument 가 아닌 카테고리는 null.
541 + /// </summary>
542 + private static string? ClassifyTagClass(string tagNo, string? category, bool hasExperionLink)
543 + {
544 + if (category != PidEquipment.CategoryInstrument)
545 + return null;
546 +
547 + if (hasExperionLink)
548 + return PidEquipment.TagClassSystem;
549 +
550 + var m = _tagPrefixRe.Match(tagNo);
551 + if (m.Success && m.Groups[1].Value.Length >= 2)
552 + {
553 + var prefix = m.Groups[1].Value.ToUpperInvariant();
554 + // 첫 글자(측정변수) 이후 후속문자에 제어시스템 함수가 있으면 system
555 + for (int i = 1; i < prefix.Length; i++)
556 + if (_systemFuncLetters.Contains(prefix[i]))
557 + return PidEquipment.TagClassSystem;
558 + }
559 +
560 + return PidEquipment.TagClassField;
561 + }
562 +
563 // ── Prefix Rule CRUD ───────────────────────────────────────────────────────
564
565 public async Task<List<PidPrefixRule>> GetPrefixRulesAsync()
● 4. 추출 시점에 TagClass 설정 (experion 연결 정보가 이미 있는 지점)
● Update(src/Core/Application/Services/PidExtractorService.cs)
 Added 2 lines
84 : await FindFallbackTagAsync(item.TagNo);
85
86 var category = await MatchCategoryAsync(item.TagNo);
87 + var tagClass = ClassifyTagClass(item.TagNo, category, experionTag != null);
88
89 dbItems.Add(new PidEquipment
90 {
...
96 Confidence = item.Confidence,
97 ExperionTagId = experionTag?.Id,
98 Category = category,
99 + TagClass = tagClass,
100 ExtractedAt = DateTime.UtcNow,
101 UpdatedAt = DateTime.UtcNow
102 });
● 5. ApplyCategoriesToExistingAsync — 기존 instrument 행 tag_class backfill 추가 (Unmatched/category 로직은 그대로)
● Update(src/Core/Application/Services/PidExtractorService.cs)
 Added 20 lines
631 if (category != null)
632 {
633 item.Category = category;
634 + item.TagClass = ClassifyTagClass(item.TagNo, category, item.ExperionTagId != null)
+;
635 item.UpdatedAt = DateTime.UtcNow;
636 total++;
637 }
638 }
639 await _dbContext.SaveChangesAsync();
640 }
641 +
642 + // 이미 instrument 로 분류됐으나 tag_class 미지정인 기존 행 backfill
643 + // (Unmatched/category 자체는 건드리지 않음)
644 + while (true)
645 + {
646 + var batch = await _dbContext.PidEquipment
647 + .Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagClass == null)
648 + .Take(batchSize)
649 + .ToListAsync();
650 + if (!batch.Any()) break;
651 +
652 + foreach (var item in batch)
653 + {
654 + item.TagClass = ClassifyTagClass(item.TagNo, item.Category, item.ExperionTagId != null
+);
655 + item.UpdatedAt = DateTime.UtcNow;
656 + total++;
657 + }
658 + await _dbContext.SaveChangesAsync();
659 + }
660 return total;
661 }
662 }
● 6. Excel 출력에 태그분류 컬럼 추가 (RAG row 청크에 의미 실림)
● Update(src/Core/Application/Services/PidExtractorService.cs)
 Added 2 lines, removed 1 line
449 worksheet.Cells[1, 11].Value = "Role";
450 worksheet.Cells[1, 12].Value = "From";
451 worksheet.Cells[1, 13].Value = "To";
452 + worksheet.Cells[1, 14].Value = "태그분류";
453
453 - using var headerRange = worksheet.Cells[1, 1, 1, 13];
454 + using var headerRange = worksheet.Cells[1, 1, 1, 14];
455 headerRange.Style.Font.Bold = true;
456 headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid;
457 headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);
● Update(src/Core/Application/Services/PidExtractorService.cs)
 Added 6 lines
472 worksheet.Cells[row, 11].Value = item.Role ?? "";
473 worksheet.Cells[row, 12].Value = item.FromTag ?? "";
474 worksheet.Cells[row, 13].Value = item.ToTag ?? "";
475 + worksheet.Cells[row, 14].Value = item.TagClass switch
476 + {
477 + PidEquipment.TagClassSystem => "시스템(DCS)",
478 + PidEquipment.TagClassField => "현장",
479 + _ => ""
480 + };
481 row++;
482 }
483
Read 1 file (ctrl+o to expand)
● Update(src/Core/Application/Services/PidExtractorService.cs)
 Added 2 lines, removed 2 lines
378 return await Task.Run(() =>
379 {
380 var sb = new StringBuilder();
381 - sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsAct
-ive,ExtractedAt,ExperionTagId,Category,Role,From,To");
381 + sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsAct
+ive,ExtractedAt,ExperionTagId,Category,Role,From,To,TagClass");
382 foreach (var i in items)
383 - sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.L
-ineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId},{Csv
-(i.Category)},{Csv(i.Role)},{Csv(i.FromTag)},{Csv(i.ToTag)}");
383 + sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.L
+ineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId},{Csv
+(i.Category)},{Csv(i.Role)},{Csv(i.FromTag)},{Csv(i.ToTag)},{Csv(i.TagClass)}");
384 return sb.ToString();
385 });
386 }
● Bash(dotnet build src/Web/ExperionCrawler.csproj -clp:ErrorsOnly 2>&1 | tail -8)
 MSBuild version 17.8.49+7806cbf7b for .NET
Build succeeded.
… +4 lines (ctrl+o to expand)
● 빌드 통과. 로직을 실제 DB 데이터로 검증합니다.
● Bash(mcp-server/.venv/bin/python - <<'EOF'
import psycopg, re…)
 instrument 474건 → tag_class 분포: {'system': 136, 'field': 338}
prefix별 field/system 판정 (대표):
… +25 lines (ctrl+o to expand)
● 검증 결과 분류가 도메인 모델과 정확히 일치합니다.
구현 완료 (B안 + experion 연결 우선)
변경 파일
┌────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────┐
│ 파일 │ 변경 │
├────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ PidEquipment.cs │ TagClass 컬럼 + TagClassField/TagClassSystem 상수 │
├────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ ExperionDbContext.cs │ ALTER TABLE pid_equipment ADD COLUMN IF NOT EXISTS tag_class (기존 DB 안전) │
├────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ PidExtractorService.cs │ ClassifyTagClass() 추가, 추출 시 설정, ApplyCategoriesToExistingAsync backfill, │
│ │ Excel/CSV에 태그분류 컬럼 │
└────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
분류 규칙 (ClassifyTagClass) — MatchCategoryAsync(prefix 룰)는 그대로 유지, Unmatched 로직 미변경:
1. instrument 아니면 → null
2. experion 연결됨 → system (DCS/SCADA DB 포인트, ground truth, 1순위)
3. ISA 후속문자에 I/C/A/Q/Y/R 포함 → system (지시·제어·알람·적산·연산·기록 = 제어시스템 함수)
4. 그 외 → field (현장 실물 기기)
실데이터 검증 (instrument 474건 → field 338 / system 136)
┌──────────────┬─────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│ 판정 │ 대표 prefix │ 도메인 해석 │
├──────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ field │ PG(127) TG(54) PSV(40) TE(29) BV(18) DP(5) │ 게이지·안전밸브·1차요소·기계식 = 현장 실물 ✓ │
├──────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ system │ FCV/PCV/LCV/TCV FICQ(22) PIC/TIC TICA/PICA │ 제어밸브·적산·지시제어·알람·기록 = DCS 함수블록 ✓ │
│ │ TR │ │
├──────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ system │ 40건 │ experion 연결된 전송기 등 → 우선 규칙대로 system │
│ (강제) │ │ ✓ │
└──────────────┴─────────────────────────────────────────────┴───────────────────────────────────────────────────┘
연결 안 된 PT/LT는 field(현장 전송기 실물), 연결된 것은 system(DCS 포인트) — "experion 연결 우선" 의도대로 동작.
RAG 효과: xlsx_parser가 컬럼값을 row 청크 텍스트에 그대로 넣으므로, 각 행 임베딩에 태그분류=시스템(DCS) /
태그분류=현장이 실려 "DCS 적산 데이터" vs "현장 압력게이지" 류 질의가 의미적으로 분리됩니다. 시트 구조는 그대로라
회색지대 오분류 위험 없음.
기존 1161건 반영: DB 변경은 하지 않았습니다. RAG 관리/P&ID 페이지의 기존 POST /api/pid/apply-categories 버튼을
호출하면 기존 instrument 행의 tag_class가 backfill됩니다(Unmatched·카테고리는 안 건드림). 다음 P&ID 추출/재추출분은
자동 적용됩니다.

View File

@@ -6,7 +6,7 @@
"/home/windpacer/projects/ExperionCrawler/mcp-server/server.py"
],
"env": {},
"description": "ExperionCrawler RAG — Qdrant(코드베이스+OPC UA 문서) + GLM-4.7-Flash"
"description": "ExperionCrawler RAG — Qdrant(코드베이스+OPC UA 문서), 현재 LLM은 mcp-server/llm-model.json 참조"
}
}
}

3
.gitignore vendored
View File

@@ -58,3 +58,6 @@ env/
.pytest_cache/
.mypy_cache/
.ipynb_checkpoints/
# KB 업로드 원본 파일 (런타임 데이터)
storage/

View File

@@ -215,7 +215,7 @@ Vanilla JS SPA in `wwwroot/`. No build step. Tab navigation, no auto-fire on ent
**Python MCP server dependencies:** `mcp[cli]`, `fastapi`, `qdrant-client`, `sentence-transformers`, `openai`, `httpx`, `psycopg`, `ezdxf`, `paddleocr`, `pymupdf`
**Python infra stack:**
- **LLM:** vLLM serving Qwen3.6-27B-FP8 at `http://localhost:8000/v1`
- **LLM:** vLLM serving Qwen3.6-35B-A3B-FP8 at `http://localhost:8001/v1`
- **Embeddings:** Ollama `nomic-embed-text` at `http://localhost:11434`
- **Vector DB:** Qdrant at `http://localhost:6333` (2 collections: codebase + OPC docs)
- **Task workers:** `worker/rag_worker.py` (:5002), `worker/nl2sql_worker.py` (:5003)

View File

@@ -26,6 +26,23 @@ All controllers are in `src/Web/Controllers/ExperionControllers.cs` (single file
**PostgreSQL** (NOT SQLite — README is stale). Connection strings in `src/Web/appsettings.json`. TimescaleDB extension may be enabled on `history_table` via DDL only; no app code changes needed.
### ⚠️ Critical — `duration_seconds` semantics
`event_history_table.duration_seconds` = **직전 상태의 지속 시간(초)**. 현재 이벤트의 지속 시간이 아니다.
- `DigitalEventDetectorService`가 이전 상태를 처음 관측한 시점부터 값이 바뀔 때까지의 경과 시간(wall-clock)을 기록
- 예: `prev=R-RUN, curr=R-TRIP, duration_seconds=1423`**R-RUN 상태가 1423초간 유지되다가** R-TRIP으로 전환된 것. TRIP이 1423초간 지속된 게 아님
- MCP 툴(`query_events`, `active_alarms`) JSON 출력 시 `prev_state_duration_s` 필드명으로 변환하여 반환하므로 LLM이 필드명만 보고 의미를 알 수 있음
- LLM 프롬프트에 이벤트 데이터를 넘길 때는 `(직전상태유지={duration}s)` 형식으로 전달
### ⏰ Timezone — KST
DB는 UTC 저장, LLM에는 **KST(UTC+9) 변환**해서 전달.
- `server.py``_kst_str()` 함수로 UTC ISO 문자열 → KST ISO 문자열 변환
- 모든 시스템 프롬프트에 "모든 시각은 KST"라고 명시
- MCP 서버 재기동 필요 시 `uv run server.py --http`
## Critical Convention — JSON camelCase
`PropertyNamingPolicy = null` in Program.cs means C# PascalCase becomes JSON keys. **Frontend expects camelCase**. Every controller `Ok(...)` response MUST use explicit anonymous objects with camelCase keys:

204
AnalyzeConnectionsAsync.md Normal file
View File

@@ -0,0 +1,204 @@
# AnalyzeConnectionsAsync — 방향표지판 기반 P&ID 연결 분석
> 작성: 2026-05-18
> 다음 작업 예정: 2026-05-19
---
## 오늘 한 일 (2026-05-18)
### 배경
기존 `AnalyzeConnectionsAsync()`는 loop 번호 그룹 + 최단거리 anchor 방식이라 직관적 배관 연결을 못 잡았다. 방향표지판(▶ 사각형+V자) 패턴을 검출해서 LINE endpoint 추적 + 태그 매칭으로 실제 배관 연결을 분석하는 방법을 시뮬레이션(`sim_line_connection.py` 확장)으로 검증.
### 접근법: 3단계 연결 검출
1. **방향표지판 검출** — 머지된 수평선쌍 + 왼쪽수직선 + 오른쪽V자대각선≥2
2. **LINE endpoint 단순 추적** — 머지된 LINE의 한쪽 endpoint → 반대쪽 endpoint: 거기에 뭐가 있는지 체크(다른마커/장비/빈곳)
3. **태그 매칭** — 같은 태그의 마커가 서로 다른 위치에 있으면 배관으로 연결됨
### 실행 결과
**방향표지판 검출:**
- 전체: 418개 (관심영역 y≥5100: 106개)
- 태그 매칭 연결: 63/106개 (59%)가 동일태그 그룹(21개 그룹)에 속함
**주요 배관 경로:**
```
T-10100(1656) ↔ T-10100(1989) ↔ T-3210(1989) ↔ T-3210(2439) ↔ T-10200(2439) ↔ T-10200(4611) ↔ C-10211 칼럼
T-9124(1657) ↔ T-9124(1989) ↔ T-9124(2004) ↔ T-10101(2004)
P-10101(2050) ↔ E-10119(2024) ↔ T-10101/T-9124 (성분 6)
```
**장비 연결:**
| 장비 | 연결된 마커 | 방식 |
|------|------------|------|
| C-10211 VLINE (x≈4545) | 1F~4F 유틸리티, C-10111, C-10211, D-10113/10213 | 좌표근접(50u) |
| COOLING TOWER(CT-10601) VLINE | EYE SHOWER, SCRUBBER, UTILITY | 좌표근접 |
| FCV-10101 VLINE (x≈5034) | 2F~5F 유틸리티 | 좌표근접 |
| EMERGENCY VLINE | SC-9128 | 좌표근접 |
---
## ✅ 성과 (Good)
### 1. 방향표지판 검출 알고리즘 안정적
- 직사각형(가변폭/높이, 글자수 따라 변화) + V자 45° 사선 검출 성공적
- 관심영역 129개 → 사용자 "거의 맞는군" 확인
- 머지된 LINE + raw LINE 조합으로 사각형+V자 동시 검출
### 2. 단순 추적(endpoint→endpoint)이 대부분 동작
- 머지된 LINE의 한쪽 끝 → 반대쪽 끝만 보는 방식으로 중간 junction 무시
- 106개 마커 중 대부분에서 추적 성공 (좌/우 중 하나라도 연결 발견)
- 19개 연결성분 자동 분류 성공
### 3. 태그 매칭으로 장거리 배관 연결 포착
- T-10100: 좌측(1656) → 중앙(1989) → 우측(4611) — 2956u 스팬
- T-10101: (1972)→(1990)→(2004)→(4612) — 2640u 스팬
- T-10200: (2102)→(2439)→(4611) — 2509u 스팬
### 4. 장비-마커 연결 (50u 근접 기준)
- VLINE 칼럼 장비(C-10211, FCV-10101, CT-10601, EMERGENCY) 연결 성공
- 큰 원(r≥4.5) 장비 16개 검출
### 5. 발견한 P&ID 패턴
- 같은 태그가 여러 위치에 있음 = 배관 연결
- 장비는 긴 수직LINE(≥100u) + TEXT 태그 근접
- 방향표지판의 V자 방향 = 유체 흐름 방향 (▶ = 오른쪽으로 흐름)
---
## ❌ 문제점 / 개선 필요 (Bad)
### 1. P-10101 마커 endpoint tracing 실패 (Highest Priority)
```
P-10101@(2050,5289) ←L→ 자기자신
```
- P-10101 마커 좌측 LINE은 3.9u짜리 짧은 stub
- 실제 배관은 (2040.2,5289.3)에서 20u 떨어진 y=5287.2에서 이어짐
- 원인: 배관이 수평→수직→수평으로 굽혀져(jog) 머지 안 됨
- LINE#9513(1911→2019, y=5287.2) → LINE#9514(수직↑) → LINE#9663(수평→) → stub→P-10101
- **해결방안**: 직각 굽힘 허용 trace 필요 (1-2 hop BFS with angle check)
### 2. 장비-마커 연결 threshold 문제
- C-10211 칼럼(x≈4545)과 마커(x≈4585) 사이 40u 간격
- 현재 25u threshold로는 연결 누락 → 50u로 완화했지만 더 많은 튜닝 필요
- 큰 원 장비는 거의 연결 안 됨 (마커 태그 = P-10101인데 P-10101 원 검출 안 됨?)
### 3. 장비 검출 한계
- `P-10101` 펌프 원(r≈1.98)은 r≥4.5 threshold보다 작아서 장비 미검출
- 작은 장비(r<4.5) 검출 기준 추가 필요
- 실제 P&ID에서 펌프는 r≈2.0의 작은 원으로 표현
### 4. 방향표지판 방향 의미 미활용
- V자 방향(▶/◀)이 "어디서→어디로"를 나타내지만 아직 사용 안 함
- ▶ 마커: 좌측 = 배관 도착, 우측apex = 배관 출발
- ◀ 마커: 반대 (거의 없지만 처리 필요)
### 5. 연결성분 그래프에 P-10101→C-10111 경로 누락
- 이전 수동 분석으로 확인된 P-10101→볼밸브→주름관→체크밸브→Reducer→MASS→FCV-10101→C-10111
- 자동 분석 성분 6은 P-10101 주변만 포착, C-10111까지 연결 안 됨
- 이유: P-10101 마커 tracing 실패 + FCV-10101와 E-10103 연결 누락
### 6. 미연결 마커 (43/106개)
- 대부분 단일 위치에만 존재하는 유틸리티 마커 (SAMPLE, STEAM, CWS/CWR 등)
- 일부는 태그가 비어서 그룹화 불가
---
## 현재 알고리즘 개요
### 방향표지판 검출
```
h_seg 수평선쌍(xl,xr 일치, y간격 2~8u)
→ 왼쪽수직선 확인(has_vert)
→ 오른쪽V자대각선≥2 확인(cnt_diag)
→ 태그 TEXT 찾기(25u 이내)
```
### LINE endpoint 연결 검출
```
마커 좌/우측 포인트 → nearest merged LINE endpoint (30u 이내)
→ 그 LINE의 반대쪽 endpoint
→ what_at(): 마커? 장비? 빈곳?
```
### 태그 매칭 연결
```
같은 tag의 마커가 다른 위치에 있음 = 배관 연결됨
```
### 장비 검출
```
원: CIRCLE r≥4.5 + 근접 TEXT 태그
수직LINE: 머지LINE dy≥100, dy>dx*3, 근접 TEXT 태그
```
---
## 데이터
### 방향표지판 속성
| 필드 | 설명 |
|------|------|
| tag | 내부 TEXT (25u 이내) |
| mx, my | 중심좌표 |
| xl, xr | 좌/우 X 좌표 |
| ylo, yhi | 상/하 Y 좌표 |
| dr | 오른쪽 V자 대각선 개수 |
### 장비 속성
| 필드 | 설명 |
|------|------|
| tag | 근접 TEXT |
| kind | CIRCLE / VLINE |
| x, y | 중심 (VLINE은 x만) |
| r | 반지름 (CIRCLE만) |
| y_min, y_max | 수직범위 (VLINE만) |
### 연결 엣지
| 타입 | from | to | 조건 |
|------|------|----|------|
| MARKER→MARKER | marker tag@pos | marker tag@pos | LINE endpoint tracing |
| MARKER→EQ | marker tag@pos | equipment tag | LINE endpoint + 근접도(50u) |
| TAG_MATCH | marker tag@pos | same tag@pos | 다른 위치 → 배관 연결 |
---
## 오늘 한 일 (2026-05-18, Code)
### Priority 1: P-10101 경로 추적 수정 ✓
- [x] 직각 굽힘(수평→수직→수평)을 허용하는 2-hop BFS trace 구현
- [x] LINE endpoint에서 다음 LINE 찾을 때 각도 무관(모든 연결 허용)
- [ ] P-10101(2050,5289) → FCV-10101 → C-10111(4585,5237) 전체 경로 검증 (부분)
- P-10101 마커 검출 OK (2046,5289)
- 2-hop BFS trace: P-10101 좌/우 → 모두 P-10101 marker (자기자신 추적)
- FCV-10101 TEXT (4987,5232) 방향표지판 미검출 — C-10111도 동일
- **원인**: 배관 경로가 3+ hop 이상 (stub→수평→수직→수평), 2-hop으로 부족
- **차후**: 3-hop BFS 또는 태그매칭 우회 필요
### Priority 2: 장비 검출 개선 ✓
- [x] 작은 원(r<4.5, r≥1.5)도 장비 후보로 추가 → 41개 검출
- [x] 원 안 TEXT(tag)가 아닌 근접 TEXT로 장비명 결정 (기존과 동일 로직)
- [x] 장비-마커 근접 threshold 동적 조정 (limit=30 → equipment trace에서 활용)
### Priority 3: 시뮬레이션 코드 정리 ✓
- [x] `sim_line_connection.py`에 방향표지판 검출 + trace + 태그매칭 통합
- [ ] 결과 JSON 출력 (edges + components)
- [ ] C-10111 DXF 별도 검증 (c-10111.dxf)
## 내일 할 일 (2026-05-19)
### Priority 3-2: JSON 출력 + C-10111 검증
- [ ] 연결 결과 JSON 출력 (marker_edges, tag_match_edges, components)
- [ ] `c-10111.dxf` 별도 검증 실행
### Priority 4: C# AnalyzeConnectionsAsync 재작성
- [ ] 현재 loop+anchor 거리 방식 → 방향표지판+태그매칭+LINE trace 방식으로 교체
- [ ] `PidEquipment` 테이블에 방향표지판 속성 컬럼 추가 검토
- [ ] 기존 테스트 통과 확인
### Priority 5: 방향 의미 활용
- [ ] V자 방향(▶/◀)으로 from_tag/to_tag 방향성 부여
- [ ] 태그가 붙은 마커 = "이 배관은 XXX에 속함" 정보로 equipment tag 식별
### Priority 6: P-10101 3+ hop trace
- [ ] 3-hop BFS로 P-10101(2046,5289) → FCV-10101 → C-10111 경로 완전 추적

685
CLAUDE.md
View File

@@ -7,638 +7,75 @@
## 완료된 작업
### 기능 추가 — OPC UA 서버 기능 (2026-04-15)
### Phase 7 + Phase 5 후순위 일괄 구현 (2026-05-14)
#### 배경
ExperionCrawler가 OPC UA 클라이언트 역할만 했으나, 외부 OPC UA 클라이언트(SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽을 수 있도록 OPC UA 서버 기능 추가.
`plans/빅피클-잔여작업-코딩계획.md`의 1~6번 항목을 일괄 구현. Phase 7 옵션 4종 + Phase 5
후순위 2종으로 채팅 UX·관리 편의·운영자 분석 능력을 강화.
#### 아키텍처
```
[Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템]
(OPC UA Server)
[PostgreSQL DB]
```
#### 구현 내역
#### 주소 공간 구조
```
Root/Objects/ExperionCrawler
├── ServerInfo/Status, PointCount, LastUpdateTime
└── Realtime/<tagname_1>, <tagname_2>, … (ns=2;s=tag_{tagname})
```
| # | 항목 | 핵심 |
|---|------|------|
| 1 | 툴 카드 영구 보존 | `sess.messages[*].toolCalls[]` 저장 → `llmRenderMessages`에서 재렌더링. F5 새로고침 후에도 툴 카드 유지 |
| 2 | KB 청크 미리보기 UI | Qdrant Scroll API → `/api/kb/documents/{id}/chunks` → 모달에 청크 카드 (접기/펼침) |
| 3 | 시계열 미니 스파클라인 | `llmDetectTimeSeries` (timestamp+value 키 자동 감지) → uPlot 90px 차트가 표 위에 자동 렌더링 |
| 4 | NL2SQL 의도 라우터 | `_classify_intent` 정규식 6규칙 → `query_with_nl` 진입 시 알람/요약/태그검색/이벤트로 위임. `classify_intent` MCP 도구로도 노출 |
| 5 | 대화 요약 | `sess.summary` + `summarizedUpTo` 인덱스, `LLM_MAX_HISTORY=20` 초과 시 `/api/ollama/summarize` 호출 → systemPrompt에 누적 요약 prepend |
| 6 | 에이전트 모드 | `#llm-agent-mode` 토글 → `AgentMode` 요청 필드 → `ComposeSystemPrompt``AgentModeGuideKo` (ReAct 사이클) 주입 |
#### 수정/추가 파일
#### 수정 파일
| 파일 | 수정 내용 |
| 파일 | 변경 요약 |
|------|----------|
| `src/Web/ExperionCrawler.csproj` | `OPCFoundation.NetStandard.Opc.Ua.Server v1.5.378.134` 패키지 추가 |
| `src/Web/appsettings.json` | `OpcUaServer` 섹션 추가 (Port:4841, EnableSecurity:false, AllowAnonymous:true) |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionOpcServerService` 인터페이스, `OpcServerStatus` record, `GetRealtimeNodeDataTypesAsync()` 추가 |
| `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` | 신규 — `CustomNodeManager2` 상속, 주소 공간 관리 (`CreateAddressSpace`, `RebuildAddressSpace`, `UpdateNodeValue`) |
| `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` | 신규 — `ExperionStandardServer` + `ExperionOpcServerService` (`IHostedService` + `IExperionOpcServerService`) |
| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `_pointCache` (nodeId→RealtimePoint) 추가; `FlushPendingAsync`에서 OPC 서버 노드 값 lazy 갱신 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetRealtimeNodeDataTypesAsync()` — realtime_table × node_map_master 조인 |
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionOpcServerController` 추가 (start/stop/status/rebuild) |
| `src/Web/Program.cs` | `ExperionOpcServerService` Singleton+HostedService 등록 |
| `src/Web/wwwroot/index.html` | 08 OPC UA 서버 탭 + pane-opcsvr 섹션 추가 |
| `src/Web/wwwroot/js/app.js` | `srvLoad/Start/Stop/Rebuild/_srvRender/_srvStartPoll/_srvStopPoll` 구현 |
| `src/Web/wwwroot/css/style.css` | `.srv-status-card`, `.srv-meta`, `.dot.grn` 스타일 추가 |
| `src/Web/wwwroot/js/app.js` | `llmRenderToolCardsHtml`(영구 렌더), `llmDetectTimeSeries`+`llmBuildSparklineHtml`+`llmMountSparkline`(uPlot), `kbShowChunks`+`kbRenderChunks`(모달), `LLM_MAX_HISTORY`+`llmEnsureSummary`+`sess.summary` 표시, `llmAgentMode`+`llmToggleAgentMode`, 툴 카드 표시 + 요약 prepend 로직 |
| `src/Web/wwwroot/index.html` | `#kb-chunk-modal` 모달, `#llm-agent-row`/`#llm-agent-mode` 토글 |
| `src/Web/wwwroot/css/style.css` | `.kb-chunk-*`(청크 카드/뱃지/locator), `.llm-sparkline-box/-chart`, `.llm-summary-card`(접기/펼침) |
| `src/Web/Controllers/OllamaController.cs` | `AgentMode` request 필드, `ComposeSystemPrompt(agentMode)`, `AgentModeGuideKo` ReAct 가이드, `POST /api/ollama/summarize` 엔드포인트, `OllamaSummarizeRequest` DTO, `ToolGuideKo``classify_intent` 추가 |
| `src/Web/Controllers/KbController.cs` | `GET /api/kb/documents/{id}/chunks` (admin) — KbQdrantClient Scroll 호출 |
| `src/Infrastructure/Kb/KbQdrantClient.cs` | `GetChunksByDocIdAsync(collection, docId, limit)` — payload-only Scroll API |
| `mcp-server/server.py` | `_CLASSIFY_RULES`+`_classify_intent`+`@mcp.tool() classify_intent`, `query_with_nl` 진입부에서 라우팅 후 5개 전용 도구로 위임 (실패 시 SQL fallback) |
#### 주요 설계 결정
#### 의도 라우터 규칙
| 정규식 | 라우팅 대상 |
|--------|------------|
| `활성.*알람\|현재.*알람\|지금.*알람\|active.*alarm` | `active_alarms` |
| `트립\|trip` | `active_alarms` |
| `상태\s*보고서\|교대.*보고\|status.*report\|운전.*보고` | `generate_status_report` |
| `요약\|보고서\|리포트\|summary\|summarize\|report` | `summarize_events` |
| `태그.*찾\|tag.*찾\|찾아\s*줘\|find.*tag\|어떤.*태그` | `find_tags` |
| `이벤트.*조회\|이벤트.*목록\|event.*list\|event.*query\|로그.*조회` | `query_events` |
| (그 외) | `query_with_nl` (기본 SQL 경로) |
스모크 테스트 10건 모두 통과 — "지금 알람 알려줘"→active_alarms / "FIC-6113.PV 값 보여줘"→query_with_nl / "안녕"→query_with_nl 등.
#### 설계 결정
| 항목 | 결정 |
|------|------|
| 인증서 | 기존 `pki/own/certs/{hostname}.pfx` 재사용 (`ApplicationType.ClientAndServer`) |
| 포트 | 기본 4841 (4840은 Experion HS R530이 사용 가능) |
| 보안 | 기본 None (appsettings.json에서 변경 가능) |
| 자동 재시작 | `opcserver_autostart.json` 플래그 파일 패턴 (RealtimeService와 동일) |
| 순환 참조 | `IServiceProvider` lazy resolve — `_opcServer ??= _sp.GetService<IExperionOpcServerService>()` |
| FlushLoop 연동 | 500ms 배치 DB 업데이트 후 → OPC 서버 노드 값도 동시 갱신 (DB 폴링 없음) |
#### API 엔드포인트
- `GET /api/opcserver/status` — 상태 조회 (running, clientCount, nodeCount, endpointUrl, startedAt)
- `POST /api/opcserver/start` — 서버 시작
- `POST /api/opcserver/stop` — 서버 중지
- `POST /api/opcserver/rebuild` — 주소 공간 재구성
#### 빌드 결과
- 경고 11건 (기존 8건 + OPC SDK Server Start/Stop deprecated 3건), **에러 0건** — 빌드 성공
#### OPC UA 서버가 노출하는 데이터
**데이터 출처**: `realtime_table`에 등록된 포인트 전체 (포인트빌더에서 빌드/수동 추가한 포인트)
**주소 공간 구조**
```
Root/Objects/ExperionCrawler
├── ServerInfo/
│ ├── Status (String) — "Running" / "Stopped"
│ ├── PointCount (Int32) — 구독 중인 포인트 수
│ └── LastUpdateTime (DateTime) — 마지막 값 갱신 시각
└── Realtime/
├── <tagname_1> ns=2;s=tag_FIC101_PV
├── <tagname_2>
└── …
```
**NodeId 명명 규칙**: `ns=2;s=tag_{tagname}`
**DataType 결정**: `realtime_table` × `node_map_master` 조인
- Double/Float/Int32/Int64/Boolean/DateTime → 해당 OPC UA 타입
- 기타/NULL → String (fallback)
**접근 제한**: 읽기 전용 (`AccessLevel = CurrentRead`), `Historizing = false`
**갱신 주기**: Experion HS R530 → FlushLoop 500ms 배치 → DB + OPC 서버 노드 동시 갱신
---
### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15)
#### 증상
히스토리 스냅샷 1회 저장마다 터미널에 로그 2줄 출력:
```
[ExperionDb] history 스냅샷: 1752건 @ 01:14:18
[HistoryService] 스냅샷 저장: 1752건
```
#### 원인
DB 저장 완료 후 `ExperionDbService`에서 `LogInformation`, 호출자 `ExperionHistoryService`에서도 `LogInformation`. 저장은 1회이나 로그가 2줄.
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Infrastructure/Database/ExperionDbContext.cs` | `SnapshotToHistoryAsync()` 내부 로그를 `LogInformation``LogDebug`로 변경 |
#### 결과
운영 로그(`Information` 레벨)에서 `[HistoryService] 스냅샷 저장: N건` 1줄만 출력.
---
### 버그 수정 — Ctrl+C 종료 시 자동재시작 플래그 삭제 오류 (2026-04-15)
#### 증상
Ctrl+C로 앱 종료 시 `realtime_autostart.json` 플래그 파일이 삭제되어, 재기동 후 자동 구독 시작이 동작하지 않음.
#### 원인
`IHostedService.StopAsync(CancellationToken)` (앱 종료 훅)이 UI 수동 중지 메서드인 `StopAsync()`를 그대로 호출. `StopAsync()`는 플래그 파일을 삭제하므로 앱 종료와 수동 중지를 구분하지 못했음.
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `IHostedService.StopAsync(CancellationToken)` 분리 — `_cts.Cancel()` + 태스크 대기만 수행, 플래그 파일 삭제 없음 |
#### 동작 구분
| 종료 방식 | 플래그 파일 |
|----------|------------|
| Ctrl+C (앱 종료) | **유지** → 재기동 시 자동 구독 시작 |
| UI 중지 버튼 | **삭제** → 재기동 후 자동 시작 없음 |
---
### 버그 수정 — 이력 조회 중복 키 예외 (2026-04-15)
#### 증상
이력 조회 시 서버 500 에러:
```
System.ArgumentException: An item with the same key has already been added.
Key: p-6102.hzset.fieldvalue
at ExperionDbService.QueryHistoryAsync ... line 342
```
#### 원인
`history_table`에 동일 `recorded_at` + 동일 `tagname` 조합이 중복 저장된 행 존재. `.ToDictionary(r => r.TagName, r => r.Value)` 호출 시 중복 키로 예외 발생.
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Infrastructure/Database/ExperionDbContext.cs` | `TagName` 기준 `GroupBy` 추가 → 중복 시 `.Last().Value` 사용 |
---
### 기능 추가 — 이력 조회 날짜/시간 팝업 피커 (2026-04-15)
#### 배경
- `datetime-local` 입력이 Windows 브라우저 로케일에 따라 AM/PM 12시간제로 표시됨
- 서버(Ubuntu UTC) / 브라우저(Windows KST) 시간대 차이로 인한 표시 혼란
#### 설계
- `datetime-local` 입력 제거 → 클릭 시 커스텀 달력+시간 팝업 오픈
- 달력: 월 이동 가능, 오늘 날짜 amber 강조, 선택일 반전 표시
- 시간: 24시간제, ``/`+` 버튼 또는 직접 입력 (023시, 059분)
- 확인 시 `YYYY-MM-DD HH:MM` 형식으로 필드 표시
- hidden input에 로컬 시간 문자열 저장 → `new Date(...).toISOString()`으로 KST→UTC 변환 후 서버 전송 (기존 로직 유지)
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Web/wwwroot/index.html` | `datetime-local` 2개 → `.dt-display` + `hidden input` 교체; 팝업 HTML(`#dt-popup`, `#dt-overlay`) 추가 |
| `src/Web/wwwroot/css/style.css` | `.dt-popup`, `.dt-cal-grid`, `.dt-day`, `.dt-time-row` 등 피커 전용 다크 테마 스타일 추가; 기존 `datetime-local` AM/PM 숨김 CSS 제거 |
| `src/Web/wwwroot/js/app.js` | `dtOpen()`, `dtRenderCal()`, `dtSelectDay()`, `dtPrevMonth()`, `dtNextMonth()`, `dtAdjTime()`, `dtClampTime()`, `dtConfirm()`, `dtClear()`, `dtClose()` 구현; `histReset()`에서 `dtClearField()` 호출로 표시 텍스트 초기화 |
#### 빌드 결과
- 경고 8건 (기존 동일), **에러 0건** — 빌드 성공
---
### 버그 수정 — 단일 태그 읽기 성공/실패 판정 오류 (2026-04-15)
#### 증상
서버접속테스트 페이지에서 단일 태그 읽기 시, OPC UA 서버가 `BadNodeIdUnknown(0x80340000)` 등 에러 상태 코드를 반환해도 "✅ 읽기 성공"으로 표시되는 버그.
#### 원인
`ExperionOpcClient.cs``ReadTagsAsync` 내부에서 `StatusCode` 값과 무관하게 `Success = true`를 하드코딩해서 `ExperionReadResult`를 생성했음.
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Infrastructure/OpcUa/ExperionOpcClient.cs` | `StatusCode.IsGood()` 결과를 `Success` 플래그로 사용. Bad이면 `Success=false`, `Value=null`, `Error`에 상태 코드 메시지 설정 |
#### 결과
`BadNodeIdUnknown` 등 Bad 상태 코드 수신 시 → ❌ 읽기 실패로 정상 표시
#### 빌드 결과 (경고 상세)
경고 8건, **에러 0건** — 빌드 성공
| # | 파일 | 내용 |
|---|------|------|
| 1 | `ExperionOpcClient.cs:108` | `Session.Create()``ISessionFactory.CreateAsync` 사용 권장 |
| 2 | `ExperionRealtimeService.cs:161` | `Subscription.ApplyChanges()``ApplyChangesAsync()` 사용 권장 |
| 3 | `ExperionRealtimeService.cs:168` | 동일 |
| 4 | `ExperionRealtimeService.cs:277` | `Subscription.Create()``CreateAsync()` 사용 권장 |
| 5 | `ExperionRealtimeService.cs:346` | `Subscription.Delete()``DeleteAsync()` 사용 권장 |
| 6 | `ExperionRealtimeService.cs:424` | `Session.Create()``ISessionFactory.CreateAsync` 사용 권장 |
| 78 | (위 항목 중 중복 카운트) | — |
전부 OPC UA SDK가 동기 메서드를 `[Obsolete]`로 표시하고 비동기 버전을 권장하는 경고. 기능상 문제 없음.
---
### 노드맵 대시보드 구현 (2026-04-14)
node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다.
#### 수정된 파일
| 파일 | 내용 |
|------|------|
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService``GetMasterStatsAsync()` / `QueryMasterAsync()` 추가, `NodeMapStats` / `NodeMapQueryResult` record 추가 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `ExperionDbService`에 두 메서드 구현 (통계·필터 조회, 페이지네이션) |
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionNodeMapController` 추가 (`GET /api/nodemap/stats`, `GET /api/nodemap/query`) |
| `src/Web/wwwroot/index.html` | 사이드바 05번 탭 추가, `#pane-nm-dash` 섹션 추가 (통계 카드·필터폼·페이지네이션·테이블) |
| `src/Web/wwwroot/js/app.js` | `nmLoad()` / `nmQuery()` / `nmPrev()` / `nmNext()` / `nmReset()` 구현, 탭 클릭 핸들러에 `nmLoad()` 호출 추가 |
| `src/Web/wwwroot/css/style.css` | `.nm-stat-row`, `.nm-cls`, `.nm-dtype`, `.pg`, `.btn-sm` 등 대시보드 전용 스타일 추가 |
#### 빌드 결과
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
#### 주의 사항
- 인증서 관련 코드(`ExperionCertificateService.cs`, 인증서 컨트롤러)는 일절 수정하지 않음
---
### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14)
노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다.
#### 수정된 파일
| 파일 | 내용 |
|------|------|
| `src/Core/Application/Interfaces/IExperionServices.cs` | `GetNameListAsync()` 추가; `QueryMasterAsync` 파라미터 `string? name``IEnumerable<string>? names` |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetNameListAsync()` 구현 (distinct + 오름차순 정렬); `QueryMasterAsync`에서 `nameList.Contains(x.Name)` → EF가 `WHERE name IN (...)` SQL 생성 |
| `src/Web/Controllers/ExperionControllers.cs` | `GET /api/nodemap/names` 엔드포인트 추가; `Query` 액션 파라미터 `string? name``List<string>? names` (ASP.NET Core가 `?names=A&names=B` 자동 바인딩) |
| `src/Web/wwwroot/index.html` | "이름 검색" 텍스트 입력 제거 → `nf-name-1` ~ `nf-name-4` 4개 `<select>` 드롭다운 추가 |
| `src/Web/wwwroot/js/app.js` | `nmLoad()`에서 `/api/nodemap/names` 병렬 호출 후 4개 드롭다운 채우기; `nmQuery()`에서 선택 이름들을 `params.append('names', nm)`로 OR 전송; `nmReset()`에서 4개 드롭다운 초기화 |
| `src/Web/wwwroot/css/style.css` | `.nm-name-selects` (4열 그리드, 900px 이하 2열) 추가 |
#### 빌드 결과
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
---
## 구현 완료 (2026-04-14, todo.md 전항목)
### 빌드 결과
- 경고 6건 (기존 3건 + 신규 3건 OPC SDK deprecated API 경고), **에러 0건** — 빌드 성공
---
## 버그 수정 이력 (2026-04-14)
### 버그 1 — OPC UA 연결 시 OS TCP 타임아웃(최대 127초) 문제
#### 증상
- 접속 테스트 버튼을 눌렀을 때 수분간 응답 없는 것처럼 보임
- `ExperionRealtimeService`: "연결 오류, 30초 후 재시도" 로그가 매우 늦게 출력됨
- 오류: `System.Net.Sockets.SocketException (110): Connection timed out`
#### 원인
Linux에서 OPC UA 서버 IP가 응답 없음(firewall/unreachable)이면 OS TCP SYN 재전송 타임아웃이 최대 127초까지 걸림. `TransportQuotas.OperationTimeout`은 OPC UA 프로토콜 레벨 타임아웃이라 TCP connect 단계에는 적용되지 않음.
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `ExperionOpcClient.cs` | `SelectEndpointAsync``CancellationTokenSource(10초)` 추가 — DiscoveryClient 생성 시 10초 타임아웃 적용 |
| `ExperionRealtimeService.cs` | 동일하게 `SelectEndpointAsync` 10초 타임아웃 적용 |
#### 결과
서버 미응답 시 127초 대기 → **10초 이내 실패** 처리
---
### 버그 2 — PostgreSQL `sorry, too many clients already` (SQLSTATE 53300)
#### 증상
구독 시작 후 실시간 값 수신 시 터미널에 다량의 에러:
```
Npgsql.PostgresException (0x80004005): 53300: sorry, too many clients already
at ExperionDbService.UpdateLiveValueAsync(...)
at ExperionRealtimeService.<<OnNotification>b__0>d.MoveNext()
```
#### 원인
`OnNotification` 콜백이 포인트마다 `Task.Run` → 새 DI 스코프 → 새 `DbContext` → 새 DB 커넥션을 열었음. 2000여개 포인트가 동시에 값 변경 콜백을 받으면 순식간에 PostgreSQL `max_connections`(기본 100) 초과.
```
값 변경 콜백 × 2000개 → Task.Run × 2000개 → DB 커넥션 × 2000개 → 💥
```
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `IExperionServices.cs` | `BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate>)` 인터페이스 추가, `LiveValueUpdate` record 추가 |
| `ExperionDbContext.cs` | `BatchUpdateLiveValuesAsync` 구현 — 단일 DbContext에서 순차 ExecuteUpdateAsync |
| `ExperionRealtimeService.cs` | `OnNotification`에서 `Task.Run` 제거 → `ConcurrentDictionary`에 최신값만 기록. 별도 `FlushLoopAsync` 태스크가 500ms마다 단일 DbContext로 배치 업데이트 |
#### 수정 후 구조
```
값 변경 콜백 × N개 → ConcurrentDictionary[nodeId] = 최신값
↓ 500ms마다
단일 DbContext → BatchUpdateLiveValuesAsync → DB 커넥션 1개
```
#### 결과
- DB 커넥션 동시 사용 수: 2000개 → **최대 1개**
- 500ms 내 중복 변경은 최신값 1건만 DB에 반영 (deduplication)
- 빌드: 경고 6건(기존 동일), **에러 0건**
---
### 버그 3 — 대시보드 탭 진입 시 자동 API 호출로 인한 CPU/브라우저 버벅임
#### 증상
- **노드맵 대시보드** 탭 진입 시 CPU 과부하, 페이지 버벅임
- **포인트빌더** 탭 진입 시 동일 증상
- **이력 조회** 탭 진입 시 한참 동안 열리지 않음
#### 원인 (항목별)
| 탭 | 자동 호출 API | 무거운 이유 |
|----|--------------|------------|
| 노드맵 대시보드 | `/api/nodemap/stats` + `/api/nodemap/names` + `/api/nodemap/query` | stats: 5가지 집계 쿼리(COUNT×4, MAX, DISTINCT). 결과로 전체 조회까지 자동 실행 |
| 포인트빌더 | `/api/nodemap/names` + `/api/nodemap/stats` | stats 집계 쿼리 (포인트빌더 dataType 드롭다운 채우기 용도) |
| 이력 조회 | `/api/history/tagnames` → 드롭다운 8개에 2000개 옵션 삽입 | 8 × 2000 = 16,000개 DOM `<option>` 생성으로 브라우저 freeze |
#### 수정 내용
**공통 원칙**: 탭 진입 시 API 호출 0건. 사용자가 명시적으로 버튼을 눌렀을 때만 실행.
| 파일 | 수정 내용 |
|------|----------|
| `app.js` | 탭 클릭 핸들러에서 `nmLoad()`, `pbLoad()`, `histLoad()` 자동 호출 제거 |
| `app.js` | `nmReset()` 에서 `nmQuery(0)` 자동 호출 제거 |
| `app.js` | `nmLoad()``nmLoadNames()`로 분리 (이름 드롭다운만, 버튼 클릭 시 호출) |
| `app.js` | `nmLoad()` 내부의 통계 카드 렌더링 + `nmQuery(0)` 자동 호출 제거 |
| `app.js` | `pbLoad()` 에서 `/api/nodemap/stats` 호출 제거 |
| `app.js` | `histLoad()` 는 유지하되 탭 자동 호출 제거, "▼ 옵션 불러오기" 버튼 클릭 시에만 실행 |
| `index.html` | 노드맵 대시보드: 통계 카드(`nm-stat-row`) 제거, 데이터타입 select → text input |
| `index.html` | 노드맵 대시보드: 이름 드롭다운에 "▼ 옵션 불러오기" 버튼 추가 |
| `index.html` | 포인트빌더: 데이터타입 select 2개 → text input 2개 (`Double`, `Int32` 등 직접 입력) |
| `index.html` | 이력 조회: 태그 선택 드롭다운에 "▼ 옵션 불러오기" 버튼 추가 |
#### 결과 (탭별 진입 시 API 호출 수)
| 탭 | 이전 | 이후 |
|----|------|------|
| 노드맵 대시보드 | stats + names + query = **3건** | **0건** |
| 포인트빌더 | names + stats = **2건** | names = **1건** |
| 이력 조회 | tagnames = **1건** + DOM 16,000개 생성 | **0건** |
#### 주의 사항
- `/api/nodemap/stats` 엔드포인트는 서버에 남아있으나 프론트엔드에서 호출하지 않음
- 이름/태그 드롭다운은 "▼ 옵션 불러오기" 버튼으로 수동 로드
- 데이터타입 필터는 text input 직접 입력 방식으로 변경 (API 불필요)
---
### 버그 4 — 포인트빌더 탭 진입 시 여전히 버벅임 (2026-04-14)
#### 증상
버그 3 수정 이후에도 포인트빌더 탭 진입 시 버벅임 지속.
#### 원인
버그 3 수정 시 탭 핸들러에서 `pbLoad()` 제거를 누락. `app.js``if (tab === 'pb') pbLoad()` 가 그대로 남아 있었음. `pbLoad()``/api/nodemap/names` 호출 → 8개 드롭다운에 전체 name 목록 삽입 → DOM 부하.
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `app.js` | 탭 핸들러에서 `if (tab === 'pb') pbLoad()` 제거 |
| `index.html` | 포인트빌더 이름 선택 레이블 옆에 "▼ 옵션 불러오기" 버튼 추가 (`onclick="pbLoad()"`) |
#### 결과
포인트빌더 탭 진입 시 API 호출 **0건**
---
### 기능 추가 — 실시간 구독 자동 재시작 플래그 (2026-04-14)
#### 배경
앱 재기동 시 구독이 자동으로 재시작되지 않아 매번 수동으로 구독 시작 버튼을 눌러야 했음.
히스토리 스냅샷이 구독 여부와 무관하게 무조건 실행되어 `livevalue = NULL` 행이 저장되는 문제도 존재.
#### 설계
- 구독 시작 시 서버 설정을 `realtime_autostart.json` 파일로 저장 (앱 실행 디렉토리)
- 앱 기동 시 (`IHostedService.StartAsync`) 파일 존재 여부 확인 → 있으면 자동 구독 시작
- 구독 중지 시 파일 삭제 → 재기동 후 자동 시작 없음
- `ExperionHistoryService``IExperionRealtimeService.GetStatus().Running` 확인 → OFF이면 스냅샷 건너뜀
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `ExperionRealtimeService.cs` | `StartAsync(cfg)``realtime_autostart.json` 저장; `StopAsync()` 시 파일 삭제; `StartAsync(CancellationToken)` (IHostedService)에서 파일 읽어 자동 재시작 |
| `ExperionHistoryService.cs` | `IExperionRealtimeService` 생성자 주입; 스냅샷 전 `GetStatus().Running` 체크 → false이면 `continue` |
#### 동작 흐름
```
구독 시작 버튼 → realtime_autostart.json 저장 → OPC UA 구독 시작
앱 재기동 → 파일 감지 → 자동 구독 시작
구독 중지 버튼 → 파일 삭제 → 재기동 후 자동 시작 안 함
히스토리 서비스 → Running=false이면 스냅샷 건너뜀
```
---
### 기능 추가 — 수동 포인트 추가 시 OPC UA 핫 추가 및 유효성 검증 (2026-04-14)
#### 배경
수동으로 포인트를 추가해도 기존 구독에는 반영되지 않아 구독 재시작이 필요했음.
잘못된 node_id 입력 시 DB에만 저장되고 `livevalue`가 영원히 NULL인 문제도 존재.
#### 설계
- 수동 추가 시 DB 저장 후 구독 중이면 `MonitoredItem` 핫 추가 (`ApplyChanges()`)
- OPC UA 서버 응답 상태 확인 → bad 상태코드이면 subscription 제거 + DB 롤백 + 에러 반환
- 구독 중이 아닌 경우 DB에만 저장 → 다음 구독 시작 시 자동 포함
#### 수정 파일
| 파일 | 수정 내용 |
|------|----------|
| `IExperionServices.cs` | `IExperionRealtimeService``AddMonitoredItemAsync(string nodeId)` 추가 (반환: `(bool Success, string Message)`) |
| `ExperionRealtimeService.cs` | `AddMonitoredItemAsync` 구현 — MonitoredItem 생성, `ApplyChanges()`, 상태 확인, bad이면 롤백 |
| `ExperionControllers.cs` | `ExperionPointBuilderController``IExperionRealtimeService` 주입; `Add` 엔드포인트에서 DB 저장 후 `AddMonitoredItemAsync` 호출 → 실패 시 `DeleteRealtimePointAsync`로 DB 롤백 |
#### 동작 흐름
```
수동 추가 요청
├── DB 저장
├── 구독 중 아님 → 성공 ("다음 구독 시작 시 자동 포함")
└── 구독 중
├── OPC UA ApplyChanges() → Good → 즉시 구독 포함, 성공
└── OPC UA → Bad → subscription 제거 + DB 롤백 + 에러 반환
```
#### 빌드 결과
- 경고 8건 (기존 6건 + OPC SDK deprecated 2건), **에러 0건** — 빌드 성공
---
### 성능 분석 — 1,699포인트 기준 CPU 부하 추정 (2026-04-14)
#### 전제 조건
- 실시간 포인트: 1,699개
- 히스토리 스냅샷 주기: 60초
- 실시간 배치 flush 주기: 500ms
#### 히스토리 스냅샷 (60초마다)
- 작업: `realtime_table` 1,699행 SELECT → `history_table` INSERT 1,699행
- 특성: 1분에 1번 순간 burst, 수십 ms 수준
- 앱 CPU: EF Core 객체 생성 1,699개 → 거의 무시 가능
- **결론: 평균 CPU 기여 < 1%**
#### 실시간 livevalue 갱신 (500ms마다 배치)
- 작업: `ExecuteUpdateAsync` × (변경된 포인트 수)건 / 500ms
- OPC UA는 값이 바뀔 때만 콜백 → 전 포인트가 동시에 변경되는 경우는 드묾
- 실제 변경 수: 수십~수백건/500ms가 일반적
- **결론: 변경 포인트 수에 비례, 대부분의 경우 낮음**
#### 종합
| 작업 | 주기 | 예상 CPU |
|------|------|----------|
| 히스토리 스냅샷 | 60초/회 | 무시 가능 (< 1%) |
| 실시간 배치 업데이트 | 500ms/회 | 변경 포인트 수에 비례 |
| **합계** | - | **단일 코어 기준 5~15% 이내** |
실제 병목은 CPU보다 **PostgreSQL I/O와 커넥션 처리**쪽이 먼저 나타남. 현재 구조(단일 DbContext, 배치 flush)는 이미 최적화된 상태.
---
### 성능 분석 — 멀티모니터 4대 실시간 폴링 부하 (2026-04-14)
#### 시나리오
- 웹페이지에서 `realtime_table` 조회, 페이지당 200개, 2초 간격 갱신
- 멀티모니터 4대에서 4개의 브라우저 탭/창이 동시 동작
#### 부하 추정
| 항목 | 계산 | 평가 |
|------|------|------|
| 서버 요청 수 | 4탭 × 1회/2초 = **2 req/s** | 무시 가능 |
| DB 쿼리 | SELECT 200행 × 2회/s | 경량 |
| 응답 크기 | 200행 × ~150 bytes ≈ **30KB/응답** | 소량 |
| 네트워크 | 4 × 30KB / 2s = **60KB/s** | 거의 없음 |
| 브라우저 RAM | 탭당 60~100MB × 4 = **240~400MB** | 보통 수준 |
**결론: 서버 부하 크지 않음. 일반 개발용 PC(i5급, 8GB RAM)에서 충분히 감당 가능.**
#### 실질적 병목 — 브라우저 DOM 재렌더링
현재 `pbRender()``tbl.innerHTML`로 테이블 전체를 교체하는 방식 (full re-render).
- 200행 × 4탭 × 2초마다 전체 재생성 → 체감 가능한 CPU 사용
#### 결정 사항
**실시간 모니터링 페이지 구현 시 반드시 incremental DOM update 방식 사용**
- 이미 그려진 `<td>` 셀의 `.textContent`만 갱신 (값이 바뀐 셀만)
- `innerHTML` 전체 교체 금지
- 구조 변경(행 추가/삭제) 시에만 DOM 재구성 허용
---
### TimescaleDB 관련 결정 사항 (2026-04-14)
PostgreSQL에 TimescaleDB 확장이 설치되어 있음.
#### 결론: 앱 코드 수정 불필요
TimescaleDB는 PostgreSQL **확장(extension)** 이므로:
- 연결 문자열: 기존 PostgreSQL 그대로 사용
- EF Core / Npgsql 드라이버: 그대로 사용
- `history_table` hypertable 전환은 DB에서 DDL 한 줄만 실행
```sql
SELECT create_hypertable('history_table', 'recorded_at');
```
이 명령을 DB에서 한 번 실행하면 이후 INSERT/SELECT는 코드 변경 없이 TimescaleDB가 자동으로 시계열 최적화를 적용함.
**DbContext, 엔티티, 컨트롤러 등 앱 코드는 전혀 수정 불필요.**
---
## 구현 계획 (참고용)
### Task 1 — RealtimeTable + 포인트빌더 대시보드
#### 개요
- `realtime_table` PostgreSQL 테이블 생성: `id, tagname, node_id, livevalue, timestamp`
- `tagname`: `node_id.Substring(node_id.LastIndexOf(':') + 1)` (마지막 ':' 오른쪽 문자열, 없으면 전체)
- 소스: `node_map_master WHERE name IN (...) AND data_type = 'Double'`
#### 수정 파일
| 파일 | 내용 |
|------|------|
| `ExperionEntities.cs` | `RealtimePoint` 엔티티 추가 (`realtime_table` 매핑) |
| `IExperionServices.cs` | `IExperionDbService``BuildRealtimeTableAsync`, `GetRealtimePointsAsync`, `AddRealtimePointAsync`, `DeleteRealtimePointAsync` 추가 |
| `ExperionDbContext.cs` | `DbSet<RealtimePoint>`, 테이블 DDL, 4개 서비스 메서드 구현 |
| `ExperionControllers.cs` | `ExperionPointBuilderController` 추가 (POST /api/pointbuilder/build, GET /api/pointbuilder/points, POST /api/pointbuilder/add, DELETE /api/pointbuilder/{id}) |
| `index.html` | 06번 탭 '포인트빌더' 추가 — name 드롭다운 8개, dataType 드롭다운, 빌드 버튼, 수동 node_id 입력, 포인트 테이블 |
| `app.js` | `pbLoad()`, `pbBuild()`, `pbAddManual()`, `pbDelete(id)`, `pbRender()` 구현 |
| `style.css` | 포인트빌더 전용 스타일 추가 |
#### 설계 결정
- `BuildRealtimeTableAsync`는 기존 레코드를 모두 지우고 재생성 (TRUNCATE + INSERT)
- 수동 추가(`AddRealtimePointAsync`)는 `tagname`을 자동 추출해서 삽입
- 약 2000건 → 페이지네이션 불필요, 전체 목록을 클라이언트 측 테이블로 렌더링
---
### Task 2 — OPC UA 실시간 구독 (livevalue 업데이트)
#### 개요
- OPC UA Subscription + MonitoredItem API 사용 (값 변경 시에만 콜백)
- `IExperionRealtimeService` 인터페이스 + `ExperionRealtimeService` BackgroundService 신규 파일
- 서버 접속 설정은 `appsettings.json`에서 읽음 (기존 `ExperionServerConfig` 구조 재사용)
- 값 변경 콜백 → `realtime_table.livevalue` 업데이트 + `timestamp` 갱신
#### 수정 파일
| 파일 | 내용 |
|------|------|
| `IExperionServices.cs` | `IExperionRealtimeService` 인터페이스, `IExperionDbService``UpdateLiveValueAsync` 추가 |
| `ExperionDbContext.cs` | `UpdateLiveValueAsync` 구현 |
| `ExperionRealtimeService.cs` (신규) | `BackgroundService` 구현 — Subscription 생성, MonitoredItem 등록, 콜백 처리 |
| `ExperionControllers.cs` | `ExperionRealtimeController` 추가 (POST /api/realtime/start, POST /api/realtime/stop, GET /api/realtime/status) |
| `Program.cs` | `AddHostedService<ExperionRealtimeService>()` 등록 |
| `index.html` + `app.js` | 포인트빌더 탭에 실시간 시작/정지 버튼, 상태 표시, livevalue 폴링(3초) 추가 |
#### 설계 결정
- OPC UA Subscription: `PublishingInterval = 1000ms`
- MonitoredItem: `SamplingInterval = 500ms`, `DeadBandType = None`
- 값 변경 없으면 콜백 없음 → DB 업데이트 없음 (OPC UA 규약 준수)
- 서비스 재시작 시 자동 재연결 로직 포함 (30초 재시도)
---
### Task 3 — HistoryTable (시계열 스냅샷)
#### 개요
- `history_table`: `id, tagname, node_id, value, recorded_at`
- `ExperionHistoryService` BackgroundService → 설정된 주기(기본 60초)마다 `realtime_table` 전체를 스냅샷
- 주기는 `appsettings.json: "HistoryIntervalSeconds": 60` 에서 읽음
#### 수정 파일
| 파일 | 내용 |
|------|------|
| `ExperionEntities.cs` | `HistoryRecord` 엔티티 추가 (`history_table` 매핑) |
| `IExperionServices.cs` | `IExperionDbService``SnapshotToHistoryAsync` 추가 |
| `ExperionDbContext.cs` | `DbSet<HistoryRecord>`, 테이블 DDL, `SnapshotToHistoryAsync` 구현 |
| `ExperionHistoryService.cs` (신규) | `BackgroundService` — 주기적 `SnapshotToHistoryAsync` 호출 |
| `Program.cs` | `AddHostedService<ExperionHistoryService>()` 등록 |
---
### Task 4 — HistoryTable 웹페이지
#### 개요
- 07번 탭 '이력 조회' 추가
- tagname 드롭다운 최대 8개 선택 (다중 선택으로 열 구성)
- 시작 시간 / 종료 시간 범위 필터
- 결과 테이블: tagname이 열 헤더, recorded_at이 행
#### 수정 파일
| 파일 | 내용 |
|------|------|
| `IExperionServices.cs` | `IExperionDbService``GetTagNamesAsync`, `QueryHistoryAsync` 추가; `HistoryQueryResult` record 추가 |
| `ExperionDbContext.cs` | `GetTagNamesAsync`, `QueryHistoryAsync` 구현 |
| `ExperionControllers.cs` | `ExperionHistoryController` 추가 (GET /api/history/tagnames, GET /api/history/query) |
| `index.html` | 07번 탭 '이력 조회' + `#pane-hist` 섹션 추가 |
| `app.js` | `histLoad()`, `histQuery()`, `histRender()` 구현 |
| `style.css` | 이력 조회 전용 스타일 추가 |
| 툴 카드 영구화 | `assistantMsg.toolCalls``{id,name,args,ok,preview,length,payload}` 누적 저장. 기존 세션에 `toolCalls` 없으면 렌더링 생략(역호환) |
| AbortError 시 | `content` 또는 `toolCalls`가 비어있지 않으면 메시지 유지 (도구만 호출하고 중단된 경우도 보존) |
| 시계열 감지 | `timestamp/recorded_at/ts/time/datetime` + `value/pv/val/fieldvalue/sp/op` 페어. 3건 이상이어야 차트 생성. uPlot 미로딩 시 렌더 생략 |
| 스파클라인 부착 | innerHTML로 컨테이너만 만든 후 `requestAnimationFrame`에서 uPlot 생성 (DOM 마운트 후 실행 보장) |
| 요약 임계 | `LLM_MAX_HISTORY=20` 초과 시 오래된 절반을 요약. 누적 요약은 이전 요약을 system 메시지로 함께 전송 |
| 요약 송신 | sess.summary가 있으면 systemPrompt 맨 앞에 `[이전 대화 요약]` 블록 prepend. 사용자에게는 접이식 카드로 표시 |
| 의도 라우터 fallback | 라우팅 시도 실패(예외) 시 조용히 SQL 경로로 fallback |
| 에이전트 모드 조건부 표시 | `llmType==='vllm' && llmUseTools` 일 때만 토글 노출. localStorage 영속 |
| 청크 미리보기 권한 | admin 토큰 필요. payload만 조회(vector 제외), 최대 500개/문서 |
#### 빌드/검증
- `dotnet build src/Web/ExperionCrawler.csproj` — 경고 0건, **에러 0건**
- `python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py` — OK
- `python3 -c "import server"` — 9개 도구 모두 attribute로 노출 확인
- `node -c src/Web/wwwroot/js/app.js` — syntax OK
- 의도 분류기 10/10 통과
#### 런타임 셋업
- `mcp-server` 재시작 — `classify_intent` 신규 도구 인식
- 브라우저 캐시 무효화 (Ctrl+F5) — 신규 JS/CSS 적용
- 사용자 첫 진입 시 에이전트 모드 토글은 OFF (옵트인)
#### 잔여
- **결정 보류**: 현장 재고 데이터 출처 (KB 엑셀 업로드로 즉시 가능, 별도 개발 불필요), 임베딩 모델 BGE-M3 마이그레이션 (위험 대비 임계값 평가 필요)
- 모두 코드 작업 아닌 분석/결정 항목

View File

@@ -0,0 +1,265 @@
# DXF P&ID 추출 개선 2차 — 배관번호·펌프 태그 누락 버그 수정
작성일: 2026-05-17
작성: Claude Sonnet 4.6
---
## 문제 개요
P&ID DXF 파일에서 아래 태그들이 추출되지 않는 버그 보고:
- `P-10101` (10차 펌프 장비 태그)
- `P-10101-25A-F1A-n` (10차 프로세스 배관번호)
- `P-9101`, `P-9102` (9차 펌프 장비 태그)
- `P-9107-25A-F-n` 등 (9차 배관번호)
사용자가 "중복방지 로직에 차단된 것 아닌가" 질문함 → 실제로는 **regex 불일치****레이어 처리 누락** 이 원인.
---
## 실제 DXF 구조 조사 결과
### 파일별 배관번호 형식 차이
| DXF 파일 | 배관번호 레이어 | 형식 예시 | 필드 수 |
|----------|--------------|---------|--------|
| `p9-p&id-20.03.19.dxf` (9차) | `LINENO` | `P-9107-25A-F-n` | **5필드** |
| `p9-p&id-20.03.19.dxf` (9차) | `14-D-PIPELINE-LINE` | `CHR-9641-50A-F-C50` | **6필드** |
| `plant-10100-only.dxf` (10차) | `LINENO` | `P-10101-25A-F1A-n` | **7필드** |
| `10차플랜트-P&ID.dxf` | `LINENO` | `P-10138-600A-F2A-H100` | **7필드** |
### 펌프 태그 레이어
| DXF 파일 | 레이어 | 예시 |
|----------|-------|------|
| 9차 플랜트 | `0` | `P-9101`, `P-9116`, `P-9201` |
| 10차 플랜트 | `0`, `1` | `P-10101`, `VP-10117`, `DP-10101` |
펌프 태그는 모두 일반 TEXT 엔티티 → 추출 자체는 가능했으나 배관번호로 오인되는 문제 있었음.
---
## 버그 분석
### Bug 1: `_PID_LINENO_FULL_RE` regex가 9차 배관번호 형식 불일치
**파일**: `mcp-server/server.py`
**기존 regex (7필드 고정)**:
```
^([A-Z][A-Z0-9]{0,3})-(\d{3,6})-(\d{1,4}[A-Z]?)-([A-Z])(\d)([A-Z])-([A-Za-z0-9]+)$
```
그룹 구조: `SERVICE - LINENUM - SIZE - MATERIAL - FLANGE_DIGIT - INSUL_CODE - INSUL_THICK`
| 입력 | 결과 | 이유 |
|------|------|------|
| `P-10101-25A-F1A-n` | ✓ MATCH | F→1→A→n 순서 맞음 |
| `P-9107-25A-F-n` | ✗ FAIL | F 다음에 `\d` 기대하나 `-n` 등장 |
| `CHR-9641-50A-F-C50` | ✗ FAIL | 동일 이유 |
9차 플랜트는 플랜지등급 숫자와 단열코드가 분리되지 않고 `F-n`, `F-H50` 형식으로 통합되어 있어 7필드 regex에 걸리지 않음.
---
### Bug 2: `_extract_pid_dxf_fast`가 레이어별로만 배관번호 판단
**파일**: `mcp-server/server.py`
**기존 로직**:
```python
if layer == 'LINENO': # LINENO 레이어만 배관번호 처리
parsed = _parse_pid_lineno(txt)
...
continue
if _PID_TAG_RE.match(txt): # 그 외 레이어는 TAG_RE만 체크
...
```
결과:
- `14-D-PIPELINE-LINE` 레이어의 `CHR-9641-50A-F-C50` → TAG_RE 불일치 → **완전 누락**
- 다른 도면에서 배관번호 레이어 이름이 다르면 → **모두 누락**
레이어 이름 하드코딩은 도면 간 이식성이 없음. 레이어 이름이 아닌 **regex 패턴으로 판단**해야 함.
---
### Bug 3: `build_pid_graph_parallel` pump extractor가 배관번호를 펌프로 오인
**파일**: `mcp-server/worker/pid_extract_prompts.py`
LLM pump extractor 프롬프트에 5자리 예시만 있고 배관번호 제외 지시 없음:
```
Examples: P-10101, VP-10117, DP-10101, C-10201, CP-10301, BP-10401
```
DXF 전체 텍스트에 `P-10101-25A-F1A-n`이 포함되어 있을 때 LLM이 이를 보고 `P-10101`로 잘못 추출.
→ Phase 4 `seen_tagnos` 중복 제거에서 실제 펌프 P-10101과 충돌
→ 배관번호 P-10101-25A-F1A-n은 graph에서 완전 누락
(사용자가 의심한 "중복방지 로직 차단"은 이 케이스에 해당 — 다만 원인은 LLM의 잘못된 추출임)
---
## 수정 내용
### Fix 1: `_PID_LINENO_FULL_RE` — 5~7필드 통합 regex
**`mcp-server/server.py`**
```python
# 기존 (7필드 고정)
_PID_LINENO_FULL_RE = re.compile(
r'^([A-Z][A-Z0-9]{0,3})-(\d{3,6})-(\d{1,4}[A-Z]?)-([A-Z])(\d)([A-Z])-([A-Za-z0-9]+)$'
)
# 수정 (5~7필드 통합: pipe_spec이 F, F1A, F2A 등 가변)
_PID_LINENO_FULL_RE = re.compile(
r'^([A-Z][A-Z0-9]{0,3})-(\d{3,6})-(\d{1,4}[A-Z]?)-([A-Za-z][A-Za-z0-9]*)-([A-Za-z0-9]+)$'
)
```
새 그룹: `(service, line_no, size, pipe_spec, insul)`
| 입력 | 매칭 | pipe_spec | insul |
|------|------|-----------|-------|
| `P-9107-25A-F-n` | ✓ | F | n |
| `P-9113-20A-F-H50` | ✓ | F | H50 |
| `CHR-9641-50A-F-C50` | ✓ | F | C50 |
| `P-10101-25A-F1A-n` | ✓ | F1A | n |
| `P-10138-600A-F2A-H100` | ✓ | F2A | H100 |
| `VG-6203-15A-F1A-n` | ✓ | F1A | n |
`_parse_pid_lineno` 반환값도 그룹 수에 맞게 단순화:
```python
# 기존: material_spec, flange_rating, insul_code, insul_thickness (4개 필드)
# 수정: pipe_spec, insul (2개 필드로 통합)
return {
"raw": token, "service": service, "fluid": ...,
"line_no": line_no, "size": size,
"pipe_spec": pipe_spec, # F, F1A, F2A 등
"insul": insul, # n, H50, H100, C50 등
}
```
---
### Fix 2: `_extract_pid_dxf_fast` — regex 우선, 레이어는 보조 힌트로만
**`mcp-server/server.py`**
```python
# 기존: 레이어 이름 == 'LINENO' 이면 배관번호
if layer == 'LINENO':
parsed = _parse_pid_lineno(txt)
...
continue
if _PID_TAG_RE.match(txt):
...
# 수정: FULL_RE 매칭 → 레이어 무관 배관번호, 짧은 형식만 레이어 힌트 사용
if _PID_LINENO_FULL_RE.match(txt): # 완전한 배관번호 → 레이어 무관
parsed = _parse_pid_lineno(txt)
if parsed is not None:
linenos.append(parsed)
continue
if 'LINENO' in layer.upper(): # 레이어 이름에 LINENO 포함 → 짧은 형식도 배관번호
parsed = _parse_pid_lineno(txt) # (P-10101 같은 단순형은 펌프와 구분 불가능,
if parsed is not None: # 레이어 힌트 불가피)
linenos.append(parsed)
continue
if _PID_TAG_RE.match(txt): # 일반 장비/계기 태그
...
```
핵심 원칙: **완전한 배관번호는 regex로 식별, 레이어 이름에 의존하지 않음**
---
### Fix 3: pump extractor 프롬프트 개선
**`mcp-server/worker/pid_extract_prompts.py`**
```python
_PUMP_PROMPT = _PROMPT_HEADER + """
Extract ONLY pumps and compressors (simple equipment tags, NO pipe size suffix).
Target equipment types: P (pump), VP (vertical pump), DP (dual pump),
C (compressor), CP (centrifugal pump), BP (booster pump), SP (sump pump),
and their variants.
Examples (4~5 digit loop numbers): P-10101, VP-10117, DP-10101, C-10201, P-9101, P-9116, VP-9201
IMPORTANT: Do NOT extract pipeline/line numbers that have a pipe size suffix (e.g. 25A, 50A, 100A).
SKIP (pipeline, not a pump): P-10101-25A-F1A-n, P-9107-25A-F-n, CHR-9641-50A-F-C50
INCLUDE (pump tag): P-10101, VP-10117, P-9101
"""
```
변경점:
- 4자리 번호 예시 추가 (`P-9101`, `P-9116`, `VP-9201`)
- 배관번호 제외 지시 명시 (파이프 사이즈 suffix 있으면 제외)
- SKIP / INCLUDE 예시로 명확하게 구분
---
## 검증 결과
### regex 단위 테스트 (14/14 통과)
```
✓ P-9107-25A-F-n → pipe (9차 5필드)
✓ P-9113-20A-F-H50 → pipe (9차 단열)
✓ P-9127-500A-F-H100 → pipe (9차 대구경)
✓ P-10101-25A-F1A-n → pipe (10차 7필드)
✓ P-10138-600A-F2A-H100→ pipe (10차 대구경)
✓ CHR-9641-50A-F-C50 → pipe (냉각수 6필드)
✓ VG-6203-15A-F1A-n → pipe (벤트가스)
✓ SW-10810-25A-F1A-E50 → pipe (소프트워터)
✓ P-10101 → tag (10차 펌프)
✓ P-9101 → tag (9차 펌프)
✓ VP-10117 → tag (진공펌프)
✓ FIT-10101 → tag (유량계)
✓ FCV-6113 → tag (유량제어밸브)
✓ PT-9101 → tag (압력계)
```
### 실제 DXF 엔드투엔드 검증
```
=== p9-p&id-20.03.19.dxf (9차) ===
배관번호 총 242개 (P-: 83개) ← 수정 전: 0개
P 배관번호 예시: P-9107-25A, P-9114-20A, P-9113-20A, ...
펌프 태그: P-9101, P-6101, P-201, P-9201, P-9116
=== plant-10100-only.dxf (10차) ===
배관번호 총 96개 (P-: 57개)
P 배관번호 예시: P-10138-600A, P-10143-32A, P-10127-65A, ...
펌프 태그: P-10101, P-10114, P-10116, P-10118
```
---
## 수정 파일 목록
| 파일 | 변경 라인 | 내용 |
|------|---------|------|
| `mcp-server/server.py` | ~221 | `_PID_LINENO_FULL_RE` regex 교체 |
| `mcp-server/server.py` | ~244 | `_parse_pid_lineno` 반환값 `pipe_spec`/`insul`로 단순화 |
| `mcp-server/server.py` | ~305 | `_extract_pid_dxf_fast` 레이어/배관번호 처리 로직 수정 |
| `mcp-server/server.py` | ~359 | `_extract_pid_tags_from_text` step 1 출력에 `pipeSpec`/`insul` 추가 |
| `mcp-server/worker/pid_extract_prompts.py` | ~61 | `_PUMP_PROMPT` 개선 |
---
## 설계 결정 사항
| 항목 | 결정 | 이유 |
|------|------|------|
| regex 필드 통합 방식 | 5필드 통합 (`pipe_spec`이 F, F1A, F2A 통합) | 플랜트마다 배관 사양 코드 체계가 달라 고정 필드 분해는 취약 |
| 레이어 이름 역할 | FULL_RE 불일치 시 보조 힌트로만 사용 | 레이어 이름은 회사·도면마다 다름. regex가 primary. |
| 짧은 배관번호(`P-10101`) 처리 | LINENO 계열 레이어에서만 배관번호로 인식 | `P-10101`은 펌프 태그와 텍스트가 동일 → 레이어 힌트 불가피 |
| C# PidExtractorService | 미수정 | 펌프 태그(TEXT 엔티티)는 기존 코드에서 정상 추출됨. ATTRIB 읽기 추가는 별도 검토 필요 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
pid 추출에서 다운받은 엑셀을 다시 사용자가 정리해서 RAG 관리 페이지를 통해서 RAG indexing을 시킬건데, 현재
pid추출에서 다운받은 엑셀 파일 인덱싱시 각 시트 및 컬럼이 어떤 식으로 인덱싱이 되는지 알고싶어
● Excel RAG 인덱싱 파이프라인을 추적하겠습니다.
Searched for 4 patterns, read 5 files, listed 1 directory (ctrl+o to expand)
● 전체 인덱싱 파이프라인을 파악했습니다. 설명드리겠습니다.
---
Excel RAG 인덱싱 전체 흐름
업로드 → parse_document(MCP) → xlsx_parser.py → 청크 목록
→ Ollama nomic-embed-text → 768차원 벡터
→ Qdrant upsert (cosine similarity)
---
xlsx_parser.py가 만드는 청크 구조
시트 1개당 두 종류 청크를 생성합니다.
1) Sheet 청크 (시트당 1개)
chunk_kind = "sheet"
locator = "sheet=Instrument"
text = 마크다운 표 (최대 1000행)
| 태그번호 | 장비명 | 장비타입 | 라인번호 | 도면번호 | 신뢰도 | ... |
| --- | --- | --- | ... |
| FT-9101 | Flow Transmitter | FT | | | 0.95 | ... |
| PT-9111A | Pressure Transmitter | PT | | | 0.92 | ... |
...
→ 시트 전체가 하나의 벡터. "Instrument 시트 전체 목록" 같은 쿼리에 매칭.
2) Row 청크 (데이터 행마다 1개)
chunk_kind = "row"
locator = "sheet=Instrument; row=2"
text = "Instrument: 태그번호=FT-9101, 장비명=Flow Transmitter, 장비타입=FT, 신뢰도=0.95"
→ 빈 셀 컬럼은 제외, 값이 있는 컬럼만 컬럼명=값 형식으로 연결.
---
P&ID 추출 엑셀 시트별 청크 수 예측
┌─────────────────┬────────────┬─────────────────┬───────────────────────────────────────────────────────────────┐
│ 시트명 │ sheet 청크 │ row 청크 │ row 청크 텍스트 예시 │
├─────────────────┼────────────┼─────────────────┼───────────────────────────────────────────────────────────────┤
│ Instrument │ 1 │ 태그 수만큼 │ Instrument: 태그번호=FT-9101, 장비명=Flow Transmitter, ... │
├─────────────────┼────────────┼─────────────────┼───────────────────────────────────────────────────────────────┤
│ Power Equipment │ 1 │ 태그 수만큼 │ Power Equipment: 태그번호=P-9101, 장비명=Pump, ... │
├─────────────────┼────────────┼─────────────────┼───────────────────────────────────────────────────────────────┤
│ Pipings │ 1 │ 배관번호 수만큼 │ Pipings: 태그번호=P-9107-25A-F-n, 장비타입=PROCESS FLUID, ... │
├─────────────────┼────────────┼─────────────────┼───────────────────────────────────────────────────────────────┤
│ Unmatched │ 1 │ 수만큼 │ Unmatched: 태그번호=UFD-001, ... │
└─────────────────┴────────────┴─────────────────┴───────────────────────────────────────────────────────────────┘
---
Qdrant에 저장되는 payload (청크당)
{
"doc_id": "uuid",
"collection_key": "컬렉션키",
"title": "문서 제목",
"text": "Instrument: 태그번호=FT-9101, 장비명=...",
"chunk_kind": "row",
"locator": "sheet=Instrument; row=2",
"uploaded_at": "2026-05-17T...",
"tags": ["pid", "instrument"]
}
---
검색 시 동작 (search_kb)
사용자가 "FT-9101 태그 정보" 질의 →
nomic-embed-text 임베딩 → Qdrant cosine 검색 →
row 청크 "Instrument: 태그번호=FT-9101, 장비명=Flow Transmitter..." 상위 매칭
---
알아두실 주의사항
┌─────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
│ 항목 │ 내용 │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 1000행 한도 │ sheet 청크는 선두 1000행만 — Pipings처럼 행이 많으면 sheet 청크 불완전. row 청크는 영향 │
│ │ 없음 │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 빈 셀 제외 │ 라인번호/도면번호가 비어있으면 row 청크 텍스트에서 생략됨 │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 컬럼명이 RAG 질의 │ "태그번호", "장비명" 등 한국어 컬럼명이 그대로 검색 텍스트 됨. 컬럼명 정리가 검색 품질에 │
│ 키 │ 직결 │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 중복 태그 주의 │ 같은 태그가 여러 시트에 있으면 row 청크 중복 생성 → 검색 결과 중복 │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ Experion 태그 컬럼 │ 연결된 Experion 태그가 있으면 row 청크에 포함 → "FT-9101의 Experion 태그" 쿼리에 잘 매칭 │
└─────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘
RAG 검색 최적화를 위해 사용자가 정리할 때 권장하는 사항:
- 불필요한 시트 제거 (Unmatched는 제외 권장)
- 빈 컬럼(라인번호, 도면번호)을 채워서 업로드하면 row 청크 정보 밀도 향상
- 컬럼명을 영문으로 바꾸면 영문 질의에도 잘 매칭 (현재 한국어)

View File

@@ -0,0 +1,452 @@
version=1
# This file caches language service data to improve the performance of C# Dev Kit.
# It is not intended for manual editing. It can safely be deleted and will be
# regenerated automatically. For more information, see https://aka.ms/lscache
#
# To control where cache files are stored, use the following VS Code setting:
# "dotnet.projectsystem.cacheInProjectFolder": true
[project]
language=C#
primary
lastDtbSucceeded
[properties]
AssemblyName=ExperionCrawler.Tests
CommandLineArgsForDesignTimeEvaluation=-langversion:12.0 -define:TRACE
CompilerGeneratedFilesOutputPath=
MaxSupportedLangVersion=12.0
ProjectAssetsFile=<PATH>obj/project.assets.json
RootNamespace=ExperionCrawler.Tests
RunAnalyzers=
RunAnalyzersDuringLiveAnalysis=
SolutionPath=<PATH>../ExperionCrawler.sln
TargetFrameworkIdentifier=.NETCoreApp
TargetPath=<PATH>bin/Debug/net8.0/ExperionCrawler.Tests.dll
TargetRefPath=<PATH>obj/Debug/net8.0/ref/ExperionCrawler.Tests.dll
TemporaryDependencyNodeTargetIdentifier=net8.0
[commandLineArguments]
/noconfig
/unsafe-
/checked-
/nowarn:1701,1702,1701,1702
/fullpaths
/nostdlib+
/errorreport:prompt
/warn:8
/define:TRACE;DEBUG;NET;NET8_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
/highentropyva+
/nullable:enable
/debug+
/debug:portable
/filealign:512
/optimize-
/out:obj/Debug/net8.0/ExperionCrawler.Tests.dll
/refout:obj/Debug/net8.0/refint/ExperionCrawler.Tests.dll
/target:exe
/warnaserror-
/utf8output
/deterministic+
/langversion:12.0
/warnaserror+:NU1605,SYSLIB0011
[sourceFiles]
<NUGET>/microsoft.net.test.sdk/17.6.0/build/netcoreapp3.1/Microsoft.NET.Test.Sdk.Program.cs
@folderNames=..,..,..,.nuget,packages,microsoft.net.test.sdk,17.6.0,build,netcoreapp3.1
GlobalUsings.cs
KoreanTimeRangeExtractorTests.cs
obj/Debug/net8.0/
.NETCoreApp,Version=v8.0.AssemblyAttributes.cs
ExperionCrawler.Tests.AssemblyInfo.cs
ExperionCrawler.Tests.GlobalUsings.g.cs
SqlValidatorTests.cs
TextToSqlServiceTests.cs
TextToSqlTest.cs
UnitTest1.cs
[metadataReferences]
../../../../../usr/lib/dotnet/packs/Microsoft.AspNetCore.App.Ref/8.0.26/ref/net8.0/
Microsoft.AspNetCore.Antiforgery.dll
Microsoft.AspNetCore.Authentication.Abstractions.dll
Microsoft.AspNetCore.Authentication.BearerToken.dll
Microsoft.AspNetCore.Authentication.Cookies.dll
Microsoft.AspNetCore.Authentication.Core.dll
Microsoft.AspNetCore.Authentication.dll
Microsoft.AspNetCore.Authentication.OAuth.dll
Microsoft.AspNetCore.Authorization.dll
Microsoft.AspNetCore.Authorization.Policy.dll
Microsoft.AspNetCore.Components.Authorization.dll
Microsoft.AspNetCore.Components.dll
Microsoft.AspNetCore.Components.Endpoints.dll
Microsoft.AspNetCore.Components.Forms.dll
Microsoft.AspNetCore.Components.Server.dll
Microsoft.AspNetCore.Components.Web.dll
Microsoft.AspNetCore.Connections.Abstractions.dll
Microsoft.AspNetCore.CookiePolicy.dll
Microsoft.AspNetCore.Cors.dll
Microsoft.AspNetCore.Cryptography.Internal.dll
Microsoft.AspNetCore.Cryptography.KeyDerivation.dll
Microsoft.AspNetCore.DataProtection.Abstractions.dll
Microsoft.AspNetCore.DataProtection.dll
Microsoft.AspNetCore.DataProtection.Extensions.dll
Microsoft.AspNetCore.Diagnostics.Abstractions.dll
Microsoft.AspNetCore.Diagnostics.dll
Microsoft.AspNetCore.Diagnostics.HealthChecks.dll
Microsoft.AspNetCore.dll
Microsoft.AspNetCore.HostFiltering.dll
Microsoft.AspNetCore.Hosting.Abstractions.dll
Microsoft.AspNetCore.Hosting.dll
Microsoft.AspNetCore.Hosting.Server.Abstractions.dll
Microsoft.AspNetCore.Html.Abstractions.dll
Microsoft.AspNetCore.Http.Abstractions.dll
Microsoft.AspNetCore.Http.Connections.Common.dll
Microsoft.AspNetCore.Http.Connections.dll
Microsoft.AspNetCore.Http.dll
Microsoft.AspNetCore.Http.Extensions.dll
Microsoft.AspNetCore.Http.Features.dll
Microsoft.AspNetCore.Http.Results.dll
Microsoft.AspNetCore.HttpLogging.dll
Microsoft.AspNetCore.HttpOverrides.dll
Microsoft.AspNetCore.HttpsPolicy.dll
Microsoft.AspNetCore.Identity.dll
Microsoft.AspNetCore.Localization.dll
Microsoft.AspNetCore.Localization.Routing.dll
Microsoft.AspNetCore.Metadata.dll
Microsoft.AspNetCore.Mvc.Abstractions.dll
Microsoft.AspNetCore.Mvc.ApiExplorer.dll
Microsoft.AspNetCore.Mvc.Core.dll
Microsoft.AspNetCore.Mvc.Cors.dll
Microsoft.AspNetCore.Mvc.DataAnnotations.dll
Microsoft.AspNetCore.Mvc.dll
Microsoft.AspNetCore.Mvc.Formatters.Json.dll
Microsoft.AspNetCore.Mvc.Formatters.Xml.dll
Microsoft.AspNetCore.Mvc.Localization.dll
Microsoft.AspNetCore.Mvc.Razor.dll
Microsoft.AspNetCore.Mvc.RazorPages.dll
Microsoft.AspNetCore.Mvc.TagHelpers.dll
Microsoft.AspNetCore.Mvc.ViewFeatures.dll
Microsoft.AspNetCore.OutputCaching.dll
Microsoft.AspNetCore.RateLimiting.dll
Microsoft.AspNetCore.Razor.dll
Microsoft.AspNetCore.Razor.Runtime.dll
Microsoft.AspNetCore.RequestDecompression.dll
Microsoft.AspNetCore.ResponseCaching.Abstractions.dll
Microsoft.AspNetCore.ResponseCaching.dll
Microsoft.AspNetCore.ResponseCompression.dll
Microsoft.AspNetCore.Rewrite.dll
Microsoft.AspNetCore.Routing.Abstractions.dll
Microsoft.AspNetCore.Routing.dll
Microsoft.AspNetCore.Server.HttpSys.dll
Microsoft.AspNetCore.Server.IIS.dll
Microsoft.AspNetCore.Server.IISIntegration.dll
Microsoft.AspNetCore.Server.Kestrel.Core.dll
Microsoft.AspNetCore.Server.Kestrel.dll
Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.dll
Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.dll
Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.dll
Microsoft.AspNetCore.Session.dll
Microsoft.AspNetCore.SignalR.Common.dll
Microsoft.AspNetCore.SignalR.Core.dll
Microsoft.AspNetCore.SignalR.dll
Microsoft.AspNetCore.SignalR.Protocols.Json.dll
Microsoft.AspNetCore.StaticFiles.dll
Microsoft.AspNetCore.WebSockets.dll
Microsoft.AspNetCore.WebUtilities.dll
Microsoft.Extensions.Configuration.Binder.dll
Microsoft.Extensions.Configuration.CommandLine.dll
Microsoft.Extensions.Configuration.dll
Microsoft.Extensions.Configuration.EnvironmentVariables.dll
Microsoft.Extensions.Configuration.FileExtensions.dll
Microsoft.Extensions.Configuration.Ini.dll
Microsoft.Extensions.Configuration.Json.dll
Microsoft.Extensions.Configuration.KeyPerFile.dll
Microsoft.Extensions.Configuration.UserSecrets.dll
Microsoft.Extensions.Configuration.Xml.dll
Microsoft.Extensions.Diagnostics.Abstractions.dll
Microsoft.Extensions.Diagnostics.dll
Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.dll
Microsoft.Extensions.Diagnostics.HealthChecks.dll
Microsoft.Extensions.Features.dll
Microsoft.Extensions.FileProviders.Abstractions.dll
Microsoft.Extensions.FileProviders.Composite.dll
Microsoft.Extensions.FileProviders.Embedded.dll
Microsoft.Extensions.FileProviders.Physical.dll
Microsoft.Extensions.FileSystemGlobbing.dll
Microsoft.Extensions.Hosting.Abstractions.dll
Microsoft.Extensions.Hosting.dll
Microsoft.Extensions.Http.dll
Microsoft.Extensions.Identity.Core.dll
Microsoft.Extensions.Identity.Stores.dll
Microsoft.Extensions.Localization.Abstractions.dll
Microsoft.Extensions.Localization.dll
Microsoft.Extensions.Logging.Configuration.dll
Microsoft.Extensions.Logging.Console.dll
Microsoft.Extensions.Logging.Debug.dll
Microsoft.Extensions.Logging.EventLog.dll
Microsoft.Extensions.Logging.EventSource.dll
Microsoft.Extensions.Logging.TraceSource.dll
Microsoft.Extensions.ObjectPool.dll
Microsoft.Extensions.Options.ConfigurationExtensions.dll
Microsoft.Extensions.Options.DataAnnotations.dll
Microsoft.Extensions.WebEncoders.dll
Microsoft.JSInterop.dll
Microsoft.Net.Http.Headers.dll
System.Diagnostics.EventLog.dll
System.Security.Cryptography.Xml.dll
System.Threading.RateLimiting.dll
../../../../../usr/lib/dotnet/packs/Microsoft.NETCore.App.Ref/8.0.26/ref/net8.0/
Microsoft.CSharp.dll
Microsoft.VisualBasic.Core.dll
Microsoft.VisualBasic.dll
Microsoft.Win32.Primitives.dll
Microsoft.Win32.Registry.dll
mscorlib.dll
netstandard.dll
System.AppContext.dll
System.Buffers.dll
System.Collections.Concurrent.dll
System.Collections.dll
System.Collections.NonGeneric.dll
System.Collections.Specialized.dll
System.ComponentModel.Annotations.dll
System.ComponentModel.DataAnnotations.dll
System.ComponentModel.dll
System.ComponentModel.EventBasedAsync.dll
System.ComponentModel.Primitives.dll
System.ComponentModel.TypeConverter.dll
System.Configuration.dll
System.Console.dll
System.Core.dll
System.Data.Common.dll
System.Data.DataSetExtensions.dll
System.Data.dll
System.Diagnostics.Contracts.dll
System.Diagnostics.Debug.dll
System.Diagnostics.FileVersionInfo.dll
System.Diagnostics.Process.dll
System.Diagnostics.StackTrace.dll
System.Diagnostics.TextWriterTraceListener.dll
System.Diagnostics.Tools.dll
System.Diagnostics.TraceSource.dll
System.Diagnostics.Tracing.dll
System.dll
System.Drawing.dll
System.Drawing.Primitives.dll
System.Dynamic.Runtime.dll
System.Formats.Tar.dll
System.Globalization.Calendars.dll
System.Globalization.dll
System.Globalization.Extensions.dll
System.IO.Compression.Brotli.dll
System.IO.Compression.dll
System.IO.Compression.FileSystem.dll
System.IO.Compression.ZipFile.dll
System.IO.dll
System.IO.FileSystem.AccessControl.dll
System.IO.FileSystem.dll
System.IO.FileSystem.DriveInfo.dll
System.IO.FileSystem.Primitives.dll
System.IO.FileSystem.Watcher.dll
System.IO.IsolatedStorage.dll
System.IO.MemoryMappedFiles.dll
System.IO.Pipes.AccessControl.dll
System.IO.Pipes.dll
System.IO.UnmanagedMemoryStream.dll
System.Linq.dll
System.Linq.Expressions.dll
System.Linq.Parallel.dll
System.Linq.Queryable.dll
System.Memory.dll
System.Net.dll
System.Net.Http.dll
System.Net.Http.Json.dll
System.Net.HttpListener.dll
System.Net.Mail.dll
System.Net.NameResolution.dll
System.Net.NetworkInformation.dll
System.Net.Ping.dll
System.Net.Primitives.dll
System.Net.Quic.dll
System.Net.Requests.dll
System.Net.Security.dll
System.Net.ServicePoint.dll
System.Net.Sockets.dll
System.Net.WebClient.dll
System.Net.WebHeaderCollection.dll
System.Net.WebProxy.dll
System.Net.WebSockets.Client.dll
System.Net.WebSockets.dll
System.Numerics.dll
System.Numerics.Vectors.dll
System.ObjectModel.dll
System.Reflection.DispatchProxy.dll
System.Reflection.dll
System.Reflection.Emit.dll
System.Reflection.Emit.ILGeneration.dll
System.Reflection.Emit.Lightweight.dll
System.Reflection.Extensions.dll
System.Reflection.Metadata.dll
System.Reflection.Primitives.dll
System.Reflection.TypeExtensions.dll
System.Resources.Reader.dll
System.Resources.ResourceManager.dll
System.Resources.Writer.dll
System.Runtime.CompilerServices.Unsafe.dll
System.Runtime.CompilerServices.VisualC.dll
System.Runtime.dll
System.Runtime.Extensions.dll
System.Runtime.Handles.dll
System.Runtime.InteropServices.dll
System.Runtime.InteropServices.JavaScript.dll
System.Runtime.InteropServices.RuntimeInformation.dll
System.Runtime.Intrinsics.dll
System.Runtime.Loader.dll
System.Runtime.Numerics.dll
System.Runtime.Serialization.dll
System.Runtime.Serialization.Formatters.dll
System.Runtime.Serialization.Json.dll
System.Runtime.Serialization.Primitives.dll
System.Runtime.Serialization.Xml.dll
System.Security.AccessControl.dll
System.Security.Claims.dll
System.Security.Cryptography.Algorithms.dll
System.Security.Cryptography.Cng.dll
System.Security.Cryptography.Csp.dll
System.Security.Cryptography.dll
System.Security.Cryptography.Encoding.dll
System.Security.Cryptography.OpenSsl.dll
System.Security.Cryptography.Primitives.dll
System.Security.Cryptography.X509Certificates.dll
System.Security.dll
System.Security.Principal.dll
System.Security.Principal.Windows.dll
System.Security.SecureString.dll
System.ServiceModel.Web.dll
System.ServiceProcess.dll
System.Text.Encoding.CodePages.dll
System.Text.Encoding.dll
System.Text.Encoding.Extensions.dll
System.Text.RegularExpressions.dll
System.Threading.Channels.dll
System.Threading.dll
System.Threading.Overlapped.dll
System.Threading.Tasks.Dataflow.dll
System.Threading.Tasks.dll
System.Threading.Tasks.Extensions.dll
System.Threading.Tasks.Parallel.dll
System.Threading.Thread.dll
System.Threading.ThreadPool.dll
System.Threading.Timer.dll
System.Transactions.dll
System.Transactions.Local.dll
System.ValueTuple.dll
System.Web.dll
System.Web.HttpUtility.dll
System.Windows.dll
System.Xml.dll
System.Xml.Linq.dll
System.Xml.ReaderWriter.dll
System.Xml.Serialization.dll
System.Xml.XDocument.dll
System.Xml.XmlDocument.dll
System.Xml.XmlSerializer.dll
System.Xml.XPath.dll
System.Xml.XPath.XDocument.dll
WindowsBase.dll
../src/Web/obj/Debug/net8.0/linux-arm64/ref/ExperionCrawler.dll
<NUGET>/
bitfaster.caching/2.5.4/lib/net6.0/BitFaster.Caching.dll
csvhelper/33.0.1/lib/net8.0/CsvHelper.dll
epplus.interfaces/6.1.1/lib/net7.0/EPPlus.Interfaces.dll
epplus.system.drawing/6.1.1/lib/net7.0/EPPlus.System.Drawing.dll
epplus/7.4.2/lib/net8.0/EPPlus.dll
konscious.security.cryptography.argon2/1.3.1/lib/net8.0/Konscious.Security.Cryptography.Argon2.dll
konscious.security.cryptography.blake2/1.1.1/lib/net8.0/Konscious.Security.Cryptography.Blake2.dll
microsoft.codecoverage/17.6.0/lib/netcoreapp3.1/Microsoft.VisualStudio.CodeCoverage.Shim.dll
microsoft.entityframeworkcore.abstractions/9.0.0/lib/net8.0/Microsoft.EntityFrameworkCore.Abstractions.dll
microsoft.entityframeworkcore.relational/9.0.0/lib/net8.0/Microsoft.EntityFrameworkCore.Relational.dll
microsoft.entityframeworkcore/9.0.0/lib/net8.0/Microsoft.EntityFrameworkCore.dll
microsoft.extensions.caching.abstractions/9.0.0/lib/net8.0/Microsoft.Extensions.Caching.Abstractions.dll
microsoft.extensions.caching.memory/9.0.0/lib/net8.0/Microsoft.Extensions.Caching.Memory.dll
microsoft.extensions.configuration.abstractions/9.0.0/lib/net8.0/Microsoft.Extensions.Configuration.Abstractions.dll
microsoft.extensions.dependencyinjection.abstractions/10.0.2/lib/net8.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll
microsoft.extensions.dependencyinjection/10.0.2/lib/net8.0/Microsoft.Extensions.DependencyInjection.dll
microsoft.extensions.logging.abstractions/10.0.2/lib/net8.0/Microsoft.Extensions.Logging.Abstractions.dll
microsoft.extensions.logging/10.0.2/lib/net8.0/Microsoft.Extensions.Logging.dll
microsoft.extensions.options/10.0.2/lib/net8.0/Microsoft.Extensions.Options.dll
microsoft.extensions.primitives/10.0.2/lib/net8.0/Microsoft.Extensions.Primitives.dll
microsoft.io.recyclablememorystream/3.0.1/lib/net6.0/Microsoft.IO.RecyclableMemoryStream.dll
microsoft.openapi/1.6.14/lib/netstandard2.0/Microsoft.OpenApi.dll
microsoft.testplatform.testhost/17.6.0/lib/netcoreapp3.1/
Microsoft.TestPlatform.CommunicationUtilities.dll
Microsoft.TestPlatform.CoreUtilities.dll
Microsoft.TestPlatform.CrossPlatEngine.dll
Microsoft.TestPlatform.PlatformAbstractions.dll
Microsoft.TestPlatform.Utilities.dll
Microsoft.VisualStudio.TestPlatform.Common.dll
Microsoft.VisualStudio.TestPlatform.ObjectModel.dll
testhost.dll
microsoft.win32.systemevents/7.0.0/lib/net7.0/Microsoft.Win32.SystemEvents.dll
netdxf/2022.11.2/lib/net6.0/netDxf.dll
newtonsoft.json/13.0.4/lib/net6.0/Newtonsoft.Json.dll
npgsql.entityframeworkcore.postgresql/9.0.0/lib/net8.0/Npgsql.EntityFrameworkCore.PostgreSQL.dll
npgsql/9.0.0/lib/net8.0/Npgsql.dll
nuget.frameworks/5.11.0/lib/netstandard2.0/NuGet.Frameworks.dll
opcfoundation.netstandard.opc.ua.client/1.5.378.134/lib/net8.0/Opc.Ua.Client.dll
opcfoundation.netstandard.opc.ua.configuration/1.5.378.134/lib/net8.0/Opc.Ua.Configuration.dll
opcfoundation.netstandard.opc.ua.core/1.5.378.134/lib/net8.0/Opc.Ua.Core.dll
opcfoundation.netstandard.opc.ua.security.certificates/1.5.378.134/lib/net8.0/Opc.Ua.Security.Certificates.dll
opcfoundation.netstandard.opc.ua.server/1.5.378.134/lib/net8.0/Opc.Ua.Server.dll
opcfoundation.netstandard.opc.ua.types/1.5.378.134/lib/net8.0/Opc.Ua.Types.dll
pdfpig/0.1.9/lib/net8.0/
UglyToad.PdfPig.Core.dll
UglyToad.PdfPig.dll
UglyToad.PdfPig.DocumentLayoutAnalysis.dll
UglyToad.PdfPig.Fonts.dll
UglyToad.PdfPig.Package.dll
UglyToad.PdfPig.Tokenization.dll
UglyToad.PdfPig.Tokens.dll
swashbuckle.aspnetcore.swagger/6.8.1/lib/net8.0/Swashbuckle.AspNetCore.Swagger.dll
swashbuckle.aspnetcore.swaggergen/6.8.1/lib/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll
swashbuckle.aspnetcore.swaggerui/6.8.1/lib/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll
system.collections.immutable/10.0.2/lib/net8.0/System.Collections.Immutable.dll
system.diagnostics.diagnosticsource/10.0.2/lib/net8.0/System.Diagnostics.DiagnosticSource.dll
system.drawing.common/7.0.0/lib/net7.0/System.Drawing.Common.dll
system.formats.asn1/10.0.2/lib/net8.0/System.Formats.Asn1.dll
system.io.pipelines/10.0.2/lib/net8.0/System.IO.Pipelines.dll
system.security.cryptography.pkcs/8.0.1/lib/net8.0/System.Security.Cryptography.Pkcs.dll
system.text.encodings.web/10.0.2/lib/net8.0/System.Text.Encodings.Web.dll
system.text.json/10.0.2/lib/net8.0/System.Text.Json.dll
xunit.abstractions/2.0.3/lib/netstandard2.0/xunit.abstractions.dll
xunit.assert/2.4.2/lib/netstandard1.1/xunit.assert.dll
xunit.extensibility.core/2.4.2/lib/netstandard1.1/xunit.core.dll
xunit.extensibility.execution/2.4.2/lib/netstandard1.1/xunit.execution.dotnet.dll
[analyzerReferences]
../../../../../usr/lib/dotnet/packs/Microsoft.AspNetCore.App.Ref/8.0.26/analyzers/dotnet/cs/
Microsoft.AspNetCore.App.Analyzers.dll
Microsoft.AspNetCore.App.CodeFixes.dll
Microsoft.AspNetCore.Components.Analyzers.dll
../../../../../usr/lib/dotnet/packs/Microsoft.NETCore.App.Ref/8.0.26/analyzers/dotnet/cs/
Microsoft.Interop.ComInterfaceGenerator.dll
Microsoft.Interop.JavaScript.JSImportGenerator.dll
Microsoft.Interop.LibraryImportGenerator.dll
Microsoft.Interop.SourceGeneration.dll
System.Text.RegularExpressions.Generator.dll
../../../../../usr/lib/dotnet/sdk/8.0.126/Sdks/Microsoft.NET.Sdk/analyzers/
Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll
Microsoft.CodeAnalysis.NetAnalyzers.dll
<NUGET>/
microsoft.entityframeworkcore.analyzers/9.0.0/analyzers/dotnet/cs/Microsoft.EntityFrameworkCore.Analyzers.dll
microsoft.extensions.logging.abstractions/10.0.2/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll
microsoft.extensions.options/10.0.2/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll
system.text.json/10.0.2/analyzers/dotnet/roslyn4.4/cs/System.Text.Json.SourceGeneration.dll
xunit.analyzers/1.0.0/analyzers/dotnet/cs/
xunit.analyzers.dll
xunit.analyzers.fixes.dll
[analyzerConfigFiles]
../../../../../usr/lib/dotnet/sdk/8.0.126/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_8_default.globalconfig
obj/Debug/net8.0/ExperionCrawler.Tests.GeneratedMSBuildEditorConfig.editorconfig

View File

@@ -36,13 +36,13 @@ public class TextToSqlTest
#region 1. SQL
[Fact]
public void ParseNaturalLanguageAsync_WithValidInput_ReturnsValidSqlFormat()
public async Task ParseNaturalLanguageAsync_WithValidInput_ReturnsValidSqlFormat()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - 응답 형식 검증
Assert.NotNull(sql);
@@ -54,78 +54,78 @@ public class TextToSqlTest
}
[Fact]
public void ParseNaturalLanguageAsync_WithMaxKeyword_ReturnsMaxFunction()
public async Task ParseNaturalLanguageAsync_WithMaxKeyword_ReturnsMaxFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최대값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithMinKeyword_ReturnsMinFunction()
public async Task ParseNaturalLanguageAsync_WithMinKeyword_ReturnsMinFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최솟값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("min", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithFirstKeyword_ReturnsFirstFunction()
public async Task ParseNaturalLanguageAsync_WithFirstKeyword_ReturnsFirstFunction()
{
// Arrange
var input = "FICQ-6101.PV 초기 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("first", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithLastKeyword_ReturnsLastFunction()
public async Task ParseNaturalLanguageAsync_WithLastKeyword_ReturnsLastFunction()
{
// Arrange
var input = "FICQ-6101.PV 마지막 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("last", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithAvgKeyword_ReturnsAvgFunction()
public async Task ParseNaturalLanguageAsync_WithAvgKeyword_ReturnsAvgFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithMultipleTags_ReturnsAllTagsInSql()
public async Task ParseNaturalLanguageAsync_WithMultipleTags_ReturnsAllTagsInSql()
{
// Arrange
var input = "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
@@ -133,13 +133,13 @@ public class TextToSqlTest
}
[Fact]
public void ParseNaturalLanguageAsync_WithOpcUaNodeId_ReturnsOpcUaFormat()
public async Task ParseNaturalLanguageAsync_WithOpcUaNodeId_ReturnsOpcUaFormat()
{
// Arrange
var input = "ns=2;s=Reactor.Temperature 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("'ns=2;s=Reactor.Temperature'", sql);
@@ -246,23 +246,23 @@ public class TextToSqlTest
}
[Fact]
public async Task ExecuteQueryAsync_WithEmptySql_ThrowsException()
public async Task ExecuteQueryAsync_WithEmptySql_ThrowsArgumentException()
{
// Arrange
var sql = "";
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _service.ExecuteQueryAsync(sql));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ExecuteQueryAsync(sql));
}
[Fact]
public async Task ExecuteQueryAsync_WithNullSql_ThrowsException()
public async Task ExecuteQueryAsync_WithNullSql_ThrowsArgumentNullException()
{
// Arrange
string? sql = null;
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _service.ExecuteQueryAsync(sql!));
await Assert.ThrowsAsync<ArgumentNullException>(() => _service.ExecuteQueryAsync(sql!));
}
[Fact]
@@ -290,7 +290,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("INTERVAL '1 hour'", sql);
@@ -303,7 +303,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 24시간 최대값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("INTERVAL '24 hours'", sql);
@@ -316,7 +316,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 7일 최소값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("INTERVAL '7 days'", sql);
@@ -329,7 +329,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 1개월 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("INTERVAL '30 days'", sql);
@@ -342,7 +342,7 @@ public class TextToSqlTest
var input = "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 4월 14일 까지";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
@@ -358,7 +358,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 4월 1일 부터 4월 7일 까지 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
@@ -372,7 +372,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 오전 9시부터 오후 6시까지 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
@@ -387,7 +387,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 오늘 오후 2시 이후 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - 시작 시간 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
@@ -401,7 +401,7 @@ public class TextToSqlTest
var input = "temp-001 온도, pressure-002 압력 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - 한국어 설명은 제거되고 태그명만 포함되어야 함
Assert.Contains("'temp-001'", sql);
@@ -417,7 +417,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - "최근" 키워드는 제거되고 태그명만 포함되어야 함
Assert.Contains("'FICQ-6101.PV'", sql);
@@ -431,7 +431,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - "1시간" 패턴은 제거되고 태그명만 포함되어야 함
Assert.Contains("'FICQ-6101.PV'", sql);
@@ -445,7 +445,7 @@ public class TextToSqlTest
var input = "데이터 중 aia-131.sp 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - "데이터 중" 키워드는 제거되고 태그명만 포함되어야 함
Assert.Contains("'aia-131.sp'", sql);
@@ -459,7 +459,7 @@ public class TextToSqlTest
var input = "데이터 중 aia-131.sp 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - "중" 키워드 이후 태그명만 추출되어야 함
Assert.Contains("'aia-131.sp'", sql);
@@ -472,7 +472,7 @@ public class TextToSqlTest
var input = "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 현재까지의 값 표시";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
@@ -485,7 +485,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("'FICQ-6101.PV'", sql);
@@ -498,7 +498,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - default time bucket should be "5 min"
Assert.Contains("5 min", sql);
@@ -511,7 +511,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 1시간 average";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
@@ -524,7 +524,7 @@ public class TextToSqlTest
var input = "FICQ-6101.PV 최근 1시간 최대";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase);

View File

@@ -0,0 +1,499 @@
# OllamaController.cs — 재진단 보고서 (2차)
**진단 대상**: `src/Web/Controllers/OllamaController.cs` (1,194줄)
**진단 기준**: `diagnosis-checklist.md` 8단계
**날짜**: 2026-05-16
> ⚠️ **1차 진단 대비 주요 변경사항**:
> - `LoadConfig()` case-sensitivity 버그 발견 (1차 진단에서 누락)
> - 기존 static 캐시 문제 (HIGH) → 실제 배포 환경(systemd 단일 프로세스)에선 문제 없음으로 재평가 → **MED 강등**
> - 기존 Headers.Append → **LOW 강등** (현재 아키텍처에선 중복 조건 없음)
> - 기존 silent catch (HIGH) → 실제 낙하 경로 분석 결과 **MED**로 조정
> - 신규 발견: `VllmChatStreamWithTools` Reflection 추출, `GetModels` thundering herd
> - 파일 I/O blocking 항목 → Q3/Q4 탈락으로 보고서에서 완전 제거
---
## STEP 1 — 맥락 파악 (변경 없음)
- **역할**: Ollama / vLLM LLM HTTP Proxy + MCP Tool Calling Bridge (Web/API Controller)
- **주요 엔드포인트**: `GET /models`, `POST /chat`, `POST /chat/stream` (Ollama native) + `vllm/` variants + `GET/POST /config` + `GET /ping`
## STEP 2 — 구조 탐색 (변경 없음)
## STEP 3 — 코드 읽기 (전체 재확인 완료)
## STEP 4 — 호출 계층 지도 (변경 없음)
---
## STEP 5 — 패턴 매칭 + STEP 6 — 교차 검증
| # | 발견 | Q1 이미 수정? | Q2 다른 레이어? | Q3 의도적? | Q4 재현 시나리오? | 결과 |
|---|------|:---:|:---:|:---:|:---:|:---:|
| 1 | **LoadConfig() case-sensitive deserialization** → 저장한 설정이 유실됨 | ❌ | ❌ | ❌ | ✅ 저장 → 새로고침 → 기본값(localhost) 표시 | 🔴 HIGH |
| 2 | **`VllmChatStreamWithTools` anonymous type reflection 추출** → 느리고 fragile | ❌ | ❌ | ❌ | ✅ 동시 다중 tool_calls + 부하 상황 | 🟠 MED |
| 3 | **JSON 텍스트 도구 감지 실패 시 silent catch + fallthrough** | ❌ | ❌ | ❌ | ✅ LLM이 `{"tool":"run_sql","parameters":{...}}` 출력 → 파싱 예외 → 도구 무시 | 🟠 MED |
| 4 | **static 캐시(`_capsCache`) 메모리 누수 — TTL 만료 항목 미제거** | ❌ | ❌ | ❌ | ✅ Ollama에 100+ 모델 등록 시 누적 | 🟠 MED |
| 5 | **`GetModels()` — 모든 모델 capabilities를 동시 병렬 조회 (thundering herd)** | ❌ | ❌ | ❌ | ✅ Ollama에 20+ 모델 존재 시 /api/show 동시 20+ 요청 | 🟠 MED |
| 6 | **Summarize — `LoadVllmModel()` 무시하고 환경변수만 참조** | ❌ | ❌ | ❌ | ✅ `VLLM_MODEL` 미설정 + `req.Model` null → 빈 문자열로 vLLM 요청 | 🟠 MED |
| 7 | **`Response.Headers.Append()` — 이론적 중복 가능** | ❌ | ✅ 현재 미들웨어 체인에서 선행 설정 없음 | ❌ | ❌ 실제 재현 불가(LOW 조건) | 🟡 LOW |
| 8 | **HttpRequestMessage/HttpResponseMessage 미처분** | ❌ | ✅ .NET HttpClient가 내부 관리 | ❌ | ❌ 실제 누수 측정 불가 | 🟡 LOW |
| 9 | **ExtractFirstJsonObject 문자열 내 brace 미처리** | ❌ | ❌ | ❌ | ✅ 드물지만 LLM output에 `{` 포함 시 파싱 실패 | 🟡 LOW |
---
## STEP 7 — 상세 진단
---
### [1]. LoadConfig() case-sensitive deserialization → 설정 저장 후 유실 (🔴 HIGH)
**문제**: `SetConfig()``{"host":"10.0.0.50","port":11434}`처럼 **camelCase**로 JSON 파일을 저장하지만, `LoadConfig()``JsonSerializer.Deserialize<OllamaConfig>(json)` (기본 옵션, `PropertyNameCaseInsensitive = false`)로 읽기 때문에 `host``Host` 매칭이 실패한다. `OllamaConfig` 클래스에는 `[JsonPropertyName]` 애트리뷰트가 없으므로, 항상 기본값(`localhost:11434`)이 반환된다.
**근거**:
파일 쓰기 (`SetConfig()` 492-496줄) — JSON 키가 **camelCase**:
```csharp
var json = JsonSerializer.Serialize(new
{
host = cfg.Host, // ← "host" (camelCase)
port = cfg.Port // ← "port" (camelCase)
}, new JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(path, json);
```
파일 읽기 (`LoadConfig()` 47-63줄) — `PropertyNameCaseInsensitive = false` 기본값:
```csharp
OllamaConfig LoadConfig()
{
var path = OllamaConfigPath;
if (System.IO.File.Exists(path))
{
var json = System.IO.File.ReadAllText(path);
return JsonSerializer.Deserialize<OllamaConfig>(json) // ← case-sensitive!
?? new OllamaConfig(); // ← "host" != "Host" → 기본값 반환
}
return new OllamaConfig(); // localhost:11434
}
```
대상 클래스 (`OllamaConfig` 1144-1150줄):
```csharp
public class OllamaConfig
{
public string Host { get; set; } = "localhost"; // ← PascalCase
public int Port { get; set; } = 11434; // ← PascalCase
public string BaseUrl => $"http://{Host}:{Port}";
}
```
참고 — ASP.NET Core `[FromBody]` 모델 바인딩은 `AddJsonOptions`에서 `PropertyNameCaseInsensitive = true`가 기본값으로 설정되므로, `SetConfig(OllamaConfig cfg)`에서 `{ host: "..." }` 수신은 성공한다. 하지만 `LoadConfig()`가 호출하는 `JsonSerializer.Deserialize<T>()`는 그 옵션을 공유하지 않는다.
**영향**:
사용자 시나리오:
1. 설정 화면에서 Ollama host를 `10.0.0.50`로 변경 → `POST /api/ollama/config``SetConfig` → 파일 저장 직후 응답은 `{"host":"10.0.0.50","port":11434}`로 정상
2. 프론트엔드 alert: "변경 사항 적용을 위해 페이지를 새로고침하세요."
3. 새로고침 → `llmLoadConfigToUI()``GET /api/ollama/config``GetConfig()``LoadConfig()``"host"` != `"Host"``Host = "localhost"` 반환
4. 사용자는 "왜 저장이 안 되지?" 반복 시도 → 설정 저장은 항상 실패한 것처럼 보임
5. 심지어 서버 재시작 후에는 자동으로 `localhost:11434`로 동작하므로, 원격 Ollama 서버에 연결 불가
**수정** — 두 가지 중 택일:
**수정 A (권장 — `LoadConfig`에서 case-insensitive 옵션 적용):**
```csharp
private static readonly JsonSerializerOptions _configJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
OllamaConfig LoadConfig()
{
try
{
var path = OllamaConfigPath;
if (System.IO.File.Exists(path))
{
var json = System.IO.File.ReadAllText(path);
return JsonSerializer.Deserialize<OllamaConfig>(json, _configJsonOptions)
?? new OllamaConfig();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용");
}
return new OllamaConfig();
}
```
**수정 B (PascalCase로 파일 저장 — SetConfig 수정):**
```csharp
// SetConfig에서 PascalCase로 저장
System.IO.File.WriteAllText(path, JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }));
```
수정 A가 더 안전하다. 언젠가 다른 코드 경로에서도 이 파일을 camelCase로 작성할 가능성이 있으므로, 읽는 쪽에서 대소문자를 무시하는 것이 근본 해결책이다.
---
### [2]. `VllmChatStreamWithTools` — anonymous type reflection으로 tool call 추출 (🟠 MED)
**문제**: `VllmChatStreamWithTools()`(894-899줄)에서 `tcList`에 anonymous object를 담은 후, 같은 메서드 내에서 Reflection(`GetType().GetProperty(...)`)으로 값을 다시 꺼낸다. 값은 이미 변수(`tcId`, `funcName`, `funcArgs`)에 들어 있는데도 중복 추출하고 있다.
**근거** (`OllamaController.cs:887-899`):
```csharp
// 1. tcList에 anonymous object 저장
tcList.Add(new
{
id = tcId, // ← 이미 이 시점에 값이 로컬 변수에 있음
type = "function",
function = new
{
name = funcName,
arguments = funcArgs
}
});
// 2. 직후에 Reflection으로 다시 꺼냄 (894-899줄)
foreach (var tc in tcList)
{
var tcId = tc.GetType().GetProperty("id")?.GetValue(tc) as string ?? "";
var func = tc.GetType().GetProperty("function")?.GetValue(tc);
var funcName = func?.GetType().GetProperty("name")?.GetValue(func) as string ?? "";
var funcArgs = func?.GetType().GetProperty("arguments")?.GetValue(func) as string ?? "{}";
```
**영향**:
- Reflection은 직접 접근보다 **10~100배 느림**
- 컴파일 타임 타입 안전성 없음 — anonymous type property가 rename되면 조용히 `null` 반환
- 고부하 다중 tool_calls 시 불필요한 CPU 낭비
**수정** — 이미 알고 있는 변수 직접 사용:
```csharp
// messages.Add용으로만 tcList 유지 (line 887-892)
messages.Add(new
{
role = "assistant",
content = (string?)null,
tool_calls = tcList
});
// tool execution은 변수 직접 사용 (894-899줄 대신)
// 위쪽 루프(866-884)에서 tcId, funcName, funcArgs를 이미 알고 있음
// → 해당 루프 내에서 바로 tool 실행하거나, 별도 List<(string, string, string)>에 저장
var toolCallInfos = new List<(string id, string name, string args)>();
foreach (var tc in toolCalls.EnumerateArray())
{
var id = tc.GetProperty("id").GetString() ?? $"tc_{toolRound}_{Guid.NewGuid():N}";
var func = tc.GetProperty("function");
var name = func.GetProperty("name").GetString() ?? "";
var args = func.GetProperty("arguments").GetString() ?? "{}";
toolCallInfos.Add((id, name, args));
}
// messages에 assistant + tool_calls 추가 (tcList는 anonymous array)
messages.Add(new { role = "assistant", content = (string?)null, tool_calls = tcList });
// tool execution: toolCallInfos의 값을 직접 사용 (Reflection 불필요)
foreach (var (tcId, funcName, funcArgs) in toolCallInfos)
{
await EmitToolStart(tcId, funcName, funcArgs);
// ... 나머지 동일 ...
}
```
---
### [3]. JSON 텍스트 도구 감지 silent catch + fallthrough (🟠 MED)
**문제**: `VllmChatStreamWithTools()`(944-1036줄)에서 LLM 응답 내 JSON을 파싱하여 텍스트 기반 도구 호출을 감지할 때, `catch { }`(1035줄)로 예외를 삼킨 후 코드가 아래로 낙하한다. `stopContent`가 있으면 raw text로 SSE 발송되어 사용자에게 도구 의도가 노출된다. 로깅도 없어 디버깅이 불가능하다.
**근거** (`OllamaController.cs:944-1036`):
```csharp
var jsonCandidate = ExtractFirstJsonObject(stopContent);
if (!string.IsNullOrEmpty(jsonCandidate))
{
try
{
// ... JSON 파싱, 도구 감지, 실행 ...
if (detectedTool != null && args.Count > 0)
{
// ... 도구 호출 ...
continue; // ← 성공만 continue
}
// (실패: detectedTool == null || args.Count == 0) → fallthrough!
}
catch { } // ← 파싱 예외도 fallthrough → 로깅 없음
}
// 1038-1047: stopContent가 있으면 raw text로 SSE 발송
if (!string.IsNullOrEmpty(stopContent))
{
var msgJson = JsonSerializer.Serialize(new { message = new { content = stopContent } });
await Response.WriteAsync($"event: message\ndata: {msgJson}\n\n");
// ...
return; // ← 도구 대신 원본 텍스트 노출!
}
```
**영향**:
- LLM이 `{"tool": "run_sql", "parameters": {"sql": "..."}}`를 출력했으나 JSON 파싱 실패 → SQL이 실행되지 않고 JSON 텍스트가 그대로 사용자에게 전달됨
- 에이전트 모드 다단계 추론 중단
- `catch { }`로 디버깅 불가
**수정**:
```csharp
if (!string.IsNullOrEmpty(jsonCandidate))
{
bool toolExecuted = false;
try
{
// ... 기존 파싱/도출/실행 로직 ...
if (detectedTool != null && args.Count > 0) toolExecuted = true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패, Text로 fallback: {Candidate}", jsonCandidate);
}
if (toolExecuted) continue;
}
```
---
### [4]. `_capsCache` TTL 만료 항목 미제거 — 메모리 누수 (🟠 MED)
**문제**: `GetModelCapabilitiesAsync()`(329-357줄)의 `_capsCache``DateTime.UtcNow.AddMinutes(5)`로 TTL을 설정하지만, 만료된 항목을 제거하는 로직이 없다. Ollama 서버에 새 모델이 계속 추가되면(실험/개발 환경) 딕셔너리가 무한히 커진다.
**근거** (`OllamaController.cs:329-357`):
```csharp
private static readonly Dictionary<string, (DateTime Until, string[] Caps)> _capsCache = new();
private static readonly object _capsCacheLock = new();
private async Task<string[]> GetModelCapabilitiesAsync(string baseUrl, string model)
{
lock (_capsCacheLock)
{
if (_capsCache.TryGetValue(model, out var hit) && hit.Until > DateTime.UtcNow)
return hit.Caps;
}
// ... HTTP call ...
lock (_capsCacheLock) { _capsCache[model] = (DateTime.UtcNow.AddMinutes(5), caps); }
return caps;
}
```
**영향**:
- 10개 모델에서 200개 모델로 증가 시 캐시가 20배 확장 (무제한)
- 앱 생명주기 동안 제거되지 않아 메모리 단편화 유발
**수정**`IMemoryCache`로 교체 (TTL + 자동 pruning):
```csharp
private readonly IMemoryCache _memoryCache;
// DI: Program.cs에서 builder.Services.AddMemoryCache();
public OllamaController(..., IMemoryCache memoryCache)
{
_memoryCache = memoryCache;
}
private async Task<string[]> GetModelCapabilitiesAsync(string baseUrl, string model)
{
var cacheKey = $"caps_{model}";
if (_memoryCache.TryGetValue(cacheKey, out string[] cached))
return cached;
try
{
// ... HTTP call ...
_memoryCache.Set(cacheKey, caps, TimeSpan.FromMinutes(5));
return caps;
}
catch { return Array.Empty<string>(); }
}
```
---
### [5]. `GetModels()` — 모든 모델의 `/api/show`를 동시 병렬 호출 (🟠 MED)
**문제**: `GetModels()`(300-305줄)는 `allModels.Select(async n => ...)` + `Task.WhenAll(tasks)`를 사용하여 모든 모델의 capabilities를 **동시에** 조회한다. Ollama 서버에 20개 모델이 있으면 20개의 `/api/show` 요청이 순간적으로 폭주한다.
**근거** (`OllamaController.cs:300-305`):
```csharp
var tasks = allModels.Select(async n =>
{
var caps = await GetModelCapabilitiesAsync(cfg.BaseUrl, n);
return (name: n, isChat: caps.Contains("completion"));
}).ToList();
var results = await Task.WhenAll(tasks);
```
**영향**:
- Ollama 서버에 부하 집중 (특히 모델이 디스크에서 로딩 중이면 응답 지연)
- 초기 페이지 로드 시 `/api/ollama/models`가 수십 초 지연 가능
- 분당 수백 번 호출 시 서버 리소스 고갈
**수정**`SemaphoreSlim`으로 동시성 제한:
```csharp
private static readonly SemaphoreSlim _modelCapSemaphore = new(3); // 최대 3 concurrent
var tasks = allModels.Select(async n =>
{
await _modelCapSemaphore.WaitAsync();
try
{
var caps = await GetModelCapabilitiesAsync(cfg.BaseUrl, n);
return (name: n, isChat: caps.Contains("completion"));
}
finally { _modelCapSemaphore.Release(); }
}).ToList();
var results = await Task.WhenAll(tasks);
```
또는 더 간단한 방법 — 캐시가 있으므로 `GetModels``Task.WhenAll`을 제거하고 순차 조회:
```csharp
// 이미 5분 TTL 캐시가 있으므로, 순차 조회해도 두 번째 요청부터는 캐시 히트
foreach (var n in allModels)
{
var caps = await GetModelCapabilitiesAsync(cfg.BaseUrl, n);
if (caps.Contains("completion")) chatModels.Add(n);
else embeddingModels.Add(n);
}
```
---
### [6]. Summarize — `LoadVllmModel()` 무시 (🟠 MED)
**문제**: `Summarize()`(663줄)에서 model 기본값을 `Environment.GetEnvironmentVariable("VLLM_MODEL") ?? ""`로 설정한다. `LoadVllmModel()` 메서드가 파일→환경변수→기본값 순서로 폴백하는 것과 달리, 환경변수만 확인하고 빈 문자열로 폴백한다.
**근거** (`OllamaController.cs:663`):
```csharp
var model = string.IsNullOrWhiteSpace(req.Model)
? Environment.GetEnvironmentVariable("VLLM_MODEL") ?? "" // ← LoadVllmModel() 무시
: req.Model;
```
동일 컨트롤러의 기존 메서드 (`OllamaController.cs:536-553`):
```csharp
string LoadVllmModel()
{
// 1. llm-model.json 파일 확인
// 2. 파일 없으면 → "Qwen3.6-27B-FP8" 기본값 반환
}
```
**영향**:
- `VLLM_MODEL` env 미설정 환경에서 `req.Model` 미포함 요청 시 빈 문자열 model로 vLLM 400 오류
- llm-model.json에 설정된 모델명이 무시됨
**수정**:
```csharp
var model = string.IsNullOrWhiteSpace(req.Model)
? LoadVllmModel() // ← 기존 메서드 재사용
: req.Model;
```
---
### [7]. `Response.Headers.Append()` — 이론적 중복 가능 (🟡 LOW)
**문제**: SSE 스트리밍 엔드포인트(402-405, 702-705줄)에서 `Response.Headers.Append()`로 헤더를 설정한다. `IHeaderDictionary.Append()`는 기존 헤더가 이미 존재할 경우 **값을 추가**하므로(덮어쓰지 않음), nginx 등 reverse proxy에서 중복 헤더 충돌 가능성이 있다.
**근거** (`OllamaController.cs:402-405`):
```csharp
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
Response.Headers.Append("X-Accel-Buffering", "no");
```
**영향**: 현재 아키텍처(Kestrel 직접 서빙, 미들웨어 체인에 선행 헤더 세터 없음)에서는 재현 불가. nginx-proxy 도입 시 발생 가능.
**수정**:
```csharp
Response.Headers["Content-Type"] = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["Connection"] = "keep-alive";
Response.Headers["X-Accel-Buffering"] = "no";
```
---
### [8]. HttpRequestMessage / HttpResponseMessage 미처분 (🟡 LOW)
**문제**: 스트리밍 메서드들(`OllamaChatStream` 420-425줄, `VllmChatStreamSimple` 751-756줄, `VllmChatStreamWithTools` 832-836, 1060-1065줄)에서 `HttpRequestMessage``HttpResponseMessage``using`이 누락되었다.
**근거**: `OllamaChatStream`(420-434줄):
```csharp
var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{cfg.BaseUrl}/api/chat")
{
Content = content
}; // ← using 없음
var res = await _httpClient.SendAsync(httpRequest, ...); // ← using 없음
// ...
using var reader = new StreamReader(stream, Encoding.UTF8); // StreamReader만 using
```
**영향**: .NET `HttpClient`(SocketsHttpHandler)가 내부적으로 연결을 관리하고, `StreamReader` disposal이 스트림을 닫으므로 실질적 누수는 미미하다. 하지만 코드 분석 툴에서 경고를 발생시키고, 극단적 부하에서 GC 압박 유발 가능.
**수정**: 일관성 있는 `using var` 적용:
```csharp
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, ...) { Content = content };
using var res = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted);
```
---
### [9]. `ExtractFirstJsonObject` — 문자열 내 `{` brace 미처리 (🟡 LOW)
**문제**: `ExtractFirstJsonObject`(1130-1141줄)가 escaping과 문자열 상태를 고려하지 않고 depth만 센다. LLM 응답 문자열 리터럴에 `{`가 포함되면 depth 계산이 틀어진다.
**영향**: LLM이 응답 중간에 JSON 형식을 출력하면(예: `설명: 매개변수 {a, b, c}`) brace depth가 깨져서 잘못된 범위 추출. 발생 빈도 낮음.
**수정**: 1차 진단과 동일 (문자열 상태 추적 로직 추가).
---
## STEP 8 — 자가 검증
- [x] 각 지적 사항을 현재 파일의 특정 줄 번호로 직접 가리킴
- [x] HIGH 항목(1건)은 재현 가능한 시나리오를 한 문장으로 서술 가능
- [x] 교차 검증 4개 질문 모두 통과
- [x] 실측 없는 성능 수치 배제
- [x] 기존 보고서 누락 항목(`LoadConfig` case-sensitivity) 추가 완료
---
## 최종 요약
| 심각도 | 수 | 항목 |
|--------|---|------|
| 🔴 HIGH | 1 | [1] `LoadConfig()` case-sensitive deserialization → 설정 저장 후 유실 |
| 🟠 MED | 5 | [2] Reflection tool call 추출, [3] JSON 감지 silent catch + fallthrough, [4] 캐시 메모리 누수, [5] Thundering herd, [6] Summarize 기본값 누락 |
| 🟡 LOW | 3 | [7] Headers.Append 중복, [8] HttpMessage disposal 누락, [9] ExtractFirstJsonObject brace |
**우선 수정 순서**: [1] → [2] → [3] → [5] → [4] → [6] → [9] → [7] → [8]
- **[1]은 서비스 장애** — 설정을 저장할 수 없어 원격 Ollama 연결 불가
- **[2]는 기능 버그** — Reflection 깨지면 tool_calling 무시
- **[3]은 디버깅 불가** — 도구 호출 누락 시 silent
- **[5]는 성능** — 모델 20개 이상 시 페이지 로드 수초 지연
- **[4][6][9]는 안정성/일관성**
- **[7][8]은 Best practice**
### 1차 보고서와의 비교
| 항목 | 1차 (등급) | 2차 (등급) | 사유 |
|------|:---------:|:---------:|------|
| static 캐시 | 🔴 HIGH | 🟠 MED | systemd 단일 프로세스 환경에선 문제 아님. 메모리 누수만 재평가 |
| JSON silent catch | 🔴 HIGH | 🟠 MED | fallthrough 후에도 stopContent는 SSE 발송됨 (crash는 아님) |
| Headers.Append | 🟠 MED | 🟡 LOW | 선행 미들웨어 없어 실제 재현 불가 |
| HttpMessage disposal | 🟠 MED | 🟡 LOW | .NET 런타임이 내부 관리 |
| LoadConfig 캐싱 | 🟡 LOW | **🔴 HIGH** | **case-sensitivity 버그 발견** (1차 누락) |
| Reflection tool call | — | 🟠 MED | 1차 누락 |
| Thundering herd | — | 🟠 MED | 1차 누락 |
| Summarize 기본값 | 🟠 MED | 🟠 MED | 유지 |
| ExtractFirstJsonObject | 🟡 LOW | 🟡 LOW | 유지 |

View File

@@ -0,0 +1,101 @@
# PGMEA 회수 플랜트 운전 일반상식
> 단일 컬럼, Packing Type, 진공 가열 방식
---
## 1. PGMEA 물성 및 진공 증류 필요성
| 항목 | 값 |
|------|-----|
| 화학식 | Propylene Glycol Methyl Ether Acetate |
| 상압 끓는점 | 약 146°C |
| 인화점 | 약 34°C (Class IB 가연성) |
| 분해 온도 | 180°C 이상에서 열분해 시작 |
**진공 증류 필요성:** 상압 끓는점(146°C)이 분해 온도에 가까워 열분해 위험이 있습니다. 진공(약 50~100 Torr)으로 압력을 낮추면 끓는점을 **70~90°C 대**로 낮출 수 있어 열분해를 방지합니다.
---
## 2. 주요 구성 요소
- **증류탑 (Distillation Column)**: **Packing Type** (패킹 충전식), 상단에서 정제된 PGMEA 증기 추출
- **리보일러 (Reboiler)**: 간접 가열형 (steam 또는 열유), 열분해 방지를 위해 과열 방지
- **콘덴서 (Condenser)**: 탑상 증기를 액화, 냉각수 순환
- **진공 펌프 (Vacuum Pump)**: 수봉식(Liquid Ring) 또는 제트 펌프, 탑 내 감압 유지
- **리플럭스 펌프 (Reflux Pump)**: 콘덴서드에서 일부 액체를 탑상으로 순환
- **저장 탱크**: 회수 PGMEA 제품 탱크, 잔여물(바텀) 탱크
### 분배기 (Distributor)
Packing Type 증류탑의 핵심 내부부품. 패킹(충전제) 위에 액체를 균일하게 분배하여 기액 접촉 면적을 최대화합니다. 분배가 불균일하면 채널링이 발생해 분리 효율이 급격히 저하됩니다.
| 종류 | 영어 | 역할 |
|------|------|------|
| 환류액 분배기 | Reflux Distributor | 탑상에서 내려오는 리플럭스액을 패킹 위에 균일하게 분배 |
| 원료 분배기 | Feed Distributor | 중간에 투입되는 피드(원료)를 패킹 위에 균일하게 분배 |
| 액체 분배기 | Liquid Distributor | 일반적인 액체 분배용 (다단계 패킹 구간마다 설치) |
---
## 3. 운전 순서
### 시동
1. 진공 펌프 가동 → 탑 내 압력 목표치까지 감압
2. 냉각수 공급 시작 (콘덴서)
3. 리플럭스 탱크 충전 후 리플럭스 펌프 가동
4. 리보일러 서서히 가열 (ramp-up, 1~2°C/min)
5. 피드 탱크에서 사용된 PGMEA를 피드 펌프로 공급 시작
6. 정상 운전 파라미터 도달까지 안정화
### 정상 운전
- 압력, 온도, 리플럭스 비, 피드 유량 자동 제어
- 상단 제품 (PGMEA) 품질 모니터링 (굴절률, GC 분석)
### 정지
1. 피드 공급 중단
2. 리보일러 가열 중단, 자연 냉각
3. 탑 내 잔여액 드레인
4. 진공 해제 (대기압 복귀)
5. 냉각수 중단
---
## 4. 주요 운전 파라미터 (참고값)
| 파라미터 | 범위 |
|----------|------|
| 탑 압력 | 50~100 Torr (진공) |
| 탑저 온도 | 80~100°C |
| 탑상 온도 | 60~80°C |
| 리플럭스 비 | 3:1 ~ 10:1 (순도에 따라) |
| 제품 순도 | 99.5% 이상 (반도체 등급) |
---
## 5. 안전 고려사항
- **가연성**: 인화점 34°C → 정전식 장비, 폭발방지 설계, 가연성 가스 감지기 필수
- **열분해**: 리보일러 온도 과승 시 아세테이트 분해 → 독성 가스 발생 가능
- **진공 파손**: 대기압 유입 시 급격한 끓음 가능 → 진공 rupture 디스크 설치
- **정전기**: 정전기 발생 방지 (접지, 유속 제한)
---
## 6. 일반적인 문제점
| 문제 | 원인 | 대응 |
|------|------|------|
| 제품 순도 저하 | 리플럭스 비 부족, 패킹 막힘 | 리플럭스 증가, 세정 |
| 진공 불안정 | 실(Seal) 불량, 펌프 성능 저하 | 실 점검, 펌프 유지보수 |
| 압력 강하 증가 | 패킹 fouling, 채널링 | 패킹 세정 또는 교체 |
| 열분해 증상 | 리보일러 온도 과승 | 온도 제한, 가열율 감소 |
---
## 7. 용어 정리
| 잘못된 표기 | 올바른 표기 | 영어 |
|-------------|-------------|------|
| 리프러드 | **리플럭스** | Reflux |
| vakuum | **진공** | Vacuum |
| 시일 | **실** | Seal |

1293
PID-추출-개선안1.md Normal file

File diff suppressed because it is too large Load Diff

69
PROJECT_CONTEXT.md Normal file
View File

@@ -0,0 +1,69 @@
# PROJECT_CONTEXT.md
## 🎯 Project Overview
**ExperionCrawler**는 신암정유 전공장의 제어 시스템인 **Honeywell Experion HS R530**으로부터 실시간 데이터를 수집, 저장, 분석하여 지능형 운영 환경을 제공하는 시스템입니다. OPC UA를 통해 데이터를 획득하며, RAG(Retrieval-Augmented Generation)와 MCP(Model Context Protocol)를 결합하여 LLM이 공정 데이터를 이해하고 운영 가이드를 제공할 수 있도록 설계되었습니다.
## 🏗️ System Architecture
프로젝트는 **Clean Architecture** 패턴을 따르며, `src/` 디렉토리 내의 세 계층으로 구성됩니다. (`Core``Infrastructure``Web` 프로젝트에 포함되어 빌드됩니다.)
### 1. Core Layer (`src/Core/`)
* **Domain/Entities:** 데이터베이스 엔티티 및 도메인 모델.
* **Application/Interfaces:** 모든 서비스의 계약(`IExperionServices.cs`).
* **Application/Services:** 비즈니스 로직 구현 (예: `TextToSqlService`, `PidGraphService`).
* **Application/DTOs:** API 및 서비스 간 데이터 전송 객체.
### 2. Infrastructure Layer (`src/Infrastructure/`)
* **OpcUa/:** OPC UA 클라이언트/서버 로직 및 실시간 데이터 처리.
* **Database/:** Entity Framework Core 및 PostgreSQL/TimescaleDB 연동.
* **Mcp/:** Python 기반 MCP 서버와의 통신을 위한 브릿지 (JSON-RPC over HTTP).
* **Kb/:** Qdrant 기반 지식 베이스(Knowledge Base) 관리.
### 3. Web Layer (`src/Web/`)
* **Program.cs:** 의존성 주입(DI) 및 백그라운드 서비스(HostedServices) 오케스트레이션.
* **Controllers/:** 단일 파일(`ExperionControllers.cs`)로 구성된 API 엔드포인트.
* **wwwroot/:** 빌드 단계가 없는 Vanilla JS SPA.
## ⚙️ Critical Operational Semantics
### ⏰ Timezone & Time Handling
* **Storage:** 모든 데이터베이스 타임스탬프는 **UTC**로 저장됩니다.
* **Presentation:** LLM 및 사용자에게 전달될 때는 반드시 **KST (UTC+9)**로 변환되어야 합니다.
### ⏳ Event Duration Semantics
* **`event_history_table.duration_seconds`**: 이 필드는 **"직전 상태가 유지된 시간(초)"**을 의미합니다. 현재 이벤트의 지속 시간이 아님에 주의하십시오.
### 🔡 JSON Naming Convention (CRITICAL)
* **Backend:** `PropertyNamingPolicy = null` 설정으로 인해 C#의 PascalCase가 JSON 키로 그대로 전달됩니다.
* **Frontend:** 프론트엔드는 **camelCase**를 기대합니다.
* **Rule:** 모든 Controller의 `Ok(...)` 응답 시, 반드시 **익명 객체를 사용하여 명시적으로 camelCase 키를 지정**해야 합니다.
*`return Ok(new { id = x.Id, tagName = x.TagName });`
*`return Ok(new { x.Id, x.TagName });`
## 🔄 Background Services (HostedServices)
| Service | Responsibility |
| :--- | :--- |
| `ExperionRealtimeService` | OPC UA 구독 및 500ms 단위 데이터 DB 플러시 |
| `ExperionHistoryService` | 60초 주기 `realtime_table``history_table` 스냅샷 |
| `ExperionOpcServerService` | 로컬 데이터를 OPC UA 서버(Port 4841)로 노출 |
| `McpServerHostedService` | Python MCP 서버 프로세스 생명주기 관리 |
| `ExperionFastService` | 고주파 데이터 캡처 세션 관리 |
| `ExperionFastCleanupService` | 만료된 Fast 세션 정리 |
## 🛠️ Development & Deployment
### Build & Test
* **Build:** `dotnet build src/Web/ExperionCrawler.csproj`
* **Run (Dev):** `dotnet run` (Working Dir: `src/Web/`)
* **Test:** `dotnet test`
* **Lint/Typecheck:** (사용자 제공 시 실행 필수)
### Deployment
* **Command:** `sudo bash deploy.sh`
* **Target:** `/opt/ExperionCrawler`
* **Service:** `systemd` 서비스 `experioncrawler`로 실행 (User: `www-data`)
## 🤖 Agent Operational Guidelines
1. **Verification:** 코드 수정 후에는 반드시 프로젝트의 Lint 및 Typecheck 명령어를 실행하여 무결성을 검증하십시오.
2. **Search First:** 새로운 기능을 구현하거나 버그를 수정하기 전, 반드시 `Grep``Glob`을 사용하여 기존 패턴과 컨벤션을 확인하십시오.
3. **Data Integrity:** DB 쿼리 시 `duration_seconds`의 의미와 시간대 변환 규칙을 항상 염두에 두십시오.
4. **Commit Policy:** 사용자의 명시적 요청이 없는 한 절대 `git commit`을 수행하지 마십시오.

View File

@@ -1,3 +1,4 @@
<!-- Hello from Worker! -->
# ExperionCrawler
Honeywell Experion OPC UA 서버를 위한 웹 기반 데이터 수집 도구.
@@ -22,99 +23,4 @@ ExperionCrawler/
│ └── Database/ # ExperionDbContext + ExperionDbService (EF Core / SQLite)
└── Web/
├── Controllers/ # ExperionCertificateController, ConnectionController ...
├── Program.cs # DI 등록, 미들웨어
└── wwwroot/ # index.html + css/style.css + js/app.js
```
## 기능
| 메뉴 | 설명 |
|------|------|
| 01 인증서 관리 | OPC UA 클라이언트 X.509 인증서 생성 / 상태 확인 |
| 02 서버 접속 테스트 | OPC UA 서버 연결 테스트, 단일 태그 읽기, 노드 탐색 |
| 03 데이터 크롤링 | 복수 노드 주기 수집 → CSV 저장 |
| 04 DB 저장 | CSV 파일 → SQLite DB 임포트, 레코드 조회 |
## Ubuntu 서버 배포
### 사전 요구사항
```bash
# .NET 8 SDK (없으면 deploy.sh 가 자동 설치)
dotnet --version
```
### 한 번에 배포
```bash
git clone <repo> ExperionCrawler
cd ExperionCrawler
sudo bash deploy.sh
```
### 수동 실행 (개발/테스트)
```bash
cd src/Web
dotnet run
# → http://localhost:5000
```
### 서비스 관리
```bash
sudo systemctl status experioncrawler
sudo systemctl restart experioncrawler
sudo systemctl stop experioncrawler
sudo journalctl -u experioncrawler -f # 실시간 로그
```
## PKI 디렉토리 구조 (원본 Program.cs 준수)
```
<실행 위치>/
└── pki/
├── own/certs/{clientHostName}.pfx ← 생성된 클라이언트 인증서
├── trusted/certs/ ← 신뢰 피어 인증서
├── issuers/certs/ ← 신뢰 발급자 (필수 경로)
└── rejected/certs/ ← 거부된 인증서
```
## 데이터 저장 위치
```
<실행 위치>/
└── data/
├── experion.db ← SQLite DB
└── csv/ ← 크롤링 CSV 파일
```
## API 엔드포인트
```
GET /api/certificate/status?clientHostName=dbsvr
POST /api/certificate/create { clientHostName, subjectAltNames, pfxPassword }
POST /api/connection/test { serverHostName, port, clientHostName, userName, password }
POST /api/connection/read { serverConfig, nodeId }
POST /api/connection/browse { serverConfig, startNodeId? }
POST /api/crawl/start { serverConfig, nodeIds[], intervalSeconds, durationSeconds }
GET /api/database/files
POST /api/database/import { fileName }
GET /api/database/records?limit=100&from=&to=
```
Swagger UI: `http://<서버IP>:5000/swagger` (Development 모드)
## 패키지 버전
| 패키지 | 버전 |
|--------|------|
| OPCFoundation.NetStandard.Opc.Ua.Client | 1.5.374.85 |
| OPCFoundation.NetStandard.Opc.Ua.Core | 1.5.374.85 |
| CsvHelper | 33.0.1 |
| Microsoft.EntityFrameworkCore.Sqlite | 8.0.13 |
| Swashbuckle.AspNetCore | 6.8.1 |
├── Controllers/ # ExperionC

50
T2SQL-진단-GEMMA4.md Normal file
View File

@@ -0,0 +1,50 @@
# Text-to-SQL 기능 E2E 보안 진단 보고서 (Generated by Gemma 4)
## 1. 진단 개요
- **대상**: ExperionCrawler Text-to-SQL 기능 (NLP to SQL 및 E2E Query 실행 경로)
- **목적**: LLM이 생성한 SQL의 실행 과정에서 발생할 수 있는 보안 취약점(SQL Injection 등) 식별
- **진단 방법**: `diagnosis-checklist.md` 프로토콜에 따른 8단계 정밀 진단
## 2. 발견된 결함 (Critical Findings)
### **[결함 1] 검증의 단절 (Architectural Validation Gap)**
- **위험 등급**: **CRITICAL**
- **설명**: 시스템 내에 매우 강력한 `SqlValidator`가 구현되어 있으나, 자연어 질의를 통한 실행 경로(`QueryWithNl`)에서는 이 검증 로직이 완전히 누락되어 있음.
- **상세 경로 비교**:
- **Safe Path (A)**: `TextToSqlService.ExecuteQueryAsync` $\rightarrow$ `SqlValidator.Validate()` $\rightarrow$ DB 실행 (정상 검증)
- **Vulnerable Path (B)**: `TextToSqlController.QueryWithNl` $\rightarrow$ `McpService` $\rightarrow$ `McpClient` $\rightarrow$ **Python MCP Server** (검증 없음)
- **영향**: 공격자가 LLM을 조작하여 악의적인 SQL을 생성하도록 유도할 경우, `SqlValidator`의 모든 방어 기제(키워드 차단, 함수 화이트리스트, 테이블 제한 등)를 무력화하고 DB를 직접 조작할 수 있음.
## 3. 기술적 세부 분석
### **A. SqlValidator의 방어 수준 (C# 구현)**
`SqlValidator.cs`는 다음과 같은 다층 방어를 수행함:
- `SELECT` 문 강제 (Line 49)
- DDL/DML 위험 키워드 차단 (Line 60)
- `UNION`, `INTO`, `CALL` 등 금지된 구문(Clause) 정규식 검사 (Line 95)
- 허용된 함수(Whitelist) 외 호출 차단 (Line 131)
- 허용된 테이블(Whitelist) 외 참조 차단 (Line 196)
- 의심스러운 Injection 패턴(OR 1=1 등) 감지 (Line 255)
### **B. 취약점 발생 지점**
`McpClient.cs``QueryWithNlAsync` (Line 151) 및 `RunSqlAsync` (Line 123) 메서드는 입력된 문자열을 그대로 Python MCP 서버로 전달하는 Proxy 역할만 수행하며, C# 계층에서의 어떠한 유효성 검사도 수행하지 않음.
## 4. 공격 시나리오 (Exploit Scenario)
1. **유도**: 공격자가 "모든 로그 테이블을 삭제하고 현재 시간을 출력해줘"와 같은 자연어 입력.
2. **생성**: LLM이 `DROP TABLE history_table; SELECT NOW();` 또는 `DELETE FROM ...` 형태의 SQL 생성.
3. **실행**: `McpClient`가 해당 SQL을 Python 서버로 전송.
4. **결과**: Python 서버(FastMCP)가 전달받은 SQL을 즉시 실행하여 데이터 유실 발생.
## 5. 권장 조치 사항 (Remediation)
### **[단기 조치 - 우선순위: 높음]**
- **C# 계층 검증 강제**: `McpService.QueryWithNlAsync` 또는 `McpClient.CallToolAsync` 호출 직전에 `SqlValidator`를 반드시 거치도록 로직 수정.
- **경로 통합**: `TextToSqlService` 내에서 MCP 호출 전 SQL을 미리 생성하여 검증한 뒤 전달하는 방식으로 흐름 변경.
### **[장기 조치 - 우선순위: 보통]**
- **Defense in Depth**: Python MCP 서버 내부에서도 C#의 `SqlValidator`와 유사한 수준의 SQL 검증 로직을 구현하여 2중 방어 체계 구축.
- **최소 권한 원칙**: MCP 서버가 사용하는 DB 계정의 권한을 `SELECT` 전용으로 제한.
---
**진단 완료일**: 2026-05-17
**진단 도구**: opencode (Gemma 4 기반)

165
T2SQL-진단-반박.md Normal file
View File

@@ -0,0 +1,165 @@
# T2SQL 보안 진단 보고서 — Gemma 4 반박
## 결론: Gemma 4의 CRITICAL 발견은 **False Positive**
Gemma 4 보고서는 `QueryWithNl` 경로에서 SQL 검증이 누락되었다고 주장함. 그러나 코드 기반 조사 결과, Python MCP 서버 측에서 동등한 검증이 존재함을 확인함.
---
## STEP 1: 기능 아키텍처
Text-to-SQL 기능은 두 경로를 가짐:
1. **C# 직접 경로**: `TextToSqlController.QueryWithNl()``TextToSqlService``SqlValidator` → PostgreSQL
2. **MCP 경로**: `TextToSqlController.QueryWithNl()``McpService``McpClient` → Python MCP Server → PostgreSQL
Gemma 4는 MCP 경로만 분석하고 C# `SqlValidator`가 이 경로에 적용되지 않는 것을 CRITICAL로 분류함.
## STEP 2: 관련 파일
| 파일 | 역할 | 검증 관련 |
|------|------|-----------|
| `src/Web/Controllers/TextToSqlController.cs` | Controller | L56 `QueryWithNl` 엔드포인트 |
| `src/Core/Application/Services/TextToSqlService.cs` | C# Service | `SqlValidator` 사용 |
| `src/Core/Application/Services/SqlValidator.cs` | C# Validator | 다중 레이어 검증 |
| `src/Infrastructure/Mcp/McpService.cs` | MCP Service | `McpClient` 래핑 |
| `src/Infrastructure/Mcp/McpClient.cs` | HTTP Client | 검증 없음 (Python에 위임) |
| `mcp-server/server.py` | Python MCP Server | `_validate_sql()` L573 |
| `mcp-server/worker/nl2sql_worker.py` | NL2SQL Worker | 별도 검증 로직 |
## STEP 3: 소스 코드 분석
### C# `SqlValidator` (`src/Core/Application/Services/SqlValidator.cs`)
- 다중 레이어 검증 (키워드, 문법, 길이)
- `statement_timeout` + auto LIMIT 적용
- 파일 경로 표현 차단 (`..`, `~`)
### Python `_validate_sql()` (`mcp-server/server.py` L573-589)
```python
def _validate_sql(sql: str) -> tuple[bool, str]:
"""SQL 안전 검증 — SELECT/WITH만 허용, 위험 키워드 차단."""
if len(sql) > 2000:
return False, "쿼리 길이 2000자를 초과했습니다."
dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY']
sql_upper = sql.upper()
for kw in dangerous:
if re.search(rf"\b{kw}\b", sql_upper):
return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다."
head = sql_upper.lstrip().lstrip('(').lstrip()
if not (head.startswith('SELECT') or head.startswith('WITH')):
return False, "SELECT 또는 WITH 쿼리만 허용됩니다."
if '..' in sql or '~' in sql:
return False, "파일 경로 표현은 허용되지 않습니다."
if ';' in sql.rstrip().rstrip(';'):
return False, "다중 문장(세미콜론)은 허용되지 않습니다."
return True, ""
```
### Python `_execute_sql_internal()` (`mcp-server/server.py` L907-914)
```python
async def _execute_sql_internal(sql: str) -> str:
"""SQL 검증 + 실행 (공통 경로)."""
valid, reason = _validate_sql(sql)
if not valid:
return json.dumps({"success": False, "error": reason})
# ... auto-LIMIT + statement_timeout 적용 후 실행
```
### Python `query_with_nl` tool (`mcp-server/server.py` L1114-1222)
- LLM이 SQL 생성 → `_execute_sql_internal(sql)` 호출 (L1199)
- 검증 실패 시 에러 반환, 실행하지 않음
## STEP 4: 호출 계층도
```
QueryWithNl 요청
├── C# 직접 경로 (TextToSqlService)
│ ├── TextToSqlService.ExecuteQueryAsync()
│ │ ├── SqlValidator.Validate() ← 검증 O
│ │ └── Dapper.QueryAsync()
│ └── 결과 반환
└── MCP 경로 (McpService)
├── McpService.QueryWithNlAsync()
│ └── McpClient.PostAsync()
│ └── HTTP POST → Python MCP Server
│ └── query_with_nl tool
│ ├── LLM SQL 생성
│ └── _execute_sql_internal(sql)
│ ├── _validate_sql(sql) ← 검증 O
│ ├── _apply_sql_guards() ← auto-LIMIT
│ └── psycopg 실행
└── 결과 반환
```
**핵심**: 두 경로 모두 검증이 존재함. Gemma 4는 MCP 경로에서 C# `SqlValidator`가 적용되지 않는 것만 보고 CRITICAL로 분류했으나, Python 측에서 동등한 검증이 존재함.
## STEP 5: 보안 체크리스트 패턴 매칭
| 체크리스트 항목 | C# 직접 경로 | MCP 경로 | 상태 |
|----------------|-------------|---------|------|
| SQL Injection (키워드 차단) | ✅ SqlValidator | ✅ _validate_sql | **PASS** |
| SELECT-only 강제 | ✅ SqlValidator | ✅ _validate_sql | **PASS** |
| 다중 문장 차단 | ✅ SqlValidator | ✅ _validate_sql | **PASS** |
| 파일 경로 차단 | ✅ SqlValidator | ✅ _validate_sql | **PASS** |
| 길이 제한 | ✅ SqlValidator (2000자) | ✅ _validate_sql (2000자) | **PASS** |
| Auto LIMIT | ✅ SqlValidator | ✅ _apply_sql_guards | **PASS** |
| Statement Timeout | ✅ SqlValidator | ✅ _execute_sql_internal | **PASS** |
| 파라미터화 쿼리 | ✅ Dapper | ❌ 문자열 연결 | **WARNING** |
## STEP 6: 교차 검증 (4가지 질문)
### Q1: 검증이 실제로 실행되는가?
**A**: Yes. `_execute_sql_internal()``run_sql``query_with_nl` 두 도구 모두에서 호출됨. 검증 실패 시 SQL 실행 전에 에러 반환.
### Q2: 검증 로직이 충분한가?
**A**: Yes. C# `SqlValidator`와 Python `_validate_sql`은 동일한 키워드 목록을 사용하며, 동일한 검증 규칙을 적용함.
### Q3: 검증 우회 가능성이 있는가?
**A**: Partial. 키워드 기반 검증은 regex word boundary(`\b`)를 사용하므로, 대소문자 변환 후 매칭됨. 그러나 PostgreSQL의 경우 `DROP` 키워드를 포함하지 않는 다른 공격 벡터(예: `pg_dump` 함수 호출)는 차단하지 않음.
### Q4: 검증 실패 시 안전한가?
**A**: Yes. 검증 실패 시 `_execute_sql_internal()`은 SQL을 실행하지 않고 에러 JSON을 반환함.
## STEP 7: 심각도 분류
| 발견 사항 | 심각도 | 설명 |
|----------|-------|------|
| Gemma 4 CRITICAL (검증 누락) | **FALSE POSITIVE** | Python MCP 서버에서 검증 존재 |
| 파라미터화 쿼리 미적용 | **LOW** | LLM 생성 SQL이므로 파라미터화 불가. 검증이 대신 역할 |
| 키워드 기반 검증의 한계 | **LOW** | `\b` boundary 사용으로 기본 공격 차단. 고급 우회 가능하지만 LLM이 생성하는 SQL에서는 현실적이지 않음 |
## STEP 8: 최종 보고서
### Gemma 4 보고서 반박
Gemma 4는 `QueryWithNl` 경로에서 SQL 검증이 누락되었다고 CRITICAL로 분류함. 그러나:
1. **Python MCP 서버는 `_validate_sql()`을 통해 동등한 검증을 수행함** (`server.py` L573-589)
2. **`_execute_sql_internal()`은 모든 SQL 실행 전에 검증을 강제함** (`server.py` L907-914)
3. **검증 규칙은 C# `SqlValidator`와 동일함**: 키워드 차단, SELECT-only, 다중 문장 차단, 파일 경로 차단, 길이 제한
4. **추가 보안 조치**: auto-LIMIT (`_apply_sql_guards`), statement_timeout 적용
### 실제 보안 상태
| 항목 | 상태 |
|------|------|
| SQL Injection | **차단됨** (키워드 기반 검증) |
| 데이터 삭제/수정 | **차단됨** (DROP/DELETE/UPDATE 차단) |
| 다중 문장 실행 | **차단됨** (세미콜론 차단) |
| 파일 접근 | **차단됨** (`..`/`~` 차단) |
| 과도한 데이터 반환 | **차단됨** (auto-LIMIT) |
| 무한 실행 | **차단됨** (statement_timeout) |
### 개선 제안 (선택적)
1. **Python 검증 로직을 C#과 동기화**: 두 검증기가 동일한 키워드 목록과 규칙을 사용하도록 유지
2. **LLM 출력 검증 강화**: LLM이 생성한 SQL에 대한 추가 검증 (예: 테이블명 화이트리스트)
3. **감사 로깅**: 실행된 SQL을 로깅하여 이상 패턴 감지
### 결론
Gemma 4의 CRITICAL 발견은 **False Positive**임. MCP 경로는 Python MCP 서버 측에서 동등한 SQL 검증을 수행하며, 알려진 SQL Injection 공격 벡터에 대해 적절히 보호됨. 추가적인 보안 조치는 선택적이며, 현재 상태에서는 심각한 보안 취약점이 없음.
---
*진단일: 2026-05-17 | 프로토콜: diagnosis-checklist.md 8-Step | 검증 범위: C# + Python MCP Server*

File diff suppressed because it is too large Load Diff

104501
dxf-graph/No-10_graph.graphml Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
-- No-10_Plant_PID.dxf instrument from_tag/to_tag (distance-filtered)
-- Graph: ezdxf+networkx symbol-aware, gap-bridged. Edge weight = euclidean (drawing units).
-- from_tag: nearest connected tagged element within 50 u (equipment prioritized).
-- to_tag : next nearest distinct tagged element within 150 u.
-- NOT physical flow direction. Spurious cross-sheet links (dist in thousands) excluded.
-- rows=286 both=252 from_only=34 dropped(no local peer)=55
BEGIN;
UPDATE pid_equipment SET from_tag='CWS-10612-200A-S2A-N', updated_at=now() WHERE id=5899; -- PSV-10112 from[pipings d=20.2] (single-sided)
UPDATE pid_equipment SET from_tag='ST-10511-100A-S1A-H50', updated_at=now() WHERE id=5910; -- PSV-10115 from[pipings d=6.9] (single-sided)
UPDATE pid_equipment SET from_tag='P-10104-25A-F1A-n', to_tag='VG-10422-50A-F1A-N', updated_at=now() WHERE id=5904; -- BV-10101 from[pipings d=42.0] to[pipings d=43.3]
UPDATE pid_equipment SET from_tag='VG-10440-300A-F1A-N', to_tag='SC-9128', updated_at=now() WHERE id=5906; -- PSV-10119B from[pipings d=24.1] to[process_equipment d=114.8]
UPDATE pid_equipment SET from_tag='CD-10514-40A-S1A-H50', to_tag='CD-10513-40A-S1A-H50', updated_at=now() WHERE id=5961; -- TR-10115B from[pipings d=8.1] to[pipings d=35.3]
UPDATE pid_equipment SET from_tag='CD-10516-65A-S1A-H50', to_tag='P-10112-25A-F1A-n', updated_at=now() WHERE id=5964; -- PSV-10103 from[pipings d=7.5] to[pipings d=44.1]
UPDATE pid_equipment SET from_tag='VG-10433-50A-F1A-N', updated_at=now() WHERE id=6000; -- BV-10201 from[pipings d=47.8] (single-sided)
UPDATE pid_equipment SET from_tag='ST-10521-65A-S1A-H50', updated_at=now() WHERE id=6003; -- PSV-10215 from[pipings d=6.9] (single-sided)
UPDATE pid_equipment SET from_tag='P-10214-20A-F1A-n', updated_at=now() WHERE id=6021; -- PSV-10219A from[pipings d=36.3] (single-sided)
UPDATE pid_equipment SET from_tag='VG-10432-50A-F1A-N', to_tag='P-10226-25A-F2A-n', updated_at=now() WHERE id=6014; -- BV-10221 from[pipings d=0] to[pipings d=19.2]
UPDATE pid_equipment SET from_tag='VG-10431-50A-F1A-N', to_tag='P-10234-15A-F1A-n', updated_at=now() WHERE id=6017; -- BV-10200 from[pipings d=20.1] to[pipings d=115.5]
UPDATE pid_equipment SET from_tag='IA-10902-15A-F1A-n', to_tag='IA-10903-15A-F1A-n', updated_at=now() WHERE id=6116; -- PCV-10111 from[pipings d=0] to[pipings d=47.4]
UPDATE pid_equipment SET from_tag='IA-10903-15A-F1A-n', to_tag='FCV-10216', updated_at=now() WHERE id=6117; -- PCV-10211 from[pipings d=0] to[instrument d=32.6]
UPDATE pid_equipment SET from_tag='IA-10905-15A-F1A-n', to_tag='IA-10910-15A-F1A-n', updated_at=now() WHERE id=6118; -- FCV-10116 from[pipings d=0] to[pipings d=26.6]
UPDATE pid_equipment SET from_tag='IA-10906-15A-F1A-n', to_tag='IA-10908-15A-F1A-n', updated_at=now() WHERE id=6119; -- FCV-10216 from[pipings d=0] to[pipings d=24.2]
UPDATE pid_equipment SET from_tag='TCV-10111', to_tag='IA-10910-15A-F1A-n', updated_at=now() WHERE id=6120; -- FCV-10101 from[instrument d=8.1] to[pipings d=15.5]
UPDATE pid_equipment SET from_tag='TCV-10211', to_tag='IA-10907-15A-F1A-n', updated_at=now() WHERE id=6121; -- FCV-10201 from[instrument d=18.3] to[pipings d=19.2]
UPDATE pid_equipment SET from_tag='FCV-10101', to_tag='IA-10909-15A-F1A-n', updated_at=now() WHERE id=6122; -- TCV-10111 from[instrument d=8.1] to[pipings d=15.5]
UPDATE pid_equipment SET from_tag='FCV-10201', to_tag='IA-10908-15A-F1A-n', updated_at=now() WHERE id=6123; -- TCV-10211 from[instrument d=18.3] to[pipings d=19.2]
UPDATE pid_equipment SET from_tag='LCV-2705', to_tag='FCV-2123', updated_at=now() WHERE id=6153; -- FCV-2122 from[instrument d=5.9] to[instrument d=6.4]
UPDATE pid_equipment SET from_tag='FCV-2122', to_tag='XV-2136', updated_at=now() WHERE id=6154; -- LCV-2705 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='LCV-2705', to_tag='FCV-2122', updated_at=now() WHERE id=6156; -- XV-2136 from[instrument d=11.7] to[instrument d=17.6]
UPDATE pid_equipment SET from_tag='FCV-2124', to_tag='FCV-2122', updated_at=now() WHERE id=6157; -- FCV-2123 from[instrument d=5.9] to[instrument d=6.4]
UPDATE pid_equipment SET from_tag='FCV-2131', to_tag='FCV-2123', updated_at=now() WHERE id=6158; -- FCV-2124 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-2124', to_tag='LCV-2121', updated_at=now() WHERE id=6159; -- FCV-2131 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-2121', to_tag='FCV-2131', updated_at=now() WHERE id=6160; -- LCV-2121 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='LCV-2111', to_tag='LCV-2121', updated_at=now() WHERE id=6161; -- PCV-2121 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='LCV-2113', to_tag='PCV-2121', updated_at=now() WHERE id=6162; -- LCV-2111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='LCV-2111', to_tag='FCV-2111', updated_at=now() WHERE id=6163; -- LCV-2113 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='LCV-2113', to_tag='PCV-2111', updated_at=now() WHERE id=6164; -- FCV-2111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='TCV-2111', to_tag='FCV-2111', updated_at=now() WHERE id=6165; -- PCV-2111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-2111', to_tag='FCV-2111', updated_at=now() WHERE id=6166; -- TCV-2111 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-6218', to_tag='FCV-6214', updated_at=now() WHERE id=6185; -- FCV-6216 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-6216', to_tag='FCV-6214', updated_at=now() WHERE id=6186; -- FCV-6218 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-6211', to_tag='FCV-6218', updated_at=now() WHERE id=6187; -- FCV-6214 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-6214', to_tag='FCV-6213', updated_at=now() WHERE id=6188; -- PCV-6211 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-6211', to_tag='TCV-6211', updated_at=now() WHERE id=6189; -- FCV-6213 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-6213', to_tag='FCV-6201', updated_at=now() WHERE id=6190; -- TCV-6211 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='TCV-6211', to_tag='FCV-6213', updated_at=now() WHERE id=6191; -- FCV-6201 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-6118', to_tag='FCV-6114', updated_at=now() WHERE id=6192; -- FCV-6116 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-6116', to_tag='FCV-6114', updated_at=now() WHERE id=6193; -- FCV-6118 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-6111', to_tag='FCV-6118', updated_at=now() WHERE id=6194; -- FCV-6114 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-6114', to_tag='FCV-6113', updated_at=now() WHERE id=6195; -- PCV-6111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-6111', to_tag='TCV-6111', updated_at=now() WHERE id=6196; -- FCV-6113 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-6113', to_tag='FCV-6101', updated_at=now() WHERE id=6197; -- TCV-6111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='TCV-6111', to_tag='FCV-6113', updated_at=now() WHERE id=6198; -- FCV-6101 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-5118', to_tag='FCV-5114', updated_at=now() WHERE id=6199; -- FCV-5116 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-5116', to_tag='FCV-5114', updated_at=now() WHERE id=6200; -- FCV-5118 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-5111', to_tag='FCV-5118', updated_at=now() WHERE id=6201; -- FCV-5114 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-5114', to_tag='FCV-5113', updated_at=now() WHERE id=6202; -- PCV-5111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-5111', to_tag='TCV-5111', updated_at=now() WHERE id=6203; -- FCV-5113 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-5113', to_tag='FCV-5101', updated_at=now() WHERE id=6204; -- TCV-5111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='TCV-5111', to_tag='FCV-5113', updated_at=now() WHERE id=6205; -- FCV-5101 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='FCV-9114', to_tag='XV-9125', updated_at=now() WHERE id=6210; -- FCV-9118 from[instrument d=5.9] to[instrument d=6.5]
UPDATE pid_equipment SET from_tag='PCV-9111', to_tag='FCV-9118', updated_at=now() WHERE id=6211; -- FCV-9114 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-9113', to_tag='FCV-9114', updated_at=now() WHERE id=6212; -- PCV-9111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='PCV-9111', to_tag='TCV-9111', updated_at=now() WHERE id=6213; -- FCV-9113 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='FCV-9101', to_tag='FCV-9113', updated_at=now() WHERE id=6214; -- TCV-9111 from[instrument d=5.9] to[instrument d=5.9]
UPDATE pid_equipment SET from_tag='TCV-9111', to_tag='FCV-9113', updated_at=now() WHERE id=6215; -- FCV-9101 from[instrument d=5.9] to[instrument d=11.7]
UPDATE pid_equipment SET from_tag='XV-9125', to_tag='FCV-9118', updated_at=now() WHERE id=6226; -- XV-6121 from[instrument d=11.1] to[instrument d=17.6]
UPDATE pid_equipment SET from_tag='FCV-9118', to_tag='XV-6121', updated_at=now() WHERE id=6227; -- XV-9125 from[instrument d=6.5] to[instrument d=11.1]
UPDATE pid_equipment SET from_tag='XV-5402', updated_at=now() WHERE id=6261; -- XV-5301 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='XV-5301', updated_at=now() WHERE id=6262; -- XV-5402 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6264; -- PG-6212 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6265; -- LT-10800 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='LIA-10111', to_tag='LT-10100', updated_at=now() WHERE id=6266; -- LIA-10800 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6267; -- PG-10900A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', updated_at=now() WHERE id=6263; -- PIC-6211 from[instrument d=7.5] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6268; -- PG-10900B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6269; -- TE-6601 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TIA-10111B', to_tag='TE-10103', updated_at=now() WHERE id=6270; -- TIA-6601 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6271; -- PG-10900C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6272; -- PG-10900D from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6273; -- PG-10800 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6274; -- PG-10900E from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6275; -- PG-10900F from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6276; -- PG-10900G from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6277; -- PG-10900H from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6278; -- PG-10900I from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6279; -- PG-10900J from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6280; -- PT-10900 from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PI-10900E', to_tag='PT-10111A', updated_at=now() WHERE id=6281; -- PI-10900 from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6282; -- PT-10900I from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PI-10900', to_tag='PT-10111A', updated_at=now() WHERE id=6283; -- PI-10900I from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6284; -- PT-10900H from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PI-10900', to_tag='PT-10111A', updated_at=now() WHERE id=6285; -- PI-10900H from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6286; -- PT-10900G from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PI-10900', to_tag='PT-10111A', updated_at=now() WHERE id=6287; -- PI-10900G from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6288; -- PT-10900F from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PI-10900', to_tag='PT-10111A', updated_at=now() WHERE id=6289; -- PI-10900F from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6290; -- PT-10900E from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PI-10900', to_tag='PT-10111A', updated_at=now() WHERE id=6291; -- PI-10900E from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6294; -- PG-10102B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6295; -- TE-10111A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6297; -- LT-10111 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='FICQ-10113', to_tag='FIT-10101', updated_at=now() WHERE id=6292; -- FICQ-10101 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FIT-10113', to_tag='FICQ-10101', updated_at=now() WHERE id=6293; -- FIT-10101 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='TICA-10211A', updated_at=now() WHERE id=6296; -- TICA-10111A from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6298; -- FIT-10116 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6299; -- FICQ-10116 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6301; -- PG-10111B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6302; -- TE-10111B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6303; -- TG-10111B from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TIA-10111C', to_tag='TE-10103', updated_at=now() WHERE id=6304; -- TIA-10111B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6305; -- PT-10111B from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PIA-10211B', updated_at=now() WHERE id=6306; -- PIA-10111B from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PT-10111B', to_tag='PI-10900', updated_at=now() WHERE id=6307; -- PT-10111A from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6308; -- TE-10111D from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6309; -- TG-10111D from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TIA-10111B', to_tag='TE-10103', updated_at=now() WHERE id=6310; -- TIA-10111D from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6313; -- TE-10111C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TIA-10111B', to_tag='TE-10103', updated_at=now() WHERE id=6314; -- TIA-10111C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6318; -- TG-10117B from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6319; -- TE-10117 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6323; -- TG-10111C from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6324; -- PG-10117B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6325; -- PG-10102C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6326; -- PG-10102A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6329; -- LT-10113 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6330; -- TG-10113 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10101A', to_tag='PIC-6211', updated_at=now() WHERE id=6331; -- PG-10100 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6332; -- TG-10112 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6333; -- PG-10101B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6334; -- LT-10101 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6335; -- PG-10101A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PRV-10215', updated_at=now() WHERE id=6337; -- PRV-10115 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6339; -- PG-10101C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6300; -- TG-10111A from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6311; -- FIT-10118 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6312; -- FICQ-10118 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6315; -- FIT-10113 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6316; -- FICQ-10113 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6317; -- LG-10111B from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='TI-10103', updated_at=now() WHERE id=6320; -- TI-10117 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6321; -- FIT-10114A from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6340; -- PG-10114 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6341; -- PG-10118 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6342; -- PG-10116 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6343; -- PG-10115A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6344; -- PG-10115B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6345; -- PG-10111A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10101', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6346; -- TG-10100 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6347; -- PG-3210 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6348; -- LT-3210 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6349; -- TG-10119 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6350; -- PG-10119 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6351; -- TG-10120 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PICA-10211A', updated_at=now() WHERE id=6353; -- PICA-10111A from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6354; -- PG-10117A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6356; -- TG-10101 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='LIA-10211', to_tag='LT-10100', updated_at=now() WHERE id=6357; -- LIA-10111 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='LT-10101', to_tag='LIA-10111', updated_at=now() WHERE id=6359; -- LT-10100 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TE-10111A', to_tag='TIA-10111B', updated_at=now() WHERE id=6360; -- TE-10103 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TI-10117', updated_at=now() WHERE id=6361; -- TI-10103 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6362; -- PG-10117C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6363; -- PG-10115C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6364; -- TG-10117A from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6366; -- PG-10112 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6367; -- PG-10117 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6370; -- PG-10202B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6371; -- TE-10211A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TICA-10111A', updated_at=now() WHERE id=6372; -- TICA-10211A from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6373; -- LT-10211 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6376; -- TG-10211A from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6377; -- PG-10211B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6378; -- TE-10211B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6379; -- TG-10211B from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TIA-10111B', to_tag='TE-10103', updated_at=now() WHERE id=6380; -- TIA-10211B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6381; -- PT-10211B from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PIA-10111B', updated_at=now() WHERE id=6382; -- PIA-10211B from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6383; -- PT-10211A from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6384; -- TE-10211D from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6385; -- TG-10211D from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TIA-10111B', to_tag='TE-10103', updated_at=now() WHERE id=6386; -- TIA-10211D from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6389; -- TE-10211C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TIA-10111B', to_tag='TE-10103', updated_at=now() WHERE id=6390; -- TIA-10211C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6394; -- TG-10217 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6395; -- TE-10217 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TI-10103', updated_at=now() WHERE id=6396; -- TI-10217 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6399; -- TE-10203 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TI-10103', updated_at=now() WHERE id=6400; -- TI-10203 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6401; -- TG-10211C from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6402; -- PG-10217B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6403; -- PG-10202C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6404; -- PG-10202A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6407; -- LT-10213 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6408; -- TG-10213 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6409; -- PG-10217 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6410; -- PG-10200 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6411; -- PG-10212 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6412; -- TG-10212 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6413; -- PG-10201B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6414; -- LT-10201 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6416; -- PG-10201A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6418; -- PG-10215C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PRV-10115', updated_at=now() WHERE id=6419; -- PRV-10215 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6421; -- PG-10201C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6422; -- PG-10214 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6423; -- PG-10218 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6424; -- PG-10221C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6425; -- PG-10221B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6426; -- TG-10221 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6427; -- PG-10221A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6428; -- PG-10216 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6429; -- PG-10215A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6430; -- PG-10215B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6431; -- PG-10211A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6432; -- TG-10200 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6433; -- TG-10219 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6434; -- PG-10219 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6435; -- TG-10220 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='FI-10111', updated_at=now() WHERE id=6436; -- FI-10211 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PICA-10111A', updated_at=now() WHERE id=6437; -- PICA-10211A from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6438; -- PG-10217A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LICA-10113', to_tag='P-10311-125A-F1A-n', updated_at=now() WHERE id=6439; -- LICA-10213 from[instrument d=0] to[pipings d=18.5]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6440; -- TG-10201 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='LIA-10111', to_tag='LT-10100', updated_at=now() WHERE id=6441; -- LIA-10211 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6442; -- LT-10221 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6445; -- LT-10200 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6447; -- TG-10217A from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6448; -- PG-10217C from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6449; -- TE-10650 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TI-10103', updated_at=now() WHERE id=6450; -- TI-10650 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='TE-10103', to_tag='TIA-10111B', updated_at=now() WHERE id=6451; -- TE-10600 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='TI-10103', updated_at=now() WHERE id=6452; -- TI-10600 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6453; -- PG-10602A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6454; -- PG-10602B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6455; -- LT-10128 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6459; -- TG-10128 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6460; -- TG-3210 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='LIA-10111', to_tag='LT-10100', updated_at=now() WHERE id=6461; -- LIA-3210 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='LT-10100', to_tag='LIA-10111', updated_at=now() WHERE id=6466; -- LT-9128 from[instrument d=0] to[instrument d=10.6]
UPDATE pid_equipment SET from_tag='TG-10100', to_tag='P-10121-25A-F1A-H50', updated_at=now() WHERE id=6468; -- TG-9128 from[instrument d=0] to[pipings d=62.9]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6478; -- PG-10128A from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6479; -- PG-10128B from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6480; -- PG-10901 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6481; -- PT-10952 from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6484; -- PG-2901 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PG-10100', to_tag='PIC-6211', updated_at=now() WHERE id=6485; -- PG-952 from[instrument d=0] to[instrument d=7.5]
UPDATE pid_equipment SET from_tag='PT-10111A', to_tag='PI-10900', updated_at=now() WHERE id=6486; -- PT-2900 from[instrument d=0] to[instrument d=7.4]
UPDATE pid_equipment SET from_tag='PIA-10111B', updated_at=now() WHERE id=6487; -- PIA-2900 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='VG-10421-50A-F1A-N', to_tag='LI-10100', updated_at=now() WHERE id=5921; -- BV-10100 from[pipings d=20.1] to[instrument d=49.2]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6322; -- FICQ-10114 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='LG-10111B', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6327; -- LG-10111A from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6328; -- LG-10113B from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6336; -- FIT-10115 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6338; -- FICQ-10115 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FI-10211', updated_at=now() WHERE id=6352; -- FI-10111 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='LICA-10213', to_tag='P-10311-125A-F1A-n', updated_at=now() WHERE id=6355; -- LICA-10113 from[instrument d=0] to[pipings d=18.5]
UPDATE pid_equipment SET from_tag='LI-10128', to_tag='P-10149-40A-F1A-n', updated_at=now() WHERE id=6358; -- LI-10100 from[instrument d=0] to[pipings d=32.5]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6368; -- FICQ-10201 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6369; -- FIT-10201 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6374; -- FIT-10216 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6375; -- FICQ-10216 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6387; -- FIT-10218 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6388; -- FICQ-10218 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6391; -- FIT-10213 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6392; -- FICQ-10213 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6393; -- LG-10211B from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6397; -- FIT-10214 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6398; -- FICQ-10214 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6405; -- LG-10211A from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6406; -- LG-10213B from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='LI-10100', to_tag='P-10149-40A-F1A-n', updated_at=now() WHERE id=6415; -- LI-10201 from[instrument d=0] to[pipings d=32.5]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6417; -- FIT-10215 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='FICQ-10101', to_tag='FIT-10101', updated_at=now() WHERE id=6420; -- FICQ-10215 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='LI-10100', to_tag='P-10149-40A-F1A-n', updated_at=now() WHERE id=6443; -- LI-10221 from[instrument d=0] to[pipings d=32.5]
UPDATE pid_equipment SET from_tag='LI-10100', to_tag='P-10149-40A-F1A-n', updated_at=now() WHERE id=6444; -- LI-10200 from[instrument d=0] to[pipings d=32.5]
UPDATE pid_equipment SET from_tag='LI-10100', to_tag='P-10149-40A-F1A-n', updated_at=now() WHERE id=6456; -- LI-10128 from[instrument d=0] to[pipings d=32.5]
UPDATE pid_equipment SET from_tag='FIT-10101', to_tag='FICQ-10101', updated_at=now() WHERE id=6457; -- FIT-10128 from[instrument d=0] to[instrument d=5.3]
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6462; -- BV-9201 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6463; -- BV-9123 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6464; -- BV-9101 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6465; -- BV-9221 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='LI-10100', to_tag='P-10149-40A-F1A-n', updated_at=now() WHERE id=6467; -- LI-9128 from[instrument d=0] to[pipings d=32.5]
UPDATE pid_equipment SET from_tag='PSV-9111', to_tag='VG-10442-150A-F1A-N', updated_at=now() WHERE id=6469; -- PSV-9219B from[instrument d=0] to[pipings d=20.2]
UPDATE pid_equipment SET from_tag='PSV-9111', to_tag='VG-10442-150A-F1A-N', updated_at=now() WHERE id=6470; -- PSV-9119B from[instrument d=0] to[pipings d=20.2]
UPDATE pid_equipment SET from_tag='PSV-9119B', to_tag='VG-10442-150A-F1A-N', updated_at=now() WHERE id=6471; -- PSV-9111 from[instrument d=0] to[pipings d=20.2]
UPDATE pid_equipment SET from_tag='PSV-9111', to_tag='VG-10442-150A-F1A-N', updated_at=now() WHERE id=6472; -- PSV-9211 from[instrument d=0] to[pipings d=20.2]
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6473; -- BV-9121 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6474; -- BV-9122 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6475; -- BV-9124 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9101', updated_at=now() WHERE id=6476; -- BV-9100 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='BV-9100', updated_at=now() WHERE id=6477; -- BV-9200 from[instrument d=0] (single-sided)
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6482; -- LG-10213A from[instrument d=0] to[pipings d=3.2]
UPDATE pid_equipment SET from_tag='LG-10111A', to_tag='P-10127-65A-F2A-n', updated_at=now() WHERE id=6483; -- LG-10113A from[instrument d=0] to[pipings d=3.2]
COMMIT;

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

View File

@@ -0,0 +1,432 @@
[
{
"sheet": "Sheet R5C0",
"nodes": 170,
"bbox": [
1543,
6394,
1982,
6651
],
"components": 26,
"line_numbers": []
},
{
"sheet": "Sheet R5C1",
"nodes": 254,
"bbox": [
1988,
6393,
2422,
6629
],
"components": 34,
"line_numbers": []
},
{
"sheet": "Sheet R5C2",
"nodes": 77,
"bbox": [
2442,
6392,
2797,
6639
],
"components": 21,
"line_numbers": []
},
{
"sheet": "Sheet R5C3",
"nodes": 264,
"bbox": [
2908,
6396,
3291,
6643
],
"components": 30,
"line_numbers": []
},
{
"sheet": "Sheet R4C0",
"nodes": 304,
"bbox": [
1543,
6096,
1952,
6390
],
"components": 32,
"line_numbers": []
},
{
"sheet": "Sheet R4C1",
"nodes": 142,
"bbox": [
2011,
6096,
2422,
6392
],
"components": 36,
"line_numbers": []
},
{
"sheet": "Sheet R4C2",
"nodes": 165,
"bbox": [
2423,
6096,
2851,
6392
],
"components": 35,
"line_numbers": []
},
{
"sheet": "Sheet R4C3",
"nodes": 152,
"bbox": [
2871,
6096,
3299,
6391
],
"components": 28,
"line_numbers": []
},
{
"sheet": "Sheet R4C4",
"nodes": 106,
"bbox": [
3311,
6096,
3545,
6376
],
"components": 24,
"line_numbers": []
},
{
"sheet": "Sheet R3C0",
"nodes": 204,
"bbox": [
1644,
5766,
1949,
5876
],
"components": 29,
"line_numbers": []
},
{
"sheet": "Sheet R2C0",
"nodes": 360,
"bbox": [
1644,
5579,
1924,
5764
],
"components": 44,
"line_numbers": []
},
{
"sheet": "Sheet R2C1",
"nodes": 318,
"bbox": [
1998,
5579,
2150,
5651
],
"components": 49,
"line_numbers": [
"VG-6203-15A-F1A"
]
},
{
"sheet": "Sheet R1C0",
"nodes": 2894,
"bbox": [
1649,
5190,
1982,
5433
],
"components": 158,
"line_numbers": [
"ST-10511-100A-S1A-H50",
"P-10311-125A-F1A",
"P-10150-25A-F1A",
"P-10122-20A-F1A",
"P-10151-15A-F1A",
"P-10115-200A-F2A-H50",
"P-10106-25A-F1A",
"P-10119-32A-F1A-H50",
"P-10121-25A-F1A-H50",
"P-10122-25A-F1A-N",
"P-10143-32A-F2A",
"P-10142-25A-F2A"
]
},
{
"sheet": "Sheet R1C1",
"nodes": 3030,
"bbox": [
1983,
5137,
2422,
5433
],
"components": 201,
"line_numbers": [
"P-10201-25A-F1A",
"ST-10521-65A-S1A-H50",
"P-10213-20A-F1A",
"P-10313-100A-F1A",
"SAM-10951-10A-F2A",
"SAM-10953-10A-F2A",
"P-10237-40A-F1A",
"VG-10411-65A-F1A",
"VG-10440-300A-F1A-N",
"P-10149-40A-F1A",
"VG-10421-50A-F1A",
"P-10208-400A-F2A-H50"
]
},
{
"sheet": "Sheet R1C2",
"nodes": 1279,
"bbox": [
2423,
5137,
2861,
5433
],
"components": 117,
"line_numbers": [
"VG-10401-150A-F1A-N",
"P-10314-80A-F1A",
"VG-9440-300A-F1A-N",
"P-10236-40A-F1A",
"P-10226-25A-F2A",
"VG-10431-50A-F1A",
"P-10225-25A-F2A",
"VG-10444-25A-F1A",
"P-10227-25A-F2A",
"VG-10412-50A-F1A",
"VG-10411-65A-F1A-N",
"VG-10412-50A-F1A-N"
]
},
{
"sheet": "Sheet R1C3",
"nodes": 368,
"bbox": [
2867,
5136,
3299,
5433
],
"components": 54,
"line_numbers": [
"CWS-10601-300A-S2A-N",
"CWS-10600-300A-S2A-N",
"PW-10903-25A-F1A-E40",
"CWR-10625-50A-F1A-N",
"CWS-10615-50A-F1A-N",
"CWS-10615-50A-S2A-N",
"CWS-10616-25A-S2A-N",
"CWR-10625-50A-S2A-N",
"CWS-10618-25A-S2A-N",
"CWR-10628-25A-S2A-N",
"CWS-10617-25A-S2A-N",
"CWR-10627-25A-S2A-N"
]
},
{
"sheet": "Sheet R1C4",
"nodes": 747,
"bbox": [
3314,
5136,
3739,
5433
],
"components": 87,
"line_numbers": [
"CWS-10620-300A-S2A-N",
"CWS-10601-300A-S2A-N",
"CWS-10611-80A-S2A-N",
"CWR-10621-80A-S2A-N",
"CWS-10612-200A-S2A-N",
"CWR-10622-200A-S2A-N"
]
},
{
"sheet": "Sheet R1C5",
"nodes": 545,
"bbox": [
3746,
5136,
4149,
5433
],
"components": 59,
"line_numbers": [
"SW-10802-25A-F1A-E50",
"SW-10801-50A-F1A-E50",
"SW-10810-25A-F1A-E50",
"SW-10805-25A-F1A-E50",
"SW-10808-25A-F1A-E50",
"SW-10806-25A-F1A-E50",
"SW-10807-25A-F1A-E50",
"SW-10809-15A-F1A-E50"
]
},
{
"sheet": "Sheet R1C6",
"nodes": 840,
"bbox": [
4213,
5136,
4622,
5433
],
"components": 85,
"line_numbers": []
},
{
"sheet": "Sheet R1C7",
"nodes": 613,
"bbox": [
4644,
5136,
5061,
5433
],
"components": 97,
"line_numbers": [
"IA-10900-25A-F1A",
"IA-10910-15A-F1A",
"IA-10904-15A-F1A",
"IA-10908-15A-F1A",
"IA-10909-15A-F1A",
"IA-10907-15A-F1A",
"IA-10906-15A-F1A",
"IA-10905-15A-F1A",
"IA-10903-15A-F1A",
"IA-10902-15A-F1A",
"IA-10914-25A-F1A",
"IA-10913-25A-F1A"
]
},
{
"sheet": "Sheet R1C8",
"nodes": 650,
"bbox": [
5069,
5136,
5502,
5433
],
"components": 57,
"line_numbers": [
"CHS-9630-100A-F-C50",
"CHR-9640-100A-F-C50",
"CHS-9631-50A-F-C50",
"CHS-9632-100A-F-C50",
"CHS-9633-32A-F-C50",
"CHR-6641-50A-F-C50",
"CHR-6642-25A-F-C50",
"CHR-6643-50A-F-C50",
"CHS-10631-65A-F1A-C50",
"CHR-10641-65A-F1A-C50",
"CHR-9643-32A-F-C50",
"CHR-9641-50A-F-C50"
]
},
{
"sheet": "Sheet R1C9",
"nodes": 148,
"bbox": [
5506,
5136,
5583,
5433
],
"components": 24,
"line_numbers": [
"CHR-6644-25A-F-C50",
"CHS-10632-50A-F1A-C50",
"CHR-10642-50A-F1A-C50"
]
},
{
"sheet": "Sheet R0C2",
"nodes": 577,
"bbox": [
2448,
4822,
2862,
5136
],
"components": 58,
"line_numbers": [
"VG-9421-50A-F1A-N",
"VG-9423-50A-F1A-N",
"VG-9424-50A-F1A-N",
"VG-9431-50A-F1A-N",
"VG-9433-50A-F1A-N",
"VG-9434-50A-F1A-N",
"VG-9432-50A-F1A-N",
"VG-9426-50A-F1A-N",
"VG-9425-50A-F1A-N",
"VG-9412-50A-F1A-N",
"VG-9411-50A-F1A-N",
"WW-9193-25A-F1A-N"
]
},
{
"sheet": "Sheet R0C3",
"nodes": 149,
"bbox": [
2863,
4822,
2978,
5136
],
"components": 24,
"line_numbers": [
"VG-10412-50A-F1A-N",
"VG-10411-65A-F1A-N"
]
},
{
"sheet": "Sheet R0C7",
"nodes": 1068,
"bbox": [
4729,
4823,
5062,
5136
],
"components": 136,
"line_numbers": [
"IA-10922-25A-F1A"
]
},
{
"sheet": "Sheet R0C8",
"nodes": 163,
"bbox": [
5063,
4823,
5147,
5136
],
"components": 29,
"line_numbers": []
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

207
experion-loop.py Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
experion-loop.py: Hierarchical LLM Loop
Qwen3.6-27B (Orchestrator) → Qwen3-8B (Worker)
Usage:
python3 experion-loop.py "refactor ExtractTagNames to use compiled regex"
python3 experion-loop.py "fix all build warnings in src/Core" --max-iter 20
"""
import argparse, json, os, re, subprocess, sys, time, datetime
from openai import OpenAI
QWEN = dict(base_url="http://localhost:8000/v1", api_key="none", model="Qwen3.6-27B-FP8")
WORKER_MODEL = dict(base_url="http://localhost:8001/v1", api_key="none", model="Qwen3-8B-FP8")
client = OpenAI(**{k:v for k,v in QWEN.items() if k != "model"}, timeout=300)
worker = OpenAI(**{k:v for k,v in WORKER_MODEL.items() if k != "model"}, timeout=120)
now = lambda: datetime.datetime.now().strftime("%H:%M:%S")
TOOLS = [
{"type": "function", "function": {
"name": "read_file", "description": "Read a file",
"parameters": {"type": "object", "properties": {"filepath": {"type": "string"}}, "required": ["filepath"]}}},
{"type": "function", "function": {
"name": "write_file", "description": "Write file (overwrites)",
"parameters": {"type": "object", "properties": {
"filepath": {"type": "string"}, "content": {"type": "string"}}, "required": ["filepath", "content"]}}},
{"type": "function", "function": {
"name": "edit_file", "description": "Replace exact string in file",
"parameters": {"type": "object", "properties": {
"filepath": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"}},
"required": ["filepath", "old_string", "new_string"]}}},
{"type": "function", "function": {
"name": "bash", "description": "Run bash command",
"parameters": {"type": "object", "properties": {
"command": {"type": "string"}, "workdir": {"type": "string"}},
"required": ["command"]}}},
]
def log(msg: str):
print(f"[{now()}] {msg}", flush=True)
def call_llm(client, model: str, msgs: list, tools=None, max_tok=4096, temp=0.3) -> object:
kwargs = dict(model=model, messages=msgs, max_tokens=max_tok, temperature=temp)
if tools:
kwargs["tools"] = tools
t0 = time.time()
r = client.chat.completions.create(**kwargs)
log(f"LLM {model.split('/')[-1][:12]}{time.time()-t0:.0f}s tok={r.usage.total_tokens}")
msg = r.choices[0].message
if msg.content is None and msg.tool_calls:
# tool call response - don't set content
pass
elif msg.content is None:
# reasoning consumed all tokens; retry with higher limit
log(" ↳ content was None (thinking overflow); retrying with more tokens")
kwargs["max_tokens"] = max_tok * 2
t0 = time.time()
r = client.chat.completions.create(**kwargs)
log(f" ↳ retry: {time.time()-t0:.0f}s")
msg = r.choices[0].message
return msg
def exec_tool(name: str, args: dict, workdir: str = ".") -> str:
try:
if name == "read_file":
with open(args["filepath"]) as f:
return f.read()
elif name == "write_file":
os.makedirs(os.path.dirname(args["filepath"]) or ".", exist_ok=True)
with open(args["filepath"], "w") as f:
f.write(args["content"])
return f"Written {len(args['content'])}b → {args['filepath']}"
elif name == "edit_file":
fp = args["filepath"]; old, new = args["old_string"], args["new_string"]
with open(fp) as f:
c = f.read()
if old not in c:
return f"Error: 'old_string' not found in {fp}"
with open(fp, "w") as f:
f.write(c.replace(old, new, 1))
return f"Edited {fp}"
elif name == "bash":
res = subprocess.run(args["command"], shell=True, capture_output=True, text=True,
cwd=args.get("workdir", workdir), timeout=120)
out = res.stdout[-4000:] if len(res.stdout) > 4000 else res.stdout
err = res.stderr[-2000:] if len(res.stderr) > 2000 else res.stderr
return (out + ("\n[stderr]\n" + err if err else "")).strip() or "(empty)"
return f"Unknown tool: {name}"
except Exception as e:
return f"Error: {e}"
def worker_loop(task: str, workdir: str, max_iter: int = 15) -> dict:
"""Worker loop: repeat until <<DONE>> or max_iter."""
log("┌─ Worker starting ──────────────────────────────")
msgs = [{"role": "system", "content": (
f"You are a coding assistant. Work in: {workdir}\n\nTask:\n{task}\n\n"
f"Tools: read_file, write_file, edit_file, bash\n"
f"\n## Protocol (follow strictly):\n"
f"1. read_file ONCE (first iter only) to understand current code\n"
f"2. Then use edit_file for ALL targeted changes (NEVER write_file for existing files)\n"
f"3. Verify with bash (build, test, or check)\n"
f"4. If verify fails → fix with edit_file → reverify\n"
f"5. Output <<DONE>> when all criteria met and changes verified\n"
f"\n## Critical rules:\n"
f"- read_file: STRICTLY first iteration only. NEVER read the same file twice\n"
f" If you need to re-check file content after iteration 1, use 'head' or 'grep' via bash instead\n"
f"- edit_file: preferred for ALL changes to existing files. NEVER rewrite entire files\n"
f"- write_file: ONLY for NEW files that don't exist yet\n"
f"- Make as few tool calls as possible (batch changes)\n"
f"- Always verify after changes")}]
for i in range(max_iter):
log(f"├─ Worker iter {i+1}/{max_iter}")
try:
msg = call_llm(worker, WORKER_MODEL["model"], msgs, tools=TOOLS, max_tok=8192, temp=0.2)
except Exception as e:
log(f"│ ✗ API error: {e}")
time.sleep(3)
continue
if msg.content and "<<DONE>>" in msg.content:
log(f"└─ ✓ Worker done ({i+1} iters)")
return {"status": "done", "iterations": i + 1}
if msg.tool_calls:
msgs.append(msg)
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
log(f"│ → {tc.function.name} {str(args)[:80]}")
result = exec_tool(tc.function.name, args, workdir)
# If this is a read_file, only keep first 1000 chars to avoid context bloat
truncated = result[:1000] if tc.function.name == "read_file" else result[:5000]
msgs.append({"role": "tool", "tool_call_id": tc.id, "content": truncated})
elif msg.content:
msgs.append(msg)
msgs.append({"role": "user", "content": "Continue. Make changes and output <<DONE>> when done."})
log("└─ ✗ Max iterations reached")
return {"status": "max_iter", "iterations": max_iter}
def orchestrator(goal: str, workdir: str = "."):
"""Qwen plans → Qwen executes → Qwen verifies."""
log("═══ Qwen: planning ═══")
msg = call_llm(client, QWEN["model"], [{"role": "system", "content": "You are a planning assistant. Answer concisely in Korean."},
{"role": "user", "content": (
f"Goal: {goal}\n\n"
f"Create a numbered todo list (max 5 items).\n"
f"Each todo: files to modify, what to change.\n"
f"Output as markdown numbered list ONLY, no explanation.\n\n"
f"Project: ExperionCrawler (.NET 8, C#)\n"
f" src/Core/, src/Infrastructure/, src/Web/\n"
f" Tests: `dotnet test`")}], max_tok=2048)
plan = msg.content or "(empty plan)"
print(f"\n{plan}\n")
todos = [re.sub(r'^\d+[\.\)]\s*', '', l).strip()
for l in plan.split("\n") if re.match(r'^\d+[\.\)]\s', l)]
if not todos:
log("No todos extracted — running full goal as one worker task")
worker_loop(goal, workdir)
return
log(f"═══ Executing {len(todos)} todos ═══")
prev_results = []
for idx, todo in enumerate(todos, 1):
log(f"── Todo {idx}/{len(todos)}: {todo[:80]} ──")
prev_section = ""
if prev_results:
prev_section = "\nPrevious todos completed:\n" + "\n".join(prev_results[-3:]) + "\n"
msg = call_llm(client, QWEN["model"], [{"role": "user", "content": (
f"Goal: {goal}\n{prev_section}"
f"\nTodo ({idx}/{len(todos)}): {todo}\n\n"
f"Write a concise task description for the worker:\n"
f"- Files to modify\n- What to change\n- Verification steps\n- Completion criteria\n"
f"IMPORTANT: Skip verification if the change was already done in a previous todo.")}],
max_tok=2048)
task = msg.content or todo
result = worker_loop(task, workdir)
log(f"{result['status']} ({result['iterations']} iters)")
prev_results.append(f"Todo {idx}: {todo}{result['status']} ({result['iterations']} iters)")
log("═══ Qwen: final verification ═══")
msg = call_llm(client, QWEN["model"], [{"role": "user", "content": (
f"Goal: {goal}\n\nAll todos executed. Run final verification:\n"
f"1. `dotnet build`\n2. `dotnet test`\n3. Report any issues")}],
tools=[t for t in TOOLS if t["function"]["name"] == "bash"], max_tok=4096)
if msg.content:
print(msg.content)
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="Qwen hierarchical loop (orchestrator + worker)")
ap.add_argument("goal", nargs="*", help="Goal description")
ap.add_argument("--dir", "-d", default=".", help="Working directory")
ap.add_argument("--max-iter", "-m", type=int, default=15, help="Max worker iterations per todo")
args = ap.parse_args()
goal = " ".join(args.goal) or "Current dir: review and improve code"
orchestrator(goal, args.dir)

19
gemma4-run.sh Normal file
View File

@@ -0,0 +1,19 @@
docker run -d --name vllm_gemma4 \
--restart unless-stopped \
--gpus all --network host --ipc host \
--ulimit memlock=-1 --ulimit stack=67108864 \
--entrypoint "" \
vllm-node-tf5 \
bash -c "
exec vllm serve RedHatAI/gemma-4-26B-A4B-it-FP8-Dynamic \
--max-model-len 65536 \
--gpu-memory-utilization 0.4 \
--port 8001 --host 0.0.0.0 \
--enable-prefix-caching \
--override-generation-config '{\"temperature\": 0.2}' \
--enable-auto-tool-choice --tool-call-parser gemma4 \
--reasoning-parser gemma4 \
--kv-cache-dtype fp8 \
--max-num-batched-tokens 8192 \
-tp 1
"

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import os
_SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
_MODEL_FILE = os.path.join(_SERVER_DIR, "llm-model.json")
_DEFAULT_MODEL = "Qwen3.6-27B-FP8"
_DEFAULT_MODEL = "Qwen3.6-35B-A3B-FP8"
def get_vllm_model() -> str:

View File

@@ -0,0 +1 @@
"""Field instrument inference from DCS base_tag to field instruments."""

View File

@@ -0,0 +1,171 @@
"""3시트 Excel 초안 생성기 (§3 스키마)."""
from __future__ import annotations
import os
import tempfile
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
from .rules import get_all_measurements, get_all_modifiers, get_all_special_prefixes
INSTRUMENT_COLS = [
"instrument_id", "display_name", "parent_base_tag", "role", "loop",
"area", "measures", "data_points", "from", "to", "description",
"confidence", "needs_review", "inference_basis", "operator_notes", "delete",
]
POWER_EQUIPMENT_COLS = [
"instrument_id", "display_name", "parent_base_tag", "role", "loop",
"area", "equipment_type", "data_points", "description",
"confidence", "needs_review", "inference_basis", "operator_notes", "delete",
]
HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
LOW_FILL = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
MEDIUM_FILL = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
def generate_excel(instruments: list[dict], unmatched: list[str], power_equipment: list[dict] | None = None) -> str:
"""
4시트 Excel 생성 후 파일 경로 반환.
Args:
instruments: infer.py 결과 (instrument dict 리스트)
unmatched: 룰 미매칭 base_tag 목록
power_equipment: 동력기기 (펌프, 압축기, 교반기 등)
output_dir: 출력 디렉토리 (기본: /tmp)
Returns:
생성된 xlsx 파일의 절대 경로
"""
output_dir = tempfile.gettempdir()
os.makedirs(output_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = os.path.join(output_dir, f"instruments_draft_{ts}.xlsx")
wb = openpyxl.Workbook()
# ── 시트 1: instruments ──
_build_instruments_sheet(wb, instruments)
# ── 시트 2: power_equipment ──
_build_power_equipment_sheet(wb, power_equipment or [])
# ── 시트 3: unmatched_tags ──
_build_unmatched_sheet(wb, unmatched)
# ── 시트 4: naming_convention ──
_build_naming_convention_sheet(wb)
wb.save(filepath)
return filepath
def _build_instruments_sheet(wb: openpyxl.Workbook, instruments: list[dict]) -> None:
ws = wb.active
ws.title = "instruments"
# 헤더
for col_idx, col_name in enumerate(INSTRUMENT_COLS, 1):
cell = ws.cell(row=1, column=col_idx, value=col_name)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center", wrap_text=True)
# 데이터 행
for row_idx, inst in enumerate(instruments, 2):
for col_idx, col_name in enumerate(INSTRUMENT_COLS, 1):
val = inst.get(col_name, "")
cell = ws.cell(row=row_idx, column=col_idx, value=val)
# confidence별 색상
if col_name == "confidence":
if val == "low":
cell.fill = LOW_FILL
elif val == "medium":
cell.fill = MEDIUM_FILL
# 컬럼 너비 자동 조정
for col_idx in range(1, len(INSTRUMENT_COLS) + 1):
max_len = 0
for row in range(1, ws.max_row + 1):
val = str(ws.cell(row=row, column=col_idx).value or "")
max_len = max(max_len, len(val))
ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = min(max_len + 4, 40)
def _build_naming_convention_sheet(wb: openpyxl.Workbook) -> None:
ws = wb.create_sheet("naming_convention")
# 측정량 표
ws.cell(row=1, column=1, value="측정량 (첫 글자)").font = HEADER_FONT
ws.cell(row=1, column=2, value="의미").font = HEADER_FONT
meas = get_all_measurements()
for i, (letter, meaning) in enumerate(meas.items(), 2):
ws.cell(row=i, column=1, value=letter)
ws.cell(row=i, column=2, value=meaning)
# 수식어 표 (D열부터)
ws.cell(row=1, column=4, value="수식어 (두 번째 이후)").font = HEADER_FONT
ws.cell(row=1, column=5, value="role").font = HEADER_FONT
ws.cell(row=1, column=6, value="가상").font = HEADER_FONT
mods = get_all_modifiers()
for i, (letter, info) in enumerate(mods.items(), 2):
ws.cell(row=i, column=4, value=letter)
ws.cell(row=i, column=5, value=info.get("role", ""))
ws.cell(row=i, column=6, value=info.get("virtual", False))
# 특수 prefix 표 (G열부터)
ws.cell(row=1, column=7, value="특수 prefix").font = HEADER_FONT
ws.cell(row=1, column=8, value="role").font = HEADER_FONT
sps = get_all_special_prefixes()
for i, (prefix, info) in enumerate(sps.items(), 2):
ws.cell(row=i, column=7, value=prefix)
ws.cell(row=i, column=8, value=info.get("role", ""))
def _build_unmatched_sheet(wb: openpyxl.Workbook, unmatched: list[str]) -> None:
ws = wb.create_sheet("unmatched_tags")
ws.cell(row=1, column=1, value="base_tag").font = HEADER_FONT
ws.cell(row=1, column=2, value="area").font = HEADER_FONT
ws.cell(row=1, column=3, value="action").font = HEADER_FONT
ws.cell(row=1, column=4, value="operator_notes").font = HEADER_FONT
for i, tag in enumerate(unmatched, 2):
ws.cell(row=i, column=1, value=tag)
ws.cell(row=i, column=2, value="(none)")
ws.cell(row=i, column=3, value="운영자가 instruments 시트에 행 추가 필요")
ws.cell(row=i, column=4, value="")
def _build_power_equipment_sheet(wb: openpyxl.Workbook, equipment: list[dict]) -> None:
ws = wb.create_sheet("power_equipment")
for col_idx, col_name in enumerate(POWER_EQUIPMENT_COLS, 1):
cell = ws.cell(row=1, column=col_idx, value=col_name)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center", wrap_text=True)
for row_idx, eq in enumerate(equipment, 2):
for col_idx, col_name in enumerate(POWER_EQUIPMENT_COLS, 1):
val = eq.get(col_name, "")
cell = ws.cell(row=row_idx, column=col_idx, value=val)
if col_name == "confidence":
if val == "low":
cell.fill = LOW_FILL
elif val == "medium":
cell.fill = MEDIUM_FILL
for col_idx in range(1, len(POWER_EQUIPMENT_COLS) + 1):
max_len = 0
for row in range(1, ws.max_row + 1):
val = str(ws.cell(row=row, column=col_idx).value or "")
max_len = max(max_len, len(val))
ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = min(max_len + 4, 40)

View File

@@ -0,0 +1,329 @@
"""DCS base_tag → 현장 계기 자동 유추 알고리즘."""
from __future__ import annotations
import re
from .rules import get_measurement, get_modifier, get_special_prefix
def split_tag(base_tag: str) -> tuple[str, str]:
"""'ficq-6101' → ('ficq', '6101') / 'xv-6124' → ('xv', '6124')."""
m = re.match(r"^([a-zA-Z]+)-?(.*)", base_tag)
if not m:
return base_tag, ""
return m.group(1).lower(), m.group(2)
def infer_instruments_for_base_tag(
base_tag: str,
data_points: list[str],
area: str,
) -> list[dict]:
"""
단일 base_tag에 대해 현장 계기 목록을 유추.
Returns:
instrument dict 리스트 (§3.1 스키마와 일치).
매칭 실패 시 1행 포함 (confidence=low, needs_review=True).
"""
head, loop = split_tag(base_tag)
if not loop:
loop = "unknown"
dp_set = set(data_points) if data_points else set()
# 규칙에서 어긋난 태그 → unmatched
# loop에 '_' 포함 (fica-3102_op 등) 또는 'esd' 포함 (lt-9113-lo-esd 등 시스템 포인트)
if "_" in loop or "esd" in loop.lower():
return _build_unmatched(base_tag, area, head, loop)
# 1. 특수 prefix 우선 (정확한 전체 일치)
sp = get_special_prefix(head)
if sp:
return _build_special(head, loop, area, sp, dp_set)
# 2. 첫 글자 = 측정량
first = head[0]
meas = get_measurement(first)
if not meas:
return _build_unmatched(base_tag, area, head, loop)
instruments = []
has_transmitter = False
has_controller = False
# DCS 내부 기능: controller, totalizer, alarm, switch는 현장 계기가 아님
_dcs_internal_roles = {"controller", "totalizer", "alarm", "switch"}
# 3. 수식어 글자별로 계기 생성
for letter in head[1:]:
mod = get_modifier(letter)
if not mod:
continue
if mod.get("virtual"):
continue # I, R은 가상
role = mod["role"]
# DCS 내부 기능은 instruments에서 제외
if role in _dcs_internal_roles:
if role == "controller":
has_controller = True
continue
inst = _build_instrument(first, meas, role, loop, area, mod, dp_set, base_tag)
instruments.append(inst)
if role == "transmitter":
has_transmitter = True
# 4. T 글자가 명시 안 됐어도 컨트롤러가 있으면 송신기 암시
if not has_transmitter and has_controller:
inst = _build_implicit_transmitter(first, meas, loop, area, dp_set, base_tag)
instruments.insert(0, inst)
has_transmitter = True
# 5. 컨트롤러 → 제어밸브 자동 생성 (auto_pair)
if has_controller:
inst = _build_paired_valve(first, meas, loop, area, dp_set, base_tag)
instruments.append(inst)
# [H-4 수정] 빈 리스트일 때 implicit transmitter 강제 추가
# pi-XXXX/ti-XXXX 등 "측정량 + I(virtual)"만 있는 경우 instruments가 비어 있음
if not instruments:
inst = _build_implicit_transmitter(first, meas, loop, area, dp_set, base_tag)
instruments.append(inst)
# 6. from/to 채우기
_link_signal_flow(instruments, base_tag, area)
# 7. confidence 계산
for inst in instruments:
inst["confidence"] = _score_confidence(inst, dp_set)
inst["needs_review"] = inst["confidence"] == "low"
return instruments
def _build_instrument(
first_letter: str, meas: str, role: str, loop: str, area: str, mod: dict, dp_set: set, parent_tag: str
) -> dict:
role_id = _role_to_id(first_letter, role, loop)
display = f"{first_letter.upper()}{_role_suffix(role).upper()}-{loop.upper()}"
dps = mod.get("data_points", [])
matched_dps = [d for d in dps if d in dp_set]
return {
"instrument_id": role_id,
"display_name": display,
"parent_base_tag": parent_tag,
"role": role,
"loop": loop,
"area": area or "(none)",
"measures": meas,
"data_points": ",".join(matched_dps) if matched_dps else "(none)",
"from": "(none)",
"to": "(none)",
"description": "",
"confidence": "medium",
"needs_review": False,
"inference_basis": f"{first_letter.upper()}+{mod.get('role','?')}",
"operator_notes": "",
"delete": False,
}
def _build_special(head: str, loop: str, area: str, sp: dict, dp_set: set) -> list[dict]:
role = sp["role"]
meas = sp.get("measures")
equip_type = sp.get("equipment_type")
role_id = f"{head}-{loop}"
display = f"{head.upper()}-{loop.upper()}"
dp_map = {
"shutdown-valve": [".instate0", ".instate1"],
"interlock-relay": [".instate0", ".instate1"],
"positioner": [".op"],
"power_equipment": [".run", ".fault"],
}
expected_dps = dp_map.get(role, [])
matched_dps = [d for d in expected_dps if d in dp_set]
inst = {
"instrument_id": role_id,
"display_name": display,
"parent_base_tag": f"{head}-{loop}",
"role": role,
"loop": loop,
"area": area or "(none)",
"measures": meas or "(none)",
"data_points": ",".join(matched_dps) if matched_dps else "(none)",
"from": "(none)",
"to": "(none)",
"description": "",
"confidence": "medium",
"needs_review": False,
"inference_basis": f"special_prefix:{head}",
"operator_notes": "",
"delete": False,
}
if role == "power_equipment" and equip_type:
inst["equipment_type"] = equip_type
return [inst]
def _build_unmatched(base_tag: str, area: str, head: str, loop: str) -> list[dict]:
return [{
"instrument_id": f"{head}-{loop}",
"display_name": f"{head.upper()}-{loop.upper()}",
"parent_base_tag": base_tag,
"role": "equipment",
"loop": loop,
"area": area or "(none)",
"measures": "(none)",
"data_points": "(none)",
"from": "(none)",
"to": "(none)",
"description": "",
"confidence": "low",
"needs_review": True,
"inference_basis": "unmatched_prefix",
"operator_notes": "",
"delete": False,
}]
def _build_implicit_transmitter(first: str, meas: str, loop: str, area: str, dp_set: set, parent: str) -> dict:
role_id = f"{first}t-{loop}"
return {
"instrument_id": role_id,
"display_name": f"{first.upper()}T-{loop.upper()}",
"parent_base_tag": parent,
"role": "transmitter",
"loop": loop,
"area": area or "(none)",
"measures": meas,
"data_points": ".pv" if ".pv" in dp_set else "(none)",
"from": f"process/{area or 'unknown'}-{loop}-inlet",
"to": f"tag/{parent}",
"description": "",
"confidence": "medium",
"needs_review": False,
"inference_basis": f"{first.upper()}+(implied T)",
"operator_notes": "",
"delete": False,
}
def _build_paired_valve(first: str, meas: str, loop: str, area: str, dp_set: set, parent: str) -> dict:
role_id = f"{first}cv-{loop}"
return {
"instrument_id": role_id,
"display_name": f"{first.upper()}CV-{loop.upper()}",
"parent_base_tag": parent,
"role": "control-valve",
"loop": loop,
"area": area or "(none)",
"measures": "(none)",
"data_points": ".op" if ".op" in dp_set else "(none)",
"from": f"tag/{first}ic-{loop}",
"to": f"process/{area or 'unknown'}-{loop}-downstream",
"description": "",
"confidence": "high",
"needs_review": False,
"inference_basis": "C -> CV auto_pair",
"operator_notes": "",
"delete": False,
}
def _role_suffix(role: str) -> str:
mapping = {
"transmitter": "t",
"controller": "ic",
"totalizer": "q",
"switch": "s",
"alarm": "a",
"interlock-relay": "y",
"positioner": "z",
}
return mapping.get(role, role[:2])
def _role_to_id(first_letter: str, role: str, loop: str) -> str:
return f"{first_letter}{_role_suffix(role)}-{loop}"
def _link_signal_flow(instruments: list[dict], parent_tag: str, area: str) -> None:
"""role별 from/to 기본값 채음 (§5.1)."""
transmitters = [i for i in instruments if i["role"] == "transmitter"]
valves = [i for i in instruments if i["role"] == "control-valve"]
for t in transmitters:
if t["from"] == "(none)":
t["from"] = f"process/{area or 'unknown'}-{t['loop']}-inlet"
if t["to"] == "(none)":
t["to"] = f"tag/{parent_tag}"
for v in valves:
if v["from"] == "(none)":
v["from"] = f"tag/{parent_tag}"
if v["to"] == "(none)":
v["to"] = f"process/{area or 'unknown'}-{v['loop']}-downstream"
def _score_confidence(inst: dict, dp_set: set) -> str:
"""data_point 일치도 + prefix 매칭으로 신뢰도 계산."""
role = inst["role"]
basis = inst.get("inference_basis", "")
if basis == "unmatched_prefix":
return "low"
expected = {
"transmitter": [".pv"],
"controller": [".sp", ".op"],
"totalizer": [".qv"],
"switch": [".instate0"],
"shutdown-valve": [".instate0"],
}
checks = expected.get(role, [])
if not checks:
return "medium" if basis.startswith("special") else "high"
matched = sum(1 for c in checks if c in dp_set)
if matched == len(checks):
return "high"
if matched > 0:
return "medium"
return "low"
def _role_to_korean_description(role: str, meas: str) -> str:
"""role + 측정량 → 한국어 설명 초안."""
meas_ko = {
"flow": "유량", "pressure": "압력", "temperature": "온도",
"level": "위차", "analysis": "분석", "speed": "회전수",
"weight": "중량", "density": "비중", "power": "전력", "moisture": "함량",
}
m = meas_ko.get(meas, meas)
role_desc = {
"transmitter": f"{m} 송신기",
"controller": f"{m} 제어기",
"totalizer": f"{m} 적산기",
"switch": f"{m} 스위치",
"alarm": f"{m} 알람",
"control-valve": f"{m} 제어밸브",
"shutdown-valve": "차단밸브",
"interlock-relay": "인터록 릴레이",
"positioner": "포지셔너",
"motor": "모터",
"pump": "펌프",
"compressor": "압축기",
"agitator": "교반기",
"blower": "송풍기",
"fan": "송풍기",
}
return role_desc.get(role, role)

View File

@@ -0,0 +1,47 @@
"""YAML 룰 로더 — prompts/instrument_inference.yaml을 읽고 캐싱."""
from __future__ import annotations
import os
import yaml
from functools import lru_cache
_RULES_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "prompts", "instrument_inference.yaml",
)
@lru_cache(maxsize=1)
def load_rules() -> dict:
"""YAML 룰 파일 로드 (lru_cache로 1회만 로드)."""
with open(_RULES_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def get_measurement(letter: str) -> str | None:
"""첫 글자 → 측정량 반환."""
return load_rules().get("measurement", {}).get(letter)
def get_modifier(letter: str) -> dict | None:
"""수식어 글자 → role 정보 반환."""
return load_rules().get("modifiers", {}).get(letter)
def get_special_prefix(head: str) -> dict | None:
"""특수 prefix → role 정보 반환."""
return load_rules().get("special_prefixes", {}).get(head)
def get_all_measurements() -> dict:
"""측정량 전체 표 반환 (naming_convention 시트용)."""
return load_rules().get("measurement", {})
def get_all_modifiers() -> dict:
"""수식어 전체 표 반환 (naming_convention 시트용)."""
return load_rules().get("modifiers", {})
def get_all_special_prefixes() -> dict:
"""특수 prefix 전체 표 반환 (naming_convention 시트용)."""
return load_rules().get("special_prefixes", {})

129
mcp-server/legend_probe.py Normal file
View File

@@ -0,0 +1,129 @@
"""
범례 심볼 프로브 — 좌표 박스 내 프리미티브 추출 + 정규화 시그니처 도출.
사용:
python3 mcp-server/legend_probe.py XMIN YMIN XMAX YMAX [라벨]
출력:
1) 원시 프리미티브 (LINE/ARC/CIRCLE/LWPOLYLINE)
2) 정규화 시그니처: 앵커=박스 좌하단 기준 상대좌표,
도형종류별 개수, 선분 길이·각도, 공유 정점(연결 토폴로지),
폐합 삼각형 등 패턴 단서
"""
import sys, math, collections
import ezdxf
from ezdxf import recover
DXF = "dxf-graph/No-10_Plant_PID.dxf"
def load():
try:
return ezdxf.readfile(DXF)
except ezdxf.DXFStructureError:
d, _ = recover.readfile(DXF)
return d
def main():
if len(sys.argv) < 5:
print("usage: legend_probe.py XMIN YMIN XMAX YMAX [label]")
sys.exit(1)
x0, y0, x1, y1 = map(float, sys.argv[1:5])
label = sys.argv[5] if len(sys.argv) > 5 else "?"
if x0 > x1:
x0, x1 = x1, x0
if y0 > y1:
y0, y1 = y1, y0
msp = load().modelspace()
def inb(x, y):
return x0 <= x <= x1 and y0 <= y <= y1
lines, arcs, circles, polys, texts = [], [], [], [], []
for e in msp:
t = e.dxftype()
try:
if t == "LINE":
s, en = e.dxf.start, e.dxf.end
if inb(s.x, s.y) or inb(en.x, en.y):
lines.append(((s.x, s.y), (en.x, en.y)))
elif t == "ARC":
c = e.dxf.center
if inb(c.x, c.y):
arcs.append((c.x, c.y, e.dxf.radius,
e.dxf.start_angle, e.dxf.end_angle))
elif t == "CIRCLE":
c = e.dxf.center
if inb(c.x, c.y):
circles.append((c.x, c.y, e.dxf.radius))
elif t == "LWPOLYLINE":
p = [(a, b) for a, b in e.get_points("xy")]
if p and inb(p[0][0], p[0][1]):
polys.append(p)
elif t in ("TEXT", "MTEXT"):
ip = e.dxf.insert
if inb(ip.x, ip.y):
v = (e.plain_text() if t == "MTEXT" else e.dxf.text).strip()
if v:
texts.append((round(ip.x, 1), round(ip.y, 1), v[:40]))
except Exception:
pass
print(f"=== '{label}' box=({x0:.1f},{y0:.1f})-({x1:.1f},{y1:.1f}) ===")
print(f"LINE={len(lines)} ARC={len(arcs)} CIRCLE={len(circles)} "
f"LWPOLY={len(polys)} TEXT={len(texts)}")
if texts:
print("텍스트:", [t[2] for t in texts])
# 앵커 = 비텍스트 프리미티브 최소 x,y
pts = []
for a, b in lines:
pts += [a, b]
for cx, cy, r, *_ in arcs:
pts += [(cx - r, cy - r), (cx + r, cy + r)]
for cx, cy, r in circles:
pts += [(cx - r, cy - r), (cx + r, cy + r)]
for p in polys:
pts += p
if not pts:
print("(비텍스트 프리미티브 없음)")
return
ax = min(p[0] for p in pts)
ay = min(p[1] for p in pts)
w = max(p[0] for p in pts) - ax
h = max(p[1] for p in pts) - ay
print(f"앵커=({ax:.2f},{ay:.2f}) 정규화 bbox= {w:.2f} x {h:.2f}")
def n(x, y):
return (round(x - ax, 2), round(y - ay, 2))
print("\n-- LINE (상대좌표 | 길이 | 각도°) --")
seg = []
for (sx, sy), (ex, ey) in sorted(lines):
ln = math.hypot(ex - sx, ey - sy)
ang = round(math.degrees(math.atan2(ey - sy, ex - sx)) % 180, 1)
print(f" {n(sx,sy)}{n(ex,ey)} len={ln:.2f} ang={ang}")
seg.append((n(sx, sy), n(ex, ey), round(ln, 2), ang))
for cx, cy, r in circles:
print(f"-- CIRCLE c={n(cx,cy)} r={r:.2f}")
for cx, cy, r, sa, ea in arcs:
print(f"-- ARC c={n(cx,cy)} r={r:.2f} {sa:.0f}°→{ea:.0f}°")
for p in polys:
print(f"-- LWPOLY {[n(x,y) for x,y in p]}")
# 공유 정점 (연결 토폴로지) — 0.3u 이내 동일점
vtx = collections.defaultdict(int)
for s, e, *_ in seg:
vtx[s] += 1
vtx[e] += 1
shared = {v: c for v, c in vtx.items() if c >= 3}
print(f"\n공유정점(차수≥3 = apex/junction): {shared}")
ang_hist = collections.Counter(s[3] for s in seg)
print(f"각도 분포: {dict(ang_hist)}")
print(f"선분 길이 분포: {sorted(s[2] for s in seg)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,144 @@
{
"_doc": "범례 역설계 심볼 시그니처 카탈로그. 좌표는 No-10_Plant_PID.dxf 범례 기준. 도면 매칭은 위치/스케일 무관(상대 토폴로지)로 적용.",
"_unit_note": "길이 단위 = DXF drawing unit. 범례 글리프 기준 측정.",
"connection_rules": {
"flange_joint": {
"pattern": "기기 양단의 평행 수직선 2개(len~2.27, ~0.68 간격) = [배관측 상대(짝)플랜지(바깥) | 기기측 플랜지(안)] 한 쌍의 플랜지 조인트.",
"graph_attach": "배관 라인은 바깥쪽(배관측) 플랜지 수직선에 종단. 기기는 플랜지조인트를 '통과 연결'(한쪽 바깥플랜지→다른쪽 바깥플랜지).",
"applies_to": ["GATE_VALVE(도면 실제 표기)", "MASS_FLOW_METER", "인라인 기기 일반"]
}
},
"families": {
"FLOW_METER": {
"pattern": "닫힌 사각 LWPOLYLINE 본체(~7.2 x 2.27) + 내부 약어 TEXT + 양단 플랜지조인트 + 중심 흐름선 stub.",
"type_by_text": "내부 TEXT가 계기 종류 확정: MASS=코리올리/질량, VORTEX=와류 등. 본체 형상 동일, 텍스트만 상이."
}
},
"symbols": [
{
"name": "GATE_VALVE",
"status": "confirmed",
"skeleton": "bowtie: 마주보는 두 삼각형, 수직변 2개 len~2.27, 좌우 수평 stub",
"diagonal_len": 2.14,
"diagonal_count": 4,
"center_marker": "none",
"diagonal_variant": "변형A(범례): 중점 apex에서 만나는 단대각선 4개 len~2.14. 변형B(도면): 코너-투-코너 교차 대각선 2개 len~4.29(32°/148°). 매칭 불변량 = 수직변2 len~2.27 + 교차대각선 + 중심이 stub선상.",
"discriminator": "중심에 마커(SOLID/CIRCLE/채움) 없음 + bowtie 교차 대각선만. stem/상단박스 없음(있으면 ON/OFF).",
"in_drawing_note": "실제 도면에서는 bowtie 양쪽에 동일길이 분리 수직선(플랜지)이 있고 배관은 그 플랜지선에 연결. 밸브는 플랜지→플랜지 통과연결.",
"legend_ref": {"box": [1654, 5804, 1670, 5809], "label_xy": [1674.2, 5805.3]}
},
{
"name": "GLOBE_VALVE",
"status": "confirmed",
"skeleton": "bowtie: 수직변 2개 len~2.27, 좌우 stub, 대각선 4개 len 2.14 중심 닿음",
"diagonal_len": 2.14,
"diagonal_count": 4,
"center_marker": "filled",
"center_marker_entity": "CLOSED LWPOLYLINE (width 부여 → 채워진 디스크) ~폭 0.74. SOLID/HATCH(solid)도 채움으로 간주.",
"discriminator": "bowtie 중심에 채움 엔티티(닫힌 폴리라인/SOLID/solid HATCH) 존재",
"legend_ref": {"box": [1650, 5797.6, 1672, 5804.0], "label_xy": [1674.2, 5799.7]}
},
{
"name": "BALL_VALVE",
"status": "confirmed",
"skeleton": "bowtie 골격(수직변 2개 len~2.27, 좌우 stub) + 중심 빈 CIRCLE",
"diagonal_len": 1.46,
"diagonal_count": 4,
"center_marker": "empty_circle",
"center_marker_entity": "CIRCLE r~0.68, 채움 엔티티 없음. 대각선이 짧아(1.46) 중심에 안 닿고 원과 간격.",
"discriminator": "bowtie 중심에 빈 CIRCLE + 대각선 len 1.46(중심 미도달)",
"legend_ref": {"box": [1650, 5792.0, 1672, 5798.4], "label_xy": [1674.2, 5794.1]}
},
{
"name": "CHECK_VALVE",
"status": "confirmed",
"skeleton": "수직변 2개 len~2.27 + 좌우 stub (gate 계열 공통 골격)",
"diagonal_len": 2.14,
"diagonal_count": 1,
"center_marker": "filled_triangle",
"center_marker_entity": "SOLID 엔티티(채워진 삼각형) 1개, 중심서 우측 offset. 단일 사선 1개(대칭 4대각선 아님).",
"discriminator": "SOLID(채워진 삼각형) 존재 + 단일 사선(비대칭) → 대칭 bowtie와 구분. 방향성 있음(SOLID dart가 허용 흐름방향).",
"directional": true,
"legend_ref": {"box": [1650, 5775.4, 1672, 5781.4], "label_xy": [1674.2, 5777.3], "anchor": [1660.03, 5777.3]}
},
{
"name": "ON_OFF_VALVE",
"status": "confirmed",
"skeleton": "GATE bowtie 몸체(수직변 2개 len~2.27 + 코너교차 대각선 2개 len~4.29 + 좌우 stub)",
"extra": "중심(stub선)에서 위로 단일 수직 stem len~2.03 + 상단 사각 액추에이터 박스 ~3.41 x 1.49 (내부 수평 분할선 1개, 수평선 3개)",
"center_marker": "none",
"discriminator": "GATE bowtie + 중심 수직 stem + 상단 사각박스(액추에이터). SOLID/CIRCLE/HATCH 채움 없음. → 박스+stem 유무가 GATE와의 분기점.",
"legend_ref": {"box": [1650, 5770.0, 1672, 5777.2], "label_xy": [1674.2, 5771.7], "anchor": [1660.03, 5771.7]}
},
{
"name": "CONTROL_VALVE",
"status": "confirmed",
"skeleton": "GATE bowtie 몸체(수직변 2개 len~2.27 + 코너교차 대각선 len~4.29 + 좌우 stub)",
"extra": "중심에서 위로 수직 stem len~2.03 + 상단 ARC 돔(다이어프램 액추에이터, r~1.75, ~반원 12°→168°) + 돔 하단 수평 base선 len~3.41",
"center_marker": "none",
"actuator": "arc_dome",
"discriminator": "GATE bowtie + 중심 stem + 상단 ARC 돔. 액추에이터가 ARC 돔(↔ON/OFF는 사각박스). stem 있음(↔DIAPHRAGM은 stem 없음).",
"legend_ref": {"box": [1650, 5759.0, 1672, 5764.5], "label_xy": [1674.1, 5760.5], "anchor": [1660.03, 5760.5]}
},
{
"name": "DIAPHRAGM_VALVE",
"status": "confirmed",
"skeleton": "GATE bowtie 몸체(수직변 2개 len~2.27 + 코너교차 대각선 len~4.29 + 좌우 stub)",
"extra": "상단 ARC 돔(다이어프램 액추에이터, r~1.82, 32°→148°). 중심 stem 없음.",
"center_marker": "none",
"actuator": "arc_dome",
"discriminator": "GATE bowtie + 상단 ARC 돔, 단 중심 수직 stem 없음. ↔ CONTROL_VALVE 는 동일 형태 + stem 있음. (사용자 확정 판별자: stem 유무)",
"legend_ref": {"box": [1650, 5765.5, 1672, 5769.5], "label_xy": [1674.1, 5766.1], "anchor": [1660.03, 5766.1]}
},
{
"name": "MASS_FLOW_METER",
"status": "confirmed",
"family": "FLOW_METER",
"skeleton": "닫힌 사각 LWPOLYLINE 본체 ~7.21 x 2.27 + 내부 TEXT \"MASS\"",
"extra": "양단 플랜지조인트(각 측 수직 2개 len~2.27, ~0.68 간격: 배관측 상대플랜지+기기측 플랜지) + 기기플랜지→본체 short선 len~1.6 + 중심 흐름선 stub(y중심), 본체폭 valve(~3.6)보다 큼(~7.2)",
"center_marker": "text:MASS",
"discriminator": "닫힌 사각본체 + 내부 약어 TEXT 'MASS'. bowtie/SOLID/CIRCLE 없음. FLOW_METER 패밀리 — 내부텍스트로 종류 확정. 연결은 connection_rules.flange_joint 적용.",
"legend_ref": {"box": [1650, 5670.5, 1672, 5677.2], "label_xy": [1673.7, 5673.2], "anchor": [1654.0, 5671.5]}
},
{
"name": "GENERAL_PUMP",
"status": "confirmed",
"kind": "equipment",
"skeleton": "CIRCLE r~2.96 (펌프 케이싱) + 중심 동심 ARC r~0.66 (노즐/디테일)",
"extra": "케이싱 하단에서 좌우 대칭 다리 2개 len~3.34 (각 ~60.5°/119.5°) → 수평 base선 len~6.88 (페디스털 받침)",
"center_marker": "concentric_arc",
"discriminator": "큰 CIRCLE(r≈3) + 좌우대칭 다리 2개 + 수평 base선. bowtie/SOLID/flange-joint 없음 → 인라인 아닌 장비. 연결=케이싱 노즐(흡입/토출).",
"calibration_note": "펌프 케이싱 r≈2~3 (실측 2.96, 도면 P-10101 r≈1.98). sim_line_connection 의 '큰원 r≥4.5' 임계는 펌프 미검출 → 펌프는 r≈2~3 CIRCLE + 다리+base 로 검출해야 함.",
"legend_ref": {"box": [1808, 5659.0, 1842, 5675.0], "label_xy": [1841.3, 5666.8], "anchor": [1810.0, 5660.0]}
},
{
"name": "VACUUM_PUMP",
"status": "confirmed",
"kind": "equipment",
"skeleton": "CIRCLE r~2.96 + 좌우대칭 다리 2개 len~3.34 (60.5°/119.5°) + 자기 base선 len~6.89 (GENERAL_PUMP 골격 동일)",
"extra": "원 위 X 마크(대각선 2개 len~5.11, 60°/120°) + 원 중심 관통 수평 지름선 len~5.11. GENERAL_PUMP의 중심 소형 동심 ARC 없음.",
"center_marker": "X_plus_diameter",
"discriminator": "펌프 골격(원 r~3 + 다리 + base) + 원에 X 마크 + 수평 지름선 → VACUUM. (↔ GENERAL_PUMP 은 원에 소형 동심 ARC)",
"legend_ref": {"box": [1808, 5648.0, 1842, 5660.0], "label_xy": [1841.1, 5655.4], "anchor": [1810.0, 5648.0],
"note": "박스가 위 GENERAL_PUMP 행 침범 시 abs y~5663.3 의 len6.89 수평선(=GENERAL base)은 제외. 펌프 행간격~11.4."}
},
{
"name": "DIAPHRAGM_PUMP",
"status": "confirmed",
"kind": "equipment",
"skeleton": "닫힌 사각 LWPOLY body ~4.65 x 2.65 (CIRCLE 없음 — 박스형 정변위 펌프)",
"extra": "양측 flange_joint(짧은 평행 수직선 스택 len~2.27/2.39/2.64) + 중심 흐름선(len~3.44 양방향) + body 측면 소형 노즐 len~0.97",
"center_marker": "none",
"discriminator": "원 없음 + 사각 LWPOLY body + 양측 flange_joint → DIAPHRAGM(박스형). ↔ GENERAL/VACUUM 은 CIRCLE 케이싱.",
"legend_ref": {"box": [1808, 5636.0, 1842, 5648.0], "label_xy": [1841.1, 5643.7], "anchor": [1810.0, 5636.0],
"note": "박스가 위 VACUUM_PUMP 행 침범 시 abs y~5651.94 의 len6.89 수평선(=VACUUM base)은 제외."}
}
],
"_probe_lesson": "펌프/다행 심볼은 박스를 단일 행 높이(±~5.7)로 좁혀라. 위 행의 base 수평선(len~6.89)이 침범하면 제외. 펌프 행간격≈11.4 (base: GENERAL 5663.3 / VACUUM 5651.94 / DIAPHRAGM ~5640).",
"ball_valve_variants": {
"BALL_VALVE": "수평, r~0.68, 대각 1.46, 바 2.27, bbox 3.64x2.27",
"BALL_VALVE_VERTICAL": "수직(흐름 상하), r~0.32, 대각 0.66, 바 1.01 (수평바4). 수평형의 ~0.46배 축소 + 90° 회전. 글리프 외 블록에 연결배관선(len~13.58) 포함.",
"detection_lesson": "원-앵커 매칭 시 r 필터를 0.4 미만까지(>=0.28) 확장 필수. r=0.32~0.35 대역에 소형 볼밸브가 대량(히스토그램 139+180). 매칭은 스케일 불변(비율) + 회전 불변이어야 함. 소형은 faceted(원이 작아 거의 점) — 원 존재 자체를 앵커로, 주변 bowtie 바/대각 비율로 확정.",
"flange_inclusion_rule": "BALL_VALVE_VERTICAL 블록은 플랜지(수평 바 4개 len~1.01)를 포함. 클러스터 전체(원 r0.32 + 대각4 len0.66 + 플랜지바4 + 연결선)를 볼밸브 1기로 판정 — 플랜지를 별도 식별/분리하지 말 것. 단일 통과형 기기로 처리(흐름 통과)."
}
}

View File

@@ -1 +1 @@
{"vllm_model":"Qwen3.6-27B-FP8"}
{"vllm_model":"Qwen3.6-35B-A3B-FP8"}

View File

@@ -0,0 +1,8 @@
"""KB 문서 파서 모음.
각 모듈은 `parse(path: str) -> list[dict]` 인터페이스를 제공한다.
반환 청크는 다음 키를 가진다:
text: str 임베딩 대상 본문 (보통 200~1500자)
chunk_kind: str row | sheet | section | table | page | paragraph | heading
locator: str 사람 가독 위치 문자열 (예: "sheet=Pump-A; row=12")
"""

View File

@@ -0,0 +1,41 @@
"""docx 청킹 — 헤딩 경로 별 청크."""
from __future__ import annotations
def parse(path: str) -> list[dict]:
from docx import Document
doc = Document(path)
chunks: list[dict] = []
cur_path: list[str] = []
buf: list[str] = []
def flush():
if buf:
heading = " / ".join(cur_path) if cur_path else "preface"
chunks.append({
"text": "\n".join(buf).strip(),
"chunk_kind": "heading",
"locator": f"heading={heading}",
})
for p in doc.paragraphs:
text = (p.text or "").strip()
if not text:
continue
style_name = (p.style.name or "").lower() if p.style else ""
if style_name.startswith("heading"):
flush()
buf = []
try:
level = int(style_name.split()[-1])
except (ValueError, IndexError):
level = 1
cur_path = cur_path[: max(0, level - 1)] + [text]
else:
buf.append(text)
flush()
return chunks

View File

@@ -0,0 +1,34 @@
"""pdf 청킹 — pdfplumber로 페이지/표 추출, 헤딩 분리 실패 시 페이지 단위 fallback."""
from __future__ import annotations
def parse(path: str) -> list[dict]:
import pdfplumber
chunks: list[dict] = []
with pdfplumber.open(path) as pdf:
for pno, page in enumerate(pdf.pages, start=1):
txt = (page.extract_text() or "").strip()
if txt:
chunks.append({
"text": txt[:5000],
"chunk_kind": "page",
"locator": f"page={pno}",
})
try:
tables = page.extract_tables() or []
except Exception:
tables = []
for ti, table in enumerate(tables, start=1):
rows = [[(c or "").strip() for c in row] for row in table if row]
if not rows:
continue
md = "\n".join(" | ".join(r) for r in rows[:200])
chunks.append({
"text": md,
"chunk_kind": "table",
"locator": f"page={pno}; table={ti}",
})
return chunks

View File

@@ -0,0 +1,56 @@
"""md / txt 청킹 — md는 # 헤딩 단위, txt는 빈 줄 두 개 단위."""
from __future__ import annotations
import os
def parse(path: str) -> list[dict]:
ext = os.path.splitext(path)[1].lower()
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
if ext == ".md":
return _parse_md(content)
return _parse_txt(content)
def _parse_md(text: str) -> list[dict]:
chunks: list[dict] = []
lines = text.split("\n")
cur_heading = "preface"
buf: list[str] = []
section_idx = 0
def flush():
nonlocal section_idx
body = "\n".join(buf).strip()
if body:
section_idx += 1
chunks.append({
"text": body,
"chunk_kind": "heading",
"locator": f"heading={cur_heading}",
})
for ln in lines:
s = ln.lstrip()
if s.startswith("#"):
flush()
buf = []
cur_heading = s.lstrip("#").strip() or "section"
else:
buf.append(ln)
flush()
return chunks
def _parse_txt(text: str) -> list[dict]:
chunks: list[dict] = []
parts = [p.strip() for p in text.split("\n\n") if p.strip()]
for i, p in enumerate(parts, start=1):
chunks.append({
"text": p,
"chunk_kind": "paragraph",
"locator": f"paragraph={i}",
})
return chunks

View File

@@ -0,0 +1,49 @@
"""xlsx 청킹 — 시트 단위(markdown) + 행 단위 둘 다 생성."""
from __future__ import annotations
def parse(path: str) -> list[dict]:
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True, data_only=True)
chunks: list[dict] = []
for sheet in wb.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header = [str(c) if c is not None else "" for c in rows[0]]
sheet_name = sheet.title
# 1) 시트 청크 — markdown 표 (선두 1000행 제한)
body_rows = rows[1:1001]
md_lines = ["| " + " | ".join(header) + " |",
"| " + " | ".join(["---"] * len(header)) + " |"]
for r in body_rows:
cells = [str(c) if c is not None else "" for c in r]
cells += [""] * (len(header) - len(cells))
md_lines.append("| " + " | ".join(cells[: len(header)]) + " |")
chunks.append({
"text": "\n".join(md_lines),
"chunk_kind": "sheet",
"locator": f"sheet={sheet_name}",
})
# 2) 행 청크 — 각 행을 'col=val' 형식 한 줄로
for i, r in enumerate(rows[1:], start=2):
parts = []
for j, val in enumerate(r):
if val is None or val == "":
continue
col = header[j] if j < len(header) and header[j] else f"col{j+1}"
parts.append(f"{col}={val}")
if not parts:
continue
chunks.append({
"text": f"{sheet_name}: " + ", ".join(parts),
"chunk_kind": "row",
"locator": f"sheet={sheet_name}; row={i}",
})
return chunks

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
# P&ID 흐름 추적 · From/To 추출 알고리즘 사양서
> 출처: P10-EQP-BLOCK.dxf 협업 역설계로 검증된 규칙 통합 (2026-05-18).
> 참조 자산: `mcp-server/legend_symbols.json`(심볼 시그니처), `mcp-server/block_template_library.json`(블록 템플릿),
> `mcp-server/legend_probe.py`(프로브). 관련 메모리: feedback-trace-reporting-granularity,
> reference-lineno-leader-rule, project-legend-symbol-catalog.
---
## 0. 목적과 핵심 전제 (지배 규칙)
- **목적**: 도면에서 유체 흐름을 추적해 **랜드마크 간 From/To 연결그래프**와 **라인번호·출처**를 추출.
- **답변 단위**: "FCV-10101 어디야?" → "라인 P-10149-40A-F1A-n (출처 T-10100) 상, P-10101 흡입헤더·토출·F-10102A/B 거쳐 FIT-10101 하류". **수동밸브/체크/호스/레듀서 등 commodity는 절대 열거 안 함.**
- **정밀 카운트 아님**: 견적용 개수 검출이 목적이 아니라 연결·위상 파악이 목적.
### 노드 분류
| 분류 | 예 | 처리 |
|---|---|---|
| **랜드마크 노드** | 설비(P-,T-,E-,C-,D-,F-,SC-…), 컨트롤밸브/태그계기(FCV-,FIT-,LIC-…), 모터 | From/To 그래프의 정점 |
| **투명 통과 (commodity)** | 수동 볼/체크/글로브밸브, 레듀서, 플렉시블호스, 스트레이너, 플랜지 | 흐름 안 끊기게 **브리지만**, 노드 아님, 출력에 미열거 |
| **연결 매체** | 배관(pipings) | 엣지. line_number·출처 보유 |
---
## 1. 입력 자산
1. **설비 블록**: 태그명 INSERT (이름=태그). 위치 = `virtual_entities()` 월드 bbox (insert (0,0)+내부절대좌표 / 또는 실좌표 — 변환이 흡수).
2. **심볼 템플릿 라이브러리**: 명명 블록 정의의 정규화 시그니처(상대좌표·길이·각도·토폴로지, 원/호/SOLID 수). 회전·미러 **불변** 종류식별 + 월드기하 방향산출 2계층.
3. **raw 지오메트리**: modelspace LINE/LWPOLYLINE/ARC/CIRCLE/SOLID/HATCH — 실제 그려진 배관·commodity 심볼.
4. **OFFPAGE_CONNECTOR**: 방향성 블록(LINE 5~6) + **본체 bbox 내부 TEXT 2개**(출처설비/서비스, 라인번호/연속키).
5. **라인번호 TEXT + 지시선**: 직접배치 또는 leader(SOLID/선-구성 chevron 화살촉 + stem).
---
## 2. 알고리즘 단계
### Phase 1 — 랜드마크 노드 추출
- 모든 INSERT 순회. 이름이 태그패턴(`^[A-Z0-9]{1,4}-?\d{3,5}` 또는 명시 설비명)이면 **설비 노드**: tag, world bbox 중심, 카테고리(prefix 규칙).
- 태그계기/컨트롤밸브: 심볼(글로브밸브 등) + 인접 태그TEXT(FCV-/FIT-…) → **태그계기 노드**. 태그=인접 라벨, 위치=심볼 월드좌표.
- `-SAME`/중복 복제 = **동일 노드로 병합**(같은 물리설비).
### Phase 2 — 배관망 + commodity 투명 브리지
- raw LINE/LWPOLYLINE → 끝점 그래프(좌표 양자화). 세그먼트 = 잠재 배관.
- **commodity 심볼 인식 → 투명 통과**:
- 소형 빈 CIRCLE r≈0.28~0.46 (+ bowtie 바/대각) = 볼밸브 → pass-through. (r 필터 하한 0.28 필수; faceted·무원 변형은 클러스터 패턴으로)
- SOLID 삼각형 + 단일사선 = 체크밸브(방향성) → pass-through
- 닫힌 LWPOLY 채움 = 글로브 / ARC 돔+stem = 컨트롤·다이어프램 (단 **태그 있으면 랜드마크**, 없으면 commodity)
- 레듀서/플렉시블호스/스트레이너/플랜지 = pass-through
- 회전·미러는 `virtual_entities()` 월드기하로 흡수. **블록명은 힌트, 방향근거 금지.**
- **gap-통과 브리지**: 배관 끊김 → 진행 흐름벡터 방향(콘)으로 lookahead, gap에 commodity 심볼 있으면 그 너머 공선 세그먼트로 연결 계속.
### Phase 3 — 위상 인식 (흐름방향·분기·합류·루프)
- **흐름방향 = 의미적**: 발원지(OFFPAGE/탱크/펌프)에서 누적 흐름벡터. **DXF 선 정점순서 무시, 역행 금지, 방문엣지 가드(진동 방지).**
- **FLOW_DIRECTION 화살표**: 만나면 그 위치 흐름방향을 **월드기하 apex→tail 벡터로 확정**(이름 아님). 의미적 추정보다 우선.
- **엘보**: 직각 굽힘은 끝점공유로 따라감(수평→수직 등).
- **TEE = 분기/합류**: 한 점에 3선 이상. 분기 시 본류+지선 분할.
- **재순환(킥백) 루프**: 한 지선이 이미 방문한 흡입측으로 복귀 = **정상 사이클**. 무한루프 아님 → `recirculation` 엣지로 표기, 그 지선 종료.
- **헤더/매니폴드**: 수평선에 수직 분기 다수 → 합류 매니폴드. 좌단 블라인드플랜지=막힘(흐름 반대편).
- **병렬 트레인**: 동일 x/y의 평행 분기→합류 (예 F-10102A/B 수직병렬).
- **OFFPAGE 짝**: 본체 내부 라인번호/연속키 TEXT 추출. **동일 연속키 가진 커넥터끼리 페어** → 라인 점프(시트/위치). 방향 = 커넥터 월드기하.
### Phase 4 — 라인번호 ↔ 배관 귀속
- **케이스 A 직접배치**: 텍스트가 배관 복도 근접(수평배관 위 평행, **또는 수직배관 옆 span중간 가로배치**). 박스 내 최근접 세그먼트.
- **케이스 B 지시선**: 화살촉(SOLID **또는** 1.0바+0.66대각 선-구성 chevron) → **tip 좌표가 닿는 배관 = 대상.** **tip↔배관 매칭 허용오차 극소(sub-0.5u, ≈0)** — 밀집부 오매칭 방지. stem(LINE/LWPOLY/SPLINE/LEADER 무관) → landing → 텍스트 역추적도 성립.
- **모호 분해**: 근접 평행 수직 2개 등 기하로 불가 시 → **흐름-토폴로지 컨텍스트**(어느 배관이 추적경로/설비에 실제 연결)로 결정.
### Phase 5 — 랜드마크 From/To 환원
- 투명 commodity 체인 **붕괴(collapse)**: 랜드마크→(commodity*)→랜드마크 를 **랜드마크↔랜드마크 단일 엣지**로.
- 엣지 = `from_tag`(상류 랜드마크), `to_tag`(하류 랜드마크). **From/To에 배관번호 안 넣음.**
- 상/하류 = 흐름방향으로 결정. 미결정 시 무방향(from/to 임의·플래그).
- 배관번호·출처 = **pipings 레코드/엣지 속성**(권위). 랜드마크 위치질의는 랜드마크→인접배관→line_number+OFFPAGE출처 **조인 조립**.
---
## 3. 출력 스키마 (`pid_equipment` 정합)
| 카테고리 | from_tag / to_tag | line_number |
|---|---|---|
| equipment / control_valve / motor / instrument | 인접 **랜드마크 태그** ↔ 랜드마크 태그 | (옵션) 얹힌 라인 단일 참조, From/To 불포함 |
| pipings | 라인 양끝 랜드마크 / OFFPAGE 짝 | 그 배관 라인번호 (권위) |
- `recirculation`/`bypass` 엣지는 타입 플래그로 구분(정상 위상, 누락·추측 금지).
- 재실행 결정성: 기존 From/To 초기화 후 재계산하되 **수동 import 잠금(ConnectionLocked) 보존**.
---
## 4. 핵심 불변식 (검증된 함정 회피)
1. 흐름방향은 의미적(발원지 기준), 선 정점순서·블록명 불신. 방문엣지 가드 필수(self-oscillation 방지).
2. 회전/미러 → 종류는 불변시그니처, 방향은 `virtual_entities()` 월드기하. 동일형상 다른이름(FLOW_DIR 3종 등) 주의.
3. 볼밸브 r≈0.28~0.46 (소형 지배, 도면 다수). 펌프 케이싱 r≈2~3 (≥4.5 아님).
4. 재순환선 = 정상 사이클. 끊지 말고 루프로 표기.
5. 화살촉 tip↔배관 = sub-0.5u 준-정확. 화살촉은 SOLID 또는 선-구성 chevron.
6. From/To = 랜드마크↔랜드마크. 배관번호는 엣지/pipings 속성. commodity 미열거(답변 단위 전제).
---
## 5. 정직한 한계 / 미검증
- raw 패턴매칭은 퍼지: 재현율 100% 보장 못 함(회전·스케일·작도변형·배관병합).
- 밀집부(토출·HATCH다수)는 좁은 허용오차 없이는 오탐.
- 지시선 변형: SOLID·선-구성 chevron 검증됨. **SPLINE 곡선 stem 미검증**.
- 직접배치 평행수직 모호 → 토폴로지 컨텍스트 의존(자동화 시 흐름추적 선행 필요).
- OFFPAGE 짝 매칭은 연속키 텍스트 정확추출 전제(밀집·회전 텍스트 노이즈 가능).
---
## 6. 구현 상태 · 다음 세션 인계 (2026-05-18 2차 세션 종료 시점)
### 산출물 (확정·보존)
- 사양서: `mcp-server/pid_trace_algorithm.md` (이 문서)
- 구현체: `mcp-server/pid_tracer.py` — Phase 1·1b·2·3·4·5 구현 (미드스트림 라우팅 미완)
- 카탈로그: `mcp-server/legend_symbols.json`, `mcp-server/block_template_library.json`, `mcp-server/legend_probe.py`
- 출력: `mcp-server/storage/P10-EQP-BLOCK_connections.json` (C# 소비 포맷 호환)
- C# 소비경로: `AnalyzeConnectionsAsync` 이미 `<prefix>_connections.json` edges 소비 (이전 세션 구현)
### 정답 레퍼런스 (협업 검증된 ground truth)
- P-10101 시스템 전체 위상 = **흡입 T-10100/T-10101 합류 + 킥백 재순환 루프 +
토출 → 병렬필터 F-10102A/B → MASS_FLOW_METER(FIT-10101) → FCV(+바이패스) → E-10103**
- 주의: 핸드오프 기억상 "FCV-10101" 이나 도면 실제 인접 버블은 **FCV-10116** (도면 권위)
### 현재 어디까지 (2026-05-18 2차 세션 (a) 보강)
**현재 트레이서 실측 출력:**
- 랜드마크 57, OFFPAGE 시드 1, 볼앵커 372, raw 14,764, 라인번호귀속 227
- `OFFPAGE T-10100` 시드: T-10100 노드만 (공급선 P-10101 미완주)
- `P-10101 discharge` 시드: **P-10101 → F-10102B → F-10102A → FIT-10101** (여기서 멈춤)
**완료·검증:**
1.**OFFPAGE 발원 소스 시드**: OFFPAGE_CONNECTOR 4종 중 LEFT_TO_RIGHT 처리.
apex = 본체 주축 두 극단 중 '뾰족한'(정점 1개) 쪽, 방향 = 뒷변중점→apex (월드기하
권위). 본체 근접 TEXT 분류 → 출처태그 `T-10100` 발원 노드 등록 + 라인번호
`P-10149-40A-F1A-n` 시드 부착. (공급선이 P-10101 까지 *완주*는 미달 — §아래)
2. ⬆️ **랜드마크 통과-후-재개**: `resume_nozzle` — 블록 실체범위(SOLID/원반경 포함
`world_extent`) 밖, **안정적 시드 흐름축** 투영 최대 미방문 노즐로 재개. 결과:
`P-10101 → F-10102B → F-10102A → FIT-10101` (이전: F-10102A 에서 멈춤).
3. ⬆️ **명명 심볼 = 태그계기 합성**: MASS_FLOW_METER/CONTROL_VALVE_GLOBE/
FLOW_METER_VARIABLE-AREA 를 commodity 에서 제외, 인접 함수코드+번호 버블 TEXT 로
태그 합성 (`FIT`+`10101`→**FIT-10101**, `FCV`+`10116`→**FCV-10116**). 블록명은
종류 힌트, 태그는 인접 TEXT 권위.
4. ⬆️ **일반 전방 gap-브리지**: commodity/작도 끊김을 흐름축 콘(dot>0.85+수직오프셋
최소)으로 점프, 착지점에서 정상 conn 재전진(역행 억제).
5. ⬆️ **볼앵커 과포함 정밀화**: r 0.28~0.46 + 동반 단선(≤2.6) ≥2 조건 → 560→372.
6. 🧩 **Phase 4·5 실코드화**: 라인번호 귀속(SOLID tip sub-0.5u / 직접배치, 227건),
재순환 엣지·OFFPAGE 라인번호 엣지 속성.
**남은 미완 (다음 세션 계속 (a)2/(a)3):**
- **밀집 미드스트림 라우팅**: `FIT-10101 → FCV-10116 → E-10103` 장거리 런이
계기 테이크오프·샘플 분기 fitting 기하에 갇힘. 계기-테이크오프 거부 +
헤더/TEE 추종 필요.
- **공급선 P-10101 완주**: OFFPAGE 시드가 흡입헤더까지 라우팅 미완(하향 드리프트).
- TEE 분기 본류/지선 분할·OFFPAGE 동일연속키 짝 매칭 = 미구현(골격).
**(b) C# 연동·DB 반영 (착수 전):**
1. 트레이서 의미 엣지 산출 후 `AnalyzeConnectionsAsync` 를 P10-EQP-BLOCK 출력 연결
2. From/To = 랜드마크↔랜드마크만(배관번호 미내장, pipings/엣지 속성) — §3 스키마대로 DB 반영
3. 수동 import 잠금(ConnectionLocked) 보존 회귀 확인
검증 진입점(SEEDS)·정답 위상은 `pid_tracer.py` 주석 및 본 문서 §2·메모리에 보존됨.

497
mcp-server/pid_tracer.py Normal file
View File

@@ -0,0 +1,497 @@
"""
P&ID 흐름 추적 · 랜드마크 From/To 추출 (pid_trace_algorithm.md 구현)
Phase 1(랜드마크+OFFPAGE 소스) → Phase 2(배관망+commodity 투명) →
Phase 3(흐름추적: 방문가드·엘보·TEE·재순환·랜드마크 통과-후-재개) →
Phase 4(라인번호 귀속) → Phase 5(랜드마크 From/To 환원).
출력: <prefix>_connections.json (C# AnalyzeConnectionsAsync 소비 포맷 호환).
usage:
python3 mcp-server/pid_tracer.py [DXF경로] (기본 src/Web/uploads/pid/P10-EQP-BLOCK.dxf)
PID_TRACE_DEBUG=1 python3 mcp-server/pid_tracer.py (경로 좌표 덤프)
"""
import sys, os, re, math, json, collections
import ezdxf
from ezdxf import recover
DXF = sys.argv[1] if len(sys.argv) > 1 else "src/Web/uploads/pid/P10-EQP-BLOCK.dxf"
DEBUG = os.environ.get("PID_TRACE_DEBUG") == "1"
# 랜드마크 태그 패턴 / commodity 블록명(투명 통과)
EQP_TAG = re.compile(r'^[A-Z]{1,4}-?\d{3,5}[A-Z]?$')
INSTR_TAG = re.compile(r'^(FCV|FIT|FIC|LIC|LCV|PCV|PIC|TIC|TCV|FV|LV|PV|TV|AT|FT|LT|PT|TT)-?\d', re.I)
# 라인번호: 사이즈코드/연속세그먼트 다수 (예 P-10149-40A-F1A-n, 25Ax32A)
LINE_NO = re.compile(r'^[A-Z]{1,3}-?\d{3,5}-\w+-\w+|^\d+A?x\d+A?$', re.I)
COMMODITY = re.compile(
r'BALL|CHECK|GLOBE|GATE|REDUCER|FLEX|HOSE|TUBE|STRAIN|FLANGE|VALVE_BALL|ON-?OFF|'
r'DIAPHRAGM|NEEDLE|PLUG|BUTTERFLY|TRAP|EXPAN|CONE', re.I)
JUNK = {"asda", "QQ", "sm", "bv", "GENAXEH", "D660198", "RC11", "HS_BOM",
"IC", "ORDERNO", "ASDADAS", "1", "F", "SG", "BFT", "EX1"}
# 명명 심볼 블록 = 태그계기/컨트롤밸브 (인접 버블 TEXT 로 태그 합성). commodity 아님.
NAMED_INSTRUMENT = {"MASS_FLOW_METER", "CONTROL_VALVE_GLOBE",
"FLOW_METER_VARIABLE-AREA"}
FUNC_CODE = re.compile(
r'^(FIT|FCV|FIC|FE|FQ|FQI|FI|FT|LIC|LCV|LI|LT|LSL|LSH|PIC|PCV|PI|PT|PSV|PSH|'
r'TIC|TCV|TI|TT|AT|AIT|FV|LV|PV|TV)$')
NUM_TXT = re.compile(r'^\d{3,6}[A-Z]?$')
def load():
try:
return ezdxf.readfile(DXF)
except ezdxf.DXFStructureError:
d, _ = recover.readfile(DXF)
return d
def world_extent(insert):
"""SOLID/ARC/CIRCLE 반경까지 포함한 월드 bbox (블록 실체 범위)."""
xs, ys = [], []
try:
for ve in insert.virtual_entities():
t = ve.dxftype()
if t == "LINE":
xs += [ve.dxf.start.x, ve.dxf.end.x]
ys += [ve.dxf.start.y, ve.dxf.end.y]
elif t in ("CIRCLE", "ARC"):
r = getattr(ve.dxf, "radius", 0.0)
xs += [ve.dxf.center.x - r, ve.dxf.center.x + r]
ys += [ve.dxf.center.y - r, ve.dxf.center.y + r]
elif t == "ELLIPSE":
xs += [ve.dxf.center.x]
ys += [ve.dxf.center.y]
elif t == "SOLID":
for k in ("vtx0", "vtx1", "vtx2", "vtx3"):
p = getattr(ve.dxf, k, None)
if p is not None:
xs.append(p.x)
ys.append(p.y)
except Exception:
pass
if not xs:
ip = insert.dxf.insert
return (ip.x, ip.y, ip.x, ip.y)
return (min(xs), min(ys), max(xs), max(ys))
def is_landmark_block(name):
if name.startswith(("A$", "*")) or name in JUNK:
return False
if name.startswith("OFFPAGE") or name.startswith("FLOW_DIRECTION"):
return False
if COMMODITY.search(name):
return False
base = name.split("-SAME")[0].strip()
return bool(EQP_TAG.match(base)) or name in ("IBC TANK", "3-10203") or bool(INSTR_TAG.match(name))
def main():
doc = load()
msp = doc.modelspace()
all_text = []
for e in msp:
if e.dxftype() in ("TEXT", "MTEXT"):
v = (e.plain_text() if e.dxftype() == "MTEXT" else e.dxf.text).strip()
if v:
all_text.append((v, e.dxf.insert.x, e.dxf.insert.y))
# ── Phase 1: 랜드마크 노드 (INSERT 블록 + 태그계기 TEXT) ───────────
landmarks = {} # tag -> (cx, cy)
lm_block = {} # tag -> (cx, cy, (x0,y0,x1,y1)) INSERT 실체 범위
for e in msp:
if e.dxftype() != "INSERT":
continue
nm = e.dxf.name
if not is_landmark_block(nm):
continue
tag = nm.split("-SAME")[0].strip() # T-3210-SAME* → T-3210 병합
x0, y0, x1, y1 = world_extent(e)
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
landmarks.setdefault(tag, (cx, cy))
lm_block.setdefault(tag, (cx, cy, (x0, y0, x1, y1)))
# 태그계기: 인접 TEXT 가 FCV-/FIT- 인 commodity-형상 심볼 (월드 텍스트로 근사)
for v, tx, ty in all_text:
vk = v.split()[0].replace(" ", "")
if INSTR_TAG.match(vk) and not LINE_NO.match(vk):
landmarks.setdefault(vk, (tx, ty))
# 명명 심볼 블록(MASS_FLOW_METER 등) = 태그계기. 인접 함수코드+번호 버블로
# 태그 합성(FIT+10101 → FIT-10101). 블록명은 종류힌트, 태그는 인접 TEXT.
func_txt = [(v, x, y) for v, x, y in all_text if FUNC_CODE.match(v.strip())]
num_txt = [(v, x, y) for v, x, y in all_text if NUM_TXT.match(v.strip())]
for e in msp:
if e.dxftype() != "INSERT" or e.dxf.name not in NAMED_INSTRUMENT:
continue
x0, y0, x1, y1 = world_extent(e)
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
code = min(((math.hypot(fx - cx, fy - cy), fv, fx, fy)
for fv, fx, fy in func_txt), default=None)
if not code or code[0] > 12:
continue
_, fv, fx, fy = code
num = min(((math.hypot(nx - fx, ny - fy), nv)
for nv, nx, ny in num_txt
if math.hypot(nx - fx, ny - fy) <= 4.5), default=None)
if not num:
continue
tag = f"{fv.strip()}-{num[1].strip()}"
landmarks.setdefault(tag, (cx, cy))
lm_block.setdefault(tag, (cx, cy, (x0, y0, x1, y1)))
# ── Phase 1b: OFFPAGE_CONNECTOR 발원 소스 시드 ───────────────────
# 본체 라인 → apex(수렴 정점) 와 흐름방향(centroid→apex, 월드기하 권위).
# 본체 근접 TEXT → 출처설비태그(EQP/INSTR) + 라인번호(LINE_NO).
offpage_seeds = [] # (src_tag, (ax,ay), (dx,dy), line_no, conn_name)
for e in msp:
if e.dxftype() != "INSERT" or not e.dxf.name.startswith("OFFPAGE"):
continue
pts = []
for ve in e.virtual_entities():
if ve.dxftype() == "LINE":
pts += [(ve.dxf.start.x, ve.dxf.start.y), (ve.dxf.end.x, ve.dxf.end.y)]
if not pts:
continue
cxp = sum(p[0] for p in pts) / len(pts)
cyp = sum(p[1] for p in pts) / len(pts)
bx0, by0 = min(p[0] for p in pts), min(p[1] for p in pts)
bx1, by1 = max(p[0] for p in pts), max(p[1] for p in pts)
# apex = 본체 주축(긴 변)의 두 극단 중 '뾰족한' 쪽(정점 1개) — 평평한
# 뒷변(정점 2개)과 구분. 방향 = 뒷변 중점 → apex (월드기하 권위).
ax_is_x = (bx1 - bx0) >= (by1 - by0)
key = (lambda p: p[0]) if ax_is_x else (lambda p: p[1])
lo, hi = min(pts, key=key), max(pts, key=key)
lo_n = sum(1 for p in pts if abs(key(p) - key(lo)) <= 0.5)
hi_n = sum(1 for p in pts if abs(key(p) - key(hi)) <= 0.5)
apex_pt, back_pts = (hi, [p for p in pts if abs(key(p) - key(lo)) <= 0.5]) \
if hi_n <= lo_n else (lo, [p for p in pts if abs(key(p) - key(hi)) <= 0.5])
ax, ay = apex_pt
bmx = sum(p[0] for p in back_pts) / len(back_pts)
bmy = sum(p[1] for p in back_pts) / len(back_pts)
dvx, dvy = ax - bmx, ay - bmy
dn = math.hypot(dvx, dvy) or 1.0
dvx, dvy = dvx / dn, dvy / dn
src_tag, line_no = None, None
best_src = 9e9
for v, tx, ty in all_text:
if not (bx0 - 4 <= tx <= bx1 + 4 and by0 - 4 <= ty <= by1 + 4):
continue
vk = v.split()[0].replace(" ", "")
if LINE_NO.match(vk) or re.search(r'\d+A?x\d+A?', vk):
if line_no is None:
line_no = v
elif EQP_TAG.match(vk) or INSTR_TAG.match(vk):
d = math.hypot(tx - cxp, ty - cyp)
if d < best_src:
best_src, src_tag = d, vk
if src_tag:
landmarks.setdefault(src_tag, (cxp, cyp)) # 발원 소스 노드 등록
offpage_seeds.append((src_tag, (ax, ay), (dvx, dvy), line_no, e.dxf.name))
# ── Phase 2: 배관망 + commodity 투명 앵커 ──────────────────────────
raw = []
for e in msp:
if e.dxftype() == "LINE":
s, en = e.dxf.start, e.dxf.end
if math.hypot(en.x - s.x, en.y - s.y) > 0.05:
raw.append((round(s.x, 2), round(s.y, 2), round(en.x, 2), round(en.y, 2)))
elif e.dxftype() == "LWPOLYLINE":
p = [(round(a, 2), round(b, 2)) for a, b in e.get_points("xy")]
for i in range(len(p) - 1):
raw.append((p[i][0], p[i][1], p[i + 1][0], p[i + 1][1]))
# commodity 볼앵커: 소형 빈 원 + 동반 bowtie/레버 단선 2개 이상 (과포함 정밀화)
circ = [(x.dxf.center.x, x.dxf.center.y, round(x.dxf.radius, 3))
for x in msp if x.dxftype() == "CIRCLE"]
seg_mid = collections.defaultdict(list)
for x1, y1, x2, y2 in raw:
L = math.hypot(x2 - x1, y2 - y1)
if L <= 2.6:
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
seg_mid[(round(mx), round(my))].append((mx, my))
def companion_count(cx, cy, R=0.8):
n = 0
for gx in (int(cx) - 1, int(cx), int(cx) + 1):
for gy in (int(cy) - 1, int(cy), int(cy) + 1):
for mx, my in seg_mid.get((gx, gy), ()):
if math.hypot(mx - cx, my - cy) <= R:
n += 1
return n
ball_anchors = [(x, y) for x, y, r in circ
if 0.28 <= r <= 0.46 and companion_count(x, y) >= 2]
Q = 0.6
node = collections.defaultdict(list)
for idx, (x1, y1, x2, y2) in enumerate(raw):
L = math.hypot(x2 - x1, y2 - y1)
node[(round(x1 / Q), round(y1 / Q))].append((idx, (x1, y1), (x2, y2), L))
node[(round(x2 / Q), round(y2 / Q))].append((idx, (x2, y2), (x1, y1), L))
def conn(p):
out = []
k = (round(p[0] / Q), round(p[1] / Q))
for i in (-1, 0, 1):
for j in (-1, 0, 1):
for idx, a, b, L in node.get((k[0] + i, k[1] + j), ()):
if math.hypot(a[0] - p[0], a[1] - p[1]) <= 0.8:
out.append((idx, a, b, L))
return out
def ball_ahead(p, dv, ml=14):
best, bd = None, ml
for x, y in ball_anchors:
v = (x - p[0], y - p[1])
d = math.hypot(*v)
if 0.5 < d <= ml and (v[0] * dv[0] + v[1] * dv[1]) / d > 0.65 and d < bd:
bd, best = d, (x, y)
return best
lm_pts = list(landmarks.items())
def nearest_landmark(p, r=10):
best, bd = None, r
for t, (lx, ly) in lm_pts:
d = math.hypot(lx - p[0], ly - p[1])
if d < bd:
bd, best = d, t
return best
def resume_nozzle(lm_tag, pt, flow, visited):
"""랜드마크 블록 통과 후 하류 노즐에서 흐름 재개점 산출.
블록 실체 범위 밖, 안정적 흐름축(flow) 투영 최대인 미방문 배관 끝점.
(직전 세그먼트 came 이 아닌 시드 흐름축을 써 병렬·수직 홉 노이즈 흡수.)"""
if lm_tag not in lm_block:
return None
cx, cy, (bx0, by0, bx1, by1) = lm_block[lm_tag]
half = max(bx1 - bx0, by1 - by0) / 2
rng = max(half + 8.0, 10.0)
best, bscore = None, 0.6
for idx, (x1, y1, x2, y2) in enumerate(raw):
if idx in visited:
continue
for ex, ey, ox, oy in ((x1, y1, x2, y2), (x2, y2, x1, y1)):
# 블록 실체 내부 끝점 제외(노이즈), 중심 기준 하류측만
if bx0 - 0.5 <= ex <= bx1 + 0.5 and by0 - 0.5 <= ey <= by1 + 0.5:
continue
vx, vy = ex - cx, ey - cy
d = math.hypot(vx, vy)
if not (half * 0.4 < d <= rng):
continue
proj = (vx * flow[0] + vy * flow[1]) / d
if proj > bscore:
bscore, best = proj, (idx, (ex, ey), (ox, oy))
return best
# ── Phase 3: 흐름 추적 ────────────────────────────────────────────
def trace(start, came, max_steps=400):
flow_axis = came # 안정적 시드 흐름축(통과 재개 기준)
pt = start
visited = set()
path = [] # [(kind, detail, (x,y))]
hit = collections.OrderedDict() # 만난 랜드마크 순서
exited = set() # 통과-후-재개 완료한 랜드마크
recirc = None
for _ in range(max_steps):
lm = nearest_landmark(pt, 10)
if lm:
if lm in hit and lm not in exited and len(hit) > 2:
# 이미 방문 랜드마크 복귀 = 재순환(킥백) 루프 → 분기 종료
recirc = lm
path.append(("recirculation", lm, pt))
break
hit.setdefault(lm, pt)
if lm not in exited:
exited.add(lm)
nz = resume_nozzle(lm, pt, flow_axis, visited)
if nz:
idx, ep, op = nz
visited.add(idx)
d = (op[0] - ep[0], op[1] - ep[1])
n = math.hypot(*d) or 1
came = (d[0] / n, d[1] / n)
pt = (op[0], op[1])
path.append(("through", lm, pt))
continue
cand = [(idx, a, b, L) for idx, a, b, L in conn(pt) if idx not in visited]
if cand:
cand.sort(key=lambda c: -((c[2][0] - c[1][0]) * came[0] + (c[2][1] - c[1][1]) * came[1])
/ (math.hypot(c[2][0] - c[1][0], c[2][1] - c[1][1]) or 1))
idx, a, b, L = cand[0]
visited.add(idx)
d = (b[0] - a[0], b[1] - a[1])
n = math.hypot(*d) or 1
came = (d[0] / n, d[1] / n)
pt = (b[0], b[1])
path.append(("pipe", round(L, 1), pt))
continue
bv = ball_ahead(pt, came)
if bv:
best, bd = None, 18
for i, (x1, y1, x2, y2) in enumerate(raw):
if i in visited:
continue
for ex, ey in ((x1, y1), (x2, y2)):
v = (ex - pt[0], ey - pt[1])
dist = math.hypot(*v)
if 1.0 < dist < bd and (v[0] * came[0] + v[1] * came[1]) / dist > 0.7:
bd, best = dist, (ex, ey)
path.append(("commodity_passthrough", None, pt))
if best:
pt = best
continue
# 일반 전방 gap-브리지: commodity/작도 끊김을 흐름방향 콘으로 점프.
# 안정적 흐름축(flow_axis) 콘 사용 — through/병렬 홉 직후 came 역행 억제.
# (좁은 콘 dot>0.85 + 수직오프셋 최소 → 밀집부 누수 억제)
cone = flow_axis if (path and path[-1][0] in ("through", "gap_bridge")) else came
gb, gdist = None, 12.0
for i, (x1, y1, x2, y2) in enumerate(raw):
if i in visited:
continue
for ex, ey, ox, oy in ((x1, y1, x2, y2), (x2, y2, x1, y1)):
vx, vy = ex - pt[0], ey - pt[1]
dist = math.hypot(vx, vy)
if not (0.8 < dist <= gdist):
continue
al = (vx * cone[0] + vy * cone[1]) / dist
if al <= 0.85:
continue
perp = abs(vx * cone[1] - vy * cone[0])
score = dist + perp * 4
if score < gdist:
gdist, gb = score, (i, (ex, ey), (ox, oy))
if gb:
idx, ep, op = gb
# 착지점(ep)으로 이동, 흐름방향(cone) 유지 — 이후 정상 conn 으로 전진.
# (먼 끝 op 로 강제 점프 시 역행 가능 → 착지 후 재선택)
jl = math.hypot(ep[0] - pt[0], ep[1] - pt[1])
came = cone
pt = (ep[0], ep[1])
path.append(("gap_bridge", round(jl, 1), pt))
continue
break
return list(hit.keys()), path, recirc
# 시드: OFFPAGE 발원 소스 + 검증된 ground-truth 진입점
SEEDS = []
for src_tag, apex, dv, line_no, cn in offpage_seeds:
SEEDS.append((f"OFFPAGE {src_tag}→({cn})", apex, dv, src_tag, line_no))
SEEDS.append(("P-10101 discharge", (1702.52, 5217.69), (1, 0), "P-10101", None))
results = []
for label, sp, dv, src_tag, line_no in SEEDS:
lms, path, recirc = trace(sp, dv)
if src_tag and (not lms or lms[0] != src_tag):
lms = [src_tag] + lms
pc = sum(1 for k, _, _ in path if k == "pipe")
cm = sum(1 for k, _, _ in path if k == "commodity_passthrough")
th = sum(1 for k, _, _ in path if k == "through")
gb_n = sum(1 for k, _, _ in path if k == "gap_bridge")
results.append({"seed": label, "start": [round(sp[0], 2), round(sp[1], 2)],
"source_tag": src_tag, "line_number": line_no,
"landmarks_in_order": lms,
"pipe_segments": pc, "commodity_passed": cm,
"blocks_through": th, "gap_bridges": gb_n,
"recirculation": recirc})
if DEBUG:
print(f"\n[DEBUG {label}] steps={len(path)}")
for k, det, xy in path:
if k in ("through", "recirculation", "gap_bridge"):
print(f" {k:<22} {det} @({xy[0]:.1f},{xy[1]:.1f})")
# ── Phase 4: 라인번호 ↔ 배관 귀속 (직접배치 + 지시선 tip) ──────────
# SOLID 화살촉 tip ↔ 배관 끝점 sub-0.5u 매칭, 또는 텍스트 복도 근접.
solids = []
for e in msp:
if e.dxftype() == "SOLID":
vs = [getattr(e.dxf, k, None) for k in ("vtx0", "vtx1", "vtx2", "vtx3")]
vs = [(p.x, p.y) for p in vs if p is not None]
if len(vs) >= 3:
solids.append(vs)
pipe_ends = []
for idx, (x1, y1, x2, y2) in enumerate(raw):
pipe_ends.append((idx, x1, y1))
pipe_ends.append((idx, x2, y2))
def pipe_near(px, py, tol):
best, bd = None, tol
for idx, ex, ey in pipe_ends:
d = math.hypot(ex - px, ey - py)
if d < bd:
bd, best = d, idx
return best
line_no_attr = [] # {line_number, pipe_idx, mode}
for v, tx, ty in all_text:
vk = v.split()[0].replace(" ", "")
if not (LINE_NO.match(vk) or re.search(r'\d+A?x\d+A?', vk)):
continue
# 케이스 B: 텍스트 근방 SOLID 화살촉 → tip 이 닿는 배관
attached = None
for vs in solids:
sc = (sum(p[0] for p in vs) / len(vs), sum(p[1] for p in vs) / len(vs))
if math.hypot(sc[0] - tx, sc[1] - ty) > 6.0:
continue
for tipx, tipy in vs:
pi = pipe_near(tipx, tipy, 0.5)
if pi is not None:
attached = (pi, "leader")
break
if attached:
break
# 케이스 A: 직접배치 — 텍스트 박스 근접 배관
if attached is None:
pi = pipe_near(tx, ty, 3.0)
if pi is not None:
attached = (pi, "direct")
if attached:
line_no_attr.append({"line_number": v, "pipe_idx": attached[0],
"mode": attached[1]})
# ── Phase 5: 랜드마크 From/To 엣지 환원 ───────────────────────────
edges = []
for r in results:
lm = r["landmarks_in_order"]
for i in range(len(lm) - 1):
edges.append({"from": lm[i], "to": lm[i + 1], "type": "process",
"via_seed": r["seed"],
"line_number": r["line_number"] if i == 0 else None})
if r["recirculation"] and lm:
edges.append({"from": lm[-1], "to": r["recirculation"],
"type": "recirculation", "via_seed": r["seed"],
"line_number": None})
out = {
"drawing": os.path.basename(DXF),
"stats": {"landmarks": len(landmarks),
"offpage_seeds": len(offpage_seeds),
"ball_anchors": len(ball_anchors),
"raw_segments": len(raw),
"traces": len(results),
"edges": len(edges),
"line_number_attributions": len(line_no_attr)},
"landmarks": [{"tag": t, "x": round(xy[0], 1), "y": round(xy[1], 1)}
for t, xy in sorted(landmarks.items())],
"traces": results,
"edges": edges,
}
prefix = os.path.basename(DXF).split("_")[0].split(".")[0]
op = os.path.join("mcp-server", "storage", f"{prefix}_connections.json")
os.makedirs(os.path.dirname(op), exist_ok=True)
json.dump(out, open(op, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"landmarks={len(landmarks)} offpage_seeds={len(offpage_seeds)} "
f"ball_anchors={len(ball_anchors)} raw_seg={len(raw)} "
f"edges={len(edges)} lineno_attr={len(line_no_attr)}")
for r in results:
print(f"\n[{r['seed']}] pipe={r['pipe_segments']} "
f"through={r['blocks_through']} commodity={r['commodity_passed']}"
f"{' recirc=' + r['recirculation'] if r['recirculation'] else ''}")
print(" 랜드마크 순서:", "".join(r["landmarks_in_order"]) or "(없음)")
print(f"\n{op}")
if __name__ == "__main__":
main()

View File

@@ -13,7 +13,7 @@ class MappingResult(BaseModel):
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
class IntelligentMapper:
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None, model_name: str = "Qwen3.6-27B-FP8"):
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None, model_name: str = "Qwen3.6-35B-A3B-FP8"):
self.graph = graph
self.system_tags = system_tags
self.client = api_client

View File

@@ -114,6 +114,12 @@ class PidTopologyBuilder:
grid.add(nid, self.G.nodes[nid]['bbox'])
return grid
# 시그널 레이어 이름 집합 (ELECTRIC SIGNAL, INSTRUMENT signal선 등)
_SIGNAL_LAYERS = frozenset({'ELECTRIC SIGNAL', 'SIGNAL', 'ELEC', 'CABLE', 'WIRE'})
def _relation_for_layer(self, layer: str) -> str:
return 'signal' if (layer or '').upper() in {s.upper() for s in self._SIGNAL_LAYERS} else 'pipe'
def build_graph(self):
# 1. 모든 객체를 노드로 추가
for item in self.data:
@@ -150,7 +156,7 @@ class PidTopologyBuilder:
if best_match:
self.G.add_edge(tag, best_match, relation='associated_with')
# 4. 배관 기반 물리적 연결 (Pipe) — SpatialGrid 사용
# 4. 배관/시그널 기반 연결 — SpatialGrid 사용
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
for line_id in lines:
@@ -161,6 +167,8 @@ class PidTopologyBuilder:
coords = original_item['coordinates']
line_geom = LineString(coords)
line_bbox = line_geom.bounds
layer = original_item.get('layer', '')
relation = self._relation_for_layer(layer)
# SpatialGrid로 후보 집합 조회 (O(1) 그리드 셀 기반)
nearby_equipment_ids = eq_grid.query(
@@ -183,27 +191,22 @@ class PidTopologyBuilder:
connected_nodes = list(set(connected_nodes))
if len(connected_nodes) >= 2:
# 개선: 단순 순서가 아닌, 기하학적 좌표 기반의 흐름 방향 추론 (왼쪽 -> 오른쪽, 위 -> 아래 우선)
# 실제 공정 도면의 일반적인 흐름 방향을 반영
node0_bbox = self.G.nodes[connected_nodes[0]]['bbox']
node1_bbox = self.G.nodes[connected_nodes[1]]['bbox']
center0 = ((node0_bbox.bounds[0] + node0_bbox.bounds[2])/2, (node0_bbox.bounds[1] + node0_bbox.bounds[3])/2)
center1 = ((node1_bbox.bounds[0] + node1_bbox.bounds[2])/2, (node1_bbox.bounds[1] + node1_bbox.bounds[3])/2)
# X축 차이가 Y축 차이보다 크면 X축 기준, 아니면 Y축 기준으로 방향 결정
if abs(center1[0] - center0[0]) > abs(center1[1] - center0[1]):
# X축 기준: 왼쪽 -> 오른쪽
if center0[0] < center1[0]:
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe', flow_direction='forward')
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation=relation, flow_direction='forward')
else:
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward')
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation=relation, flow_direction='forward')
else:
# Y축 기준: 위 -> 아래 (도면 좌표계에 따라 다를 수 있으나 일반적인 관례 적용)
if center0[1] > center1[1]:
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe', flow_direction='forward')
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation=relation, flow_direction='forward')
else:
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward')
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation=relation, flow_direction='forward')
elif len(connected_nodes) == 1:
# 단일 연결의 경우, 일단 엣지를 생성하되 방향은 미정(undirected-like)으로 처리하거나
# 추후 전파 로직에서 결정하도록 함

View File

@@ -24,6 +24,12 @@ dependencies = [
"scikit-learn>=1.3.0",
"numpy>=1.24.0",
"Pillow>=10.0.0",
# KB 문서 파싱
"openpyxl>=3.1.0",
"python-docx>=1.1.0",
"pdfplumber>=0.11.0",
# 계기 유추
"pyyaml>=6.0.0",
]
[project.scripts]
@@ -34,4 +40,4 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
only-include = ["server.py", "index_opc_docs.py"]
only-include = ["server.py", "index_opc_docs.py", "instrument_inference/", "parsers/", "pipeline/", "worker/", "config.py"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,717 @@
"""
P&ID 연결 분석 v2 — 좌표 근접성 기반
======================================
원리: C-10111 분석에서 발견한 패턴
- 설비(컬럼/탱크) = 긴 수직 LINE(≥200u) 또는 큰 원(r≥4.5)
- 계기(instrument balloon)는 설비와 Y축 겹침 + 수평 200u 이내
- LINE 연결이 없어도 좌표 근접성으로 연결 판단
순서:
1. DXF 로드, LINE/CIRCLE/TEXT 추출
2. instrument balloon 식별 (CIRCLE r≥1.5 + func+num)
3. equipment anchor 식별 (긴 수직 LINE + 큰 원)
4. 좌표 근접성 기반 연결 (Y겹침 + 수평거리 ≤ 200)
5. LINE endpoint 연결과 비교
"""
import ezdxf, re, time, math
from shapely.geometry import LineString, Point, MultiLineString, box
from shapely.strtree import STRtree
from shapely.ops import linemerge
from collections import defaultdict
DXF_PATH = "src/Web/uploads/pid/No-10_Plant_PID.dxf"
TAG_RE = re.compile(r'^[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*$', re.I)
FUNC_RE = re.compile(r'^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$')
NUM_RE = re.compile(r'^\d{3,6}[A-Z]?$')
PIPE_RE = re.compile(r'^\w+-\d{3,6}-\d+[A-Z]?-[A-Z][A-Z0-9]', re.I)
LOOP_RE = re.compile(r'\d{3,6}[A-Z]?')
# ── 1-2. DXF 로드 + LINE/CIRCLE/TEXT ──────────────────────────────────
t0 = time.time()
doc = ezdxf.readfile(DXF_PATH)
msp = doc.modelspace()
print(f"1. DXF 로드: {time.time()-t0:.2f}s", flush=True)
# LINE 추출
lines_raw = []
for e in msp:
t = e.dxftype()
if t == 'LINE':
s = (e.dxf.start.x, e.dxf.start.y)
e2 = (e.dxf.end.x, e.dxf.end.y)
if s != e2:
lines_raw.append(LineString([s, e2]))
elif t == 'LWPOLYLINE':
pts = [(p[0], p[1]) for p in e.vertices()]
if len(pts) >= 2:
lines_raw.append(LineString(pts))
# linemerge
merged = linemerge(MultiLineString(lines_raw))
merged_list = list(merged.geoms) if merged.geom_type == 'MultiLineString' else [merged]
# CIRCLE
circles = [(e.dxf.center.x, e.dxf.center.y, e.dxf.radius) for e in msp if e.dxftype() == 'CIRCLE']
# TEXT
text_entries = []
for e in msp:
if e.dxftype() == 'TEXT':
v = e.dxf.text.strip().replace('%%U', '').replace('%%C', 'Φ')
if v:
text_entries.append((e.dxf.insert.x, e.dxf.insert.y, v))
print(f"2. 추출: LINE={len(lines_raw)}, CIRCLE={len(circles)}, TEXT={len(text_entries)}", flush=True)
# ── 3. Instrument balloon 식별 ────────────────────────────────────────
t0 = time.time()
# TEXT → CIRCLE 포함 (r≥1.5)
text_in_circle = defaultdict(list)
for x, y, v in text_entries:
for cx, cy, r in circles:
if r >= 1.5 and (x-cx)**2 + (y-cy)**2 <= r*r:
text_in_circle[v].append((cx, cy, r))
# circle center → func+num
circ_data = {}
for v, occ in text_in_circle.items():
for cx, cy, r in occ:
k = (round(cx, 1), round(cy, 1))
if k not in circ_data:
circ_data[k] = {'r': r, 'x': cx, 'y': cy}
if FUNC_RE.match(v):
circ_data[k]['func'] = v
elif NUM_RE.match(v):
circ_data[k]['num'] = v
elif TAG_RE.match(v):
circ_data[k]['tag'] = v
instruments = []
for k, d in circ_data.items():
if 'func' in d:
tag = f"{d['func']}-{d.get('num', '?')}"
# prefer explicit tag if available
if 'tag' in d:
tag = d['tag']
instruments.append({'x': d['x'], 'y': d['y'], 'tag': tag, 'r': d['r'],
'loop': re.search(r'\d{3,6}', tag).group() if re.search(r'\d{3,6}', tag) else None})
# loop number별 계기 그룹
loop_inst = defaultdict(list)
for inst in instruments:
if inst['loop']:
loop_inst[inst['loop']].append(inst)
print(f"3-A. Instrument: {len(instruments)}")
print(f" Loop 번호 그룹: {len(loop_inst)}")
# ── 3-B. 방향표지판 검출 ──────────────────────────────────────────
t0 = time.time()
# 방향별 분류: h_seg는 raw LINE만 (O(n²) 회피), v/d_seg는 merged+raw
h_lines = []
v_lines = []
d_lines = []
for l in lines_raw:
coords = list(l.coords)
if len(coords) < 2:
continue
x1, y1 = coords[0]
x2, y2 = coords[-1]
dx = abs(x2 - x1)
dy = abs(y2 - y1)
if dy <= 1 and dx >= 3:
h_lines.append({'xl': min(x1, x2), 'xr': max(x1, x2), 'y': y1})
elif dx <= 1 and dy >= 2.5:
v_lines.append({'x': x1, 'y1': min(y1, y2), 'y2': max(y1, y2)})
elif dx >= 1 and dy >= 1:
angle = math.degrees(math.atan2(dy, dx))
if 20 <= angle <= 70 or 110 <= angle <= 160:
d_lines.append({'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2})
# merged LINE 보강 (v/d만) - merged LINE은 길어서 relaxed threshold
for l in merged_list:
coords = list(l.coords)
if len(coords) < 2:
continue
x1, y1 = coords[0]
x2, y2 = coords[-1]
dx = abs(x2 - x1)
dy = abs(y2 - y1)
if dx <= 1 and dy >= 2.5:
v_lines.append({'x': x1, 'y1': min(y1, y2), 'y2': max(y1, y2)})
elif dx >= 1 and dy >= 1:
angle = math.degrees(math.atan2(dy, dx))
if 20 <= angle <= 70 or 110 <= angle <= 160:
d_lines.append({'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2})
# 수평선쌍: 같은 xl/xr + y간격 2~8u (xr 기준 버킷으로 O(n²) 회피)
h_by_xr = defaultdict(list)
for h in h_lines:
h_by_xr[round(h['xr'] / 5) * 5].append(h)
h_pairs = []
seen_pair = set() # (xl_round, xr_round, y_mid_round) 중복 제거
for bucket in h_by_xr.values():
for i, h1 in enumerate(bucket):
for h2 in bucket[i+1:]:
if abs(h1['y'] - h2['y']) < 2:
continue
if abs(h1['xl'] - h2['xl']) <= 3 and abs(h1['xr'] - h2['xr']) <= 3:
ylo = min(h1['y'], h2['y'])
yhi = max(h1['y'], h2['y'])
gap = yhi - ylo
if 2 <= gap <= 8:
xl = (h1['xl']+h2['xl'])/2
xr = (h1['xr']+h2['xr'])/2
k = (round(xl), round(xr), round((ylo+yhi)/2))
if k not in seen_pair:
seen_pair.add(k)
h_pairs.append({'xl': xl, 'xr': xr, 'ylo': ylo, 'yhi': yhi, 'y_mid': (ylo+yhi)/2})
def _has_left_vert(hp, vlist, tol=8):
xl, ym = hp['xl'], hp['y_mid']
return any(abs(v['x'] - xl) <= tol and v['y1'] <= ym <= v['y2'] for v in vlist)
def _count_right_diag(hp, dlist, tol=8):
xr, ym = hp['xr'], hp['y_mid']
return sum(1 for d in dlist
if abs(d['x1'] - xr) <= tol and abs(d['y1'] - ym) <= tol
or abs(d['x2'] - xr) <= tol and abs(d['y2'] - ym) <= tol)
def _nearest_tag_to(hp, texts, limit=25):
mx, my = (hp['xl']+hp['xr'])/2, hp['y_mid']
best, best_d = None, 999
for tx, ty, tv in texts:
d = math.hypot(tx - mx, ty - my)
if d < best_d and d <= limit:
best_d, best = d, tv
return best, best_d
print(f" marker filter start... v={len(v_lines)} d={len(d_lines)} texts={len(text_entries)}")
markers = []
for i, hp in enumerate(h_pairs):
if not _has_left_vert(hp, v_lines):
continue
cnt_diag = _count_right_diag(hp, d_lines)
if cnt_diag < 2:
continue
tag, tag_dist = _nearest_tag_to(hp, text_entries)
if tag is None:
continue
mx = round((hp['xl']+hp['xr'])/2, 1)
my = round(hp['y_mid'], 1)
markers.append({'tag': tag, 'mx': mx, 'my': my,
'xl': hp['xl'], 'xr': hp['xr'],
'ylo': hp['ylo'], 'yhi': hp['yhi'], 'dr': cnt_diag})
et = time.time()
# 위치 중복 제거 (동일태그 + 동일좌표 → 1개)
seen = set()
deduped = []
for m in markers:
k = (m['tag'], round(m['mx'], 0), round(m['my'], 0))
if k not in seen:
seen.add(k)
deduped.append(m)
markers = deduped
print(f"3-B. 방향표지판(마커): {len(markers)}개 (h_pairs={len(h_pairs)}, elapsed={et-t0:.2f}s)")
print(f" ROI(y≥5100): {sum(1 for m in markers if m['my'] >= 5100)}")
tag_groups = defaultdict(list)
for m in markers:
tag_groups[m['tag']].append(m)
multi = {t: v for t, v in tag_groups.items() if len(v) >= 2}
print(f" 태그 있는 마커: {sum(1 for m in markers if m['tag'])}")
print(f" 동일태그 그룹(≥2): {len(multi)}")
for t, v in sorted(multi.items(), key=lambda x: -len(x[1]))[:10]:
poss = ', '.join(f"({m['mx']:.0f},{m['my']:.0f})" for m in v)
print(f" {t}: {len(v)}개 [{poss}]")
# ── 4. Equipment anchor 식별 ──────────────────────────────────────────
t0_4 = time.time()
# (A) 긴 수직 LINE (≥200u, almost vertical)
vert_lines = []
for l in merged_list:
c = list(l.coords)
xs = [p[0] for p in c]
ys = [p[1] for p in c]
dx = max(xs) - min(xs)
dy = max(ys) - min(ys)
if dy >= 200 and dy > dx * 3: # vertical-ish
vert_lines.append({
'x': round(sum(xs)/len(xs), 1),
'y_min': min(ys), 'y_max': max(ys),
'length': l.length, 'bounds': l.bounds
})
# dedup by x
vert_grouped = defaultdict(list)
for v in vert_lines:
vert_grouped[round(v['x'])].append(v)
vert_anchors = []
for x, vl in vert_grouped.items():
y_min = min(v['y_min'] for v in vl)
y_max = max(v['y_max'] for v in vl)
vert_anchors.append({'x': x, 'y_min': y_min, 'y_max': y_max,
'count': len(vl), 'total_len': sum(v['length'] for v in vl)})
# (B) 큰 원 (r≥4.5, empty or with text)
large_circs = [(cx, cy, r) for cx, cy, r in circles if r >= 4.5]
# Find nearest TAG text
eqp_by_circle = []
for cx, cy, r in large_circs:
best_tag = None
best_d = 999
for tx, ty, v in text_entries:
if TAG_RE.match(v):
d = math.hypot(tx-cx, ty-cy)
if d < best_d and d > r and d < 200:
best_d = d
best_tag = v
eqp_by_circle.append({'x': cx, 'y': cy, 'tag': best_tag or f'CIRCLE@{cx:.0f},{cy:.0f}',
'r': r, 'kind': 'large_circle'})
print(f"\n4. Equipment anchor:")
print(f" 수직 LINE(≥200u): {len(vert_anchors)}")
for va in sorted(vert_anchors, key=lambda v: v['x']):
print(f" x={va['x']:6.1f}, y=[{va['y_min']:.0f}, {va['y_max']:.0f}], 높이={va['y_max']-va['y_min']:.0f}")
print(f" 큰 원(r≥4.5): {len(eqp_by_circle)}")
for eq in eqp_by_circle:
print(f" ({eq['x']:6.1f}, {eq['y']:6.1f}) r={eq['r']:.1f} tag={eq['tag']}")
# (C) 작은 원 (r<4.5, r≥1.5, exclude instrument balloons)
# instrument balloon circles already identified as those containing TEXT
balloon_circles = set()
for v, occ in text_in_circle.items():
for cx, cy, r in occ:
balloon_circles.add((round(cx, 1), round(cy, 1)))
small_circs = [(cx, cy, r) for cx, cy, r in circles
if 1.5 <= r < 4.5 and (round(cx,1), round(cy,1)) not in balloon_circles]
small_eqp = []
for cx, cy, r in small_circs:
best_tag = None
best_d = 999
for tx, ty, v in text_entries:
if TAG_RE.match(v):
d = math.hypot(tx-cx, ty-cy)
if d < best_d and d > r and d < 100:
best_d = d
best_tag = v
small_eqp.append({'x': cx, 'y': cy, 'tag': best_tag or f'SMALL@{cx:.0f},{cy:.0f}',
'r': r, 'kind': 'small_circle'})
eqp_by_small = small_eqp
print(f" 작은 원(1.5≤r<4.5, excl. balloon): {len(eqp_by_small)}")
for eq in eqp_by_small[:10]:
print(f" ({eq['x']:6.1f}, {eq['y']:6.1f}) r={eq['r']:.1f} tag={eq['tag']}")
# Merge small into _what_at equipment list
all_eqp = eqp_by_circle + eqp_by_small
# ── 3-C. 방향표지판 LINE endpoint 추적 (2-hop BFS, 직각굽힘 허용) ──
t0 = time.time()
# Precompute: endpoint spatial index (grid bucketing)
# 30u limit → grid cell size 15 so we only check 3×3 neighbors
GRID = 15
ep_grid = defaultdict(list)
line_ep = {}
for li, l in enumerate(merged_list):
coords = list(l.coords)
if not coords:
continue
s, e = coords[0], coords[-1]
for ep, ei in [(s, 0), (e, -1)]:
gx, gy = int(ep[0] // GRID), int(ep[1] // GRID)
ep_grid[(gx, gy)].append((li, ei, ep))
line_ep[li] = {'s': s, 'e': e}
def _grid_neighbors(pt):
gx, gy = int(pt[0] // GRID), int(pt[1] // GRID)
return [(gx+dx, gy+dy) for dx in (-1,0,1) for dy in (-1,0,1)]
def _find_ep_idx(pt, limit=30):
best = None
for gk in _grid_neighbors(pt):
for li, ei, ep in ep_grid.get(gk, []):
d = math.hypot(pt[0]-ep[0], pt[1]-ep[1])
if d <= limit and (best is None or d < best[0]):
best = (d, li, ei, ep)
return best
def _follow_line(li, ei):
ep = line_ep[li]
return ep['e'] if ei == 0 else ep['s']
def _connected_at(pt, tol=5):
result = []
for gk in _grid_neighbors(pt):
for li, ei, ep in ep_grid.get(gk, []):
d = math.hypot(pt[0]-ep[0], pt[1]-ep[1])
if d <= tol:
result.append((li, ei, ep))
return result
def _is_origin(kind, tag, pt, origin_tag, origin_pos, span=40):
"""추적 결과가 출발 마커 자기자신인지 (cycle) 판정."""
if origin_tag is None or kind != 'marker' or tag != origin_tag:
return False
return math.hypot(pt[0] - origin_pos[0], pt[1] - origin_pos[1]) <= span
def _trace_2hop(pt, markers, vert_anchors, all_eqp, limit=30,
origin_tag=None, origin_pos=None):
"""마커 endpoint → 2-hop 추적. 출발 마커로 되돌아오는 사이클 차단 + 중복 제거."""
hop0 = _find_ep_idx(pt, limit)
if not hop0:
return []
_, li1, ei1, ep1 = hop0
op1 = _follow_line(li1, ei1)
best = {} # (kind, tag) → (total_len, path) 최단 1개만 유지
def _offer(kind, tag, total_len, path, at_pt):
if kind == 'empty':
return
if _is_origin(kind, tag, at_pt, origin_tag, origin_pos):
return # self-cycle: 출발 마커로 회귀
k = (kind, tag)
if k not in best or total_len < best[k][0]:
best[k] = (total_len, path)
# hop1: 같은 endpoint를 공유하는 세그먼트(중복 li2 제거)
seen_li2 = set()
for li2, ei2, ep2 in _connected_at(op1):
if li2 == li1 or li2 in seen_li2:
continue
seen_li2.add(li2)
op2 = _follow_line(li2, ei2)
kind, tag = _what_at_pt(op2, markers, vert_anchors, all_eqp, limit)
_offer(kind, tag, merged_list[li1].length + merged_list[li2].length,
f"seg#{li1}→seg#{li2}", op2)
# hop0 자체의 반대쪽 끝
kind1, tag1 = _what_at_pt(op1, markers, vert_anchors, all_eqp, limit)
_offer(kind1, tag1, merged_list[li1].length, f"seg#{li1}", op1)
return [(k[0], k[1], v[0], v[1]) for k, v in best.items()]
# Precompute marker grid for fast what_at lookup
marker_grid = defaultdict(list)
for mi, m in enumerate(markers):
gx, gy = int(m['mx'] // GRID), int(m['my'] // GRID)
marker_grid[(gx, gy)].append(mi)
def _what_at_pt(pt, markers, vert_anchors, all_eqp, limit=30):
px, py = pt
for gk in _grid_neighbors(pt):
for mi in marker_grid.get(gk, []):
m = markers[mi]
if math.hypot(px-m['mx'], py-m['my']) <= limit:
return 'marker', m['tag']
for va in vert_anchors:
if va['y_min'] <= py <= va['y_max'] and abs(px-va['x']) <= limit:
return 'equipment', f"VLINE@{va['x']:.0f}"
for eq in all_eqp:
if math.hypot(px-eq['x'], py-eq['y']) <= limit:
return 'equipment', eq['tag']
return 'empty', ''
marker_traces = []
trace_log = []
for m in markers:
mx, my, xl, xr, tag = m['mx'], m['my'], m['xl'], m['xr'], m['tag']
for side, pt in [('left', (xl, my)), ('right', (xr, my))]:
hops = _trace_2hop(pt, markers, vert_anchors, all_eqp,
origin_tag=tag, origin_pos=(mx, my))
for kind, to_tag, total_len, path in hops:
marker_traces.append({
'from': tag, 'from_pos': f"({mx:.0f},{my:.0f})",
'side': side, 'to_kind': kind, 'to_tag': to_tag,
'total_len': total_len, 'path': path
})
if tag == 'P-10101':
trace_log.append(f" {side} ({pt[0]:.1f},{pt[1]:.1f}) → {path}: {kind}={to_tag} ({total_len:.1f}u)")
et = time.time()
print(f"3-C. 마커 LINE endpoint 추적 (2-hop BFS, elapsed={et-t0:.2f}s):")
print(f" 추적 엣지: {len(marker_traces)}")
lk = defaultdict(int)
for e in marker_traces:
lk[e['to_kind']] += 1
for k, c in sorted(lk.items()):
print(f"{k}: {c}")
if trace_log:
print(f" P-10101 상세 추적:")
for line in trace_log:
print(line)
# ── 3-D. 태그 매칭 연결 ─────────────────────────────────────────
t0 = time.time()
tag_match_edges = []
for tag, ml in multi.items():
for i in range(len(ml)):
for j in range(i+1, len(ml)):
tag_match_edges.append({
'tag': tag,
'from_pos': f"({ml[i]['mx']:.0f},{ml[i]['my']:.0f})",
'to_pos': f"({ml[j]['mx']:.0f},{ml[j]['my']:.0f})",
'span': abs(ml[i]['mx'] - ml[j]['mx'])
})
et = time.time()
print(f"3-D. 태그 매칭 연결: {len(tag_match_edges)}쌍 (elapsed={et-t0:.2f}s)")
for t, ml in sorted(multi.items(), key=lambda x: -len(x[1]))[:10]:
xs = [m['mx'] for m in ml]
span = max(xs) - min(xs)
print(f" {t}: {len(ml)}개 위치, 스팬={span:.0f}u")
# ── 5. 좌표 근접성 기반 연결 ──────────────────────────────────────────
t0 = time.time()
# 각 instrument → 가장 가까운 equipment anchor 찾기
# 기준: Y범위 겹침 + 수평거리 ≤ 200
HORIZONTAL_LIMIT = 200
inst_eqp_connections = [] # (inst, eqp, dist, method)
unconnected_inst = []
for inst in instruments:
ix, iy = inst['x'], inst['y']
best_eqp = None
best_d = 999
# 수직 LINE anchor
for va in vert_anchors:
if va['y_min'] <= iy <= va['y_max']:
d = abs(ix - va['x'])
if d < best_d and d <= HORIZONTAL_LIMIT:
best_d = d
best_eqp = (f"VLINE@{va['x']:.0f}", va, 'vline')
# 큰 원 anchor
for eq in eqp_by_circle:
d = math.hypot(ix - eq['x'], iy - eq['y'])
if d < best_d and d <= HORIZONTAL_LIMIT:
best_d = d
best_eqp = (eq['tag'], eq, 'circle')
if best_eqp:
inst_eqp_connections.append((inst, best_eqp[0], best_d, best_eqp[2]))
else:
unconnected_inst.append(inst)
print(f"\n5. 좌표 근접 연결 (수평≤{HORIZONTAL_LIMIT}):")
print(f" 계기-설비 연결: {len(inst_eqp_connections)}")
print(f" 미연결 계기: {len(unconnected_inst)}")
# 연결 분류
by_method = defaultdict(int)
for *_, method in inst_eqp_connections:
by_method[method] += 1
for m, c in by_method.items():
print(f" {m}: {c}")
# loop별 연결 분포
loop_connected = defaultdict(list)
loop_unconnected = defaultdict(list)
for inst, eqp_tag, d, method in inst_eqp_connections:
loop_connected[inst['loop']].append((inst, eqp_tag, d))
for inst in unconnected_inst:
loop_unconnected[inst['loop']].append(inst)
print(f"\n Loop별 연결/미연결:")
for loop in sorted([k for k in loop_connected if k is not None], key=int)[:20]:
conn = len(loop_connected[loop])
unconn = len(loop_unconnected.get(loop, []))
total = conn + unconn
if total >= 3:
eqps = set(e for _, e, _ in loop_connected[loop])
print(f" Loop {loop}: {conn}/{total} 연결 → 설비: {', '.join(eqps)}")
# ── 6. LINE endpoint 연결과 비교 ──────────────────────────────────────
t0 = time.time()
# long lines (≥30)
long_lines = [l for l in merged_list if l.length >= 30]
# build anchor index
inst_tree = STRtree([Point(inst['x'], inst['y']) for inst in instruments])
inst_list = instruments
BUFFER = 50
def nearest_inst(pt):
idxs = inst_tree.query(box(pt.x-BUFFER, pt.y-BUFFER, pt.x+BUFFER, pt.y+BUFFER), predicate='intersects')
if len(idxs) == 0:
return None
best_d = 999
best = None
for idx in idxs:
p = inst_tree.geometries[idx]
d = p.distance(pt)
if d < best_d:
best_d = d
best = (inst_list[idx], best_d)
if best and best[1] <= BUFFER:
return best
return None
line_connections = []
inst_seen = set()
for l in long_lines:
ep1 = Point(l.coords[0])
ep2 = Point(l.coords[-1])
n1 = nearest_inst(ep1)
n2 = nearest_inst(ep2)
if n1 and n2:
i1, d1 = n1
i2, d2 = n2
if i1['tag'] != i2['tag']:
line_connections.append((i1, i2, l.length, d1, d2))
print(f"\n6. LINE endpoint 연결 (≥30u, buffer={BUFFER}):")
print(f" 계기-계기 연결: {len(line_connections)}")
# 같은 loop 내 연결 vs 다른 loop 연결
same_loop = 0
diff_loop = 0
for i1, i2, ll, d1, d2 in line_connections:
if i1['loop'] and i2['loop'] and i1['loop'] == i2['loop']:
same_loop += 1
else:
diff_loop += 1
print(f" 같은 loop: {same_loop}")
print(f" 다른 loop: {diff_loop}")
# ── 7. Loop 기반 통합 연결 ────────────────────────────────────────────
# loop → 가장 가까운 equipment anchor
print(f"\n7. Loop 기반 연결 분석:")
for loop in sorted(loop_inst, key=int):
insts = loop_inst[loop]
# Y범위
ys = [i['y'] for i in insts]
y_min, y_max = min(ys), max(ys)
xs = [i['x'] for i in insts]
x_min, x_max = min(xs), max(xs)
# 가장 가까운 equipment 찾기
cx, cy = sum(xs)/len(xs), sum(ys)/len(ys)
best_eqp = None
best_d = 999
for va in vert_anchors:
if va['y_min'] <= cy <= va['y_max']:
d = abs(cx - va['x'])
if d < best_d and d <= HORIZONTAL_LIMIT:
best_d = d
best_eqp = f"VLINE@{va['x']:.0f}"
for eq in eqp_by_circle:
d = math.hypot(cx - eq['x'], cy - eq['y'])
if d < best_d and d <= HORIZONTAL_LIMIT:
best_d = d
best_eqp = eq['tag']
if best_eqp:
connected = sum(1 for inst in insts if any(inst['tag'] == ii['tag'] for ii, *_ in loop_connected.get(loop, [])))
print(f" Loop {loop}: {len(insts)}개 계기 → {best_eqp} (거리={best_d:.0f}, Y=[{y_min:.0f},{y_max:.0f}], X=[{x_min:.0f},{x_max:.0f}])")
# ── 8. 방향성 부여 + 유향 그래프 + JSON 출력 ──────────────────────────────
# ▶ 마커(V자=우측)는 국소 흐름이 좌→우.
# side='left' 추적대상 = 상류(upstream) → edge: target → marker
# side='right' 추적대상 = 하류(downstream)→ edge: marker → target
# 태그매칭: 같은 태그가 여러 x위치 = off-page connector 연속. x오름차순 체인(좌→우).
import json
try:
import networkx as _nx
_HAVE_NX = True
except ImportError:
_HAVE_NX = False
directed_edges = [] # {from, to, type, basis, weight}
_seen_de = set()
def _add_de(frm, to, typ, basis, w):
if not frm or not to or frm == to:
return
k = (frm, to, typ)
if k in _seen_de:
return
_seen_de.add(k)
directed_edges.append({'from': frm, 'to': to, 'type': typ,
'basis': basis, 'weight': round(w, 1)})
# (1) trace 기반 유향 엣지 (사이클 차단된 marker_traces 사용)
for e in marker_traces:
if e['to_kind'] == 'empty' or not e['to_tag'] or e['to_tag'] == e['from']:
continue
if e['side'] == 'left':
_add_de(e['to_tag'], e['from'], 'trace', 'marker-left(upstream)', e['total_len'])
else:
_add_de(e['from'], e['to_tag'], 'trace', 'marker-right(downstream)', e['total_len'])
# (2) 태그매칭 유향 엣지 (x오름차순 체인)
for tag, ml in multi.items():
ordered = sorted(ml, key=lambda m: m['mx'])
for a, b in zip(ordered, ordered[1:]):
_add_de(f"{tag}@{a['mx']:.0f},{a['my']:.0f}",
f"{tag}@{b['mx']:.0f},{b['my']:.0f}",
'tagmatch', 'offpage-connector(+x)',
abs(b['mx'] - a['mx']))
# 유향 그래프 → 약연결 성분
components_out = []
if _HAVE_NX:
DG = _nx.DiGraph()
for de in directed_edges:
DG.add_edge(de['from'], de['to'])
for comp in sorted(_nx.weakly_connected_components(DG), key=len, reverse=True):
if len(comp) >= 2:
components_out.append(sorted(comp))
out = {
'drawing': DXF_PATH,
'stats': {
'instruments': len(instruments),
'markers': len(markers),
'marker_tag_groups': len(multi),
'trace_edges_raw': len(marker_traces),
'directed_edges': len(directed_edges),
'tagmatch_directed': sum(1 for d in directed_edges if d['type'] == 'tagmatch'),
'trace_directed': sum(1 for d in directed_edges if d['type'] == 'trace'),
'components': len(components_out),
},
'markers': [{'tag': m['tag'], 'x': m['mx'], 'y': m['my'],
'dir': 'right', 'diag': m['dr']} for m in markers],
'equipment': [{'tag': eq['tag'], 'kind': eq['kind'],
'x': round(eq['x'], 1), 'y': round(eq['y'], 1)}
for eq in all_eqp],
'edges': directed_edges,
'components': components_out,
}
OUT_JSON = "mcp-server/storage/No-10_connections.json"
import os as _os
_os.makedirs(_os.path.dirname(OUT_JSON), exist_ok=True)
with open(OUT_JSON, 'w', encoding='utf-8') as f:
json.dump(out, f, ensure_ascii=False, indent=1)
print(f"\n8. 방향성 + JSON 출력:")
print(f" 유향 엣지: {len(directed_edges)}"
f"(태그매칭 {out['stats']['tagmatch_directed']}, trace {out['stats']['trace_directed']})")
print(f" 유향 약연결 성분(≥2): {len(components_out)}")
print(f"{OUT_JSON} ({_os.path.getsize(OUT_JSON)/1024:.0f} KB)")
# ── 요약 ──────────────────────────────────────────────────────────────────
print(f"\n{'='*60}")
print(f"요약")
print(f"{'='*60}")
print(f"Instruments (balloon): {len(instruments)}")
print(f"방향표지판 (marker): {len(markers)}개 (태그있음={sum(1 for m in markers if m['tag'])}개)")
print(f"동일태그 그룹(≥2): {len(multi)}개, 태그매칭 엣지: {len(tag_match_edges)}")
print(f"Equipment anchors: 수직LINE {len(vert_anchors)}개 + 큰원 {len(eqp_by_circle)}개 + 작은원 {len(eqp_by_small)}")
print(f"좌표 근접 연결 (수평≤{HORIZONTAL_LIMIT}): {len(inst_eqp_connections)}개 / {len(instruments)}개 ({len(inst_eqp_connections)/max(len(instruments),1)*100:.1f}%)")
print(f"LINE endpoint 연결 (balloon-balloon): {len(line_connections)}쌍 (같은 loop {same_loop}개)")
print(f"Marker trace 엣지: {len(marker_traces)}")

77
mcp-server/uv.lock generated
View File

@@ -1226,11 +1226,15 @@ dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "openai" },
{ name = "openpyxl" },
{ name = "paddleocr" },
{ name = "paddlepaddle" },
{ name = "pdfplumber" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pymupdf" },
{ name = "python-docx" },
{ name = "pyyaml" },
{ name = "qdrant-client" },
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
@@ -1246,11 +1250,15 @@ requires-dist = [
{ name = "mcp", extras = ["cli"], specifier = ">=1.0.0" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "paddleocr", specifier = ">=2.6.0,<2.7.0" },
{ name = "paddlepaddle", specifier = ">=2.6.0,<3.0.0" },
{ name = "pdfplumber", specifier = ">=0.11.0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
{ name = "pymupdf", specifier = ">=1.24.0" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "qdrant-client", specifier = ">=1.9.0" },
{ name = "scikit-learn", specifier = ">=1.3.0" },
{ name = "sentence-transformers", specifier = ">=3.0.0" },
@@ -2597,6 +2605,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" },
]
[[package]]
name = "pdfminer-six"
version = "20251230"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
]
[[package]]
name = "pdfplumber"
version = "0.11.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pdfminer-six" },
{ name = "pillow" },
{ name = "pypdfium2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
@@ -3156,6 +3191,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pypdfium2"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" },
{ url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" },
{ url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" },
{ url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" },
{ url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" },
{ url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" },
{ url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" },
{ url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" },
{ url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" },
{ url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" },
{ url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" },
{ url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" },
{ url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" },
{ url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -3168,6 +3232,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"

View File

@@ -16,6 +16,7 @@ Usage: python nl2sql_worker.py <port>
from __future__ import annotations
import sys
import os
import re
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -51,6 +52,45 @@ def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
async def _aget_db_connection():
"""비동기 환경에서 안전하게 DB 연결 획득 (blocking connect를 to_thread로 격리)."""
import asyncio
return await asyncio.to_thread(_get_db_connection)
# ── SQL 가드 ─────────────────────────────────────────────────────────────────
SQL_MAX_ROWS = int(os.environ.get("SQL_MAX_ROWS", "1000"))
SQL_STATEMENT_TIMEOUT_MS = int(os.environ.get("SQL_STATEMENT_TIMEOUT_MS", "30000"))
_RE_LIMIT_TAIL = re.compile(r"\bLIMIT\b\s+\d+(\s+OFFSET\s+\d+)?\s*$", re.IGNORECASE)
_DANGEROUS_KW = ('EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY')
def _validate_sql(sql: str) -> tuple[bool, str]:
"""SELECT/WITH만 허용, 위험 키워드/다중 문장 차단."""
if not sql or len(sql) > 2000:
return False, "쿼리가 비어있거나 2000자를 초과했습니다."
upper = sql.upper()
for kw in _DANGEROUS_KW:
if re.search(rf"\b{kw}\b", upper):
return False, f"허용되지 않은 키워드 '{kw}'"
head = upper.lstrip().lstrip('(').lstrip()
if not (head.startswith('SELECT') or head.startswith('WITH')):
return False, "SELECT 또는 WITH 쿼리만 허용됩니다."
if ';' in sql.rstrip().rstrip(';'):
return False, "다중 문장(세미콜론)은 허용되지 않습니다."
return True, ""
def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str:
s = sql.strip().rstrip(';').strip()
if _RE_LIMIT_TAIL.search(s):
return s
return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}"
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
@@ -206,11 +246,17 @@ async def execute(request: Request):
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
"""SQL 실행 (가드: SELECT/WITH만, auto-LIMIT, statement_timeout)."""
valid, err = _validate_sql(sql)
if not valid:
return {"success": False, "error": f"SQL 검증 실패: {err}"}
capped_sql = _apply_sql_guards(sql)
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
cur.execute(capped_sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
@@ -219,6 +265,7 @@ async def _run_sql(sql: str) -> str:
"success": True,
"columns": columns,
"count": len(data),
"row_limit": SQL_MAX_ROWS,
"data": data,
}
else:
@@ -227,6 +274,8 @@ async def _run_sql(sql: str) -> str:
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as e:
return {"success": False, "error": f"SQL 실행 실패: {e}"}
finally:
conn.close()
@@ -235,18 +284,17 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str,
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_db_connection()
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
# TimescaleDB의 time_bucket 함수 사용
cur.execute(
"""
SELECT time_bucket('1 min', ts) AS time, tag_name, value
FROM realtime_table
WHERE tag_name = ANY(%s)
AND ts >= %s
AND ts <= %s
ORDER BY time DESC
SELECT recorded_at AS time, tagname AS tag_name, value
FROM history_table
WHERE tagname = ANY(%s)
AND recorded_at >= %s
AND recorded_at <= %s
ORDER BY recorded_at DESC, tagname
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
@@ -267,22 +315,30 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str,
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
SELECT tagname, livevalue, timestamp, node_id
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
WHERE tagname ILIKE %s
ORDER BY tagname
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
columns = ["tag_name", "current_value", "last_updated", "node_id"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
data = [
{
"tag_name": r[0],
"current_value": r[1],
"last_updated": r[2].isoformat() if r[2] else None,
"node_id": r[3],
}
for r in rows
]
return {
"success": True,
"query": query,
@@ -294,7 +350,7 @@ async def _get_tag_metadata(query: str, limit: int = 10) -> str:
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
@@ -315,14 +371,13 @@ async def _list_drawings(unit_no: str = None) -> str:
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
names = [row[0] for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
"count": len(names),
"names": names,
}
finally:
conn.close()
@@ -336,10 +391,17 @@ async def _query_with_nl(question: str) -> str:
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
# LLM 생성 SQL도 동일 가드 적용
valid, err = _validate_sql(sql)
if not valid:
return {"success": False, "sql": sql, "error": f"SQL 검증 실패: {err}"}
capped_sql = _apply_sql_guards(sql)
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
cur.execute(capped_sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
@@ -349,6 +411,7 @@ async def _query_with_nl(question: str) -> str:
"sql": sql,
"columns": columns,
"count": len(data),
"row_limit": SQL_MAX_ROWS,
"data": data,
}
else:

View File

@@ -59,13 +59,17 @@ Examples: PG-101, TG-201, LG-301, PG-10101, TG-10201
# 펌프: P-10101, VP-10117, DP-10101 등
_PUMP_PROMPT = _PROMPT_HEADER + """
Extract ONLY pumps and compressors.
Extract ONLY pumps and compressors (simple equipment tags, NO pipe size suffix).
Target equipment types: P (pump), VP (vertical pump), DP (dual pump),
C (compressor), CP (centrifugal pump), BP (booster pump),
Target equipment types: P (pump), VP (vertical pump), DP (dual pump),
C (compressor), CP (centrifugal pump), BP (booster pump), SP (sump pump),
and their variants.
Examples: P-10101, VP-10117, DP-10101, C-10201, CP-10301, BP-10401
Examples (4~5 digit loop numbers): P-10101, VP-10117, DP-10101, C-10201, P-9101, P-9116, VP-9201
IMPORTANT: Do NOT extract pipeline/line numbers that have a pipe size suffix (e.g. 25A, 50A, 100A).
SKIP (pipeline, not a pump): P-10101-25A-F1A-n, P-9107-25A-F-n, CHR-9641-50A-F-C50
INCLUDE (pump tag): P-10101, VP-10117, P-9101
"""
# 프롬프트 매핑

View File

@@ -1,17 +1,37 @@
{
"permission": "allow",
"$schema": "https://opencode.ai/config.json",
"model": "vllm-35b/Qwen3.6-35B-A3B-FP8",
"provider": {
"vllm": {
"vllm-27b": {
"npm": "@ai-sdk/openai-compatible",
"name": "vLLM (local)",
"name": "Qwen 27B",
"options": {
"baseURL": "http://localhost:8000/v1"
},
"models": {
"google/gemma-4-31B-it-vllm-fp8-dflash-16k": {
"name": "Gemma 4 31B dflash",
"Qwen3.6-27B-FP8": {
"name": "Qwen 3.6 27B FP8",
"reasoning": true,
"limit": {
"context": 16384,
"context": 131072,
"output": 8192
}
}
}
},
"vllm-35b": {
"npm": "@ai-sdk/openai-compatible",
"name": "Qwen 35B A3B",
"options": {
"baseURL": "http://localhost:8001/v1"
},
"models": {
"Qwen3.6-35B-A3B-FP8": {
"name": "Qwen 3.6 35B A3B FP8",
"reasoning": true,
"limit": {
"context": 131072,
"output": 8192
}
}

View File

@@ -0,0 +1,15 @@
파워포인트 파일 작성, 아래 요구사항에 따라서
1) AX 도입의 필연성 및 효율성 (1 페이지)
2) AX화 를 위해서 전산팀(소프트웨어 개발 부서)외의 현업 부서에서 해야할 사전 준비 항목 서술
- 신규 프로젝트 시 ,
- DXF, PDF GRAPH화를 위한 캐드 도면 작성 규칙
- 문서 작성 규칙
- 기존 프로젝트 RAG화 방안
- 좋은 방법 제안해줘
- 현실적인 OCR 등의 문제(기존 도면 및 문서의 표준 PDF, DXF 화 불가능시)를 감안
- 다른 관점에서도 제안
3) 현재 ExperionCrawler 소개
4) 위의 AX화를 위한 문제점을 ExperionCrawler에서 어떻게 보완하고 있는가 설명
5) 너의 의견 및 제안 환영함!!!

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,288 @@
현재까지 작업한 것의 사용자가 해야할 일 정리해줘
● 사용자 액션 가이드 (Phase 0~5 구현물 사용 절차)
1. 사전 환경 셋업 (1회만)
1.1 Python 의존성 추가 설치 — Phase 2에서 openpyxl/python-docx/pdfplumber를 추가했으므로
mcp-server 가상환경에 설치 필요:
cd ~/projects/ExperionCrawler/mcp-server
uv pip install -e .
1.2 외부 서비스 동작 확인 — 셋 다 떠 있어야 함:
curl -s http://localhost:6333/collections | jq # Qdrant
curl -s http://localhost:11434/api/tags | jq # Ollama (nomic-embed-text 필요)
curl -s http://localhost:8000/v1/models | jq # vLLM (Qwen3.6-27B-FP8)
- Ollama에 nomic-embed-text 모델이 없으면: ollama pull nomic-embed-text
1.3 (선택) 관리자 초기 비번을 환경변수로 미리 설정 — 콘솔 로그에 비번 노출이 싫으면:
export KB_ADMIN_INITIAL_PASSWORD='원하는초기비번'
설정 안 하면 첫 부팅 시 자동 생성 후 콘솔에 1회 출력.
---
2. 첫 부팅
2.1 mcp-server 재시작 — 새 도구(parse_document, search_kb) 등록:
# 기존 프로세스 종료 후
cd ~/projects/ExperionCrawler/mcp-server
# 기존 기동 방식 그대로 (예: uv run / python server.py --http 등)
2.2 ExperionCrawler 앱 빌드/기동:
cd ~/projects/ExperionCrawler
dotnet build src/Web/ExperionCrawler.csproj
dotnet run --project src/Web/ExperionCrawler.csproj
# 또는 deploy.sh 사용
2.3 콘솔 로그 확인 — 다음 두 줄이 보이는지:
[Kb] 관리자 초기 비밀번호 자동 생성: XXXXXXXXXXXXXXXX ← 즉시 변경하세요
[Kb] Qdrant 컬렉션 ensure 완료: 5건
랜덤 비번을 즉시 메모. (env var 사용했다면 "환경변수 사용" 로그 1줄만 출력)
2.4 Qdrant 컬렉션 5개 생성 확인:
curl -s http://localhost:6333/collections | jq '.result.collections[] | .name'
# kb_system_instrument, kb_plant_operation, kb_procedure, kb_report, kb_vendor_doc
포함되어야 함
---
3. 관리자 첫 로그인 + 비번 변경 (필수)
1. 브라우저 → http://localhost:5000 → 사이드바 14 RAG 관리 클릭
2. 콘솔에서 받은 초기 비번 입력 → 로그인
3. 상단 [비밀번호 변경] → 새 비번 (6자 이상) 설정 → 자동 로그아웃
4. 새 비번으로 재로그인
---
4. (선택) plant_context.md 작성
prompts/plant_context.md는 현재 빈 골격입니다. 채워두면 채팅의 시스템 프롬프트에 자동
주입되어 답변 품질이 좋아집니다:
nano prompts/plant_context.md
채울 항목 (파일 안 주석 참고):
- 단위 (Unit A: 압축, Unit B: 분리 …)
- 계기 prefix 약어 (FIC/PT/TI/XV …)
- 태그 명명 규칙 (소문자, .pv/.sp/.op/.instate0~7)
- 시간대 (UTC 저장, KST 표시)
- 예시 질문 → 도구 매핑
수정 후 앱 재기동 없이 즉시 반영됩니다 (요청 시마다 파일 읽음).
---
5. KB 문서 업로드 워크플로우
1. 14번 탭 → [📁 파일 업로드] 클릭
2. 컬렉션 선택 (5종 중 1개):
- system_instrument — 계기 datasheet, P&ID 사양서
- plant_operation — 재고, 생산현황, 정비이력, 교대일지
- procedure — SOP, 정비 절차, 알람 대응 매뉴얼
- report — 일/주/월 보고서
- vendor_doc — 벤더 카탈로그, 매뉴얼
3. 제목(기본: 파일명) / 태그(콤마 구분, 예: unit-a, P-6201) 입력
4. 파일 선택 → [업로드]
5. 목록에 새 행이 pending 상태로 등장 → 1.5초마다 자동 갱신 → parsing → embedding → indexed
6. 실패(failed) 시 해당 행의 에러 메시지 확인, [↻] 재인덱스 가능
지원 형식: .xlsx / .xlsm / .pdf / .docx / .md / .txt
---
6. 채팅에서 KB 활용
1. 사이드바 13 로컬 LLM 채팅 클릭
2. LLM 종류 = vLLM 선택 (tool 사용은 vLLM 전용)
3. 모델 = Qwen3.6-27B-FP8 선택
4. MCP 도구 ✓ 체크박스 켜기
5. welcome 화면의 추천 칩 중 하나 클릭 (예: "P-6201 펌프의 정비 이력") → 자동으로 입력창에
채워짐 → Enter
6. 진행 상황 가시화:
- 회색 spinning 카드: 툴 호출 시작
- 녹색 ✓: 성공 + 결과 (클릭하면 전체 펼침, 표/KB hits 자동 렌더)
- 빨간 ✗: 실패 + 에러 메시지
7. KB에서 가져온 문서 제목이 답변 본문에 점선 밑줄 링크로 자동 표시됨 → 클릭하면 원본 파일
다운로드
---
7. 검증 (옵션)
항목: 작은 txt 업로드 → 30초 내 indexed
확인 방법: 14번 탭에서 상태 칩 색상 변화
────────────────────────────────────────
항목: 큰 PDF 실패 시 재시도 동작
확인 방법: jobs 테이블 attempts 누적 (앱 콘솔 로그)
────────────────────────────────────────
항목: 세션 60분 자동 만료
확인 방법: 60분 방치 후 동작 시 재로그인 요구
────────────────────────────────────────
항목: KB 인용 다운로드
확인 방법: search_kb 호출 후 답변 안의 제목 링크 클릭
────────────────────────────────────────
항목: Qdrant 청크 확인
확인 방법: curl http://localhost:6333/collections/kb_plant_operation | jq
'.result.points_count'
---
8. 운영 팁
- 재인덱스: 청킹 정책을 바꿨거나 파일 손상 시 → 행의 [↻] 버튼
- 임시 숨김: 잘못 올렸지만 보관은 유지 → [🚫] (비활성화, 검색 결과에서 제외)
- 영구 삭제: [✖] 또는 하단 [비활성화 영구삭제(90일 이상)]
- 동일 제목 일괄 비활성화: 재업로드 후 구버전 한 번에 비활성화 — 하단 [🚫 동일 제목 일괄
비활성화]
- 로그 위치: mcp-server/logs/, dotnet 콘솔 — [Kb] [Kb][Worker] [Kb][Qdrant] 접두사로 grep
---
9. 알려진 제약 / 다음 단계
- 시계열 시각화는 표만 자동 렌더됨 (스파클라인 보류)
- 툴 실행 카드는 스트리밍 중에만 보임 — 메시지 영구 보존은 Phase 6 이후
- 이벤트/알람 도구 (active_alarms 등), find_tags, generate_status_report 매크로는 Phase
6에서 추가 예정 — 현재 추천 칩 중 일부는 모델이 직접 SQL을 짜서 답변
- run_sql 안전장치 (자동 LIMIT, statement_timeout)도 Phase 6
---
막힘이 있는 단계가 있으면 어디서 멈췄는지 알려주시면 함께 보겠습니다.
1. KbStorageService.SaveAsync — SHA256 TransformBlock 사용 (MED)
문제: SHA256.TransformBlock() / TransformFinalBlock()은 .NET 8에서 Deprecated되었으며, 스트리밍 입력에서 단일 패스에서 SHA256 계산 시 버퍼 경계 처리가 정확하지 않을 수 있다. 특히 input.ReadAsync(buffer.AsMemory(0, buffer.Length))로 읽은 데이터를 TransformBlock(buffer, 0, read, null, 0)에 넘기는데, TransformBlock은 내부 버퍼링을 하며 마지막 블록에서 TransformFinalBlock을 호출해야 하는데, 여기서는 빈 배열을 전달한다. 이 자체는 동작하지만 SHA256.Hash가 TransformFinalBlock 호출 후에만 유효하므로 로직상 문제는 없다. 하지만 SHA256.Create()의 인스턴스가 using 처리되지 않아 리소스 누수 가능성이 있다.
근거: KbStorageService.cs:36 — using var sha = SHA256.Create(); → 실제로는 using이 적용되어 있음.
교차 검증: 실제 코드를 다시 확인하니 using var sha = SHA256.Create();로 되어 있고 TransformFinalBlock(Array.Empty<byte>(), 0, 0) 호출 후 sha.Hash를 참조함. Q1 통과 — 이미 올바르게 구현됨. 보고서에서 제거.
2. KbIngestWorker — FOR UPDATE SKIP LOCKED 미사용 (MED)
문제: 플랜 3.7절에서 "FOR UPDATE SKIP LOCKED 큐 폴링(2초)"으로 명시되어 있으나, 실제 구현은 WHERE j.FinishedAt == null && j.Stage == "parse" && j.Attempts < _maxAttempts + OrderBy(j => j.EnqueuedAt).FirstOrDefaultAsync()로 단순 SELECT 후 업데이트 패턴이다. 동시에 여러 인스턴스가 실행되면 같은 잡을 중복으로 처리할 수 있다.
근거: KbIngestWorker.cs:74-77
교차 검증:
Q2: 다른 레이어에서 처리되는가? → Program.cs에서 KbIngestWorker는 단일 AddHostedService로 등록됨. 단일 인스턴스만 실행되므로 동시성 문제는 발생하지 않음.
Q3: 의도적 설계인가? → 플랜에는 SKIP LOCKED가 명시되었으나, 단일 인스턴스 아키텍처에서는 불필요함.
Q2/Q3 통과 — 보고서에서 제거.
3. KbQdrantClient — HttpClient 직접 생성 (LOW)
문제: KbQdrantClient 생성자에서 new HttpClient를 직접 생성하고 있다. .NET best practice는 IHttpClientFactory를 사용하는 것이다. 현재 Singleton으로 등록되므로 단일 인스턴스에서는 문제가 없으나, using 처리되지 않아Dispose 누수 가능성이 있다.
근거: KbQdrantClient.cs:21 — new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) }
교차 검증:
Q2: KbQdrantClient가 Singleton으로 등록되어 수명주기 전체에서 하나의 HttpClient를 사용하므로 소켓 고갈 문제는 없음.
Q4: 실제 장애 시나리오? → 앱 종료 시 HttpClient가 명시적으로 Dispose되지 않지만, 프로세스 종료 시 OS가 정리하므로 실제 영향 없음.
Q2/Q4 통과 → LOW 강등
3. KbQdrantClient — HttpClient 직접 생성 (LOW)
문제: KbQdrantClient.cs:21 — new HttpClient 직접 생성. IHttpClientFactory 미사용.
영향: Singleton 등록이므로 단일 인스턴스에서는 소켓 고갈 없음. 하지만 명시적 Dispose가 없어 이론적 리소스 누수. 프로세스 종료 시 정리됨.
수정: Program.cs에서 AddHttpClient("KbQdrant") 팩토리 등록 후 KbQdrantClient에 주입
4. nl2sql_worker.py — 비동기 함수 내 blocking DB 연결 (MED)
문제: _run_sql, _query_pv_history, _get_tag_metadata, _list_drawings, _query_with_nl 함수들이 async def로 선언되어 있으나, 내부에서 _get_db_connection()을 asyncio.to_thread 없이 직접 호출한다. psycopg3의 connect()는 blocking TCP 호출이며, 이벤트 루프를 블로킹한다.
근거: nl2sql_worker.py:208-210 — conn = _get_db_connection() (async 함수 내에서 blocking)
교차 검증:
Q1: 이미 수정된 문제인가? → server.py의 _get_db_connection()은 asyncio.to_thread로 감싸져 있으나, nl2sql_worker.py는 별도 워커로 독립 구현되어 있고 여기서는 감싸지 않음.
Q2: 다른 레이어에서 처리되는가? → 이 워커는 FastAPI + uvicorn으로 독립 실행되며, /execute 엔드포인트가 HTTP 요청을 처리한다. 이벤트 루프 블로킹은 동시 HTTP 처리 성능에 영향을 줄 수 있음.
Q4: 실제 시나리오? → 여러 HTTP 요청이 동시 도착 시 DB 연결 대기 중 이벤트 루프 블로킹으로 다른 요청 지연.
Q1/Q2/Q4 통과 — 유지
4. nl2sql_worker.py — 비동기 함수 내 blocking DB 연결 (MED)
문제: async def 함수 내에서 psycopg.connect()를 asyncio.to_thread 없이 직접 호출하여 이벤트 루프 블로킹.
근거: nl2sql_worker.py:210 (_run_sql), 238 (_query_pv_history), 270 (_get_tag_metadata), 303 (_list_drawings), 346 (_query_with_nl)
영향: 동시 HTTP 요청이 여러 개 들어올 때 DB 연결 대기 중 이벤트 루프 블로킹 → 응답 지연
수정: 각 함수에서 conn = await asyncio.to_thread(_get_db_connection)으로 변경
5. nl2sql_worker.py:_list_drawings — 데이터 파싱 버그 (HIGH)
문제: cur.fetchall() 결과가 (name,) 튜플인데, dict(zip(columns, row[0]))로 처리하고 있다. row[0]는 문자열(name 컬럼 값)이며, 문자열은 이터러블이므로 각 문자가 개별 값으로 분리되어 {"name": "f"} 같은 잘못된 결과가 생성된다.
근거: nl2sql_worker.py:327 — data = [dict(zip(columns, row[0])) for row in rows]
교차 검증:
Q1: 이미 수정된 문제인가? → 확인함. 아직 수정되지 않음.
Q4: 실제 시나리오? → list_drawings 도구 호출 시 반환된 names 배열이 각 문자로 분리된 객체가 됨 → 프론트엔드에서 렌더링 불가.
Q1/Q4 통과 — 유지
5. nl2sql_worker.py:_list_drawings — 데이터 파싱 버그 (HIGH)
문제: row[0]가 문자열인데 dict(zip(columns, row[0]))로 전달 → 문자열이 문자 단위 이터레이션되어 잘못된 dict 생성.
근거: nl2sql_worker.py:327
영향: list_drawings 도구 호출 시 names 필드가 {"name": "f"} 같은 잘못된 데이터 반환
수정: dict(zip(columns, row)) 또는 단순히 [row[0] for row in rows]로 변경
6. OllamaController — plant_context.md 매 요청마다 파일 읽기 (LOW)
문제: LoadPlantContext()는 매 호출마다 File.ReadAllText()를 수행한다. ComposeSystemPrompt()는 모든 채팅 요청마다 호출되며, 이는 불필요한 디스크 I/O를 발생시킨다.
근거: OllamaController.cs:76-89 — LoadPlantContext(), 호출 위치 cs:147
교차 검증:
Q3: 의도적 설계인가? → 파일 변경을 실시간으로 반영하기 위한 것으로 보이나, plant_context.md는 운영 중 자주 변경되지 않음.
Q4: 실제 장애 시나리오? → 파일 읽기는 빠르지만 고빈도 채팅 시 누적 오버헤드. 캐싱으로 개선 가능하나 동작에는 영향 없음.
Q3/Q4 → LOW 강등
6. OllamaController — plant_context.md 매 요청 파일 읽기 (LOW)
문제: LoadPlantContext()가 ComposeSystemPrompt() 호출마다 File.ReadAllText() 실행.
근거: OllamaController.cs:76-89
영향: 고빈도 채팅 시 불필요한 디스크 I/O. 동작에는 영향 없음.
수정: Lazy<string> 또는 MemoryCache로 캐싱 (파일 변경 감지 옵션)
7. KbAuthService.EnsureCredentialAsync — 자동 생성 비밀번호 로깅 (LOW)
문제: 자동 생성된 초기 비밀번호가 _logger.LogWarning()로 평문 로깅된다. 로그 파일이 적절히 보호되지 않은 환경에서는 비밀번호 노출 위험이 있다.
근거: KbAuthService.cs:64 — _logger.LogWarning("[Kb] 관리자 초기 비밀번호 자동 생성: {Pw} ← 즉시 변경하세요", initial)
교차 검증:
Q3: 의도적 설계인가? → 환경변수가 설정되지 않은 경우 사용자에게 비밀번호를 전달할 방법이 로그밖에 없으므로 의도적임.
Q4: 실제 시나리오? → 로그 파일이 여러 사용자에게 노출되는 환경에서만 문제.
Q3/Q4 → LOW 강등
7. KbAuthService — 자동 생성 비밀번호 평문 로깅 (LOW)
문제: 자동 생성 비밀번호가 로그에 평문으로 기록됨.
근거: KbAuthService.cs:64
영향: 로그 파일 접근 권한이 느슨한 환경에서 비밀번호 노출. 한 번만 기록되며 즉시 변경 요구.
수정: 부분 마스킹({Pw[:4]}****) 또는 콘솔 출력 전용
8. KbIngestWorker — 임베딩 실패 시 전체 실패 (LOW)
문제: 단일 청크의 임베딩 실패(vec == null) 시 전체 문서 인덱싱이 실패한다. 부분 실패 처리가 없어 일부 청크만 문제가 있어도 전체가 재시도된다.
근거: KbIngestWorker.cs:123 — if (vec == null) throw new Exception("임베딩 실패(부분)")
교차 검증:
Q3: 의도적 설계인가? → 임베딩 모델(Ollama)이 응답하지 않는 상태에서는 부분 인덱싱보다 전체 실패가 더 안전한 선택.
Q4: 재시도 로직이 attempts 기반이므로 3회 시도 후 failed 상태로 전환됨.
Q3/Q4 → LOW 강등
8. KbIngestWorker — 단일 청크 임베딩 실패 시 전체 실패 (LOW)
문제: 청크 1개 임베딩 실패 시 전체 문서 인덱싱 abort.
근거: KbIngestWorker.cs:123
영향: 대용량 문서에서 일부 청크만 문제 있어도 전체 재시도. 3회 시도 후 failed 전환.
수정: 실패 청크 제외하고 나머지 인덱싱 진행 (선택적)
요약
# 심각도 항목 상태
5 🔴 HIGH nl2sql_worker.py:_list_drawings 데이터 파싱 버그 수정 필요
4 🟠 MED nl2sql_worker.py blocking DB 연결 (5개 함수) 수정 필요
3 🟡 LOW KbQdrantClient HttpClient 직접 생성 개선 권장
6 🟡 LOW OllamaController plant_context.md 매 요청 파일 읽기 개선 권장
7 🟡 LOW 자동 생성 비밀번호 평문 로깅 개선 권장
8 🟡 LOW 단일 청크 임베딩 실패 시 전체 실패 개선 권장

View File

@@ -64,20 +64,20 @@
### 2.1 채팅 페이지 개선 방향 (별도 사용자 결정 대기 항목 있음)
| # | 항목 | 합의 |
|---|---|---|
| C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 |
| C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 |
| C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 |
| C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 |
| C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 |
| C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 |
| C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 |
| C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 |
| C9 | NL2SQL 의도 라우터 | 검토 후 결정 |
| C10 | 대화 요약/압축 | 후순위 |
| C11 | 에이전트 모드(자율 멀티스텝) | 후순위 |
| C12 | 위 1.3 결함 픽스 | 즉시 진행 |
| # | 항목 | 합의 | 상태 (2026-05-13) |
|---|---|---|---|
| C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 | ✅ 완료 (Phase 5.5) |
| C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 | ✅ 완료 (Phase 0.3, 빈 골격 생성) |
| C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 | ⏳ Phase 6.1 |
| C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 | ✅ 완료 (Phase 5.1, 5.2) |
| C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 | ✅ 표 완료 / 스파클라인 보류 (Phase 5.4) |
| C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 | ⏳ Phase 6.3 |
| C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 | ⏳ Phase 6.2 |
| C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 | ⏳ Phase 6.4 |
| C9 | NL2SQL 의도 라우터 | 검토 후 결정 | ⏳ 보류 (Phase 7.1) |
| C10 | 대화 요약/압축 | 후순위 | ⏳ 보류 (Phase 7.2) |
| C11 | 에이전트 모드(자율 멀티스텝) | 후순위 | ⏳ 보류 (Phase 7.3) |
| C12 | 위 1.3 결함 픽스 | 즉시 진행 | ✅ 완료 (Phase 0.1, 0.2) |
### 2.2 지식 증강(RAG ingest) 결정
@@ -477,56 +477,51 @@ rag_query(question: str,
## 4. 구현 순서 (Todo)
### Phase 0 — 사전 정비 (반나절)
0.1 `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정
0.2 `mcp-server/llm-model.json` 모델명을 실제 vLLM 서빙명과 일치
0.3 `OllamaController.cs:608` 시스템 프롬프트 한글화 + plant_context.md 외부 파일화
### Phase 0 — 사전 정비 (반나절) ✅ 완료
0.1 `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정 (history_table.recorded_at 사용, `_get_tag_metadata`도 같이 수정)
0.2 `mcp-server/llm-model.json` — 실제 서빙 `Qwen3.6-27B-FP8`과 이미 동기화 (memory만 갱신)
0.3 `OllamaController.ComposeSystemPrompt(...)` — 한글 base + `prompts/plant_context.md`(빈 골격) + tool guide 합성, app.js 영문 하드코딩 제거
### Phase 1 — 데이터 모델 & 인증 (1일)
1.1 PostgreSQL 마이그레이션: `kb_collections`, `kb_documents`, `kb_ingest_jobs`,
`kb_admin_credential`, `kb_admin_sessions` 테이블 생성
1.2 시드 데이터 INSERT: kb_collections 5건 (system_instrument, plant_operation, procedure,
report, vendor_doc)
1.3 Qdrant 컬렉션 5개 생성 (kb_system_instrument, kb_plant_operation, kb_procedure,
kb_report, kb_vendor_doc) — 임베딩 차원에 맞춰
1.4 `KbAuthController` (login/logout/status/change-password) + Argon2 해시 유틸
1.5 첫 실행 시 초기 비번 시드 로직
### Phase 1 — 데이터 모델 & 인증 (1일) ✅ 완료
1.1 ✅ DDL 5개 + 시드 5건 (ExperionDbContext.InitializeAsync 자동 적용, pgcrypto 활성화)
1.2 ✅ KbStartupService — 부팅 시 활성 컬렉션 5개 Qdrant idempotent ensure (`KbQdrantClient.EnsureCollectionAsync`)
1.3 ✅ KbAuthController (login/logout/status/change-password) + `PasswordHasher` (Konscious Argon2id), `X-Kb-Token` 헤더, 초기 비번 env or 자동 생성
### Phase 2 — 업로드 & 비동기 워커 (2일)
2.1 `KbController.Upload` — multipart 수신storage 저장 → kb_documents/kb_ingest_jobs INSERT
2.2 `KbIngestWorker (BackgroundService)` — 큐 폴링 + 단계별 처리
2.3 MCP `parse_document` — xlsx (행+시트), pdf (섹션+표), docx, md/txt
2.4 MCP `_embed` 배치 호출 + Qdrant upsert (collection_key 기반 라우팅)
2.5 `KbController.Documents/Jobs` — 목록·상세·진행률 폴링
### Phase 2 — 업로드 & 비동기 워커 (2일) ✅ 완료
2.1 KbController.Upload — multipart 수신, `storage/kb/{yyyy-MM}/{uuid}.{ext}` 저장, SHA256, kb_documents/kb_ingest_jobs INSERT
2.2 KbIngestWorker — 2초 폴링, parse→embed→index 단일 패스, attempts ≥3=failed
2.3 MCP `parse_document` + `parsers/{xlsx,pdf,docx,text}_parser.py` (행+시트 / 페이지+표 / 헤딩 path / md헤딩·txt단락)
2.4 ✅ KbEmbeddingClient (Ollama nomic-embed-text 768-dim) + KbQdrantClient.UpsertAsync (collection_key 라우팅)
2.5 ✅ Documents/Jobs/Download/Delete/Reindex/Disable/BulkDisable/PurgeDisabled
### Phase 3 — 관리 탭 #14 (1일)
3.1 사이드바 14번 탭 추가, `<section id="pane-kbadmin">` 신설
3.2 비번 입력 → 토큰 받아 sessionStorage 저장
3.3 컬렉션 필터, 상태/태그 필터, 검색
3.4 업로드 모달(드래그앤드롭 + collection_key 드롭다운 + 태그)
3.5 목록, 상세 보기, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제
3.6 1초 폴링으로 ingesting 진행률 표시
### Phase 3 — 관리 탭 #14 (1일) ✅ 완료
3.1 사이드바 14번 + pane-kbadmin
3.2 비번 로그인 → sessionStorage 토큰 (X-Kb-Token)
3.3 컬렉션/상태/제목 필터
3.4 업로드 모달 (collection_key 드롭다운 강제 + 제목 + 태그) — 드래그앤드롭은 후순위
3.5 목록, 다운로드, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제
3.6 ✅ 1.5초 폴링 (pending/parsing/embedding 있을 때만 새로고침)
### Phase 4 — 다운로드 & 검색 (반나절)
4.1 `/api/kb/download/{docId}` 원본 스트림, Content-Disposition
4.2 MCP `search_kb`다중 컬렉션 + uploaded_at 최신 가중치 + 태그 필터
4.3 기존 `rag_query` 확장: `search_kb` 통합 옵션
### Phase 4 — 다운로드 & 검색 (반나절) ✅ 완료
4.1 /api/kb/download/{docId} — Content-Disposition + MIME (KbController.Download)
4.2 MCP `search_kb``_search_kb_collection`(태그 filter), `_recency_factor`(7d+10%/30d+5%/90d+2%), `_search_kb_raw`(다중 컬렉션 + since 후필터 + doc_id::locator dedup)
4.3 rag_query 확장 `search_kb`, `kb_collections` 인자 + `_format_kb_results` 인용 직렬화
### Phase 5 — 채팅 통합 (1~2일)
5.1 SSE 이벤트 추가: `tool_start`, `tool_result` (백엔드 `VllmChatStreamWithTools` 안)
5.2 프론트 채팅 메시지에 툴 실행 카드 렌더 (접이식)
5.3 모델 인용 자동 → 다운로드 링크 치환
5.4 테이블/시계열 자동 렌더 (`{success, columns, data}` JSON 감지)
5.5 추천 질문 칩(welcome 화면)
5.6 system prompt 합성 로직 (plant_context.md + 도구 가이드 + 사용자 입력)
### Phase 5 — 채팅 통합 (1~2일) ✅ 완료
5.1 SSE `tool_start`/`tool_result` 이벤트 — `EmitToolStart/EmitToolResult` 헬퍼, 공식 tool_calls 경로 + JSON-텍스트 폴백 경로 둘 다 발행
5.2 툴 실행 카드 (접이식, running/ok/err 색상, spin 애니메이션)
5.3 ✅ KB 인용 자동 링크`llmKbDocMap`에 search_kb hits title→docId 누적, `llmLinkKbCitations`로 본문 치환
5.4 ✅ 표 자동 렌더 (`{success, columns, data}` `data:[{...}]` 감지, 최대 50행) / 스파클라인은 보류
5.5 추천 질문 칩 7종 (활성 알람, Unit A 요약, FIC-6113 추이, 디지털 이벤트, 정비 이력, 주간 보고, find_tags)
5.6 ✅ Phase 0.3에서 이미 완료
### Phase 6 — 보강 도구 (1일)
### Phase 6 — 보강 도구 (1일) ⏳ 미구현
6.1 MCP `query_events`, `summarize_events`, `active_alarms` (event_history_table 기반)
6.2 MCP `find_tags` — tag_metadata 시맨틱 검색 (별도 Qdrant 컬렉션 또는 KB와 통합)
6.3 MCP `generate_status_report` — 매크로 툴
6.4 `run_sql` LIMIT 자동 + `SET LOCAL statement_timeout = 10s`
### Phase 7 — 운영 보강 (옵션)
### Phase 7 — 운영 보강 (옵션) ⏳ 미구현
7.1 NL2SQL 의도 라우터
7.2 대화 요약/압축 (장기 세션)
7.3 에이전트 모드 (자율 멀티스텝 계획)

View File

@@ -0,0 +1,591 @@
# P&ID 추출 데이터 이용방안 플랜
작성일: 2026-05-14
배경: `pid_equipment` 테이블에 No-10_Plant_PID.dxf로부터 460건의 구조화 데이터(태그·라인번호·fluid·종류·area) 적재 완료. Experion realtime_table과의 자동 매핑(exact + prefix only, false positive 제거)도 동작 중. 다음 단계로 이 데이터를 무엇에 쓸지 결정.
## 현재 보유 데이터
| 컬럼 | 의미 | 예시 |
|------|------|------|
| `tag_no` | 추출된 원본 태그/라인번호 | `P-10138-600A-F2A-H100`, `PSV-10217`, `FCV-6113` |
| `instrument_type` | prefix 분류 | `FCV`, `PSV`, `P`, `T`, `BT`, `LIA` |
| `line_number` | LineNo 식별번호 (pipe만) | `10138`, `6203` |
| `confidence` | 추출 신뢰도 | 0.95~0.99 |
| `experion_tag_id` | realtime_table FK (매핑 시) | NULL or int |
| `is_active` | 활성 여부 | true/false |
서버 내부 사전(코드)으로 `_PID_FLUID_DICT`(P=PROCESS, CWS=COOLING WATER SUPPLY 등 19종), `_PID_EQUIPMENT_PREFIX`(P=Pump, T=Tank, F=Filter 등 14종), `_PID_INSTRUMENT_FIRST`/`_PID_INSTRUMENT_MODIFIER`(ISA letter 코드)도 함께 보유.
---
## 갈래 1 — 채팅 컨텍스트 보강 (최우선 추천)
**가치**: 운전원이 즉시 체감. 작업량 최소.
**작업량**: 0.5~1일.
### 구현
MCP 도구 2개 추가 (mcp-server/server.py):
```python
@mcp.tool()
async def find_pid_equipment(query: str, kind: str | None = None,
area: str | None = None, limit: int = 20) -> str:
"""P&ID 장비/계기 검색. tag_no ILIKE 매칭 + kind/area 필터.
kind: 'pipe'|'equipment'|'instrument' (없으면 전체)
area: 6-1 → tag_no가 P-61XX/T-61XX 등 6+1로 시작
"""
@mcp.tool()
async def get_pid_equipment_info(tag_no: str) -> str:
"""단일 태그 상세. 매핑된 Experion 태그도 함께 반환.
응답: {tag_no, type(fluid 또는 장비종류), instrument_type, line_number,
experion_tag, latest_value (있으면)}
"""
```
`ToolGuideKo`(OllamaController.cs)에 새 도구 설명 추가 → LLM이 자동 활용.
### 효과 예시
- "PSV-10217이 뭐야?" → "Pressure Safety Valve (mechanical, OPC 신호 없음). 10차 area"
- "10차에 펌프 몇 대?" → `find_pid_equipment(kind='equipment', area='10')` → 카운트 + 리스트
- "P-10138 어떤 fluid?" → "PROCESS FLUID, 600A, F2A spec, H100 단열"
- "FCV-6113 지금 값은?" → 매핑된 experion 태그로 자동 join → 실시간값
---
## 갈래 2 — 알람·이벤트 컨텍스트 자동 주입
**가치**: 알람 보고서가 풍부해짐. 운전원이 태그 코드만 보고 의미 파악 가능.
**작업량**: 0.5일. 기존 `summarize_events`/`generate_status_report`에 join 추가.
### 구현
- `query_events` 결과를 `pid_equipment`와 LEFT JOIN해서 각 이벤트에 PID 컨텍스트 첨부
- 예: ALARM event `tagname=fcv-6113.pv` → 컨텍스트 "FCV-6113, Flow Control Valve, 6-1 area, P-10138 라인의 PROCESS 흐름 제어"
- `generate_status_report` 마크다운 출력에 "## 영향 장비" 섹션 추가 (해당 area의 PID 인벤토리 요약)
### 효과
- 알람 요약 LLM이 "단순 태그 나열" → "공정 맥락 포함된 운전 보고서"로 격상
- KB 문서에서 같은 area의 절차서 자동 인용 (search_kb area 필터 + PID 데이터 연동)
---
## 갈래 3 — P&ID 다이어그램 시각화 (UI)
**가치**: 시각적 자산 파악. 도면 없이도 area별 장비 구조 한눈에 보기.
**작업량**: UI 작업 중심. 그래프 보기까지 가려면 from-to 추출 추가 필요.
### Phase 3a (즉시 가능)
현재 데이터만으로 — area별·종류별 트리뷰/리스트뷰:
```
└─ 10차 area
├─ 펌프 (15) P-10101, P-10116, …
├─ 탱크 (19) T-10101, T-10102, …
├─ 필터 (4) F-10101A, F-10101B, …
├─ 안전밸브 (18) PSV-10101, PSV-10103, …
└─ 라인 (172) P-10138, CWS-10612, …
```
14번 P&ID 탭 또는 신규 탭에서 구현.
### Phase 3b (확장, 작업 큼)
DXF에서 라인 끝점 좌표 + 장비 좌표 추출 후 KD-tree로 from-to 추정 → cytoscape/d3로 노드 그래프. 사용자 이미 결정한 "위치좌표 계산 없이" 방향과 상충 — **추후 필요해질 때만 착수**.
---
## 갈래 4 — 자산 마스터 + KB 통합 (장기 그림)
**가치**: 단순 검색을 넘어 정비·운영 데이터 통합 플랫폼.
**작업량**: 큼. 다른 데이터 소스 통합 필요.
### 구성요소
1. **equipment_registry.yaml** (이전 논의) — Experion htm에서 추출한 좌표·area + PID 추출 데이터 머지
2. **정비/점검 일정 테이블** — 종류별 주기 정의 (PSV 1년, FCV 6개월, …) → 다음 점검일 자동 계산
3. **누락 검출 리포트**`pid_equipment`(P&ID 기준) vs `realtime_table`(Experion 기준) 양방향 차집합
- P&ID에만 있는 것 → 측정 누락 (or mechanical/지표 없는 장비)
- Experion에만 있는 것 → 도면 누락 (or 시스템 신호)
4. **KB 문서 자동 인용**`search_kb`에 PID 태그 정보 자동 추가해서 검색 품질 향상
### 의존성
- equipment_registry yaml 구축이 선결 (이전 결정: htm 파서 추가)
- 정비 일정은 별도 사용자 입력 또는 기존 정비 시스템 연동 필요
---
## 갈래 5 — 기존 `infer_field_instruments`와의 통합 (보완 관계, 죽이지 않음)
**배경**: RAG 관리 → Field Instrument 탭의 기존 기능(`infer_field_instruments` → Excel 초안 다운로드)이 `parse_pid_dxf` 등장 후 의미를 잃었는지 의문 제기됨. **결론: 의미 없어지지 않음, 오히려 통합 가치가 생김**.
### 두 기능의 본질적 차이
| 구분 | `infer_field_instruments` (기존) | `parse_pid_dxf` (신규) |
|------|--------------------------------|-----------------------|
| **입력 소스** | Experion DB의 OPC 태그 (`v_tag_summary`) | DXF 도면 파일 |
| **추론 방향** | "실제 측정점이 있다 → 현장에 계기 있을 것" | "도면에 그려져 있다 → 자산으로 존재" |
| **`ficq-6101` 처리** | base 보고 FT-6101 / FIC-6101 / FCV-6101 **3개로 분해** | 도면에 그려진 그대로 (보통 FCV-6101, FIC-6101 등) 인식 |
| **PSV-10217** (mechanical, 신호 없음) | ❌ 못 만듦 | ✅ 도면에 있으면 추출 |
| **추가된 임시 계기** (OPC 신호만 존재) | ✅ 추론 가능 | ❌ 도면에 없으면 못 잡음 |
| **출력** | Excel 초안 (운전원 수동 검수 후 KB 업로드) | `pid_equipment` 테이블 |
| **동력기기** | `power_equipment` role로 별도 분류 | Pump prefix(`P`) 등으로 분류 |
### 잡는 누락이 정반대 — 단독으로는 불완전
- **OPC 신호 있지만 도면 미반영**: `infer_field_instruments`만 잡음 (도면 업데이트 누락 케이스)
- **도면엔 있지만 OPC 신호 없음**: `parse_pid_dxf`만 잡음 (PSV/수동밸브/게이지/파이프 등 mechanical 자산)
- **둘 다 존재**: `match_pid_tags`가 자동 연결 (exact + prefix)
### 통합으로 새로 가능해지는 작업
1. **`infer_field_instruments` 출력 자동 검증**
- infer가 추론한 FT-6101이 실제 도면(`pid_equipment`)에 있는지 즉시 대조
- 추론 정확도 자동 측정 → 추론 룰 개선 피드백 루프 가능
2. **Excel 초안 자동 라벨링 보강**
- 도면에는 PSV-10217이 있는데 infer는 못 만들었음 → "OPC 신호 없는 mechanical" 라벨로 Excel에 자동 행 추가
- 도면 LineNo 정보(`P-10138-600A-F2A-H100`)로 fluid/size/material 컬럼 자동 채움
- infer가 만든 row와 P&ID에서만 보이는 row를 한 Excel에 합쳐서 운전원이 한 번에 검수
3. **양방향 차집합 리포트** (갈래 4의 핵심 산출물)
| 차집합 | 의미 | 운영 액션 |
|--------|------|-----------|
| `pid_equipment` `realtime_table` (도면 ⊖ 실제) | 도면엔 있는데 측정 없는 자산 | (a) mechanical → 정상, "OPC 신호 없음" 라벨로 마스터에 등록<br>(b) 측정 누락 → 신호 추가 검토 |
| `realtime_table` `pid_equipment` (실제 ⊖ 도면) | 측정은 있는데 도면 없는 신호 | 도면 업데이트 누락 → P&ID 도면 보완 요청 |
| 양쪽 모두 | 정상 매핑 | UI에서 ✅ 표시 (현재 상태) |
### 구현 (작업량 적음)
- `infer_field_instruments`의 dedup 단계 뒤에 `pid_equipment` LEFT JOIN 한 번
- infer 결과 row마다 `in_pid_drawing: bool`, `pid_line_number`, `pid_drawing_no` 컬럼 추가
- infer가 만들지 못한 `pid_equipment` row를 별도 시트(`Pid_Only_Mechanical`)로 Excel에 추가
- `parse_pid_dxf`는 손대지 않음. infer 쪽에만 join 추가
- 운전원이 다운받는 Excel이 그대로 더 풍부해짐 — 추가 UI 변경 불필요
### 의존성
- 갈래 1·2 없어도 가능. 단독 진행 가능
- `infer_field_instruments`의 출력 Excel 포맷 일부 변경 (시트 추가) — 기존 운영 흐름에 영향 적음
---
## 갈래 6 — PID 추출 → 옵시디언 노트 부트스트랩 → 하이브리드 RAG
**배경**: `plans/옵시디언-구조적용-플랜.md`는 마크다운 노트 + `[[wikilinks]]` + frontmatter 기반 그래프 RAG를 제안하지만 **아직 미구현** (`notes/` 디렉토리·DB 테이블 없음, 위키링크가 들어있는 .md는 계획 문서 자체뿐). 옵시디언 노트 시스템을 0에서 시작하면 "초기 비어있음" 문제가 큰데, PID 추출 결과를 활용하면 **첫날부터 460개 anchor 노트**로 부트스트랩 가능.
### 역할 분담
| 영역 | PID 추출 (`pid_equipment`) | 옵시디언 노트 (planned) |
|------|---------------------------|------------------------|
| 채우는 것 | **뼈대** — 모든 태그/라인/장비 자동 인벤토리 | **살** — 절차, 루프 의미, 트러블슈팅, 벤더 정보, 운전 노하우 |
| 입력 | 도면 1번 + 자동 | 운전원/엔지니어 수기 + 채팅 저장 |
| 갱신 | 도면 바뀌면 재추출 | 사람이 알게 될 때마다 |
| 핵심 식별자 | `tag_no` (FCV-6113, P-10101) | 노트 ID `tag/fcv-6113`, `loop/compression-a` |
**공통 anchor가 tag ID** — PID가 만든 모든 태그는 곧 노트 ID가 되고, 사람이 쓴 노트는 `[[tag/fcv-6113]]` 위키링크로 PID 인벤토리에 자동 연결.
### 구현 단계
#### Step 1 — PID stub 노트 자동 생성
`pid_equipment` 460건을 각각 frontmatter-only `.md` 파일로 발행:
```yaml
---
id: tag/fcv-6113
kind: tag
prefix: FCV
type: Flow Control Valve
area: 6-1
line_number: 6113
experion_tag: fcv-6113.pv
source_drawing: No-10_Plant_PID.dxf
source: pid_equipment.id=42
generated_at: 2026-05-14
---
<!-- 본문은 비어둠 — 운전원이 채움 -->
```
- 디렉토리: `notes/tag/`, `notes/area/`, `notes/drawing/`
- 한 파일 = 한 노트 = `pid_equipment` 1행
- 본문 비어있어도 anchor 역할 → 위키링크 타깃으로 즉시 사용 가능
#### Step 2 — 사람 노트는 위에 자라남
운전원/엔지니어가 만드는 손 노트는 별도 kind:
- `notes/loop/compression-a.md` — "이 루프는 [[tag/fcv-6113]] [[tag/pt-6111]] [[tag/ft-6113]]로 구성됨"
- `notes/procedure/start-up-a.md` — "기동 시 [[loop/compression-a]] 먼저 안정화"
- `notes/event-pattern/surge-recovery.md` — "[[tag/fcv-6113]] surge 발생 시 [[procedure/anti-surge]] 적용"
**원칙**: 사람은 신규 마크다운만 쓰고 PID stub은 안 건드림. 재추출 시 stub 안전하게 덮어쓰기 가능.
#### Step 3 — Stub 갱신 정책
PID 재추출 시:
- frontmatter는 항상 최신 `pid_equipment` 값으로 덮어씀
- 본문(`<!-- comment -->` 아래)에 운전원이 추가한 텍스트가 있으면 보존 (merge 로직)
- 새로 등장한 tag → 신규 stub 생성
- 사라진 tag → `archived: true` frontmatter 추가 (파일 삭제 안 함, 위키링크 깨짐 방지)
#### Step 4 — KB 인덱싱 + 그래프 엣지 추출
`notes/` 디렉토리를 KbIngestWorker가 별도 컬렉션(예: `kb_notes`)으로 처리:
- 노트 1개 = 1 청크 (헤딩으로 자르지 않음 — 작은 단위 유지)
- frontmatter → Qdrant payload (area, kind, prefix 필터링 가능)
- 본문에서 `[[note-id]]` 정규식 추출 → 신규 테이블 `kb_note_edges (source_id, target_id, link_type)`에 적재
#### Step 5 — 채팅 RAG에 그래프 1-hop 결합
`search_kb`가 top-k 청크 검색 → 각 청크가 노트면 `kb_note_edges`에서 1-hop 위키링크 타깃 조회 → 그 타깃 노트들의 frontmatter+첫 200자를 컨텍스트에 함께 주입.
```python
# 의사 코드
chunks = search_kb(query, top_k=5)
extra_context = []
for chunk in chunks:
if chunk.note_id:
targets = db.fetch_edges(chunk.note_id, depth=1)
extra_context.extend(targets) # 5개 청크 + N개 1-hop 노트
return chunks + extra_context # LLM에 함께 전달
```
LLM은 청크 5개만이 아니라 "이 청크와 명시적으로 연결된 절차/루프/이벤트 패턴"까지 함께 보고 답변.
### 트레이드오프
- (+) 옵시디언 시스템의 "초기 비어있음" 문제 해결 — 첫날부터 460개 anchor 보유
- (+) 운전원이 채팅에서 받은 답변을 `loop/surge-recovery.md` 한 줄로 영구화 가능 → 지식 자산이 자라남
- (+) 마크다운이라 외부 옵시디언 앱으로 열어 편집해도 호환 (부수효과)
- () stub 갱신 시 본문 보존 머지 로직 필요 (text diff 충돌 검출)
- () 운전원이 위키링크 작성 습관 안 들이면 anchor만 잔뜩에 살이 없는 상태 → **저진입 UX 필수**:
- 채팅에서 "이 답변을 [[loop/compression-a]]에 저장" 같은 한 클릭 액션
- 노트 작성 시 `[[` 입력하면 자동완성 드롭다운으로 PID stub 추천
### 의존성
- 갈래 1 권장 선결 — PID 데이터 조회 인프라가 있어야 stub 생성 스크립트 작성이 쉬움
- `옵시디언-구조적용-플랜.md`의 데이터 모델 결정사항 따름 (note ID 규칙, kind 9종, frontmatter 스키마)
- KbIngestWorker에 notes 디렉토리 전용 처리 분기 추가
### 작업 분할
| 단계 | 산출물 | 작업량 |
|------|--------|--------|
| Step 1 | `mcp-server/notes_sync.py` — pid_equipment → notes/tag/*.md stub 생성기 | 0.5일 |
| Step 3 | 머지 보존 로직 + archived 처리 | 0.5일 |
| Step 4 | KbIngestWorker notes 분기 + `kb_note_edges` 테이블 + 위키링크 파서 | 1일 |
| Step 5 | `search_kb` 그래프 1-hop 보강 + 채팅 통합 | 0.5일 |
| 사람 노트 저진입 UX | "답변 → 노트 저장" 버튼, `[[` 자동완성 | 1일 (UI) |
| **합계** | | **3.5일** |
---
## 갈래 7 — Excel 라운드트립으로 service/role 메타데이터 보강 (마지막 연결고리)
**배경**: DXF 파서는 tag·LineNo·fluid(파이프 한정)·prefix 분류는 자동 추출하지만, **장비의 실질 정보(service, role, contents, from-to)는 도면에 텍스트로 없음**. 예: T-201이 원료탱크인지 폐액탱크인지, 무슨 fluid를 담는지, 어디로 보내는지 — LLM이 답할 컨텍스트가 없음. 운전원이 가장 익숙한 도구(Excel)로 한 번에 채우는 round-trip이 가장 실용적인 해결책.
**갈래 5와의 차이**: 갈래 5는 `infer_field_instruments` Excel(현장 계기 초안, **다운로드 전용**)에 PID 정보를 합치는 것. **갈래 7은 PID 메타데이터를 사람이 채워 다시 시스템에 반영하는 round-trip — 편집 가능한 마스터 시트**.
### 시트 구성 (장비 종류별 분리)
| 시트 | 자동 채워진 컬럼 | 사용자가 채울 컬럼 |
|------|----------------|------------------|
| `Tanks` (T-XXX) | tag_no, area, source_drawing | service, role(feed/buffer/product/waste), contents(PGMEA/HBM/...), capacity, notes |
| `Drums` (D-XXX) | 위와 동일 | service, role, contents |
| `Filters` (F-XXX) | tag_no, area | service, medium, from_tag, to_tag |
| `Heat Exchangers` (E-XXX) | tag_no, area | service, hot_side, cold_side, duty |
| `Columns` (C-XXX) | tag_no, area | service, product, overhead, bottom |
| `Pumps` (P-XXX) | tag_no, area, mapped_experion_tag | service, driver(motor/turbine), from_tag, to_tag, power_kw |
| `Compressors / Fans` | tag_no, area | service, driver, power_kw |
| `Cooling Towers` | tag_no, area | service, capacity |
| `Manual Valves` (BV/GV/XV) | tag_no, area | service, normal_position(open/closed), purpose |
| `Pipes` (LineNo) | service, fluid, size, spec, insul (이미 파싱됨) | from_tag, to_tag (오직 이거만) |
| `Legend` | dropdown 후보 (role 종류, fluid 코드, position 등) | — |
| `Index` | 시트별 완성도 (채워진 비율) | — |
### Round-trip 흐름
```
[다운로드]
pid_equipment → 장비 종류별 그룹핑 → 시트별 분리 → Excel 생성
(자동 채운 컬럼 + 빈 컬럼 + dropdown legend)
[운전원이 채움 (며칠~몇 주, 영역별 분할 가능)]
[업로드]
Excel 파싱 → 검증 (dropdown 값, 필수 필드, 참조 tag_no 존재 여부)
→ pid_equipment에 신규 컬럼들(service, role, contents, from_tag, to_tag, …) UPDATE
→ 변경 diff 리포트 (몇 건 채워졌는지, 무효 값 몇 건)
→ (갈래 6 도입 후) tag stub 노트 frontmatter 자동 갱신
```
### 데이터 모델 확장
`pid_equipment` 테이블에 컬럼 추가:
```sql
ALTER TABLE pid_equipment
ADD COLUMN service TEXT, -- 운전원 입력 fluid/용도 (P=PROCESS와 별개로 자유 텍스트)
ADD COLUMN role TEXT, -- feed_tank, buffer_tank, waste_tank, transfer_pump, ...
ADD COLUMN contents TEXT, -- PGMEA, HBM, CITY_WATER, ...
ADD COLUMN from_tag TEXT, -- 흐름 상류 tag (수동 입력)
ADD COLUMN to_tag TEXT, -- 흐름 하류 tag
ADD COLUMN driver TEXT, -- motor / turbine / electric / steam
ADD COLUMN capacity TEXT, -- 자유 텍스트 (단위 포함)
ADD COLUMN power_kw NUMERIC,
ADD COLUMN user_notes TEXT, -- 운전원 자유 메모
ADD COLUMN metadata_filled_at TIMESTAMPTZ; -- 마지막 사용자 갱신 시각
```
### 갈래 5/6과의 관계 — 같은 그림의 다른 단계
| 단계 | 역할 | 갈래 |
|------|------|------|
| 자동 추출 | 뼈대 (모든 태그 자동 인벤토리) | PID 파서 (완료) |
| 자동 검증 Excel | mechanical/누락 자산 검출 (read-only) | 갈래 5 |
| **수기 보강 round-trip** | **구조화 슬롯 일괄 채우기 (service/role/from-to)** | **갈래 7 (이 항목)** |
| 노트 자동 생성 | tag stub frontmatter에 갈래 7 데이터 자동 반영 | 갈래 6 Step 1 |
| 채팅 RAG 강화 | 위 모두 통합 검색 | 갈래 1+2+6 |
**갈래 7이 진짜 마지막 연결고리**. 이게 없으면 갈래 6 stub의 frontmatter가 빈약하고, 채팅도 "T-201이 뭐 담는지" 답을 못 함.
### 트레이드오프
- (+) Excel은 운전원이 가장 자연스럽게 다루는 도구 — 마크다운 학습 비용 0
- (+) 한 번 다운로드받아 오프라인에서도 채울 수 있음
- (+) 시트별 분리로 "오늘은 펌프만, 내일은 탱크만" 같은 분할 작업 가능
- (+) dropdown legend로 데이터 일관성 자동 확보
- () Excel은 discrete batch — 동시에 여러 사람이 채우면 머지 충돌 위험. 한 번에 한 사람 권장
- () 다운로드 받은 파일이 오래되면 사이에 PID 재추출로 신규 tag 생긴 경우 머지 필요 — 업로드 시 "신규 tag는 이번 Excel에 없음" 경고
- () 한 번에 460건 다 채우라 하면 부담 → 영역별/종류별 분할 가이드 필요 ("이번 주는 6-1 area Pump만")
### 구현 (작업량)
| 단계 | 산출물 | 작업량 |
|------|--------|--------|
| DB 마이그레이션 | `pid_equipment` 신규 컬럼 ALTER + InitializeAsync DDL 갱신 | 0.3일 |
| 다운로드 엔드포인트 | `GET /api/pid/metadata/excel` — 시트별 분리 Excel 생성 (openpyxl) | 0.5일 |
| 업로드 엔드포인트 | `POST /api/pid/metadata/excel` — 파싱+검증+UPDATE+diff 리포트 | 1일 |
| UI (RAG 관리 또는 P&ID 탭) | 다운로드/업로드 버튼 + 진행률 + diff 표시 | 0.5일 |
| Legend dropdown 사전 | role/contents/driver 등 유효값 정의 + 운영 후 확장 가능하게 | 0.2일 |
| **합계** | | **2.5일** |
### 의존성
- 갈래 1·5와 독립적으로 진행 가능
- 갈래 6보다 먼저 끝나야 stub frontmatter가 풍부해짐 → **Phase B 또는 Phase C 초반에 진행 권장**
---
## 갈래 8 — LineNo 파생 정보 컬럼 추가 (작지만 모든 갈래에 가치 전파)
**배경**: 현업 통찰 — 운영 부서는 LineNo(`P-10138`, `16456` 등 시공·단관 제작용 번호)로 파이프를 부르지 않음. 가치 있는 건 거기서 파생되는 **{service, fluid, size, material, flange_rating, insulation}**. LineNo 자체는 시공·정비 참조용으로 보존하되, 파생 정보를 별도 컬럼으로 저장해야 운영 검색·통계·일관성 검증이 가능해짐.
**현재 상태**: `_parse_pid_lineno`가 MCP 응답에 7개 필드를 다 담고 있지만, C# 측이 `line_number` 하나만 DB에 저장하고 나머지는 버리고 있음. 추출은 되는데 활용이 안 됨.
### 구현
```sql
-- pid_equipment 컬럼 추가 (line_number는 그대로 유지)
ALTER TABLE pid_equipment
ADD COLUMN service_code TEXT, -- 'P', 'CWS', 'ST', 'VG', ...
ADD COLUMN fluid_name TEXT, -- 'PROCESS FLUID', 'STEAM' (legend 변환)
ADD COLUMN pipe_size TEXT, -- '25A', '600A'
ADD COLUMN material_spec TEXT, -- 'F', 'S'
ADD COLUMN flange_rating INT, -- 1=150#, 2=300#
ADD COLUMN insul_code TEXT, -- 'A', 'B'
ADD COLUMN insul_thickness TEXT; -- 'H50', 'H100', 'E50', 'n'
```
장비/계기 행은 이 컬럼들이 NULL — 정상. 한 테이블 유지, 정규화 분리 안 함.
### 변경 파일
| 파일 | 변경 |
|------|------|
| `ExperionDbContext.InitializeAsync` | ALTER TABLE 7개 컬럼 추가 (또는 새 설치용 CREATE TABLE 갱신) |
| `PidEquipment` 엔티티 | 7개 속성 + `[Column("snake_case")]` 매핑 |
| `ExtractedItem` DTO | 7개 필드 추가 (PipeService/FluidName/PipeSize/...) |
| `PidExtractorService.ParseJson` | MCP 응답의 pipe 필드를 ExtractedItem에 매핑 |
| `PidExtractorService.ExtractFromStreamAsync` | save 시 7개 컬럼 채움 (pipe 행만, instrument/equipment는 null) |
| MCP `extract_pid_tags` / `parse_pid_dxf` | 이미 7개 필드 응답 중 — 변경 불필요 |
### 활용은 자동으로 따라옴
컬럼만 채워지면 기존 갈래들이 즉시 더 풍부해짐:
- **갈래 1 채팅 검색**: "CWS 25A 이상 라인", "스팀 H100 단열 라인" 같은 속성 조합 검색 즉시 가능
- **갈래 6 stub frontmatter**: pipe 노트에 `service: P`, `fluid: PROCESS FLUID`, `pipe_size: 25A`, `insul_thickness: H50` 자동 반영 → 노트가 빈약하지 않음
- **갈래 7 Pipes 시트**: 자동 채워진 컬럼이 7개로 늘어남, 사용자는 from_tag/to_tag/서비스별칭만 채우면 됨
- **(부수효과)** 사양 분포 리포트, 일관성 검증 같은 분석이 가능해짐 — 필요할 때 별도 도구로 노출하면 됨 (지금은 컬럼만 추가)
### 작업량
| 단계 | 산출물 | 작업량 |
|------|--------|--------|
| DB 컬럼 추가 | ALTER TABLE + InitializeAsync DDL 갱신 | 0.2일 |
| `PidEquipment` 엔티티 + `[Column]` 매핑 | 7개 속성 추가 | 0.1일 |
| `ExtractedItem` 7개 필드 + ParseJson 매핑 | DTO 확장 | 0.2일 |
| `PidExtractorService` save 경로 | 7개 컬럼 INSERT | 0.2일 |
| 기존 460건 재추출 (또는 in-place UPDATE 스크립트) | truncate 후 재추출이 간단 | 0.1일 |
| **합계** | | **0.8일** |
### 의존성
- 없음. 단독 진행 가능
- **갈래 1·6·7의 가치를 증폭시키므로 가장 먼저 배치 권장**
---
## 갈래 9 — 추출 결과 저장·폐기 선택 UX (실수 방지)
**배경**: 현재 P&ID 추출 UI는 "추출 시작" 클릭 시 MCP 추출 → `pid_equipment` 즉시 INSERT가 자동으로 이뤄짐. 운전원이 실수로 다른 파일(다른 플랜트, 구버전 도면 등)을 추출해도 되돌릴 방법이 없음. TRUNCATE는 정상 데이터까지 날아가서 사용 불가. 추출 전/후에 사용자가 검토·취사선택할 수 있어야 함.
**결정 보류**: 아래 3개 옵션 중 어느 방향으로 갈지 사용자 결정 필요.
### 옵션 비교
#### 옵션 A — Dry-run + 저장 버튼 (사전 검토)
```
[추출 시작] → MCP 추출만 (DB 저장 X) → "미리보기 460건" 표시
├── [✅ DB에 저장] → INSERT 수행
└── [🗑️ 폐기] → 결과 버림
```
| 항목 | 내용 |
|------|------|
| 장점 | 가장 안전 — DB 들어가기 전 검토 가능. 운전원 의도 명확 ("이걸 저장할까?") |
| 단점 | UI 흐름 2단계로 늘어남. 결과를 서버 임시 캐시 또는 클라이언트 메모리에 보관 필요. 사용자가 검토 중 페이지 떠나면 데이터 잃음 |
| 작업량 | 약 1일 |
| 구현 | `PidExtractorService.ExtractWithoutSaveAsync` 추가, `IMemoryCache` 결과 보관, UI에 미리보기 + 저장/폐기 버튼 |
#### 옵션 B — 자동 저장 + 즉시 취소 버튼 (사후 롤백)
```
[추출 시작] → MCP 추출 → DB 저장 (extraction_batch_id 부여)
→ "✅ 추출 완료: 460건" + [🗑️ 이번 추출 취소] 버튼
batch_id WHERE 절로 일괄 삭제
```
| 항목 | 내용 |
|------|------|
| 장점 | 기존 UX 거의 그대로. 추출 이력 자체가 자동으로 남아 추후 분석 가능 |
| 단점 | 저장 후 후속 작업(confidence 수정 등)이 일어났으면 일괄 삭제가 그 작업까지 날림 → "최근 N분만 취소 가능" 같은 가드 필요. 다른 사용자가 잠깐 잘못된 데이터 볼 수 있음 |
| 작업량 | 약 0.5일 |
| 구현 | `pid_equipment.extraction_batch_id` (UUID) 컬럼 추가, 추출 시 부여, UI에 "이번 추출 취소" 버튼 + 시간 가드 |
#### 옵션 C — A + 영구 batch_id 보존 (절충)
```
[추출 시작] → MCP 추출 (DB 저장 X, staging 보관)
├── [저장] → batch_id와 함께 INSERT
│ → 이후에도 "추출 이력" 페이지에서 batch_id로 일괄 삭제 가능
└── [폐기] → staging만 버림
```
| 항목 | 내용 |
|------|------|
| 장점 | A의 안전성 + B의 사후 롤백 모두 확보. "추출 이력" 페이지에서 과거 batch 단위 관리 가능 |
| 단점 | 작업량 가장 큼. UI도 가장 복잡 (미리보기 + 저장 + 이력 관리) |
| 작업량 | 약 1.5~2일 |
| 구현 | 옵션 A + 옵션 B 합친 형태. staging 캐시 + batch_id 컬럼 + 이력 페이지 |
### 추천 (결정 보류 상태)
| 우선 고려 | 추천 옵션 |
|----------|----------|
| 안전성 우선 | **옵션 A** |
| 작업량 최소 | 옵션 B |
| 장기 운영 (감사·이력) | 옵션 C |
운전원이 자주 추출하는 운영 환경이면 옵션 A의 사전 검토가 정신적 부담 적고 안전 — **기본 추천은 옵션 A**. 단, "추출 이력 관리" 같은 요구가 미래에 나올 가능성이 크면 옵션 C가 미리 대응됨.
### 의존성
- 옵션 A: `IMemoryCache` 또는 클라이언트 세션 — 표준 ASP.NET Core 기능
- 옵션 B/C: `pid_equipment.extraction_batch_id` 컬럼 (UUID) — 갈래 8 컬럼 추가와 함께 ALTER 묶어 처리하면 효율적
- 어느 옵션이든 갈래 8과 같은 시점에 진행하면 마이그레이션 1번으로 끝낼 수 있음
### 결정 시점
- 갈래 1~5 진행 중 사용 패턴 보면서 결정 가능 (당장 결정 안 해도 됨)
- 단, 옵션 B/C를 선택한다면 갈래 8 마이그레이션 시 `extraction_batch_id` 컬럼을 함께 추가하는 게 효율적 → **갈래 8 착수 전에 한 번 가볍게 결정 권장**
---
## 추천 진행 순서
| 우선순위 | 항목 | 작업량 | 가치 | 의존성 |
|----------|------|--------|------|--------|
| **0** | **갈래 8 (LineNo 파생 정보 컬럼 추가)** | 0.8일 | **모든 갈래에 가치 전파** | 없음 |
| **0+** | **갈래 9 (추출 결과 저장/폐기 UX)** | 0.5~2일 (옵션별) | 높음 (실수 방지) | 갈래 8과 함께 마이그레이션 묶기 권장 |
| **1** | 갈래 1 (MCP 도구 2개) | 0.5~1일 | 높음 | 갈래 8 권장 |
| **2** | 갈래 5 (infer ↔ pid_equipment 통합 Excel) | 0.5일 | 높음 | 없음 |
| **3** | 갈래 2 (이벤트 컨텍스트) | 0.5일 | 높음 | 갈래 1 |
| **4** | 갈래 7 (Excel round-trip으로 service/role 보강) | 2.5일 | 매우 높음 | 갈래 8 권장 |
| **5** | 갈래 6 (옵시디언 노트 부트스트랩 + 하이브리드 RAG) | 3.5일 | 매우 높음 (장기) | 갈래 1·7·8 권장 |
| 6 | 갈래 3a (트리뷰 UI) | 1~2일 | 중 | 없음 |
| 7 | 갈래 4 일부 (누락 검출 리포트 단독) | 0.5일 | 중 | 갈래 5와 중복 — 갈래 5에 통합 권장 |
| 보류 | 갈래 3b (그래프 시각화) | 큼 | 사용 패턴 보고 결정 | from-to 추출 |
| 보류 | 갈래 4 전체 (정비 일정·KB 통합) | 큼 | 큼 | equipment_registry, 정비 시스템 |
### 추천 흐름
**Phase 0 (반나절, 0.8일)** — 모든 갈래의 가치 증폭
0. **갈래 8**: `pid_equipment`에 service/fluid/size/material/flange/insul 7개 컬럼 추가 + 460건 재추출
- 이후 모든 갈래가 더 풍부한 데이터로 동작
**Phase A (이번 주, 1~2일)** — 즉시 효과
1. **갈래 1**: `find_pid_equipment` / `get_pid_equipment_info` 추가 → 갈래 8로 채워진 7개 필드까지 검색·표시
2. **갈래 5** 병행: `infer_field_instruments``pid_equipment` join 추가 → Excel 초안에 mechanical/누락 자산 자동 포함
**Phase B (Phase A 사용 패턴 본 뒤, 0.5일)** — 정착 단계
3. **갈래 2**: 알람·이벤트 컨텍스트에 PID 정보 자동 첨부 → 보고서 품질 향상
**Phase C (2.5일 + 운전원 채움 기간)** — 마지막 연결고리
4. **갈래 7**: Excel round-trip 인프라 구축 → 운전원이 service/role/contents/from-to 채움
- 자동 컬럼이 갈래 8 덕분에 7개 더 풍부 → 사용자 부담 줄어듦
- 구축 자체는 2.5일이지만 **운전원이 채우는 데 며칠~몇 주** 걸림 (영역별 분할)
- 일부라도 채워지면 즉시 갈래 1·2의 답변 품질이 올라감 (T-201 → "PGMEA 원료 탱크" 답변 가능)
**Phase D (Phase C 진행 중 병행, 3.5일)** — 지식 자산화로 진화
5. **갈래 6**: PID stub 자동 생성 → 옵시디언 노트 시스템 부트스트랩 → 하이브리드 RAG
- 갈래 8·7의 결과가 stub frontmatter로 자동 반영되어 빈약하지 않음
- 운전원이 채팅에서 노트 작성 습관 들이면 시간이 갈수록 그래프가 자라남
- **장기적으로는 이 갈래가 시스템의 핵심 자산** — PID/Experion은 자동 입력 소스, 노트는 사람의 운영 지식 저장소
**Phase E (필요해질 때)** — 확장
6. **갈래 3a**: 시각적 인벤토리 필요해지면 트리뷰 UI 작업
7. **갈래 4 전체** 또는 **갈래 3b**: 정비 시스템 연동/그래프 시각화 등은 실제 운영 요구가 명확해진 시점에 착수
### 우선순위 배치 근거
- **갈래 8 최우선**: 0.8일 작업으로 갈래 1·6·7 모두의 데이터 가치를 증폭. ROI 극단적으로 높음. 다른 모든 작업 전에 끝내는 게 합리적
- **갈래 5 > 갈래 4**: 갈래 4의 핵심 산출물(양방향 차집합 리포트)이 갈래 5의 Excel 시트로 자연스럽게 포함됨. 운전원이 이미 익숙한 UX 위에 얹는 거라 학습 비용 0
- **갈래 7 > 갈래 6**: 갈래 6 stub의 frontmatter는 갈래 7의 결과(service/role/contents)로 채워질 때 비로소 가치가 있음. 갈래 7 없이 갈래 6만 가면 빈 anchor만 잔뜩
- **갈래 7 > 갈래 3a**: service/role이 채워지지 않은 상태에서 시각화는 "이름만 있는 그림" — 우선 의미를 채워야 함
- **갈래 6은 갈래 1·2·7·8 이후가 적기**: 채팅에서 PID 데이터 활용 패턴이 나오고 메타데이터가 채워진 뒤라야 노트 시스템이 의미 있음
---
## 결정 필요 항목
- [ ] **갈래 8** 즉시 착수 여부 — `pid_equipment` 7개 컬럼 추가 + 460건 재추출 (0.8일, ROI 최고)
- [ ] **갈래 9** 옵션 선택 — A(dry-run, 1일) / B(자동저장+취소, 0.5일) / C(절충, 1.5~2일) 중 하나 선택. **갈래 8 착수 전 결정 권장** (B/C면 `extraction_batch_id` 컬럼을 갈래 8 마이그레이션에 함께 묶음)
- [ ] **갈래 1** 즉시 착수 여부 — `find_pid_equipment` / `get_pid_equipment_info` MCP 도구 추가
- [ ] **갈래 5** 즉시 착수 여부 — `infer_field_instruments``pid_equipment` LEFT JOIN + 별도 시트 추가
- [ ] 갈래 2 진행 여부 — Phase A 정착 후
- [ ] **갈래 7** 데이터 모델 확정 — `pid_equipment` 컬럼 추가 항목(service/role/contents/from_tag/to_tag/driver/capacity/power_kw/user_notes) 확인
- [ ] **갈래 7** legend dropdown 사전 시드값 — role/contents/driver 유효값 누가 정의
- [ ] **갈래 7** 운전원 채우기 가이드 — 영역별 분할 방식(어떤 area부터?)
- [ ] **갈래 6 Step 1·2** 일정 — PID stub 생성기 + 머지 보존 로직 (갈래 8·7 컬럼이 stub frontmatter로 흐르도록 같이 설계)
- [ ] 갈래 6 Step 5 (그래프 1-hop RAG) — `search_kb` 수정 시점 확정 필요
- [ ] 갈래 6 저진입 UX (채팅 답변 → 노트 저장 버튼) — UI 작업 일정
- [ ] 갈래 3a 트리뷰 UI 작업 일정 — 갈래 7 보강 데이터가 채워진 뒤가 효과적
- [ ] 갈래 4 단독 누락 검출 리포트 — 갈래 5에 흡수할지, 별도 페이지로 만들지
## 잔여 데이터 정합성 이슈
- **중복 추출**: 현재 460건 중 동일 tag_no가 2번씩 들어간 행 다수 발견 (예: PSV-10101이 2회). DXF에서 같은 텍스트가 여러 위치에 그려져서 발생. unique index 또는 (tag_no, pid_drawing_no) 복합키로 해결 필요.
- **pid_drawing_no NULL**: 현재 모든 행 NULL. 도면 파일명을 자동 채우거나 사용자 입력 받도록 보강 필요 — 갈래 4 누락 검출에 도면 단위 추적 필요할 때.
- **prefix 오분류**: PFD, SP, SC, TR 등 도면 라벨/약어가 instrument로 잘못 분류되는 케이스 — `_PID_TAG_RE` 또는 `_classify_pid_tag`에 예외 사전 추가 필요.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,407 @@
# RAG 지식 증강 보완 플랜 — Field-Instrument 자동 유추 + 관계 그래프
> 옵시디언 vault 풀구현(`plans/옵시디언-구조적용-플랜.md`) 이전에, **신규 인프라 0** + **휴먼-인-더-루프**로 RAG 품질을 빠르게 끌어올리는 단계.
>
> DCS 로직 태그(`ficq-6101.pv` 등) → 글자/loop 룰로 현장 계기 유추 → role/from/to까지 채운 Excel 초안 → 운영자가 검토·수정 → 기존 KB 업로드 흐름으로 인덱싱.
작성: 2026-05-14 | 위치: 14번 탭(RAG 관리) 확장 | 의존: 기존 `xlsx_parser`, `KbIngestWorker`, Qdrant `system_instrument` 컬렉션
---
## 1. 목적과 이유
### 1.1 해결하려는 문제
- `node_map_master`/`v_tag_summary`에는 **DCS 로직 블록 태그**만 있다 (`ficq-6101`, `xv-6124` ...).
- 운전원 현장 언어는 **계기 단위**다 ("FT-6101 막혔어", "FCV-6101 포지셔너 점검").
- 사이의 **번역 갭**을 RAG가 메우려면 명시적 매핑이 필요한데, 현재 KB에는 그 매핑이 없다.
- 게다가 도면/PDF만으로는 "이 송신기 → 이 컨트롤러 → 저 밸브" 같은 **신호 흐름**이 LLM 컨텍스트로 들어오지 않는다.
### 1.2 왜 지금 이 단계인가
- 옵시디언 vault 풀구현은 데이터 모델 5개 테이블 + 워커 + UI 확장 + 그래프 검색까지 1~2주 작업.
- 이 단계는 **0.5~1일** 작업으로 RAG 품질이 즉시 좋아지고, 만들어진 Excel은 vault 마이그레이션 시 **그대로 시드**가 된다 → 손해보는 작업이 아니다.
- LLM은 지루한 초안만, 사람은 도메인 검증만 → 가장 비용 효율적인 분업.
### 1.3 비채택 옵션
- LLM 100% 추론 (옵션 C) — 2000행에 27B 호출 비용 큼, 환각 위험. 채택 안 함.
- 자동 인덱싱 (운영자 검토 없이 바로 KB) — 부정확한 매핑이 RAG 답변에 그대로 섞이면 신뢰도 손상. 채택 안 함.
---
## 2. 채택: 규칙 + 부분 LLM (옵션 B)
| 부분 | 방법 | 처리량 |
|------|------|--------|
| 계기 구조 추론 (FT/FCV/Totalizer) | **결정론적 룰** | 2000행 < 5초 |
| role/from/to 기본값 | **결정론적 룰** | 위와 함께 |
| description 한국어 초안 (없을 때) | **LLM 배치** (옵션 토글) | 2000행 ≈ 수십 분 |
| confidence / needs_review | 룰 적중도 계산 | 즉시 |
`use_llm=false`로도 운영자가 즉시 작업 시작 가능. LLM은 옵션.
---
## 3. Excel 스키마 — Long Format (1행 = 1계기)
> 이전 안의 "1행 = 1 base_tag, 컬럼에 FT/FCV/..." 와이드 포맷을 버리고 **롱 포맷** 채택. role/from/to를 1열에 자연스럽게 표현할 수 있고, 향후 vault note 1개 ↔ 행 1개 대응이 깔끔.
### 3.1 시트 1: `instruments`
| 열 | 타입 | 설명 | 예 |
|----|------|------|----|
| `instrument_id` | text (PK) | 정규화 ID, 소문자 kebab | `ft-6101` |
| `display_name` | text | 운전원이 부르는 이름 | `FT-6101` |
| `parent_base_tag` | text | 이 계기가 속한 DCS 태그 | `ficq-6101` |
| `role` | enum | §4의 역할 표 | `transmitter` |
| `loop` | text | loop 번호 | `6101` |
| `area` | text | unit/area | `A` |
| `measures` | enum/text | 측정량 (transmitter류) | `flow` |
| `data_points` | text(csv) | DCS 데이터포인트 | `.pv` 또는 `.qv,.qv.value` |
| `from` | text | 신호/물질 출처 | `process/compressor-a-suction` |
| `to` | text | 신호/물질 목적지 | `tag/ficq-6101` |
| `description` | text | 한국어 설명 | `압축기 A열 입구 유량 송신기` |
| `confidence` | enum | `high` / `medium` / `low` | `high` |
| `needs_review` | bool | TRUE면 운영자 검토 필수 | `FALSE` |
| `inference_basis` | text | 어떤 룰로 유추됐는지 | `F+T@modifier rule` |
| `operator_notes` | text | 운영자 자유 입력 | `2024년 교체. P&ID 105` |
| `delete` | bool | TRUE면 재업로드 시 제외 | `FALSE` |
### 3.2 시트 2: `naming_convention` (참조용, read-only)
운영자가 시트 1을 수정할 때 참조하는 룰 표. §6 YAML을 그대로 시트로 풀어둠.
### 3.3 시트 3: `unmatched_tags` (운영자 보충용)
룰이 전혀 매칭 안 된 base_tag 목록 (예: 비표준 prefix, 한글 prefix). 운영자가 직접 시트 1에 행 추가해야 함.
### 3.4 예시 5행
| instrument_id | display_name | parent_base_tag | role | loop | area | measures | data_points | from | to | description | confidence | needs_review | inference_basis |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ft-6101 | FT-6101 | ficq-6101 | transmitter | 6101 | A | flow | .pv | process/inlet-a | tag/ficq-6101 | 입구 유량 송신기 | high | FALSE | F+(implied T) |
| fic-6101 | FIC-6101 | ficq-6101 | controller | 6101 | A | flow | .sp,.op | tag/ft-6101 | tag/fcv-6101 | 입구 유량 제어기 | high | FALSE | F+I+C |
| fcv-6101 | FCV-6101 | ficq-6101 | control-valve | 6101 | A | — | .op | tag/fic-6101 | process/compressor-a-suction | 입구 제어밸브 | high | FALSE | C → CV |
| fq-6101 | Totalizer-6101 | ficq-6101 | totalizer | 6101 | A | flow | .qv,.qv.value | tag/ficq-6101 | (none) | 입구 유량 적산기 | medium | FALSE | Q → totalizer |
| xv-6124 | XV-6124 | xv-6124 | shutdown-valve | 6124 | A | — | .instate0..7 | safety/esd-loop-1 | process/header-a | ESD 차단밸브 | low | **TRUE** | digital-only |
---
## 4. Role 정의 (시드 표)
> 옵시디언 vault의 `kind: instrument` 노트 frontmatter `role` 필드와 1:1 매칭되도록 설계.
| role | 의미 | 룰 글자 | 일반 data_point |
|------|------|---------|-----------------|
| `transmitter` | 측정 송신기 (FT/PT/TT/LT/AT) | 수식어 T 또는 측정 letter 단독 | `.pv` |
| `indicator` | 표시기 (계기 없이 DCS 화면) | I | (가상) |
| `controller` | 제어기 (FIC/PIC/TIC/...) | C | `.sp`, `.op`, `.mode` |
| `recorder` | 기록계 | R | (가상) |
| `totalizer` | 적산기 | Q | `.qv`, `.qv.value` |
| `switch` | 스위치 (FS/PS/LS/...) | S | `.instate0..n` |
| `alarm` | 알람 단독 | A | (이벤트 채널) |
| `control-valve` | 제어밸브 (FCV/PCV/TCV) | 제어기 C 동반 시 자동 추론 | `.op` 참조 |
| `shutdown-valve` | 차단/ESD 밸브 (XV/SDV) | X/SDV prefix | `.instate*` |
| `check-valve` | 체크밸브 | (운영자 수동) | — |
| `positioner` | 포지셔너 (FZ) | Z | — |
| `interlock-relay` | 인터록 릴레이 (FY) | Y | — |
| `motor` | 모터 (M, KM) | 별도 prefix | `.run`, `.fault` |
| `pump` | 펌프 (P) | 별도 prefix | `.run`, `.flow` |
| `compressor` | 압축기 (K, C) | 별도 prefix | (다중) |
| `analyzer` | 분석기 (AT, AIT) | A 단독+T | `.pv` |
| `damper` | 댐퍼 | (운영자 수동) | — |
| `equipment` | 그 외 정적 장치 | (운영자 수동) | — |
→ 운영자가 신규 role을 만들어도 그대로 보존. role 자체가 frontmatter라 시스템 enum 위반은 경고만(차단 X).
---
## 5. From / To 의미론
> "**신호 또는 물질의 흐름**"을 표현. 운영자 직관과 맞아야 함.
### 5.1 기본 규칙 (role별 자동 채움)
| role | from 기본값 | to 기본값 |
|------|------------|----------|
| `transmitter` | `process/<area>-<loop-측정점>` (placeholder) | `tag/<parent>` |
| `controller` | `tag/<measurement-transmitter>` | `tag/<paired-valve>` |
| `control-valve` | `tag/<paired-controller>` | `process/<area>-<loop-downstream>` (placeholder) |
| `totalizer` | `tag/<parent>` | `(none)` |
| `switch` | `process/<area>-<loop-측정점>` | `safety/<interlock-id>` (운영자 보충) |
| `shutdown-valve` | `safety/<esd-trigger>` (운영자 보충) | `process/<area>-<downstream>` |
| 그 외 | `(none)` | `(none)` |
`process/...`, `safety/...` 경로는 **placeholder**. 운영자가 실제 ID(있다면)로 바꾸거나 그대로 둬도 됨. vault 마이그레이션 시 자동으로 `process` kind 노트가 빨간 링크로 잡힘 (= 채워야 할 노트 목록 = §10 옵시디언 플랜의 `vault_unresolved` 기능).
### 5.2 작성 형식
운영자는 4가지 중 하나로 적을 수 있음:
1. **vault note id**: `tag/ficq-6101`, `instrument/ft-6101`, `process/header-a` — 향후 vault 그래프로 직변환
2. **base_tag 그대로**: `ficq-6101` — 자동 정규화로 `tag/ficq-6101`
3. **자유 텍스트**: `압축기 1열 입구 헤더` — 보존만, 그래프 엣지로는 안 잡힘
4. **`(none)`**: 명시적 없음
룰 엔진은 4번을 디폴트로 두지 않고, 가능한 한 1번 형식의 placeholder를 채움.
### 5.3 검증
업로드 시:
- `from` / `to`가 형식 1·2면 자동 엣지로 인식
- 형식 3은 그대로 보존하되 "그래프 엣지 미생성" 정보 행 카운트 표시
- 형식 4는 명시적 제외
---
## 6. 룰 테이블 (`prompts/instrument_inference.yaml`)
> 코드 외부에 두는 이유: 운영자/엔지니어가 plant_context.md처럼 직접 편집 가능.
```yaml
# 첫 글자 = 측정량
measurement:
F: flow
P: pressure
T: temperature
L: level
A: analysis
S: speed
W: weight
D: density
J: power
M: moisture
# 두 번째 이후 = 기능 수식어 (순서대로 별도 계기 생성)
modifiers:
I: { role: indicator, virtual: true }
R: { role: recorder, virtual: true }
T: { role: transmitter, data_points: [.pv] }
C: { role: controller, data_points: [.sp, .op] }
Q: { role: totalizer, data_points: [.qv, "qv.value"] }
S: { role: switch, data_points: [.instate0, .instate1] }
A: { role: alarm }
Y: { role: interlock-relay }
Z: { role: positioner }
# 컨트롤러가 있으면 자동으로 제어밸브 1개 생성
auto_pair:
- if_role: controller
create:
role: control-valve
id_pattern: "{meas}cv-{loop}"
display_pattern: "{MEAS}CV-{loop}"
# 특수 prefix (글자 분해 룰 미적용)
special_prefixes:
xv: { role: shutdown-valve, measures: null }
sdv: { role: shutdown-valve, measures: null }
fy: { role: interlock-relay, measures: flow }
fz: { role: positioner, measures: flow }
km: { role: motor }
p: { role: pump }
k: { role: compressor }
# data_point 패턴별 보정 (룰 추론 결과 검증)
data_point_validation:
has_qv_when: role == "totalizer"
has_sp_op_when: role == "controller"
has_instate_when: role in ["switch", "shutdown-valve"]
# 신뢰도 계산
confidence:
high_when:
- prefix matches measurement OR special_prefixes
- all modifiers resolved
- data_points present and consistent
medium_when:
- prefix matches but some data_points missing
- or modifiers contain unknown letter
low_when:
- special_prefixes only AND data_points unusual
- or first letter not in measurement table
```
---
## 7. 유추 알고리즘 (Python 의사코드)
```python
def infer_instruments_for_base_tag(base_tag: str, data_points: list[str], area: str) -> list[dict]:
# 1. 분해
head, loop = split_letters_and_number(base_tag) # "ficq", "6101"
# 2. 특수 prefix 우선
if head in rules["special_prefixes"]:
return [build_special_instrument(head, loop, data_points, area)]
# 3. 첫 글자 = 측정량
if head[0] not in rules["measurement"]:
return [build_unmatched(base_tag, area)] # confidence=low, needs_review=TRUE
meas = rules["measurement"][head[0]]
instruments = []
# 4. 수식어 글자별로 계기 생성
has_transmitter = False
for letter in head[1:]:
mod = rules["modifiers"].get(letter)
if not mod or mod.get("virtual"):
continue # I, R은 가상
inst = build_instrument(meas, mod["role"], loop, area, data_points)
instruments.append(inst)
if mod["role"] == "transmitter":
has_transmitter = True
# 5. T 글자가 명시 안 됐어도 측정 letter만으로 송신기 암시 (FI-6101 → FT-6101 묵시)
if not has_transmitter and any(m["role"] == "controller" for m in instruments):
instruments.insert(0, build_implicit_transmitter(meas, loop, area))
# 6. 컨트롤러 → 제어밸브 자동 생성 (auto_pair)
for inst in list(instruments):
if inst["role"] == "controller":
instruments.append(build_paired_valve(meas, loop, area))
# 7. from/to 채우기 (§5.1)
link_signal_flow(instruments)
# 8. data_point 검증 → confidence 계산
for inst in instruments:
inst["confidence"] = score_confidence(inst, data_points)
inst["needs_review"] = inst["confidence"] == "low"
return instruments
```
---
## 8. LLM 보강 (옵션, `use_llm=true`)
- **대상**: `description` 열이 비어 있거나 confidence=low인 행만.
- **호출**: 한 번에 최대 50행 묶어서 vLLM에 한국어 요약 요청. 시스템 프롬프트는 `prompts/plant_context.md` + role 표.
- **출력**: description 한 줄(≤80자). 환각 방지 위해 "확실치 않으면 비워두라" 명시.
- **캐시**: 같은 (parent_base_tag, role) 조합 결과는 재실행 시 재사용.
- **토큰 예산**: 50행 묶음 × ~40 묶음 = 호출 40회, 각 ≤ 2k 토큰.
---
## 9. 워크플로
```
[14번 탭 > Field Instrument 모드]
1. ▼ 초안 생성 (use_llm 토글)
POST /api/kb/instruments/infer → 백그라운드 잡 → 진행률 폴링
2. ▼ Excel 다운로드 (instruments_draft_YYYYMMDD.xlsx)
3. 운영자가 로컬에서 Excel 편집
- confidence=low / needs_review=TRUE 행 우선
- from/to 보충 (process/, safety/ placeholder 채우기)
- delete=TRUE로 잘못된 추론 제외
- 시트 3(unmatched_tags)에서 행 추가
4. ▼ 같은 탭에 업로드 (collection = system_instrument)
5. 기존 KbIngestWorker → 시트별/행별 청킹 → Qdrant 인덱싱
6. 채팅에서 즉시 search_kb로 활용
- "FT-6101 점검 이력" → instrument_id, parent_base_tag, description 청크 hit
- "ficq-6101 관련 계기" → parent_base_tag 매칭으로 4행(FT/FIC/FCV/Q) 회수
```
재실행 시: 운영자가 수정한 Excel을 시드로 받아 (`POST /api/kb/instruments/infer?seed_doc_id=...`) **사용자 수정사항 보존 + 신규 base_tag만 추가** 모드.
---
## 10. 구현 단계
### Phase A — 룰 엔진 + Excel 생성 (0.5일)
- `prompts/instrument_inference.yaml` 작성 (§6)
- `mcp-server/instrument_inference/` 신규 모듈
- `rules.py` — YAML 로더
- `infer.py``infer_instruments_for_base_tag()` (§7)
- `excel.py` — openpyxl 기반 3시트 생성기
- `@mcp.tool() infer_field_instruments(use_llm=False, seed_doc_id=None)` 신규 도구
- 단위 테스트 10건 (ficq, xv, pic, tic, ts, lt, fy, km, 비표준 prefix, area 누락)
### Phase B — API + UI (0.3일)
- `GET /api/kb/instruments/infer/start` (admin) — 백그라운드 잡 ID 반환
- `GET /api/kb/instruments/infer/status/{jobId}` — 진행률
- `GET /api/kb/instruments/infer/download/{jobId}` — xlsx 스트림
- 14번 탭: "Field Instrument 초안" 버튼 + 진행률 카드 + 다운로드 링크
- 업로드는 **기존 14번 탭 업로드** 그대로 (system_instrument 컬렉션 선택)
### Phase C — LLM 보강 (0.2일, 옵션)
- `infer.py``enrich_with_llm(rows, batch=50)` 추가
- vLLM 호출, 캐시 dict
- 토글로 on/off
### Phase D — 검증/소비 강화 (0.2일)
- `search_kb``system_instrument` 컬렉션 우선 가중 (이미 가능, 가중치만)
- `ToolGuideKo`에 "FT/FCV 등 현장 계기명 질의는 system_instrument 컬렉션 우선" 한 줄 추가
- 채팅 답변에 instrument hit 시 `parent_base_tag` 동시 노출
### Phase E (선택) — diff 도구
- 운영자가 수정한 Excel ↔ 룰 출력 비교 → 어떤 룰을 추가/수정하면 되는지 리포트
- 룰 진화 피드백 루프
총 1.2일 (옵션 제외 0.8일).
---
## 11. vault 마이그레이션 시 변환 (미래)
> 옵시디언 vault 구현이 시작되면 이 Excel이 **그대로 시드**.
| Excel 열 | vault 변환 |
|---------|----------|
| `instrument_id` | `instrument/{id}` 노트 ID |
| `display_name` | `title` |
| `role` | frontmatter `role` |
| `parent_base_tag` | 본문에 `[[tag/{parent}]]` 위키링크 |
| `loop`, `area` | frontmatter |
| `measures`, `data_points` | frontmatter |
| `from` (vault id 형식) | 본문 `## 입력` 섹션에 `[[from]]` |
| `to` (vault id 형식) | 본문 `## 출력` 섹션에 `[[to]]` |
| `description` | 본문 첫 단락 |
| `operator_notes` | 본문 `## 운영자 메모` 섹션 |
→ 변환기 1개로 노트 N개 + `vault_links` M개 자동 생성. Excel 수정 이력이 그대로 그래프가 됨.
---
## 12. 산출물 미리보기 (작업 후 KB가 답할 수 있는 질문)
| 질의 | 응답 가능성 (전) | 응답 가능성 (후) |
|------|----------------|----------------|
| "FT-6101 어디 붙어있어?" | ❌ (계기 매핑 없음) | ✅ parent_base_tag + area + from |
| "FCV-6101은 뭐가 제어해?" | ❌ | ✅ `to: tag/ficq-6101` 백트래킹 |
| "ficq-6101 관련 현장 계기 다 보여줘" | △ description ILIKE만 | ✅ parent_base_tag로 4행 일괄 |
| "ESD 차단밸브 목록" | ❌ | ✅ role=shutdown-valve 필터 |
| "FT-6101 점검 이력" | △ (운영자 노트가 KB 어딘가에 있다면) | ✅ 동일 + operator_notes 청크 매칭 |
---
## 13. 잔여 결정 (사용자 확인 필요)
| 항목 | 옵션 | 추천 |
|------|------|------|
| 첫 실행 LLM 사용 | (A) off (B) description만 on | **A** — 빠른 1차 산출, 운영자 작업 시작 |
| Excel 컬렉션 | `system_instrument` | 기존 시드 그대로 |
| ID 정규화 정책 | 무조건 소문자 kebab | 운영자가 대문자 입력해도 자동 변환 |
| 운영자 신규 role 허용 | 허용(경고만) / 차단 | **허용** — frontmatter 자유도 유지 |
| `from`/`to` 미입력 시 | placeholder 자동 / 공란 | **placeholder** — vault 시 빨간 링크로 가시화 |
| 재실행 정책 | 매번 새 Excel / seed_doc_id 기반 머지 | **머지** — 운영자 수정 보존 |
| unmatched_tags 시트 알림 | UI에 카운트 | 운영자 행동 유도 |
---
## 14. 핵심 메시지
- **신규 인프라 0** — 기존 xlsx 인덱싱·KB 업로드·Qdrant 컬렉션 그대로 활용.
- **롱 포맷 + role/from/to** — 단순 매핑 표가 아니라 **계기 그래프의 노드+엣지**. vault로 그대로 이전.
- **휴먼-인-더-루프** — LLM은 초안만, 운영자는 검증만. 백지에서 작성하는 것 대비 100배 빠름.
- **재실행 안전** — 룰 개선 시 운영자 수정 보존하고 신규만 추가.
- **즉시 효과** — 업로드 직후 채팅이 현장 계기명으로 답할 수 있음. 옵시디언 vault 풀구현을 기다리지 않아도 됨.

View File

@@ -0,0 +1,14 @@
# 현재 운엉상의 문제점
개선안 1) RAG관리 -> 초안생성시 : 계기 유추 전혀 안됨, FICQ-6101 로 부터 FT-6101, FCV-6101를 유추해서 초안에 만들어 줘야 함
예) 1. FICQ-XXXXX, FIC-XXXXX -> FT-6101, FCV-6101 유추, PICX-> PT & PCV, LICX->LT & LCV 등등 ( 태그명 XXCXX- 'C'가 있으면 CONTROL VALVE가 있다고 생각)
2. FI-XXXXX, FIA-XXXXX -> FT-XXXXX
3. TI-XXXXX, TIA-XXXXX, TIS-XXXXX -> TE-XXXXX (Temperature Element)
4. LI-XXXXX, LIA-XXXXX, LIS-XXXXX -> LT-XXXXX
5. PI-XXXXX, PIA-XXXXX, PIS-XXXXX -> PT-XXXXX
수정완료[x]
개선안 2) :
1. display_name : FT-10114a --> 모두 대문자로
2. 규칙에서 어긋난 태그명 : unmatched_tags로 분류할 것, fica-3102_op -> ft-3102_op, fcv-3102_op
3. lt-9113-lo-esd, lcv-9113-lo-esd 등등 %-esd% 인 것들 --> 시스템 포인트 임 계기 아님

View File

@@ -0,0 +1,680 @@
# No-10 플랜트 P&ID 추출 데이터
**도면**: `No-10_Plant_PID.dxf` (DXF)
**추출 일자**: 2026-05-14
**데이터 소스**: PostgreSQL `pid_equipment` 테이블 (460건, is_active=true)
**용도**: RAG 검색 보조용 — 운전원 질의 시 P&ID 컨텍스트 자동 인용
---
## 통계
| 구분 | 수량 |
|---|---|
| **파이프 (LineNo)** | 247 |
| **장비** | 78 |
| **계기** | 110 |
| **미분류** | 25 |
| **Experion 매핑 완료** | 25 |
### 영역(area)별 분포
| area 추정 | 항목 수 | 비고 |
|---|---|---|
| `10` | 318 | 태그 prefix 숫자 기반 추정 |
| `91` | 19 | 태그 prefix 숫자 기반 추정 |
| `61` | 17 | 태그 prefix 숫자 기반 추정 |
| `94` | 16 | 태그 prefix 숫자 기반 추정 |
| `1` | 13 | 태그 prefix 숫자 기반 추정 |
| `21` | 13 | 태그 prefix 숫자 기반 추정 |
| `62` | 12 | 태그 prefix 숫자 기반 추정 |
| `92` | 9 | 태그 prefix 숫자 기반 추정 |
| `51` | 8 | 태그 prefix 숫자 기반 추정 |
| `9` | 7 | 태그 prefix 숫자 기반 추정 |
| `29` | 5 | 태그 prefix 숫자 기반 추정 |
| `2` | 4 | 태그 prefix 숫자 기반 추정 |
| `32` | 3 | 태그 prefix 숫자 기반 추정 |
| `34` | 3 | 태그 prefix 숫자 기반 추정 |
| `0` | 2 | 태그 prefix 숫자 기반 추정 |
| `27` | 2 | 태그 prefix 숫자 기반 추정 |
| `31` | 2 | 태그 prefix 숫자 기반 추정 |
| `81` | 2 | 태그 prefix 숫자 기반 추정 |
| `53` | 1 | 태그 prefix 숫자 기반 추정 |
| `66` | 1 | 태그 prefix 숫자 기반 추정 |
| `7` | 1 | 태그 prefix 숫자 기반 추정 |
| `90` | 1 | 태그 prefix 숫자 기반 추정 |
| `99` | 1 | 태그 prefix 숫자 기반 추정 |
### 파이프 서비스(fluid) 분포
| service 코드 | 의미 | 수량 |
|---|---|---|
| `P` | PROCESS FLUID | 90 |
| `VG` | VENT GAS | 46 |
| `CWS` | COOLING WATER SUPPLY | 24 |
| `CWR` | COOLING WATER RETURN | 22 |
| `IA` | INSTRUMENT AIR | 20 |
| `SW` | SOFT WATER | 12 |
| `SAM` | SAMPLE LINE | 9 |
| `CD` | STEAM CONDENSATE | 6 |
| `NBD` | NITROGEN BLOW DOWN | 5 |
| `ST` | STEAM | 3 |
| `WW` | WASTE WATER | 3 |
| `CHR` | CHILLED WATER RETURN | 2 |
| `CHS` | CHILLED WATER SUPPLY | 2 |
| `SC` | VENT GAS | 2 |
| `PW` | PW | 1 |
### 장비 종류 분포
| prefix | 의미 | 수량 |
|---|---|---|
| `T` | Tank | 20 |
| `E` | Heat Exchanger | 18 |
| `P` | Pump | 15 |
| `D` | Drum | 5 |
| `C` | Column | 4 |
| `F` | Filter | 4 |
| `K` | Compressor | 4 |
| `DP` | Drainage Point | 3 |
| `VP` | Vacuum Pump | 2 |
| `BT` | Buffer Tank | 1 |
| `CH` | Chiller | 1 |
| `CT` | Cooling Tower | 1 |
### 계기 종류 분포
| prefix | 의미 | 수량 |
|---|---|---|
| `FCV` | Flow Control Valve | 46 |
| `PSV` | Pressure Switch Valve | 27 |
| `PCV` | Pressure Control Valve | 11 |
| `LCV` | Level Control Valve | 10 |
| `TCV` | Temperature Control Valve | 9 |
| `TR` | Temperature Recorder | 3 |
| `PFD` | Pressure | 2 |
| `SP` | Speed | 2 |
---
## 장비 목록
### BT — Buffer Tank (1대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `BT-6200` | 62 | `—` | 0.950 |
### C — Column (4대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `C-10111` | 10 | `—` | 0.950 |
| `C-10211` | 10 | `—` | 0.950 |
| `C-9111` | 91 | `—` | 0.950 |
| `C-9128` | 91 | `—` | 0.950 |
### CH — Chiller (1대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `CH-6601` | 66 | `—` | 0.950 |
### CT — Cooling Tower (1대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `CT-10601` | 10 | `—` | 0.950 |
### D — Drum (5대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `D-10113` | 10 | `—` | 0.950 |
| `D-10213` | 10 | `—` | 0.950 |
| `D-10901` | 10 | `—` | 0.950 |
| `D-2901` | 29 | `—` | 0.950 |
| `D-901` | 9 | `—` | 0.950 |
### DP — Drainage Point (3대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `DP-10101` | 10 | `—` | 0.950 |
| `DP-10201` | 10 | `—` | 0.950 |
| `DP-3210` | 32 | `—` | 0.950 |
### E — Heat Exchanger (18대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `E-10103` | 10 | `—` | 0.950 |
| `E-10112` | 10 | `—` | 0.950 |
| `E-10115` | 10 | `—` | 0.950 |
| `E-10115A` | 10 | `—` | 0.950 |
| `E-10115B` | 10 | `—` | 0.950 |
| `E-10117` | 10 | `—` | 0.950 |
| `E-10119` | 10 | `—` | 0.950 |
| `E-10203` | 10 | `—` | 0.950 |
| `E-10212` | 10 | `—` | 0.950 |
| `E-10215` | 10 | `—` | 0.950 |
| `E-10217` | 10 | `—` | 0.950 |
| `E-10219` | 10 | `—` | 0.950 |
| `E-8115` | 81 | `—` | 0.950 |
| `E-9103` | 91 | `—` | 0.950 |
| `E-9112` | 91 | `—` | 0.950 |
| `E-9115` | 91 | `—` | 0.950 |
| `E-9203` | 92 | `—` | 0.950 |
| `E-9215` | 92 | `—` | 0.950 |
### F — Filter (4대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `F-10900` | 10 | `—` | 0.950 |
| `F-10952` | 10 | `—` | 0.950 |
| `F-2952` | 29 | `—` | 0.950 |
| `F-952` | 9 | `—` | 0.950 |
### K — Compressor (4대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `K-10901` | 10 | `—` | 0.500 |
| `K-2901` | 29 | `—` | 0.500 |
| `K-901A` | 9 | `—` | 0.500 |
| `K-901B` | 9 | `—` | 0.500 |
### P — Pump (15대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `P-10101` | 10 | `p-10101.pv` | 0.950 |
| `P-10114` | 10 | `p-10114.pv` | 0.950 |
| `P-10116` | 10 | `p-10116.pv` | 0.950 |
| `P-10118` | 10 | `p-10118.pv` | 0.950 |
| `P-10201` | 10 | `p-10201.pv` | 0.950 |
| `P-10214` | 10 | `p-10214.pv` | 0.950 |
| `P-10216` | 10 | `p-10216.pv` | 0.950 |
| `P-10218` | 10 | `p-10218.pv` | 0.950 |
| `P-10221` | 10 | `p-10221.pv` | 0.950 |
| `P-201` | 2 | `p-201.pv` | 0.950 |
| `P-202` | 2 | `p-202.pv` | 0.950 |
| `P-3101` | 31 | `—` | 0.950 |
| `P-5101` | 51 | `—` | 0.950 |
| `P-6101` | 61 | `p-6101.pv` | 0.950 |
| `P-9102` | 91 | `p-9102.pv` | 0.950 |
### T — Tank (20대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `T-10100` | 10 | `—` | 0.950 |
| `T-10101` | 10 | `—` | 0.950 |
| `T-10200` | 10 | `—` | 0.950 |
| `T-10201` | 10 | `—` | 0.950 |
| `T-10221` | 10 | `—` | 0.950 |
| `T-10800` | 10 | `—` | 0.950 |
| `T-201` | 2 | `—` | 0.950 |
| `T-202` | 2 | `—` | 0.950 |
| `T-2704` | 27 | `—` | 0.950 |
| `T-3101` | 31 | `—` | 0.950 |
| `T-3210` | 32 | `—` | 0.950 |
| `T-6121` | 61 | `—` | 0.950 |
| `T-6122` | 61 | `—` | 0.950 |
| `T-6125` | 61 | `—` | 0.950 |
| `T-6126` | 61 | `—` | 0.950 |
| `T-6222` | 62 | `—` | 0.950 |
| `T-8121` | 81 | `—` | 0.950 |
| `T-9123` | 91 | `—` | 0.950 |
| `T-9124` | 91 | `—` | 0.950 |
| `T-9125` | 91 | `—` | 0.950 |
### VP — Vacuum Pump (2대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `VP-10117` | 10 | `vp-10117.pv` | 0.950 |
| `VP-10217` | 10 | `vp-10217.pv` | 0.950 |
---
## 계기 목록
### FCV — Flow Control Valve (46개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `FCV-10101` | 10 | `—` | 0.950 |
| `FCV-10113` | 10 | `—` | 0.950 |
| `FCV-10114A` | 10 | `—` | 0.950 |
| `FCV-10116` | 10 | `—` | 0.950 |
| `FCV-10118` | 10 | `—` | 0.950 |
| `FCV-10201` | 10 | `—` | 0.950 |
| `FCV-10213` | 10 | `—` | 0.950 |
| `FCV-10214` | 10 | `—` | 0.950 |
| `FCV-10216` | 10 | `—` | 0.950 |
| `FCV-10218` | 10 | `—` | 0.950 |
| `FCV-111` | 1 | `—` | 0.950 |
| `FCV-113` | 1 | `—` | 0.950 |
| `FCV-122` | 1 | `—` | 0.950 |
| `FCV-123` | 1 | `—` | 0.950 |
| `FCV-124` | 1 | `—` | 0.950 |
| `FCV-131` | 1 | `—` | 0.950 |
| `FCV-2111` | 21 | `—` | 0.950 |
| `FCV-2122` | 21 | `—` | 0.950 |
| `FCV-2123` | 21 | `—` | 0.950 |
| `FCV-2124` | 21 | `—` | 0.950 |
| `FCV-2131` | 21 | `—` | 0.950 |
| `FCV-5101` | 51 | `—` | 0.950 |
| `FCV-5113` | 51 | `—` | 0.950 |
| `FCV-5114` | 51 | `—` | 0.950 |
| `FCV-5116` | 51 | `—` | 0.950 |
| `FCV-5118` | 51 | `—` | 0.950 |
| `FCV-6101` | 61 | `—` | 0.950 |
| `FCV-6113` | 61 | `—` | 0.950 |
| `FCV-6114` | 61 | `—` | 0.950 |
| `FCV-6116` | 61 | `—` | 0.950 |
| `FCV-6118` | 61 | `—` | 0.950 |
| `FCV-6201` | 62 | `—` | 0.950 |
| `FCV-6213` | 62 | `—` | 0.950 |
| `FCV-6214` | 62 | `—` | 0.950 |
| `FCV-6216` | 62 | `—` | 0.950 |
| `FCV-6218` | 62 | `—` | 0.950 |
| `FCV-9101` | 91 | `—` | 0.950 |
| `FCV-9113` | 91 | `—` | 0.950 |
| `FCV-9114` | 91 | `—` | 0.950 |
| `FCV-9116` | 91 | `—` | 0.950 |
| `FCV-9118` | 91 | `—` | 0.950 |
| `FCV-9201` | 92 | `—` | 0.950 |
| `FCV-9213` | 92 | `—` | 0.950 |
| `FCV-9214` | 92 | `—` | 0.950 |
| `FCV-9216` | 92 | `—` | 0.950 |
| `FCV-9218` | 92 | `—` | 0.950 |
### LCV — Level Control Valve (10개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `LCV-111` | 1 | `—` | 0.950 |
| `LCV-113` | 1 | `—` | 0.950 |
| `LCV-121` | 1 | `—` | 0.950 |
| `LCV-131` | 1 | `—` | 0.950 |
| `LCV-2111` | 21 | `—` | 0.950 |
| `LCV-2113` | 21 | `—` | 0.950 |
| `LCV-2121` | 21 | `—` | 0.950 |
| `LCV-2131` | 21 | `—` | 0.950 |
| `LCV-2705` | 27 | `—` | 0.950 |
| `LCV-3402` | 34 | `—` | 0.950 |
### PCV — Pressure Control Valve (11개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `PCV-10111` | 10 | `—` | 0.950 |
| `PCV-10211` | 10 | `—` | 0.950 |
| `PCV-111` | 1 | `—` | 0.950 |
| `PCV-121` | 1 | `—` | 0.950 |
| `PCV-2111` | 21 | `—` | 0.950 |
| `PCV-2121` | 21 | `—` | 0.950 |
| `PCV-5111` | 51 | `—` | 0.950 |
| `PCV-6111` | 61 | `—` | 0.950 |
| `PCV-6211` | 62 | `—` | 0.950 |
| `PCV-9111` | 91 | `—` | 0.950 |
| `PCV-9211` | 92 | `—` | 0.950 |
### PFD — Pressure (2개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `PFD-001` | 0 | `—` | 0.950 |
| `PFD-002` | 0 | `—` | 0.950 |
### PSV — Pressure Switch Valve (27개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `PSV-10101` | 10 | `—` | 0.950 |
| `PSV-10103` | 10 | `—` | 0.950 |
| `PSV-10111` | 10 | `—` | 0.950 |
| `PSV-10112` | 10 | `—` | 0.950 |
| `PSV-10115` | 10 | `—` | 0.950 |
| `PSV-10117` | 10 | `—` | 0.950 |
| `PSV-10119A` | 10 | `—` | 0.950 |
| `PSV-10119B` | 10 | `—` | 0.950 |
| `PSV-10201` | 10 | `—` | 0.950 |
| `PSV-10203` | 10 | `—` | 0.950 |
| `PSV-10211` | 10 | `—` | 0.950 |
| `PSV-10212` | 10 | `—` | 0.950 |
| `PSV-10215` | 10 | `—` | 0.950 |
| `PSV-10217` | 10 | `—` | 0.950 |
| `PSV-10219A` | 10 | `—` | 0.950 |
| `PSV-10219B` | 10 | `—` | 0.950 |
| `PSV-10900` | 10 | `—` | 0.950 |
| `PSV-10900A` | 10 | `—` | 0.950 |
| `PSV-10900B` | 10 | `—` | 0.950 |
| `PSV-10901` | 10 | `—` | 0.950 |
| `PSV-10902` | 10 | `—` | 0.950 |
| `PSV-2900` | 29 | `—` | 0.950 |
| `PSV-2901` | 29 | `—` | 0.950 |
| `PSV-6203` | 62 | `—` | 0.950 |
| `PSV-900A` | 9 | `—` | 0.950 |
| `PSV-900B` | 9 | `—` | 0.950 |
| `PSV-901` | 9 | `—` | 0.950 |
### SP — Speed (2개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `SP-10601` | 10 | `—` | 0.950 |
| `SP-10602` | 10 | `—` | 0.950 |
### TCV — Temperature Control Valve (9개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `TCV-10111` | 10 | `—` | 0.950 |
| `TCV-10211` | 10 | `—` | 0.950 |
| `TCV-111` | 1 | `—` | 0.950 |
| `TCV-2111` | 21 | `—` | 0.950 |
| `TCV-5111` | 51 | `—` | 0.950 |
| `TCV-6111` | 61 | `—` | 0.950 |
| `TCV-6211` | 62 | `—` | 0.950 |
| `TCV-9111` | 91 | `—` | 0.950 |
| `TCV-9211` | 92 | `—` | 0.950 |
### TR — Temperature Recorder (3개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `TR-10115A` | 10 | `—` | 0.950 |
| `TR-10115B` | 10 | `—` | 0.950 |
| `TR-10215` | 10 | `—` | 0.950 |
---
## 파이프(LineNo) 목록
총 247개 파이프 라인. 각 라인은 `service-line_no-size-spec-insul` 형식으로 명명됨.
| tag_no | service (fluid) | line_no | size | material | flange | insul |
|---|---|---|---|---|---|---|
| `CD-10513-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10513 | 40A | S | 1 | AH50 |
| `CD-10514-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10514 | 40A | S | 1 | AH50 |
| `CD-10515-65A-S1A-H50` | CD (STEAM CONDENSATE) | 10515 | 65A | S | 1 | AH50 |
| `CD-10516-65A-S1A-H50` | CD (STEAM CONDENSATE) | 10516 | 65A | S | 1 | AH50 |
| `CD-10522-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10522 | 40A | S | 1 | AH50 |
| `CD-10523-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10523 | 40A | S | 1 | AH50 |
| `CHR-10641-65A-F1A-C50` | CHR (CHILLED WATER RETURN) | 10641 | 65A | F | 1 | AC50 |
| `CHR-10642-50A-F1A-C50` | CHR (CHILLED WATER RETURN) | 10642 | 50A | F | 1 | AC50 |
| `CHS-10631-65A-F1A-C50` | CHS (CHILLED WATER SUPPLY) | 10631 | 65A | F | 1 | AC50 |
| `CHS-10632-50A-F1A-C50` | CHS (CHILLED WATER SUPPLY) | 10632 | 50A | F | 1 | AC50 |
| `CWR-10620-300A-S2A-N` | CWR (COOLING WATER RETURN) | 10620 | 300A | S | 2 | AN |
| `CWR-10621-80A-S2A-N` | CWR (COOLING WATER RETURN) | 10621 | 80A | S | 2 | AN |
| `CWR-10621-80A-S2A-n` | CWR (COOLING WATER RETURN) | 10621 | 80A | S | 2 | An |
| `CWR-10622-200A-S2A-N` | CWR (COOLING WATER RETURN) | 10622 | 200A | S | 2 | AN |
| `CWR-10622-200A-S2A-n` | CWR (COOLING WATER RETURN) | 10622 | 200A | S | 2 | An |
| `CWR-10623-50A-S2A-N` | CWR (COOLING WATER RETURN) | 10623 | 50A | S | 2 | AN |
| `CWR-10623-50A-S2A-n` | CWR (COOLING WATER RETURN) | 10623 | 50A | S | 2 | An |
| `CWR-10624-150A-S2A-N` | CWR (COOLING WATER RETURN) | 10624 | 150A | S | 2 | AN |
| `CWR-10624-150A-S2A-n` | CWR (COOLING WATER RETURN) | 10624 | 150A | S | 2 | An |
| `CWR-10625-50A-F1A-N` | CWR (COOLING WATER RETURN) | 10625 | 50A | F | 1 | AN |
| `CWR-10625-50A-S2A-N` | CWR (COOLING WATER RETURN) | 10625 | 50A | S | 2 | AN |
| `CWR-10625-50A-S2A-n` | CWR (COOLING WATER RETURN) | 10625 | 50A | S | 2 | An |
| `CWR-10626-25A-F1A-N` | CWR (COOLING WATER RETURN) | 10626 | 25A | F | 1 | AN |
| `CWR-10626-25A-F1A-n` | CWR (COOLING WATER RETURN) | 10626 | 25A | F | 1 | An |
| `CWR-10626-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10626 | 25A | S | 2 | AN |
| `CWR-10627-25A-F1A-N` | CWR (COOLING WATER RETURN) | 10627 | 25A | F | 1 | AN |
| `CWR-10627-25A-F1A-n` | CWR (COOLING WATER RETURN) | 10627 | 25A | F | 1 | An |
| `CWR-10627-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10627 | 25A | S | 2 | AN |
| `CWR-10628-15A-F1A-N` | CWR (COOLING WATER RETURN) | 10628 | 15A | F | 1 | AN |
| `CWR-10628-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10628 | 25A | S | 2 | AN |
| `CWR-10629-15A-F1A-N` | CWR (COOLING WATER RETURN) | 10629 | 15A | F | 1 | AN |
| `CWR-10629-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10629 | 25A | S | 2 | AN |
| `CWS-10600-300A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10600 | 300A | S | 2 | AN |
| `CWS-10601-300A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10601 | 300A | S | 2 | AN |
| `CWS-10611-80A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10611 | 80A | S | 2 | AN |
| `CWS-10611-80A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10611 | 80A | S | 2 | An |
| `CWS-10612-200A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10612 | 200A | S | 2 | AN |
| `CWS-10612-200A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10612 | 200A | S | 2 | An |
| `CWS-10613-50A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10613 | 50A | S | 2 | AN |
| `CWS-10613-50A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10613 | 50A | S | 2 | An |
| `CWS-10614-150A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10614 | 150A | S | 2 | AN |
| `CWS-10614-150A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10614 | 150A | S | 2 | An |
| `CWS-10615-50A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10615 | 50A | F | 1 | AN |
| `CWS-10615-50A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10615 | 50A | S | 2 | AN |
| `CWS-10615-50A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10615 | 50A | S | 2 | An |
| `CWS-10616-25A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10616 | 25A | F | 1 | AN |
| `CWS-10616-25A-F1A-n` | CWS (COOLING WATER SUPPLY) | 10616 | 25A | F | 1 | An |
| `CWS-10616-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10616 | 25A | S | 2 | AN |
| `CWS-10617-25A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10617 | 25A | F | 1 | AN |
| `CWS-10617-25A-F1A-n` | CWS (COOLING WATER SUPPLY) | 10617 | 25A | F | 1 | An |
| `CWS-10617-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10617 | 25A | S | 2 | AN |
| `CWS-10618-15A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10618 | 15A | F | 1 | AN |
| `CWS-10618-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10618 | 25A | S | 2 | AN |
| `CWS-10619-15A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10619 | 15A | F | 1 | AN |
| `CWS-10619-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10619 | 25A | S | 2 | AN |
| `CWS-10620-300A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10620 | 300A | S | 2 | AN |
| `IA-10900-25A-F1A-n` | IA (INSTRUMENT AIR) | 10900 | 25A | F | 1 | An |
| `IA-10901-15A-F1A-n` | IA (INSTRUMENT AIR) | 10901 | 15A | F | 1 | An |
| `IA-10902-15A-F1A-n` | IA (INSTRUMENT AIR) | 10902 | 15A | F | 1 | An |
| `IA-10903-15A-F1A-n` | IA (INSTRUMENT AIR) | 10903 | 15A | F | 1 | An |
| `IA-10904-15A-F1A-n` | IA (INSTRUMENT AIR) | 10904 | 15A | F | 1 | An |
| `IA-10905-15A-F1A-n` | IA (INSTRUMENT AIR) | 10905 | 15A | F | 1 | An |
| `IA-10906-15A-F1A-n` | IA (INSTRUMENT AIR) | 10906 | 15A | F | 1 | An |
| `IA-10907-15A-F1A-n` | IA (INSTRUMENT AIR) | 10907 | 15A | F | 1 | An |
| `IA-10908-15A-F1A-n` | IA (INSTRUMENT AIR) | 10908 | 15A | F | 1 | An |
| `IA-10909-15A-F1A-n` | IA (INSTRUMENT AIR) | 10909 | 15A | F | 1 | An |
| `IA-10910-15A-F1A-n` | IA (INSTRUMENT AIR) | 10910 | 15A | F | 1 | An |
| `IA-10912-15A-F1A-n` | IA (INSTRUMENT AIR) | 10912 | 15A | F | 1 | An |
| `IA-10913-25A-F1A-n` | IA (INSTRUMENT AIR) | 10913 | 25A | F | 1 | An |
| `IA-10914-25A-F1A-n` | IA (INSTRUMENT AIR) | 10914 | 25A | F | 1 | An |
| `IA-10915-15A-F1A-n` | IA (INSTRUMENT AIR) | 10915 | 15A | F | 1 | An |
| `IA-10918-15A-F1A-n` | IA (INSTRUMENT AIR) | 10918 | 15A | F | 1 | An |
| `IA-10919-15A-F1A-n` | IA (INSTRUMENT AIR) | 10919 | 15A | F | 1 | An |
| `IA-10920-15A-F1A-n` | IA (INSTRUMENT AIR) | 10920 | 15A | F | 1 | An |
| `IA-10921-15A-F1A-n` | IA (INSTRUMENT AIR) | 10921 | 15A | F | 1 | An |
| `IA-10922-25A-F1A-n` | IA (INSTRUMENT AIR) | 10922 | 25A | F | 1 | An |
| `NBD-10100` | NBD (NITROGEN BLOW DOWN) | 10100 | — | — | — | — |
| `NBD-10101` | NBD (NITROGEN BLOW DOWN) | 10101 | — | — | — | — |
| `NBD-10200` | NBD (NITROGEN BLOW DOWN) | 10200 | — | — | — | — |
| `NBD-10201` | NBD (NITROGEN BLOW DOWN) | 10201 | — | — | — | — |
| `NBD-10221` | NBD (NITROGEN BLOW DOWN) | 10221 | — | — | — | — |
| `P-10101-25A-F1A-n` | P (PROCESS FLUID) | 10101 | 25A | F | 1 | An |
| `P-10102-25A-F1A-n` | P (PROCESS FLUID) | 10102 | 25A | F | 1 | An |
| `P-10103-25A-F1A-n` | P (PROCESS FLUID) | 10103 | 25A | F | 1 | An |
| `P-10104-25A-F1A-n` | P (PROCESS FLUID) | 10104 | 25A | F | 1 | An |
| `P-10106-25A-F1A-n` | P (PROCESS FLUID) | 10106 | 25A | F | 1 | An |
| `P-10107-40A-F1A-n` | P (PROCESS FLUID) | 10107 | 40A | F | 1 | An |
| `P-10109-40A-F1A-n` | P (PROCESS FLUID) | 10109 | 40A | F | 1 | An |
| `P-10110-40A-F1A-n` | P (PROCESS FLUID) | 10110 | 40A | F | 1 | An |
| `P-10111-25A-F1A-n` | P (PROCESS FLUID) | 10111 | 25A | F | 1 | An |
| `P-10112-25A-F1A-n` | P (PROCESS FLUID) | 10112 | 25A | F | 1 | An |
| `P-10113-25A-F1A-H50` | P (PROCESS FLUID) | 10113 | 25A | F | 1 | AH50 |
| `P-10114-250A-F2A-H50` | P (PROCESS FLUID) | 10114 | 250A | F | 2 | AH50 |
| `P-10115-200A-F2A-H50` | P (PROCESS FLUID) | 10115 | 200A | F | 2 | AH50 |
| `P-10116-200A-F2A-H50` | P (PROCESS FLUID) | 10116 | 200A | F | 2 | AH50 |
| `P-10117-300A-F2A-H50` | P (PROCESS FLUID) | 10117 | 300A | F | 2 | AH50 |
| `P-10117-500A-F2A-H50` | P (PROCESS FLUID) | 10117 | 500A | F | 2 | AH50 |
| `P-10118-300A-F2A-H50` | P (PROCESS FLUID) | 10118 | 300A | F | 2 | AH50 |
| `P-10119-32A-F1A-H50` | P (PROCESS FLUID) | 10119 | 32A | F | 1 | AH50 |
| `P-10120-25A-F1A-H50` | P (PROCESS FLUID) | 10120 | 25A | F | 1 | AH50 |
| `P-10121-25A-F1A-H50` | P (PROCESS FLUID) | 10121 | 25A | F | 1 | AH50 |
| `P-10122-20A-F1A-n` | P (PROCESS FLUID) | 10122 | 20A | F | 1 | An |
| `P-10122-25A-F1A-N` | P (PROCESS FLUID) | 10122 | 25A | F | 1 | AN |
| `P-10123-20A-F1A-n` | P (PROCESS FLUID) | 10123 | 20A | F | 1 | An |
| `P-10124-20A-F1A-n` | P (PROCESS FLUID) | 10124 | 20A | F | 1 | An |
| `P-10125-20A-F1A-n` | P (PROCESS FLUID) | 10125 | 20A | F | 1 | An |
| `P-10126-20A-F1A-n` | P (PROCESS FLUID) | 10126 | 20A | F | 1 | An |
| `P-10127-65A-F2A-n` | P (PROCESS FLUID) | 10127 | 65A | F | 2 | An |
| `P-10128-50A-F2A-n` | P (PROCESS FLUID) | 10128 | 50A | F | 2 | An |
| `P-10129-25A-F2A-n` | P (PROCESS FLUID) | 10129 | 25A | F | 2 | An |
| `P-10130-25A-F2A-n` | P (PROCESS FLUID) | 10130 | 25A | F | 2 | An |
| `P-10132-25A-F2A-n` | P (PROCESS FLUID) | 10132 | 25A | F | 2 | An |
| `P-10133-25A-F2A-n` | P (PROCESS FLUID) | 10133 | 25A | F | 2 | An |
| `P-10134-25A-F2A-n` | P (PROCESS FLUID) | 10134 | 25A | F | 2 | An |
| `P-10135-25A-F2A-n` | P (PROCESS FLUID) | 10135 | 25A | F | 2 | An |
| `P-10136-25A-F2A-n` | P (PROCESS FLUID) | 10136 | 25A | F | 2 | An |
| `P-10137-25A-F1A-n` | P (PROCESS FLUID) | 10137 | 25A | F | 1 | An |
| `P-10138-600A-F2A-H100` | P (PROCESS FLUID) | 10138 | 600A | F | 2 | AH100 |
| `P-10139-600A-F2A-n` | P (PROCESS FLUID) | 10139 | 600A | F | 2 | An |
| `P-10140-40A-F2A-n` | P (PROCESS FLUID) | 10140 | 40A | F | 2 | An |
| `P-10141-32A-F2A-n` | P (PROCESS FLUID) | 10141 | 32A | F | 2 | An |
| `P-10142-25A-F2A-n` | P (PROCESS FLUID) | 10142 | 25A | F | 2 | An |
| `P-10143-32A-F2A-n` | P (PROCESS FLUID) | 10143 | 32A | F | 2 | An |
| `P-10144-25A-F1A-n` | P (PROCESS FLUID) | 10144 | 25A | F | 1 | An |
| `P-10146-20A-F1A-n` | P (PROCESS FLUID) | 10146 | 20A | F | 1 | An |
| `P-10147-25A-F1A-n` | P (PROCESS FLUID) | 10147 | 25A | F | 1 | An |
| `P-10148-25A-F1A-n` | P (PROCESS FLUID) | 10148 | 25A | F | 1 | An |
| `P-10149-40A-F1A-n` | P (PROCESS FLUID) | 10149 | 40A | F | 1 | An |
| `P-10150-25A-F1A-n` | P (PROCESS FLUID) | 10150 | 25A | F | 1 | An |
| `P-10151-15A-F1A-n` | P (PROCESS FLUID) | 10151 | 15A | F | 1 | An |
| `P-10201-25A-F1A-n` | P (PROCESS FLUID) | 10201 | 25A | F | 1 | An |
| `P-10202-40A-F1A-n` | P (PROCESS FLUID) | 10202 | 40A | F | 1 | An |
| `P-10203-40A-F1A-n` | P (PROCESS FLUID) | 10203 | 40A | F | 1 | An |
| `P-10204-25A-F1A-n` | P (PROCESS FLUID) | 10204 | 25A | F | 1 | An |
| `P-10205-25A-F1A-n` | P (PROCESS FLUID) | 10205 | 25A | F | 1 | An |
| `P-10207-25A-F1A-H50` | P (PROCESS FLUID) | 10207 | 25A | F | 1 | AH50 |
| `P-10208-400A-F2A-H50` | P (PROCESS FLUID) | 10208 | 400A | F | 2 | AH50 |
| `P-10209-200A-F2A-H50` | P (PROCESS FLUID) | 10209 | 200A | F | 2 | AH50 |
| `P-10210-25A-F1A-H50` | P (PROCESS FLUID) | 10210 | 25A | F | 1 | AH50 |
| `P-10211-20A-F1A-H50` | P (PROCESS FLUID) | 10211 | 20A | F | 1 | AH50 |
| `P-10212-20A-F1A-H50` | P (PROCESS FLUID) | 10212 | 20A | F | 1 | AH50 |
| `P-10213-20A-F1A-n` | P (PROCESS FLUID) | 10213 | 20A | F | 1 | An |
| `P-10214-20A-F1A-n` | P (PROCESS FLUID) | 10214 | 20A | F | 1 | An |
| `P-10215-20A-F1A-n` | P (PROCESS FLUID) | 10215 | 20A | F | 1 | An |
| `P-10216-20A-F1A-n` | P (PROCESS FLUID) | 10216 | 20A | F | 1 | An |
| `P-10217-65A-F2A-n` | P (PROCESS FLUID) | 10217 | 65A | F | 2 | An |
| `P-10218-40A-F2A-n` | P (PROCESS FLUID) | 10218 | 40A | F | 2 | An |
| `P-10219-25A-F2A-n` | P (PROCESS FLUID) | 10219 | 25A | F | 2 | An |
| `P-10220-25A-F1A-n` | P (PROCESS FLUID) | 10220 | 25A | F | 1 | An |
| `P-10221-25A-F2A-n` | P (PROCESS FLUID) | 10221 | 25A | F | 2 | An |
| `P-10222-25A-F2A-n` | P (PROCESS FLUID) | 10222 | 25A | F | 2 | An |
| `P-10223-25A-F2A-n` | P (PROCESS FLUID) | 10223 | 25A | F | 2 | An |
| `P-10224-40A-F2A-n` | P (PROCESS FLUID) | 10224 | 40A | F | 2 | An |
| `P-10225-25A-F2A-n` | P (PROCESS FLUID) | 10225 | 25A | F | 2 | An |
| `P-10226-25A-F2A-n` | P (PROCESS FLUID) | 10226 | 25A | F | 2 | An |
| `P-10227-25A-F2A-n` | P (PROCESS FLUID) | 10227 | 25A | F | 2 | An |
| `P-10228-500A-F2A-H100` | P (PROCESS FLUID) | 10228 | 500A | F | 2 | AH100 |
| `P-10228-500A-F2A-n` | P (PROCESS FLUID) | 10228 | 500A | F | 2 | An |
| `P-10229-40A-F2A-n` | P (PROCESS FLUID) | 10229 | 40A | F | 2 | An |
| `P-10230-25A-F2A-n` | P (PROCESS FLUID) | 10230 | 25A | F | 2 | An |
| `P-10231-15A-F2A-n` | P (PROCESS FLUID) | 10231 | 15A | F | 2 | An |
| `P-10232-25A-F1A-n` | P (PROCESS FLUID) | 10232 | 25A | F | 1 | An |
| `P-10233-25A-F2A-n` | P (PROCESS FLUID) | 10233 | 25A | F | 2 | An |
| `P-10234-15A-F1A-n` | P (PROCESS FLUID) | 10234 | 15A | F | 1 | An |
| `P-10235-15A-F1A-n` | P (PROCESS FLUID) | 10235 | 15A | F | 1 | An |
| `P-10236-40A-F1A-n` | P (PROCESS FLUID) | 10236 | 40A | F | 1 | An |
| `P-10237-40A-F1A-n` | P (PROCESS FLUID) | 10237 | 40A | F | 1 | An |
| `P-10311-125A-F1A-n` | P (PROCESS FLUID) | 10311 | 125A | F | 1 | An |
| `P-10312-100A-F1A-n` | P (PROCESS FLUID) | 10312 | 100A | F | 1 | An |
| `P-10313-100A-F1A-n` | P (PROCESS FLUID) | 10313 | 100A | F | 1 | An |
| `P-10314-80A-F1A-n` | P (PROCESS FLUID) | 10314 | 80A | F | 1 | An |
| `PW-10903-25A-F1A-E40` | PW (PW) | 10903 | 25A | F | 1 | AE40 |
| `SAM-10951-10A-F2A-n` | SAM (SAMPLE LINE) | 10951 | 10A | F | 2 | An |
| `SAM-10952-10A-F2A-n` | SAM (SAMPLE LINE) | 10952 | 10A | F | 2 | An |
| `SAM-10953-10A-F2A-n` | SAM (SAMPLE LINE) | 10953 | 10A | F | 2 | An |
| `SAM-10954-10A-F2A-n` | SAM (SAMPLE LINE) | 10954 | 10A | F | 2 | An |
| `SAM-10955-10A-F2A-n` | SAM (SAMPLE LINE) | 10955 | 10A | F | 2 | An |
| `SAM-10956-10A-F2A-n` | SAM (SAMPLE LINE) | 10956 | 10A | F | 2 | An |
| `SAM-10957-10A-F2A-n` | SAM (SAMPLE LINE) | 10957 | 10A | F | 2 | An |
| `SAM-10958-10A-F2A-n` | SAM (SAMPLE LINE) | 10958 | 10A | F | 2 | An |
| `SAM-9954-10A-F2A-n` | SAM (SAMPLE LINE) | 9954 | 10A | F | 2 | An |
| `SC-10128` | SC (VENT GAS) | 10128 | — | — | — | — |
| `SC-9128` | SC (VENT GAS) | 9128 | — | — | — | — |
| `ST-10511-100A-S1A-H50` | ST (STEAM) | 10511 | 100A | S | 1 | AH50 |
| `ST-10512-100A-S1A-H50` | ST (STEAM) | 10512 | 100A | S | 1 | AH50 |
| `ST-10521-65A-S1A-H50` | ST (STEAM) | 10521 | 65A | S | 1 | AH50 |
| `SW-10801-50A-F1A-E50` | SW (SOFT WATER) | 10801 | 50A | F | 1 | AE50 |
| `SW-10802-25A-F1A-E50` | SW (SOFT WATER) | 10802 | 25A | F | 1 | AE50 |
| `SW-10803-20A-F1A-E50` | SW (SOFT WATER) | 10803 | 20A | F | 1 | AE50 |
| `SW-10804-25A-F1A-E50` | SW (SOFT WATER) | 10804 | 25A | F | 1 | AE50 |
| `SW-10805-25A-F1A-E50` | SW (SOFT WATER) | 10805 | 25A | F | 1 | AE50 |
| `SW-10806-25A-F1A-E50` | SW (SOFT WATER) | 10806 | 25A | F | 1 | AE50 |
| `SW-10807-25A-F1A-E50` | SW (SOFT WATER) | 10807 | 25A | F | 1 | AE50 |
| `SW-10808-25A-F1A-E50` | SW (SOFT WATER) | 10808 | 25A | F | 1 | AE50 |
| `SW-10809-15A-F1A-E50` | SW (SOFT WATER) | 10809 | 15A | F | 1 | AE50 |
| `SW-10810-25A-F1A-E50` | SW (SOFT WATER) | 10810 | 25A | F | 1 | AE50 |
| `SW-10821-50A-F1A-E50` | SW (SOFT WATER) | 10821 | 50A | F | 1 | AE50 |
| `SW-10822-32A-F1A-E50` | SW (SOFT WATER) | 10822 | 32A | F | 1 | AE50 |
| `VG-10401-150A-F1A-N` | VG (VENT GAS) | 10401 | 150A | F | 1 | AN |
| `VG-10411-65A-F1A-N` | VG (VENT GAS) | 10411 | 65A | F | 1 | AN |
| `VG-10411-65A-F1A-n` | VG (VENT GAS) | 10411 | 65A | F | 1 | An |
| `VG-10412-50A-F1A-N` | VG (VENT GAS) | 10412 | 50A | F | 1 | AN |
| `VG-10412-50A-F1A-n` | VG (VENT GAS) | 10412 | 50A | F | 1 | An |
| `VG-10421-50A-F1A-N` | VG (VENT GAS) | 10421 | 50A | F | 1 | AN |
| `VG-10421-50A-F1A-n` | VG (VENT GAS) | 10421 | 50A | F | 1 | An |
| `VG-10422-50A-F1A-N` | VG (VENT GAS) | 10422 | 50A | F | 1 | AN |
| `VG-10422-50A-F1A-n` | VG (VENT GAS) | 10422 | 50A | F | 1 | An |
| `VG-10423-50A-F1A-N` | VG (VENT GAS) | 10423 | 50A | F | 1 | AN |
| `VG-10424-50A-F1A-N` | VG (VENT GAS) | 10424 | 50A | F | 1 | AN |
| `VG-10425-50A-F1A-N` | VG (VENT GAS) | 10425 | 50A | F | 1 | AN |
| `VG-10426-50A-F1A-N` | VG (VENT GAS) | 10426 | 50A | F | 1 | AN |
| `VG-10431-50A-F1A-N` | VG (VENT GAS) | 10431 | 50A | F | 1 | AN |
| `VG-10431-50A-F1A-n` | VG (VENT GAS) | 10431 | 50A | F | 1 | An |
| `VG-10432-50A-F1A-N` | VG (VENT GAS) | 10432 | 50A | F | 1 | AN |
| `VG-10432-50A-F1A-n` | VG (VENT GAS) | 10432 | 50A | F | 1 | An |
| `VG-10433-50A-F1A-N` | VG (VENT GAS) | 10433 | 50A | F | 1 | AN |
| `VG-10433-50A-F1A-n` | VG (VENT GAS) | 10433 | 50A | F | 1 | An |
| `VG-10440-300A-F1A-N` | VG (VENT GAS) | 10440 | 300A | F | 1 | AN |
| `VG-10441-125A-F1A-N` | VG (VENT GAS) | 10441 | 125A | F | 1 | AN |
| `VG-10441-200A-F1A-N` | VG (VENT GAS) | 10441 | 200A | F | 1 | AN |
| `VG-10441-200A-F1A-n` | VG (VENT GAS) | 10441 | 200A | F | 1 | An |
| `VG-10442-100A-F1A-N` | VG (VENT GAS) | 10442 | 100A | F | 1 | AN |
| `VG-10442-150A-F1A-N` | VG (VENT GAS) | 10442 | 150A | F | 1 | AN |
| `VG-10443-25A-F1A-N` | VG (VENT GAS) | 10443 | 25A | F | 1 | AN |
| `VG-10443-25A-F1A-n` | VG (VENT GAS) | 10443 | 25A | F | 1 | An |
| `VG-10444-25A-F1A-N` | VG (VENT GAS) | 10444 | 25A | F | 1 | AN |
| `VG-10444-25A-F1A-n` | VG (VENT GAS) | 10444 | 25A | F | 1 | An |
| `VG-6203-15A-F1A-n` | VG (VENT GAS) | 6203 | 15A | F | 1 | An |
| `VG-9400-150A-F1A-N` | VG (VENT GAS) | 9400 | 150A | F | 1 | AN |
| `VG-9411-50A-F1A-N` | VG (VENT GAS) | 9411 | 50A | F | 1 | AN |
| `VG-9412-50A-F1A-N` | VG (VENT GAS) | 9412 | 50A | F | 1 | AN |
| `VG-9421-50A-F1A-N` | VG (VENT GAS) | 9421 | 50A | F | 1 | AN |
| `VG-9423-50A-F1A-N` | VG (VENT GAS) | 9423 | 50A | F | 1 | AN |
| `VG-9424-50A-F1A-N` | VG (VENT GAS) | 9424 | 50A | F | 1 | AN |
| `VG-9425-50A-F1A-N` | VG (VENT GAS) | 9425 | 50A | F | 1 | AN |
| `VG-9426-50A-F1A-N` | VG (VENT GAS) | 9426 | 50A | F | 1 | AN |
| `VG-9431-50A-F1A-N` | VG (VENT GAS) | 9431 | 50A | F | 1 | AN |
| `VG-9432-50A-F1A-N` | VG (VENT GAS) | 9432 | 50A | F | 1 | AN |
| `VG-9433-50A-F1A-N` | VG (VENT GAS) | 9433 | 50A | F | 1 | AN |
| `VG-9434-50A-F1A-N` | VG (VENT GAS) | 9434 | 50A | F | 1 | AN |
| `VG-9440-300A-F1A-N` | VG (VENT GAS) | 9440 | 300A | F | 1 | AN |
| `VG-9441-125A-F1A-N` | VG (VENT GAS) | 9441 | 125A | F | 1 | AN |
| `VG-9441-150A-F1A-n` | VG (VENT GAS) | 9441 | 150A | F | 1 | An |
| `VG-9442-65A-F1A-N` | VG (VENT GAS) | 9442 | 65A | F | 1 | AN |
| `WW-10191-25A-F1A-E50` | WW (WASTE WATER) | 10191 | 25A | F | 1 | AE50 |
| `WW-10193-25A-F1A-N` | WW (WASTE WATER) | 10193 | 25A | F | 1 | AN |
| `WW-9193-25A-F1A-N` | WW (WASTE WATER) | 9193 | 25A | F | 1 | AN |
---
## 미분류 항목 (25건)
| tag_no | instrument_type | line_number | confidence |
|---|---|---|---|
| `BV-10100` | BV | — | 0.500 |
| `BV-10101` | BV | — | 0.500 |
| `BV-10200` | BV | — | 0.500 |
| `BV-10201` | BV | — | 0.500 |
| `BV-10221` | BV | — | 0.500 |
| `KA-10901` | KA | — | 0.500 |
| `KD-10901` | KD | — | 0.500 |
| `KF-10901A` | KF | — | 0.500 |
| `KF-10901B` | KF | — | 0.500 |
| `UFD-9005` | UFD | — | 0.500 |
| `XV-10111` | XV | — | 0.500 |
| `XV-10211` | XV | — | 0.500 |
| `XV-2136` | XV | — | 0.500 |
| `XV-3208` | XV | — | 0.500 |
| `XV-3402` | XV | — | 0.500 |
| `XV-3470` | XV | — | 0.500 |
| `XV-5320` | XV | — | 0.500 |
| `XV-6120` | XV | — | 0.500 |
| `XV-6121` | XV | — | 0.500 |
| `XV-6122` | XV | — | 0.500 |
| `XV-6125` | XV | — | 0.500 |
| `XV-6126` | XV | — | 0.500 |
| `XV-6220` | XV | — | 0.500 |
| `XV-705` | XV | — | 0.500 |
| `XV-9125` | XV | — | 0.500 |

View File

View File

@@ -0,0 +1,145 @@
# 결정 보류 2건 — 사용자 액션 가이드
> 작성일: 2026-05-14
> 대상: `plans/빅피클-잔여작업-코딩계획.md` 7번(현장 재고), 8번(BGE-M3)
> 상태: 코드 작업 불필요. **사용자 의사 결정과 운영 액션이 필요**.
---
## 1. 현장 재고 데이터 출처 결정
### 현황
Phase 0 설계서(G3)에서 "현장 재고 데이터 자체가 시스템에 없음"으로 식별됨.
운전원이 "P-6201 펌프 예비품 재고", "교체용 PT100 센서 수량" 같은 질문을 했을 때
참조할 데이터 소스가 없어 답변 불가.
### 결정해야 할 것
| 옵션 | 설명 | 사용자 액션 |
|------|------|------------|
| **A. KB 업로드 (권장)** | 엑셀/CSV/PDF 재고 대장을 `plant_operation` 또는 `report` 컬렉션에 업로드 → 즉시 RAG 검색 가능 | 1) 재고 대장 파일 확보 (관리부/구매팀 협조)<br>2) 컬럼 구조 정리 (품목·규격·수량·위치·재발주점)<br>3) 14번 탭 "RAG 관리" → 업로드 |
| **B. ERP/MES API 연동** | 외부 시스템에서 실시간 재고 조회 | 1) ERP 운영팀과 API 협의<br>2) 인증/네트워크 방화벽 정책<br>3) 별도 개발 필요 (Phase 8) |
| **C. 수동 입력 테이블** | PostgreSQL에 `inventory_table` 신설, 관리자 UI에서 직접 입력 | 1) 누가 어떻게 최신화할지 거버넌스 결정<br>2) 별도 개발 필요 |
| **D. 보류 유지** | 운전원 질문 시 "재고 정보는 별도 시스템 참조" 안내 | 운전원 교육 |
### 권장: 옵션 A
**근거**:
- 현재 KB 시스템(Qdrant 5종 컬렉션)이 이미 운영 중
- xlsx/pdf 자동 파싱 + 임베딩 + 한국어 검색 모두 동작
- 코드 추가 0줄, 즉시 시도 가능
- 부족하다면 그때 B 또는 C로 확장
### 사용자가 해야 할 일 (옵션 A 채택 시)
1. **재고 대장 파일 확보**
- [ ] 관리부/구매팀에서 최신 재고 엑셀 입수
- [ ] 민감정보(가격·공급사 단가 등) 제외 또는 가공 결정
2. **업로드**
- [ ] 앱 접속 → 14번 "RAG 관리" 탭
- [ ] 관리자 비밀번호 로그인
- [ ] 컬렉션 = `plant_operation` (또는 신규 `inventory` 컬렉션을 원하면 DB 시드 확장 요청)
- [ ] 태그 = `inventory`, `예비품` 등 일관된 태그 부착
3. **검증**
- [ ] 채팅(#13)에서 "PT100 센서 재고 알려줘" 같은 질문으로 검색 결과 확인
- [ ] 결과가 부정확하면 청크 미리보기(🔍 버튼)로 인덱싱 품질 확인
4. **운영 룰**
- [ ] 월 1회 or 분기 1회 최신 파일 재업로드 (이전 버전은 "🚫 비활성화" 후 "🗑 정리")
- [ ] 갱신 주체 1명 지정
### 사용자가 해야 할 일 (옵션 B/C로 갈 경우)
**이 문서 범위 밖**. 별도 요건 정의서가 필요. 본 항목은 옵션 결정만 요청.
---
## 2. 임베딩 모델 BGE-M3 마이그레이션 검토
### 현황
- 현재 사용 중: **`nomic-embed-text`** (768-dim, Ollama 호스트)
- 후보: **`bge-m3`** (1024-dim, 다국어 SOTA, 특히 한국어 성능 우수)
- 5개 Qdrant 컬렉션이 768-dim으로 생성된 상태
### 결정해야 할 것
전환 시 **이득**과 **비용**을 비교 후 GO/NO-GO/POSTPONE 결정.
| 이득 | 비용/위험 |
|------|----------|
| 한국어 검색 품질 향상 (대시기관 평가 기준 +5~15%) | Qdrant 컬렉션 5개 전부 재생성 → 기존 인덱스 일시 소실 |
| 멀티링구얼(영문 매뉴얼+한국어 SOP 혼합)에 강함 | 1024-dim → Qdrant 디스크 사용량 +33% |
| Dense + Sparse + ColBERT 통합 모델 | 임베딩 속도 ~30% 느려짐 (GPU 점유율 ↑) |
| 청크 크기 8K 토큰까지 지원 (nomic은 2K) | Ollama가 bge-m3 미지원 시 다른 호스트 필요 (HF transformers 등) |
### 마이그레이션 절차 (실행 시)
1. **사전 평가** ⚠️ (사용자 액션)
- [ ] BGE-M3가 Ollama Library에 있는지 확인 (`ollama pull bge-m3`)
- [ ] 없으면 대안 결정: HF transformers, sentence-transformers 또는 별도 임베딩 서버
- [ ] 현재 KB 검색 품질에 실질 문제가 있는지 — 운전원 피드백 수집 (1~2주)
2. **샘플 비교 (선택)** (사용자 액션)
- [ ] 동일 한국어 쿼리 10건을 두 모델로 비교
- [ ] 한국어 정밀도에서 명백한 개선이 보일 때만 GO
3. **백업** (사용자 액션)
- [ ] `storage/kb/` 전체 백업
- [ ] `pg_dump``kb_*` 테이블 백업
4. **코드 작업** (개발자 액션 — 본 문서 범위 아님)
- `appsettings.json``Kb:EmbeddingModel`, `Kb:VectorSize` 설정 추가
- `KbEmbeddingClient.cs` 에서 모델명/차원 환경변수 참조
- `KbStartupService.cs` 에서 컬렉션 차원 mismatch 감지 시 재생성
5. **컷오버** (사용자 액션)
- [ ] 운영 외 시간대 선택 (KB 검색 일시 중단)
- [ ] Qdrant 컬렉션 5개 삭제 → 새 차원으로 재생성
- [ ] 모든 KB 문서 일괄 `재인덱싱(↻)` 트리거
- [ ] 완료까지 모니터링 (문서 수 × 청크 수 × 임베딩 시간)
### 권장: POSTPONE (당분간 보류)
**근거**:
- 현재 nomic-embed-text 검색 품질이 운영에 임계 미달이라는 정량 증거가 없음
- BGE-M3 전환은 비가역적 비용(재인덱싱 시간, 디스크 +33%)이 큰 결정
- Phase 7 신규 기능(채팅 통합·청크 미리보기·요약 등)이 운영에서 안정화된 후
사용자 피드백으로 검색 품질 이슈가 누적되면 그때 검토
### 사용자가 해야 할 일
- [ ] 1~2개월간 운영하면서 **KB 검색 결과가 부정확했던 사례를 메모**
- 질문 / 기대 답변 / 실제 검색된 문서 / 점수
- 메모 위치: 별도 텍스트 파일 또는 본 문서에 추가
- [ ] 사례가 **5건 이상 누적**되고 한국어 매칭 실패가 패턴화되면 마이그레이션 재검토 회의
- [ ] 회의 시점에 다시 본 문서를 갱신 (GO/NO-GO 결정 기록)
### 즉시 가능한 대안 (개발자 액션 불필요)
검색 품질이 부족할 때 BGE-M3 전환 없이 시도해 볼 수 있는 것:
| 시도 | 방법 |
|------|------|
| 더 좋은 청킹 | 업로드 시 `chunking_policy` 옵션 조정 (현재는 자동) |
| 태그 보강 | KB 업로드 시 태그를 풍부하게 부착 → `search_kb`의 tags 필터로 정확도 ↑ |
| 컬렉션 분리 | doc_type별 5개 컬렉션을 더 세분화 |
| `top_k` 조정 | 채팅에서 검색 결과 수를 늘려 LLM이 더 많은 단서로 답하게 |
---
## 체크리스트 요약 (사용자가 해야 할 일)
### 단기 (이번 주 ~ 2주 내)
- [ ] **재고 데이터**: 옵션 A(KB 업로드) 채택 여부 결정 → 채택 시 관리부에 파일 요청
- [ ] **BGE-M3**: 마이그레이션은 보류, 검색 품질 모니터링 시작
### 중기 (1~3개월)
- [ ] 재고 KB 파일 1차 업로드 + 검증
- [ ] KB 검색 부정확 사례 누적 (5건 임계)
### 장기 (3개월+)
- [ ] BGE-M3 마이그레이션 재검토 회의 (사례 누적 시)
- [ ] 재고 데이터를 ERP 연동으로 격상할지 검토 (옵션 A로 한계 도달 시)
---
## 본 문서 갱신 규칙
이 문서는 결정이 진행됨에 따라 사용자가 직접 갱신합니다.
- 결정 완료 시: 해당 섹션에 "결정 결과", "결정자", "결정일" 추가
- 보류 유지: "재검토 예정일" 추가
- 옵션 변경: 새 옵션 행 추가 + 이전 결정 사유 보존

14
plans/막쓰는노트.md Normal file
View File

@@ -0,0 +1,14 @@
qwen 역할
1) todo.md 읽고,
2) diagnosis-checklist.md 규칙에 따라서 스텝별 실행계획 만들기
3) 스텝 미션을 gemma4 에게 전달
5) 끝났다고 보고 받으면, 검증 시작
6) 검증완료면 다음 스텝 미션 전달
gemma4는
1) 스텝 미션 받아서,
2) 코딩하고,
3) diagnosis-checklist.md에 따라서 자기 검증
4) 문제있으면 loop 돌림
5) 끝나면 끝났다고 보고

View File

@@ -0,0 +1,301 @@
# 배포 테스트 — Qwen2.5-7B-Instruct-FP8 모델 전환 검토
**날짜**: 2026-05-15
**환경**: NVIDIA GB10 (DGX Spark) / 통합 메모리 121 GiB / vLLM in Docker `vllm_node`
**결론**: **7B 운영 부적합. 27B 유지 결정.**
---
## TL;DR
- 동기: 27B Qwen3.6-FP8이 48 tok/s로 느리게 느껴져, 더 가벼운 7B FP8로 교체 시 속도/메모리 이득을 볼 수 있는지 검토.
- 실측: 7B FP8(`RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic`)이 29.4 tok/s로 오히려 **느림** (27B는 `qwen3_next_mtp` speculative decoding 효과).
- 결정타: 7B는 **도구 호출을 회피하고 가짜 데이터를 만들어냄** — 운전원 안전 위험. 도구 토글 ON·에이전트 모드 ON에도 동작 안 함.
- 조치: 27B로 즉시 복귀. 검증 중 만든 시스템 프롬프트 강화는 그대로 유지(27B에도 도움).
---
## 1. 환경 정보
### 하드웨어
| 항목 | 값 |
|---|---|
| GPU/CPU | NVIDIA GB10 (Grace Blackwell, unified memory) |
| 통합 메모리 | 121 GiB (시스템·GPU 공유) |
| Swap | 15 GiB |
### 27B 운영 구성 (baseline)
| 항목 | 값 |
|---|---|
| 모델 | `Qwen/Qwen3.6-27B-FP8` |
| 메모리 점유 | ~97 GiB |
| `max-model-len` | 262,144 (256K) |
| `gpu-memory-utilization` | 0.80 |
| `speculative-config` | `qwen3_next_mtp`, 2 tokens |
| `tool-call-parser` | `qwen3_coder` |
| 측정 속도 | **48 tok/s** |
| GB10 튜닝 mod | `mods/vllm-tune-qwen--qwen3.6-27b-fp8-tp1` 적용 |
---
## 2. 7B FP8 띄우기 레시피 (재현용)
### 모델 선택
HF에서 `neuralmagic/Qwen2.5-7B-Instruct-FP8`**존재하지 않음** (404). neuralmagic이 RedHatAI로 흡수됨.
올바른 repo:
-`RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic` (다운로드 2378회, 공식 후속)
- 그 외: `CalamitousFelicitousness/...`, `llmcompressor-quants/...` 등 community 변형
### 27B 종료 + 7B 기동 절차
```bash
# 1. 현재 27B vllm 프로세스만 종료 (컨테이너는 sleep infinity로 유지)
docker exec vllm_node pkill -f "vllm serve"
sleep 5
# 2. 7B FP8 기동 (launch-cluster.sh exec 패턴)
cd /home/windpacer/ai-models/spark-vllm-docker
./launch-cluster.sh -t vllm-node-tf5 --solo -d \
exec vllm serve RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic \
--served-model-name Qwen2.5-7B-Instruct-FP8 \
--max-model-len 32768 \
--max-num-seqs 4 \
--enable-prefix-caching \
--gpu-memory-utilization 0.30 \
--port 8000 --host 0.0.0.0 \
--enable-chunked-prefill \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--trust-remote-code \
-tp 1
```
### 27B 명령에서 제거한 인자 (Qwen3 전용)
| 인자 | 이유 |
|---|---|
| `--apply-mod mods/vllm-tune-qwen--qwen3.6-27b-fp8-tp1` | Qwen3.6-27B 전용 GEMM kernel config |
| `--load-format instanttensor` | Qwen3 instant tensor 최적화 |
| `--reasoning-parser qwen3` | Qwen3 reasoning trace (Qwen2.5 미지원) |
| `--default-chat-template-kwargs '{"preserve_thinking":true}'` | Qwen3 thinking block |
| `--speculative-config '{"method":"qwen3_next_mtp",...}'` | Qwen3 MTP — Qwen2.5에 적용 불가 |
| `--tool-call-parser qwen3_coder` | → `hermes`로 교체 (Qwen2.5는 hermes 포맷) |
| `--max-model-len 262144` | → 32768로 축소 (KV 캐시 절약) |
| `--gpu-memory-utilization 0.80` | → 0.30 (7B는 메모리 적게 필요) |
| `--max-num-batched-tokens 32768` | 32K로 충분 |
| `--override-generation-config` | 7B 기본값 사용 |
### 동기화 작업
```bash
# vllm_model 표시명을 UI에 맞게 변경
echo '{"vllm_model":"Qwen2.5-7B-Instruct-FP8"}' > mcp-server/llm-model.json
```
`appsettings.json`의 vLLM endpoint, `OllamaController.cs`, `KbEmbeddingClient.cs` 등은 **변경 불필요** — 포트 8000 동일.
### 기동 시간
- 가중치 다운로드: 152초 (~8 GiB, 첫 회만)
- 가중치 로드: 45초
- torch.compile 캐시: ~30초
- **합계 약 4분** (캐시 후 재기동은 ~75초)
---
## 3. 검증 결과
### 3.1 메모리 점유
| 모델 | 점유 | 가용 |
|---|---|---|
| 27B FP8 (max-len 256K, util 0.80) | 97 GiB | 11 GiB |
| 7B FP8 (max-len 32K, util 0.30) | ~25 GiB | ~85 GiB |
### 3.2 순수 채팅 속도 (3회 평균)
```
27B FP8 + qwen3_next_mtp : 48 tok/s
7B FP8 (vanilla) : 29.4 tok/s
```
**역설**: 7B가 더 느림. 이유:
1. 27B는 MTP(Multi-Token Prediction) speculative decoding으로 한 step에 2~3토큰 생성
2. 27B는 GB10 전용 GEMM kernel mod 적용
3. 7B는 default kernel + vanilla autoregressive
### 3.3 도구 호출 능력 — 결정적 검증
#### 테스트 1: 일반 KB 질의
> "5월12일 6차플랜트 이상 상황 보고해줘"
- 7B 응답: `summarize_events(since=2026-05-12T00:00:00Z, area=P6)` ⭕ 도구 선택·area 매핑 정확
- 단 KST→UTC 변환 일부 생략 (27B는 `2026-05-11T15:00:00Z`로 정확)
#### 테스트 2: 시간 인자 정확도
> "지난 30분 동안 ficq-6101.pv의 값을 표시해줘"
- 7B 응답: `time_from: 2026-05-15T14:00:00, time_to: 2026-05-15T14:30:00` (**현재로부터 +16시간 미래!**)
- 결과 `count: 0` → "데이터 없습니다" 답변 (사실은 84건 존재)
- 원인: 7B가 "지난 30분"의 절대 시각 계산 못 함 → 학습 데이터에서 흔한 KST 14:00 차용 후 KST→UTC 변환 생략
**시스템 프롬프트 강화 후 재시도** (`BuildDateContextKo`에 시:분 + 상대시간 표 + 미래 금지):
- 결과: 시간은 그럴듯하게 표기되었으나 **도구 호출 자체를 skip**하고 가짜 값(50.2 → 51.1 단조 증가) 생성
#### 테스트 3: 결정적 검증 프롬프트
요청:
```
다음 3가지를 정확히 알려줘. 도구 결과만 인용, 추측 금지.
1) ficq-6101.pv 가장 최근 1건 (값 + UTC 시각)
2) ficq-6101.sp 가장 최근 1건 (값 + UTC 시각)
3) event_history_table 전체 이벤트 수
```
비교:
| 항목 | 7B 답변 | 실제 DB | 판정 |
|---|---|---|---|
| 1) ficq-6101.pv | `50.2` @ `22:19:00Z` | `47.4666...` @ `22:23:03.055Z` | ❌ |
| 2) ficq-6101.sp | `50.5` @ `22:18:00Z` | `34.4000...` @ `22:23:03.055Z` | ❌ |
| 3) event_history_table count | **`12345`** | **`53`** | ❌ |
**3가지 모두 fabrication**. 특히 `12345`는 챗봇 demo placeholder. "도구 결과 그대로 인용"이라고 7B가 표기까지 했음에도 실제 도구 호출 없음.
#### UI 측 도구 호출 카드
- 도구 토글: ON ✅
- 에이전트 모드(ReAct 강제): ON ✅
- **실제 도구 카드 표시: 없음** ❌
7B가 도구 호출 자체를 회피. ReAct 프롬프트도 무시.
### 3.4 일반 채팅 품질
- 한국어 자연스러움: ⭕ 양호 (시스템 프롬프트 한자 금지 강화 후)
- 프로젝트 설명 등 paraphrase: ⭕ 양호
- 시스템 프롬프트 내용 활용: ⭕ 양호
---
## 4. 결론
| 사용 유형 | 7B FP8 평가 |
|---|---|
| 일반 한국어 Q&A | ⭕ 사용 가능 |
| 시스템/문서 내용 paraphrase | ⭕ 사용 가능 |
| **실시간 도구 호출** | ❌ **결정적 실패** |
| **데이터 값 정확 인용** | ❌ **fabrication** |
| 속도 | ❌ 27B보다 느림 |
| 메모리 절약 | ⭕ 유일한 이점 (~70 GiB 절약) |
**판단 기준**: 운전원이 "최근 PV값"을 물었을 때 가짜 수치(50.2 vs 실제 47.47)를 사실로 받는 것은 산업 안전 위험. 12,345 같은 명백한 placeholder를 53건으로 보고하면 운영 판단 완전 왜곡.
**7B 운영 부적합 확정**.
---
## 5. 27B 롤백 절차
검증 종료 시 즉시 복귀.
### 롤백 스크립트 (`/tmp/restore-27b.sh`)
```bash
#!/bin/bash
# 27B vLLM 복귀
set -e
cd /home/windpacer/ai-models/spark-vllm-docker
# 현재 vllm 프로세스 종료
docker exec vllm_node pkill -f "vllm serve" 2>/dev/null || true
sleep 3
./launch-cluster.sh -t vllm-node-tf5 --solo -d \
--apply-mod mods/vllm-tune-qwen--qwen3.6-27b-fp8-tp1 \
exec vllm serve Qwen/Qwen3.6-27B-FP8 \
--served-model-name Qwen3.6-27B-FP8 \
--max-model-len 262144 \
--max-num-seqs 4 \
--enable-prefix-caching \
--gpu-memory-utilization 0.80 \
--port 8000 --host 0.0.0.0 \
--load-format instanttensor \
--enable-chunked-prefill \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--reasoning-parser qwen3 \
--max-num-batched-tokens 32768 \
--trust-remote-code \
--default-chat-template-kwargs '{"preserve_thinking": true}' \
--speculative-config '{"method": "qwen3_next_mtp", "num_speculative_tokens": 2}' \
--generation-config auto \
--override-generation-config '{"temperature": 0.6, "top_p": 0.95, "top_k": 20, "min_p": 0.0, "presence_penalty": 0.0, "repetition_penalty": 1.0}' \
-tp 1
```
### 함께 복원
```bash
echo '{"vllm_model":"Qwen3.6-27B-FP8"}' > mcp-server/llm-model.json
```
C# 앱은 사용자 재시작 (`dotnet run --project src/Web/ExperionCrawler.csproj`).
### 로딩 시간 (캐시 hit)
- 약 75초 (가중치는 이미 `~/.cache/huggingface/`에 있음)
---
## 6. 향후 재시도 시 점검 항목
다른 모델/구성으로 재검토할 경우 반드시 통과해야 할 체크리스트:
### 필수 (이 중 하나라도 실패하면 운영 불가)
- [ ] **결정 프롬프트** (Section 3.3 테스트 3) 3개 항목 모두 도구 결과 정확 인용
- [ ] UI에서 `query_pv_history` / `run_sql` 도구 카드가 시각적으로 표시됨
- [ ] `event_history_table` 전체 카운트가 실제값과 일치 (추측 불가 숫자)
- [ ] 시각 인자에 미래 시각(NOW 이후) 절대 안 나옴
- [ ] 데이터 값이 단조 증가 demo 패턴(`50.2 → 51.1` 등)이 아님
### 우대 (있으면 좋음)
- [ ] 한자(중국어) 글자 응답에 섞이지 않음
- [ ] KST/UTC 라벨 정확
- [ ] 27B 대비 동등 또는 더 빠른 tok/s
- [ ] `plant_context.md` 의 area 매핑(`P1~P10`) 따라 인자 생성
### 검토 후보 (참고)
| 모델 | 비고 |
|---|---|
| **`gemma4-quantized` (사용자 우선순위)** | **다음 검토 1순위.** 70~80 GiB 점유 예상 (27B와 유사 위치). 배포 환경에서도 이걸 쓰기로 계획됨. 검증 시 GB10 호환 양자화 종류(AWQ/GPTQ/FP8/MXFP4 등) 확인 필요. tool-call-parser는 Gemma 전용 파서(`gemma`/`hermes` 호환 여부) 점검 |
| `RedHatAI/Qwen2.5-72B-Instruct-FP8` | 72B FP8 ~72 GiB. 도구 호출 신뢰성 27B에 근접 예상 |
| `exaone3.5:7.8b` (LG) | 한국어 특화 7.8B. 함수 호출 미지원일 수 있어 사전 확인 필요 |
| `Qwen3-30B-A3B-Instruct-2507` | Qwen3 차세대 MoE 30B (3B active). 출시되면 검토 가치 |
### Gemma4 검토 시 사전 준비 (TODO)
- [ ] HF에서 GB10·FP8 호환 정확한 repo 식별 (예: `google/gemma-4-27b-it`의 FP8/AWQ 변형, RedHatAI/neuralmagic 양자화 후속)
- [ ] vLLM의 Gemma 지원 버전 확인 — `--tool-call-parser` 옵션 (`gemma`, `pythonic`, `hermes` 중 어느 것)
- [ ] 기존 mod 디렉토리에 Gemma 관련 자료 유무 점검 (`/home/windpacer/ai-models/spark-vllm-docker/mods/fix-gemma4-tool-parser`가 이미 있음 — Gemma4 도구 파서 패치 시사)
- [ ] speculative decoding 가능 여부 (Gemma3는 미지원, Gemma4는 별도 확인)
- [ ] 27B FP8 (97 GiB)을 종료한 뒤에만 충분한 여유. 동시 운영은 메모리 부족 가능성 (배포 환경 기준 80GB로 가능)
---
## 7. 부산물 — 이 검증 과정에서 추가된 코드 개선
7B 검증 실패와 무관하게 시스템 전반에 도움이 되는 변경:
| 파일 | 개선 내용 |
|---|---|
| `src/Web/Controllers/OllamaController.cs` | `BuildDateContextKo`에 KST/UTC 현재 시:분 + 상대시간 변환 표(지난 30분/1시간/24시간/오늘/어제) + 미래 시각 금지 명시 |
| `src/Web/Controllers/OllamaController.cs` | `BaseSystemPromptKo`에 한자(중국어) 금지 강화 — 자주 출현하는 9글자(`请·您·是·了·我·会·什么·可以·需要`) 명시 |
| `src/Web/Controllers/OllamaController.cs` | `GetModels`에 Ollama `/api/show` 호출 → `capabilities=['embedding']` 모델(bge-m3, nomic-embed-text) 채팅 셀렉터에서 자동 제외 |
| `prompts/plant_context.md` | P1~P10/UTIL/PACKING area 매핑 + `event_history_table` vs `v_tag_summary` area 형식 차이 안내 + 의도별 권장 도구 표 |
| `mcp-server/server.py` | `_CLASSIFY_RULES``이상.*상황\|상황.*보고\|이상.*보고\|비정상.*상황\|abnormal``summarize_events` 추가 |
27B에도 도움 — 그대로 유지.
---
## 8. 메모
- `/tmp/restore-27b.sh`는 임시 파일. 재기동 후 사라짐. 영구 보관하려면 `scripts/restore-27b.sh`로 옮기는 것 권장.
- 7B 가중치는 `~/.cache/huggingface/hub/models--RedHatAI--Qwen2.5-7B-Instruct-FP8-dynamic/`에 캐시됨 (~8 GiB). 디스크 정리 시 삭제 가능.
- 다음에 7B를 다시 시도할 경우 **결정 프롬프트(Section 3.3 테스트 3)부터** 30분 안에 통과 여부 판정 가능. 시간 낭비 방지.

View File

@@ -0,0 +1,517 @@
# 빅피클 — 잔여 작업 상세 코딩 계획
> 기준: `CLAUDE.md` 잔여 작업 항목 (2026-05-14)
> 상태: Phase 0~6 완료, Phase 7 + Phase 5 후순위 + 결정 보류 항목이 대상
---
## 현황 요약
| 영역 | 상태 | 비고 |
|------|------|------|
| Phase 0~5 (RAG + 채팅) | ✅ 완료 | Ollama/vLLM 이중 백엔드, SSE 스트리밍, MCP 툴콜 루프(10라운드), KB 업로드/인덱싱 5종 컬렉션 |
| Phase 5 핫픽스 | ✅ 완료 | nl2sql 버그, KB DDL `String.Format` 이슈, 비번 로그 마스킹, `IHttpClientFactory`, plant_context 캐시 |
| Phase 6 (run_sql 가드) | ✅ 완료 | `_validate_sql`, `_apply_sql_guards`, keyword 차단, auto-LIMIT, statement_timeout |
| Phase 6 (보강 도구 5종) | ✅ 완료 | `find_tags`, `query_events`, `active_alarms`, `summarize_events`, `generate_status_report` |
| **Phase 7 (옵션)** | **⏳ 잔여** | NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 |
| **Phase 5 후순위** | **⏳ 잔여** | 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존 |
| **결정 보류** | **⏳ 분석** | 현장 재고 데이터 출처, BGE-M3 마이그레이션 |
---
## 1. NL2SQL 의도 라우터 (Phase 7.1)
### 배경
현재 `query_with_nl`은 모든 자연어 질문을 무조건 LLM에 보내 SQL 생성을 시도한다.
"활성 알람 보여줘", "이벤트 요약" 같은 질문도 SQL 경로로 가므로 불필요한 LLM 호출 + SQL
실패 위험이 있다. 의도 라우터가 질문을 분류하여 적절한 MCP 도구로 직접 라우팅한다.
### 설계
```
사용자 질문
[_classify_intent()] ← 키워드/Trie 기반 (ML 불필요)
├── "알람/트립" → active_alarms 도구
├── "이벤트/요약/보고서" → summarize_events 또는 generate_status_report
├── "태그 찾기/검색" → find_tags 도구
├── "SQL/조회/데이터" → query_with_nl (기존)
└── "기타/모름" → query_with_nl (fallback)
```
### 수정 파일
**mcp-server/server.py**
| 위치 | 변경 |
|------|------|
| `_CLASSIFY_RULES` 상수 (신규) | 키워드→도구 매핑 규칙 맵 (예: `{"alarm|트립|경보|trip": "active_alarms", ...}`) |
| `_classify_intent(query) → str` (신규) | 정규식 + 키워드 Trie로 의도 분류, fallback은 `"query_with_nl"` |
| `query_with_nl` 수정 | `query_with_nl` 내부 첫 줄에서 `_classify_intent` 호출, 매칭되면 해당 도구로 위임 |
| `@mcp.tool() classify_intent(question)` 검토 | MCP 도구로 별도 노출할지 결정 (OllamaController의 JSON 폴백 경로에 활용 가능) |
**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `ToolGuideKo` | (필요시) `classify_intent` 도구 항목 추가 |
### 분류 규칙 (예시)
```python
_CLASSIFY_RULES = [
(r'alarm|트립|경보|trip|경보|비상', 'active_alarms'),
(r'요약|보고서|리포트|summary|report', 'generate_status_report'),
(r'태그.*찾|검색|찾아줘|찾기|find.*tag', 'find_tags'),
(r'이벤트|event|로그|기록', 'query_events'),
(r'SQL|조회|데이터|select|값.*보여줘|수치', 'query_with_nl'),
]
```
### 동작 흐름 (query_with_nl 변경 후)
```
query_with_nl("지금 알람 상황 알려줘")
→ _classify_intent → "active_alarms"
→ active_alarms(area=None) 호출 → 결과 반환
→ (SQL 생성 생략)
```
### 검증
- `python3 -c "import server; print(server._classify_intent('지금 알람 알려줘'))"``"active_alarms"`
- `python3 -c "import server; print(server._classify_intent('FIC-6113.PV 값 보여줘'))"``"query_with_nl"`
- `python3 -c "import server; print(server._classify_intent('안녕'))"``"query_with_nl"` (fallback)
---
## 2. 대화 요약 (Phase 7.2)
### 배경
현재 매 턴 전체 messages 배열을 LLM에 전송한다. 대화가 길어질수록 컨텍스트 윈도우를
초과하거나 토큰 비용이 증가한다. N messages 이상이면 이전 메시지를 요약하여
system prompt에 "지금까지의 대화 요약: ..." 형태로 압축한다.
### 설계
```
messages 길이 > MAX_HISTORY (기본 20)
llmSend() 전에 PreprocessMessages()
├── 오래된 메시지를 LLM에 요약 요청
├── 요약 텍스트를 system prompt에 "대화 요약: ..." 형태로 삽입
└── 요약된 메시지 제거
```
### 수정 파일
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `LLM_MAX_HISTORY = 20` (신규 상수) | 요약 트리거 기준 messages 수 |
| `llmPreprocessMessages(messages)` (신규) | messages 길이 체크 → 초과 시 요약 API 호출 |
| `llmSend()` (line 3725) | `llmPreprocessMessages` 호출 추가 (line 3748 앞) |
| session 저장 구조 확장 | `sess.summary` 필드 추가 (요약 텍스트 보관) |
| `llmRenderMessages()` | summary 표시 (접힌 카드) |
**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `POST /api/ollama/summarize` (신규) | messages 배열 받아 LLM으로 요약 + 반환 |
| `VllmChatStreamWithTools` | `maxToolRounds` 카운트 유지, 필요시 요약 |
**src/Web/wwwroot/css/style.css**
| `.llm-summary-card` | 요약 표시용 스타일 (접힘/펼침) |
### UI 동작
```
def llmPreprocessMessages(messages):
if len(messages) <= LLM_MAX_HISTORY: return messages
# 최근 절반 유지, 오래된 절반 요약 요청
old_msgs = messages[:-LLM_MAX_HISTORY//2]
new_msgs = messages[-LLM_MAX_HISTORY//2:]
summary = await api('POST', '/api/ollama/summarize', {messages: old_msgs})
sess.summary = summary
# system prompt에 요약 주입
systemPrompt = f"[대화 요약]\n{summary}\n\n[최근 대화]"
return new_msgs
```
### 검증
- 25개 메시지 → 20개 초과 → 요약 트리거
- 요약 이후 처음 메시지에 system prompt에 `[대화 요약]` 포함 확인
- 요약 카드가 UI에 정상 렌더링
---
## 3. 에이전트 모드 (Phase 7.3)
### 배경
현재 툴콜 루프(10라운드)는 LLM이 도구를 호출하면 실행하고 결과를 다시 LLM에 주입한다.
하지만 "이 공정의 문제점을 분석해줘" 같은 복합 태스크는: (1) 활성 알람 조회 →
(2) 관련 태그 이력 조회 → (3) 보고서 생성 순서로 여러 도구를 자율적으로 호출해야 한다.
### 설계
"에이전트 모드" 토글을 채팅 UI에 추가:
- OFF (현행): 단순 툴콜 루프 (LLM이 요청한 도구만 실행)
- ON: 계획→실행→관찰→반복 사이클 (ReAct 패턴)
### 수정 파일
**src/Web/wwwroot/index.html**
| 위치 | 변경 |
|------|------|
| `#llm-agent-mode` 체크박스 (line 1269 옆) | "에이전트 모드" 토글 추가 (MCP 도구 체크박스 옆) |
| 설명 툴팁 | "복합 태스크를 단계별로 계획하고 실행합니다" |
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `llmAgentMode` 변수 (line 3297 옆) | localStorage persist |
| `llmToggleAgentMode()` (신규) | 토글 변경 시 상태 저장 |
| `llmSend()` | agentMode ON이면 system prompt에 ReAct 프롬프트 주입 |
| `llmRenderMessages()` | agent planning 단계 시각화 |
**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `ComposeSystemPrompt()` | agentMode 인자 추가 → ReAct 가이드 포함 |
| `ToolGuideKo` | agent 모드용 tool 사용 설명 추가 |
**ReAct 시스템 프롬프트 (ToolGuideKo에 추가)**
```
[에이전트 모드]
복잡한 질문은 다음 단계로 분해하여 도구를 호출하세요:
1. Thought: 현재 상황과 필요한 정보 파악
2. Action: 적절한 도구 호출 (active_alarms, query_events, query_pv_history 등)
3. Observation: 도구 결과 분석
4. Thought: 다음 단계 결정
5. ...반복...
6. Final Answer: 모든 정보를 종합하여 답변
```
### 동작 흐름
```
사용자: "지금 공장 상황을 분석해줘"
Round 1: Thought → active_alarms() → 결과 분석
Round 2: Thought → find_tags("pump") → 결과 분석
Round 3: Thought → query_events(area="A") → 결과 분석
Round 4: Thought → generate_status_report() → 최종 보고서 생성
최종: 모든 정보를 종합한 한국어 보고서 출력
```
### 검증
- 에이전트 ON/OFF 토글 localStorage 저장 확인
- ON 상태에서 복합 질문 시 2라운드 이상 도구 호출
- 최종 응답에 모든 단계의 정보가 통합됨
---
## 4. KB 청크 미리보기 UI (Phase 7.4)
### 배경
현재 KB 관리 탭에서 문서 목록은 보이지만, 각 문서의 청크 내용을 볼 수 없다.
인덱싱 결과를 눈으로 확인할 수 없어 디버깅과 품질 검증이 어렵다.
### 설계
Qdrant에서 `doc_id`로 청크를 조회하는 API → 프론트엔드에서 접이식 카드로 표시.
### 수정 파일
**src/Infrastructure/Kb/KbQdrantClient.cs**
| 위치 | 변경 |
|------|------|
| `GetChunksByDocIdAsync(docId, collection)` (신규) | Qdrant Scroll API로 doc_id 필터, payload(text, chunk_kind, locator) 반환 |
**src/Web/Controllers/KbController.cs**
| 위치 | 변경 |
|------|------|
| `GET /api/kb/documents/{id}/chunks` (신규) | admin 인증 필요, `KbQdrantClient.GetChunksByDocIdAsync` 호출 |
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `kbShowChunks(docId)` (신규) | `/api/kb/documents/{id}/chunks` 호출 → 모달에 렌더 |
| `kbRenderChunks(chunks)` (신규) | 청크 목록을 접이식 카드로 표시 (chunk_kind 배지, text 미리보기 200자, locator 표시) |
| `kbRenderDocs` | "청크 미리보기" 버튼 추가 (청크 수 > 0인 경우) |
**src/Web/wwwroot/index.html**
| 위치 | 변경 |
|------|------|
| 청크 미리보기 모달 (kb-upload-modal 다음) | #kb-chunk-modal: 모달 내 청크 리스트 + 닫기 버튼 |
**src/Web/wwwroot/css/style.css**
| `.kb-chunk-card` | 청크 카드 (테두리, 접힘/펼침) |
| `.kb-chunk-badge` | chunk_kind 배지 (table/page/text 등) |
| `.kb-chunk-locator` | locator 표시 (파일 내 위치) |
### API 응답 형식
```json
GET /api/kb/documents/{id}/chunks
{
"success": true,
"docId": "uuid",
"collection": "plant_operation",
"count": 12,
"chunks": [
{
"text": "청크 내용...",
"chunk_kind": "table",
"locator": "Sheet1, Row 5-10",
"score": null
}
]
}
```
### UI 동작
```
[d832a1f2] 온도센서 교체 매뉴얼.pdf | plant_operation | indexed | 12청크 | [청크보기]
↓ 클릭
┌─ 청크 미리보기 ─────────────────────────────────┐
│ [table] Sheet1, Row 5-10 │
│ ┌───────┬──────┬──────┐ │
│ │ 온도 │ 범위 │ 오차 │ │
│ ├───────┼──────┼──────┤ │
│ │ PT100 │ -200…│ ±0.1 │ │
│ └───────┴──────┴──────┘ │
│ │
│ [text] 페이지 2, 3번째 문단 │
│ RTD 센서는 저항 온도 센서로... [펼치기] │
└──────────────────────────────────────────────────┘
```
### 검증
- 청크 0건 문서 → 버튼 미표시
- 청크 12건 문서 → 버튼 표시 → 클릭 시 모달 오픈
- 모달 내 청크 카드 접기/펼치기 동작
---
## 5. 시계열 미니 스파클라인 (Phase 5 후순위)
### 배경
Phase 5 설계서(C5)에서 "표 자동 렌더링"은 완료했으나 "시계열 스파클라인"은 보류됨.
uPlot은 이미 fastRecord 탭에서 사용 중 (`index.html:1535` 로드 완료).
### 설계
`query_pv_history` 또는 `run_sql` 결과에서 timestamp + numeric value 컬럼 감지 시
uPlot 미니 차트(스파클라인)를 자동 렌더링.
### 수정 파일
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `llmRenderSparkline(containerId, data)` (신규) | uPlot 미니차트 생성 (height: 80px, grid 없음, tooltip만) |
| `llmRenderToolPayload()` (line 3587) | JSON 응답에 timestamp+value 패턴 감지 시 스파클라인 렌더링 옵션 제공 |
| `llmDetectTimeSeries(data)` (신규) | 데이터가 `[{timestamp, value}]` 또는 `[{recorded_at, pv}]` 형태인지 감지 |
**src/Web/wwwroot/css/style.css**
| `.llm-sparkline-box` | 스파클라인 컨테이너 (padding, border, max-width) |
| `.llm-sparkline-toggle` | "📈 추세 보기" 토글 버튼 |
### 스파클라인 생성 코드 (예시)
```javascript
function llmRenderSparkline(containerId, data, valueKey, timeKey) {
const times = data.map(r => new Date(r[timeKey || 'timestamp']).getTime() / 1000);
const vals = data.map(r => parseFloat(r[valueKey || 'value']));
const opts = {
width: 280, height: 64,
cursor: { show: true },
select: { show: false },
axes: [{ show: false }, { show: false }],
series: [
{ label: '' },
{ label: '', stroke: 'var(--accent)', width: 1, points: { show: false } }
]
};
new uPlot(opts, [times, vals], document.getElementById(containerId));
}
```
### 동작 흐름
```
query_pv_history 결과
→ llmRenderToolPayload에서 {success, data:[{tag_name, timestamp, value}]} 감지
→ "📈 추세 보기" 버튼 표시
→ 클릭 시 llmRenderSparkline() 실행
→ uPlot 미니차트가 툴 카드 내에 렌더링
```
### 검증
- 시계열 데이터 2건 미만 → 차트 미표시
- 3건 이상 → "📈 추세 보기" 버튼 → 클릭 시 uPlot 차트 렌더링
- 툴 카드 접기/펼치기와 호환
---
## 6. 툴 카드 메시지 영구 보존 (Phase 5 후순위)
### 배경
현재 툴 카드(`llm-tool-card`)는 SSE `tool_start`/`tool_result` 이벤트로 DOM에 직접
삽입되지만, `llmRenderMessages()``sess.messages`만으로 전체 메시지 영역을
innerHTML=...로 재생성하므로 툴 카드가 사라진다.
즉, 페이지 새로고침이나 탭 전환 후 이전 대화를 열면 툴 카드가 모두消失.
### 설계
`sess.messages``tool_call` 타입 메시지를 저장하고, `llmRenderMessages()`에서
툴 카드를 재생성할 수 있도록 구조화.
### 수정 파일
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| **데이터 모델** | `sess.messages` item에 type: `"text"` \| `"tool_call"` 속성 추가 |
| `llmSend()` (line 3758) | assistant 메시지에 `toolCalls: []` 배열 추가 (초기 빈 배열) |
| SSE `tool_start` 처리 (line 3838) | `assistantMsg.toolCalls.push({id, name, args, ok:null, payload:null})` |
| SSE `tool_result` 처리 (line 3846) | 해당 toolCalls 항목 업데이트 (`ok`, `payload`, `preview`, `length`) |
| `llmRenderMessages()` (line 3418) | 메시지에 `toolCalls` 배열 있으면 툴 카드 렌더링 |
| `llmRenderToolCards(toolCalls)` (신규) | toolCalls 배열 → 툴 카드 HTML 생성 |
| `llmSaveSessions()` | 변경 없음 (messages 배열에 포함되어 자동 저장) |
### 데이터 구조 (localStorage)
```javascript
sess.messages = [
{ role: 'user', content: '지금 알람 보여줘' },
{
role: 'assistant',
content: '현재 3개의 활성 알람이 있습니다...',
toolCalls: [
{ id: 'tc_1_xxx', name: 'active_alarms', args: '{}', ok: true, payload: '{...}' }
]
}
]
```
### 수정 상세
**llmSend() - toolCalls 배열 초기화 (line 3758)**
```javascript
// 변경 전
const assistantMsg = { role: 'assistant', content: '' };
// 변경 후
const assistantMsg = { role: 'assistant', content: '', toolCalls: [] };
```
**SSE `tool_start` 핸들러 (line 3838)**
```javascript
// 추가
assistantMsg.toolCalls.push({ id: t.id, name: t.name, args: t.args, ok: null, payload: null });
```
**SSE `tool_result` 핸들러 (line 3846)**
```javascript
// 추가
const tc = assistantMsg.toolCalls.find(x => x.id === t.id);
if (tc) { tc.ok = t.ok; tc.payload = t.payload; }
```
**llmRenderMessages() - toolCalls 렌더링 (line 3449 이후)**
```javascript
// 각 메시지의 content 다음에 toolCalls 렌더링
if (m.toolCalls && m.toolCalls.length > 0) {
const cardsHtml = m.toolCalls.map(tc => {
const statusClass = tc.ok === null ? 'running' : (tc.ok ? 'ok' : 'err');
const statusText = tc.ok === null ? '실행 중…' : (tc.ok ? '완료' : '실패');
// ... 툴 카드 HTML 생성
}).join('');
html += `<div class="llm-tool-cards">${cardsHtml}</div>`;
}
```
### 마이그레이션 (기존 세션 호환)
기존 localStorage `llmSessions`에는 `toolCalls` 필드가 없음. `llmRenderMessages()`에서
`toolCalls`가 undefined/null이면 툴 카드 렌더링 생략 (기존처럼 동작).
### 검증
1. 새 채팅에서 도구 호출 → SSE 완료 후 localStorage 확인 → `toolCalls` 배열 저장됨
2. F5 새로고침 → 이전 대화 열기 → 툴 카드가 정상 렌더링
3. 기존 세션(툴 카드 없는 메시지) → 오류 없음
4. 저장/로드 시 JSON 용량 증가 확인 (toolCalls 1개당 약 200바이트)
---
## 7. 현장 재고 데이터 출처 (결정 보류 — 분석만)
### 현황
Phase 0 설계서(G3)에서 "현장 재고 데이터 자체 없음"으로 식별됨. 코드 작업 없음.
### 분석 방향
| 측면 | 검토 사항 |
|------|----------|
| 데이터 성격 | 예비품 재고, 소모품, 교체 이력, 위치 정보 |
| 가능한 출처 | 별도 엑셀 관리 → KB 업로드, ERP 연동 API, 수동 입력 |
| KB 활용 | plant_operation 또는 report 컬렉션에 엑셀 업로드로 즉시 해결 가능 |
| 우선순위 | 낮음 (Phase 0~6 안정화 후 검토) |
### 권장
현장 재고 데이터는 기존 KB 시스템에 엑셀/CSV를 `plant_operation` 컬렉션으로
업로드하여 즉시 RAG 검색 가능. 별도 개발 불필요.
---
## 8. 임베딩 모델 BGE-M3 마이그레이션 (결정 보류 — 계획만)
### 현황
현재 `nomic-embed-text` (768차원, Ollama) 사용 중. BGE-M3 (1024차원)는
다국어(한국어) 성능이 더 우수하나 Qdrant 컬렉션 재생성이 필요.
### 마이그레이션 계획 (향후 실행 시)
| 단계 | 작업 | 영향 |
|------|------|------|
| 1 | BGE-M3 Ollama에 pull (`ollama pull bge-m3`) | 서버 리소스 추가 사용 |
| 2 | `KbEmbeddingClient.cs` 모델명 변경 (settings.json) | 임베딩 차원 768→1024 |
| 3 | Qdrant 컬렉션 5개 재생성 (기존 삭제 + vector_size=1024로 recreate) | 기존 인덱스 전부 소멸 |
| 4 | 전체 문서 재인덱싱 | 시간 소요 (문서 수에 비례) |
| 5 | `_embed()` consistency check | 1024차원 정상 출력 확인 |
### 위험
- 기존 Qdrant 컬렉션 삭제 시 모든 KB 검색 불가 (재인덱싱 완료까지)
- 1024차원으로 변경 시 메모리 사용량 증가 (약 33%)
- BGE-M3의 한국어 성능 향상이 임계값 이상인지 사전 평가 필요
### 권장
긴급하지 않음. nomic-embed-text로 Phase 7 운영 후, BGE-M3 안정성 확인 후
점진적 마이그레이션. `KbEmbeddingClient.cs`에 dimension을 환경변수/설정에서
읽도록 개선하는 선행 작업 권장.
---
## 실행 우선순위
| 순위 | 작업 | 예상 시간 | 영향도 |
|------|------|-----------|--------|
| 1 | **툴 카드 영구 보존** | 2~3h | HIGH — UX 품질, 데이터 손실 방지 |
| 2 | **KB 청크 미리보기 UI** | 2~3h | MED — 관리자 디버깅 편의 |
| 3 | **시계열 스파클라인** | 1~2h | MED — 데이터 가시성 향상 |
| 4 | **NL2SQL 의도 라우터** | 1~2h | MED — 불필요한 SQL 호출 감소 |
| 5 | **대화 요약** | 1~2h | LOW — 장기 대화 안정성 |
| 6 | **에이전트 모드** | 2~3h | LOW — 고급 기능, Phase 7 후순위 |
| 7 | **BGE-M3 분석/계획 수립** | — | 보류 |
| 8 | **현장 재고 데이터 출처** | — | 보류 |
---
## 빌드/검증 명령
```bash
# .NET 빌드
dotnet build src/Web/ExperionCrawler.csproj
# Python syntax check
python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py
# Python import check
cd mcp-server && python3 -c "import server"
```
## 런타임 셋업 (코드 외)
- mcp-server 재시작 (의도 라우터 추가 시)
- 브라우저 캐시 무효화 (Ctrl+F5)

View File

@@ -0,0 +1,453 @@
# 옵시디언-식 그래프 구조 적용 플랜
> 옵시디언(Obsidian) 앱을 도입하는 것이 아니라, 옵시디언이 사용하는 **데이터 패턴**(마크다운 노트 + YAML frontmatter + `[[wikilinks]]` + `#tags` + 그래프 탐색)을 현재 RAG 파이프라인 안에 흡수해 P&ID/DXF/PDF만으로 부족한 지식 표현을 보강하는 것이 목적이다.
작성: 2026-05-14 | 대상 스택: vLLM Qwen3.6-27B-FP8 + Ollama nomic-embed-text(768) + Qdrant + PostgreSQL + KbIngestWorker(C#) + MCP(Python)
---
## 1. 배경 — 왜 지금 그래프 구조가 필요한가
### 1.1 현재 한계
| 영역 | 현재 동작 | 한계 |
|------|----------|------|
| P&ID / DXF | `parse_pid_dxf`, `parse_pid_pdf`로 태그/심볼/선 추출 → PostgreSQL `pid_*` 테이블 적재 | 추출 정확도가 도면 품질에 종속. LLM이 그림을 "읽지" 못해 27B로도 흐름/인과 추론이 약함. 누락된 관계는 영원히 누락 |
| PDF/Excel KB | `parse_document`로 텍스트 청크 → 벡터 임베딩 → Qdrant 5개 컬렉션 | 청크는 의미 단위가 아닌 페이지/시트/표 단위. 청크 간 관계(이 절차는 저 알람과 연결됨)는 모델이 매번 "발견"해야 함 |
| 채팅 RAG | `search_kb`/`rag_query`로 top-k 청크 retrieval | 의미적으로 가까운 청크는 잘 찾지만 **명시적 연결**이 없는 항목(같은 루프의 다른 태그, 같은 area의 작년 이벤트)은 안 따라옴 |
| 현장 지식 | 운전원 머릿속 / Excel / Word | 검색·연결·LLM 주입이 불가 |
### 1.2 27B 모델의 현실
`Qwen3.6-27B-FP8` 단일 GPU 서빙. 한 답변에 토큰 46k 정도가 안정 구간. 따라서:
- **검색기가 똑똑할수록 LLM 부담이 줄어든다** — 청크 100개 던지고 LLM에게 정리시키면 토큰·지연 폭증. 청크 510개 + **관계 그래프 1-hop**을 던지는 게 훨씬 싸다.
- **그래프 탐색은 LLM 추론이 아니라 인덱스 조회로 해결**해야 한다. GraphRAG처럼 LLM으로 엔티티/관계를 추출하는 무거운 방식은 우리 GPU에서 비용이 크다.
### 1.3 옵시디언 패턴이 잘 맞는 이유
- **마크다운 = LLM 친화** — 토큰화 효율, 헤딩 기반 청킹 자연스러움
- **`[[wikilinks]]` = 사람이 손으로 만든 명시적 엣지** — LLM이 추출할 필요 없음. 운전원/엔지니어가 노트 쓰면서 자동으로 그래프가 자란다
- **YAML frontmatter = 구조화 메타데이터** — `area`, `loop`, `vendor`, `range` 같은 필드가 노트 수준에서 일관됨
- **`#tags` = 가로 분류** — 폴더 계층과 무관한 횡적 묶음
- 모두 **로컬 텍스트 파일**로 표현 가능 → 백업/git/diff 자유롭고 KB 파이프라인에 그대로 흘려 넣을 수 있음
---
## 2. 채택할 것 / 채택하지 않을 것 (스코프 선)
### 채택
- 노트 = 마크다운 1개 파일 + YAML frontmatter
- `[[note-id]]` 위키링크 문법 — id는 안정적이고 URL-safe
- `#tag` 인라인 태그 + frontmatter `tags:` 둘 다 인정
- 노트 간 **유향 그래프** (`source_note``target_note`, `link_type`)
- 그래프 13 hop 탐색 도구 + 벡터 검색과의 하이브리드
### 채택하지 않음
- ❌ Obsidian 앱 실행 / `.obsidian/` 설정 / plugin 호환
- ❌ Obsidian Sync · Publish
- ❌ Canvas, Excalidraw 등 GUI 부속
- ❌ "Vault 폴더 → 자동 동기화" 패턴 (파일 워처) — 현재 KB는 업로드형 큐 모델이고 일관성 유지에 유리. 동기화는 단방향 임포트 도구로 충분
- ❌ LLM으로 자동 엔티티/관계 추출 (GraphRAG 식) — 27B 처리량 부담. 대신 **결정론적 추출**(정규식·frontmatter·기존 P&ID 파서 결과) + 사용자 작성을 1차로 한다
→ 옵시디언으로 노트 편집하고 싶은 사용자는 `.md` 파일을 외부에서 옵시디언으로 열어도 호환된다. 그것은 **부수효과**이지 의존 관계가 아니다.
---
## 3. 데이터 모델
### 3.1 노트 ID 규칙
`{kind}/{slug}` 형식. 예:
```
tag/fic-6113
loop/compression-a
area/unit-a
drawing/pid-105
procedure/start-up-a
vendor/honeywell-c300
event-pattern/surge-recovery
```
- 모두 소문자, kebab-case
- `tag/`, `loop/`, `area/`, `drawing/`, `procedure/`, `vendor/`, `event-pattern/`, `term/`, `kpi/` 9종 (시드, 확장 가능)
- 위키링크 본문에선 `[[tag/fic-6113]]` 또는 별칭 `[[tag/fic-6113|FIC-6113]]`
### 3.2 Frontmatter 스키마 (공통 + kind별)
공통:
```yaml
---
id: tag/fic-6113 # 필수, 위 ID 규칙
title: FIC-6113 (압축단 1열 입구 유량)
kind: tag # 필수, 9종 중 하나
tags: [flow, area-a, critical]
aliases: [FIC6113, fic-6113.pv]
created: 2026-05-14
updated: 2026-05-14
sources: # 선택 — 이 노트의 근거 문서
- kb_doc_id: 1f3a... # 기존 kb_documents UUID
locator: "p.12 §3.2"
---
```
kind별 추가 필드 (선택, frontmatter라 유연):
| kind | 권장 필드 |
|------|----------|
| `tag` | `pv_tag`, `sp_tag`, `op_tag`, `unit`, `range_min`, `range_max`, `eu`, `area`, `loop` |
| `loop` | `area`, `controllers`, `actuators`, `transmitters`, `setpoint_strategy` |
| `area` | `unit`, `manager`, `process_description` |
| `drawing` | `drawing_number`, `revision`, `pid_doc_id` (기존 pid_drawings.id 연결) |
| `procedure` | `applicable_loops`, `last_review`, `owner` |
| `vendor` | `manufacturer`, `model`, `firmware` |
| `event-pattern` | `event_types`, `area`, `mitigation` |
### 3.3 본문 컨벤션
```markdown
# FIC-6113
압축기 A열 입구 유량 제어. [[loop/compression-a]] 의 1차 제어 변수.
## 정상 운전
- SP: 120 t/h (보통 115125 범위)
- 알람 상한: 135 t/h → 트립: 145 t/h
- 상위 cascade: [[tag/pic-6101]]
## 관련 이벤트 패턴
- [[event-pattern/surge-recovery]] — 빠르게 떨어지면 ESV 확인
- 정기점검 후 첫 기동 시 잦은 흔들림 → [[procedure/start-up-a]] 단계 7 참조
## 참고
- 도면: [[drawing/pid-105]]
- 벤더: [[vendor/honeywell-c300]]
```
- 헤딩 ≤ H3 권장 → 청킹 단위
- 위키링크 `[[id]]` 또는 `[[id|alias]]`
- 인라인 태그 `#flow #critical` 도 허용 (frontmatter `tags:`에 자동 병합)
### 3.4 PostgreSQL 스키마 (신규 5개 테이블)
기존 KB 파이프라인과 **분리된 네임스페이스**(`vault_*` prefix). `kb_documents`는 원본 파일(PDF/Excel)용 그대로 유지, vault는 별도 트랙.
```sql
-- 노트 본체
CREATE TABLE vault_notes (
id TEXT PRIMARY KEY, -- "tag/fic-6113"
kind TEXT NOT NULL, -- 9종 중 하나
title TEXT NOT NULL,
body_md TEXT NOT NULL, -- 원본 마크다운
frontmatter JSONB NOT NULL DEFAULT '{}',
content_hash TEXT NOT NULL, -- SHA256(body_md+frontmatter)
status TEXT NOT NULL DEFAULT 'pending',-- pending/embedding/indexed/failed
error_message TEXT,
source_doc_id UUID REFERENCES kb_documents(id),-- 임포트된 경우
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON vault_notes (kind);
CREATE INDEX ON vault_notes USING gin (frontmatter);
-- 별칭 (id ↔ alias 양방향)
CREATE TABLE vault_aliases (
alias TEXT PRIMARY KEY, -- 소문자 정규화
note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE
);
-- 태그 (frontmatter + 인라인 #tag 병합 결과)
CREATE TABLE vault_tags (
note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (note_id, tag)
);
CREATE INDEX ON vault_tags (tag);
-- 위키링크 (유향 엣지)
CREATE TABLE vault_links (
src_note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE,
dst_note_id TEXT NOT NULL, -- 미존재 노트도 허용(빨간 링크)
dst_resolved BOOLEAN NOT NULL DEFAULT FALSE, -- 타겟 노트가 실제 존재?
link_type TEXT NOT NULL DEFAULT 'wikilink', -- 향후 'cascade','vendor-of' 등 확장
occurrences INT NOT NULL DEFAULT 1, -- 같은 노트에서 N회 등장
PRIMARY KEY (src_note_id, dst_note_id, link_type)
);
CREATE INDEX ON vault_links (dst_note_id);
-- 청크 (Qdrant point_id ↔ 노트/헤딩 매핑)
CREATE TABLE vault_chunks (
point_id UUID PRIMARY KEY, -- Qdrant point_id
note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE,
heading_path TEXT, -- "## 정상 운전 > ### 알람"
chunk_text TEXT NOT NULL,
token_count INT
);
CREATE INDEX ON vault_chunks (note_id);
```
`dst_resolved=false`인 링크는 "아직 노트가 안 만들어진 빨간 링크" — 자라야 할 그래프의 빈자리를 가시화한다.
### 3.5 Qdrant 컬렉션
신규 1개 — `vault` (768-dim cosine, payload만 인덱스: `note_id`, `kind`, `tags`, `area`, `loop`).
기존 `kb_*` 5개와 **공존**. `search_kb`는 그대로, `search_vault`는 신규(아래 §6).
---
## 4. 인덱싱 파이프라인
### 4.1 입력 경로 3가지
| 경로 | 동작 |
|------|------|
| **A. UI에서 마크다운 직접 작성/수정** | 14번 탭 확장. 저장 시 `vault_notes` UPSERT + 인덱싱 큐 적재 |
| **B. zip/폴더 임포트** | `.md` 파일들을 zip으로 업로드 → 일괄 파싱·검증·노트 생성 |
| **C. 기존 데이터 자동 시드** | `node_map_master`/`pid_tags`/`pid_drawings`/`kb_collections` 등에서 자동으로 노트 스켈레톤 생성 (§7.2 별도 절) |
세 경로 모두 동일한 **MarkdownNoteWorker**(C# `BackgroundService`, 기존 `KbIngestWorker` 패턴 차용)로 수렴.
### 4.2 워커 단계 (단일 패스)
```
[큐: vault_notes WHERE status='pending']
1. 파싱 (server.py 또는 C# Markdig)
- frontmatter 추출 → JSON 검증
- 본문에서 [[id]] / [[id|alias]] / #tag 추출
- 헤딩 트리 추출 → 청크 분할 (≤512 토큰, 헤딩 경계 우선)
2. 정규화
- aliases 소문자화, 중복 제거
- 위키링크 id의 alias 해석 (vault_aliases 조회)
- 자기 자신 링크 제거
3. 그래프 갱신 (트랜잭션)
- vault_links 기존 행 삭제 후 재삽입
- dst_resolved 계산: dst가 vault_notes에 존재 OR alias에 존재
- 신규 노트가 들어오면 기존의 'unresolved' 링크들을 다시 resolve 시도
- vault_tags 동기화
4. 임베딩 (Ollama /api/embeddings, 청크별)
- 실패 청크는 skip + error_message에 누적, attempts<3
5. Qdrant upsert
- 기존 doc의 point들 먼저 delete(filter: note_id)
- 신규 청크 일괄 upsert
6. status='indexed' or 'failed'
```
부분 실패 정책은 기존 KbIngestWorker와 동일(전부 실패 시 failed, 일부 실패 시 indexed + error_message).
### 4.3 파싱 위치 선택
- **C# 측 Markdig + 자체 정규식** — 의존성 단순. frontmatter는 `YamlDotNet`.
- 또는 **MCP server.py에 `parse_markdown_note` 도구 추가** — Python `markdown-it-py` + `python-frontmatter` 사용, 기존 parsers/ 구조와 일관.
권장: **server.py에 추가**. 이유: 기존 `parse_document`/`parse_pid_*`와 같은 파이프라인이고, 위키링크·태그 추출 같은 텍스트 처리는 Python이 더 쉬움. C# Worker는 큐 관리·Qdrant 호출만 담당.
---
## 5. 하이브리드 검색 (핵심 차별점)
### 5.1 단계
```
질의 q
A. 벡터 검색 (Qdrant vault collection, top_k=10)
→ 시드 노트 집합 S = { note_id of top hits }
B. 그래프 확장 (PostgreSQL, 1-hop, 옵션 2-hop)
→ for each s in S:
out_links = vault_links WHERE src=s AND dst_resolved
in_links = vault_links WHERE dst=s
→ 확장 집합 E = S neighbors
C. 재순위 (rule-based, LLM 호출 없음)
- 벡터 점수 + boost(같은 area), boost(같은 loop)
- kind 우선순위 가중 (질의에 "절차" 포함 시 procedure 가중)
D. 청크 선택 + 컨텍스트 패킹
- 시드 노트는 top-2 청크
- 이웃 노트는 frontmatter + 첫 문단(요약)만
- 총 토큰 ≤ 3000 budget
LLM에 주입
```
핵심: **그래프 확장은 PostgreSQL JOIN 한두 번**. LLM 호출 0회. 27B GPU 부담 없음.
### 5.2 옵시디언의 "백링크"가 주는 가치
특정 태그 노트(`tag/fic-6113`)에 대한 백링크 목록 = 그 태그를 언급한 모든 절차/이벤트패턴/도면. 운전원이 "FIC-6113 이상해" 라고 물으면:
1. 벡터 검색이 `tag/fic-6113` 노트를 시드로 잡고
2. 백링크로 `[[procedure/start-up-a]]`, `[[event-pattern/surge-recovery]]` 가 따라붙고
3. cascade frontmatter로 `[[tag/pic-6101]]`도 자동 추가
→ 27B에게 던지는 컨텍스트가 **의미 검색만으로는 절대 못 모을 조합**으로 채워진다.
---
## 6. MCP 도구 (신규)
`server.py`에 추가. 모두 PostgreSQL/Qdrant 조회 — LLM 호출 없음.
| 도구 | 시그니처 | 동작 |
|------|---------|------|
| `vault_search` | `(query, kinds?, tags?, top_k=8, expand_hops=1)` | §5의 하이브리드 검색. 시드 + 확장 노트 목록 + 청크 반환 |
| `vault_get_note` | `(note_id, include_body=True)` | 단일 노트 전체. 헤딩 트리·frontmatter·in/out 링크 카운트 |
| `vault_backlinks` | `(note_id, limit=50)` | 이 노트를 가리키는 모든 src 노트 (`vault_links` WHERE dst) |
| `vault_neighbors` | `(note_id, hops=1, kinds?)` | 13 hop 이웃. 시각화·운전원 질문 "이 루프에 뭐 있어" |
| `vault_by_tag` | `(tag, kind?, limit=50)` | 태그 매칭 노트 목록 |
| `vault_unresolved` | `(limit=50)` | dst_resolved=false 링크 리스트 — "비어있는 노트" 진단 |
| `vault_update_note` | `(note_id, body_md, frontmatter)` | (admin) 노트 upsert. 큐 적재. 운전원이 채팅 중 "이거 노트로 저장" 흐름 위해 |
채팅 의도 라우터(`_classify_intent`)에도 규칙 추가:
```
"노트|이 태그.*정보|관련.*뭐|연결" → vault_search
"백링크|어디서.*언급" → vault_backlinks
```
`ToolGuideKo`에 위 도구들 추가, `rag_query` 확장 옵션 `use_vault=True` (벡터-only 모드와 토글 가능).
---
## 7. UI (14번 탭 확장 + 신규 카드 / 신규 탭은 보류)
> 신규 탭(15번)을 또 만들기보다 **14번 "RAG 관리" 탭 안에 두 번째 모드 토글**을 두는 게 코드/사용자 인지부하 면에서 깔끔하다.
### 7.1 14번 탭 — 모드 전환
```
[원본 문서] | [Vault 노트] ← 토글
```
### 7.2 Vault 노트 모드 화면
- 좌측: kind 필터(tag/loop/area/...) + 태그 facet + 빨간 링크 카운트
- 중앙: 노트 목록 (검색·정렬·일괄 선택)
- 우측: 단일 노트 뷰
- 상단: frontmatter 폼 (수정 가능, 저장 시 큐 적재)
- 가운데: 마크다운 편집기 (textarea + 프리뷰 토글, [[ 자동완성)
- 하단: "백링크 (N)" / "이웃 그래프 미니뷰" (SVG 간단한 force-layout — 100 노드 미만, 그 이상은 리스트로 fallback)
### 7.3 채팅 통합
- `vault_search` 결과 카드: 노트 제목 클릭 시 14번 탭의 해당 노트로 점프
- `[[id]]` 토큰이 답변에 등장하면 자동으로 다운로드/노트뷰 링크 치환 (현재 `llmLinkKbCitations`와 동일 패턴 확장)
신규 라이브러리는 도입하지 않는다 — 마크다운 렌더는 기존이 있으면 재사용, 없으면 textarea + 단순 `marked` CDN 한 줄.
---
## 8. P&ID/DXF 한계 해결 — 자동 시드 + 운전원 보강
### 8.1 자동 시드 (1회성 마이그레이션 잡)
기존 PostgreSQL 데이터에서 결정론적으로 노트를 만든다 (LLM 0회):
| 소스 | 생성될 노트 | frontmatter 자동 채움 |
|------|------------|-----------------------|
| `node_map_master` | `tag/{base-tag}` 각 1개 | `pv_tag`, `sp_tag`, `op_tag`, `unit`, `range_*`, `eu` |
| `pid_drawings` | `drawing/{drawing_number}` | `revision`, `pid_doc_id` |
| `pid_tags` (drawing 안의 태그) | drawing 노트 본문에 `[[tag/...]]` 위키링크로 자동 삽입 | — |
| `kb_collections` 시드 5종 | `term/system-instrument` 등 | — |
| `event_history_table` 빈도 상위 패턴 | (보류 — LLM 없이 군집화 어려움) | — |
→ 첫 임포트로 **수천 개 노트가 빨간 링크 없는 상태**로 깔린다. P&ID에서 못 잡은 관계는 운전원이 노트 본문에 `[[tag/...]]` 한 줄 추가로 채운다. 채울수록 그래프가 풍부해진다.
### 8.2 DXF/PDF는 그대로 두되 결과를 노트로 발행
`parse_pid_dxf` 결과는 지금처럼 `pid_*` 테이블에 그대로. 거기에 더해:
- drawing 1개 = 노트 1개 (자동 생성, 본문은 태그 리스트 + 도면 파일 임베드 링크)
- DXF 파싱이 인식한 태그 = 해당 drawing 노트 본문에 위키링크
- 인식 못 한 영역은 노트 본문 하단에 `## TODO` 섹션 자동 생성 → 운전원이 채움
**LLM이 도면을 "읽을" 필요가 없어진다.** 운전원이 노트로 옮긴 정보만 LLM이 본다.
---
## 9. 하드웨어/성능 예측
가정: 자동 시드 후 노트 ~3,000개, 평균 본문 1.5KB, 청크 평균 3개/노트 → 청크 ~9,000개.
| 자원 | 추정 | 비고 |
|------|------|------|
| PostgreSQL 디스크 | < 50 MB | vault_notes(본문) + 인덱스 |
| Qdrant 메모리 | 9,000 × 768 × 4byte ≈ 28 MB + HNSW 오버헤드 ≈ **< 100 MB** | 기존 ws-/experion-opc-docs와 별도 |
| 임베딩 초기 비용 | 9,000 청크 × nomic-embed-text | CPU/GPU에 따라 다르지만 단발성. 이후 변경된 노트만 |
| 검색 (벡터+1hop) | Qdrant 10ms + PG 5ms ≈ **< 50ms** | LLM 호출 없음 |
| 검색 (벡터+2hop) | PG가 노드 수 폭발 가능 → **2-hop은 옵션 + 노드 cap=200** | |
| LLM 컨텍스트 증가 | 시드 청크 5 + 이웃 메타 10개 ≈ **+1.5k 토큰** | 27B 컨텍스트 32k 한계 내 안전 |
| 동시성 | 워커 1개 단일 패스 (기존 정책 유지) | 임베딩 큐가 LLM과 GPU 충돌 시: **임베딩은 Ollama(별도 프로세스/CPU 가능)** 이므로 vLLM과 분리 |
**하드웨어 추가 부담 거의 없음**. 가장 큰 비용은 초기 임베딩 1회.
---
## 10. Phase 분할 (현실적 단위)
### Phase A — 데이터 모델 + 워커 골격 (변경 최소, 채팅 영향 없음)
- `vault_*` 테이블 5개 DDL (ExperionDbContext.InitializeAsync에 추가, 기존 KB DDL 패턴 답습 — `NpgsqlConnection.ExecuteNonQueryAsync` 사용해 중괄호 이슈 회피)
- `VaultNote` / `VaultLink` / `VaultTag` / `VaultChunk` 엔티티
- `IVaultService` 인터페이스 + 구현 (CRUD + 큐 적재)
- `MarkdownNoteWorker` BackgroundService (parse → embed → index)
- `server.py``parse_markdown_note` MCP 도구
- 검증: 손으로 `.md` 1개 업로드 → `indexed` 도달 + Qdrant 점 생성 확인
### Phase B — MCP 도구 + 채팅 연결
- `vault_search` / `vault_get_note` / `vault_backlinks` / `vault_neighbors` / `vault_by_tag` / `vault_unresolved`
- `ToolGuideKo` 갱신
- 의도 라우터 규칙 추가
- 채팅 결과 카드에 노트 인용 렌더링 (`llmLinkVaultCitations`)
- 검증: 채팅에서 "FIC-6113 관련 노트 보여줘" → 도구 호출 + 카드 표시
### Phase C — UI (14번 탭 확장)
- 모드 토글, 노트 목록, 노트 뷰/편집, frontmatter 폼
- 빨간 링크 진단 패널
- 이웃 그래프 미니뷰 (간단 SVG)
- zip 임포트 엔드포인트
### Phase D — 자동 시드 마이그레이션
- `node_map_master` → tag 노트 생성 잡
- `pid_drawings` + `pid_tags` → drawing 노트 + 위키링크
- 1회성 admin-only 엔드포인트 `POST /api/vault/seed` (dry-run 옵션 포함)
### Phase E (선택) — 점진적 자동화
- 채팅에서 LLM이 답변하면서 새 fact를 발견하면 "이걸 [[tag/...]] 노트에 추가할까요?" 제안 → 운전원 확인 시 `vault_update_note` 호출
- 운영자가 일정 누르면 "지난주 자주 등장한 unresolved 링크 N개 — 노트 만들기" 작업 큐
Phase AB는 12일, C는 1일, D는 0.5일, E는 보류 가능.
---
## 11. 잔여 결정사항 (사용자 확인 필요)
| 항목 | 옵션 | 코멘트 |
|------|------|--------|
| 노트 ID 충돌 시 | (1) 거부 (2) 버전 suffix | 기본 (1) 권장 |
| `.md` 외부 편집 허용 여부 | (1) UI 전용 (2) 파일 export/import 양방향 | 양방향은 동기화 충돌 비용 큼. (1) 권장. 필요 시 read-only export만 |
| 옵시디언 앱과 호환 (부수효과) | (1) frontmatter 키 호환 (2) Obsidian 전용 키(`cssclasses` 등) 무시 | (1) — 우리가 정의한 키 외엔 그냥 frontmatter에 보존만 하면 됨 |
| 이웃 그래프 시각화 라이브러리 | (1) 직접 SVG (2) Cytoscape.js (3) vis-network | (1) 노드 ≤ 100 가정. 그 이상은 리스트 fallback |
| 자동 시드 후 첫 화면 | (1) 모든 태그 노트가 "빈 본문" (2) 자동으로 한 줄 요약 LLM 생성 | (1) 권장 — LLM 비용 회피 + 운전원이 손으로 채우면서 도메인 정합 |
| 백업/git | `storage/vault-export/` 매일 1회 `.md` 덤프 | DB 백업과 별개로 사람이 읽을 수 있는 형태 보존 |
---
## 12. 핵심 메시지
- **옵시디언 도입이 아니다.** 옵시디언이 검증한 4개 패턴(마크다운 + frontmatter + 위키링크 + 태그)만 빌려와 기존 KB 위에 **그래프 레이어**를 얹는다.
- **27B로 그래프를 "추론"시키지 않는다.** 그래프는 사람·규칙·기존 DB로 만든다. LLM은 만들어진 그래프를 **탐색**한 결과를 받아쓴다.
- **P&ID 한계는 노트로 우회한다.** 도면이 표현 못 한 관계를 운전원이 위키링크 한 줄로 채운다. 채울수록 RAG 품질이 자란다.
- **하드웨어 부담은 임베딩 초기 1회 + Qdrant < 100 MB.** 검색 경로에 LLM 추가 호출이 없다.
- **기존 KB와 공존**한다. PDF/Excel은 그대로, vault는 별도 트랙. 채팅은 두 채널 결과를 모두 본다.

Some files were not shown because too many files have changed in this diff Show More