chore: 루트 문서 정리 — knowledge/ CANON 소스 신설 + 흩어진 문서 루트 밖 격리

seed 품질 확보(GIGO 차단). 루트에 흩어졌던 ~150개 문서를 용도별 분리.

- knowledge/ 신설 = 단일 CANON 지식 소스 (RAG/지식은 여기만 참조)
  · 플랜트 지식 7: 구조설명 6-1/6-2차, 측류추출 관계식·시간지연, PGMEA 일반상식·운전주의점
  · 도면-데이터시트/: As-Built 15 + FCV 데이터시트 2 (PDF 바이너리는 .gitignore, 디스크 유지)
- 계획·진단·대화로그·멀티모델 초안(byQwen/byGemma 등)·완료작업(dxf-graph/·fastTable/·plans/)은
  **프로젝트 루트 밖 저장소로 격리**(삭제 아닌 이동, 복원 가능):
    /home/windpacer/projects/ReferenceSources/ExperionCrawler/
  (ExperionCrawler.Tests/ 도 동일 위치 — 완료/실패분, 필요시 복원)
- .gitignore: 대용량 PDF(knowledge 104M + src/Web/uploads 157M)·*.backup 제외

근거 플랜(아카이브): ReferenceSources/.../plans/online-lora-학습-파이프라인-실행계획-byOPUS.md Phase -1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-26 09:55:19 +09:00
parent ab3e36680f
commit 3e9f3076ef
367 changed files with 1566 additions and 2525740 deletions

View File

@@ -1,477 +0,0 @@
/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 추출/재추출분은
자동 적용됩니다.

8
.gitignore vendored
View File

@@ -65,3 +65,11 @@ storage/
# wwwroot 프론트 라이브러리는 추적 (위 Python용 lib/ 규칙의 예외)
!src/Web/wwwroot/lib/
!src/Web/wwwroot/lib/**
# 백업 잔여물
*.backup
*.backup.*
# 대용량 PDF 바이너리는 git 제외 (디스크엔 유지, 필요시 Git LFS)
src/Web/uploads/*.pdf
knowledge/도면-데이터시트/

View File

@@ -1,78 +0,0 @@
import networkx as nx
from typing import Dict, List, Optional
import json
import os
class PidAnalysisEngine:
def __init__(self, topology_file: str, mapping_file: str):
self.topology_file = topology_file
self.mapping_file = mapping_file
self.graph = nx.DiGraph()
self.tag_mapping = {}
self.load_data()
def load_data(self):
"""그래프 및 매핑 데이터 로드"""
try:
if os.path.exists(self.topology_file):
with open(self.topology_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# NetworkX 그래프 생성 (node_link_data 형식 가정)
for node in data.get('nodes', []):
self.graph.add_node(node['id'], **node)
for edge in data.get('links', []): # node_link_data는 'links' 사용
self.graph.add_edge(edge['source'], edge['target'], **edge)
if os.path.exists(self.mapping_file):
with open(self.mapping_file, 'r', encoding='utf-8') as f:
self.tag_mapping = json.load(f)
except Exception as e:
print(f"Error loading analysis data: {e}")
def get_propagation_path_with_flow(self, start_node: str):
"""
엣지의 방향성(flow_direction)과 상태(valve_status)를 고려한 실제 영향 전파 경로 추출
"""
if start_node not in self.graph:
return {}
# 1. 유효한 엣지만 필터링 (방향이 forward이고 밸브가 open인 경로)
valid_edges = [
(u, v) for u, v, d in self.graph.edges(data=True)
if d.get('flow_direction', 'forward') == 'forward'
and d.get('valve_status', 'open') == 'open'
]
filtered_graph = nx.DiGraph()
filtered_graph.add_edges_from(valid_edges)
# 2. 전파 단계별 노드 추출 (BFS)
try:
propagation_levels = nx.single_source_shortest_path_length(filtered_graph, start_node)
return propagation_levels
except Exception:
return {}
def analyze_impact(self, node_id: str):
"""특정 노드 장애 시 영향도 분석 결과 반환"""
if node_id not in self.graph:
return {"success": False, "error": f"Node {node_id} not found in topology"}
impact_map = self.get_propagation_path_with_flow(node_id)
# 경로 추출 (시각화를 위해 모든 영향 노드로의 최단 경로 포함)
paths = []
for target in impact_map.keys():
if target != node_id:
try:
path = nx.shortest_path(self.graph, source=node_id, target=target)
paths.append(path)
except nx.NetworkXNoPath:
continue
return {
"success": True,
"startNode": node_id,
"impactedNodes": impact_map,
"paths": paths
}

View File

@@ -1,189 +0,0 @@
import ezdxf
import re
import json
import logging
from typing import List, Optional, Tuple, Union
from pydantic import BaseModel, Field
from shapely.geometry import box, Point
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Data Models ---
class BoundingBox(BaseModel):
min_x: float
min_y: float
max_x: float
max_y: float
center: Tuple[float, float]
class GeometricEntity(BaseModel):
entity_id: str
entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC
layer: str
bbox: BoundingBox
raw_value: Optional[str] = None
clean_value: Optional[str] = None
coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list)
properties: dict = Field(default_factory=dict)
# --- Extractor Implementation ---
class PidGeometricExtractor:
def __init__(self, file_path: str):
try:
self.doc = ezdxf.readfile(file_path)
self.msp = self.doc.modelspace()
except Exception as e:
raise IOError(f"Failed to load DXF file: {e}")
def clean_text(self, text: str) -> str:
"""
DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환.
"""
if not text:
return ""
# 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등)
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
# 2. 중괄호 { } 제거
text = re.sub(r'[\{\}]', ' ', text)
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
text = re.sub(r'%%[U|O|S|R]', ' ', text)
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
text = re.sub(r'\s+', ' ', text).strip()
return text
def get_bbox(self, entity) -> Optional[BoundingBox]:
"""
엔티티 타입별로 동적인 Bounding Box를 계산하여 반환.
"""
try:
if entity.dxftype() == 'TEXT':
p = entity.dxf.insert
h = entity.dxf.height
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
width = len(entity.dxf.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + width, p.y + h)
elif entity.dxftype() == 'MTEXT':
p = entity.dxf.insert
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + w, p.y + h)
elif entity.dxftype() == 'LINE':
start = entity.dxf.start
end = entity.dxf.end
return self._create_bbox(
min(start.x, end.x), min(start.y, end.y),
max(start.x, end.x), max(start.y, end.y)
)
elif entity.dxftype() == 'LWPOLYLINE':
points = entity.get_points()
if not points: return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return self._create_bbox(min(xs), min(ys), max(xs), max(ys))
elif entity.dxftype() in ('CIRCLE', 'ARC'):
center = entity.dxf.center
radius = entity.dxf.radius
return self._create_bbox(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
)
except Exception as e:
logger.error(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}", exc_info=True)
return None
def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox:
return BoundingBox(
min_x=min_x,
min_y=min_y,
max_x=max_x,
max_y=max_y,
center=((min_x + max_x) / 2, (min_y + max_y) / 2)
)
def extract_and_save(self, output_path: str):
"""
기하학적 데이터를 추출하여 JSON 파일로 저장.
"""
results = []
logger.info(f"Starting DXF extraction from {self.doc.filename if hasattr(self.doc, 'filename') else 'unknown file'}")
for entity in self.msp:
try:
bbox_obj = self.get_bbox(entity)
if not bbox_obj:
continue
raw_text = ""
if entity.dxftype() == 'TEXT':
raw_text = entity.dxf.text
elif entity.dxftype() == 'MTEXT':
raw_text = entity.text
# 좌표 추출 (3D 좌표를 2D로 변환)
coords = []
if hasattr(entity, 'get_points'):
# ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함
coords = [(p[0], p[1]) for p in entity.get_points()]
elif entity.dxftype() == 'LINE':
coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)]
elif entity.dxftype() in ('CIRCLE', 'ARC'):
coords = [(entity.dxf.center.x, entity.dxf.center.y)]
entity_data = GeometricEntity(
entity_id=entity.dxf.handle,
entity_type=entity.dxftype(),
layer=entity.dxf.layer,
bbox=bbox_obj,
raw_value=raw_text if raw_text else None,
clean_value=self.clean_text(raw_text) if raw_text else None,
coordinates=coords,
properties={
"color": entity.dxf.color,
"lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None,
}
)
results.append(entity_data.model_dump())
except Exception as e:
logger.error(f"Unexpected error processing entity {entity.dxftype()} ({entity.dxf.handle}): {e}")
continue
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=4)
logger.info(f"Successfully saved {len(results)} entities to {output_path}")
except Exception as e:
logger.error(f"Failed to save extraction results to {output_path}: {e}")
raise
return output_path
# --- Proximity Utilities ---
def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool:
"""
두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인.
shapely box를 사용하여 거리 계산.
"""
box_a = box(bbox_a.min_x, bbox_a.min_y, bbox_a.max_x, bbox_a.max_y)
box_b = box(bbox_b.min_x, bbox_b.min_y, bbox_b.max_x, bbox_b.max_y)
return box_a.distance(box_b) <= threshold
def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool:
"""
특정 점이 Bounding Box 내부에 있는지 확인.
"""
return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y)

File diff suppressed because it is too large Load Diff

View File

@@ -1,144 +0,0 @@
import networkx as nx
import asyncio
import json
from typing import List, Optional, Dict, Any, Tuple
from pydantic import BaseModel, Field
from rapidfuzz import process, fuzz
from openai import AsyncOpenAI
# --- 응답 구조화를 위한 Pydantic 모델 ---
class MappingResult(BaseModel):
resolved_tag: str = Field(..., description="The final mapped system tag")
reason: str = Field(..., description="Reason for this mapping based on context")
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):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
if not self.graph.has_node(node_id):
return "Node not found in graph"
node_attr = self.graph.nodes[node_id]
node_type = node_attr.get('type', 'Unknown')
node_val = node_attr.get('value', 'Unknown')
# 1. 직접 연결된 이웃 노드 정보
neighbors = list(self.graph.neighbors(node_id))
neighbor_info = []
for n in neighbors:
attr = self.graph.nodes[n]
val = attr.get('value', n)
typ = attr.get('type', 'Unknown')
# 엣지 속성(관계) 추가
rel = self.graph.get_edge_data(node_id, n).get('relation', 'connected')
neighbor_info.append(f"[{rel}] {val} (Type: {typ})")
# 2. 2-hop 연결 정보 (더 넓은 맥락 파악)
extended_context = []
for n in neighbors:
second_neighbors = list(self.graph.neighbors(n))
for sn in second_neighbors:
if sn == node_id: continue
s_attr = self.graph.nodes[sn]
extended_context.append(f"Indirectly connected via {self.graph.nodes[n].get('value', n)} to {s_attr.get('value', sn)} (Type: {s_attr.get('type', 'Unknown')})")
context_str = (
f"Target Node: {node_val} (Type: {node_type})\n"
f"Direct Neighbors: {', '.join(neighbor_info) if neighbor_info else 'None'}\n"
f"Extended Context: {', '.join(extended_context[:10]) if extended_context else 'None'}"
)
return context_str
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
if not self.client:
return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0)
# Phase 2에서 'value'에 clean_value가 저장됨
node_data = self.graph.nodes.get(node_id, {})
tag_text = node_data.get('value', '')
# 1차 후보 추출 (RapidFuzz)
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await self.client.chat.completions.create(
model="Qwen3.6-27B-FP8", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
results = await asyncio.gather(*tasks)
return dict(zip(node_ids, results))
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
results = await asyncio.gather(*tasks)
return dict(zip(node_ids, results))
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
results = await asyncio.gather(*tasks)
return dict(zip(node_ids, results))
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
if resolved_tag == "UNKNOWN":
return False, "Tag not resolved"
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"

File diff suppressed because it is too large Load Diff

View File

@@ -1,490 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, os.path.basename(filepath) + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# Phase 3: 병렬 LLM 매핑
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
# Phase 4: 최종 위상 모델링 + 저장
final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags)
final_builder.build_graph()
graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
final_builder.save_graph(graph_path)
logging.info(f"build_pid_graph_parallel graph_id={graph_id} "
f"nodes={final_builder.G.number_of_nodes()} "
f"edges={final_builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": final_builder.G.number_of_nodes(),
"edges": final_builder.G.number_of_edges(),
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,506 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
logging.info(f"[{basename}] Phase 1: 기하 추출 시작")
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티 추출")
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
logging.info(f"[{basename}] 시스템 태그 {len(system_tags)}개 로드")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
t1 = time.time()
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t1:.1f}s) - 그래프: {builder.G.number_of_nodes()}노드, {builder.G.number_of_edges()}엣지")
# Phase 3: 병렬 LLM 매핑
t2 = time.time()
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
logging.info(f"[{basename}] Phase 3 시작 - transmitter:{len(transmitter_nodes)}, valve:{len(valve_nodes)}, equipment:{len(equipment_nodes)}")
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t2:.1f}s)")
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
logging.info(f"[{basename}] 매핑 완료: {len(all_mapped_tags)}개 태그 해결")
# Phase 4: 최종 위상 모델링 + 저장
t3 = time.time()
final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags)
final_builder.build_graph()
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
final_builder.save_graph(graph_path)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={final_builder.G.number_of_nodes()} "
f"edges={final_builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": final_builder.G.number_of_nodes(),
"edges": final_builder.G.number_of_edges(),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,189 +0,0 @@
import ezdxf
import re
import json
import logging
from typing import List, Optional, Tuple, Union
from pydantic import BaseModel, Field
from shapely.geometry import box, Point
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Data Models ---
class BoundingBox(BaseModel):
min_x: float
min_y: float
max_x: float
max_y: float
center: Tuple[float, float]
class GeometricEntity(BaseModel):
entity_id: str
entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC
layer: str
bbox: BoundingBox
raw_value: Optional[str] = None
clean_value: Optional[str] = None
coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list)
properties: dict = Field(default_factory=dict)
# --- Extractor Implementation ---
class PidGeometricExtractor:
def __init__(self, file_path: str):
try:
self.doc = ezdxf.readfile(file_path)
self.msp = self.doc.modelspace()
except Exception as e:
raise IOError(f"Failed to load DXF file: {e}")
def clean_text(self, text: str) -> str:
"""
DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환.
"""
if not text:
return ""
# 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등)
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
# 2. 중괄호 { } 제거
text = re.sub(r'[\{\}]', ' ', text)
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
text = re.sub(r'%%[U|O|S|R]', ' ', text)
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
text = re.sub(r'\s+', ' ', text).strip()
return text
def get_bbox(self, entity) -> Optional[BoundingBox]:
"""
엔티티 타입별로 동적인 Bounding Box를 계산하여 반환.
"""
try:
if entity.dxftype() == 'TEXT':
p = entity.dxf.insert
h = entity.dxf.height
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
width = len(entity.dxf.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + width, p.y + h)
elif entity.dxftype() == 'MTEXT':
p = entity.dxf.insert
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + w, p.y + h)
elif entity.dxftype() == 'LINE':
start = entity.dxf.start
end = entity.dxf.end
return self._create_bbox(
min(start.x, end.x), min(start.y, end.y),
max(start.x, end.x), max(start.y, end.y)
)
elif entity.dxftype() == 'LWPOLYLINE':
points = entity.get_points()
if not points: return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return self._create_bbox(min(xs), min(ys), max(xs), max(ys))
elif entity.dxftype() in ('CIRCLE', 'ARC'):
center = entity.dxf.center
radius = entity.dxf.radius
return self._create_bbox(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
)
except Exception as e:
logger.error(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}", exc_info=True)
return None
def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox:
return BoundingBox(
min_x=min_x,
min_y=min_y,
max_x=max_x,
max_y=max_y,
center=((min_x + max_x) / 2, (min_y + max_y) / 2)
)
def extract_and_save(self, output_path: str):
"""
기하학적 데이터를 추출하여 JSON 파일로 저장.
"""
results = []
logger.info(f"Starting DXF extraction from {self.doc.filename if hasattr(self.doc, 'filename') else 'unknown file'}")
for entity in self.msp:
try:
bbox_obj = self.get_bbox(entity)
if not bbox_obj:
continue
raw_text = ""
if entity.dxftype() == 'TEXT':
raw_text = entity.dxf.text
elif entity.dxftype() == 'MTEXT':
raw_text = entity.text
# 좌표 추출 (3D 좌표를 2D로 변환)
coords = []
if hasattr(entity, 'get_points'):
# ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함
coords = [(p[0], p[1]) for p in entity.get_points()]
elif entity.dxftype() == 'LINE':
coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)]
elif entity.dxftype() in ('CIRCLE', 'ARC'):
coords = [(entity.dxf.center.x, entity.dxf.center.y)]
entity_data = GeometricEntity(
entity_id=entity.dxf.handle,
entity_type=entity.dxftype(),
layer=entity.dxf.layer,
bbox=bbox_obj,
raw_value=raw_text if raw_text else None,
clean_value=self.clean_text(raw_text) if raw_text else None,
coordinates=coords,
properties={
"color": entity.dxf.color,
"lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None,
}
)
results.append(entity_data.model_dump())
except Exception as e:
logger.error(f"Unexpected error processing entity {entity.dxftype()} ({entity.dxf.handle}): {e}")
continue
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=4)
logger.info(f"Successfully saved {len(results)} entities to {output_path}")
except Exception as e:
logger.error(f"Failed to save extraction results to {output_path}: {e}")
raise
return output_path
# --- Proximity Utilities ---
def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool:
"""
두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인.
shapely box를 사용하여 거리 계산.
"""
box_a = box(bbox_a.min_x, bbox_a.min_y, bbox_a.max_x, bbox_a.max_y)
box_b = box(bbox_b.min_x, bbox_b.min_y, bbox_b.max_x, bbox_b.max_y)
return box_a.distance(box_b) <= threshold
def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool:
"""
특정 점이 Bounding Box 내부에 있는지 확인.
"""
return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y)

View File

@@ -1,200 +0,0 @@
import networkx as nx
from shapely.geometry import box, Point, LineString
import json
from typing import List, Dict, Any, Optional, Tuple
class PidTopologyBuilder:
def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None):
"""
- geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts)
- all_extracted_tags: 통합된 태그 리스트
- config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값
"""
self.data = geometric_data
self.all_tags = all_extracted_tags if all_extracted_tags else []
# 기본 설정값
default_config = {'dist_threshold': 50.0, 'tag_threshold': 100.0}
self.config = config if config else default_config
# 동적 스케일링 적용
self._apply_dynamic_scaling()
self.G = nx.DiGraph() # 방향성 그래프 생성
def _apply_dynamic_scaling(self):
"""도면의 전체 크기를 기반으로 임계값을 동적으로 조정"""
if not self.data:
return
# 모든 BBox를 포함하는 전체 영역 계산
all_min_x = min((item['bbox']['min_x'] for item in self.data if 'bbox' in item), default=0)
all_max_x = max((item['bbox']['max_x'] for item in self.data if 'bbox' in item), default=1000)
all_min_y = min((item['bbox']['min_y'] for item in self.data if 'bbox' in item), default=0)
all_max_y = max((item['bbox']['max_y'] for item in self.data if 'bbox' in item), default=1000)
drawing_width = all_max_x - all_min_x
drawing_height = all_max_y - all_min_y
diag = (drawing_width**2 + drawing_height**2)**0.5
if diag == 0: return
# 기준 대각선 길이 (예: 5000 units) 대비 현재 도면 크기 비율 계산
# 기준 도면 크기가 5000일 때 dist_threshold=50 (1%)
scale_factor = diag / 5000.0
# 임계값 업데이트 (최소/최대 범위 제한)
self.config['dist_threshold'] = max(5.0, min(200.0, 50.0 * scale_factor))
self.config['tag_threshold'] = max(20.0, min(500.0, 100.0 * scale_factor))
def build_graph(self):
# 1. 모든 객체를 노드로 추가
for item in self.data:
bbox_vals = item['bbox']
# BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y)
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
self.G.add_node(item['entity_id'],
type=item['entity_type'],
bbox=bbox_geom,
value=item.get('clean_value'),
layer=item.get('layer'))
# 2. 분산 추출된 태그 통합 및 노드 추가
for tag in self.all_tags:
bbox_vals = tag['bbox']
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
self.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 3. 태그-설비 논리적 연결 (Association)
tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT']
equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
for tag in tags:
best_match = self._find_nearest_equipment(tag, equipments)
if best_match:
self.G.add_edge(tag, best_match, relation='associated_with')
# 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반]
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
for line_id in lines:
original_item = next((item for item in self.data if item['entity_id'] == line_id), None)
if not original_item or not original_item.get('coordinates'):
continue
coords = original_item['coordinates']
line_geom = LineString(coords)
# 개선: 끝점뿐만 아니라 라인 전체가 설비 BBox와 교차하거나 매우 가까운지 확인
connected_nodes = []
for eq_id in equipments:
eq_bbox = self.G.nodes[eq_id]['bbox']
# 1. 라인이 BBox와 교차하는지 확인 (관통 포함)
if line_geom.intersects(eq_bbox):
connected_nodes.append(eq_id)
# 2. 교차하지 않더라도 임계값 이내에 있는지 확인 (근접 연결)
elif line_geom.distance(eq_bbox) < self.config['dist_threshold']:
connected_nodes.append(eq_id)
# 중복 제거
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')
else:
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', 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')
else:
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward')
elif len(connected_nodes) == 1:
# 단일 연결의 경우, 일단 엣지를 생성하되 방향은 미정(undirected-like)으로 처리하거나
# 추후 전파 로직에서 결정하도록 함
pass
def _find_nearest_equipment(self, tag_id, equipment_ids):
"""
단순 거리 기반 매핑에서 위상 기반 가중치 매핑으로 개선.
가중치 = 거리 점수 + 연결성 점수
"""
tag_bbox = self.G.nodes[tag_id]['bbox']
best_score = float('inf')
nearest = None
# 태그 노드와 연결된 배관(LINE/LWPOLYLINE) 확인
connected_pipes = [n for n in self.G.neighbors(tag_id) if self.G.nodes[n]['type'] in ['LINE', 'LWPOLYLINE']]
for eq_id in equipment_ids:
eq_bbox = self.G.nodes[eq_id]['bbox']
dist = tag_bbox.distance(eq_bbox)
if dist > self.config['tag_threshold']:
continue
# 1. 거리 점수 (낮을수록 좋음)
score = dist
# 2. 연결성 가중치 (태그와 설비가 동일한 배관에 연결되어 있다면 점수 대폭 감점 = 우선순위 상승)
# 태그가 직접 배관에 연결되어 있지는 않지만, 태그 근처의 배관이 설비에 연결되어 있는지 확인
for pipe_id in connected_pipes:
if self.G.has_edge(pipe_id, eq_id) or self.G.has_edge(eq_id, pipe_id):
score -= self.config['tag_threshold'] * 0.5 # 연결성 보너스
if score < best_score:
best_score = score
nearest = eq_id
return nearest
def validate_topology(self):
"""위상 무결성 검증"""
isolated = list(nx.isolates(self.G))
return {
"isolated_nodes": isolated,
"node_count": self.G.number_of_nodes(),
"edge_count": self.G.number_of_edges()
}
def save_graph(self, output_path: str):
"""그래프 구조를 JSON 형태로 저장"""
from networkx.readwrite import json_graph
data = json_graph.node_link_data(self.G)
# shapely geometry 객체는 JSON 직렬화가 안 되므로 변환
for node in data['nodes']:
if 'bbox' in node:
bbox = node['bbox']
node['bbox'] = {
'min_x': bbox.bounds[0],
'min_y': bbox.bounds[1],
'max_x': bbox.bounds[2],
'max_y': bbox.bounds[3]
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
return output_path
def analyze_impact(graph, start_node):
"""특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출"""
if start_node not in graph:
return []
# BFS를 통해 도달 가능한 모든 노드 탐색
impacted_nodes = nx.descendants(graph, start_node)
return list(impacted_nodes)

View File

@@ -1,350 +0,0 @@
# P&ID 재설계 - 코딩 계획
> 작성일: 2026-05-04
> 상태: 진행 중
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
---
## 현황 요약
| 단계 | 내용 | 상태 |
|------|------|------|
| Step 1 | 현황 분석 (DXF 구조 파악) | ✅ 완료 |
| Step 2 | 성공 테스트 분석 (분할+병렬 성공 요인) | ✅ 완료 |
| Step 3 | 레전드 페이지 분석 (legend_parser.py 생성) | ✅ 완료 |
| Step 4 | 해결 방향 정의 | ✅ 완료 |
| Step 5~10 | 구현 (아래 상세 계획) | 🔲 미시작 |
---
## 핵심 전략
1. **도면 분할** — 한 DXF 파일 내 여러 도면을 좌표 기반으로 분리 (9개 도면 → 각각 독립 처리)
2. **계측기 유형별 병렬 LLM 호출** — Sensor/Valve/System/Gauge/Pump 동시 처리
3. **위치 정보 저장** — 태그의 DXF 상 좌표 + 근처 텍스트
4. **AREA 그룹핑** — 태그명에서 플랜트 번호 유추
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 |
| `mcp-server/worker/pid_worker.py` | 도면 분할 + 병렬 LLM 호출 |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## 상세 코딩 계획
### Phase 1: 도면 분할 로직 (extractor.py)
#### 1-1. 도면 분할 테스트 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: `No-10_Plant_PID.dxf`를 TITLE 레이어 LINE으로 분할하는 로직 프로토타입
- **작업 내용**:
- ezdxf로 DXF 로드
- TITLE 레이어의 LINE 엔티티 탐색
- 수직 LINE(X 좌표가 일정)을 도면 경계로 감지
- 각 도면별 X/Y 범위 출력
- **검증**: 9개 도면 영역이 올바르게 분리되는지 확인
- **완료 기준**: 콘솔에 9개 도면의 X/Y 범위가 출력됨
#### 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `split_drawings()` 메서드 추가
- **작업 내용**:
- `split_drawings() -> List[DrawingRegion]` 메서드 추가
- DrawingRegion 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
- TITLE 레이어 LINE 기반 경계 감지
- 레전드 페이지(X < 2000) 제외
- FFD 페이지도 제외 (최상단 텍스트 기반)
- **완료 기준**: `split_drawings()` 호출 시 9개 DrawingRegion 반환
#### 1-3. 영역별 추출 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `extract_region()` 메서드 추가
- **작업 내용**:
- `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 메서드 추가
- bbox가 region 범위 내에 있는 엔티티만 필터링
- 기존 `extract_and_save()` 로직 재사용
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체 엔티티 수와 일치
#### 1-4. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: 전체 파이프라인 테스트
- **작업 내용**:
- DXF 로드 → 분할 → 영역별 추출 → 결과 검증
- 각 도면별 엔티티 수, 태그 수 확인
- 처리 시간 측정
- **완료 기준**: 9개 도면 모두 정상 추출, 총 처리 시간 < 30초
---
### Phase 2: 계측기 유형별 병렬 LLM 호출 (pid_worker.py)
#### 2-1. 계측기 유형별 프롬프트 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 각 유형별 전용 프롬프트 상수 정의
- **작업 내용**:
- `_SENSOR_EXTRACT_SYSTEM` — FT, FIT, LT, PT, TE, PG, LG, TG 추출
- `_VALVE_EXTRACT_SYSTEM` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV 추출
- `_SYSTEM_EXTRACT_SYSTEM` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA 추출
- `_GAUGE_EXTRACT_SYSTEM` — PG, TG, LG 추출
- `_PUMP_EXTRACT_SYSTEM` — P-10101, VP-10117, DP-10101 등 펌프 추출
- 각 프롬프트는 `max_tokens=65536` 적용
- **완료 기준**: 5개 프롬프트 상수 정의 완료
#### 2-2. 유형별 추출 함수 작성
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_tags_by_type()` 함수 추가
- **작업 내용**:
- `_extract_tags_by_type(text: str, type_name: str, system_prompt: str) -> List[dict]` 함수
- LLM 호출 → JSON 파싱 → 태그 목록 반환
- `max_tokens=65536` 적용
- finish_reason=length 복구 로직 포함
- **완료 기준**: 단일 유형 추출 테스트 통과
#### 2-3. 병렬 호출 로직 구현
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_all_types_parallel()` async 함수 추가
- **작업 내용**:
- `asyncio.gather()`로 5개 유형 동시 호출
- 각 유형별 결과 통합
- 중복 태그 제거 (tagNo 기준)
- **완료 기준**: 5개 유형 병렬 호출 테스트, 총 처리 시간 < 120초
#### 2-4. `_build_pid_graph_parallel()` 리팩토링
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 함수를 도면 분할 + 병렬 LLM 호출로 변경
- **작업 내용**:
- Phase 1: 도면 분할 (extractor.split_drawings())
- Phase 2: 각 도면별 기하 추출 (extractor.extract_region())
- Phase 3: 각 도면별 유형별 병렬 LLM 호출
- Phase 4: 결과 통합 + 그래프 빌드
- Phase 5: 저장
- **완료 기준**: 기존 API 호환 유지, 처리 시간 30분 → 5분 이내
#### 2-5. 병렬 처리 통합 테스트
- **파일**: `test_parallel_extraction.py` (신규)
- **목표**: 전체 병렬 처리 파이프라인 테스트
- **작업 내용**:
- `No-10_Plant_PID.dxf` 전체 처리
- 각 단계별 시간 측정
- 추출된 태그 수, 매핑 수 확인
- **완료 기준**: 전체 처리 < 5분, 태그 추출 수 > 기존
---
### Phase 3: 위치 정보 저장
#### 3-1. 위치 정보 스키마 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 태그 추출 결과에 위치 정보 포함
- **작업 내용**:
- 각 태그에 `bbox` (min_x, min_y, max_x, max_y) 필드 추가
- `drawing_no` (도면 번호) 필드 추가
- `nearby_text` (근처 텍스트) 필드 추가
- **완료 기준**: 추출 결과 JSON에 위치 정보 포함
#### 3-2. 근처 텍스트 추출 로직
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `get_nearby_text()` 메서드 추가
- **작업 내용**:
- 특정 좌표 주변 threshold 이내 TEXT 엔티티 검색
- SpatialGrid 활용 (O(1) 조회)
- 상하좌우 위치 파악 (direction 필드)
- **완료 기준**: 태그 주변 텍스트 정상 추출
#### 3-3. 위치 정보 통합 테스트
- **파일**: `test_location_info.py` (신규)
- **목표**: 위치 정보 정확도 검증
- **작업 내용**:
- 알려진 태그 위치와 추출된 위치 비교
- 근처 텍스트 정확도 확인
- **완료 기준**: 위치 정보 정확도 > 90%
---
### Phase 4: AREA 그룹핑
#### 4-1. AREA 추출 로직 개선
- **파일**: `mcp-server/pipeline/legend_parser.py`
- **목표**: `extract_area_from_tag()` 정확도 향상
- **작업 내용**:
- FICQ-6113 → "6" (6호 플랜트)
- FICQ-10113 → "10" (10호 플랜트)
- 패턴 매칭 개선 (정규식 튜닝)
- **완료 기준**: 테스트 케이스 10개 모두 정확
#### 4-2. AREA별 그룹핑 함수
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_group_by_area()` 함수 추가
- **작업 내용**:
- 추출된 태그 목록을 AREA별로 그룹핑
- AREA 번호가 없는 태그는 "unknown" 그룹
- 그룹별 통계 출력
- **완료 기준**: AREA별 그룹핑 결과 정상 출력
---
### Phase 5: Experion 태그 매핑
#### 5-1. 기존 매핑 로직 확인
- **파일**: `mcp-server/pipeline/mapper.py`
- **목표**: 기존 IntelligentMapper 동작 확인
- **작업 내용**:
- `_batch_gather()` 배치 처리 확인 (이미 구현됨)
- RapidFuzz 기반 후보 추출 확인
- LLM 기반 최종 매핑 확인
- **완료 기준**: 기존 매핑 정확도 확인
#### 5-2. 매핑 결과 통합
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_build_pid_graph_parallel()`에 매핑 결과 통합
- **작업 내용**:
- Phase 3 결과 (추출된 태그)를 IntelligentMapper에 전달
- 매핑 결과를 그래프에 추가
- 매핑 통계 출력
- **완료 기준**: 매핑 결과 그래프에 정상 반영
---
### Phase 6: UI 연동
#### 6-1. API 엔드포인트 확인
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 기존 API 엔드포인트 확인
- **작업 내용**:
- 태그 검색 API 확인
- 위치 정보 반환 여부 확인
- **완료 기준**: API 스펙 파악
#### 6-2. 위치 정보 API 추가
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 태그 위치 조회 API 추가
- **작업 내용**:
- `GET /api/pid/tags/{tagName}/location` 엔드포인트 추가
- DXF 좌표, 도면 번호, 근처 텍스트 반환
- 현재 실시간 값 연동
- **완료 기준**: API 테스트 통과
#### 6-3. 프론트엔드 UI 수정
- **파일**: `src/Web/wwwroot/js/app.js`
- **목표**: 태그 검색 시 위치 표시
- **작업 내용**:
- 태그 검색 결과에 위치 정보 표시
- "6호 정제탑, 밑에서 3번째 온도 센서입니다. 현재 온도 105도" 형식
- DXF 도면 상 위치 시각화 (선택사항)
- **완료 기준**: UI에서 위치 정보 확인 가능
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 (순차)
Phase 2 (병렬 LLM)
├── 2-1 → 2-2 → 2-3 → 2-4 → 2-5 (순차, Phase 1 완료 후)
Phase 3 (위치 정보)
├── 3-1 → 3-2 → 3-3 (순차, Phase 2 완료 후)
Phase 4 (AREA 그룹핑)
├── 4-1 → 4-2 (순차, 독립 실행 가능)
Phase 5 (매핑 통합)
├── 5-1 → 5-2 (순차, Phase 2+3 완료 후)
Phase 6 (UI 연동)
├── 6-1 → 6-2 → 6-3 (순차, Phase 5 완료 후)
```
---
## 각 단계 완료 기준
| 단계 | 완료 기준 | 예상 시간 |
|------|-----------|-----------|
| 1-1 | 9개 도면 영역 콘솔 출력 | 30분 |
| 1-2 | `split_drawings()` 9개 Region 반환 | 1시간 |
| 1-3 | `extract_region()` 정상 동작 | 30분 |
| 1-4 | 전체 분할 테스트 통과 | 30분 |
| 2-1 | 5개 프롬프트 상수 정의 | 30분 |
| 2-2 | 단일 유형 추출 테스트 통과 | 1시간 |
| 2-3 | 5개 유형 병렬 호출 테스트 | 1시간 |
| 2-4 | `_build_pid_graph_parallel()` 리팩토링 완료 | 2시간 |
| 2-5 | 전체 병렬 처리 테스트 통과 | 1시간 |
| 3-1 | 위치 정보 JSON 필드 추가 | 30분 |
| 3-2 | `get_nearby_text()` 구현 | 1시간 |
| 3-3 | 위치 정보 정확도 검증 | 30분 |
| 4-1 | AREA 추출 테스트 10개 통과 | 30분 |
| 4-2 | AREA별 그룹핑 결과 출력 | 30분 |
| 5-1 | 기존 매핑 정확도 확인 | 30분 |
| 5-2 | 매핑 결과 그래프 반영 | 1시간 |
| 6-1 | API 스펙 파악 | 30분 |
| 6-2 | 위치 조회 API 추가 | 1시간 |
| 6-3 | UI 수정 완료 | 1시간 |
**총 예상 시간: 약 18시간**
---
## 체크리스트
### Phase 1: 도면 분할
- [ ] 1-1. 도면 분할 테스트 스크립트 작성
- [ ] 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- [ ] 1-3. 영역별 추출 메서드 추가
- [ ] 1-4. 도면 분할 통합 테스트
### Phase 2: 계측기 유형별 병렬 LLM 호출
- [ ] 2-1. 계측기 유형별 프롬프트 정의
- [ ] 2-2. 유형별 추출 함수 작성
- [ ] 2-3. 병렬 호출 로직 구현
- [ ] 2-4. `_build_pid_graph_parallel()` 리팩토링
- [ ] 2-5. 병렬 처리 통합 테스트
### Phase 3: 위치 정보 저장
- [ ] 3-1. 위치 정보 스키마 정의
- [ ] 3-2. 근처 텍스트 추출 로직
- [ ] 3-3. 위치 정보 통합 테스트
### Phase 4: AREA 그룹핑
- [ ] 4-1. AREA 추출 로직 개선
- [ ] 4-2. AREA별 그룹핑 함수
### Phase 5: Experion 태그 매핑
- [ ] 5-1. 기존 매핑 로직 확인
- [ ] 5-2. 매핑 결과 통합
### Phase 6: UI 연동
- [ ] 6-1. API 엔드포인트 확인
- [ ] 6-2. 위치 정보 API 추가
- [ ] 6-3. 프론트엔드 UI 수정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지 (이미 잘 구현됨)
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

View File

@@ -1,350 +0,0 @@
# P&ID 재설계 - 코딩 계획
> 작성일: 2026-05-04
> 상태: 진행 중
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
---
## 현황 요약
| 단계 | 내용 | 상태 |
|------|------|------|
| Step 1 | 현황 분석 (DXF 구조 파악) | ✅ 완료 |
| Step 2 | 성공 테스트 분석 (분할+병렬 성공 요인) | ✅ 완료 |
| Step 3 | 레전드 페이지 분석 (legend_parser.py 생성) | ✅ 완료 |
| Step 4 | 해결 방향 정의 | ✅ 완료 |
| Step 5~10 | 구현 (아래 상세 계획) | 🔲 미시작 |
---
## 핵심 전략
1. **도면 분할** — 한 DXF 파일 내 여러 도면을 좌표 기반으로 분리 (9개 도면 → 각각 독립 처리)
2. **계측기 유형별 병렬 LLM 호출** — Sensor/Valve/System/Gauge/Pump 동시 처리
3. **위치 정보 저장** — 태그의 DXF 상 좌표 + 근처 텍스트
4. **AREA 그룹핑** — 태그명에서 플랜트 번호 유추
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 |
| `mcp-server/worker/pid_worker.py` | 도면 분할 + 병렬 LLM 호출 |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## 상세 코딩 계획
### Phase 1: 도면 분할 로직 (extractor.py)
#### 1-1. 도면 분할 테스트 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: `No-10_Plant_PID.dxf`를 TITLE 레이어 LINE으로 분할하는 로직 프로토타입
- **작업 내용**:
- ezdxf로 DXF 로드
- TITLE 레이어의 LINE 엔티티 탐색
- 수직 LINE(X 좌표가 일정)을 도면 경계로 감지
- 각 도면별 X/Y 범위 출력
- **검증**: 9개 도면 영역이 올바르게 분리되는지 확인
- **완료 기준**: 콘솔에 9개 도면의 X/Y 범위가 출력됨
#### 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `split_drawings()` 메서드 추가
- **작업 내용**:
- `split_drawings() -> List[DrawingRegion]` 메서드 추가
- DrawingRegion 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
- TITLE 레이어 LINE 기반 경계 감지
- 레전드 페이지(X < 2000) 제외
- FFD 페이지도 제외 (최상단 텍스트 기반)
- **완료 기준**: `split_drawings()` 호출 시 9개 DrawingRegion 반환
#### 1-3. 영역별 추출 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `extract_region()` 메서드 추가
- **작업 내용**:
- `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 메서드 추가
- bbox가 region 범위 내에 있는 엔티티만 필터링
- 기존 `extract_and_save()` 로직 재사용
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체 엔티티 수와 일치
#### 1-4. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: 전체 파이프라인 테스트
- **작업 내용**:
- DXF 로드 → 분할 → 영역별 추출 → 결과 검증
- 각 도면별 엔티티 수, 태그 수 확인
- 처리 시간 측정
- **완료 기준**: 9개 도면 모두 정상 추출, 총 처리 시간 < 30초
---
### Phase 2: 계측기 유형별 병렬 LLM 호출 (pid_worker.py)
#### 2-1. 계측기 유형별 프롬프트 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 각 유형별 전용 프롬프트 상수 정의
- **작업 내용**:
- `_SENSOR_EXTRACT_SYSTEM` — FT, FIT, LT, PT, TE, PG, LG, TG 추출
- `_VALVE_EXTRACT_SYSTEM` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV 추출
- `_SYSTEM_EXTRACT_SYSTEM` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA 추출
- `_GAUGE_EXTRACT_SYSTEM` — PG, TG, LG 추출
- `_PUMP_EXTRACT_SYSTEM` — P-10101, VP-10117, DP-10101 등 펌프 추출
- 각 프롬프트는 `max_tokens=65536` 적용
- **완료 기준**: 5개 프롬프트 상수 정의 완료
#### 2-2. 유형별 추출 함수 작성
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_tags_by_type()` 함수 추가
- **작업 내용**:
- `_extract_tags_by_type(text: str, type_name: str, system_prompt: str) -> List[dict]` 함수
- LLM 호출 → JSON 파싱 → 태그 목록 반환
- `max_tokens=65536` 적용
- finish_reason=length 복구 로직 포함
- **완료 기준**: 단일 유형 추출 테스트 통과
#### 2-3. 병렬 호출 로직 구현
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_all_types_parallel()` async 함수 추가
- **작업 내용**:
- `asyncio.gather()`로 5개 유형 동시 호출
- 각 유형별 결과 통합
- 중복 태그 제거 (tagNo 기준)
- **완료 기준**: 5개 유형 병렬 호출 테스트, 총 처리 시간 < 120초
#### 2-4. `_build_pid_graph_parallel()` 리팩토링
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 함수를 도면 분할 + 병렬 LLM 호출로 변경
- **작업 내용**:
- Phase 1: 도면 분할 (extractor.split_drawings())
- Phase 2: 각 도면별 기하 추출 (extractor.extract_region())
- Phase 3: 각 도면별 유형별 병렬 LLM 호출
- Phase 4: 결과 통합 + 그래프 빌드
- Phase 5: 저장
- **완료 기준**: 기존 API 호환 유지, 처리 시간 30분 → 5분 이내
#### 2-5. 병렬 처리 통합 테스트
- **파일**: `test_parallel_extraction.py` (신규)
- **목표**: 전체 병렬 처리 파이프라인 테스트
- **작업 내용**:
- `No-10_Plant_PID.dxf` 전체 처리
- 각 단계별 시간 측정
- 추출된 태그 수, 매핑 수 확인
- **완료 기준**: 전체 처리 < 5분, 태그 추출 수 > 기존
---
### Phase 3: 위치 정보 저장
#### 3-1. 위치 정보 스키마 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 태그 추출 결과에 위치 정보 포함
- **작업 내용**:
- 각 태그에 `bbox` (min_x, min_y, max_x, max_y) 필드 추가
- `drawing_no` (도면 번호) 필드 추가
- `nearby_text` (근처 텍스트) 필드 추가
- **완료 기준**: 추출 결과 JSON에 위치 정보 포함
#### 3-2. 근처 텍스트 추출 로직
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `get_nearby_text()` 메서드 추가
- **작업 내용**:
- 특정 좌표 주변 threshold 이내 TEXT 엔티티 검색
- SpatialGrid 활용 (O(1) 조회)
- 상하좌우 위치 파악 (direction 필드)
- **완료 기준**: 태그 주변 텍스트 정상 추출
#### 3-3. 위치 정보 통합 테스트
- **파일**: `test_location_info.py` (신규)
- **목표**: 위치 정보 정확도 검증
- **작업 내용**:
- 알려진 태그 위치와 추출된 위치 비교
- 근처 텍스트 정확도 확인
- **완료 기준**: 위치 정보 정확도 > 90%
---
### Phase 4: AREA 그룹핑
#### 4-1. AREA 추출 로직 개선
- **파일**: `mcp-server/pipeline/legend_parser.py`
- **목표**: `extract_area_from_tag()` 정확도 향상
- **작업 내용**:
- FICQ-6113 → "6" (6호 플랜트)
- FICQ-10113 → "10" (10호 플랜트)
- 패턴 매칭 개선 (정규식 튜닝)
- **완료 기준**: 테스트 케이스 10개 모두 정확
#### 4-2. AREA별 그룹핑 함수
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_group_by_area()` 함수 추가
- **작업 내용**:
- 추출된 태그 목록을 AREA별로 그룹핑
- AREA 번호가 없는 태그는 "unknown" 그룹
- 그룹별 통계 출력
- **완료 기준**: AREA별 그룹핑 결과 정상 출력
---
### Phase 5: Experion 태그 매핑
#### 5-1. 기존 매핑 로직 확인
- **파일**: `mcp-server/pipeline/mapper.py`
- **목표**: 기존 IntelligentMapper 동작 확인
- **작업 내용**:
- `_batch_gather()` 배치 처리 확인 (이미 구현됨)
- RapidFuzz 기반 후보 추출 확인
- LLM 기반 최종 매핑 확인
- **완료 기준**: 기존 매핑 정확도 확인
#### 5-2. 매핑 결과 통합
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_build_pid_graph_parallel()`에 매핑 결과 통합
- **작업 내용**:
- Phase 3 결과 (추출된 태그)를 IntelligentMapper에 전달
- 매핑 결과를 그래프에 추가
- 매핑 통계 출력
- **완료 기준**: 매핑 결과 그래프에 정상 반영
---
### Phase 6: UI 연동
#### 6-1. API 엔드포인트 확인
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 기존 API 엔드포인트 확인
- **작업 내용**:
- 태그 검색 API 확인
- 위치 정보 반환 여부 확인
- **완료 기준**: API 스펙 파악
#### 6-2. 위치 정보 API 추가
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 태그 위치 조회 API 추가
- **작업 내용**:
- `GET /api/pid/tags/{tagName}/location` 엔드포인트 추가
- DXF 좌표, 도면 번호, 근처 텍스트 반환
- 현재 실시간 값 연동
- **완료 기준**: API 테스트 통과
#### 6-3. 프론트엔드 UI 수정
- **파일**: `src/Web/wwwroot/js/app.js`
- **목표**: 태그 검색 시 위치 표시
- **작업 내용**:
- 태그 검색 결과에 위치 정보 표시
- "6호 정제탑, 밑에서 3번째 온도 센서입니다. 현재 온도 105도" 형식
- DXF 도면 상 위치 시각화 (선택사항)
- **완료 기준**: UI에서 위치 정보 확인 가능
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 (순차)
Phase 2 (병렬 LLM)
├── 2-1 → 2-2 → 2-3 → 2-4 → 2-5 (순차, Phase 1 완료 후)
Phase 3 (위치 정보)
├── 3-1 → 3-2 → 3-3 (순차, Phase 2 완료 후)
Phase 4 (AREA 그룹핑)
├── 4-1 → 4-2 (순차, 독립 실행 가능)
Phase 5 (매핑 통합)
├── 5-1 → 5-2 (순차, Phase 2+3 완료 후)
Phase 6 (UI 연동)
├── 6-1 → 6-2 → 6-3 (순차, Phase 5 완료 후)
```
---
## 각 단계 완료 기준
| 단계 | 완료 기준 | 예상 시간 |
|------|-----------|-----------|
| 1-1 | 9개 도면 영역 콘솔 출력 | 30분 |
| 1-2 | `split_drawings()` 9개 Region 반환 | 1시간 |
| 1-3 | `extract_region()` 정상 동작 | 30분 |
| 1-4 | 전체 분할 테스트 통과 | 30분 |
| 2-1 | 5개 프롬프트 상수 정의 | 30분 |
| 2-2 | 단일 유형 추출 테스트 통과 | 1시간 |
| 2-3 | 5개 유형 병렬 호출 테스트 | 1시간 |
| 2-4 | `_build_pid_graph_parallel()` 리팩토링 완료 | 2시간 |
| 2-5 | 전체 병렬 처리 테스트 통과 | 1시간 |
| 3-1 | 위치 정보 JSON 필드 추가 | 30분 |
| 3-2 | `get_nearby_text()` 구현 | 1시간 |
| 3-3 | 위치 정보 정확도 검증 | 30분 |
| 4-1 | AREA 추출 테스트 10개 통과 | 30분 |
| 4-2 | AREA별 그룹핑 결과 출력 | 30분 |
| 5-1 | 기존 매핑 정확도 확인 | 30분 |
| 5-2 | 매핑 결과 그래프 반영 | 1시간 |
| 6-1 | API 스펙 파악 | 30분 |
| 6-2 | 위치 조회 API 추가 | 1시간 |
| 6-3 | UI 수정 완료 | 1시간 |
**총 예상 시간: 약 18시간**
---
## 체크리스트
### Phase 1: 도면 분할
- [ ] 1-1. 도면 분할 테스트 스크립트 작성
- [ ] 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- [ ] 1-3. 영역별 추출 메서드 추가
- [ ] 1-4. 도면 분할 통합 테스트
### Phase 2: 계측기 유형별 병렬 LLM 호출
- [ ] 2-1. 계측기 유형별 프롬프트 정의
- [ ] 2-2. 유형별 추출 함수 작성
- [ ] 2-3. 병렬 호출 로직 구현
- [ ] 2-4. `_build_pid_graph_parallel()` 리팩토링
- [ ] 2-5. 병렬 처리 통합 테스트
### Phase 3: 위치 정보 저장
- [ ] 3-1. 위치 정보 스키마 정의
- [ ] 3-2. 근처 텍스트 추출 로직
- [ ] 3-3. 위치 정보 통합 테스트
### Phase 4: AREA 그룹핑
- [ ] 4-1. AREA 추출 로직 개선
- [ ] 4-2. AREA별 그룹핑 함수
### Phase 5: Experion 태그 매핑
- [ ] 5-1. 기존 매핑 로직 확인
- [ ] 5-2. 매핑 결과 통합
### Phase 6: UI 연동
- [ ] 6-1. API 엔드포인트 확인
- [ ] 6-2. 위치 정보 API 추가
- [ ] 6-3. 프론트엔드 UI 수정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지 (이미 잘 구현됨)
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

View File

@@ -1,367 +0,0 @@
# P&ID 재설계 - 코딩 계획 (독립 프로세스 병렬 아키텍처)
> 작성일: 2026-05-05
> 상태: 진행 중
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
> 아키텍처: pid_worker.py(코디네이터) + 5개 독립 추출 프로세스
---
## 제안 아키텍처
```
┌─────────────────────────────────────────────────────────────────┐
│ pid_worker.py (코디네이터) │
│ │
│ Phase 1: 도면 분할 + 기하 추출 (순차) │
│ ├─ extractor.split_drawings() → 9개 도면 영역 │
│ └─ extractor.extract_and_save() → geo.json │
│ │
│ Phase 2: 전체 텍스트 1회 추출 │
│ └─ DXF에서 TEXT/MTEXT → full_text.txt (1회만 읽기) │
│ │
│ Phase 3: 5개 독립 프로세스 병렬 실행 ← 진짜 병렬 │
│ ├─ subprocess: pid_extract_sensor.py → results/sensor.json │
│ ├─ subprocess: pid_extract_valve.py → results/valve.json │
│ ├─ subprocess: pid_extract_system.py → results/system.json │
│ ├─ subprocess: pid_extract_gauge.py → results/gauge.json │
│ └─ subprocess: pid_extract_pump.py → results/pump.json │
│ │
│ Phase 4: pid_worker가 결과 파일 읽어서 통합 │
│ ├─ 5개 JSON 파일 로드 │
│ ├─ 중복 제거 (tagNo 기준) │
│ └─ 위상 그래프 빌드 + 태그 매핑 │
│ │
│ Phase 5: 저장 + 응답 │
└─────────────────────────────────────────────────────────────────┘
```
## 기존 접근 vs 개선안 비교
| 항목 | 기존 (asyncio.gather) | 개선안 (독립 프로세스) |
|------|----------------------|----------------------|
| vLLM 요청 | 단일 프로세스 → 단일 GPU | 5개 프로세스 → 5개 GPU/큐 병렬 |
| 실제 병렬 | ❌ 가짜 (순차 처리) | ✅ 진짜 동시 처리 |
| 메모리 | pid_worker가 모든 텍스트 보유 | 각 프로세스 독립 |
| 실패 격리 | 하나 실패 → 전체 실패 | 하나 실패 → 나머지 결과 활용 |
| 테스트 | 통합 테스트만 가능 | 각 추출기 독립 테스트 가능 |
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 |
| `mcp-server/worker/pid_worker.py` | 코디네이터 로직: 프로세스 관리, 결과 통합 |
| `mcp-server/worker/pid_extract_sensor.py` | 신규: 센서 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_valve.py` | 신규: 밸브 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_system.py` | 신규: 시스템 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_gauge.py` | 신규: 게이지 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_pump.py` | 신규: 펌프 전용 추출기 (독립 프로세스) |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## Phase 2: 독립 추출기 공통 템플릿
### 2-1. 공통 추출기 템플릿 작성
- **파일**: `mcp-server/worker/pid_extract_template.py` (신규)
- **목표**: 5개 추출기가 공유하는 공통 로직 템플릿
- **작업 내용**:
- CLI 인자 파싱 (input_text, output_path, system_prompt)
- vLLM HTTP 클라이언트 연결 (환경 변수 VLLM_ENDPOINT)
- LLM 호출 → JSON 파싱 → 태그 목록 반환
- `max_tokens=65536` 적용
- finish_reason=length 복구 로직
- 결과를 JSON 파일로 쓰기
- **완료 기준**: 템플릿 스크립트가 단독 실행 가능
### 2-2. 계측기 유형별 프롬프트 정의
- **파일**: 각 추출기 스크립트 상단에 상수로 정의
- **목표**: 각 유형별 전용 프롬프트
- **프롬프트 목록**:
- `_SENSOR_PROMPT` — FT, FIT, LT, PT, TE, PG, LG, TG
- `_VALVE_PROMPT` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV
- `_SYSTEM_PROMPT` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA
- `_GAUGE_PROMPT` — PG, TG, LG
- `_PUMP_PROMPT` — P-10101, VP-10117, DP-10101 등 펌프
- **완료 기준**: 5개 프롬프트 상수 정의 완료
---
## Phase 3: 5개 독립 추출기 스크립트 생성
### 3-1. pid_extract_sensor.py
- **파일**: `mcp-server/worker/pid_extract_sensor.py` (신규)
- **목표**: 센서/계측기 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _SENSOR_PROMPT 적용
- **완료 기준**: 단독 실행 시 sensor.json 출력
### 3-2. pid_extract_valve.py
- **파일**: `mcp-server/worker/pid_extract_valve.py` (신규)
- **목표**: 밸브 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _VALVE_PROMPT 적용
- **완료 기준**: 단독 실행 시 valve.json 출력
### 3-3. pid_extract_system.py
- **파일**: `mcp-server/worker/pid_extract_system.py` (신규)
- **목표**: 시스템/제어기 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _SYSTEM_PROMPT 적용
- **완료 기준**: 단독 실행 시 system.json 출력
### 3-4. pid_extract_gauge.py
- **파일**: `mcp-server/worker/pid_extract_gauge.py` (신규)
- **목표**: 게이지 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _GAUGE_PROMPT 적용
- **완료 기준**: 단독 실행 시 gauge.json 출력
### 3-5. pid_extract_pump.py
- **파일**: `mcp-server/worker/pid_extract_pump.py` (신규)
- **목표**: 펌프 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _PUMP_PROMPT 적용
- **완료 기준**: 단독 실행 시 pump.json 출력
### 3-6. 개별 추출기 테스트
- **파일**: `test_individual_extractors.py` (신규)
- **목표**: 5개 추출기 각각 단독 실행 테스트
- **작업 내용**:
- full_text.txt를 각 추출기에 입력
- 출력 JSON 검증 (schema, tagNo 필수 필드)
- 처리 시간 측정
- **완료 기준**: 5개 모두 정상 출력, 각각 < 60초
---
## Phase 4: pid_worker.py 코디네이터 리팩토링
### 4-1. 전체 텍스트 1회 추출 로직
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: DXF에서 TEXT/MTEXT를 한 번만 읽어 full_text.txt 생성
- **작업 내용**:
- ezdxf로 DXF 로드 → TEXT/MTEXT 엔티티 순회
- 좌표 순 정렬 후 텍스트 연결
- 결과를 임시 디렉토리의 `full_text.txt`에 저장
- **완료 기준**: full_text.txt 생성, 파일 크기 < 10MB
### 4-2. 5개 프로세스 병렬 실행 로직
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `subprocess.Popen`으로 5개 추출기 동시 실행
- **작업 내용**:
- 임시 디렉토리 생성 (`results/` 폴더)
- 5개 스크립트 경로 확인 (동일 디렉토리)
- `subprocess.Popen()`으로 5개 프로세스 동시 시작
- `proc.wait()`로 전체 완료 대기 (timeout=300초)
- 각 프로세스 returncode 확인 (실패 시 로깅 + 계속)
- **완료 기준**: 5개 프로세스 동시 실행, results/에 5개 JSON 생성
### 4-3. 결과 통합 + 중복 제거
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 5개 JSON 파일 로드 → tagNo 기준 중복 제거
- **작업 내용**:
- `results/*.json` 로드 (실패한 파일 스킵)
- tagNo를 키로 하는 딕셔너리에 병합 (첫 번째 우선)
- 통합 통계 출력 (총 태그 수, 유형별 수)
- **완료 기준**: 중복 없는 통합 태그 목록 생성
### 4-4. 위상 그래프 빌드 + 태그 매핑 호출
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 topology.py, mapper.py 호출
- **작업 내용**:
- 통합 태그 목록을 `IntelligentMapper`에 전달
- 매핑 결과를 `TopologyBuilder`에 전달
- 그래프 JSON 생성
- **완료 기준**: 기존 API 호환 유지
### 4-5. `_build_pid_graph_parallel()` 전체 리팩토링
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 함수를 새 아키텍처로 교체
- **전체 흐름**:
```
1. 도면 분할 (extractor.split_drawings())
2. 기하 추출 (extractor.extract_and_save() → geo.json)
3. 전체 텍스트 1회 추출 (→ full_text.txt)
4. 5개 프로세스 병렬 실행 (→ results/*.json)
5. 결과 통합 + 중복 제거
6. 위상 그래프 빌드 + 태그 매핑
7. 저장 + 응답
```
- **완료 기준**: 전체 처리 < 5분, 기존 API 호환
---
## Phase 1: 도면 분할 로직 (extractor.py)
### 1-1. 도면 분할 테스트 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: `No-10_Plant_PID.dxf`를 TITLE 레이어 LINE으로 분할하는 로직 프로토타입
- **작업 내용**:
- ezdxf로 DXF 로드
- TITLE 레이어의 LINE 엔티티 탐색
- 수직 LINE(X 좌표가 일정)을 도면 경계로 감지
- 각 도면별 X/Y 범위 출력
- **검증**: 9개 도면 영역이 올바르게 분리되는지 확인
- **완료 기준**: 콘솔에 9개 도면의 X/Y 범위가 출력됨
### 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `split_drawings()` 메서드 추가
- **작업 내용**:
- `split_drawings() -> List[DrawingRegion]` 메서드 추가
- DrawingRegion 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
- TITLE 레이어 LINE 기반 경계 감지
- 레전드 페이지(X < 2000) 제외
- FFD 페이지도 제외 (최상단 텍스트 기반)
- **완료 기준**: `split_drawings()` 호출 시 9개 DrawingRegion 반환
### 1-3. 영역별 추출 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `extract_region()` 메서드 추가
- **작업 내용**:
- `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 메서드 추가
- bbox가 region 범위 내에 있는 엔티티만 필터링
- 기존 `extract_and_save()` 로직 재사용
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체 엔티티 수와 일치
### 1-4. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: 전체 파이프라인 테스트
- **작업 내용**:
- DXF 로드 → 분할 → 영역별 추출 → 결과 검증
- 각 도면별 엔티티 수, 태그 수 확인
- 처리 시간 측정
- **완료 기준**: 9개 도면 모두 정상 추출, 총 처리 시간 < 30초
---
## Phase 5: 통합 테스트 + 검증
### 5-1. 전체 파이프라인 통합 테스트
- **파일**: `test_full_pipeline_parallel.py` (신규)
- **목표**: `No-10_Plant_PID.dxf` 전체 처리
- **작업 내용**:
- `_build_pid_graph_parallel()` 호출
- 각 단계별 시간 측정 (Phase 1~5)
- 추출된 태그 수, 매핑 수, 그래프 노드/에지 수 확인
- **완료 기준**: 전체 처리 < 5분, 태그 추출 수 >= 기존
### 5-2. 실패 격리 테스트
- **파일**: `test_failure_isolation.py` (신규)
- **목표**: 일부 추출기 실패 시 나머지 결과 활용 확인
- **작업 내용**:
- 하나의 추출기 스크립트 고의 실패 유도
- 나머지 4개 결과로 정상 통합되는지 확인
- 에러 로깅 확인
- **완료 기준**: 4/5 성공 시 정상 통합, 에러 로그 출력
### 5-3. 메모리 사용량 측정
- **파일**: `test_memory_usage.py` (신규)
- **목표**: 각 프로세스 메모리 독립성 확인
- **작업 내용**:
- 5개 프로세스 각각 메모리 사용량 측정
- pid_worker 메모리 사용량 확인 (텍스트 불보유)
- **완료 기준**: pid_worker 메모리 < 500MB, 각 추출기 < 1GB
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 (순차)
Phase 2 (공통 템플릿)
├── 2-1 → 2-2 (순차, Phase 1 완료 후)
Phase 3 (5개 추출기 생성)
├── 3-1 ~ 3-5 (병렬 가능, Phase 2 완료 후)
├── 3-6 (순차, 3-1~3-5 완료 후)
Phase 4 (pid_worker 코디네이터)
├── 4-1 → 4-2 → 4-3 → 4-4 → 4-5 (순차, Phase 3 완료 후)
Phase 5 (통합 테스트)
├── 5-1 → 5-2 → 5-3 (순차, Phase 4 완료 후)
```
---
## 각 단계 완료 기준 및 예상 시간
| 단계 | 완료 기준 | 예상 시간 |
|------|-----------|-----------|
| 1-1 | 9개 도면 영역 콘솔 출력 | 30분 |
| 1-2 | `split_drawings()` 9개 Region 반환 | 1시간 |
| 1-3 | `extract_region()` 정상 동작 | 30분 |
| 1-4 | 전체 분할 테스트 통과 | 30분 |
| 2-1 | 템플릿 스크립트 단독 실행 가능 | 1시간 |
| 2-2 | 5개 프롬프트 상수 정의 | 30분 |
| 3-1~3-5 | 5개 추출기 각각 JSON 출력 | 각 30분 (병렬 가능) |
| 3-6 | 5개 모두 단독 테스트 통과 | 30분 |
| 4-1 | full_text.txt 생성 | 30분 |
| 4-2 | 5개 프로세스 동시 실행 | 1시간 |
| 4-3 | 중복 없는 통합 태그 목록 | 30분 |
| 4-4 | 그래프 빌드 + 매핑 호출 | 30분 |
| 4-5 | 전체 리팩토링 완료, API 호환 | 1시간 |
| 5-1 | 전체 처리 < 5분 | 1시간 |
| 5-2 | 실패 격리 테스트 통과 | 30분 |
| 5-3 | 메모리 사용량 확인 | 30분 |
**총 예상 시간: 약 14시간**
---
## 체크리스트
### Phase 1: 도면 분할
- [ ] 1-1. 도면 분할 테스트 스크립트 작성
- [ ] 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- [ ] 1-3. 영역별 추출 메서드 추가
- [ ] 1-4. 도면 분할 통합 테스트
### Phase 2: 독립 추출기 공통 템플릿
- [ ] 2-1. 공통 추출기 템플릿 작성
- [ ] 2-2. 계측기 유형별 프롬프트 정의
### Phase 3: 5개 독립 추출기 스크립트
- [ ] 3-1. pid_extract_sensor.py
- [ ] 3-2. pid_extract_valve.py
- [ ] 3-3. pid_extract_system.py
- [ ] 3-4. pid_extract_gauge.py
- [ ] 3-5. pid_extract_pump.py
- [ ] 3-6. 개별 추출기 테스트
### Phase 4: pid_worker.py 코디네이터
- [ ] 4-1. 전체 텍스트 1회 추출 로직
- [ ] 4-2. 5개 프로세스 병렬 실행 로직
- [ ] 4-3. 결과 통합 + 중복 제거
- [ ] 4-4. 위상 그래프 빌드 + 태그 매핑 호출
- [ ] 4-5. `_build_pid_graph_parallel()` 전체 리팩토링
### Phase 5: 통합 테스트
- [ ] 5-1. 전체 파이프라인 통합 테스트
- [ ] 5-2. 실패 격리 테스트
- [ ] 5-3. 메모리 사용량 측정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지 (이미 잘 구현됨)
6. **프로세스 간 통신**: 파일 기반 (JSON)으로만 통신. 공유 메모리 금지
7. **임시 파일 정리**: 각 요청 완료 후 임시 디렉토리 삭제
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

View File

@@ -1,528 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
logging.info(f"[{basename}] Phase 1: 기하 추출 시작")
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티 추출")
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
logging.info(f"[{basename}] 시스템 태그 {len(system_tags)}개 로드")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
t1 = time.time()
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t1:.1f}s) - 그래프: {builder.G.number_of_nodes()}노드, {builder.G.number_of_edges()}엣지")
# Phase 3: 병렬 LLM 매핑
t2 = time.time()
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
logging.info(f"[{basename}] Phase 3 시작 - transmitter:{len(transmitter_nodes)}, valve:{len(valve_nodes)}, equipment:{len(equipment_nodes)}")
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t2:.1f}s)")
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
logging.info(f"[{basename}] 매핑 완료: {len(all_mapped_tags)}개 태그 해결")
# Phase 4: 매핑 태그를 기존 그래프에 추가 + 저장 (build_graph() 중복 호출 제거)
t3 = time.time()
from shapely.geometry import box as shapely_box
# 기존 builder.G에 매핑 태그 노드를 추가하고 연결
for tag in all_mapped_tags:
bbox_vals = tag['bbox']
# bbox가 tuple 또는 dict 형태 처리
if isinstance(bbox_vals, (list, tuple)):
bbox_geom = shapely_box(bbox_vals[0], bbox_vals[1], bbox_vals[2], bbox_vals[3])
else:
bbox_geom = shapely_box(bbox_vals['min_x'], bbox_vals['min_y'],
bbox_vals['max_x'], bbox_vals['max_y'])
builder.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 매핑 태그-설비 연결 (SpatialGrid 사용)
equipments = [n for n, d in builder.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
eq_grid = builder._build_spatial_grid(equipments)
new_tags = [tag['entity_id'] for tag in all_mapped_tags]
for tag_id in new_tags:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation='associated_with')
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,190 +0,0 @@
import ezdxf
import re
import json
import logging
from typing import List, Optional, Tuple, Union
from pydantic import BaseModel, Field
from shapely.geometry import box, Point
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Data Models ---
class BoundingBox(BaseModel):
min_x: float
min_y: float
max_x: float
max_y: float
center: Tuple[float, float]
class GeometricEntity(BaseModel):
entity_id: str
entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC
layer: str
bbox: BoundingBox
raw_value: Optional[str] = None
clean_value: Optional[str] = None
coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list)
properties: dict = Field(default_factory=dict)
# --- Extractor Implementation ---
class PidGeometricExtractor:
def __init__(self, file_path: str):
try:
self.doc = ezdxf.readfile(file_path)
self.msp = self.doc.modelspace()
except Exception as e:
raise IOError(f"Failed to load DXF file: {e}")
def clean_text(self, text: str) -> str:
"""
DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환.
"""
if not text:
return ""
# 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등)
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
# 2. 중괄호 { } 제거
text = re.sub(r'[\{\}]', ' ', text)
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
text = re.sub(r'%%[U|O|S|R]', ' ', text)
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
text = re.sub(r'\s+', ' ', text).strip()
return text
def get_bbox(self, entity) -> Optional[BoundingBox]:
"""
엔티티 타입별로 동적인 Bounding Box를 계산하여 반환.
"""
try:
if entity.dxftype() == 'TEXT':
p = entity.dxf.insert
h = entity.dxf.height
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
width = len(entity.dxf.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + width, p.y + h)
elif entity.dxftype() == 'MTEXT':
p = entity.dxf.insert
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + w, p.y + h)
elif entity.dxftype() == 'LINE':
start = entity.dxf.start
end = entity.dxf.end
return self._create_bbox(
min(start.x, end.x), min(start.y, end.y),
max(start.x, end.x), max(start.y, end.y)
)
elif entity.dxftype() == 'LWPOLYLINE':
points = entity.get_points()
if not points: return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return self._create_bbox(min(xs), min(ys), max(xs), max(ys))
elif entity.dxftype() in ('CIRCLE', 'ARC'):
center = entity.dxf.center
radius = entity.dxf.radius
return self._create_bbox(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
)
except Exception as e:
logger.error(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}", exc_info=True)
return None
def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox:
return BoundingBox(
min_x=min_x,
min_y=min_y,
max_x=max_x,
max_y=max_y,
center=((min_x + max_x) / 2, (min_y + max_y) / 2)
)
def extract_and_save(self, output_path: str):
"""
기하학적 데이터를 추출하여 JSON 파일로 저장.
"""
results = []
logger.info(f"Starting DXF extraction from {self.doc.filename if hasattr(self.doc, 'filename') else 'unknown file'}")
for entity in self.msp:
try:
bbox_obj = self.get_bbox(entity)
if not bbox_obj:
continue
raw_text = ""
if entity.dxftype() == 'TEXT':
raw_text = entity.dxf.text
elif entity.dxftype() == 'MTEXT':
raw_text = entity.text
# 좌표 추출 (3D 좌표를 2D로 변환)
coords = []
if hasattr(entity, 'get_points'):
# ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함
coords = [(p[0], p[1]) for p in entity.get_points()]
elif entity.dxftype() == 'LINE':
coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)]
elif entity.dxftype() in ('CIRCLE', 'ARC'):
coords = [(entity.dxf.center.x, entity.dxf.center.y)]
entity_data = GeometricEntity(
entity_id=entity.dxf.handle,
entity_type=entity.dxftype(),
layer=entity.dxf.layer,
bbox=bbox_obj,
raw_value=raw_text if raw_text else None,
clean_value=self.clean_text(raw_text) if raw_text else None,
coordinates=coords,
properties={
"color": entity.dxf.color,
"lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None,
}
)
results.append(entity_data.model_dump())
except Exception as e:
logger.error(f"Unexpected error processing entity {entity.dxftype()} ({entity.dxf.handle}): {e}")
continue
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=4)
logger.info(f"Successfully saved {len(results)} entities to {output_path}")
except Exception as e:
logger.error(f"Failed to save extraction results to {output_path}: {e}")
raise
return output_path
# --- Proximity Utilities ---
def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool:
"""
두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인.
shapely 없이 BBox 좌표만으로 O(1) 계산.
"""
dx = max(0, bbox_b.min_x - bbox_a.max_x, bbox_a.min_x - bbox_b.max_x)
dy = max(0, bbox_b.min_y - bbox_a.max_y, bbox_a.min_y - bbox_b.max_y)
dist = (dx * dx + dy * dy) ** 0.5
return dist <= threshold
def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool:
"""
특정 점이 Bounding Box 내부에 있는지 확인.
"""
return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y)

View File

@@ -1,528 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
logging.info(f"[{basename}] Phase 1: 기하 추출 시작")
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티 추출")
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
logging.info(f"[{basename}] 시스템 태그 {len(system_tags)}개 로드")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
t1 = time.time()
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t1:.1f}s) - 그래프: {builder.G.number_of_nodes()}노드, {builder.G.number_of_edges()}엣지")
# Phase 3: 병렬 LLM 매핑
t2 = time.time()
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
logging.info(f"[{basename}] Phase 3 시작 - transmitter:{len(transmitter_nodes)}, valve:{len(valve_nodes)}, equipment:{len(equipment_nodes)}")
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t2:.1f}s)")
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
logging.info(f"[{basename}] 매핑 완료: {len(all_mapped_tags)}개 태그 해결")
# Phase 4: 매핑 태그를 기존 그래프에 추가 + 저장 (build_graph() 중복 호출 제거)
t3 = time.time()
from shapely.geometry import box as shapely_box
# 기존 builder.G에 매핑 태그 노드를 추가하고 연결
for tag in all_mapped_tags:
bbox_vals = tag['bbox']
# bbox가 tuple 또는 dict 형태 처리
if isinstance(bbox_vals, (list, tuple)):
bbox_geom = shapely_box(bbox_vals[0], bbox_vals[1], bbox_vals[2], bbox_vals[3])
else:
bbox_geom = shapely_box(bbox_vals['min_x'], bbox_vals['min_y'],
bbox_vals['max_x'], bbox_vals['max_y'])
builder.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 매핑 태그-설비 연결 (SpatialGrid 사용)
equipments = [n for n, d in builder.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
eq_grid = builder._build_spatial_grid(equipments)
new_tags = [tag['entity_id'] for tag in all_mapped_tags]
for tag_id in new_tags:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation='associated_with')
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,153 +0,0 @@
import networkx as nx
import asyncio
import json
from typing import List, Optional, Dict, Any, Tuple
from pydantic import BaseModel, Field
from rapidfuzz import process, fuzz
from openai import AsyncOpenAI
# --- 응답 구조화를 위한 Pydantic 모델 ---
class MappingResult(BaseModel):
resolved_tag: str = Field(..., description="The final mapped system tag")
reason: str = Field(..., description="Reason for this mapping based on context")
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):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
if not self.graph.has_node(node_id):
return "Node not found in graph"
node_attr = self.graph.nodes[node_id]
node_type = node_attr.get('type', 'Unknown')
node_val = node_attr.get('value', 'Unknown')
# 1. 직접 연결된 이웃 노드 정보
neighbors = list(self.graph.neighbors(node_id))
neighbor_info = []
for n in neighbors:
attr = self.graph.nodes[n]
val = attr.get('value', n)
typ = attr.get('type', 'Unknown')
# 엣지 속성(관계) 추가
rel = self.graph.get_edge_data(node_id, n).get('relation', 'connected')
neighbor_info.append(f"[{rel}] {val} (Type: {typ})")
# 2. 2-hop 연결 정보 (더 넓은 맥락 파악)
extended_context = []
for n in neighbors:
second_neighbors = list(self.graph.neighbors(n))
for sn in second_neighbors:
if sn == node_id: continue
s_attr = self.graph.nodes[sn]
extended_context.append(f"Indirectly connected via {self.graph.nodes[n].get('value', n)} to {s_attr.get('value', sn)} (Type: {s_attr.get('type', 'Unknown')})")
context_str = (
f"Target Node: {node_val} (Type: {node_type})\n"
f"Direct Neighbors: {', '.join(neighbor_info) if neighbor_info else 'None'}\n"
f"Extended Context: {', '.join(extended_context[:10]) if extended_context else 'None'}"
)
return context_str
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
if not self.client:
return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0)
# Phase 2에서 'value'에 clean_value가 저장됨
node_data = self.graph.nodes.get(node_id, {})
tag_text = node_data.get('value', '')
# 1차 후보 추출 (RapidFuzz)
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await self.client.chat.completions.create(
model="Qwen3.6-27B-FP8", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
# vLLM 컨커런시 제한으로 인해 배치 처리 (한 번에 15개 요청)
_BATCH_SIZE = 15
async def _batch_gather(self, node_ids: List[str], prompt: str) -> Dict[str, MappingResult]:
"""노드를 배치로 나누어 순차적으로 LLM 요청.
모든 노드를 동시에 요청하면 vLLM 큐가 가득 차 타임아웃 발생.
"""
all_results = {}
for i in range(0, len(node_ids), self._BATCH_SIZE):
batch = node_ids[i : i + self._BATCH_SIZE]
tasks = [self._resolve_generic(nid, prompt) for nid in batch]
batch_results = await asyncio.gather(*tasks)
all_results.update(dict(zip(batch, batch_results)))
return all_results
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
if resolved_tag == "UNKNOWN":
return False, "Tag not resolved"
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"

File diff suppressed because it is too large Load Diff

View File

@@ -1,339 +0,0 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
예시 (2분 간격, 여러 태그):
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket,
tagname, AVG(value::double precision) AS avg_val
FROM history_table
WHERE tagname IN ('tag1', 'tag2')
AND recorded_at >= NOW() - INTERVAL '3 hours'
GROUP BY bucket, tagname ORDER BY bucket, tagname
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
system = (
"You are a PostgreSQL SQL expert.\n"
"Convert the user's question into a SELECT SQL using the schema below.\n"
"IMPORTANT rules:\n"
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
"- INTERVAL rule:\n"
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n"
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_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
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""P&ID 태그 추출기 공통 템플릿
독립 프로세스로서 CLI에서 실행되며,
입력 텍스트 파일에서 P&ID 태그를 추출하여 JSON 파일로 출력합니다.
사용법:
python pid_extract_template.py --input full_text.txt --output result.json --prompt "system prompt text"
python pid_extract_template.py --input full_text.txt --output result.json --prompt-file prompt.txt
환경 변수:
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
VLLM_MODEL: 모델명 (기본: Qwen3.6-27B-FP8)
"""
import argparse
import json
import logging
import os
import re
import sys
import time
from typing import List
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
logger = logging.getLogger("pid_extractor")
def parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0
start = -1
best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> List[dict]:
"""
vLLM에 LLM 호출하여 태그 목록 추출.
Args:
system_prompt: 시스템 프롬프트
user_text: 입력 텍스트
max_tokens: 최대 토큰 수
Returns:
추출된 태그 목록 (JSON 배열)
"""
from openai import OpenAI
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
model = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
client = OpenAI(base_url=base_url, api_key="dummy")
logger.info(f"vLLM 호출: {base_url}, 모델: {model}, max_tokens: {max_tokens}")
logger.info(f"입력 텍스트 길이: {len(user_text)}")
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
max_tokens=max_tokens,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
finish_reason = resp.choices[0].finish_reason
logger.info(f"LLM 응답: finish_reason={finish_reason}, 응답 길이={len(raw)}")
data = parse_json_array(raw, finish_reason)
if finish_reason == "length":
logger.warning(f"finish_reason=length: 응답이 잘렸습니다. 복구 시도됨. 추출된 태그 수: {len(data)}")
return data
def main():
parser = argparse.ArgumentParser(description="P&ID 태그 추출기")
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
parser.add_argument("--prompt", type=str, default=None, help="시스템 프롬프트 (인라인)")
parser.add_argument("--prompt-file", type=str, default=None, help="시스템 프롬프트 파일 경로")
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수 (기본: 65536)")
args = parser.parse_args()
# 1. 입력 텍스트 읽기
if not os.path.exists(args.input):
logger.error(f"입력 파일을 찾을 수 없습니다: {args.input}")
sys.exit(1)
with open(args.input, "r", encoding="utf-8") as f:
input_text = f.read()
logger.info(f"입력 파일 읽기 완료: {len(input_text)}")
# 2. 시스템 프롬프트 읽기
system_prompt = None
if args.prompt:
system_prompt = args.prompt
elif args.prompt_file:
if not os.path.exists(args.prompt_file):
logger.error(f"프롬프트 파일을 찾을 수 없습니다: {args.prompt_file}")
sys.exit(1)
with open(args.prompt_file, "r", encoding="utf-8") as f:
system_prompt = f.read()
else:
logger.error("--prompt 또는 --prompt-file 중 하나를 지정해야 합니다.")
sys.exit(1)
logger.info(f"시스템 프롬프트: {len(system_prompt)}")
# 3. LLM 호출
t0 = time.time()
tags = call_llm(system_prompt, input_text, max_tokens=args.max_tokens)
elapsed = time.time() - t0
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}")
# 4. 결과 JSON 쓰기
output_dir = os.path.dirname(args.output)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
result = {
"success": True,
"count": len(tags),
"tags": tags,
"processing_time_sec": round(elapsed, 1),
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logger.info(f"결과 저장 완료: {args.output}")
# 5. 요약 출력
print(json.dumps({
"success": True,
"count": len(tags),
"time": round(elapsed, 1)
}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -1,609 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""RAG 전용 워커 프로세스
Usage: python rag_worker.py <port>
담당 도구:
search_codebase, search_r530_docs, ask_iiot_llm, rag_query
특징:
- Ollama Embedding + Qdrant 검색 + vLLM LLM 조합
- 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
COL_OPC_DOCS = os.environ.get("COL_OPC_DOCS", "experion-opc-docs")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [rag_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── HTTP 클라이언트 싱글톤 ────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _get_http_client():
return httpx.AsyncClient(timeout=30)
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
async def _embed(text: str) -> list[float]:
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
async with _get_http_client() as client:
resp = await client.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text},
)
resp.raise_for_status()
return resp.json()["embedding"]
# ── Qdrant 검색 ──────────────────────────────────────────────────────────────
async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]:
"""Qdrant에서 벡터 유사도 검색."""
async with _get_http_client() as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{collection}/points/search",
json={
"vector": query_vector,
"limit": top_k,
"with_payload": True,
},
)
resp.raise_for_status()
return resp.json().get("result", [])
# ── LLM (vLLM) ───────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
async def _ask_llm(question: str, context: str = "") -> str:
"""vLLM LLM으로 질문 응답."""
client = _llm_client()
if context:
prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요.
컨텍스트:
{context}
질문:
{question}
답변:"""
else:
prompt = question
response = await client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt},
],
max_tokens=4096,
temperature=0.1,
)
return response.choices[0].message.content
# ── RAG 도구 구현 ─────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "search_codebase":
result = await _search_codebase(**params)
elif tool == "search_r530_docs":
result = await _search_r530_docs(**params)
elif tool == "ask_iiot_llm":
result = await _ask_iiot_llm(**params)
elif tool == "rag_query":
result = await _rag_query(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _search_codebase(query: str, top_k: int = 6) -> str:
"""소스코드 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_CODEBASE, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"file": payload.get("file", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _search_r530_docs(query: str, top_k: int = 5) -> str:
"""Experion HS R530 공식 문서 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"title": payload.get("title", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _ask_iiot_llm(question: str, context: str = "") -> str:
"""IIoT/OPC UA 질문 응답."""
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"answer": answer,
}
async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""통합 RAG 검색."""
contexts = []
if search_code:
query_vector = await _embed(question)
code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3)
for hit in code_results:
contexts.append(hit.get("payload", {}).get("content", ""))
if search_docs:
query_vector = await _embed(question)
doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3)
for hit in doc_results:
contexts.append(hit.get("payload", {}).get("content", ""))
context = "\n\n".join(contexts[:5])
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"context_count": len(contexts),
"answer": answer,
}
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002
logging.info(f"Starting RAG worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Gauges',
'system': (
'You are a P&ID expert. Extract instruments tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: PG, TG, LG\n'
'Format: [{"tagNo":"PG-10101A","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of PG, TG,LG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted_PG.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, LI, PI, TI\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, LI, PI, TI, FIQ from the text below:\n\n{text}'
},
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
},
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.7,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, PG, LG, TG\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, PG, LG, TG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted1.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted2.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted3.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,609 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,608 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
Usage: python pid_worker_test.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
# 절대 경로로 변환하여 파일 경로 문제 해결
filepath = os.path.abspath(filepath)
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_task(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,609 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
Usage: python pid_worker_test.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# 프로젝트 루트를 Python 경로에 추가
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, PROJECT_ROOT)
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = PROJECT_ROOT
STORAGE_DIR = os.path.join(_SERVER_DIR, "mcp-server", "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
# 절대 경로로 변환하여 파일 경로 문제 해결
filepath = os.path.abspath(filepath)
basename = os.path.basename(filepath)
worker_dir = os.path.join(PROJECT_ROOT, "mcp-server", "worker")
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_task(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""
Qwen3-Coder-Next-FP8 출력 토큰 속도 벤치마크
- 스트리밍 모드로 수신하며 토큰/초 실시간 측정
- usage.completion_tokens 기반 최종 속도 산출
"""
import time
import sys
from openai import OpenAI
VLLM_BASE_URL = "http://localhost:8000/v1"
VLLM_MODEL = "glm-4.7-flash"
# ── 프로그램 작성 예제 프롬프트 ────────────────────────────────────────────────
PROMPT = """\
Python으로 다음 조건을 만족하는 TTL-LRU 캐시 클래스를 작성해줘.
요구사항:
1. `capacity` (최대 항목 수)와 `ttl_seconds` (항목 유효 시간)를 생성자에서 받는다.
2. `get(key)` — 없거나 만료된 항목은 None 반환.
3. `set(key, value)` — 캐시가 가득 차면 가장 오래된 항목을 제거한다.
4. `delete(key)` — 명시적 삭제.
5. `size()` — 현재 유효한 항목 수 반환 (만료된 항목 제외).
6. 스레드 안전해야 한다 (threading.Lock 사용).
7. 클래스 하단에 동작을 검증하는 `if __name__ == '__main__':` 테스트 코드를 포함한다.
추가 조건:
- 외부 라이브러리 사용 금지 (표준 라이브러리만).
- 타입 힌트를 모든 메서드에 명시한다.
- 각 메서드에 한 줄 docstring을 작성한다.
"""
def run_benchmark():
client = OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
print(f"모델 : {VLLM_MODEL}")
print(f"프롬프트 길이: {len(PROMPT)} chars")
print("=" * 60)
print()
# ── 스트리밍 요청 ──────────────────────────────────────────────
stream = client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{
"role": "system",
"content": "당신은 숙련된 Python 개발자입니다. 명확하고 실용적인 코드를 작성합니다.",
},
{"role": "user", "content": PROMPT},
],
max_tokens=2048,
temperature=0.1,
stream=True,
stream_options={"include_usage": True}, # 마지막 청크에 usage 포함
)
# ── 스트리밍 수신 + 측정 ────────────────────────────────────────
first_token_time = None
start_time = time.perf_counter()
char_count = 0
completion_tokens = 0
full_text = []
for chunk in stream:
# usage 청크 (마지막)
if chunk.usage:
completion_tokens = chunk.usage.completion_tokens
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
if first_token_time is None:
first_token_time = time.perf_counter()
ttft = first_token_time - start_time
print(f"[TTFT: {ttft:.3f}s] ", end="", flush=True)
sys.stdout.write(delta.content)
sys.stdout.flush()
full_text.append(delta.content)
char_count += len(delta.content)
end_time = time.perf_counter()
# ── 결과 출력 ──────────────────────────────────────────────────
total_time = end_time - start_time
gen_time = end_time - (first_token_time or start_time)
tps_wall = completion_tokens / total_time if total_time > 0 else 0
tps_gen = completion_tokens / gen_time if gen_time > 0 else 0
print()
print()
print("=" * 60)
print(f"총 출력 토큰 : {completion_tokens:,}")
print(f"총 소요 시간 : {total_time:.2f}s")
print(f"생성 시간 : {gen_time:.2f}s (첫 토큰 이후)")
print(f"TTFT : {(first_token_time or start_time) - start_time:.3f}s")
print(f"토큰 속도 : {tps_gen:.1f} tok/s (생성 구간)")
print(f"토큰 속도 : {tps_wall:.1f} tok/s (전체 구간, TTFT 포함)")
print("=" * 60)
if __name__ == "__main__":
run_benchmark()

View File

@@ -1,175 +0,0 @@
#!/usr/bin/env python3
"""
Qwen3-Coder-Next-FP8 RAG 연동 벤치마크
- Qdrant 코드베이스 + OPC UA 문서에서 컨텍스트 수집
- 수집된 실제 코드/문서 기반으로 복잡한 신규 기능 구현 요청
- 스트리밍으로 토큰/초 측정
"""
import time
import sys
import httpx
from openai import OpenAI
VLLM_BASE_URL = "http://localhost:8000/v1"
VLLM_MODEL = "glm-4.7-flash"
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
QDRANT_URL = "http://localhost:6333"
COL_CODEBASE = "ws-65f457145aee80b2"
COL_OPC_DOCS = "experion-opc-docs"
def embed(text: str) -> list[float]:
with httpx.Client(timeout=30) as c:
r = c.post(f"{OLLAMA_URL}/api/embeddings", json={"model": EMBED_MODEL, "prompt": text})
r.raise_for_status()
return r.json()["embedding"]
def search(collection: str, query: str, top_k: int = 5) -> list[dict]:
vec = embed(query)
with httpx.Client(timeout=20) as c:
r = c.post(
f"{QDRANT_URL}/collections/{collection}/points/search",
json={"vector": vec, "limit": top_k, "with_payload": True},
)
r.raise_for_status()
return r.json()["result"]
def fmt_hits(hits: list[dict], label: str) -> str:
chunks = []
for i, h in enumerate(hits, 1):
p = h["payload"]
src = p.get("file_path") or p.get("source") or p.get("filename") or "unknown"
text = p.get("text") or p.get("content") or p.get("chunk") or str(p)
score = h.get("score", 0)
chunks.append(f"[{label} #{i} | {src} | score={score:.3f}]\n{text}")
return "\n\n".join(chunks)
def run_benchmark():
client = OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# ── RAG 컨텍스트 수집 ──────────────────────────────────────────────────────
print("RAG 검색 중...")
t0 = time.perf_counter()
# 코드베이스: 실시간 서비스 구조 + DB 저장 패턴
hits_realtime = search(COL_CODEBASE, "ExperionRealtimeService FlushLoop subscription MonitoredItem", top_k=4)
hits_db = search(COL_CODEBASE, "ExperionDbContext history snapshot PostgreSQL EF Core", top_k=3)
# OPC UA 문서: 알람/이벤트 관련
hits_alarm = search(COL_OPC_DOCS, "alarm event notification EventNotifier condition OPC UA", top_k=4)
rag_time = time.perf_counter() - t0
total_hits = len(hits_realtime) + len(hits_db) + len(hits_alarm)
print(f"검색 완료: {total_hits}개 청크 ({rag_time:.2f}s)")
print()
ctx_realtime = fmt_hits(hits_realtime, "코드베이스/Realtime")
ctx_db = fmt_hits(hits_db, "코드베이스/DB")
ctx_alarm = fmt_hits(hits_alarm, "OPC UA 문서/Alarm")
# ── 프롬프트 구성 ──────────────────────────────────────────────────────────
prompt = f"""\
아래는 ExperionCrawler 프로젝트의 실제 코드와 OPC UA 공식 문서 발췌입니다.
이 컨텍스트를 기반으로 새로운 기능을 구현해줘.
━━━ 코드베이스 컨텍스트 ━━━
{ctx_realtime}
{ctx_db}
━━━ OPC UA 문서 컨텍스트 ━━━
{ctx_alarm}
━━━ 구현 요청 ━━━
위 컨텍스트를 바탕으로 ExperionAlarmService를 C#으로 구현해줘.
요구사항:
1. `IHostedService` + `IExperionAlarmService` 패턴 (기존 ExperionRealtimeService와 동일한 구조).
2. OPC UA `EventNotifier` 방식으로 알람/이벤트를 구독한다.
구독 대상 EventType: ConditionType, AlarmConditionType (OPC UA 표준).
3. 이벤트 수신 시 다음 정보를 `alarm_history` PostgreSQL 테이블에 저장한다:
- `id` (bigserial), `tagname`, `event_type`, `severity` (int), `message`, `active` (bool), `occurred_at` (timestamptz)
4. 기존 `ExperionDbContext` / EF Core 패턴을 따른다 (새 DbSet 추가).
5. 컨트롤러 `ExperionAlarmController` — start/stop/status + 최근 알람 조회 (GET /api/alarm/recent?limit=50).
6. `appsettings.json`에 `AlarmServer` 섹션 추가 (NodeId 목록, MaxSeverityFilter).
7. 각 클래스/메서드에 한 줄 XML 문서 주석 포함.
코드는 완성된 형태로 작성하고, 파일별로 명확히 구분해줘.
"""
prompt_chars = len(prompt)
print(f"프롬프트 길이: {prompt_chars:,} chars (RAG 컨텍스트 포함)")
print(f"모델: {VLLM_MODEL}")
print("=" * 60)
print()
# ── 스트리밍 LLM 요청 ──────────────────────────────────────────────────────
stream = client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{
"role": "system",
"content": (
"당신은 C#/.NET 백엔드와 OPC UA 프로토콜 전문가입니다. "
"ExperionCrawler 프로젝트의 기존 코드 스타일과 패턴을 그대로 따르며 "
"완성도 높은 코드를 작성합니다."
),
},
{"role": "user", "content": prompt},
],
max_tokens=4096,
temperature=0.1,
stream=True,
stream_options={"include_usage": True},
)
# ── 스트리밍 수신 + 측정 ────────────────────────────────────────────────────
first_token_time = None
start_time = time.perf_counter()
completion_tokens = 0
for chunk in stream:
if chunk.usage:
completion_tokens = chunk.usage.completion_tokens
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
if first_token_time is None:
first_token_time = time.perf_counter()
ttft = first_token_time - start_time
print(f"[TTFT: {ttft:.3f}s] ", end="", flush=True)
sys.stdout.write(delta.content)
sys.stdout.flush()
end_time = time.perf_counter()
# ── 결과 출력 ──────────────────────────────────────────────────────────────
total_time = end_time - start_time
gen_time = end_time - (first_token_time or start_time)
tps_gen = completion_tokens / gen_time if gen_time > 0 else 0
tps_wall = completion_tokens / total_time if total_time > 0 else 0
print()
print()
print("=" * 60)
print(f"RAG 검색 시간 : {rag_time:.2f}s ({total_hits}개 청크)")
print(f"총 출력 토큰 : {completion_tokens:,}")
print(f"총 소요 시간 : {total_time:.2f}s")
print(f"생성 시간 : {gen_time:.2f}s (첫 토큰 이후)")
print(f"TTFT : {(first_token_time or start_time) - start_time:.3f}s")
print(f"토큰 속도 : {tps_gen:.1f} tok/s (생성 구간)")
print(f"토큰 속도 : {tps_wall:.1f} tok/s (전체 구간)")
print("=" * 60)
if __name__ == "__main__":
run_benchmark()

View File

@@ -1,375 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그를 추출하는 스크립트
- MCP 서버를 거치지 않고 LLM에 직접 요청
- 전처리 과정에서 의미 없는 텍스트는 필터링
- CSV 형식으로 LLM에 전달
"""
import sys
import json
import re
import csv
import io
from dataclasses import dataclass
from typing import List, Optional
import requests
@dataclass
class TextEntity:
"""DXF 텍스트 엔티티"""
entity_type: str
text: str
x: float
y: float
z: float
layer: str
height: float
style: str
def parse_dxf_text_entities(file_path: str) -> List[TextEntity]:
"""DXF 파일에서 TEXT, MTEXT, ATTRIB 엔티티를 파싱"""
entities = []
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
i = 0
while i < len(lines):
line = lines[i].strip()
if line in ('TEXT', 'MTEXT', 'ATTRIB'):
entity_type = line
entity = {
'entity_type': entity_type,
'text': '',
'x': 0.0,
'y': 0.0,
'z': 0.0,
'layer': '',
'height': 0.0,
'style': ''
}
i += 1
while i < len(lines):
code = lines[i].strip()
if code == '0':
break
if i + 1 < len(lines):
value = lines[i + 1].strip()
if code == '1':
if entity['text']:
entity['text'] += ' ' + value
else:
entity['text'] = value
elif code == '10':
entity['x'] = float(value)
elif code == '20':
entity['y'] = float(value)
elif code == '30':
entity['z'] = float(value)
elif code == '8':
entity['layer'] = value
elif code == '40':
entity['height'] = float(value)
elif code == '7':
entity['style'] = value
i += 1
i += 1
if entity['text']:
entities.append(TextEntity(
entity_type=entity['entity_type'],
text=entity['text'],
x=entity['x'],
y=entity['y'],
z=entity['z'],
layer=entity['layer'],
height=entity['height'],
style=entity['style']
))
else:
i += 1
return entities
def filter_meaningful_text(entities: List[TextEntity]) -> List[TextEntity]:
"""
의미 있는 텍스트만 필터링
"""
meaningful = []
remove_patterns = [
r'^\$[A-Z]+$', # DXV 시스템 변수
r'^[0-9]+$', # 숫자만 있는 텍스트
r'^[0-9.]+$', # 숫자와 점만 있는 텍스트
r'^[a-zA-Z0-9_]{1}$', # 1자 알파벳/숫자/언더스코어
r'^[ \t]+$', # 공백만 있는 텍스트
r'^[a-zA-Z0-9]{1,2}$', # 2자 이하의 알파벳/숫자 조합
]
for entity in entities:
text = entity.text.strip()
if not text:
continue
is_system_var = False
for pattern in remove_patterns:
if re.match(pattern, text):
is_system_var = True
break
if is_system_var:
continue
is_meaningful = False
# 태그명 패턴 확인 (예: P-101, PIC-6211, T-10101)
if re.match(r'^[A-Z]+[-_][A-Z0-9]+$', text):
is_meaningful = True
# 3자 이상이고 알파벳/숫자/한글이 포함된 경우
elif len(text) >= 3 and (re.search(r'[A-Z]', text) or re.search(r'[0-9]', text)):
is_meaningful = True
# 한글 포함
elif re.search(r'[가-힣]', text):
is_meaningful = True
if is_meaningful:
meaningful.append(TextEntity(
entity_type=entity.entity_type,
text=text,
x=entity.x,
y=entity.y,
z=entity.z,
layer=entity.layer,
height=entity.height,
style=entity.style
))
return meaningful
def filter_tag_candidates_strict(entities: List[TextEntity]) -> List[TextEntity]:
"""
P&ID 태그 후보만 필터링 (엄격한 기준 - 실제 태그 패턴에만 매칭)
"""
tag_candidates = []
for entity in entities:
text = entity.text.strip()
if not text:
continue
# 태그 패턴: P-101, PIC-6211, T-10101, FT-201 등
# 첫 글자는 대문자(1-4자), 뒤에 하이픈 또는 언더스코어, 그리고 알파벳/숫자가 옴
if re.match(r'^[A-Z]{1,4}[-_][A-Z0-9]+$', text):
tag_candidates.append(entity)
return tag_candidates
def export_to_csv(entities: List[TextEntity]) -> str:
"""CSV 형식으로 변환 (LLM 파싱 용이)"""
lines = []
# 헤더 추가
lines.append("entity_type,text,x,y,z,layer,height,style")
for entity in entities:
# CSV 이스케이프: 쉼표, 따옴표, 줄바꿈이 포함된 경우 따옴표로 감싸기
text = entity.text.replace('"', '""')
if ',' in text or '"' in text or '\n' in text:
text = f'"{text}"'
lines.append(f"{entity.entity_type},{text},{entity.x},{entity.y},{entity.z},{entity.layer},{entity.height},{entity.style}")
return "\n".join(lines)
def export_to_simple_text(entities: List[TextEntity]) -> str:
"""간단한 텍스트 형식으로 변환 (LLM 파싱 용이)"""
lines = []
for entity in entities:
lines.append(f"TEXT: {entity.text}")
return "\n".join(lines)
def extract_pid_tags_with_llm_simple(text_data: str, model_url: str = "http://localhost:8000/v1/chat/completions") -> dict:
"""
LLM을 사용하여 P&ID 태그 추출 (간단한 텍스트 형식)
MCP 서버를 거치지 않고 vLLM 직접 요청
"""
prompt = f"""당신은 P&ID(Piping and Instrumentation Diagram) 도면에서 태그 정보를 추출하는 전문가입니다.
주어진 텍스트는 DXF 파일에서 추출한 P&ID 태그 후보입니다. 이 데이터에서 실제 P&ID 태그를 추출해주세요.
**태그 형식 (예시):**
- P-101: Pump (펌프)
- PIC-6211: Pressure Indicating Controller (압력 측정 및 제어)
- T-10101: Tank (탱크)
- FT-201: Flow Transmitter (유량 측정)
- PT-101: Pressure Transmitter (압력 측정)
- LIC-6201: Level Indicating Controller (유량 측정 및 제어)
- FIC-6113: Flow Indicating Controller (유량 측정 및 제어)
- DP-10101: Differential Pressure (차압)
- VP-10117: Valve Positioner (밸브 포지셔너)
- SP-10601: Switch Pressure (압력 스위치)
**태그 패턴:**
- 첫 글자는 장비/계기 유형을 나타냅니다 (P, T, F, L, P, V, S, C, E, D 등)
- 뒤에 숫자가 붙어 고유 식별자를 만듭니다
- 계기 유형은 PIC, FIC, LIC, TIC 등으로 확장될 수 있습니다
**추출할 필드:**
- tagNo: 태그 번호 (예: P-101, PIC-6211)
- equipmentName: 장비 이름 (예: Pump, Tank, Pressure Transmitter)
- instrumentType: 계기 유형 (P, T, FT, PT, PIC, LIC, FIC, LV, MV 등)
- lineNumber: 파이프 라인 번호 (있는 경우)
- pidDrawingNo: 도면 번호 (있는 경우)
- confidence: 추출 신뢰도 (0.0 ~ 1.0)
**텍스트 데이터:**
{text_data}
**요청:**
1. 텍스트 데이터에서 실제 P&ID 태그만 추출하세요 (의미 없는 텍스트는 제외)
2. JSON 배열 형식으로 응답하세요
3. 각 태그는 위의 필드를 포함해야 합니다
4. 알 수 없는 정보는 null로 설정하세요
5. 신뢰도 점수를 부여하세요
**응답 형식 (JSON만, 추가 설명 없이):**
[
{{"tagNo": "P-101", "equipmentName": "Pump", "instrumentType": "P", "lineNumber": null, "pidDrawingNo": null, "confidence": 0.95}},
{{"tagNo": "PIC-6211", "equipmentName": "Pressure Indicating Controller", "instrumentType": "PIC", "lineNumber": null, "pidDrawingNo": null, "confidence": 0.90}}
]
"""
payload = {
"model": "glm-4.7-flash",
"messages": [
{
"role": "user",
"content": prompt
}
],
"temperature": 0.1,
"max_tokens": 8192,
"stream": False
}
try:
response = requests.post(model_url, json=payload, timeout=300)
response.raise_for_status()
result = response.json()
content = result.get('choices', [{}])[0].get('message', {}).get('content', '')
# JSON 파싱
try:
# 코드 블록으로 감싸진 JSON 제거
json_match = re.search(r'\[.*\]', content, re.DOTALL)
if json_match:
json_str = json_match.group()
return json.loads(json_str)
else:
return json.loads(content)
except json.JSONDecodeError as e:
# 원본 응답을 파일로 저장
error_output_path = dxf_path.replace('.dxf', '_error_response.txt')
with open(error_output_path, 'w', encoding='utf-8') as f:
f.write(content)
return {"error": f"JSON 파싱 실패: {str(e)}", "raw_response": content, "error_output_path": error_output_path}
except requests.exceptions.RequestException as e:
return {"error": f"LLM 요청 실패: {str(e)}"}
def main():
if len(sys.argv) < 2:
print("사용법: python extract_pid_tags.py <dxf_file_path> [model_url]")
sys.exit(1)
dxf_path = sys.argv[1]
model_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000/v1/chat/completions"
print(f"DXF 파일 파싱 중: {dxf_path}")
entities = parse_dxf_text_entities(dxf_path)
print(f"{len(entities)}개 텍스트 엔티티 found")
print("의미 있는 텍스트 필터링 중...")
meaningful = filter_meaningful_text(entities)
print(f"의미 있는 텍스트: {len(meaningful)}")
# P&ID 태그 후보만 필터링 (엄격한 기준)
tag_candidates = filter_tag_candidates_strict(meaningful)
print(f"P&ID 태그 후보 (엄격한 기준): {len(tag_candidates)}")
# 상위 200개만 전달 (토큰 제한 대응)
top_meaningful = tag_candidates[:200]
print(f"LLM에 전달할 텍스트 수: {len(top_meaningful)}")
# 간단한 텍스트 형식으로 변환
simple_text = export_to_simple_text(top_meaningful)
print("\n" + "="*80)
print("LLM에 전달할 텍스트 데이터 (첫 50줄):")
print("="*80)
lines = simple_text.split('\n')
for line in lines[:50]:
print(line)
if len(lines) > 50:
print(f"... (총 {len(lines)}줄)")
print("\n" + "="*80)
print("LLM에 P&ID 태그 추출 요청 중...")
print("="*80)
result = extract_pid_tags_with_llm_simple(simple_text, model_url)
if 'error' in result:
print(f"오류: {result['error']}")
if 'raw_response' in result:
print(f"원본 응답: {result['raw_response'][:500]}")
if 'error_output_path' in result:
print(f"오류 응답 저장 경로: {result['error_output_path']}")
else:
print(f"\n성공적으로 추출된 태그: {len(result)}")
print("\n추출 결과:")
for i, tag in enumerate(result[:20], 1):
print(f"{i}. {tag.get('tagNo', 'N/A')} - {tag.get('equipmentName', 'N/A')} ({tag.get('instrumentType', 'N/A')}) - confidence: {tag.get('confidence', 0)}")
if len(result) > 20:
print(f"... (총 {len(result)}개)")
# 결과를 JSON 파일로 저장
json_output_path = dxf_path.replace('.dxf', '_extracted.json')
with open(json_output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\nJSON 결과가 저장되었습니다: {json_output_path}")
# 결과를 CSV 파일로 저장
csv_output_path = dxf_path.replace('.dxf', '_extracted.csv')
with open(csv_output_path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
writer.writerow(['tagNo', 'equipmentName', 'instrumentType', 'lineNumber', 'pidDrawingNo', 'confidence'])
for tag in result:
writer.writerow([
tag.get('tagNo', ''),
tag.get('equipmentName', ''),
tag.get('instrumentType', ''),
tag.get('lineNumber', ''),
tag.get('pidDrawingNo', ''),
tag.get('confidence', 0)
])
print(f"CSV 결과가 저장되었습니다: {csv_output_path}")
if __name__ == '__main__':
main()

View File

@@ -1,153 +0,0 @@
import networkx as nx
import asyncio
import json
from typing import List, Optional, Dict, Any, Tuple
from pydantic import BaseModel, Field
from rapidfuzz import process, fuzz
from openai import AsyncOpenAI
# --- 응답 구조화를 위한 Pydantic 모델 ---
class MappingResult(BaseModel):
resolved_tag: str = Field(..., description="The final mapped system tag")
reason: str = Field(..., description="Reason for this mapping based on context")
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):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
if not self.graph.has_node(node_id):
return "Node not found in graph"
node_attr = self.graph.nodes[node_id]
node_type = node_attr.get('type', 'Unknown')
node_val = node_attr.get('value', 'Unknown')
# 1. 직접 연결된 이웃 노드 정보
neighbors = list(self.graph.neighbors(node_id))
neighbor_info = []
for n in neighbors:
attr = self.graph.nodes[n]
val = attr.get('value', n)
typ = attr.get('type', 'Unknown')
# 엣지 속성(관계) 추가
rel = self.graph.get_edge_data(node_id, n).get('relation', 'connected')
neighbor_info.append(f"[{rel}] {val} (Type: {typ})")
# 2. 2-hop 연결 정보 (더 넓은 맥락 파악)
extended_context = []
for n in neighbors:
second_neighbors = list(self.graph.neighbors(n))
for sn in second_neighbors:
if sn == node_id: continue
s_attr = self.graph.nodes[sn]
extended_context.append(f"Indirectly connected via {self.graph.nodes[n].get('value', n)} to {s_attr.get('value', sn)} (Type: {s_attr.get('type', 'Unknown')})")
context_str = (
f"Target Node: {node_val} (Type: {node_type})\n"
f"Direct Neighbors: {', '.join(neighbor_info) if neighbor_info else 'None'}\n"
f"Extended Context: {', '.join(extended_context[:10]) if extended_context else 'None'}"
)
return context_str
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
if not self.client:
return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0)
# Phase 2에서 'value'에 clean_value가 저장됨
node_data = self.graph.nodes.get(node_id, {})
tag_text = node_data.get('value', '')
# 1차 후보 추출 (RapidFuzz)
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await self.client.chat.completions.create(
model="glm-4.7-flash", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
# vLLM 컨커런시 제한으로 인해 배치 처리 (한 번에 15개 요청)
_BATCH_SIZE = 15
async def _batch_gather(self, node_ids: List[str], prompt: str) -> Dict[str, MappingResult]:
"""노드를 배치로 나누어 순차적으로 LLM 요청.
모든 노드를 동시에 요청하면 vLLM 큐가 가득 차 타임아웃 발생.
"""
all_results = {}
for i in range(0, len(node_ids), self._BATCH_SIZE):
batch = node_ids[i : i + self._BATCH_SIZE]
tasks = [self._resolve_generic(nid, prompt) for nid in batch]
batch_results = await asyncio.gather(*tasks)
all_results.update(dict(zip(batch, batch_results)))
return all_results
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
if resolved_tag == "UNKNOWN":
return False, "Tag not resolved"
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"

File diff suppressed because it is too large Load Diff

View File

@@ -1,339 +0,0 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
예시 (2분 간격, 여러 태그):
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket,
tagname, AVG(value::double precision) AS avg_val
FROM history_table
WHERE tagname IN ('tag1', 'tag2')
AND recorded_at >= NOW() - INTERVAL '3 hours'
GROUP BY bucket, tagname ORDER BY bucket, tagname
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
system = (
"You are a PostgreSQL SQL expert.\n"
"Convert the user's question into a SELECT SQL using the schema below.\n"
"IMPORTANT rules:\n"
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
"- INTERVAL rule:\n"
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n"
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_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
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""P&ID 태그 추출기 공통 템플릿
독립 프로세스로서 CLI에서 실행되며,
입력 텍스트 파일에서 P&ID 태그를 추출하여 JSON 파일로 출력합니다.
사용법:
python pid_extract_template.py --input full_text.txt --output result.json --prompt "system prompt text"
python pid_extract_template.py --input full_text.txt --output result.json --prompt-file prompt.txt
환경 변수:
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
VLLM_MODEL: 모델명 (기본: glm-4.7-flash)
"""
import argparse
import json
import logging
import os
import re
import sys
import time
from typing import List
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
logger = logging.getLogger("pid_extractor")
def parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0
start = -1
best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> List[dict]:
"""
vLLM에 LLM 호출하여 태그 목록 추출.
Args:
system_prompt: 시스템 프롬프트
user_text: 입력 텍스트
max_tokens: 최대 토큰 수
Returns:
추출된 태그 목록 (JSON 배열)
"""
from openai import OpenAI
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
model = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
client = OpenAI(base_url=base_url, api_key="dummy")
logger.info(f"vLLM 호출: {base_url}, 모델: {model}, max_tokens: {max_tokens}")
logger.info(f"입력 텍스트 길이: {len(user_text)}")
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
max_tokens=max_tokens,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
finish_reason = resp.choices[0].finish_reason
logger.info(f"LLM 응답: finish_reason={finish_reason}, 응답 길이={len(raw)}")
data = parse_json_array(raw, finish_reason)
if finish_reason == "length":
logger.warning(f"finish_reason=length: 응답이 잘렸습니다. 복구 시도됨. 추출된 태그 수: {len(data)}")
return data
def main():
parser = argparse.ArgumentParser(description="P&ID 태그 추출기")
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
parser.add_argument("--prompt", type=str, default=None, help="시스템 프롬프트 (인라인)")
parser.add_argument("--prompt-file", type=str, default=None, help="시스템 프롬프트 파일 경로")
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수 (기본: 65536)")
args = parser.parse_args()
# 1. 입력 텍스트 읽기
if not os.path.exists(args.input):
logger.error(f"입력 파일을 찾을 수 없습니다: {args.input}")
sys.exit(1)
with open(args.input, "r", encoding="utf-8") as f:
input_text = f.read()
logger.info(f"입력 파일 읽기 완료: {len(input_text)}")
# 2. 시스템 프롬프트 읽기
system_prompt = None
if args.prompt:
system_prompt = args.prompt
elif args.prompt_file:
if not os.path.exists(args.prompt_file):
logger.error(f"프롬프트 파일을 찾을 수 없습니다: {args.prompt_file}")
sys.exit(1)
with open(args.prompt_file, "r", encoding="utf-8") as f:
system_prompt = f.read()
else:
logger.error("--prompt 또는 --prompt-file 중 하나를 지정해야 합니다.")
sys.exit(1)
logger.info(f"시스템 프롬프트: {len(system_prompt)}")
# 3. LLM 호출
t0 = time.time()
tags = call_llm(system_prompt, input_text, max_tokens=args.max_tokens)
elapsed = time.time() - t0
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}")
# 4. 결과 JSON 쓰기
output_dir = os.path.dirname(args.output)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
result = {
"success": True,
"count": len(tags),
"tags": tags,
"processing_time_sec": round(elapsed, 1),
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logger.info(f"결과 저장 완료: {args.output}")
# 5. 요약 출력
print(json.dumps({
"success": True,
"count": len(tags),
"time": round(elapsed, 1)
}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -1,609 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,609 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
Usage: python pid_worker_test.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# 프로젝트 루트를 Python 경로에 추가
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, PROJECT_ROOT)
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request, BackgroundTasks
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = PROJECT_ROOT
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
# 절대 경로로 변환하여 파일 경로 문제 해결
if not os.path.isabs(filepath):
filepath = os.path.join(PROJECT_ROOT, filepath)
filepath = os.path.abspath(filepath)
basename = os.path.basename(filepath)
worker_dir = os.path.join(PROJECT_ROOT, "mcp-server", "worker")
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_task(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""RAG 전용 워커 프로세스
Usage: python rag_worker.py <port>
담당 도구:
search_codebase, search_r530_docs, ask_iiot_llm, rag_query
특징:
- Ollama Embedding + Qdrant 검색 + vLLM LLM 조합
- 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
COL_OPC_DOCS = os.environ.get("COL_OPC_DOCS", "experion-opc-docs")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [rag_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── HTTP 클라이언트 싱글톤 ────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _get_http_client():
return httpx.AsyncClient(timeout=30)
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
async def _embed(text: str) -> list[float]:
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
async with _get_http_client() as client:
resp = await client.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text},
)
resp.raise_for_status()
return resp.json()["embedding"]
# ── Qdrant 검색 ──────────────────────────────────────────────────────────────
async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]:
"""Qdrant에서 벡터 유사도 검색."""
async with _get_http_client() as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{collection}/points/search",
json={
"vector": query_vector,
"limit": top_k,
"with_payload": True,
},
)
resp.raise_for_status()
return resp.json().get("result", [])
# ── LLM (vLLM) ───────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
async def _ask_llm(question: str, context: str = "") -> str:
"""vLLM LLM으로 질문 응답."""
client = _llm_client()
if context:
prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요.
컨텍스트:
{context}
질문:
{question}
답변:"""
else:
prompt = question
response = await client.chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt},
],
max_tokens=4096,
temperature=0.1,
)
return response.choices[0].message.content
# ── RAG 도구 구현 ─────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "search_codebase":
result = await _search_codebase(**params)
elif tool == "search_r530_docs":
result = await _search_r530_docs(**params)
elif tool == "ask_iiot_llm":
result = await _ask_iiot_llm(**params)
elif tool == "rag_query":
result = await _rag_query(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _search_codebase(query: str, top_k: int = 6) -> str:
"""소스코드 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_CODEBASE, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"file": payload.get("file", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _search_r530_docs(query: str, top_k: int = 5) -> str:
"""Experion HS R530 공식 문서 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"title": payload.get("title", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _ask_iiot_llm(question: str, context: str = "") -> str:
"""IIoT/OPC UA 질문 응답."""
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"answer": answer,
}
async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""통합 RAG 검색."""
contexts = []
if search_code:
query_vector = await _embed(question)
code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3)
for hit in code_results:
contexts.append(hit.get("payload", {}).get("content", ""))
if search_docs:
query_vector = await _embed(question)
doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3)
for hit in doc_results:
contexts.append(hit.get("payload", {}).get("content", ""))
context = "\n\n".join(contexts[:5])
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"context_count": len(contexts),
"answer": answer,
}
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002
logging.info(f"Starting RAG worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Gauges',
'system': (
'You are a P&ID expert. Extract instruments tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: PG, TG, LG\n'
'Format: [{"tagNo":"PG-10101A","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of PG, TG,LG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted_PG.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, LI, PI, TI\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, LI, PI, TI, FIQ from the text below:\n\n{text}'
},
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
},
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.7,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, PG, LG, TG\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, PG, LG, TG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted1.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted2.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted3.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -1,440 +0,0 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **텍스트 표준화** | 태그명은 표준 형식으로 TEXT 엔티티 사용 |
| **속성 활용** | 블록 속성(Attribute)으로 메타데이터 저장 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 | ✅ |
| `STEAM_LINE` | 스팀 배관 | ✅ |
| `WATER_LINE` | 물 배관 | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `TAG_TEXT` | 태그명 텍스트 | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 | ✅ |
| `FLOW_ARROW` | 흐름 방향 화살표 | ✅ |
| `SPEC_TEXT` | 설비 사양 텍스트 | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터 | PT-10111 |
| `INST_INDICATOR` | 지시기 | TI-10103 |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
| `FLOW_ARROW` | 흐름 방향 화살표 | |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 텍스트
각 장비/밸브/계측기기 옆에 태그명을 TEXT 엔티티로 반드시 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `TAG_TEXT` 레이어 사용 |
| 형식 | `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`) |
| 위치 | 장비 블록에서 10~20 단위 떨어진 곳에 배치 |
| 폰트 | 표준 폰트 (고딕, Arial) — 특수 폰트 금지 |
| 회전 | 0도 (수평) — 회전된 텍스트 금지 |
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
### 4.3 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LINE 엔티티의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (수위 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 LINE 또는 LWPOLYLINE 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
| 연결점 | LINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LINE 찾기
3. LINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
배관: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
배관: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 화살표 블록 사용 의무
**모든 배관 라인의 흐름 방향은 화살표 블록으로 명시해야 합니다.**
| 규칙 | 내용 |
|------|------|
| 블록명 | `FLOW_ARROW` |
| 레이어 | `FLOW_ARROW` |
| 위치 | 배관 라인 중간 또는 끝에 배치 |
| 회전 | 흐름 방향에 맞게 회전 (0°=우측, 90°=하측) |
### 7.2 화살표 블록 속성
```
블록명: FLOW_ARROW
속성:
DIRECTION = RIGHT / LEFT / UP / DOWN
PIPELINE = P-10101-25A-F1A-n (연결된 파이프 라인 번호)
```
### 7.3 흐름 방향 추출 로직
```
1. FLOW_ARROW 블록 찾기
2. INSERT.dxf.rotation으로 방향 확인
- 0° = 우측 흐름 (→)
- 180° = 좌측 흐름 (←)
- 90° = 상측 흐름 (↑)
- 270° = 하측 흐름 (↓)
3. 화살표 위치에서 연결된 배관 라인 찾기
4. 방향 정보 기록: [배관] → [방향]
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
추가로 `FLOW_ARROW` 블록을 배관 라인에 배치해야 합니다:
```
T-10100 (탱크)
└→ FLOW_ARROW (rotation=0°, RIGHT)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 배치 위치
화살표는 다음 위치에 반드시 배치합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관 라인에 FLOW_ARROW 블록이 배치되었는가?
- [ ] 화살표 회전 각도가 흐름 방향과 일치하는가?
- [ ] 분기점/합류점에 화살표가 배치되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 TAG_TEXT 레이어에 TEXT 엔티티로 작성되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?

View File

@@ -1,469 +0,0 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **텍스트 표준화** | 태그명은 표준 형식으로 TEXT 엔티티 사용 |
| **속성 활용** | 블록 속성(Attribute)으로 메타데이터 저장 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `TAG_TEXT` | 태그명 텍스트 | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 | ✅ |
| `SPEC_TEXT` | 설비 사양 텍스트 | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 텍스트
각 장비/밸브/계측기기 옆에 태그명을 TEXT 엔티티로 반드시 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `TAG_TEXT` 레이어 사용 |
| 형식 | `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`) |
| 위치 | 장비 블록에서 10~20 단위 떨어진 곳에 배치 |
| 폰트 | 표준 폰트 (고딕, Arial) — 특수 폰트 금지, 캐드용 SHA 금지 |
| 회전 | 0도 (수평) — 회전된 텍스트 금지 |
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
### 4.3 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LINE 엔티티의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 TAG_TEXT 레이어에 TEXT 엔티티로 작성되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?

View File

@@ -1,477 +0,0 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **속성 우선** | 태그명·사양은 블록 속성(Attribute)으로 저장 — 별도 TEXT 금지 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **엔티티 최소화** | 같은 정보를 여러 엔티티로 중복 저장하지 않음 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 (배관에만 별도 TEXT 필요) | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 — 블록 속성만 사용 (별도 TEXT 금지)
**태그명은 블록 속성(Attribute)의 `TAG` 필드에 저장합니다. 별도의 TEXT 엔티티로 태그명을 배치하지 않습니다.**
이유:
- 블록 속성에 이미 TAG가 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 삽입만 하면 됨 (텍스트 배치 불필요)
**태그명 형식**: `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`)
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
| 엔티티 | TEXT 사용 (MTEXT 금지) |
### 4.3 사양 정보 — 블록 속성만 사용 (별도 TEXT 금지)
**설비 사양(SIZE, VOLUME, MATERIAL 등)은 블록 속성(Attribute)에 저장합니다. 별도의 SPEC_TEXT 레이어로 텍스트를 배치하지 않습니다.**
이유:
- 블록 속성에 이미 모든 사양이 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 속성 입력만 하면 됨
### 4.4 텍스트 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LWPOLYLINE의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 블록 속성(Attribute)의 TAG 필드에 저장되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 TEXT로 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
- [ ] TAG_TEXT 또는 SPEC_TEXT 레이어에 불필요한 TEXT 엔티티가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?

View File

@@ -1,571 +0,0 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
10. [DXF 파일 저장 규칙](#10-dxf-파일-저장-규칙)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **속성 우선** | 태그명·사양은 블록 속성(Attribute)으로 저장 — 별도 TEXT 금지 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **엔티티 최소화** | 같은 정보를 여러 엔티티로 중복 저장하지 않음 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 (배관에만 별도 TEXT 필요) | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 — 블록 속성만 사용 (별도 TEXT 금지)
**태그명은 블록 속성(Attribute)의 `TAG` 필드에 저장합니다. 별도의 TEXT 엔티티로 태그명을 배치하지 않습니다.**
이유:
- 블록 속성에 이미 TAG가 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 삽입만 하면 됨 (텍스트 배치 불필요)
**태그명 형식**: `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`)
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
| 엔티티 | TEXT 사용 (MTEXT 금지) |
### 4.3 사양 정보 — 블록 속성만 사용 (별도 TEXT 금지)
**설비 사양(SIZE, VOLUME, MATERIAL 등)은 블록 속성(Attribute)에 저장합니다. 별도의 SPEC_TEXT 레이어로 텍스트를 배치하지 않습니다.**
이유:
- 블록 속성에 이미 모든 사양이 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 속성 입력만 하면 됨
### 4.4 텍스트 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LWPOLYLINE의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 블록 속성(Attribute)의 TAG 필드에 저장되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 TEXT로 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
- [ ] TAG_TEXT 또는 SPEC_TEXT 레이어에 불필요한 TEXT 엔티티가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?
---
## 10. DXF 파일 저장 규칙
### 10.1 1 도면 = 1 DXF 파일 원칙
**각 P&ID 도면은 반드시 별도의 DXF 파일로 저장합니다.**
| 규칙 | 내용 |
|------|------|
| 파일명 형식 | `[플랜트명]-drawing-[도면번호].dxf` |
| 예시 | `plant-10100-drawing-01.dxf`, `plant-10100-drawing-02.dxf` |
| 금지 | 여러 도면을 1개 DXF 파일에 포함하지 않음 |
**이유**:
- **매칭 연산량 감소**: 1개 파일에 N개 도면 → O(N²×M²) → 1개 도면/파일 → O(M²)
- **타임아웃 방지**: 처리량이 예측 가능하여 타임아웃 발생 제거
- **병렬 처리**: 파일 단위로 워커 분배 가능
- **에러 격리**: 1개 도면 실패가 다른 도면에 영향 없음
- **재처리 효율**: 변경된 도면만 재실행 가능
**CAD 작성자 참고**:
- AutoCAD 작업 시 여러 도면을 단일 DWG 파일로 작성해도 무방
- **DXF 내보내기 시** 도면 번호별로 분할하여 저장
### 10.2 AutoCAD DXF 내보내기 옵션
AutoCAD에서 DXF를 내보낼 때 다음 옵션을 반드시 설정합니다:
#### 필수 설정
| 옵션 | 설정값 | 이유 |
|------|--------|------|
| **DXF 버전** | **AutoCAD 2018 DXF** (또는 이후 버전) | ezdxf 호환성 보장 |
| **내보내기 범위** | **현재 도면(Current)** 또는 **선택(Selection)** | 불필요한 도면 제외 |
| **레이어** | **모든 레이어 포함** | 레이어 정보 필수 |
| **블록** | **블록 정의 포함** | 심볼 인식 필수 |
| **속성** | **속성 값 포함** | 태그명/사양 정보 필수 |
#### AutoCAD操作步骤
**방법 1: 다른 이름으로 저장 (권장)**
1. `파일``다른 이름으로 저장` 선택
2. 파일 형식: **`*.dxf`** 선택
3. 버전: **`AutoCAD 2018 DXF`** 선택
4. 파일명: `[플랜트명]-drawing-[도면번호].dxf` 형식
5. `저장` 클릭
**방법 2: 내보내기 명령**
1. `EXPORT` 또는 `DXFOUT` 명령 실행
2. 내보내기 범위: **현재 뷰(Current View)** 또는 **전체(Extents)**
3. 버전: **AutoCAD 2018** 이상 선택
4. 파일명 규칙 준수
#### 주의 사항
| 항목 | 내용 |
|------|------|
| **모델 스페이스 vs 레이아웃** | 모델 스페이스(Model Space)에서 작업한 경우, 모델 스페이스 내보내기 |
| **블록 정의** | DXF 내보내기 시 블록 정의(Block Definition)가 포함되는지 확인 |
| **속성 값** | 블록 속성(Attribute) 값이 텍스트로 저장되는지 확인 |
| **레이어 상태** | 숨겨진/잠긴 레이어도 포함되지만, 작업 요소는 반드시 ON/THAW 상태 |
| **0 레이어** | 0 레이어에 작업 요소가 없는지 확인 (9.2 체크리스트 참조) |
### 10.3 도면 분할 내보내기 워크플로우
여러 도면이 있는 DWG 파일을 DXF로 분할 내보낼 때:
1. **도면 영역 확인**: 각 도면의 Bounding Box 확인
2. **도면 1 내보내기**:
- 도면 1 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-01.dxf`
3. **도면 2 내보내기**:
- 도면 2 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-02.dxf`
4. **반복**: 모든 도면 완료까지 반복
> **참고**: 도면 분할이 번거로운 경우, Python 스크립트로 자동 분할 가능
> (bbox 기반 분할 로직은 `extractor.py`의 `split_drawings()` 메서드 참조)
### 10.4 DXF 파일 검증
내보내기 후 다음 항목을 확인하세요:
- [ ] 파일이 정상적으로 열리는가? (ezdxf 또는 AutoCAD로 확인)
- [ ] 블록 정의가 포함되어 있는가?
- [ ] 블록 속성(Attribute) 값이 있는가?
- [ ] 레이어 정보가 유지되었는가?
- [ ] LWPOLYLINE 버텍스 너비(end_width)가 유지되었는가?
- [ ] 파일 크기가 예상 범위 내인가? (도면 1개당 1~5MB)

View File

@@ -1,579 +0,0 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
10. [DXF 파일 저장 규칙](#10-dxf-파일-저장-규칙)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **이중 저장** | TAG/SPEC은 블록 속성(기계용) + TEXT(사람용)으로 모두 저장 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `TAG_TEXT` | 태그명 텍스트 (사람이 읽기 위해 필수) | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 | ✅ |
| `SPEC_TEXT` | 설비 사양 텍스트 (사람이 읽기 위해 필수) | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 텍스트 (TAG_TEXT)
**CAD 도면의 제1원칙은 사람이 읽기 위한 것입니다.** 각 장비/밸브/계측기기 옆에 태그명을 TEXT 엔티티로 반드시 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `TAG_TEXT` 레이어 사용 |
| 형식 | `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`) |
| 위치 | 장비 블록에서 10~20 단위 떨어진 곳에 배치 |
| 폰트 | 표준 폰트 (고딕, Arial) — 특수 폰트 금지, 캐드용 SHA 금지 |
| 회전 | 0도 (수평) — 회전된 텍스트 금지 |
**추출자 참고**: 태그명은 블록 속성(Attribute)의 `TAG` 필드가 "진실의 원천(Source of Truth)"입니다. TAG_TEXT는 사람이 읽기 위한 것이며, 추출 시 블록 속성을 우선하고 TEXT는 검증용 보조로 사용합니다.
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
### 4.3 사양 정보 텍스트 (SPEC_TEXT)
**도면 하단에 장비/계기 사양 정보를 TEXT로 반드시 작성합니다.** 사람이 도면을 볼 때 사양을 확인할 수 있어야 합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `SPEC_TEXT` 레이어 사용 |
| 내용 | SIZE, VOLUME, MATERIAL, PRESSURE, TEMPERATURE 등 |
| 위치 | 도면 하단 또는 장비 블록 근처 |
**추출자 참고**: 사양 정보도 블록 속성(Attribute)이 "진실의 원천"입니다. SPEC_TEXT는 사람이 읽기 위한 것이며, 추출 시 블록 속성을 우선합니다.
### 4.4 텍스트 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LWPOLYLINE의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 TAG_TEXT 레이어에 TEXT 엔티티로 작성되었는가? (사람이 읽기 위해)
- [ ] 모든 태그명이 블록 속성(Attribute)의 TAG 필드에 저장되었는가? (추출을 위해)
- [ ] TAG_TEXT와 블록 속성 TAG 값이 일치하는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 작성되었는가?
- [ ] SPEC_TEXT 레이어에 사양 정보가 작성되었는가? (사람이 읽기 위해)
- [ ] 특수 문자(%%U, %%d)가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?
---
## 10. DXF 파일 저장 규칙
### 10.1 1 도면 = 1 DXF 파일 원칙
**각 P&ID 도면은 반드시 별도의 DXF 파일로 저장합니다.**
| 규칙 | 내용 |
|------|------|
| 파일명 형식 | `[플랜트명]-drawing-[도면번호].dxf` |
| 예시 | `plant-10100-drawing-01.dxf`, `plant-10100-drawing-02.dxf` |
| 금지 | 여러 도면을 1개 DXF 파일에 포함하지 않음 |
**이유**:
- **매칭 연산량 감소**: 1개 파일에 N개 도면 → O(N²×M²) → 1개 도면/파일 → O(M²)
- **타임아웃 방지**: 처리량이 예측 가능하여 타임아웃 발생 제거
- **병렬 처리**: 파일 단위로 워커 분배 가능
- **에러 격리**: 1개 도면 실패가 다른 도면에 영향 없음
- **재처리 효율**: 변경된 도면만 재실행 가능
**CAD 작성자 참고**:
- AutoCAD 작업 시 여러 도면을 단일 DWG 파일로 작성해도 무방
- **DXF 내보내기 시** 도면 번호별로 분할하여 저장
### 10.2 AutoCAD DXF 내보내기 옵션
AutoCAD에서 DXF를 내보낼 때 다음 옵션을 반드시 설정합니다:
#### 필수 설정
| 옵션 | 설정값 | 이유 |
|------|--------|------|
| **DXF 버전** | **AutoCAD 2018 DXF** (또는 이후 버전) | ezdxf 호환성 보장 |
| **내보내기 범위** | **현재 도면(Current)** 또는 **선택(Selection)** | 불필요한 도면 제외 |
| **레이어** | **모든 레이어 포함** | 레이어 정보 필수 |
| **블록** | **블록 정의 포함** | 심볼 인식 필수 |
| **속성** | **속성 값 포함** | 태그명/사양 정보 필수 |
#### AutoCAD操作步骤
**방법 1: 다른 이름으로 저장 (권장)**
1. `파일``다른 이름으로 저장` 선택
2. 파일 형식: **`*.dxf`** 선택
3. 버전: **`AutoCAD 2018 DXF`** 선택
4. 파일명: `[플랜트명]-drawing-[도면번호].dxf` 형식
5. `저장` 클릭
**방법 2: 내보내기 명령**
1. `EXPORT` 또는 `DXFOUT` 명령 실행
2. 내보내기 범위: **현재 뷰(Current View)** 또는 **전체(Extents)**
3. 버전: **AutoCAD 2018** 이상 선택
4. 파일명 규칙 준수
#### 주의 사항
| 항목 | 내용 |
|------|------|
| **모델 스페이스 vs 레이아웃** | 모델 스페이스(Model Space)에서 작업한 경우, 모델 스페이스 내보내기 |
| **블록 정의** | DXF 내보내기 시 블록 정의(Block Definition)가 포함되는지 확인 |
| **속성 값** | 블록 속성(Attribute) 값이 텍스트로 저장되는지 확인 |
| **레이어 상태** | 숨겨진/잠긴 레이어도 포함되지만, 작업 요소는 반드시 ON/THAW 상태 |
| **0 레이어** | 0 레이어에 작업 요소가 없는지 확인 (9.2 체크리스트 참조) |
### 10.3 도면 분할 내보내기 워크플로우
여러 도면이 있는 DWG 파일을 DXF로 분할 내보낼 때:
1. **도면 영역 확인**: 각 도면의 Bounding Box 확인
2. **도면 1 내보내기**:
- 도면 1 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-01.dxf`
3. **도면 2 내보내기**:
- 도면 2 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-02.dxf`
4. **반복**: 모든 도면 완료까지 반복
> **참고**: 도면 분할이 번거로운 경우, Python 스크립트로 자동 분할 가능
> (bbox 기반 분할 로직은 `extractor.py`의 `split_drawings()` 메서드 참조)
### 10.4 DXF 파일 검증
내보내기 후 다음 항목을 확인하세요:
- [ ] 파일이 정상적으로 열리는가? (ezdxf 또는 AutoCAD로 확인)
- [ ] 블록 정의가 포함되어 있는가?
- [ ] 블록 속성(Attribute) 값이 있는가?
- [ ] 레이어 정보가 유지되었는가?
- [ ] LWPOLYLINE 버텍스 너비(end_width)가 유지되었는가?
- [ ] 파일 크기가 예상 범위 내인가? (도면 1개당 1~5MB)

View File

@@ -1,784 +0,0 @@
# 디지털 태그 추가 작업 계획 (Phase 1)
## 개요
`realtime-tag-expansion-design.md` 설계안을 기반으로 Phase 1 기본 확장을 구현합니다.
기존 `realtime_table` 구조를 유지하면서 디지털 장비(Pump, XV) 태그와 메타데이터 지원을 추가합니다.
---
## Todo List (작업 진행 상태 추적)
> 각 단계는 독립적으로 실행 가능하며, 완료 상태를 표시하여 다음 작업에서 바로 읽을 수 있습니다.
- [ ] **Step 1**: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
- [ ] **Step 2**: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
- [ ] **Step 3**: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
- [ ] **Step 4**: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
- [ ] **Step 5**: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
- [ ] **Step 6**: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
- [ ] **Step 7**: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인
---
## Step 1: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
### 상태: [ ] 미완료
### 변경 파일
- `src/Infrastructure/Database/ExperionDbContext.cs`
### 변경 내용
[`ExperionDbService.InitializeAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:176) 메서드 내에 다음 DDL을 추가합니다.
기존 DDL(`realtime_table` 생성 이후, `EnsureCreatedAsync` 설명 주석 이전)에 추가합니다.
**추가 위치:** 276번 라인 (`// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음`) 직후
```sql
// tag_metadata ( - )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL,
attribute TEXT NOT NULL,
value TEXT,
node_id TEXT,
loaded_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(base_tag, attribute)
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
""");
// v_tag_summary ( + )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE OR REPLACE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
""");
```
### 검증 방법
1. 앱을 실행하여 DB 초기화가 자동으로 수행되는지 확인
2. psql로 접속하여 `SELECT * FROM tag_metadata LIMIT 1;` 실행
3. `SELECT * FROM v_tag_summary LIMIT 5;` 실행하여 뷰가 정상 동작하는지 확인
---
## Step 2: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
### 상태: [ ] 미완료
### 변경 파일
- `src/Core/Domain/Entities/ExperionEntities.cs` (신규 Entity 추가)
- `src/Infrastructure/Database/ExperionDbContext.cs` (DbSet + ModelBuilder 등록)
### 2.1 신규 Entity 클래스 추가
**파일:** [`src/Core/Domain/Entities/ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs:138)
[`FastRecord`](src/Core/Domain/Entities/ExperionEntities.cs:129) 클래스 직후에 다음 Entity를 추가합니다.
```csharp
/// <summary>tag_metadata — 태그 메타데이터 (변경 드묾)</summary>
[Table("tag_metadata")]
public class TagMetadata
{
[Column("id")] public int Id { get; set; }
[Column("base_tag")] public string BaseTag { get; set; } = string.Empty;
[Column("attribute")] public string Attribute { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
[Column("node_id")] public string? NodeId { get; set; }
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
}
```
### 2.2 DbContext 에 DbSet 등록
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:20)
기존 DbSet 목록 ([`FastRecords`](src/Infrastructure/Database/ExperionDbContext.cs:23) 이후, P&ID DbSet 이전) 에 추가:
```csharp
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>();
```
### 2.3 ModelBuilder 설정 추가
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:30)
[`OnModelCreating()`](src/Infrastructure/Database/ExperionDbContext.cs:30) 메서드 내, [`FastRecord`](src/Infrastructure/Database/ExperionDbContext.cs:75) 설정 이후, P&ID 엔티티 설정 이전에 추가:
```csharp
modelBuilder.Entity<TagMetadata>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique();
e.HasIndex(x => x.BaseTag);
});
```
### 2.4 변경 후 파일 구조 확인
[`ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs) 최종 구조:
```
ExperionTag (line 7)
ExperionRecord (line 21)
ExperionServerConfig (line 32)
RawNodeMap (line 47)
NodeMapMaster (line 59)
RealtimePoint (line 71)
HistoryRecord (line 82)
ExperionStatusCodeInfo (line 92)
PidGraphStatus (line 102)
FastSession (line 113)
FastRecord (line 130)
TagMetadata (신규 추가) ← 여기
```
[`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs) DbSet 최종 구조:
```csharp
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>(); // ← 신규
// P&ID DbSet ...
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Entity 클래스가 `[Table("tag_metadata")]` 속성으로 DB 테이블과 매핑되는지 확인
3. DbContext에서 `TagMetadata` DbSet를 통해 CRUD가 가능한지 확인
---
## Step 3: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
### 상태: [ ] 미완료
### 신규 파일
- `src/Infrastructure/OpcUa/MetadataLoaderService.cs`
### 변경 파일
- `src/Core/Application/Interfaces/IExperionServices.cs` (인터페이스 추가)
- `src/Web/Program.cs` (DI 등록)
### 3.1 인터페이스 정의
**파일:** [`src/Core/Application/Interfaces/IExperionServices.cs`](src/Core/Application/Interfaces/IExperionServices.cs)
파일 끝에 다음 인터페이스 추가:
```csharp
// ── Metadata Loader ──────────────────────────────────────────────────────────
public interface IMetadataLoaderService
{
/// <summary>
/// 태그 등록 시 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 한 번 읽어서 tag_metadata 저장
/// </summary>
Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags);
/// <summary>
/// UI 재로드 버튼 클릭 시 메타데이터 재조회 + tag_metadata UPDATE + node_map_master UPSERT
/// </summary>
Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
}
```
### 3.2 서비스 구현
**파일:** `src/Infrastructure/OpcUa/MetadataLoaderService.cs` (신규)
```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var count = 0;
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var baseTag in baseTags)
{
foreach (var attr in MetaAttributes)
{
var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
var fullNodeId = $"ns=1;s={nodeId}";
try
{
var result = await _opcClient.ReadTagAsync(cfg, fullNodeId);
if (result.IsGood && result.Value != null)
{
entries.Add((baseTag.ToLowerInvariant(), attr, result.Value, fullNodeId));
count++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Metadata] 읽기 실패: {BaseTag}.{Attr}", baseTag, attr);
}
}
}
// 벌크 UPSERT
foreach (var (bt, attr, val, nid) in entries)
{
await _ctx.Database.ExecuteSqlRawAsync(@"
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
VALUES (@bt, @attr, @val, @nid, NOW())
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = @val, node_id = @nid, loaded_at = NOW()",
new NpgsqlTypes.NpgsqlParameter("@bt", bt),
new NpgsqlTypes.NpgsqlParameter("@attr", attr),
new NpgsqlTypes.NpgsqlParameter("@val", (object?)val ?? DBNull.Value),
new NpgsqlTypes.NpgsqlParameter("@nid", nid));
}
await _ctx.Database.ExecuteSqlRawAsync("REFRESH MATERIALIZED VIEW IF EXISTS v_tag_summary");
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({BaseTags}개 태그)", count, baseTags.Count());
return count;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
// baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
var tags = baseTags ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
return await LoadMetadataAsync(cfg, tags);
}
}
```
### 3.3 DI 등록
**파일:** `src/Web/Program.cs`
기존 서비스 등록 부분 근처에 추가:
```csharp
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. 테스트용 코드에서 `LoadMetadataAsync` 호출 시 OPC UA에서 desc/area 값이 정상 읽히는지 로그 확인
3. DB에서 `SELECT * FROM tag_metadata;` 실행하여 데이터 저장 확인
---
## Step 4: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
### 상태: [ ] 미완료
### 변경 파일
- `src/Web/Controllers/ExperionControllers.cs` (신규 컨트롤러 추가)
### 4.1 컨트롤러 추가
**파일:** [`src/Web/Controllers/ExperionControllers.cs`](src/Web/Controllers/ExperionControllers.cs)
[`ExperionPointBuilderController`](src/Web/Controllers/ExperionControllers.cs:272) 직후, [`ExperionRealtimeController`](src/Web/Controllers/ExperionControllers.cs:348) 이전에 추가:
```csharp
// ── 태그 메타데이터 관리 ──────────────────────────────────────────────────────
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
private readonly IMetadataLoaderService _metaSvc;
private readonly IOpcUaConfigProvider _configProvider;
public TagMetadataController(
IMetadataLoaderService metaSvc,
IOpcUaConfigProvider configProvider)
{
_metaSvc = metaSvc;
_configProvider = configProvider;
}
/// <summary>
/// 지정된 태그(또는 전체)의 메타데이터를 OPC UA에서 재조회하여 갱신
/// </summary>
[HttpPost("reload")]
public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
{
var cfg = new ExperionServerConfig
{
ServerHostName = req?.ServerHostName ?? "192.168.0.20",
Port = req?.Port ?? 4840,
ClientHostName = req?.ClientHostName ?? "dbsvr",
UserName = req?.UserName ?? "mngr",
Password = req?.Password ?? "mngr"
};
try
{
var count = await _metaSvc.ReloadMetadataAsync(cfg, req?.BaseTags);
return Ok(new { success = true, count, message = $"{count}개 메타데이터 갱신 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>
/// tag_metadata 전체 조회
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
{
using var ctx = new ExperionDbContext(/* DI에서 해결 필요 */);
var query = ctx.TagMetadata.AsQueryable();
if (!string.IsNullOrEmpty(baseTag))
query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());
var items = await query.Select(t => new
{
t.BaseTag,
t.Attribute,
t.Value,
t.NodeId,
t.LoadedAt
}).ToListAsync();
return Ok(new { count = items.Count, items });
}
}
public record MetadataReloadRequest(
string? ServerHostName,
int? Port,
string? ClientHostName,
string? UserName,
string? Password,
IEnumerable<string>? BaseTags);
```
### 4.2 GET 엔드포인트용 DbContext 주입 수정
위 코드에서 `GetMetadata` 메서드는 DI를 통해 DbContext를 주입받는 방식으로 수정해야 합니다.
최종 형태:
```csharp
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
private readonly IMetadataLoaderService _metaSvc;
private readonly ExperionDbContext _ctx;
public TagMetadataController(
IMetadataLoaderService metaSvc,
ExperionDbContext ctx)
{
_metaSvc = metaSvc;
_ctx = ctx;
}
[HttpPost("reload")]
public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
{
// ... 동일
}
[HttpGet]
public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
{
var query = _ctx.TagMetadata.AsQueryable();
if (!string.IsNullOrEmpty(baseTag))
query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());
var items = await query.Select(t => new
{
t.BaseTag, t.Attribute, t.Value, t.NodeId, t.LoadedAt
}).ToListAsync();
return Ok(new { count = items.Count, items });
}
}
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Postman/curl로 `POST /api/tags/metadata/reload` 호출하여 메타데이터 갱신 확인
3. `GET /api/tags/metadata?baseTag=xv-6124` 호출하여 조회 확인
---
## Step 5: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
### 상태: [ ] 미완료
### 변경 파일
- `src/Web/wwwroot/index.html` (포인트빌더 탭 HTML 변경)
- `src/Web/wwwroot/js/app.js` (JS 로직 변경)
### 5.1 HTML 변경 — 포인트빌더 최대 10개로 확장
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:422)
현재 포인트빌더 탭(`pane-pb`) 내 name 선택 드롭다운이 8개(`pb-n1`~`pb-n8`)입니다. 10개로 확장:
**변경 위치:** `pb-name-grid` 내부 select 요소 2개 추가
```html
<!-- 신규 추가 -->
<select id="pb-n9" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n10" class="inp"><option value="">— 선택 안 함 —</option></select>
```
**레이블 텍스트 변경:**
```html
<label>이름(name) 선택 <em>(OR 조건, 최대 10개)</em>
```
### 5.2 HTML 변경 — 메타데이터 재로드 버튼 추가
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:482)
`pb-rt-status` 로그 박스 직후, 포인트 목록 카드 이전에 추가:
```html
<div class="card" style="margin-top:18px">
<div class="card-cap">메타데이터 관리</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
</p>
<div class="btn-row">
<button class="btn-a" onclick="metaReload()">🔄 메타데이터 재로드</button>
<button class="btn-b" onclick="metaView()">📋 메타데이터 조회</button>
</div>
<div id="meta-log" class="logbox hidden" style="margin-top:8px"></div>
<div id="meta-view" class="hidden" style="margin-top:10px;max-height:300px;overflow:auto"></div>
</div>
```
### 5.3 JS 변경 — PB_NAME_IDS 배열 확장
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:575)
```javascript
// 변경 전:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];
// 변경 후:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10'];
```
### 5.4 JS 변경 — 메타데이터 재로드/조회 함수 추가
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:723)
`rtStatus()` 함수 직후에 추가:
```javascript
/* ── 메타데이터 관리 ─────────────────────────────────────────── */
async function metaReload() {
const body = {
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
clientHostName: document.getElementById('pb-rt-client').value.trim(),
userName: document.getElementById('pb-rt-user').value.trim(),
password: document.getElementById('pb-rt-pw').value
};
const logEl = document.getElementById('meta-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">⏳ 메타데이터 재로드 중...</div>';
try {
const d = await api('POST', '/api/tags/metadata/reload', body);
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function metaView() {
const viewEl = document.getElementById('meta-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', '/api/tags/metadata');
const items = d.items || [];
if (items.length === 0) {
viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
return;
}
viewEl.innerHTML = `
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
<tbody>
${items.map(m => `
<tr>
<td style="font-weight:600">${esc(m.baseTag)}</td>
<td>${esc(m.attribute)}</td>
<td>${esc(m.value || '-')}</td>
<td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
```
### 검증 방법
1. 브라우저에서 포인트빌더 탭 확인 — 드롭다운이 10개인지 확인
2. "메타데이터 재로드" 버튼 클릭 시 API 호출되고 결과 로그 표시 확인
3. "메타데이터 조회" 버튼 클릭 시 테이블 형태로 메타데이터 표시 확인
---
## Step 6: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
### 상태: [ ] 미완료
### 변경 파일
- `mcp-server/worker/nl2sql_worker.py` (DB_SCHEMA 변수 업데이트)
- `mcp-server/server.py` (_DB_SCHEMA 변수 업데이트)
### 6.1 nl2sql_worker.py 변경
**파일:** [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:61)
기존 `DB_SCHEMA` 변수에 `tag_metadata` 테이블과 `v_tag_summary` 뷰 정의를 추가:
```python
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv')
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
base_tag TEXT - 기본 태그명
pv TEXT - 현재 프로세스 값
sp TEXT - 설정값
op TEXT - 출력값
instate0 TEXT - 상태 비트 0 (true/false)
instate1 TEXT - 상태 비트 1 (true/false)
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double)
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지
"""
```
### 6.2 server.py 변경
**파일:** [`mcp-server/server.py`](mcp-server/server.py:430)
`_DB_SCHEMA` 변수도 동일하게 업데이트 (nl2sql_worker.py와 동일한 내용).
### 6.3 NL2SQL 사용 예시 (새로운 시나리오)
| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |
### 검증 방법
1. MCP 서버 재시작 후 NL2SQL 도구 호출 시 새로운 스키마가 반영되는지 확인
2. "xv-6124 상태 알려줘" 같은 질문으로 v_tag_summary 조회 테스트
3. tag_metadata 테이블 조회가 정상적으로 되는지 확인
---
## Step 7: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인
### 상태: [ ] 미완료
### 테스트 시나리오
#### 7.1 디지털 태그 등록 테스트
**목표:** xv-6124의 pv, op, instate0~2를 realtime_table에 등록하고 OPC UA 구독 확인
**단계:**
1. 포인트빌더 탭에서 이름에 `xv-6124` 선택
2. 데이터 타입에 `Int32`, `Boolean` 입력
3. "🔨 테이블 작성하기" 클릭
4. 포인트 목록에 xv-6124.pv, xv-6124.op, xv-6124.instate0~2가 생성되는지 확인
**기대 결과:**
- `realtime_table`에 xv-6124 관련 행이 INSERT됨
- 포인트 목록 UI에 표시됨
#### 7.2 OPC UA 구독 테스트
**목표:** 실시간 값 업데이트 확인
**단계:**
1. "▶ 구독 시작" 클릭
2. 구독 상태 로그에 xv-6124 관련 노드가 포함된지 확인
3. 몇 분 후 포인트 목록 새로고침
4. `livevalue` 컬럼에 값이 업데이트되는지 확인
**기대 결과:**
- xv-6124.pv → Int32 BCD 값 (예: 0, 1, 3, 5 등)
- xv-6124.instate0 → Boolean (true/false)
- 구독 카운터에 xv-6124 태그가 포함됨
#### 7.3 메타데이터 로드 테스트
**목표:** desc, area, state descriptor 값이 tag_metadata에 저장되는지 확인
**단계:**
1. "🔄 메타데이터 재로드" 버튼 클릭
2. 로그에 갱신된 속성 수 확인
3. "📋 메타데이터 조회" 버튼 클릭
4. xv-6124 관련 메타데이터 행 확인
**기대 결과:**
- `tag_metadata`에 xv-6124 관련 행 INSERT됨
- desc: 장비 설명 문자열
- area: 소속 플랜트 (예: "Unit-A")
- state0descriptor: "Open/Close" 또는 유사한 문자열
#### 7.4 v_tag_summary 뷰 테스트
**목표:** 실시간값 + 메타데이터 통합 조회 확인
**단계:**
```sql
-- psql에서 실행
SELECT * FROM v_tag_summary WHERE base_tag = 'xv-6124';
```
**기대 결과:**
| base_tag | pv | op | instate0 | instate1 | instate2 | description | area | state0_descriptor | state1_descriptor | state2_descriptor |
|----------|-----|-----|----------|----------|----------|-------------|------|-------------------|-------------------|-------------------|
| xv-6124 | 1 | 1 | true | false | false | XV-6124 설명 | Unit-A | Open/Close | Remote/Local | Fault/Normal |
#### 7.5 NL2SQL 통합 테스트
**목표:** 자연어 질문으로 디지털 태그 상태 조회
**단계:**
1. Text-to-SQL 탭에서 "xv-6124 현재 상태 알려줘" 입력
2. 생성된 SQL이 v_tag_summary를 사용하는지 확인
3. 결과에 instate0~2 값과 state descriptor가 포함되는지 확인
**기대 결과:**
- SQL: `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'`
- 결과 테이블에 상태 비트와 의미 표시됨
### 테스트 체크리스트
- [ ] xv-6124 포인트 realtime_table에 등록됨
- [ ] OPC UA 구독 시 livevalue 업데이트됨
- [ ] 메타데이터 재로드 시 tag_metadata에 저장됨
- [ ] v_tag_summary 뷰에서 통합 조회 가능
- [ ] NL2SQL에서 자연어 질문으로 상태 조회 가능
- [ ] 포인트빌더에서 최대 10개 태그 선택 가능

File diff suppressed because it is too large Load Diff

View File

@@ -1,339 +0,0 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
예시 (2분 간격, 여러 태그):
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket,
tagname, AVG(value::double precision) AS avg_val
FROM history_table
WHERE tagname IN ('tag1', 'tag2')
AND recorded_at >= NOW() - INTERVAL '3 hours'
GROUP BY bucket, tagname ORDER BY bucket, tagname
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
system = (
"You are a PostgreSQL SQL expert.\n"
"Convert the user's question into a SELECT SQL using the schema below.\n"
"IMPORTANT rules:\n"
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
"- INTERVAL rule:\n"
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n"
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model="Qwen3.6-27B-FP8",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_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
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -1,304 +0,0 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Opc.Ua;
using System.Collections.Generic;
namespace ExperionCrawler.Core.Application.Interfaces;
// ── Certificate ──────────────────────────────────────────────────────────────
public interface IExperionCertificateService
{
/// <summary>인증서가 없으면 생성, 있으면 기존 반환</summary>
Task<ExperionCertResult> EnsureCertificateAsync(
string applicationUri,
string clientHostName,
IEnumerable<string> subjectAltNames,
string pfxPassword = "");
bool CertificateExists(string clientHostName);
ExperionCertInfo GetCertificateInfo(string clientHostName);
}
// ── OPC UA Client ────────────────────────────────────────────────────────────
/// <summary>OPC UA ApplicationConfiguration 생성을 위한 공유 설정 공급자</summary>
public interface IOpcUaConfigProvider
{
Task<ApplicationConfiguration> GetConfigAsync(ExperionServerConfig cfg);
}
public interface IExperionOpcClient
{
Task<ExperionConnectResult> TestConnectionAsync(ExperionServerConfig cfg);
Task<ExperionReadResult> ReadTagAsync(ExperionServerConfig cfg, string nodeId);
Task<IEnumerable<ExperionReadResult>> ReadTagsAsync(ExperionServerConfig cfg, IEnumerable<string> nodeIds);
Task<ExperionBrowseResult> BrowseNodesAsync(ExperionServerConfig cfg, string? startNodeId = null);
Task<ExperionNodeMapResult> BrowseAllNodesAsync(ExperionServerConfig cfg, int maxDepth = 10, CancellationToken ct = default);
}
// ── CSV ──────────────────────────────────────────────────────────────────────
public interface IExperionCsvService
{
Task<string> ExportAsync(IEnumerable<ExperionRecord> records, string fileName);
Task<IEnumerable<ExperionRecord>> ImportAsync(string filePath);
IEnumerable<string> GetAvailableFiles();
Task<string> ExportNodeMapAsync(IEnumerable<ExperionNodeMapEntry> entries, string fileName);
}
// ── Database ─────────────────────────────────────────────────────────────────
public interface IExperionDbService
{
Task<bool> InitializeAsync();
// ── TimeScaleDB 하이퍼테이블 ──────────────────────────────────────────────────
Task<HypertableStatusInfo> GetHypertableStatusAsync();
Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request);
Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records);
Task<int> ClearRecordsAsync();
Task<int> BuildMasterFromRawAsync(bool truncate = false);
Task<IEnumerable<ExperionRecord>> GetRecordsAsync(DateTime? from = null, DateTime? to = null, int limit = 1000);
Task<int> GetTotalCountAsync();
Task<IEnumerable<string>> GetNameListAsync();
Task<NodeMapStats> GetMasterStatsAsync();
Task<NodeMapQueryResult> QueryMasterAsync(
int? minLevel, int? maxLevel, string? nodeClass,
IEnumerable<string>? names, string? nodeId, string? dataType,
int limit, int offset);
// ── RealtimeTable ─────────────────────────────────────────────────────────
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes);
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
Task<bool> DeleteRealtimePointAsync(int id);
Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp);
Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates);
// ── HistoryTable ──────────────────────────────────────────────────────────
Task<int> SnapshotToHistoryAsync();
Task<IEnumerable<string>> GetTagNamesAsync();
Task<HistoryQueryResult> QueryHistoryAsync(
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit);
/// <summary>
/// 사용자 지정 간격으로 history 이력 조회
/// history_table의 기본 저장 간격(60초)을 기반으로 사용자가 요청한 간격으로 데이터 집계
/// </summary>
/// <param name="request">조회 요청 (태그명, 시간 범위, 간격 등)</param>
/// <returns>집계된 이력 데이터</returns>
Task<HistoryIntervalQueryResult> QueryHistoryWithIntervalAsync(HistoryIntervalQueryRequest request);
// ── OPC UA Server 지원 ────────────────────────────────────────────────────
/// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync();
// ── FastSession ───────────────────────────────────────────────────────────────
Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request);
Task UpdateFastSessionStatusAsync(int sessionId, string status);
Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount);
Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned);
Task<FastSession?> GetFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetFastSessionsAsync();
Task DeleteFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
// ── FastRecord ────────────────────────────────────────────────────────────────
Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
// ── Realtime → Fast 복사용 ────────────────────────────────────────────────────
/// <summary>realtime_table에서 태그명 목록으로 livevalue와 timestamp 가져오기</summary>
Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames);
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
Task<string?> GetNodeIdByTagNameAsync(string tagName);
}
// ── Realtime Service ─────────────────────────────────────────────────────────
public interface IExperionRealtimeService
{
Task StartAsync(ExperionServerConfig cfg);
Task StopAsync();
RealtimeServiceStatus GetStatus();
/// <summary>
/// 구독 중이면 MonitoredItem 핫 추가 후 OPC UA 서버 응답 검증.
/// 구독 중이지 않으면 (true, "구독 중 아님") 반환.
/// </summary>
Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId);
}
public record RealtimeServiceStatus(bool Running, int SubscribedCount, string Message);
// ── OPC UA Server ─────────────────────────────────────────────────────────────
public interface IExperionOpcServerService
{
Task StartServerAsync();
Task StopServerAsync();
OpcServerStatus GetStatus();
void UpdateNodeValue(string tagname, string? value, DateTime timestamp);
void RebuildAddressSpace(IEnumerable<ExperionCrawler.Core.Domain.Entities.RealtimePoint> points);
}
public record OpcServerStatus(
bool Running,
int ConnectedClientCount,
int NodeCount,
string EndpointUrl,
DateTime? StartedAt);
// ── Status Code ──────────────────────────────────────────────────────────────
public interface IExperionStatusCodeService
{
ExperionStatusCodeInfo? GetByHex(string hexCode);
ExperionStatusCodeInfo? GetByUint(uint statusCode);
int LoadedCount { get; }
}
// ── Result records ───────────────────────────────────────────────────────────
public record ExperionCertResult (bool Success, string Message, string? ThumbPrint = null);
public record ExperionCertInfo (bool Exists, string? SubjectName, DateTime? NotAfter, string? ThumbPrint, string FilePath);
// DTO는 record 타입으로 유지. DisplayName null 방지는 Infrastructure 레이어에서 처리
public record ExperionNodeInfo(string NodeId, string DisplayName, string NodeClass, bool HasChildren);
public record ExperionNodeMapEntry(int Level, string NodeClass, string DisplayName, string NodeId, string DataType);
public record ExperionConnectResult(bool Success, string Message, string? SessionId = null, string? PolicyUri = null);
public record ExperionReadResult(bool Success, string NodeId, object? Value, string StatusCode,
string? ErrorMessage = null, DateTime? Timestamp = null);
public record ExperionBrowseResult(bool Success, IEnumerable<ExperionNodeInfo> Nodes, string? ErrorMessage = null);
public record ExperionNodeMapResult(bool Success, IEnumerable<ExperionNodeMapEntry> Nodes, int TotalCount, string? ErrorMessage = null);
// DisplayName 필드는 null 방지를 위해 대체 기본값이 필요하지만,
// record는 null 불가 필드를 가질 수 없으므로 Infrastructure 레이어에서 null 대체 처리
public record NodeMapStats(int Total, int ObjectCount, int VariableCount, int MaxLevel, IEnumerable<string> DataTypes);
public record NodeMapQueryResult(int Total, IEnumerable<NodeMapMaster> Items);
public record HistoryQueryResult(IEnumerable<string> TagNames, IEnumerable<HistoryRow> Rows);
public record HistoryRow(DateTime RecordedAt, IReadOnlyDictionary<string, string?> Values);
// ── History Interval Query ─────────────────────────────────────────────────────
public record HistoryIntervalQueryRequest(
IEnumerable<string> TagNames,
DateTime? From,
DateTime? To,
string Interval,
int Limit);
public record HistoryIntervalQueryResult(
IEnumerable<string> TagNames,
IEnumerable<HistoryIntervalRow> Rows,
int BaseIntervalSeconds,
string QueryInterval);
public record HistoryIntervalRow(DateTime TimeBucket, IReadOnlyDictionary<string, string?> Values);
public record LiveValueUpdate(string NodeId, string? Value, DateTime Timestamp);
// ── fastTable DTOs ────────────────────────────────────────────────────────────
public record FastSessionInfo(
int Id,
string Name,
DateTime StartedAt,
DateTime? EndedAt,
string Status,
int SamplingMs,
int DurationSec,
string[] TagList,
int RowCount,
int? RetentionDays,
bool Pinned
);
public record FastSessionStartRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
public record FastSessionCreateRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
public record FastQueryResult(
int SessionId,
DateTime From,
DateTime To,
string[] TagNames,
IEnumerable<FastRecord> Items,
int TotalCount
);
public record PinRequest(bool Pinned);
// ── fastTable Service ─────────────────────────────────────────────────────────
public interface IExperionFastService
{
Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request);
Task StopSessionAsync(int sessionId);
Task DeleteSessionAsync(int sessionId);
Task PinSessionAsync(int sessionId, bool pinned);
Task<FastSessionInfo?> GetSessionAsync(int sessionId);
Task<IEnumerable<FastSessionInfo>> GetSessionsAsync();
Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long");
Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
}
// ── P&ID Extractor ─────────────────────────────────────────────────────────────
public interface IPidExtractorService
{
// 추출
Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false);
Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false);
// 조회 (페이지네이션)
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
string? tagNo, int page, int pageSize);
Task<PidEquipment?> GetByIdAsync(long id);
// 업데이트
Task UpdateConfidenceAsync(long id, double confidence);
Task ActivateAsync(long id);
Task DeactivateAsync(long id);
// 통계
Task<int> GetTotalCountAsync();
Task<int> GetConfidenceItemsCountAsync();
Task<int> GetLowConfidenceItemsCountAsync();
Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
Task<int> GetDrawingCountAsync();
// 내보내기
Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items);
}
// ── P&ID Tag Mapping ───────────────────────────────────────────────────────────
public interface ITagMappingService
{
Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
Task<TagMappingResult?> GetMappingByIdAsync(long id);
Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
Task UpdateMappingAsync(long id, UpdateMappingRequest request);
Task ClearMappingAsync(long id);
Task<int> GetUnmappedCountAsync();
Task<int> GetMappedCountAsync();
Task<IEnumerable<string>> GetAvailableTagsAsync();
}

View File

@@ -1,138 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace ExperionCrawler.Core.Domain.Entities;
/// <summary>OPC UA 노드 태그 읽기 결과 (메모리)</summary>
public class ExperionTag
{
public int Id { get; set; }
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Value { get; set; }
public string? DataType { get; set; }
public string StatusCode { get; set; } = "Good";
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public bool IsGood => StatusCode == "Good";
}
/// <summary>크롤링으로 수집한 레코드 (DB/CSV 저장 단위)</summary>
public class ExperionRecord
{
public int Id { get; set; }
public string NodeId { get; set; } = string.Empty;
public string? Value { get; set; }
public string StatusCode { get; set; } = "Good";
public DateTime CollectedAt { get; set; } = DateTime.UtcNow;
public string? SessionId { get; set; }
}
/// <summary>Experion 서버 접속 설정</summary>
public class ExperionServerConfig
{
public int Id { get; set; }
public string ServerHostName { get; set; } = string.Empty;
public int Port { get; set; } = 4840;
public string ClientHostName { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string EndpointUrl => $"opc.tcp://{ServerHostName}:{Port}";
public string ApplicationUri => $"urn:{ClientHostName}:ExperionCrawlerClient";
}
/// <summary>OPC UA 전체 노드맵 원시 데이터 (AssetLoader binary COPY 대상)</summary>
[Table("raw_node_map")]
public class RawNodeMap
{
[Column("id")] public int Id { get; set; }
[Column("level")] public int Level { get; set; }
[Column("class")] public string Class { get; set; } = string.Empty;
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("data_type")]public string DataType { get; set; } = string.Empty;
}
/// <summary>raw_node_map 에서 빌드된 master 테이블</summary>
[Table("node_map_master")]
public class NodeMapMaster
{
[Column("id")] public int Id { get; set; }
[Column("level")] public int Level { get; set; }
[Column("class")] public string Class { get; set; } = string.Empty;
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("data_type")]public string DataType { get; set; } = string.Empty;
}
/// <summary>realtime_table — 실시간 모니터링 포인트</summary>
[Table("realtime_table")]
public class RealtimePoint
{
[Column("id")] public int Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("livevalue")] public string? LiveValue { get; set; }
[Column("timestamp")] public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>history_table — 시계열 이력 스냅샷</summary>
[Table("history_table")]
public class HistoryRecord
{
[Column("id")] public int Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
[Column("recorded_at")] public DateTime RecordedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>statuscode.json 항목</summary>
public class ExperionStatusCodeInfo
{
public string Name { get; set; } = string.Empty;
public string Hex { get; set; } = string.Empty;
public ulong Decimal { get; set; }
public string Description { get; set; } = string.Empty;
}
/// <summary>P&ID 그래프 생성 작업 상태 (DB 저장용)</summary>
[Table("pid_graph_status")]
public class PidGraphStatus
{
[Column("task_id")] public string TaskId { get; set; } = string.Empty;
[Column("progress")] public double Progress { get; set; }
[Column("status")] public string Status { get; set; } = "Pending";
[Column("message")] public string Message { get; set; } = string.Empty;
[Column("updated_at")] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>fastSession — 데이터 수집 세션 메타</summary>
[Table("fast_session")]
public class FastSession
{
[Column("id")] public int Id { get; set; }
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("started_at")] public DateTime StartedAt { get; set; }
[Column("ended_at")] public DateTime? EndedAt { get; set; }
[Column("status")] public string Status { get; set; } = "Pending";
// Status 허용값: Pending / Running / Completed / Cancelled / Failed / RowLimitReached
[Column("sampling_ms")] public int SamplingMs { get; set; }
[Column("duration_sec")] public int DurationSec { get; set; }
[Column("tag_list")] public string TagList { get; set; } = "[]"; // JSONB → string[] 직렬화
[Column("row_count")] public int RowCount { get; set; }
[Column("retention_days")] public int? RetentionDays { get; set; } // null = 무한 보관
[Column("pinned")] public bool Pinned { get; set; }
}
/// <summary>fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점)</summary>
[Table("fast_record")]
public class FastRecord
{
[NotMapped] public int Id { get; set; }
[Column("session_id")] public int SessionId { get; set; }
[Column("recorded_at")] public DateTime RecordedAt { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
}

View File

@@ -1,987 +0,0 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.AspNetCore.Mvc;
namespace ExperionCrawler.Web.Controllers;
// ── 인증서 ────────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/certificate")]
public class ExperionCertificateController : ControllerBase
{
private readonly IExperionCertificateService _certSvc;
public ExperionCertificateController(IExperionCertificateService certSvc)
=> _certSvc = certSvc;
/// <summary>현재 인증서 상태 조회</summary>
[HttpGet("status")]
public IActionResult GetStatus([FromQuery] string clientHostName = "dbsvr")
{
var info = _certSvc.GetCertificateInfo(clientHostName);
return Ok(new
{
exists = info.Exists,
subjectName = info.SubjectName,
notAfter = info.NotAfter,
thumbPrint = info.ThumbPrint,
filePath = info.FilePath
});
}
/// <summary>인증서 생성 (없으면 생성, 있으면 기존 반환)</summary>
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody] ExperionCertCreateDto dto)
{
var applicationUri = $"urn:{dto.ClientHostName}:ExperionCrawlerClient";
var result = await _certSvc.EnsureCertificateAsync(
applicationUri, dto.ClientHostName, dto.SubjectAltNames, dto.PfxPassword);
return Ok(new
{
success = result.Success,
message = result.Message,
thumbPrint = result.ThumbPrint
});
}
}
// ── 서버 접속 테스트 ──────────────────────────────────────────────────────────
[ApiController]
[Route("api/connection")]
public class ExperionConnectionController : ControllerBase
{
private readonly IExperionOpcClient _opcClient;
public ExperionConnectionController(IExperionOpcClient opcClient)
=> _opcClient = opcClient;
/// <summary>OPC UA 서버 접속 테스트</summary>
[HttpPost("test")]
public async Task<IActionResult> Test([FromBody] ExperionServerConfigDto dto)
{
var cfg = MapConfig(dto);
var r = await _opcClient.TestConnectionAsync(cfg);
return Ok(new { success = r.Success, message = r.Message, sessionId = r.SessionId, policyUri = r.PolicyUri });
}
/// <summary>단일 노드 태그 읽기</summary>
[HttpPost("read")]
public async Task<IActionResult> ReadTag([FromBody] ExperionReadTagRequestDto dto)
{
var cfg = MapConfig(dto.ServerConfig);
var r = await _opcClient.ReadTagAsync(cfg, dto.NodeId);
return Ok(new
{
success = r.Success,
nodeId = r.NodeId,
value = r.Value?.ToString(),
statusCode = r.StatusCode,
timestamp = r.Timestamp,
error = r.ErrorMessage
});
}
/// <summary>노드 탐색 (ObjectsFolder 기준)</summary>
[HttpPost("browse")]
public async Task<IActionResult> Browse([FromBody] ExperionBrowseRequestDto dto)
{
var cfg = MapConfig(dto.ServerConfig);
var r = await _opcClient.BrowseNodesAsync(cfg, dto.StartNodeId);
return Ok(new {
success = r.Success,
error = r.ErrorMessage,
nodes = r.Nodes.Select(n => new {
nodeId = n.NodeId,
displayName = n.DisplayName,
nodeClass = n.NodeClass,
hasChildren = n.HasChildren
})
});
}
private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
{
ServerHostName = dto.ServerHostName,
Port = dto.Port,
ClientHostName = dto.ClientHostName,
UserName = dto.UserName,
Password = dto.Password
};
}
// ── 크롤링 ────────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/crawl")]
public class ExperionCrawlController : ControllerBase
{
private readonly ExperionCrawlService _crawlSvc;
public ExperionCrawlController(ExperionCrawlService crawlSvc)
=> _crawlSvc = crawlSvc;
/// <summary>전체 노드맵 크롤 (재귀 탐색 → CSV 저장)</summary>
[HttpPost("nodemap")]
public async Task<IActionResult> NodeMap(
[FromBody] ExperionNodeMapCrawlRequestDto dto,
CancellationToken ct)
{
var cfg = new ExperionServerConfig
{
ServerHostName = dto.ServerConfig.ServerHostName,
Port = dto.ServerConfig.Port,
ClientHostName = dto.ServerConfig.ClientHostName,
UserName = dto.ServerConfig.UserName,
Password = dto.ServerConfig.Password
};
var result = await _crawlSvc.RunNodeMapCrawlAsync(cfg, dto.MaxDepth, ct);
return Ok(new
{
success = result.Success,
totalCount = result.TotalCount,
csvPath = result.CsvPath,
error = result.ErrorMessage
});
}
/// <summary>크롤링 시작 (동기식 완료 후 결과 반환)</summary>
[HttpPost("start")]
public async Task<IActionResult> Start(
[FromBody] ExperionCrawlRequestDto dto,
CancellationToken ct)
{
var cfg = new ExperionServerConfig
{
ServerHostName = dto.ServerConfig.ServerHostName,
Port = dto.ServerConfig.Port,
ClientHostName = dto.ServerConfig.ClientHostName,
UserName = dto.ServerConfig.UserName,
Password = dto.ServerConfig.Password
};
var result = await _crawlSvc.RunCrawlAsync(
cfg, dto.NodeIds, dto.IntervalSeconds, dto.DurationSeconds, ct);
return Ok(new
{
success = true,
sessionId = result.SessionId,
totalRecords = result.TotalRecords,
csvPath = result.CsvPath,
preview = result.Records.Take(5).Select(r => new
{
r.NodeId, r.Value, r.StatusCode,
collectedAt = r.CollectedAt
})
});
}
}
// ── DB 저장 ───────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/database")]
public class ExperionDatabaseController : ControllerBase
{
private readonly ExperionCrawlService _crawlSvc;
private readonly IExperionDbService _dbSvc;
private readonly IExperionCsvService _csvSvc;
private readonly AssetLoader _assetLoader;
public ExperionDatabaseController(
ExperionCrawlService crawlSvc,
IExperionDbService dbSvc,
IExperionCsvService csvSvc,
AssetLoader assetLoader)
{
_crawlSvc = crawlSvc;
_dbSvc = dbSvc;
_csvSvc = csvSvc;
_assetLoader = assetLoader;
}
/// <summary>수집된 CSV 파일 목록</summary>
[HttpGet("files")]
public IActionResult GetCsvFiles()
=> Ok(new { files = _csvSvc.GetAvailableFiles() });
/// <summary>CSV → DB 임포트 (ServerHostName 지정 시 → raw_node_map, 그 외 → ExperionRecords)</summary>
[HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ExperionCsvImportDto dto)
{
// 경계 문자 및 경로 조작 방지: 파일명에 점, 역슬래시, 슬래시, 공백 제거
var safeFileName = dto.FileName.Trim();
var invalidChars = new char[] { '.', '\\', '/', ' ', '\t', '\n', '\r' };
if (string.IsNullOrEmpty(safeFileName) || invalidChars.Any(c => safeFileName.Contains(c)))
{
return BadRequest(new { error = "파일명에 허용되지 않는 문자가 포함되었습니다." });
}
if (!string.IsNullOrEmpty(dto.ServerHostName) &&
safeFileName.StartsWith(dto.ServerHostName, StringComparison.OrdinalIgnoreCase))
{
try
{
var fullPath = Path.Combine("data/csv", dto.FileName);
var rawCount = await _assetLoader.ImportFullMapAsync(fullPath, dto.Truncate);
var masterCount = await _dbSvc.BuildMasterFromRawAsync(dto.Truncate);
return Ok(new
{
success = true,
count = rawCount,
masterCount,
message = $"{rawCount}개 노드 → raw_node_map 적재, {masterCount}개 → node_map_master 빌드 완료"
});
}
catch (Exception ex)
{
return Ok(new { success = false, count = 0, masterCount = 0, message = $"오류: {ex.Message}" });
}
}
var result = await _crawlSvc.ImportCsvToDbAsync(dto.FileName, dto.Truncate);
return Ok(new { success = result.Success, count = result.Count, message = result.Message });
}
/// <summary>DB 레코드 조회</summary>
[HttpGet("records")]
public async Task<IActionResult> GetRecords(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int limit = 100)
{
var records = await _dbSvc.GetRecordsAsync(from, to, limit);
var total = await _dbSvc.GetTotalCountAsync();
return Ok(new { total, records });
}
}
// ── 포인트빌더 ────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/pointbuilder")]
public class ExperionPointBuilderController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
private readonly IExperionRealtimeService _realtimeSvc;
public ExperionPointBuilderController(
IExperionDbService dbSvc,
IExperionRealtimeService realtimeSvc)
{
_dbSvc = dbSvc;
_realtimeSvc = realtimeSvc;
}
/// <summary>node_map_master → realtime_table 빌드 (기존 데이터 전체 교체)</summary>
[HttpPost("build")]
public async Task<IActionResult> Build([FromBody] PointBuilderBuildDto dto)
{
var count = await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes);
return Ok(new { success = true, count, message = $"{count}개 포인트 생성 완료" });
}
/// <summary>realtime_table 전체 조회</summary>
[HttpGet("points")]
public async Task<IActionResult> GetPoints()
{
var points = await _dbSvc.GetRealtimePointsAsync();
return Ok(new
{
total = points.Count(),
items = points.Select(p => new
{
id = p.Id,
tagName = p.TagName,
nodeId = p.NodeId,
liveValue = p.LiveValue,
timestamp = p.Timestamp
})
});
}
/// <summary>node_id 를 직접 입력해서 수동 추가</summary>
[HttpPost("add")]
public async Task<IActionResult> Add([FromBody] PointBuilderAddDto dto)
{
if (string.IsNullOrWhiteSpace(dto.NodeId))
return BadRequest(new { success = false, message = "node_id 는 필수입니다." });
var nodeId = dto.NodeId.Trim();
// DB에 먼저 추가
var point = await _dbSvc.AddRealtimePointAsync(nodeId);
// 구독 중이면 OPC UA 서버에 핫 추가 및 node_id 유효성 검증
var (ok, msg) = await _realtimeSvc.AddMonitoredItemAsync(nodeId);
if (!ok)
{
// OPC UA 서버가 거부 → DB 롤백
await _dbSvc.DeleteRealtimePointAsync(point.Id);
return BadRequest(new { success = false, message = msg });
}
return Ok(new { success = true, message = msg, point = new { id = point.Id, tagName = point.TagName, nodeId = point.NodeId } });
}
/// <summary>포인트 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var deleted = await _dbSvc.DeleteRealtimePointAsync(id);
return Ok(new { success = deleted, message = deleted ? "삭제 완료" : "포인트를 찾을 수 없습니다." });
}
}
// ── 실시간 서비스 ─────────────────────────────────────────────────────────────
[ApiController]
[Route("api/realtime")]
public class ExperionRealtimeController : ControllerBase
{
private readonly IExperionRealtimeService _rtSvc;
public ExperionRealtimeController(IExperionRealtimeService rtSvc) => _rtSvc = rtSvc;
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] ExperionServerConfigDto dto)
{
var cfg = MapConfig(dto);
await _rtSvc.StartAsync(cfg);
return Ok(new { success = true, message = "실시간 구독 시작" });
}
[HttpPost("stop")]
public async Task<IActionResult> Stop()
{
await _rtSvc.StopAsync();
return Ok(new { success = true, message = "실시간 구독 중지" });
}
[HttpGet("status")]
public IActionResult Status()
{
var s = _rtSvc.GetStatus();
return Ok(new { running = s.Running, subscribedCount = s.SubscribedCount, message = s.Message });
}
private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
{
ServerHostName = dto.ServerHostName,
Port = dto.Port,
ClientHostName = dto.ClientHostName,
UserName = dto.UserName,
Password = dto.Password
};
}
// ── 이력 조회 ─────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/history")]
public class ExperionHistoryController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
private readonly ILogger<ExperionHistoryController> _logger;
public ExperionHistoryController(IExperionDbService dbSvc, ILogger<ExperionHistoryController> logger)
{
_dbSvc = dbSvc;
_logger = logger;
}
/// <summary>realtime_table 의 tagname 목록</summary>
[HttpGet("tagnames")]
public async Task<IActionResult> TagNames()
{
try
{
_logger.LogDebug("[History] tagname 목록 조회 시작");
var names = await _dbSvc.GetTagNamesAsync();
var count = names.Count();
_logger.LogDebug("[History] tagname 목록 조회 완료: {Count}개 태그", count);
return Ok(new { tagNames = names });
}
catch (Exception ex)
{
_logger.LogError(ex, "[History] tagname 목록 조회 실패");
return StatusCode(500, new { success = false, message = $"tagname 조회 실패: {ex.Message}" });
}
}
/// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
[HttpGet("query")]
public async Task<IActionResult> Query(
[FromQuery] List<string>? tagNames,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int limit = 1000)
{
try
{
var tagList = tagNames ?? Enumerable.Empty<string>().ToList();
_logger.LogDebug("[History] 이력 조회 시작 - 태그 수: {TagCount}, FROM: {From}, TO: {To}, LIMIT: {Limit}",
tagList.Count(), from, to, limit);
var result = await _dbSvc.QueryHistoryAsync(
tagList, from, to, limit);
_logger.LogDebug("[History] 이력 조회 완료: {RowCount}행, {TagCount}태그",
result.Rows.Count(), result.TagNames.Count());
return Ok(new
{
tagNames = result.TagNames,
rows = result.Rows.Select(r => new
{
recordedAt = r.RecordedAt,
values = r.Values
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "[History] 이력 조회 실패 - tagNames: {@TagNames}, from: {From}, to: {To}",
tagNames, from, to);
return StatusCode(500, new
{
success = false,
message = $"이력 조회 실패: {ex.Message}",
detail = ex.InnerException?.Message ?? ex.StackTrace
});
}
}
}
// ── OPC UA 서버 ───────────────────────────────────────────────────────────────
[ApiController]
[Route("api/opcserver")]
public class ExperionOpcServerController : ControllerBase
{
private readonly IExperionOpcServerService _svc;
private readonly IExperionDbService _db;
public ExperionOpcServerController(
IExperionOpcServerService svc, IExperionDbService db)
{
_svc = svc;
_db = db;
}
/// <summary>OPC UA 서버 상태 조회</summary>
[HttpGet("status")]
public IActionResult Status()
{
var s = _svc.GetStatus();
return Ok(new
{
running = s.Running,
connectedClientCount = s.ConnectedClientCount,
nodeCount = s.NodeCount,
endpointUrl = s.EndpointUrl,
startedAt = s.StartedAt
});
}
/// <summary>OPC UA 서버 시작 (자동 재시작 플래그 저장)</summary>
[HttpPost("start")]
public async Task<IActionResult> Start()
{
try
{
await _svc.StartServerAsync();
return Ok(new { success = true, message = "OPC UA 서버 시작 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>OPC UA 서버 중지 (자동 재시작 플래그 삭제)</summary>
[HttpPost("stop")]
public async Task<IActionResult> Stop()
{
await _svc.StopServerAsync();
return Ok(new { success = true, message = "OPC UA 서버 중지 완료" });
}
/// <summary>주소 공간 재구성 (포인트빌더 빌드 후 호출)</summary>
[HttpPost("rebuild")]
public async Task<IActionResult> Rebuild()
{
var points = (await _db.GetRealtimePointsAsync()).ToList();
_svc.RebuildAddressSpace(points);
return Ok(new { success = true, nodeCount = points.Count });
}
}
// ── 노드맵 대시보드 ───────────────────────────────────────────────────────────
[ApiController]
[Route("api/nodemap")]
public class ExperionNodeMapController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
public ExperionNodeMapController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
/// <summary>node_map_master 의 name 컬럼 고유값 목록</summary>
[HttpGet("names")]
public async Task<IActionResult> Names()
{
var names = await _dbSvc.GetNameListAsync();
return Ok(new { names });
}
/// <summary>node_map_master 통계 (총 수, 클래스별, 최대 레벨, 데이터타입 목록)</summary>
[HttpGet("stats")]
public async Task<IActionResult> Stats()
{
var s = await _dbSvc.GetMasterStatsAsync();
return Ok(new
{
total = s.Total,
objectCount = s.ObjectCount,
variableCount = s.VariableCount,
maxLevel = s.MaxLevel,
dataTypes = s.DataTypes
});
}
/// <summary>node_map_master 필터 조회 (페이지네이션 포함, names OR 조건)</summary>
[HttpGet("query")]
public async Task<IActionResult> Query(
[FromQuery] int? minLevel,
[FromQuery] int? maxLevel,
[FromQuery] string? nodeClass,
[FromQuery] List<string>? names,
[FromQuery] string? nodeId,
[FromQuery] string? dataType,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0)
{
var r = await _dbSvc.QueryMasterAsync(
minLevel, maxLevel, nodeClass, names, nodeId, dataType, limit, offset);
return Ok(new
{
total = r.Total,
items = r.Items.Select(x => new
{
id = x.Id,
level = x.Level,
@class = x.Class,
name = x.Name,
nodeId = x.NodeId,
dataType = x.DataType
})
});
}
}
// ── 하이퍼테이블 관리 ───────────────────────────────────────────────────────────
[ApiController]
[Route("api/experion/hypertable")]
public class ExperionHypertableController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
public ExperionHypertableController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
/// <summary>하이퍼테이블 상태 조회</summary>
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
var status = await _dbSvc.GetHypertableStatusAsync();
return Ok(new
{
isHypertable = status.IsHypertable,
tableName = status.TableName,
statusMessage = status.StatusMessage,
recordCount = status.RecordCount,
hasRetentionPolicy = status.HasRetentionPolicy,
hasCompression = status.HasCompression,
hasContinuousAggregate = status.HasContinuousAggregate
});
}
private static readonly System.Text.RegularExpressions.Regex _pgIdentifier =
new(@"^[a-z_][a-z0-9_]{0,62}$", System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly HashSet<string> _allowedTables =
new(StringComparer.OrdinalIgnoreCase) { "history_table" };
/// <summary>하이퍼테이블 수동 생성</summary>
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody] HypertableCreateDto request)
{
var tableName = request.TableName ?? "history_table";
var timeColumn = request.TimeColumn ?? "recorded_at";
if (!_allowedTables.Contains(tableName))
return BadRequest(new { success = false, message = $"허용되지 않는 테이블명: {tableName}" });
if (!_pgIdentifier.IsMatch(tableName) || !_pgIdentifier.IsMatch(timeColumn))
return BadRequest(new { success = false, message = "테이블명/컬럼명은 영문 소문자, 숫자, 언더스코어만 허용됩니다." });
var createRequest = new HypertableCreateRequest
{
TableName = tableName,
TimeColumn = timeColumn,
TimeInterval = request.TimeInterval ?? "1 day",
MigrateData = request.MigrateData,
SetRetentionPolicy = request.SetRetentionPolicy,
RetentionPeriod = request.RetentionPeriod ?? "90 days",
EnableCompression = request.EnableCompression,
CompressionPeriod = request.CompressionPeriod ?? "1 day",
CreateContinuousAggregate = request.CreateContinuousAggregate
};
var result = await _dbSvc.CreateHypertableAsync(createRequest);
return result.Success
? Ok(new { result.Success, result.Message, result.TableName })
: (IActionResult)(result.TableName != null
? StatusCode(409, new { result.Success, result.Message })
: StatusCode(500, new { result.Success, result.Message }));
}
}
// ── FastTable / FastRecord ────────────────────────────────────────────────────
[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
private readonly IExperionFastService _fastSvc;
public ExperionFastController(IExperionFastService fastSvc)
=> _fastSvc = fastSvc;
/// <summary>새 fastSession 시작</summary>
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
{
try
{
var session = await _fastSvc.StartSessionAsync(request);
return Ok(new { id = session.Id, name = session.Name, status = session.Status, startedAt = session.StartedAt });
}
catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
catch (Exception ex)
{
var msgs = new List<string>();
for (var e = ex; e != null; e = e.InnerException) msgs.Add(e.Message);
return StatusCode(500, new { error = msgs[0], detail = string.Join(" → ", msgs.Skip(1)) });
}
}
/// <summary>세션 중지</summary>
[HttpPost("{id:int}/stop")]
public async Task<IActionResult> Stop(int id)
{
try
{
await _fastSvc.StopSessionAsync(id);
return Ok(new { success = true, message = "세션이 중지되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 목록 조회</summary>
[HttpGet("sessions")]
public async Task<IActionResult> GetSessions()
{
var sessions = await _fastSvc.GetSessionsAsync();
return Ok(new
{
total = sessions.Count(),
items = sessions.Select(s => new
{
id = s.Id,
name = s.Name,
status = s.Status,
samplingMs = s.SamplingMs,
durationSec = s.DurationSec,
tagCount = s.TagList.Length,
rowCount = s.RowCount,
startedAt = s.StartedAt,
endedAt = s.EndedAt,
retentionDays = s.RetentionDays,
pinned = s.Pinned
})
});
}
/// <summary>세션 상세 정보</summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> GetSession(int id)
{
var session = await _fastSvc.GetSessionAsync(id);
if (session == null) return NotFound();
return Ok(new
{
id = session.Id,
name = session.Name,
status = session.Status,
samplingMs = session.SamplingMs,
durationSec = session.DurationSec,
tagList = session.TagList,
rowCount = session.RowCount,
startedAt = session.StartedAt,
endedAt = session.EndedAt,
retentionDays = session.RetentionDays,
pinned = session.Pinned
});
}
/// <summary>레코드 조회 (Long 포맷)</summary>
[HttpGet("{id:int}/records")]
public async Task<IActionResult> GetRecords(int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string format = "long")
{
var result = await _fastSvc.GetRecordsAsync(id, from, to, format);
return Ok(new
{
sessionId = result.SessionId,
from = result.From,
to = result.To,
tagNames = result.TagNames,
total = result.TotalCount,
items = result.Items.Select(r => new
{
sessionId = r.SessionId,
recordedAt = r.RecordedAt,
tagName = r.TagName,
value = r.Value
})
});
}
/// <summary>CSV Export (스트리밍)</summary>
[HttpGet("{id:int}/csv")]
public async Task<IActionResult> ExportCsv(int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to)
{
var ms = new MemoryStream();
await _fastSvc.ExportCsvAsync(id, ms, from, to);
ms.Position = 0;
return File(ms, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv");
}
/// <summary>세션 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _fastSvc.DeleteSessionAsync(id);
return Ok(new { success = true, message = "세션이 삭제되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 고정/해제</summary>
[HttpPost("{id:int}/pin")]
public async Task<IActionResult> Pin(int id, [FromBody] PinRequest request)
{
try
{
await _fastSvc.PinSessionAsync(id, request.Pinned);
return Ok(new { success = true, pinned = request.Pinned });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
}
// ── P&ID Controller ────────────────────────────────────────────────────────────
[ApiController]
[Route("api/pid")]
public class ExperionPidController : ControllerBase
{
private readonly IPidExtractorService _extractor;
private readonly ITagMappingService _mapping;
public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
{
_extractor = extractor;
_mapping = mapping;
}
[HttpPost("extract")]
[RequestSizeLimit(100 * 1024 * 1024)]
public async Task<IActionResult> Extract(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { error = "파일 없음" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext != ".dxf" && ext != ".pdf")
return BadRequest(new { error = "지원 형식: .dxf .pdf" });
using var stream = file.OpenReadStream();
var result = await _extractor.ExtractFromStreamAsync(stream, file.FileName);
return Ok(new
{
totalCount = result.TotalCount,
confidenceItems = result.ConfidenceItems,
lowConfidenceItems = result.LowConfidenceItems
});
}
[HttpGet("equipment")]
public async Task<IActionResult> GetEquipment(
[FromQuery] string? tagNo, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var (total, items) = await _extractor.GetEquipmentAsync(tagNo, page, pageSize);
return Ok(new
{
total = total,
page = page,
pageSize = pageSize,
items = items.Select(x => new
{
id = x.Id,
tagName = x.TagNo,
equipmentName = x.EquipmentName,
instrumentType = x.InstrumentType,
lineNumber = x.LineNumber,
pidDrawingNo = x.PidDrawingNo,
confidence = x.Confidence,
isActive = x.IsActive,
extractedAt = x.ExtractedAt,
updatedAt = x.UpdatedAt,
experionTagId = x.ExperionTagId,
experionTagName = x.ExperionTag?.TagName
})
});
}
[HttpGet("statistics")]
public async Task<IActionResult> GetStatistics()
{
return Ok(new
{
total = await _extractor.GetTotalCountAsync(),
highConfidence = await _extractor.GetConfidenceItemsCountAsync(),
lowConfidence = await _extractor.GetLowConfidenceItemsCountAsync(),
drawingCount = await _extractor.GetDrawingCountAsync(),
mapped = await _mapping.GetMappedCountAsync(),
unmapped = await _mapping.GetUnmappedCountAsync(),
distribution = await _extractor.GetConfidenceDistributionAsync()
});
}
[HttpPut("{id:long}/confidence")]
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence)
{
if (confidence < 0 || confidence > 1)
return BadRequest(new { error = "신뢰도는 0~1 범위" });
await _extractor.UpdateConfidenceAsync(id, confidence);
return Ok(new { message = "업데이트 완료" });
}
[HttpPost("{id:long}/activate")]
public async Task<IActionResult> Activate(long id)
{
await _extractor.ActivateAsync(id);
return Ok(new { message = "활성화 완료" });
}
[HttpPost("{id:long}/deactivate")]
public async Task<IActionResult> Deactivate(long id)
{
await _extractor.DeactivateAsync(id);
return Ok(new { message = "비활성화 완료" });
}
[HttpGet("mappings")]
public async Task<IActionResult> GetMappings([FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var (total, items) = await _mapping.GetMappingsAsync(page, pageSize);
return Ok(new
{
total = total,
page = page,
pageSize = pageSize,
items = items.Select(x => new
{
id = x.PidEquipmentId,
tagName = x.TagNo,
equipmentName = x.EquipmentName,
confidence = x.Confidence,
experionTagId = x.ExperionTagId,
experionTagName = x.ExperionTagName
})
});
}
[HttpPost("mappings")]
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req)
{
var result = await _mapping.CreateMappingAsync(req);
return Ok(new
{
success = true,
id = result.PidEquipmentId,
tagName = result.TagNo,
equipmentName = result.EquipmentName,
confidence = result.Confidence,
experionTagId = result.ExperionTagId,
experionTagName = result.ExperionTagName
});
}
[HttpPut("mappings/{id:long}")]
public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req)
{
await _mapping.UpdateMappingAsync(id, req);
return Ok(new { message = "매핑 업데이트 완료" });
}
[HttpDelete("mappings/{id:long}")]
public async Task<IActionResult> ClearMapping(long id)
{
await _mapping.ClearMappingAsync(id);
return Ok(new { message = "매핑 해제 완료" });
}
[HttpGet("mappings/available-tags")]
public async Task<IActionResult> GetAvailableTags()
{
var tags = await _mapping.GetAvailableTagsAsync();
return Ok(new { tags = tags });
}
[HttpGet("export/csv")]
public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo)
{
var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
var csv = await _extractor.ExportToCsvAsync(items);
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
return File(bytes, "text/csv", $"pid-equipment-{DateTime.Now:yyyyMMdd}.csv");
}
[HttpGet("export/excel")]
public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo)
{
var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
var excelBytes = await _extractor.ExportToExcelAsync(items);
return File(excelBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"pid-equipment-{DateTime.Now:yyyyMMdd}.xlsx");
}
}
public record PinRequest(bool Pinned);

View File

@@ -1,155 +0,0 @@
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Infrastructure.Certificates;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using ExperionCrawler.Infrastructure.Mcp;
using ExperionCrawler.Infrastructure.OpcUa;
using ExperionCrawler.Web;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
var mvcBuilder = builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
});
// ── P&ID 컨트롤러 조건부 활성화 (기본: 비활성화) ─────────────────────────────
// PidControllers:Enabled = true 로 설정 시 P&ID 관련 컨트롤러 활성화
bool pidEnabled = builder.Configuration.GetValue<bool>("PidControllers:Enabled");
if (!pidEnabled)
{
var partManager = mvcBuilder.PartManager;
var excludedNames = new[] { "PidController", "ExperionPidController", "PidGraphController" };
var existingProvider = partManager.FeatureProviders
.OfType<Microsoft.AspNetCore.Mvc.Controllers.ControllerFeatureProvider>()
.FirstOrDefault();
if (existingProvider != null)
{
partManager.FeatureProviders.Remove(existingProvider);
partManager.FeatureProviders.Add(new ExcludedControllersFeatureProvider(existingProvider, excludedNames));
}
}
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "ExperionCrawler API", Version = "v1" }));
// ── Infrastructure ────────────────────────────────────────────────────────────
builder.Services.AddSingleton<IExperionCertificateService, ExperionCertificateService>();
builder.Services.AddSingleton<IExperionStatusCodeService, ExperionStatusCodeService>();
builder.Services.AddSingleton<IOpcUaConfigProvider, OpcUaConfigProvider>();
builder.Services.AddScoped<IExperionOpcClient, ExperionOpcClient>();
builder.Services.AddScoped<IExperionCsvService, ExperionCsvService>();
builder.Services.AddScoped<AssetLoader>();
// PostgreSQL Ubuntu 서버에서 별도 설치 없이 동작
Directory.CreateDirectory("data");
builder.Services.AddDbContext<ExperionDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<ExperionCrawlService>();
// ── KST 시간대 관리 서비스 ──────────────────────────────────────────────────
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<KstClock>();
// ── 한글 시간 범위 추출기 ──────────────────────────────────────────────────
builder.Services.AddSingleton<KoreanTimeRangeExtractor>();
// ── Text-to-SQL Service ──────────────────────────────────────────────────────
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master"],
MaxSubqueryDepth = 4
});
builder.Services.AddSingleton<SqlValidator>();
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
// ── Realtime & History BackgroundServices ─────────────────────────────────────
builder.Services.AddSingleton<ExperionRealtimeService>();
builder.Services.AddSingleton<IExperionRealtimeService>(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService<ExperionHistoryService>();
// ── MCP Service ───────────────────────────────────────────────────────────────
// Python MCP 서버 (localhost:5001)와 통신
// McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임)
builder.Services.AddSingleton<McpClient>();
builder.Services.AddSingleton<IMcpService, McpService>();
builder.Services.AddHostedService<McpServerHostedService>();
// ── OPC UA Server BackgroundService ──────────────────────────────────────────
builder.Services.AddSingleton<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(
sp => sp.GetRequiredService<ExperionOpcServerService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
// ── FastTable Service ─────────────────────────────────────────────────────────
// 중요: Singleton으로 하나만 생성 후 IExperionFastService와 IHostedService 양쪽에 같은 인스턴스 공유
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
// ── P&ID Services ───────────────────────────────────────────────────────────────
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
builder.Services.AddScoped<IPidGraphService, PidGraphService>();
builder.Services.AddDbContextFactory<ExperionDbContext>();
builder.Services.AddScoped<IStatusStore, DbStatusStore>();
builder.Services.AddSingleton<IPidGraphEventBroadcaster, PidGraphEventBroadcaster>();
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();
// ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
// ── 포트 설정 (Ubuntu 환경: 기본 5000) ───────────────────────────────────────
builder.WebHost.UseUrls("http://0.0.0.0:5000");
var app = builder.Build();
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
try
{
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.InitializeAsync();
}
}
catch (Exception ex)
{
// DB 초기화 실패 시 앱 시작 계속 — 기능 사용 시 지연 초기화
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogWarning(ex, "[DB] 초기화 실패 — DB 관련 기능 비활성화 (앱 시작 계속)");
}
// ── Middleware ────────────────────────────────────────────────────────────────
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseDefaultFiles(); // index.html
app.UseStaticFiles(); // wwwroot/
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +0,0 @@
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var baseTagList = baseTags.ToList(); // 이중 열거 방지
// ── Step 1: 모든 노드 ID 수집 ──────────────────────────────────────
var nodeMap = new Dictionary<string, (string baseTag, string attr)>();
foreach (var baseTag in baseTagList)
{
foreach (var attr in MetaAttributes)
{
var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
var fullNodeId = $"ns=1;s={nodeId}";
nodeMap[fullNodeId] = (baseTag.ToLowerInvariant(), attr);
}
}
// ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ────────────────────────
var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys);
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var result in results)
{
if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta))
{
entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId));
}
}
// ── Step 3: 단일 배치 UPSERT ──────────────────────────────────────
if (entries.Count > 0)
{
// VALUES 절을 동적으로 생성하여 한 번에 INSERT
// CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at
var valuesSql = string.Join(", ", entries.Select((e, i) =>
$"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())"));
await _ctx.Database.ExecuteSqlRawAsync(@"
WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS (
VALUES " + valuesSql + @"
)
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()",
entries.SelectMany((e, i) => new object[] {
new NpgsqlParameter($"@bt{i}", e.baseTag),
new NpgsqlParameter($"@attr{i}", e.attr),
new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value),
new NpgsqlParameter($"@nid{i}", e.nodeId)
}).ToArray());
}
// v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN)
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagList.Count);
return entries.Count;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
// baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
var tags = baseTags?.ToList() ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
return await LoadMetadataAsync(cfg, tags);
}
}

View File

@@ -1,46 +0,0 @@
P&ID 도면 분석을 고도화하여 **Graph Pipeline**을 구축하는 것은 단순한 텍스트 추출을 넘어, 설비 간의 **연결성(Connectivity)**과 **위상(Topology)**을 이해하는 것을 의미합니다.
제가 이 작업을 수행한다면, 다음과 같은 **4단계 전략**으로 접근하여 효율성을 극대화하겠습니다.
---
### 1. 데이터 추출 단계: "단순 텍스트 $\rightarrow$ 기하학적 객체"
현재의 텍스트 기반 추출에서 벗어나, 객체의 **좌표(Coordinate)**와 **속성(Property)**을 보존하는 구조로 변경해야 합니다.
* **객체 중심 파싱:** DXF의 Entity(Line, Circle, Text, Polyline)를 개별 객체로 인식하고, 각 객체의 중심점과 경계 상자(Bounding Box)를 저장합니다.
* **심볼 라이브러리 구축:** 밸브, 펌프, 탱크 등 반복되는 심볼의 기하학적 패턴을 정의하여, 텍스트가 없어도 "이 모양은 밸브다"라고 인식하는 패턴 매칭 로직을 도입합니다.
* **OCR 고도화:** PDF의 경우, 단순 텍스트 추출이 아닌 영역 기반 OCR을 통해 텍스트의 물리적 위치를 정확히 파악하여 인접한 심볼과 연결합니다.
### 2. 그래프 모델링 단계: "객체 $\rightarrow$ 노드 및 엣지"
추출된 객체들을 기반으로 **Knowledge Graph**를 생성합니다.
* **노드(Node):** 설비(Equipment), 계기(Instrument), 태그(Tag)를 노드로 정의합니다.
* **엣지(Edge):** 배관(Line)을 엣지로 정의합니다.
* **연결성 판단:** `Line`의 끝점이 `Equipment`의 경계 상자 내에 있거나 매우 근접해 있다면 두 노드를 연결된 것으로 간주합니다.
* **방향성 부여:** 화살표 심볼이나 공정 흐름(Flow)을 분석하여 엣지에 방향성을 부여합니다.
* **계층 구조 생성:** `Unit $\rightarrow$ Equipment $\rightarrow$ Component $\rightarrow$ Tag` 순의 계층적 그래프 구조를 설계합니다.
### 3. 지능형 매핑 및 검증 단계: "도면 $\rightarrow$ 실제 데이터"
그래프 구조를 활용해 Experion 시스템의 실제 태그와 정밀하게 매핑합니다.
* **맥락 기반 매핑 (Contextual Mapping):** 단순히 이름이 비슷한 태그를 찾는 것이 아니라, "펌프 P-101 옆에 있는 PT-101은 P-101의 압력 전송기일 확률이 높다"는 그래프 상의 인접성을 활용합니다.
* **상호 검증 (Cross-Validation):**
* 도면 상의 연결 관계(P-101 $\rightarrow$ V-101)와 실제 공정 데이터의 상관관계(P-101 가동 시 V-101 유량 변화)를 비교하여 매핑의 정확도를 검증합니다.
* **LLM 기반 추론:** 모호한 태그명이나 누락된 정보는 MCP 서버를 통해 LLM이 도면의 맥락과 R530 문서를 분석하여 최적의 매핑 후보를 추천하게 합니다.
### 4. 활용 및 시각화 단계: "분석 $\rightarrow$ 인사이트"
구축된 그래프를 통해 운영자에게 실질적인 가치를 제공합니다.
* **영향도 분석 (Impact Analysis):** 특정 밸브(V-101)가 고장 났을 때, 그래프 탐색(BFS/DFS)을 통해 하류(Downstream)에 영향을 받는 모든 설비와 태그를 즉시 식별합니다.
* **디지털 트윈 뷰:** P&ID 도면 위에 실시간 OPC UA 값을 오버레이하여, 도면을 보면서 현재 공정 상태를 한눈에 파악하는 인터페이스를 구현합니다.
* **이상 징후 전파 경로 추적:** 특정 태그에서 알람이 발생했을 때, 그래프를 역추적하여 근본 원인(Root Cause)이 될 가능성이 높은 상류 설비를 추천합니다.
---
### 🚀 효율적인 실행을 위한 로드맵 (Priority)
1. **Short-term (Quick Win):** DXF 파서 수정 $\rightarrow$ 객체 좌표 저장 $\rightarrow$ 단순 인접성 기반 태그-설비 매핑.
2. **Mid-term (Core):** 심볼 패턴 매칭 도입 $\rightarrow$ 배관(Line) 기반의 그래프 구조(NetworkX 등 활용) 구축.
3. **Long-term (Advanced):** LLM 기반의 도면-데이터 추론 엔진 통합 $\rightarrow$ 실시간 데이터 오버레이 UI 구현.
이렇게 **[기하학적 추출 $\rightarrow$ 위상 모델링 $\rightarrow$ 맥락적 매핑 $\rightarrow$ 운영 인사이트]** 순으로 확장하는 것이 가장 리스크가 적고 효율적인 방법이라고 생각합니다.

View File

@@ -1,131 +0,0 @@
import networkx as nx
from shapely.geometry import box, Point, LineString
import json
from typing import List, Dict, Any, Optional, Tuple
class PidTopologyBuilder:
def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None):
"""
- geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts)
- all_extracted_tags: 통합된 태그 리스트
- config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값
"""
self.data = geometric_data
self.all_tags = all_extracted_tags if all_extracted_tags else []
self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0}
self.G = nx.DiGraph() # 방향성 그래프 생성
def build_graph(self):
# 1. 모든 객체를 노드로 추가
for item in self.data:
bbox_vals = item['bbox']
# BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y)
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
self.G.add_node(item['entity_id'],
type=item['entity_type'],
bbox=bbox_geom,
value=item.get('clean_value'),
layer=item.get('layer'))
# 2. 분산 추출된 태그 통합 및 노드 추가
for tag in self.all_tags:
bbox_vals = tag['bbox']
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
self.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 3. 태그-설비 논리적 연결 (Association)
tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT']
equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
for tag in tags:
best_match = self._find_nearest_equipment(tag, equipments)
if best_match:
self.G.add_edge(tag, best_match, relation='associated_with')
# 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반]
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
for line_id in lines:
coords = self.G.nodes[line_id].get('coordinates', []) # 이 부분은 data에서 직접 가져와야 함 (아래 수정)
# GeometricEntity의 coordinates 필드 사용
# self.G.nodes[line_id]에는 bbox, type 등이 들어있으므로, 원본 data에서 coordinates를 찾아야 함
original_item = next((item for item in self.data if item['entity_id'] == line_id), None)
if not original_item or not original_item.get('coordinates'):
continue
coords = original_item['coordinates']
line_geom = LineString(coords)
endpoints = [line_geom.coords[0], line_geom.coords[-1]]
connected_nodes = []
for pt in endpoints:
p = Point(pt)
for eq_id in equipments:
if self.G.nodes[eq_id]['bbox'].distance(p) < self.config['dist_threshold']:
connected_nodes.append(eq_id)
# 중복 제거
connected_nodes = list(set(connected_nodes))
if len(connected_nodes) >= 2:
# 방향성 추론 로직 (단순화: 첫 번째 발견된 설비 -> 두 번째 발견된 설비)
# 실제로는 화살표 심볼 분석이 필요함
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe')
elif len(connected_nodes) == 1:
# 한쪽만 연결된 경우, 일단 기록 (나중에 단절 구간 분석 시 활용)
pass
def _find_nearest_equipment(self, tag_id, equipment_ids):
tag_bbox = self.G.nodes[tag_id]['bbox']
min_dist = float('inf')
nearest = None
for eq_id in equipment_ids:
eq_bbox = self.G.nodes[eq_id]['bbox']
dist = tag_bbox.distance(eq_bbox)
if dist < min_dist:
min_dist = dist
nearest = eq_id
return nearest if min_dist < self.config['tag_threshold'] else None
def validate_topology(self):
"""위상 무결성 검증"""
isolated = list(nx.isolates(self.G))
return {
"isolated_nodes": isolated,
"node_count": self.G.number_of_nodes(),
"edge_count": self.G.number_of_edges()
}
def save_graph(self, output_path: str):
"""그래프 구조를 JSON 형태로 저장 (NetworkX의 node_link_data 활용) {
"nodes": [...],
"links": [...]
}"""
from networkx.readwrite import json_graph
data = json_graph.node_link_data(self.G)
# shapely geometry 객체는 JSON 직렬화가 안 되므로 변환
for node in data['nodes']:
if 'bbox' in node:
bbox = node['bbox']
node['bbox'] = {
'min_x': bbox.bounds[0],
'min_y': bbox.bounds[1],
'max_x': bbox.bounds[2],
'max_y': bbox.bounds[3]
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
return output_path
def analyze_impact(graph, start_node):
"""특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출"""
if start_node not in graph:
return []
# BFS를 통해 도달 가능한 모든 노드 탐색
impacted_nodes = nx.descendants(graph, start_node)
return list(impacted_nodes)

View File

@@ -1,122 +0,0 @@
# 🔌 Graph Pipeline Phase 5: MCP 서버 통합 및 시스템 아키텍처 (MCP Integration)
이 문서는 앞서 설계한 1~4단계의 Graph Pipeline을 현재 프로젝트의 **Unified MCP Server (`mcp-server/server.py`)**에 통합하는 방안과 최종 프로그램 구조를 다룹니다. 이를 통해 C# 메인 서버와 LLM, 그리고 도면 분석 엔진이 하나의 생태계에서 유기적으로 동작하게 합니다.
---
## 🏗️ 1. 통합 아키텍처 설계
### 1.1 전체 데이터 흐름 (End-to-End Flow)
`Frontend (UI)` $\rightarrow$ `C# Server (API)` $\rightarrow$ `MCP Server (Python)` $\rightarrow$ `Graph Pipeline Engine` $\rightarrow$ `Experion DB/OPC UA`
1. **요청:** 사용자가 UI에서 "P-101 펌프의 영향도 분석" 요청.
2. **중계:** C# 서버가 `McpClient`를 통해 MCP 서버의 `analyze_pid_impact` 툴 호출.
3. **분석:** MCP 서버는 내부적으로 `NetworkX` 그래프를 로드하여 하류 노드를 계산.
4. **응답:** 분석 결과(노드 리스트)를 JSON으로 반환 $\rightarrow$ C# 서버 $\rightarrow$ UI 하이라이트.
### 1.2 MCP 서버 내 역할 분담
현재 `server.py`는 RAG, NL2SQL, 단순 태그 추출 기능을 가지고 있습니다. 여기에 **Graph Pipeline 전용 도구 세트**를 추가합니다.
| 기존 기능 | 추가될 Graph Pipeline 기능 | 역할 |
|---|---|---|
| `parse_pid_dxf` | `build_pid_graph` | DXF $\rightarrow$ 기하 추출 $\rightarrow$ 위상 그래프 생성 및 저장 |
| `match_pid_tags` | `resolve_graph_tags` | 그래프 맥락을 반영한 지능형 태그 매핑 |
| (신규) | `analyze_pid_impact` | 특정 노드 기준 영향도 분석 (Downstream 탐색) |
| (신규) | `get_graph_topology` | 시각화를 위한 노드-엣지 리스트 반환 |
---
## 💻 2. MCP 서버 통합 구현 가이드
### 2.1 MCP Tool 캡슐화 설계
`mcp-server/server.py`에 다음과 같은 형태로 툴을 추가합니다.
```python
# mcp-server/server.py 에 추가될 내용 (개념 코드)
@mcp.tool()
def build_pid_graph(filepath: str) -> str:
"""
P&ID 도면을 분석하여 위상 그래프를 생성하고 저장합니다.
Phase 1(기하 추출) + Phase 2(위상 모델링) 통합 실행.
"""
# 1. Phase 1: Geometric Extraction
extractor = PidGeometricExtractor(filepath)
geo_data = extractor.extract_all()
# 2. Phase 2: Topology Modeling
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 3. 그래프 저장 (GraphML 또는 JSON)
graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json")
nx.write_graphml(builder.G, f"storage/{graph_id}")
return json.dumps({"success": True, "graph_id": graph_id, "nodes": builder.G.number_of_nodes()})
@mcp.tool()
def analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
"""
특정 설비의 장애 시 영향을 받는 하류 설비 리스트를 반환합니다.
"""
# 그래프 로드
G = nx.read_graphml(f"storage/{graph_id}")
# 영향도 분석 (Phase 4 로직)
impacted = nx.descendants(G, start_node_id)
return json.dumps({
"success": True,
"start_node": start_node_id,
"impacted_nodes": list(impacted)
})
```
### 2.2 C# 서버와의 인터페이스 (`McpClient` 활용)
C# 서버는 `src/Infrastructure/Mcp/McpClient.cs`를 통해 위 툴들을 호출합니다.
```csharp
// src/Core/Application/Services/PidGraphService.cs (신규 서비스)
public async Task<ImpactResult> GetImpactAnalysisAsync(string graphId, string nodeId)
{
var request = new McpToolRequest {
ToolName = "analyze_pid_impact",
Arguments = new { graph_id = graphId, start_node_id = nodeId }
};
var jsonResponse = await _mcpClient.CallToolAsync(request);
return JsonSerializer.Deserialize<ImpactResult>(jsonResponse);
}
```
---
## 🛠️ 3. 프로그램 구성 및 배포 전략
### 3.1 디렉토리 구조 확장
```text
mcp-server/
├── server.py # MCP 메인 서버 (툴 정의)
├── pipeline/ # Graph Pipeline 핵심 로직 (Phase 1~4)
│ ├── __init__.py
│ ├── extractor.py # Phase 1: Geometric Extraction
│ ├── topology.py # Phase 2: Topology Modeling
│ ├── mapper.py # Phase 3: Intelligent Mapping
│ └── analyzer.py # Phase 4: Impact Analysis
└── storage/ # 생성된 그래프 파일 (.graphml) 저장소
```
### 3.2 실행 프로세스
1. **MCP 서버 기동:** `python mcp-server/server.py --http` (포트 5001)
2. **C# 서버 기동:** `dotnet run` (포트 5000)
3. **통신:** C# 서버 $\xrightarrow{HTTP/JSON}$ MCP 서버 $\xrightarrow{Python\ Libs}$ 결과 반환.
---
## 🚀 4. 최종 완료 기준 (Definition of Done)
- [ ] `mcp-server/server.py``build_pid_graph`, `analyze_pid_impact` 등 핵심 툴이 정의되었는가?
- [ ] Phase 1~4의 Python 로직이 `mcp-server/pipeline/` 모듈로 구조화되어 통합되었는가?
- [ ] C# `McpClient`를 통해 MCP 서버의 그래프 분석 툴을 호출하고 결과를 수신할 수 있는가?
- [ ] 도면 업로드 $\rightarrow$ 그래프 생성 $\rightarrow$ 태그 매핑 $\rightarrow$ 영향도 분석으로 이어지는 **End-to-End 파이프라인**이 완성되었는가?
- [ ] 모든 과정이 `json_response=True``stateless_http=True` 설정 하에 안정적으로 동작하는가?

File diff suppressed because it is too large Load Diff

View File

@@ -1,372 +0,0 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
base_tag TEXT - 기본 태그명
pv TEXT - 현재 프로세스 값
sp TEXT - 설정값
op TEXT - 출력값
instate0 TEXT - 상태 비트 0 (true/false)
instate1 TEXT - 상태 비트 1 (true/false)
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double)
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
예시 (2분 간격, 여러 태그):
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket,
tagname, AVG(value::double precision) AS avg_val
FROM history_table
WHERE tagname IN ('tag1', 'tag2')
AND recorded_at >= NOW() - INTERVAL '3 hours'
GROUP BY bucket, tagname ORDER BY bucket, tagname
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
system = (
"You are a PostgreSQL SQL expert.\n"
"Convert the user's question into a SELECT SQL using the schema below.\n"
"IMPORTANT rules:\n"
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
"- INTERVAL rule:\n"
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n"
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model="Qwen3.6-27B-FP8",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_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
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -1,131 +0,0 @@
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var baseTagSet = baseTags.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (baseTagSet.Count == 0) return 0;
// ── Step 1: node_map_master에서 실제 node_id 조회 ──────────────────
// hostname을 직접 조성하지 않고 DB에 저장된 원본 node_id를 사용
var masterNodes = await _ctx.NodeMapMasters
.Where(n => MetaAttributes.Contains(n.Name))
.Select(n => new { n.NodeId, n.Name })
.ToListAsync();
var nodeMap = new Dictionary<string, (string baseTag, string attr)>();
foreach (var node in masterNodes)
{
var baseTag = ExtractBaseTag(node.NodeId);
if (baseTagSet.Contains(baseTag))
nodeMap[node.NodeId] = (baseTag, node.Name);
}
// ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ────────────────────────
var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys);
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var result in results)
{
if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta))
{
entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId));
}
}
// ── Step 3: 단일 배치 UPSERT ──────────────────────────────────────
if (entries.Count > 0)
{
// VALUES 절을 동적으로 생성하여 한 번에 INSERT
// CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at
var valuesSql = string.Join(", ", entries.Select((e, i) =>
$"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())"));
await _ctx.Database.ExecuteSqlRawAsync(@"
WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS (
VALUES " + valuesSql + @"
)
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()",
entries.SelectMany((e, i) => new object[] {
new NpgsqlParameter($"@bt{i}", e.baseTag),
new NpgsqlParameter($"@attr{i}", e.attr),
new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value),
new NpgsqlParameter($"@nid{i}", e.nodeId)
}).ToArray());
}
// v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN)
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagSet.Count);
return entries.Count;
}
// "ns=1;s=sinamserver:FIC101.desc" → "FIC101"
private static string ExtractBaseTag(string nodeId)
{
var colon = nodeId.LastIndexOf(':');
if (colon < 0) return nodeId;
var afterColon = nodeId[(colon + 1)..];
var dot = afterColon.IndexOf('.');
return dot > 0 ? afterColon[..dot] : afterColon;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
List<string> tags;
if (baseTags != null)
{
tags = baseTags.ToList();
}
else
{
// realtime_table에 등록된 포인트의 tagname에서 base_tag 추출
// tagname 형식: "FIC101.pv" → base_tag: "FIC101" (첫 번째 '.' 앞)
var tagnames = await _ctx.RealtimePoints.Select(p => p.TagName).Distinct().ToListAsync();
tags = tagnames
.Select(tn => { var d = tn.IndexOf('.'); return d > 0 ? tn[..d] : tn; })
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (tags.Count == 0)
_logger.LogWarning("[Metadata] realtime_table이 비어있습니다. 메타데이터 로드 대상 없음.");
}
return await LoadMetadataAsync(cfg, tags);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,364 +0,0 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Infrastructure.Mcp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ExperionCrawler.Web.Controllers;
/// <summary>
/// Text-to-SQL API 컨트롤러
/// 자연어 질의를 파싱하고 시계열 데이터를 조회합니다.
/// MCP (Model Context Protocol) 통합을 위한 엔드포인트를 제공합니다.
/// </summary>
[ApiController]
[Route("api/text-to-sql")]
public class TextToSqlController : ControllerBase
{
private readonly ITextToSqlService _textToSqlService;
private readonly IExperionDbService _dbService;
private readonly IMcpService _mcpService;
private readonly ILogger<TextToSqlController> _logger;
public TextToSqlController(
ITextToSqlService textToSqlService,
IExperionDbService dbService,
IMcpService mcpService,
ILogger<TextToSqlController> logger)
{
_textToSqlService = textToSqlService;
_dbService = dbService;
_mcpService = mcpService;
_logger = logger;
}
/// <summary>
/// 자연어 질의를 SQL로 변환
/// </summary>
[HttpPost("parse")]
public async Task<IActionResult> Parse([FromBody] NaturalLanguageQueryDto dto)
{
try
{
var sql = await _textToSqlService.ParseNaturalLanguageAsync(dto.Query);
return Ok(new { success = true, sql });
}
catch (Exception ex)
{
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP query_with_nl 도구 호출 - 자연어 → LLM SQL 생성 → 실행
/// </summary>
[HttpPost("query-nl")]
public async Task<IActionResult> QueryWithNl([FromBody] NaturalLanguageQueryDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Query))
return BadRequest(new { success = false, error = "질문이 비어있음" });
try
{
var result = await _mcpService.QueryWithNlAsync(dto.Query);
if (!result.Success)
return Ok(new { success = false, error = result.Error });
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
return Ok(new { success = true, data = jsonData });
}
catch
{
return Ok(new { success = true, data = result.Data });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] query-nl 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP 도구 목록 조회
/// </summary>
[HttpGet("tools")]
public async Task<IActionResult> ListTools()
{
try
{
var tools = await _mcpService.ListToolsAsync();
return Ok(new { success = true, tools });
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 도구 목록 조회 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP run_sql 도구 호출 - SQL 실행
/// Text-to-SQL 엔진으로 생성된 SQL을 안전하게 실행
/// </summary>
[HttpPost("execute-mcp")]
public async Task<IActionResult> ExecuteFromMcp([FromBody] SqlQueryDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Sql))
{
return BadRequest(new { success = false, error = "SQL이 비어있음" });
}
try
{
// MCP run_sql 도구 호출
var result = await _mcpService.RunSqlAsync(dto.Sql);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
// JSON 결과 반환 (쿼리 결과)
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new { success = true, data = jsonData });
}
catch
{
return Ok(new { success = true, data = result.Data });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] MCP 실행 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP query_pv_history 도구 호출 - 과거 값 히스토리 조회
/// </summary>
[HttpPost("query-history")]
public async Task<IActionResult> QueryHistory([FromBody] HistoryQueryRequestDto dto)
{
try
{
var tagNames = dto.TagNames ?? [];
var timeFrom = dto.From ?? DateTime.UtcNow.AddDays(-1).ToString("o");
var timeTo = dto.To ?? DateTime.UtcNow.ToString("o");
var limit = dto.Limit ?? 100;
var result = await _mcpService.QueryPvHistoryAsync(
tagNames,
timeFrom,
timeTo,
limit
);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new
{
success = true,
data = jsonData
});
}
catch
{
return Ok(new
{
success = true,
data = result.Data
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] History 쿼리 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP get_tag_metadata 도구 호출 - 태그 메타데이터 검색
/// </summary>
[HttpGet("tags/search")]
public async Task<IActionResult> SearchTags([FromQuery] string query, [FromQuery] int? limit)
{
try
{
var tagLimit = limit ?? 10;
var result = await _mcpService.GetTagMetadataAsync(query, tagLimit);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new
{
success = true,
data = jsonData
});
}
catch
{
return Ok(new
{
success = true,
data = result.Data
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 태그 검색 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP list_drawings 도구 호출 - 도면 목록 조회
/// </summary>
[HttpGet("drawings")]
public async Task<IActionResult> ListDrawings([FromQuery] string? unitNo)
{
try
{
var result = await _mcpService.ListDrawingsAsync(unitNo);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new
{
success = true,
data = jsonData
});
}
catch
{
return Ok(new
{
success = true,
data = result.Data
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 도면 목록 조회 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// 쿼리 제안 (자동 완성)
/// </summary>
[HttpGet("suggest")]
public async Task<IActionResult> Suggest([FromQuery] string input = "")
{
var suggestions = await _textToSqlService.SuggestQueriesAsync(input);
return Ok(new { success = true, suggestions });
}
/// <summary>
/// 시계열 분석 (평균, 최대, 최소, 추세)
/// </summary>
[HttpPost("analyze")]
public async Task<IActionResult> Analyze([FromBody] AnalyzeRequestDto dto)
{
var result = await _textToSqlService.AnalyzeAsync(dto);
return Ok(new {
success = result.Success,
error = result.Error,
tags = result.Tags?.Select(t => new {
tagName = t.TagName,
avg = t.Avg,
mean = t.Mean,
min = t.Min,
max = t.Max,
first = t.First,
last = t.Last,
pointCount = t.PointCount,
stddev = t.StdDev,
from = t.From,
to = t.To
}).ToList()
});
}
/// <summary>
/// 사용자 지정 간격으로 history 이력 조회
/// history_table의 기본 저장 간격(60초)을 기반으로 사용자가 요청한 간격으로 데이터 집계
/// </summary>
[HttpPost("query-history-interval")]
public async Task<IActionResult> QueryHistoryInterval([FromBody] HistoryIntervalQueryRequestDto dto)
{
try
{
var request = new HistoryIntervalQueryRequest(
dto.TagNames,
dto.From,
dto.To,
dto.Interval,
dto.Limit);
var result = await _dbService.QueryHistoryWithIntervalAsync(request);
var response = new
{
success = true,
tagNames = result.TagNames.ToList(),
rows = result.Rows.Select(r => new
{
timeBucket = r.TimeBucket,
values = r.Values
}).ToList(),
baseIntervalSeconds = result.BaseIntervalSeconds,
queryInterval = result.QueryInterval
};
return Ok(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] QueryHistoryInterval 실패");
return StatusCode(StatusCodes.Status500InternalServerError, new { success = false, error = ex.Message });
}
}
}

View File

@@ -1,906 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>ExperionCrawler</title>
<link rel="stylesheet" href="/css/style.css"/>
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
</head>
<body>
<div class="shell">
<!-- ── Sidebar ───────────────────────────────────────────── -->
<nav class="sidebar">
<div class="brand">
<svg class="brand-icon" viewBox="0 0 40 40" fill="none">
<rect x="4" y="4" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<rect x="22" y="4" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<rect x="4" y="22" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<rect x="22" y="22" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<circle cx="11" cy="11" r="3" fill="currentColor" opacity=".6"/>
<circle cx="29" cy="11" r="3" fill="currentColor" opacity=".6"/>
<circle cx="11" cy="29" r="3" fill="currentColor" opacity=".6"/>
<circle cx="29" cy="29" r="3" fill="currentColor" opacity="1"/>
</svg>
<div>
<div class="brand-name">EXPERION</div>
<div class="brand-sub">CRAWLER v1.0</div>
</div>
</div>
<ul class="nav">
<li class="nav-item active" data-tab="cert">
<span class="ni">01</span>
<span class="nl">인증서 관리</span>
<span class="nb" id="cert-dot"></span>
</li>
<li class="nav-item" data-tab="conn">
<span class="ni">02</span>
<span class="nl">서버 접속 테스트</span>
</li>
<li class="nav-item" data-tab="crawl">
<span class="ni">03</span>
<span class="nl">데이터 크롤링</span>
</li>
<li class="nav-item" data-tab="db">
<span class="ni">04</span>
<span class="nl">DB 저장</span>
</li>
<li class="nav-item" data-tab="nm-dash">
<span class="ni">05</span>
<span class="nl">노드맵 대시보드</span>
</li>
<li class="nav-item" data-tab="pb">
<span class="ni">06</span>
<span class="nl">포인트빌더</span>
</li>
<li class="nav-item" data-tab="hist">
<span class="ni">07</span>
<span class="nl">이력 조회</span>
</li>
<li class="nav-item" data-tab="opcsvr">
<span class="ni">08</span>
<span class="nl">OPC UA 서버</span>
<span class="nb" id="opcsvr-dot"></span>
</li>
<li class="nav-item" data-tab="t2s">
<span class="ni">09</span>
<span class="nl">Text-to-SQL</span>
</li>
<li class="nav-item" data-tab="fast">
<span class="ni">10</span>
<span class="nl">fastRecord</span>
</li>
</ul>
<div class="sb-foot">
<span class="dot" id="g-dot"></span>
<span id="g-txt" class="mono">READY</span>
</div>
</nav>
<!-- ── Main ──────────────────────────────────────────────── -->
<main class="content">
<!-- ══════════════════════════════════════════════════════
01 인증서 관리
═══════════════════════════════════════════════════════ -->
<section class="pane active" id="pane-cert">
<header class="pane-hdr">
<div>
<h1>인증서 관리</h1>
<p>OPC UA 클라이언트 인증서를 생성합니다. 기존 파일이 있으면 재사용됩니다.</p>
</div>
<div class="pane-tag">PKI / X.509</div>
</header>
<div class="cols-2">
<div class="card">
<div class="card-cap">인증서 생성</div>
<div class="fg">
<label>Client Hostname</label>
<input id="c-host" class="inp" value="dbsvr"/>
</div>
<div class="fg">
<label>Subject Alt Names <em>(쉼표 구분)</em></label>
<input id="c-san" class="inp" value="localhost,192.168.0.50"/>
</div>
<div class="fg">
<label>PFX Password <em>(없으면 비워 두세요)</em></label>
<input id="c-pw" class="inp" type="password" placeholder=""/>
</div>
<button class="btn-a" onclick="certCreate()">🔑 인증서 생성</button>
</div>
<div class="card">
<div class="card-cap">현재 인증서 상태</div>
<button class="btn-b" onclick="certStatus()" style="margin-bottom:14px">상태 확인</button>
<div id="cert-disp" class="kv-box">
<span class="placeholder">상태 확인 버튼을 눌러 주세요</span>
</div>
</div>
</div>
<div id="cert-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
02 서버 접속 테스트
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-conn">
<header class="pane-hdr">
<div>
<h1>서버 접속 테스트</h1>
<p>Experion OPC UA 서버에 연결하고 노드 값을 읽습니다.</p>
</div>
<div class="pane-tag">OPC UA / TCP</div>
</header>
<div class="card" style="margin-bottom:18px">
<div class="card-cap">서버 설정</div>
<div class="cols-3">
<div class="fg"><label>Server IP</label>
<input id="x-server" class="inp" value="192.168.0.20"/></div>
<div class="fg"><label>Port</label>
<input id="x-port" class="inp" type="number" value="4840"/></div>
<div class="fg"><label>Client Hostname</label>
<input id="x-client" class="inp" value="dbsvr"/></div>
<div class="fg"><label>Username</label>
<input id="x-user" class="inp" value="mngr"/></div>
<div class="fg"><label>Password</label>
<input id="x-pass" class="inp" type="password" value="mngr"/></div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="connTest()">🔌 접속 테스트</button>
<button class="btn-b" onclick="connBrowse()">🌲 노드 탐색</button>
</div>
</div>
<div class="card">
<div class="card-cap">단일 태그 읽기</div>
<div class="row-inp">
<input id="x-node" class="inp flex1"
value="ns=1;s=sinamserver:p-6102.hzset.fieldvalue"
placeholder="ns=1;s=..."/>
<button class="btn-b" onclick="connRead()">읽기</button>
</div>
<div id="tag-box" class="tag-box hidden"></div>
</div>
<div id="conn-log" class="logbox hidden"></div>
<div id="browse-wrap" class="bwrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
03 데이터 크롤링
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-crawl">
<header class="pane-hdr">
<div>
<h1>데이터 크롤링</h1>
<p>지정한 노드 값을 주기적으로 수집하여 CSV 파일로 저장합니다.</p>
</div>
<div class="pane-tag">CRAWL / CSV</div>
</header>
<div class="cols-2">
<div class="card">
<div class="card-cap">서버 설정</div>
<div class="fg"><label>Server IP</label>
<input id="w-server" class="inp" value="192.168.0.20"/></div>
<div class="fg"><label>Port</label>
<input id="w-port" class="inp" type="number" value="4840"/></div>
<div class="fg"><label>Client Hostname</label>
<input id="w-client" class="inp" value="dbsvr"/></div>
<div class="fg"><label>Username</label>
<input id="w-user" class="inp" value="mngr"/></div>
<div class="fg"><label>Password</label>
<input id="w-pass" class="inp" type="password" value="mngr"/></div>
<div class="fg"><label>수집 간격 (초)</label>
<input id="w-interval" class="inp" type="number" value="1" min="1"/></div>
<div class="fg"><label>수집 시간 (초)</label>
<input id="w-duration" class="inp" type="number" value="30" min="1"/></div>
</div>
<div class="card">
<div class="card-cap">수집 노드 목록 <em>(한 줄에 하나씩)</em></div>
<textarea id="w-nodes" class="ta" rows="9"
placeholder="ns=1;s=...">ns=1;s=sinamserver:p-6102.hzset.fieldvalue</textarea>
<button class="btn-a" id="crawl-btn" onclick="crawlStart()"
style="margin-top:14px">📡 크롤링 시작</button>
</div>
</div>
<div id="crawl-prog" class="prog-wrap hidden">
<div class="prog-hdr">
<span id="crawl-ptxt">수집 중...</span>
<span id="crawl-cnt" class="mono">0</span>
</div>
<div class="prog-track"><div id="crawl-bar" class="prog-fill" style="width:0%"></div></div>
</div>
<div id="crawl-log" class="logbox hidden"></div>
<!-- ── 노드맵 수집 ──────────────────────────────────────── -->
<div class="section-div"></div>
<header class="pane-hdr" style="margin-bottom:16px">
<div>
<h2 class="sub-hdr">노드맵 수집</h2>
<p>서버 전체 노드를 재귀 탐색하여 AssetLoader 용 CSV 파일로 저장합니다.</p>
</div>
<div class="pane-tag">NODE MAP / CSV</div>
</header>
<div class="card">
<div class="card-cap">전체 노드 탐색 설정</div>
<div class="nm-row">
<div class="fg" style="margin-bottom:0;width:200px">
<label>최대 탐색 깊이</label>
<input id="nm-depth" class="inp" type="number" value="10" min="1" max="20"/>
</div>
<button class="btn-a" id="nm-btn" onclick="nodeMapCrawl()">🗺 전체 노드맵 수집</button>
</div>
<p class="nm-hint">
서버 설정은 위 크롤링 설정을 그대로 사용합니다 &nbsp;·&nbsp;
노드 수에 따라 수 분이 소요될 수 있습니다 &nbsp;·&nbsp;
결과는 <code>data/csv/{서버명}_*.csv</code> 에 저장됩니다
</p>
</div>
<div id="nm-prog" class="prog-wrap hidden">
<div class="prog-hdr">
<span id="nm-ptxt">탐색 중...</span>
<span id="nm-cnt" class="mono"></span>
</div>
<div class="prog-track"><div id="nm-bar" class="prog-fill" style="width:0%"></div></div>
</div>
<div id="nm-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
04 DB 저장
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-db">
<header class="pane-hdr">
<div>
<h1>DB 저장</h1>
<p>수집된 CSV 파일을 PostgreSQL DB에 저장하고 레코드를 조회합니다.</p>
</div>
<div class="pane-tag">PostgreSQL / EF</div>
</header>
<div class="cols-2">
<div class="card">
<div class="card-cap">CSV → DB 임포트</div>
<button class="btn-b" onclick="dbLoadFiles()" style="margin-bottom:10px">
🔄 파일 목록 갱신
</button>
<div id="file-list" class="flist">
<span class="placeholder">갱신 버튼을 눌러 주세요</span>
</div>
<div class="fg" style="margin-top:12px">
<label>선택된 파일</label>
<input id="sel-csv" class="inp" readonly placeholder="위 목록에서 파일을 선택하세요"/>
</div>
<div class="fg">
<label>저장 방식</label>
<div class="mode-group">
<label class="mode-opt">
<input type="radio" name="import-mode" value="append" checked/>
<span>추가 저장</span>
</label>
<label class="mode-opt mode-opt-danger">
<input type="radio" name="import-mode" value="truncate"/>
<span>초기화 후 저장</span>
</label>
</div>
</div>
<button class="btn-a" onclick="dbImport()">💾 DB에 저장</button>
</div>
<div class="card">
<div class="card-cap">DB 레코드 조회</div>
<div class="row-inp" style="margin-bottom:12px">
<input id="db-limit" class="inp" type="number" value="100"
min="1" max="10000" style="width:110px"/>
<button class="btn-b" onclick="dbQuery()">조회</button>
</div>
<div id="db-stats" class="stats hidden"></div>
</div>
</div>
<div id="db-log" class="logbox hidden"></div>
<div id="db-table" class="tbl-wrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
05 노드맵 대시보드
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-nm-dash">
<header class="pane-hdr">
<div>
<h1>노드맵 대시보드</h1>
<p>node_map_master 테이블을 조회합니다.</p>
</div>
<div class="pane-tag">NODE MAP / MASTER</div>
</header>
<!-- 필터 카드 -->
<div class="card">
<div class="card-cap">필터 조건</div>
<div class="cols-3">
<div class="fg">
<label>Level 최소</label>
<input id="nf-lv-min" class="inp" type="number" min="0" placeholder="0"/>
</div>
<div class="fg">
<label>Level 최대</label>
<input id="nf-lv-max" class="inp" type="number" min="0" placeholder=""/>
</div>
<div class="fg">
<label>클래스</label>
<select id="nf-class" class="inp">
<option value="">전체</option>
<option value="Object">Object</option>
<option value="Variable">Variable</option>
</select>
</div>
<div class="fg">
<label>Node ID 검색</label>
<input id="nf-nid" class="inp" placeholder="포함 검색"/>
</div>
<div class="fg">
<label>데이터 타입 <em>(직접 입력)</em></label>
<input id="nf-dtype" class="inp" placeholder="예: Double, Int32"/>
</div>
</div>
<!-- 이름 OR 조건 선택 (최대 4개) — 불러오기 버튼으로 옵션 채움 -->
<div class="fg nm-name-row">
<label style="display:flex;align-items:center;gap:8px">
이름 선택 <em>(OR 조건, 최대 4개)</em>
<button class="btn-b btn-sm" onclick="nmLoadNames()" style="margin-left:4px">▼ 옵션 불러오기</button>
</label>
<div class="nm-name-selects">
<select id="nf-name-1" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
<select id="nf-name-2" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
<select id="nf-name-3" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
<select id="nf-name-4" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
</div>
</div>
<div class="btn-row" style="align-items:center">
<button class="btn-a" onclick="nmQuery(0)">🔍 조회</button>
<button class="btn-b" onclick="nmReset()">초기화</button>
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
<label style="font-size:11px;color:var(--t2);white-space:nowrap">페이지당</label>
<input id="nf-limit" class="inp" type="number" value="100" min="10" max="500" style="width:80px"/>
<label style="font-size:11px;color:var(--t2)"></label>
</div>
</div>
</div>
<!-- 결과 통계 + 페이지네이션 -->
<div id="nm-result-bar" class="nm-result-bar hidden">
<span id="nm-result-info" class="nm-result-info"></span>
<div class="pg">
<button class="btn-b btn-sm" id="nm-pg-prev" onclick="nmPrev()">← 이전</button>
<span id="nm-pg-info" class="pg-info"></span>
<button class="btn-b btn-sm" id="nm-pg-next" onclick="nmNext()">다음 →</button>
</div>
</div>
<!-- 테이블 -->
<div id="nm-table" class="tbl-wrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
06 포인트빌더
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-pb">
<header class="pane-hdr">
<div>
<h1>포인트빌더</h1>
<p>node_map_master 에서 실시간 모니터링할 포인트를 선택해 realtime_table 을 구성합니다.</p>
</div>
<div class="pane-tag">REALTIME / BUILD</div>
</header>
<!-- 빌더 카드 -->
<div class="cols-2">
<div class="card">
<div class="card-cap">조건으로 테이블 작성</div>
<div class="fg">
<label>이름(name) 선택 <em>(OR 조건, 최대 8개)</em>
<button class="btn-b btn-sm" onclick="pbLoad()" style="margin-left:4px">▼ 옵션 불러오기</button>
</label>
<div class="pb-name-grid" id="pb-name-grid">
<!-- JS 에서 드롭다운 동적 생성 -->
<select id="pb-n1" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n2" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n3" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n4" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n5" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n6" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n7" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n8" class="inp"><option value="">— 선택 안 함 —</option></select>
</div>
</div>
<div class="fg">
<label>데이터 타입(data_type) 직접 입력 <em>(OR 조건, 최대 2개)</em></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<input id="pb-dt1" class="inp" placeholder="예: Double"/>
<input id="pb-dt2" class="inp" placeholder="예: Int32"/>
</div>
</div>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<div id="pb-build-log" class="logbox hidden" style="margin-top:10px"></div>
</div>
<div class="card">
<div class="card-cap">수동 포인트 추가</div>
<div class="fg">
<label>Node ID 직접 입력</label>
<input id="pb-manual-nid" class="inp" placeholder="ns=1;s=tagname.pv..."/>
</div>
<button class="btn-b" onclick="pbAddManual()"> 추가</button>
<div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div>
<div class="card-cap" style="margin-top:20px">실시간 구독 제어</div>
<div class="cols-2" style="gap:8px;margin-bottom:10px">
<div class="fg">
<label>서버 IP</label>
<input id="pb-rt-ip" class="inp" value="192.168.0.20"/>
</div>
<div class="fg">
<label>포트</label>
<input id="pb-rt-port" class="inp" type="number" value="4840"/>
</div>
<div class="fg">
<label>클라이언트 호스트</label>
<input id="pb-rt-client" class="inp" value="dbsvr"/>
</div>
<div class="fg">
<label>계정</label>
<input id="pb-rt-user" class="inp" value="mngr"/>
</div>
<div class="fg" style="grid-column:1/-1">
<label>비밀번호</label>
<input id="pb-rt-pw" class="inp" type="password" value="mngr"/>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="rtStart()">▶ 구독 시작</button>
<button class="btn-b" onclick="rtStop()">■ 구독 중지</button>
<button class="btn-b btn-sm" onclick="rtStatus()">상태 확인</button>
</div>
<div id="pb-rt-status" class="logbox hidden" style="margin-top:8px"></div>
</div>
</div>
<!-- 포인트 목록 -->
<div class="card" style="margin-top:0">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>포인트 목록 <span id="pb-count" class="mut">(0개)</span></span>
<button class="btn-b btn-sm" onclick="pbRefresh()">↻ 새로 고침</button>
</div>
<div id="pb-table" class="tbl-wrap">
<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════
07 이력 조회
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-hist">
<header class="pane-hdr">
<div>
<h1>이력 조회</h1>
<p>history_table 의 시계열 데이터를 조회합니다.</p>
</div>
<div class="pane-tag">HISTORY / TREND</div>
</header>
<div class="card">
<div class="card-cap">조회 조건</div>
<div class="fg">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>태그 선택 <em>(최대 8개, OR 조건)</em></span>
<button class="btn-b btn-sm" onclick="histLoad()">▼ 옵션 불러오기</button>
<span id="hist-load-status" class="hist-status">대기 중<span class="status-dot"></span></span>
</div>
<div class="pb-name-grid">
<select id="hf-t1" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t2" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t3" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t4" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t5" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t6" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t7" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t8" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
</div>
</div>
<div class="cols-4">
<div class="fg">
<label>시작 시간</label>
<input type="hidden" id="hf-from"/>
<div class="dt-display inp" id="dtp-from-display" onclick="dtOpen('from')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>종료 시간</label>
<input type="hidden" id="hf-to"/>
<div class="dt-display inp" id="dtp-to-display" onclick="dtOpen('to')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>조회 간격</label>
<select id="hf-interval" class="inp">
<option value="1 minute">원시 데이터 (기본)</option>
<option value="5 minutes">5분 집계</option>
<option value="10 minutes">10분 집계</option>
<option value="30 minutes">30분 집계</option>
<option value="1 hour">1시간 집계</option>
<option value="1 day">1일 집계</option>
</select>
</div>
<div class="fg">
<label>최대 행 수</label>
<input id="hf-limit" class="inp" type="number" value="500" min="10" max="5000"/>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="histQuery()">🔍 조회</button>
<button class="btn-b" onclick="histReset()">초기화</button>
</div>
</div>
<!-- 하이퍼테이블 관리 -->
<div class="card" id="ht-manage-card">
<div class="card-cap">하이퍼테이블 관리</div>
<div class="fg">
<label>history_table이 현재 하이퍼테이블 상태입니다. 아래 옵션을 설정하여 수동으로 생성할 수 있습니다.</label>
</div>
<div class="fg">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-retention" onchange="htToggleRetention()"/>
보관 기간 설정
</label>
<div id="ht-retention-panel" class="ht-hidden" style="margin-top:8px;padding-left:20px">
<div class="cols-2">
<div>
<label>보관 기간</label>
<input id="ht-retention-period" class="inp" type="text" value="90 days" placeholder="예: 90 days"/>
</div>
<div>
<label>테이블명</label>
<input id="ht-table-name" class="inp" type="text" value="history_table" placeholder="테이블명"/>
</div>
</div>
</div>
</div>
<div class="fg" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-compression" onchange="htToggleCompression()"/>
압축 활성화
</label>
<div id="ht-compression-panel" class="ht-hidden" style="margin-top:8px;padding-left:20px">
<div>
<label>압축 구간</label>
<input id="ht-compression-period" class="inp" type="text" value="1 day" placeholder="예: 1 day"/>
</div>
</div>
</div>
<div class="fg" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-aggregate"/>
연속 집계 생성 (선택사항)
</label>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn-a" onclick="htCreate()">🔧 하이퍼테이블 생성</button>
<button class="btn-b" onclick="htLoadStatus()">🔄 상태 새로고침</button>
</div>
</div>
<!-- 하이퍼테이블 상태 표시 -->
<div id="ht-status-box" class="ht-status-box hidden">
<div class="ht-status-header">
<span class="ht-status-icon" id="ht-status-icon"></span>
<span class="ht-status-text" id="ht-status-text">로딩 중</span>
</div>
<div class="ht-status-detail" id="ht-status-detail"></div>
<div class="ht-info-panel" id="ht-info-panel">
<div class="ht-info-grid">
<div class="ht-info-item">
<span class="ht-info-label">테이블명</span>
<span class="ht-info-value" id="ht-info-table">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">레코드 수</span>
<span class="ht-info-value" id="ht-info-records">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">보관 정책</span>
<span class="ht-info-value" id="ht-info-retention">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">압축</span>
<span class="ht-info-value" id="ht-info-compression">-</span>
</div>
</div>
</div>
</div>
<!-- 상태 표시 창 -->
<div id="hist-status-box" class="hist-status-box hidden">
<div class="hist-status-header">
<span class="hist-status-icon" id="hist-status-icon"></span>
<span class="hist-status-text" id="hist-status-text">대기 중</span>
</div>
<div class="hist-status-detail" id="hist-status-detail"></div>
</div>
<div id="hist-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
<div id="hist-table" class="tbl-wrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
08 OPC UA 서버
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-opcsvr">
<header class="pane-hdr">
<div>
<h1>OPC UA 서버</h1>
<p class="sub">ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.</p>
</div>
</header>
<!-- 상태 카드 -->
<div class="srv-status-card" id="srv-status-card">
<div class="srv-status-row">
<span class="dot" id="srv-dot"></span>
<span id="srv-status-txt" class="srv-label">상태 조회 중...</span>
</div>
<div class="srv-meta" id="srv-meta"></div>
</div>
<!-- 버튼 행 -->
<div class="row-btns" style="margin-top:12px">
<button class="btn-a" onclick="srvStart()">▶ 서버 시작</button>
<button class="btn-b" onclick="srvStop()">■ 서버 중지</button>
<button class="btn-b" onclick="srvRebuild()">↺ 주소공간 재구성</button>
<button class="btn-b" onclick="srvLoad()">↻ 상태 새로고침</button>
</div>
<div id="srv-log" class="log-box hidden" style="margin-top:16px"></div>
</section>
<!-- ══════════════════════════════════════════════════════
09 Text-to-SQL
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-t2s">
<header class="pane-hdr">
<div>
<h1>Text-to-SQL 시계열 대시보드</h1>
<p>자연어 질의를 통해 TimeScaleDB 시계열 데이터를 조회하고 분석합니다.</p>
</div>
<div class="pane-tag">AI / SQL</div>
</header>
<!-- 자연어 쿼리 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">🗣 자연어 쿼리</div>
<div class="t2s-input-row">
<input id="t2s-query" class="inp" placeholder='예: "FICQ-6101.PV 온도 최근 1시간 평균", "최대값 조회", "최근 24시간 추세"' onkeydown="if(event.key==='Enter')t2sParse()"/>
<button class="btn-a" onclick="t2sParse()">SQL 변환</button>
<button class="btn-b" onclick="t2sExecute()">▶ 실행</button>
<button class="btn-b" onclick="t2sAnalyze()">📊 분석</button>
</div>
<div style="margin-top:10px">
<span style="font-size:12px;color:var(--t1)">추천 쿼리: </span>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 평균')">최근 1시간 평균</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 24시간 최대값')">24시간 최대값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 7일 최소값')">7일 최소값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 추세')">추세 분석</button>
</div>
</div>
<!-- 생성된 SQL -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">📝 생성된 SQL</div>
<textarea id="t2s-sql" class="t2s-sql-area" placeholder="자연어 쿼리를 변환하면 여기에 SQL이 표시됩니다..."></textarea>
</div>
<!-- 태그 분석 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">🏷 태그 분석 옵션</div>
<div class="cols-3">
<div class="fg">
<label>태그명 <em>(쉼표 구분, 비우면 전체)</em></label>
<input id="t2s-tags" class="inp" placeholder="FICQ-6101.PV,PV002,PV003"/>
</div>
<div class="fg">
<label>집계 간격</label>
<select id="t2s-interval" class="inp">
<option value="1 min">1분</option>
<option value="5 min" selected>5분</option>
<option value="15 min">15분</option>
<option value="1 hour">1시간</option>
<option value="1 day">1일</option>
</select>
</div>
<div class="fg">
<label>데이터 제한</label>
<input id="t2s-limit" class="inp" type="number" value="1000"/>
</div>
</div>
<div class="cols-2" style="margin-top:12px">
<div class="fg">
<label>시작일 <em>(비우면 최근 24시간)</em></label>
<input id="t2s-date-from" class="inp" type="datetime-local"/>
</div>
<div class="fg">
<label>종료일 <em>(비우면 현재)</em></label>
<input id="t2s-date-to" class="inp" type="datetime-local"/>
</div>
</div>
<div style="margin-top:12px">
<div class="fg">
<label>분석 데이터 제한</label>
<input id="t2s-limit-analyze" class="inp" type="number" value="100"/>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">📊 조회 결과</div>
<div id="t2s-results">
<span class="placeholder">쿼리를 실행하면 여기에 결과가 표시됩니다</span>
</div>
</div>
<div class="card">
<div class="card-cap">📈 태그 분석 결과</div>
<div id="t2s-analysis-results">
<span class="placeholder">분석을 실행하면 여기에 결과가 표시됩니다</span>
</div>
</div>
<div id="t2s-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
10 fastRecord
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-fast">
<header class="pane-hdr">
<div>
<h1>fastRecord</h1>
<p>고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.</p>
</div>
<div class="pane-tag">FAST / RECORD</div>
</header>
<!-- 세션 목록 (가로 카드) -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>세션 목록</span>
<button id="btn-fast-new" class="btn-a btn-sm">+ 신규</button>
</div>
<div id="fast-session-list" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px 4px;min-height:52px"></div>
</div>
<!-- 차트 카드 -->
<div class="card">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span id="fast-session-title">세션 상세</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="btn-fast-stop" class="btn-b btn-sm" style="display:none">■ 중지</button>
<button id="btn-fast-export-xlsx" class="btn-a btn-sm" style="display:none">Excel</button>
<button id="btn-fast-export-csv" class="btn-b btn-sm" style="display:none">CSV</button>
<button id="btn-fast-pin" class="btn-b btn-sm" style="display:none">고정</button>
<button id="btn-fast-delete" class="btn-b btn-sm" style="display:none;color:var(--red,#e55)">삭제</button>
</div>
</div>
<!-- 진행률 바 -->
<div style="height:6px;background:var(--s3);border-radius:3px;margin-bottom:4px">
<div id="fast-progress-bar" style="height:100%;width:0%;background:#4caf50;border-radius:3px;transition:width .5s"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--t2);margin-bottom:10px">
<span id="fast-progress-text">0 / 0 (0%)</span>
<span id="fast-elapsed-time">경과: 0s</span>
</div>
<!-- uPlot 차트 -->
<div id="fast-chart-container" style="min-height:380px"></div>
</div>
</section>
</main>
</div>
</div>
<!-- ── fastRecord 신규 세션 모달 ────────────────────────────── -->
<div id="modal-fast-new" style="display:none;position:fixed;inset:0;z-index:900;background:rgba(0,0,0,.55);align-items:center;justify-content:center" onclick="if(event.target===this)fastModalClose()">
<div style="background:var(--s2);border:1px solid var(--bd2);border-radius:var(--rl);padding:24px;width:480px;max-width:92vw;max-height:90vh;overflow-y:auto">
<div style="font-weight:700;font-size:15px;margin-bottom:16px">신규 fastSession</div>
<div class="fg">
<label>세션 이름</label>
<input type="text" class="inp" id="fast-session-name" placeholder="예: 공정온도_분석_20260428"/>
</div>
<div class="fg">
<label>태그 선택 <em style="font-weight:400">(Ctrl/Cmd 클릭으로 다중선택, 최대 8개)</em></label>
<select id="fast-tag-select" class="inp" multiple size="8" style="height:auto"></select>
</div>
<div class="cols-2" style="gap:10px;margin-top:4px">
<div class="fg">
<label>샘플링 간격</label>
<select class="inp" id="fast-sampling-ms">
<option value="100">100ms</option>
<option value="250">250ms</option>
<option value="500" selected>500ms</option>
<option value="1000">1000ms</option>
</select>
</div>
<div class="fg">
<label>수집 기간</label>
<select class="inp" id="fast-duration-sec">
<option value="60">1분</option>
<option value="300">5분</option>
<option value="900">15분</option>
<option value="1800">30분</option>
<option value="3600" selected>1시간</option>
<option value="7200">2시간</option>
<option value="14400">4시간</option>
<option value="43200">12시간</option>
<option value="86400">24시간</option>
</select>
</div>
</div>
<div class="fg" style="margin-top:4px">
<label>보관 기간 (일, 빈 칸 = 무한)</label>
<input type="number" class="inp" id="fast-retention-days" placeholder="30"/>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn-b" onclick="fastModalClose()">취소</button>
<button class="btn-a" onclick="fastStart()">▶ 시작</button>
</div>
</div>
</div>
<!-- ── 날짜/시간 선택 팝업 ──────────────────────────────────── -->
<div id="dt-overlay" class="dt-overlay hidden" onclick="dtCancel()"></div>
<div id="dt-popup" class="dt-popup hidden">
<div class="dt-cal-nav">
<button class="dt-nav-btn" onclick="dtPrevMonth()"></button>
<span id="dt-month-label" class="dt-month-label"></span>
<button class="dt-nav-btn" onclick="dtNextMonth()"></button>
</div>
<div class="dt-cal-grid" id="dt-cal-grid"></div>
<div class="dt-time-row">
<span class="dt-time-label">시간</span>
<div class="dt-time-ctrl">
<button onclick="dtAdjTime('h',-1)"></button>
<input id="dt-hour" class="dt-time-inp" type="number" min="0" max="23" value="0" oninput="dtClampTime('h',this)"/>
<button onclick="dtAdjTime('h', 1)">+</button>
</div>
<span class="dt-time-sep">:</span>
<div class="dt-time-ctrl">
<button onclick="dtAdjTime('m',-1)"></button>
<input id="dt-min" class="dt-time-inp" type="number" min="0" max="59" value="0" oninput="dtClampTime('m',this)"/>
<button onclick="dtAdjTime('m', 1)">+</button>
</div>
</div>
<div class="dt-pop-btns">
<button class="btn-b btn-sm" onclick="dtClear()">지우기</button>
<button class="btn-b btn-sm" onclick="dtCancel()">취소</button>
<button class="btn-a btn-sm" onclick="dtConfirm()">확인</button>
</div>
</div>
<script src="/lib/uPlot.iife.min.js"></script>
<script src="/js/xlsx.full.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -1,204 +0,0 @@
# 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 경로 완전 추적

View File

@@ -1,265 +0,0 @@
# 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

@@ -1,99 +0,0 @@
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 청크 정보 밀도 향상
- 컬럼명을 영문으로 바꾸면 영문 질의에도 잘 매칭 (현재 한국어)

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="34px" height="34px"><versionheaders><versionheader name="PS" value="6.11"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[<TEXTAREA tabIndex=0 id=ALV class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; OVERFLOW: hidden; WORD-WRAP: normal; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 55.88%; FONT-FAMILY: Arial; WIDTH: 67.64%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; BORDER-RIGHT-STYLE: none; LEFT: 17.64%; BORDER-LEFT-STYLE: none; TOP: 23.52%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); VISIBILITY: hidden; BACKGROUND-COLOR: transparent; ROWS: 1" hdxproperties="fillColor:transparent;HDXBINDINGID:-4;lineColor:black;numericDisplayFormat:%.2f;textColor:#000000;">9999.99</TEXTAREA>]]></content><scripts><script event="onupdate" language="VBScript"><![CDATA[if me.value > 5 then
OVAL.style.visibility = "visible"
else
OVAL.style.visibility = "hidden"
end if
if me.value = 9 then
HH.style.visibility = "visible"
else
HH.style.visibility = "hidden"
end if
if me.value = 7 then
H.style.visibility = "visible"
else
H.style.visibility = "hidden"
end if
if me.value = 6 then
L.style.visibility = "visible"
else
L.style.visibility = "hidden"
end if
if me.value = 8 then
LL.style.visibility = "visible"
else
LL.style.visibility = "hidden"
end if]]></script></scripts></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="AlarmValue"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=OVAL class=hvg.base.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#oval)" hdxproperties="FillColor:#ffff00;fillColorBlink:True;Height:34;LineColor:#000000;lineColorBlink:False;Width:34;"></DIV>]]></content></html></element><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=HH class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">HH</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=H class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">H</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=L class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">L</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=LL class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">LL</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 52.94%; WIDTH: 64.7%; POSITION: absolute; LEFT: 17.64%; TOP: 23.52%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:18;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 34px; WIDTH: 34px; POSITION: absolute; LEFT: 112px; TOP: 143px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:34;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="ControlValve" description="" useFirstShapeForBadValue="0" width="28px" height="36px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="VALVE" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect001 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 41.66%; FONT-FAMILY: Arial; WIDTH: 7.14%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 46.42%; TOP: 36.11%; BEHAVIOR: url(#HDXVectorFactory#rect) url(#BindingBehavior) url(#hscbreakpointbehavior)" hdxproperties="BreakpointType:0;ContinuousColors:6908265_9145088_13749760_16760576_13434880_15631086_16711935_6053069_255;ContinuousValues:1_10_20_30_40_50_60_80;fillColorBlink:False;HDXBINDINGID:-4;Height:15;LineColor:#000000;lineColorBlink:False;Width:2;"></DIV>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="1"/><property name="PointRefPointName" varianttype="VT_BSTR" value=""/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.ColorBreakpoints" refcount="1"/><class ID="HSC.HVG" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=A3 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.44%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 55.55%; BEHAVIOR: url(#HDXVectorFactory#polygon) url(#BindingBehavior) url(#hscbreakpointbehavior)" hdxproperties="BreakpointType:0;ContinuousColors:6908265_9145088_13749760_16760576_13434880_15631086_16711935_6053069_255;ContinuousValues:1_10_20_30_40_50_60_80;FillColor:#ffffff;fillColorBlink:False;HDXBINDINGID:-4;Height:16;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.543478 100.000000 100.000000 0.000000 0.543478 ;textColor:#000000;Width:28;"></DIV>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="1"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::VALVE%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="OP"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.ColorBreakpoints" refcount="1"/><class ID="HSC.HVG" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=A1 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.88%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#hdxvectorfactory#wedge) url(#bindingbehavior) url(#hscbreakpointbehavior)" hdxproperties="BreakpointType:0;ContinuousColors:6908265_9145088_13749760_16760576_13434880_15631086_16711935_6053069_255;ContinuousValues:1_10_20_30_40_50_60_80;FillColor:#ffffff;fillColorBlink:False;HDXBINDINGID:-4;Height:14;LevelFilled:False;LineColor:#000000;lineColorBlink:False;ObjectType:10;Points:99.82143 89.64286 99.10715 80 97.85715 70.35714 96.07143 61.07143 93.92857 52.14286 91.42857 43.92857 88.57143 36.42857 85.35714 29.28572 81.78571 22.85714 78.03571 17.14286 73.92857 12.14286 69.46429 7.857143 64.82143 4.285714 60.17857 1.785714 55.17857 0.3571429 50 0 44.82143 0.3571429 40 1.785714 35.17857 4.285714 30.53572 7.857143 26.07143 12.14286 21.96428 17.14286 18.21428 22.85714 14.64286 29.28572 11.42857 36.42857 8.571431 43.92857 6.071431 52.14286 3.928571 61.07143 2.142857 70.35714 0.8928571 80 0.1785714 89.64286 0 100 100 100 ;PointTypes:m l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l lx ;Quadrant:1;textColor:#000000;Width:28;"></DIV>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="1"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::VALVE%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="OP"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.ColorBreakpoints" refcount="1"/><class ID="HSC.HVG" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 36px; WIDTH: 28px; POSITION: absolute; LEFT: 37px; TOP: 24px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:36;lineColorBlink:False;Width:28;"></DIV>]]></content></html></group></shape></shapes></shapefile>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,5 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="62px" height="52px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 98.07%; FONT-FAMILY: Arial; WIDTH: 98.38%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#696969;fillColorBlink:False;Height:51;LineColor:#000000;lineColorBlink:False;Width:61;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 38.46%; FONT-FAMILY: Tahoma; WIDTH: 32.25%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 1.61%; TOP: 13.46%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#00ff00;textColorBlink:False;TotalRotation:-1;Width:20;">Hz</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 38.46%; FONT-FAMILY: Tahoma; WIDTH: 32.25%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #ff00ff; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 3.22%; TOP: 57.69%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#ff00ff;textColorBlink:False;TotalRotation:-1;Width:20;">Set</DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha102 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 44.23%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 59.67%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 35.48%; BORDER-RIGHT-COLOR: transparent; TOP: 3.84%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="HZ"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha104 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 44.23%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 59.67%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #ff00ff; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 35.48%; BORDER-RIGHT-COLOR: transparent; TOP: 50%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#ff00ff;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="2"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="HZSET"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.Alpha" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 52px; WIDTH: 62px; POSITION: absolute; LEFT: 29px; TOP: 46px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:52;lineColorBlink:False;Width:62;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="sequence" title="" description="" useFirstShapeForBadValue="0" width="31px" height="31px"><versionheaders><versionheader name="PS" value="6.11"/></versionheaders><shapes><shape><element><html><content><![CDATA[
<DIV tabIndex=-1 id=oval001 class=hvg.base.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 31px; FONT-FAMILY: Arial; WIDTH: 31px; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 42px; TOP: 77px; BEHAVIOR: url(#HDXVectorFactory#oval); VISIBILITY: hidden" hdxproperties="fillColorBlink:False;Height:31;LineColor:#000000;lineColorBlink:False;Width:31;"></DIV>]]></content></html></element></shape><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=oval002 class=hvg.base.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 96.87%; FONT-FAMILY: Arial; WIDTH: 96.87%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#oval)" hdxproperties="FillColor:#ff0000;fillColorBlink:True;Height:31;LineColor:#000000;lineColorBlink:False;Width:31;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 10pt; HEIGHT: 53.12%; FONT-FAMILY: Arial; WIDTH: 59.37%; POSITION: absolute; FONT-WEIGHT: bold; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 25%; TOP: 18.75%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:19;">I/L</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 32px; WIDTH: 32px; POSITION: absolute; LEFT: 78px; TOP: 77px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:32;lineColorBlink:False;Width:32;"></DIV>]]></content><scripts><script event="onclick" language="VBScript"><![CDATA[window.external.Parent.RequestTask 21, 1, 0, 0, 0, "6TH-IL-ESD"]]></script></scripts></html></group></shape></shapes></shapefile>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="94px" height="72px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#696969;fillColorBlink:False;Height:72;LineColor:#000000;lineColorBlink:False;Width:92;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 1.38%; FONT-FAMILY: Arial; WIDTH: 98.91%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.08%; TOP: 29.16%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0 0 100 0 ;Width:91;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group008 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 97.87%; POSITION: absolute; LEFT: 1.06%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:92;"></DIV>]]></content></html></group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 38.88%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#00ff00;textColorBlink:False;TotalRotation:-1;Width:20;">PV</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #ff00ff; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 1.06%; TOP: 70.83%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#ff00ff;textColorBlink:False;TotalRotation:-1;Width:20;">QV</DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha102 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 31.94%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha104 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #ff00ff; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 65.27%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#ff00ff;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="QV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 72px; WIDTH: 94px; POSITION: absolute; LEFT: 309px; TOP: 203px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:94;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="94px" height="72px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#696969;fillColorBlink:False;Height:72;LineColor:#000000;lineColorBlink:False;Width:92;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 1.38%; FONT-FAMILY: Arial; WIDTH: 98.91%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.08%; TOP: 29.16%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0 0 100 0 ;Width:91;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group008 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 97.87%; POSITION: absolute; LEFT: 1.06%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:92;"></DIV>]]></content></html></group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 38.88%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#00ff00;textColorBlink:False;TotalRotation:-1;Width:20;">PV</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #ff00ff; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 1.06%; TOP: 70.83%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#ff00ff;textColorBlink:False;TotalRotation:-1;Width:20;">SP</DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha102 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 31.94%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha104 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #ff00ff; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 65.27%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#ff00ff;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="2"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="SP"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.Alpha" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 72px; WIDTH: 94px; POSITION: absolute; LEFT: 309px; TOP: 203px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:94;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="60px" height="44px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="Tag" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 96.66%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#808080;fillColorBlink:False;Height:44;LineColor:#000000;lineColorBlink:False;Width:58;"></DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha121 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 52.27%; FONT-FAMILY: Arial; BORDER-TOP-COLOR: transparent; WIDTH: 86.66%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 5%; BORDER-RIGHT-COLOR: transparent; TOP: 45.45%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" globalscripts hdxproperties="borderWidth:;durationFormat:-1;fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#00ff00;lineColorBlink:False;numberOfChars:5;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::Tag%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 2.27%; FONT-FAMILY: Arial; WIDTH: 96.66%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.66%; TOP: 40.9%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0 0 100 0 ;Width:58;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 44px; WIDTH: 60px; POSITION: absolute; LEFT: 410px; TOP: 100px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Width:60;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="78px" height="44px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#808080;fillColorBlink:False;Height:44;LineColor:#000000;lineColorBlink:False;Width:78;"></DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha121 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 52.27%; FONT-FAMILY: Arial; BORDER-TOP-COLOR: transparent; WIDTH: 96.15%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 1.28%; BORDER-RIGHT-COLOR: transparent; TOP: 43.18%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="borderWidth:;fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#00ff00;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 2.27%; FONT-FAMILY: Arial; WIDTH: 96.15%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.28%; TOP: 40.9%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 100.000000 0.000000 ;Width:75;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 44px; WIDTH: 78px; POSITION: absolute; LEFT: 200px; TOP: 86px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Width:78;"></DIV>]]></content></html></group></shape></shapes></shapefile>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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