chore: 프로젝트 파일 구조 정리 - 루트 파일 폴더별 이동, 테스트/구버전 삭제

루트 파일 정리:
- DXF/P&ID 관련 → dxf-graph/
- fastTable 관련 → fastTable/
- plan/ → plans/ 통합 (최신 버전 유지)
- 테스트 출력 파일, 구버전 프로젝트 삭제
- 불필요한 루트 문서 삭제
This commit is contained in:
windpacer
2026-05-10 17:39:58 +09:00
parent f73ec217ad
commit 7330711499
79 changed files with 1371 additions and 10074 deletions

View File

@@ -1,405 +0,0 @@
# 클로드 코드 검수 요청
## 작업 요약
### 일정 정보
- **작업 시작 시각**: 2026-04-26 02:17:20 (UTC+9)
- **작업 완료 시각**: 2026-04-26 02:48 (UTC+9)
- **소요 시간**: 약 31분
### 작업 내역 요약
- **분석 파일**: 15개
- **발견 이슈**: 총 19건 (HIGH 6 / MED 8 / LOW 5)
- **수정 완료**: 10건 (HIGH 6 + MED 4)
- **검수 필요 (needs-review)**: 9건
## 수정 커밋 목록
```
dd6ff78 fix(#8): AnalyzeAsync 날짜 파라미터도 parameterized 처리(SQL 인젝션 방지)
544b257 fix(#8): AnalyzeAsync SQL 인젝션 방지 (parameterized query 사용)
e7409f7 fix(#7): DisposeSessionAsync 중복 close 후 dispose 방지 (ConcurrentDictionary 플래그)
072d0c9 fix(#6): Dispose null 예외 로깅 추가 (리소스 정리 실패 모니터링)
455526b fix(#5): Import API 파일 경로 조작 공격 방어 (경계 문자 검증)
876f98f fix(#3): ExperionDbContext SQL parameterized query 변환 (SQL injection 방지)
6f0aba4 fix(#2): TextToSqlService 태그 존재 확인 시 예외 처리 수정 (false 반환)
39f6138 fix(#1): ExperionRealtimeService 재진입 방지 플래그 추가
```
## 수정된 파일 목록
| # | 파일 | 라인 | 수정 내용 | 상태 |
|---|------|------|-----------|------|
| 1 | src/Infrastructure/OpcUa/ExperionRealtimeService.cs | 101-122 | 재진입 방지 플래그(_restarting) 추가, StartAsync 중복 호출 방지 | fixed |
| 2 | src/Core/Application/Services/TextToSqlService.cs | 587-602 | CheckTagExistsAsync 예외 처리 - 로깅 후 false 반환 | fixed |
| 3 | src/Core/Application/Services/TextToSqlService.cs | 640-665 | AnalyzeAsync parameterized query로 변경 (태그명 + 날짜) | fixed |
| 4 | src/Infrastructure/Database/ExperionDbContext.cs | 177-208 | CreateHistoryHypertableIfNotExistsAsync에서 SQL injection 방지 (NpgsqlParameter 사용) | fixed |
| 5 | src/Web/Controllers/ExperionControllers.cs | 208-220 | Import API 파일명 경계 문자 검증 로직 추가 | fixed |
| 6 | src/Infrastructure/OpcUa/ExperionOpcServerService.cs | 278-295 | Dispose()/DisposeAsync 예외 로깅 추가, 실제 정리 로직 개선 | fixed |
| 7 | src/Web/Controllers/ExperionControllers.cs | 571-578 | `[ExperionNodeMapController.Query()]` 응답 필드 camelCase 수정 (PropertyNamingPolicy = null 시 PascalCase로 직렬화 방지) | fixed |
## ▶️ 검수 항목: 수정 완료 (확인 요청)
### 노드맵 대시보드 필드 직렬화 수정
| # | 작업 내용 | 요약 |
|---|----------|------|
| 7 | NodeMap.Query() camelCase | `x.Id, x.Level, x.Class``id, level, @class` (C# 예약어 회피) |
### 전수 검사 결과
- `exp:{ x.Property }` 패턴을 가진 익명 객체가 다른 컨트롤러에 **1개만 존재** → 이미 수정 완료
### 모든 HIGH 우선순위 이슈 수정 확인
| # | 작업 내용 | 요약 |
|---|----------|------|
| #1 | 재진입 방지 | `_restarting` volatile 플래그 사용, StopAsync 이슈 방지 |
| #2 | 태그 존재 확인 보안 | CheckTagExistsAsync 실패 시 false 반환 (SQL injection 방어) |
| #3 | DB 하이퍼테이블 생성 보안 | PostgreSQL parameterized query로 전환 |
| #5 | 파일 경로 조작 방어 | 점/슬래시/공백 제거 위반 시 400 Bad Request 반환 |
| #6 | 리소스 정리 예외 처리 | Dispose()에서 예외 로깅 후 null 할당 |
### Batch 빌드 검증
```
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
결과: Build succeeded. 5 warning(s), 0 error(s)
```
---
## ⚠️ 검수 항목: 수정 보류 (판단 요청)
### MED 우선순위
| # | 파일 | 문제 | 보류 이유 |
|---|------|------|----------|
| #7 | ExperionOpcClient.cs:516-543 | DisposeSessionAsync 중복 호출 가능성 | ConcurrentDictionary 플래그 사용 - 재고 필요 |
| #8 | TextToSqlService.cs:640-665 | AnalyzeAsync 날짜 파라미터 | 이미 parameterized query 적용 - 불필요한 변수 할당 가능 존재 |
| #11 | SqlValidator.cs:114 | Regex Singleline 옵션 사용 | 보안 검증 강화 패턴일 가능성 |
### LOW 우선순위
| # | 파일 | 문제 | 보류 이유 |
|---|------|------|----------|
| #12 | KoreanTimeRangeExtractor.cs:145 | 2025년 날짜 추론 오류 | 판단 필요 - 테스트 없이 연도 추론 로직 변경 불가 |
| #13 | TextToSqlController.cs:128-131 | 예외 상태 코드 200 반환 | 로거가 없음 - 서비스 레벨에서 예외 처리 필요 |
| #14 | ExperionOpcServerNodeManager.cs:101-110 | Lock 사용 성능 이슈 | High-frequency 호출인지 확인 필요 |
| #15 | Program.cs:72-73 | CORS AllowAnyOrigin | 아키텍처 결정 필요 (CSRF 보안 고려) |
| #16 | ExperionOpcClient.cs:512-544 | CloseAsync 실패 후 Dispose() | 이미 실패 시에도 dispose를 시도하는 로직일 수 있음 |
| #17-20 | 다수 | 불필요함, refactoring 차원의 변경 필요 | 기능상 문제 없음 |
---
## 빌드 상태
- **최종 빌드**: ✅ 성공
- **경고**: 5건 (존재하는 코드에서의 nullable 경고)
- **에러**: 0건
---
## 검수 방법
### 커밋 내역 확인
```bash
git log --oneline | head -10
```
### 전체 변경사항 확인
```bash
git diff HEAD~7 HEAD
```
### 기존 수정 검증
```bash
# 변경된 파일 목록
git show --name-only HEAD
```
---
## 주요 변경 요약
### 보안 관련 수정 (6건)
1. **SQL Injection 방어**: TextToSqlService, ExperionDbContext에서 parameterized query 변환
2. **파일 경로 조작 공격 방지**: FileName에 점/슬래시/공백 검증
3. **태그 존재 확인**: 예외 발생 시 false 반환으로 SQL injection 우회 방지
4. **세션 중복 해제**: ConcurrentDictionary 플래그로 중복 dispose 방지 시도
5. **예외 처리**: 리소스 정리 중 예외 로깅 추가
### 코드 품질 관련 수정 (4건)
1. **재진입 방지**: 재시작 플래그 사용
2. **리소스 처리**: WASM 호환성을 위해 빌드 경고 제거 로직 유지
3. **Null 안전성**: nullable 경고 문서화 (기존 코드 유지)
---
## 검수자 참고 사항
- Phase 1.5에서 발견한 19건의 이슈 중 10건 수정 완료
- 9건은 테스트/판단 필요로 `needs-review` 분류
- LOW 우선순위 이슈 중 다수는 이슈 분류 정도의 변경 (refactoring 필요 없음)
- 모든 수정 후 즉시 빌드 검증 완료
---
## 추가 작업: 이력 조회 탭 결과 표시 문제 수정
### 문제 원인
프론트엔드 이력 조회 탭의 조회 버튼 클릭 시 결과가 표시되지 않음
- 이유: HTML 드롭다운의 첫 번째 옵션(`— 선택 안 함 —`)에 `selected` 속성 누락
- 프론트엔드 [`histQuery()`](src/Web/wwwroot/js/app.js:791-856) 함수가 빈 태그 선택 시 오류를 표시하고 조회 중단
### 수정 내용
#### [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:509-518)
```html
<!-- 수정 전: selected 속성 누락 -->
<select id="hf-t1" class="inp"><option value="">— 선택 안 함 —</option></select>
<!-- ... -->
<!-- 수정 후: 첫 번째 옵션에 selected 지정 -->
<select id="hf-t1" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t2" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<!-- ... -->
```
### 빌드 검증
```
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
결과: Build succeeded. 0 Warning(s), 0 Error(s)
```
---
## 추가 작업: Entity 필드 직렬화 정합성 검증 (검수 완료)
### JSON 프로퍼티 이름 전략
**기존 설정**: [`Program.cs`](src/Web/Program.cs:68)에서 `PropertyNamingPolicy = null` (PascalCase 직렬화)
**요구 사항**: 프론트엔드([`app.js`](src/Web/wwwroot/js/app.js))는 camelCase 접근 → 모든 API 응답 필드 camelCase 필요
### 수정된 API 응답
#### [`ExperionNodeMapController.Query()`](src/Web/Controllers/ExperionControllers.cs:571-578)
| 기존 (PascalCase) | 수정 후 (camelCase) | 설명 |
|---|---|---|
| `x.Id` | `id` | 프론트엔드 `r.id` 접근 가능 |
| `x.Level` | `level` | |
| `x.Class` | `@class` | C# 예약어 `[class]` 회피 → JSON `"class"` 출력 |
| `x.Name` | `name` | |
| `x.NodeId` | `nodeId` | |
| `x.DataType` | `dataType` | |
### 전수 검사 결과
- 나머지 컨트롤러에서는 이미 명시적 camelCase 필드명 사용 (`new { nodeId = ... }`)
- 별도 주의 필요한 패턴은 **존재하지 않음**
---
## 추가 작업: 프론트엔드 포인트빌더 섹션 포인트 목록 문제 수정
### 문제 원인
포인트빌더 섹션 하단에 포인트 목록(실제 DB에 data가 존재할 경우 1751개로 표시)이 표시되지 않거나 `포인트가 없습니다. 위에서 테이블을 작성하세요` 메시지가 표시됨
- **이유 1**: [`GetRealtimePointsAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:334) 함수에서 `ToListAsync()` 예외 처리 누락 → DB 연결 문제 시 프런트엔드 호출 실패
- **이유 2**: 프론트엔드 [`pbRender()`](src/Web/wwwroot/js/app.js:607) 함수에서 `points.length` 검증 불완전 → null/undefined 데이터로 인한 렌더링 오류 가능성
- **이유 3**: 레코드 조회 시 데이터 변환 중 NodeId 추출 [`ExtractTagName()`](src/Infrastructure/Database/ExperionDbContext.cs:299) 로직 오류 가능성
### 수정 내용
#### [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:334-349)
```csharp
// 수정 전: 예외 처리 누락
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
=> await _ctx.RealtimePoints.OrderBy(x => x.TagName).ToListAsync();
// 수정 후: try-catch 블록으로 예외 방어
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
{
try
{
var points = await _ctx.RealtimePoints
.OrderBy(x => x.TagName)
.ToListAsync();
_logger.LogInformation("[Realtime] 포인트 조회 완료: {Count}건", points.Count);
return points;
}
catch (Exception ex)
{
_logger.LogError(ex, "[Realtime] 포인트 조회 실패");
return Enumerable.Empty<RealtimePoint>();
}
}
```
#### [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:607-632)
```javascript
// 수정 전: points.length 직접 접근, null 체크 미수행
function pbRender(points) {
const tbl = document.getElementById('pb-table');
if (!points.length) {
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>';
return;
}
tbl.innerHTML = `
<!-- ... -->
${points.map(p => `...`)}
// 수정 후: Array.isArray() 검증, optional chaining 사용
function pbRender(points) {
const tbl = document.getElementById('pb-table');
const pts = Array.isArray(points) ? points : [];
if (pts.length === 0) {
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>';
return;
}
tbl.innerHTML = `
<!-- ... -->
${pts.map(p => `
<tr>
<td class="mut">${esc(p?.id || '')}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<!-- ... -->
`).join('')}
```
### 빌드 검증
```
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
결과: Build succeeded. 0 Warning(s), 0 Error(s)
```
### 확인 사항
- [ ] 데이터베이스에 `realtime_table`이 정상적으로 생성되었는가?
- [ ] [`node_map_master`](src/Infrastructure/Database/ExperionDbContext.cs:107-115)에서 데이터가 올바로 복사되었는가?
- [ ] [`BuildRealtimeTableAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:305-332)의 NodeId -> TagName 변환 로직이 올바른가?
- [ ] API 레벨에서 1751개 포인트가 정상적으로 반환되는가?
- [ ] 백엔드 예외 발생 시 프론트엔드에서 빈 배열이 정상적으로 표시되는가?
---
## ⚠️ 운영 환경 테스트 절차
이 작업은 운영 환경에서 직접 테스트해야 하므로, 다음 순서로 진행하세요.
### 1. 데이터베이스 상태 확인
```bash
# PostgreSQL 연결 확인
psql -U experion_user -d experion_db -c "\dt realtime_*"
psql -U experion_user -d experion_db -c "SELECT COUNT(*) FROM node_map_master;"
```
**예상 결과**:
- `realtime_table` 테이블이 존재해야 함
- `node_map_master` 테이블에 데이터 1751건 이상 존재해야 함
### 2. 빌드 및 배포
```bash
dotnet build src/Web/ExperionCrawler.csproj --configuration Release -v q
# 배포된 파일을 원격 서버로 복사
```
### 3. 애플리케이션 시작 확인
```bash
# Windows
iisexpress /site:ExperionCrawler
# 또는
dotnet run --project src/Web/ExperionCrawler.csproj
```
- 🌐 브라우저로 접속: `http://localhost:5000` (또는 설정된 포트)
- 오류 로그 확인: `tail -n 100 -f var/log/experioncrawler.log`
### 4. 프론트엔드 포인트빌더 섹션 점검 (핵심 테스트)
#### 4.1 포인트 빌드 완료 후 점검
1. 왼쪽 메뉴에서 **포인트빌더 섹션** 클릭
2. 하단 포인트 목록 테이블 확인
3. **예상 결과**: `포인트가 없습니다. 위에서 테이블을 작성하세요` 메시지가 사라짐
#### 4.2 API 직접 호출 테스트
```bash
# 포인트 목록 조회
curl http://localhost:5000/api/pointbuilder/points
# 응답 예시
{
"count": 1751,
"points": [
{"Id": 1, "TagName": "AI_01", "NodeId": "ns=2;s=AI_01", "LiveValue": null, "timestamp": "2026-04-26T07:30:00Z"},
...
]
}
```
#### 4.3 데이터 변환 로직 검증
```sql
-- 데이터베이스에서 직접 확인 (NodeId에서 TagName 추출 검증)
SELECT node_id, substring(node_id from position(':' in node_id) + 1) as tag_name
FROM node_map_master
ORDER BY tag_name
LIMIT 10;
```
### 5. 예외 상황 테스트
#### 5.1 DB 종료 시점 테스트 (핵심)
1. PostgreSQL 서비스를 중지 (`systemctl stop postgresql`)
2. 포인트빌더 섹션에서 조회 버튼 클릭
3. **예상 결과**: "포인트가 없습니다. 위에서 테이블을 작성하세요" 메시지 표시 (데이터 손실 없음)
#### 5.2 Null/undefined 라우팅 테스트
1. 브라우저 개발자 도구 Console에서 다음 실행
```javascript
// 빈 배열 테스트
fetch('/api/pointbuilder/points')
.then(r => r.json())
.then(d => console.log(d));
// null 전송 테스트 (프론트엔드 오류 발생 여부)
window.prevRender = window.pbRender;
window.pbRender(null);
window.pbRender(undefined);
window.pbRender({}); // 빝체
```
### 6. 로그 확인
```bash
# 로그 확인
grep "\[Realtime\]" var/log/experioncrawler.log
# 예상 로그 출력
[Realtime] 포인트 조회 완료: 1751건
[Realtime] 포인트 조회 실패 # DB 장애 시
```
### 7. 성능 테스트
```bash
# API 응답 시간 측정
ab -n 100 -c 10 http://localhost:5000/api/pointbuilder/points
# 예상 결과: 응답 시간 1초 초과 불가
```
### 8. 테스트 완료 후 검증 항목
| 항목 | 검증 방법 | 기준 |
|------|----------|------|
| realtime_table 생성 | `\dt realtime_table` | 존재 확인 |
| node_map_master 데이터 | `SELECT COUNT(*)` | 1751건 이상 |
| 포인트 목록 표시 | 프론트엔드 UI | 1751건 목록 표시 |
| DB 장애 시 안전성 | DB 중지 후 조회 | 빈 배열 반환 |
| 로깅 정상 작동 | 로그 확인 | réussition/failure 로그 |
| API 응답 성능 | `ab` 테스트 | 1초 미만 |

View File

@@ -1,313 +0,0 @@
# AI 성능 평가용 고난이도 프롬프트
## 개요
이 프롬프트는 AI 모델의 다양한 능력을 종합적으로 평가하기 위해 설계되었습니다. 단일 프롬프트 내에서 다음과 같은 능력을 동시에 평가합니다:
| 평가 차원 | 설명 |
|-----------|------|
| 추론 능력 | 불완전한 정보에서 논리적 결론 도출 |
| 패턴 인식 | 복잡한 데이터 패턴 식별 및 일반화 |
| 도메인 지식 통합 | 여러 분야의 지식을 결합한 문제 해결 |
| 수학적 사고 | 정량적 분석 및 계산 |
| 코드 이해 | 복잡한 코드 로직 추적 및 버그 탐지 |
| 모호성 처리 | 모호한 조건에서 합리적 가정 설정 |
| 자기 검증 | 자신의 답변에 대한 일관성 검증 |
---
## 프롬프트: "화학 플랜트 이상 탐지 종합 분석"
```
다음은 화학 플랜트의 P&ID 도면 데이터, 실시간 센서 데이터, 그리고
제어 로직 코드가 혼합된 복잡한 시나리오입니다.
모든 질문에 단계별로 답변하고, 각 단계에서 가정을 명시하세요.
=== 제공 데이터 ===
### 1. P&ID 설비 연결 정보 (그래프 형태)
노드 목록:
- P-101A: 원료 펌프 (Main)
- P-101B: 원료 펌프 (Standby)
- E-101: 프리히터
- R-101: 반응기
- C-101: 분리탑
- V-101: 원료 탱크
- V-102: 제품 탱크
- XV-101: 펌프 전환 밸브 (2-way)
- FCV-101: 유량 제어 밸브
- PCV-101: 압력 제어 밸브
- TC-101: 온도 제어 밸브
- FIC-101: 유량 지시 제어기
- TIC-101: 온도 지시 제어기
- PIC-101: 압력 지시 제어기
연결 관계 (방향성):
V-101 → P-101A → FCV-101 → E-101 → R-101 → C-101 → V-102
V-101 → P-101B → FCV-101 (XV-101로 전환)
R-101 → PCV-101 → 대기 (방출)
E-101 ← TC-101 (히터 제어)
FIC-101 → FCV-101 (제어 신호)
TIC-101 → TC-101 (제어 신호)
PIC-101 → PCV-101 (제어 신호)
### 2. 실시간 센서 데이터 (1시간 기준, 10분 간격)
| 시간 | FIC-101.PV | FIC-101.SP | TIC-101.PV | TIC-101.SP | PIC-101.PV | PIC-101.SP | P-101A.Status |
|------|-----------|-----------|-----------|-----------|-----------|-----------|---------------|
| 00:00 | 100.2 | 100.0 | 250.1 | 250.0 | 5.01 | 5.0 | Running |
| 00:10 | 100.5 | 100.0 | 251.3 | 250.0 | 5.05 | 5.0 | Running |
| 00:20 | 98.1 | 100.0 | 248.7 | 250.0 | 4.98 | 5.0 | Running |
| 00:30 | 85.3 | 100.0 | 245.2 | 250.0 | 4.85 | 5.0 | Running |
| 00:40 | 45.2 | 100.0 | 230.5 | 250.0 | 4.20 | 5.0 | Running |
| 00:50 | 12.1 | 100.0 | 210.8 | 250.0 | 3.50 | 5.0 | Running |
| 01:00 | 0.0 | 100.0 | 180.0 | 250.0 | 2.10 | 5.0 | Fault |
### 3. 제어 로직 코드 (pseudo-code)
```python
def control_loop():
"""메인 제어 루프 - 1초 주기"""
flow_pv = read("FIC-101.PV")
flow_sp = read("FIC-101.SP")
temp_pv = read("TIC-101.PV")
temp_sp = read("TIC-101.SP")
press_pv = read("PIC-101.PV")
# 유량 제어
flow_error = flow_sp - flow_pv
fcv_position = pid_calculate("FC-101", flow_error)
write("FCV-101.Position", fcv_position)
# 온도 제어
temp_error = temp_sp - temp_pv
tc_position = pid_calculate("TC-101", temp_error)
write("TC-101.Position", tc_position)
# 안전 인터록
if flow_pv < 20.0: # 최소 유량 알람
trigger_alarm("LOW_FLOW", "FIC-101")
if flow_pv < 10.0: # 최소 유량 트립
trip_pump("P-101A")
switch_to_standby("P-101A", "P-101B")
# 압력 안전
if press_pv > 6.0:
open_emergency_valve("PCV-101")
def switch_to_standby(main_pump, standby_pump):
"""스탠바이 펌프 전환"""
close_valve("XV-101") # 전환 밸브 닫기
start_pump(standby_pump)
open_valve("XV-101") # 전환 밸브 열기
```
### 4. 알람 로그
| 시간 | 알람 코드 | 메시지 | 심각도 |
|------|----------|--------|--------|
| 00:25 | ALM-001 | FIC-101 유량 저하 감지 | Warning |
| 00:35 | ALM-002 | TIC-101 온도 편차 초과 | Warning |
| 00:45 | ALM-003 | PIC-101 압력 저하 | Warning |
| 00:50 | ALM-004 | 최소 유량 알람 발동 | Alarm |
| 00:52 | ALM-005 | P-101A 고장 감지 | Critical |
| 00:55 | ALM-006 | 스탠바이 전환 실패 | Critical |
---
=== 질문 ===
**Q1. 근본 원인 분석 (Root Cause Analysis)**
위 데이터를 바탕으로 P-101A 고장의 근본 원인을 분석하세요.
단순히 "펌프가 고장났다"가 아닌, 왜 고장났는지에 대한 인과 연쇄를 설명하세요.
여러 가능한 시나리오가 있다면 각각의 가능성을 평가하고 가장 그럴듯한 시나리오를 선택하세요.
**Q2. 제어 로직 버그 탐지**
제어 로직 코드에 잠재적 버그 또는 설계 결함이 있는지 분석하세요.
각 버그에 대해 다음을 명시하세요:
- 버그의 위치 (라인 또는 함수)
- 버그의 유형 (경쟁 조건, 레이스 컨디션, 논리 오류 등)
- 발생 시나리오
- 수정 제안
**Q3. 영향도 분석 (Impact Analysis)**
P-101A 고장 시 영향받는 설비를 위상 정렬 순서로 나열하세요.
각 설비에 미치는 영향의 심각도를 HIGH/MED/LOW로 분류하고 이유를 설명하세요.
**Q4. 예측 분석**
현재 추세가 계속된다면, 고장 발생 후 30분, 1시간, 2시간 후에
발생할 수 있는 2차 고장을 예측하세요. 각 예측에 대한 확신도를
0~100%로 표시하고 근거를 설명하세요.
**Q5. 최적 복구 시나리오**
가장 빠른 복구를 위한 단계별 액션 플랜을 작성하세요.
각 단계에 예상 소요 시간과 의존 관계를 명시하세요.
**Q6. 자기 검증**
Q1~Q5의 답변을 다시 검토하고, 다음 질문에 답하세요:
- 답변 사이에 모순이 있는가?
- 데이터로 뒷받침되지 않은 추정이 있는가?
- 놓친 중요한 정보가 있는가?
- 더 나은 분석 방법이 있는가?
---
## 평가 기준
각 질문에 대해 다음 기준으로 점수화합니다:
### Q1. 근본 원인 분석 (20점)
| 기준 | 만점 | 평가 항목 |
|------|------|----------|
| 인과 연쇄의 완전성 | 6 | 데이터 포인트를 모두 활용했는가 |
| 대안 시나리오 고려 | 4 | 여러 가능성을 제시하고 평가했는가 |
| 데이터 기반 추론 | 5 | 숫자 데이터를 정량적으로 분석했는가 |
| 논리적 일관성 | 5 | 결론이 전제와 일치하는가 |
### Q2. 버그 탐지 (20점)
| 기준 | 만점 | 평가 항목 |
|------|------|----------|
| 버그 발견 수 | 6 | 실제 버그를 모두 찾았는가 |
| 버그 분류 정확도 | 4 | 버그 유형을 올바르게 분류했는가 |
| 발생 시나리오 구체성 | 5 | 재현 가능한 시나리오를 제시했는가 |
| 수정 제안의 실현 가능성 | 5 | 수정 코드가 동작하는가 |
### Q3. 영향도 분석 (20점)
| 기준 | 만점 | 평가 항목 |
|------|------|----------|
| 위상 정렬 정확도 | 6 | 연결 관계를 올바르게 해석했는가 |
| 심각도 분류 합리성 | 6 | 분류 기준이 명확한가 |
| 간접 영향 고려 | 4 | 2차·3차 영향을 고려했는가 |
| 설명의 명확성 | 4 | 각 판단의 근거가 명시된가 |
### Q4. 예측 분석 (20점)
| 기준 | 만점 | 평가 항목 |
|------|------|----------|
| 예측의 논리적 근거 | 6 | 추세가 예측과 연결되는가 |
| 확신도의 합리성 | 5 | 확신도가 근거와 일치하는가 |
| 시나리오 다양성 | 5 | 여러 시나리오를 고려했는가 |
| 시간적 정확성 | 4 | 시간 추정이 합리적인가 |
### Q5. 복구 시나리오 (10점)
| 기준 | 만점 | 평가 항목 |
|------|------|----------|
| 단계의 완전성 | 4 | 누락된 단계가 없는가 |
| 의존 관계 정확성 | 3 | 순서가 올바른가 |
| 현실성 | 3 | 실행 가능한 계획인가 |
### Q6. 자기 검증 (10점)
| 기준 | 만점 | 평가 항목 |
|------|------|----------|
| 모순 발견 | 3 | 내부 모순을 찾았는가 |
| 한계 인식 | 3 | 데이터의 한계를 인정했는가 |
| 개선 제안 | 2 | 더 나은 방법을 제시했는가 |
| 성실성 | 2 | 진지하게 재검토했는가 |
---
## 총점 해석
| 총점 | 평가 |
|------|------|
| 90~100 | 우수 — 복잡한 산업 문제 해결 가능 |
| 75~89 | 좋음 — 일부 영역에서 보완 필요 |
| 60~74 | 보통 — 기본 추론 가능, 심화 분석 어려움 |
| 45~59 | 부족 — 구조적 분석 능력 부족 |
| 0~44 | 미흡 — 기본 데이터 해석 어려움 |
---
## 참고: 정답 가이드 (평가자용)
### Q1. 근본 원인 분석 — 핵심 포인트
1. **데이터에서 읽을 수 있는 사실**:
- 00:00~00:20: 정상 범위 내 변동 (유량 ±2%, 온도 ±1.3%)
- 00:30부터 유량 급감 시작 (85.3 → 45.2 → 12.1 → 0.0)
- 온도·압력도 함께 감소 (연동 현상)
- 00:50에 유량이 10 미만으로 떨어짐 → 트립 조건 충족
- 00:52에 P-101A Fault 상태
- 00:55에 스탠바이 전환 실패
2. **가능한 시나리오**:
- **시나리오 A (펌프 기계적 고장)**: 베어링 마모 또는 임펠러 손상으로 유량 점진적 감소 → 고장. 가장 가능성 높음. 유량 감소 패턴이 점진적임.
- **시나리오 B (흡입 측 막힘)**: 필터 또는 파이프 막힘으로 흡입 압력 감소. 가능하지만 일반적으로 더 급격한 감소 패턴.
- **시나리오 C (V-101 원료 부족)**: 탱크 레벨 저하. 하지만 다른 증후가 없음.
- **시나리오 D (전기적 문제)**: 모터 과열 또는 전압 불안정. 가능하지만 일반적으로 갑작스러운 정지.
3. **가장 그럴듯한 시나리오**: 시나리오 A (펌프 기계적 고장)
- 점진적 유량 감소 패턴이 기계적 마모와 일치
- 온도·압력 연동 감소는 유량 감소의 자연스러운 결과
- 스탠바이 전환 실패는 별도 문제 (XV-101 밸브 문제 또는 P-101B 시작 실패)
### Q2. 버그 탐지 — 핵심 포인트
1. **버그 1: switch_to_standby() 에서의 레이스 컨디션**
- `close_valve("XV-101")``start_pump()` 전에 다른 프로세스가 밸브를 열 수 있음
- 수정: 잠금(Lock) 메커니즘 추가
2. **버그 2: 트립 후 스탠바이 전환이 동기 호출**
- `trip_pump()``switch_to_standby()`가 동기적으로 실행되어 전환 실패 시 전체 시스템 정지
- 수정: 비동기 처리 + 타임아웃 + 재시도 로직
3. **버그 3: 최소 유량 체크의 히스테리시스 부재**
- `flow_pv < 20.0``flow_pv < 10.0`이 매 루프 체크되어 알람이 플러터링
- 수정: 히스테리시스 밴드 추가 (예: 20.0 알람, 18.0 해제)
4. **버그 4: 압력 안전 체크가 최대만 확인**
- `press_pv > 6.0`만 체크하고 최소 압력 체크 없음
- 수정: 최소 압력 알람 추가
### Q3. 영향도 분석 — 핵심 포인트
위상 정렬 순서:
1. **FCV-101** (HIGH) — 유량 0으로 인해 제어 불가
2. **E-101** (HIGH) — 유량 없음으로 프리히터 기능 정지
3. **R-101** (HIGH) — 원료 공급 중단으로 반응 정지
4. **C-101** (MED) — 반응 정지로 분리 작업 중단
5. **V-102** (LOW) — 제품 생산 중단이지만 기존 제품 저장 가능
6. **PCV-101** (MED) — 압력 저하로 방출 필요 감소
### Q4. 예측 분석 — 핵심 포인트
- **30분 후**: R-101 반응기 내 잔여 반응물로 과열 가능성 (확신도 60%)
- **1시간 후**: C-101 분리탑 내 압력 불균형 (확신도 70%)
- **2시간 후**: V-101 원료 탱크 오버플로우 위험 (유입은 계속되지만 배출 정지) (확신도 80%)
### Q5. 복구 시나리오 — 핵심 포인트
1. P-101B 스탠바이 펌프 수동 시작 (5분)
2. XV-101 밸브 수동 확인 및 전환 (3분)
3. FCV-101 유량 서서히 증가 (5분)
4. E-101, R-101 상태 확인 (5분)
5. 정상 운전 확인 (2분)
총 예상 시간: 20분
### Q6. 자기 검증 — 평가 기준
- Q1의 시나리오와 Q2의 버그 분석이 일관되는가
- Q3의 영향도 분석이 Q1의 근본 원인과 연결되는가
- Q4의 예측이 Q3의 영향도 분석을 기반으로 하는가
- Q5의 복구 계획이 Q2에서 발견한 버그를 고려하는가
---
## 사용 방법
1. 위 프롬프트를 AI 모델에 그대로 입력
2. 각 질문에 대한 답변을 평가 기준에 따라 점수화
3. 총점을 계산하여 모델의综合能力 평가
4. 여러 모델 간 비교 시 동일한 프롬프트 사용
---
## 변형 버전
필요에 따라 다음 변형이 가능합니다:
- **시간 제한 버전**: 각 질문에 시간 제한 설정 (예: Q1 5분, Q2 5분)
- **부분 정보 버전**: 일부 데이터를 숨겨 추론 능력 평가
- **실시간 버전**: 데이터가 점진적으로 제공되는 시나리오
- **다국어 버전**: 다른 언어로 동일한 시나리오 제공

View File

@@ -1,70 +0,0 @@
# ExperionCrawler 소스 코드 분석 보고서
## 분석 개요
- 분석 대상: `src/` 하위 모든 .cs 파일
- 분석 일자: 2026-04-24
- 분석 모드: Clean Architecture 위반, 빌드 오류, async/await 오용, 예외 처리 누락 등
---
## 파일별 분석 결과
### src/Core/Application/DTOs/
- [x] src/Core/Application/DTOs/ExperionDtos.cs - [심각도: MEDIUM] - 보안 취약점: ServerHostName(192.168.0.20), Port(4840), UserName("mngr"), Password("mngr")가 하드코딩됨
- [x] src/Core/Application/DTOs/TextToSqlDtos.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/DTOs/ValidationFailReason.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/DTOs/ValidationResult.cs - [심각도: LOW] - 문제 없음
### src/Core/Application/Services/
- [x] src/Core/Application/Services/ExperionCrawlService.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/Services/KoreanTimeRangeExtractor.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/Services/KstClock.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/Services/SqlValidator.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/Services/SqlValidatorOptions.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/Services/TextToSqlService.cs - [심각도: LOW] - 문제 없음
- [x] src/Core/Application/Services/TimeRange.cs - [심각도: LOW] - 문제 없음
### src/Core/Domain/
- [x] src/Core/Domain/Entities/ExperionEntities.cs - [심각도: LOW] - 문제 없음
### src/Infrastructure/
- [x] src/Infrastructure/Certificates/ExperionCertificateService.cs - [심각도: LOW] - 문제 없음
- [x] src/Infrastructure/Csv/ExperionCsvService.cs - [심각도: LOW] - 문제 없음
- [x] src/Infrastructure/Csv/AssetLoader.cs - [심각도: LOW] - 문제 없음
- [x] src/Infrastructure/Database/ExperionDbContext.cs - [심각도: LOW] - 문제 없음
- [x] src/Infrastructure/OpcUa/ExperionOpcClient.cs - [심각도: MEDIUM] - obsolete API 사용 (Session.Create, ApplyChanges, Delete, Create) - CS0618 경고
- [x] src/Infrastructure/OpcUa/ExperionOpcServerService.cs - [심각도: LOW] - obsolete API 사용 (Stop) - CS0618 경고
- [x] src/Infrastructure/OpcUa/ExperionRealtimeService.cs - [심각도: MEDIUM] - async/await 오용: Task.Run으로 래핑한 obsolete API 호출, Dispose에서 GetAwaiter().GetResult() 사용 (deadlock 위험)
- [x] src/Infrastructure/OpcUa/ExperionStatusCodeService.cs - [심각도: LOW] - 문제 없음
### src/Web/
- [x] src/Web/Program.cs - [심각도: LOW] - 문제 없음
- [x] src/Web/Controllers/ExperionControllers.cs - [심각도: LOW] - 문제 없음
- [x] src/Web/Controllers/TextToSqlController.cs - [심각도: LOW] - 문제 없음
---
## 전체 요약
### 문제 유형별 통계
- **빌드 오류 가능성**: 0건
- **Clean Architecture 위반**: 0건
- **OPC UA 연결/구독 관리 문제**: 0건
- **TimescaleDB 연결 및 쿼리 패턴 문제**: 0건
- **async/await 오용**: 1건 (ExperionRealtimeService.cs - Dispose에서 GetAwaiter().GetResult() 사용)
- **DI 등록 누락 또는 잘못된 lifetime**: 0건
- **예외 처리 누락 구간**: 0건
- **보안 취약점**: 1건 (ExperionDtos.cs - 하드코딩된 기본값)
- **obsolete API 사용**: 5건 (Session.Create, ApplyChanges, Delete, Create, Stop)
### 총 분석 파일 수: 30개
- Core/Application/DTOs: 4개
- Core/Application/Services: 8개
- Core/Domain/Entities: 1개
- Infrastructure: 8개
- Web: 3개

View File

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 = "Qwen3.6-27B-FP8"
# ── 프로그램 작성 예제 프롬프트 ────────────────────────────────────────────────
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 = "Qwen3.6-27B-FP8"
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,146 +0,0 @@
# 숫자 표시 자릿수 통일 — 전체 프론트엔드 적용
## 목표
`src/Web/wwwroot/js/app.js` 에서 숫자·시각 값을 표시하는 **모든 테이블 렌더 함수**에 아래 두 규칙을 일괄 적용한다.
| 값 종류 | 현재 표시 예시 | 목표 표시 예시 |
|---------|--------------|--------------|
| 타임스탬프 (`recorded_at`, `timeBucket`, `recordedAt`, `bucket` 등) | `2026-04-28 08:15:44.151358+00:00` | `2026-04-28 08:15:44.1` |
| 실수(float) 태그값 | `43.20000076293945` | `43.20` |
- 타임스탬프: **초 소수점 1자리**까지, 타임존 오프셋(`+00:00` 등) 제거
- 실수 태그값: **소수점 2자리**까지 (`toFixed(2)`)
- 정수·문자열·null/undefined 값은 그대로 유지
---
## 작업 기록
### ✅ [2026-04-28 08:55] 작업 시작
- `digit-trunc.md` 읽기 및 작업 계획 수립 완료
- 작업 단위: 7단계 (헬퍼 함수 추가 → 각 함수 수정 → 검증)
### ✅ [2026-04-28 08:55] fmtTs, fmtVal 헬퍼 함수 추가
**파일:** `src/Web/wwwroot/js/app.js` (문서 하단 추가)
```javascript
/**
* 타임스탬프 문자열을 "YYYY-MM-DD HH:MM:SS.f" 형식으로 변환 (소수점 1자리, 시간대 제거).
* ISO 8601 문자열 또는 Date 객체 모두 허용.
*/
function fmtTs(v) {
if (v == null) return '';
const s = String(v);
// "2026-04-28 08:15:44.151358+00:00" 또는 "2026-04-28T08:15:44.151358Z" 형태 처리
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?/);
if (!m) return s;
const frac = m[3] ? m[3].substring(0, 2) : '.0'; // ".1" 한 자리
return `${m[1]} ${m[2]}${frac}`;
}
/**
* 값이 유한 실수이면 소수점 2자리로 반환, 그 외(정수·문자열·null)는 그대로.
*/
function fmtVal(v) {
if (v == null) return v;
const n = Number(v);
if (!Number.isFinite(n)) return v;
if (Number.isInteger(n)) return v; // 정수는 그대로
return n.toFixed(2);
}
```
---
## 수정 대상 함수 목록 (남은 작업)
### 2. `t2sRenderTable` (line ~1483)
- 컬럼명이 시각 관련이면 fmtTs 적용
- 그 외 실수이면 fmtVal 적용
### 3. `renderHistoryTable` (line ~863)
- 시각 열: `fmtTs(r[timeColumn])` 적용
- 값 열: `fmtVal(raw)` 적용
### 4. `pbRender` (line ~608)
- LiveValue 열: `fmtVal(p.liveValue)` 적용
- Timestamp 열: `fmtTs(p.timestamp)` 적용
### 5. `t2sRenderAnalysis` (line ~1565)
- `.toFixed(4)``.toFixed(2)` 변경 (4곳)
---
## 적용 범위 요약
| 함수 | 타임스탬프 fmtTs | 실수값 fmtVal |
|------|:--------------:|:------------:|
| `t2sRenderTable` | ✅ | ✅ |
| `renderHistoryTable` | ✅ | ✅ |
| `pbRender` | ✅ | ✅ |
| `t2sRenderAnalysis` | — | ✅ (toFixed(4)→2) |
---
## 주의 사항
- `fmtTs` / `fmtVal` 은 **표시 전용**이다. API 전송·비교 로직에는 적용하지 않는다.
- `fmtTs``toLocaleString('ko-KR')` 을 **완전히 대체**한다 (시간대 이슈 방지).
- `fmtVal``Number.isInteger` 판별로 정수(`1`, `100`)는 건드리지 않는다.
- null/undefined 처리 기존 로직(`—`, `NULL` 스타일)은 그대로 유지한다.
### ✅ [2026-04-28 09:00] pbRender 함수 수정
**파일:** `src/Web/wwwroot/js/app.js` (608번 함수)
- LiveValue 열: `fmtVal(p.liveValue)` 적용
- Timestamp 열: `fmtTs(p.timestamp)` 적용
### ✅ [2026-04-28 09:00] t2sRenderAnalysis 함수 수정
**파일:** `src/Web/wwwroot/js/app.js` (1564번 함수)
- 평균, 최대, 최소, 표준편차: `.toFixed(4)``.toFixed(2)` 변경 (4곳)
---
## 수정 완료 요약
| 항목 | 상태 |
|------|------|
| fmtTs, fmtVal 헬퍼 함수 추가 | ✅ |
| t2sRenderTable 함수 수정 (타임스탬프, 실수값) | ✅ |
| renderHistoryTable 함수 수정 (시각, 값) | ✅ |
| pbRender 함수 수정 (LiveValue, Timestamp) | ✅ |
| t2sRenderAnalysis 함수 수정 (toFixed 4→2) | ✅ |
---
## 다음 단계
프로젝트 빌드 검증을 진행할까요?
### ✅ [2026-04-28 09:01] 빌드 검증 완료
**결과:** `.NET 8 WinUI3 프로젝트 빌드 성공 (0 Warning / 0 Error)`
---
## 📋 작업 완료 요약
| 항목 | 설명 | 상태 |
|------|------|------|
| **fmtTs** | 타임스탬프 포맷팅 (소수점 1자리, 시간대 제거) | ✅ |
| **fmtVal** | 실수값 포맷팅 (소수점 2자리 정수 유지) | ✅ |
| **t2sRenderTable** | NL2SQL 테이블: 시각/실수값에 fmt 적용 | ✅ |
| **renderHistoryTable** | 이력 테이블: 시각/값에 fmt 적용 | ✅ |
| **pbRender** | 포인트 빌드 테이블: LiveValue/Timestamp에 fmt 적용 | ✅ |
| **t2sRenderAnalysis** | 분석 카드: toFixed(4) → toFixed(2) | ✅ |
| **빌드 검증** | .NET 프로젝트 빌드 성공 | ✅ |
---

View File

@@ -0,0 +1,440 @@
# 포인트빌더 개선 방안 - 코딩 Todo List
## 현황
이전 세션에서 부분적 수정이 적용됨 (pb-n9/n10 제거, custom input 2개 추가 등).
이번 작업은 **전면 재구성**으로, 기존 코드를 완전히 대체합니다.
---
## Step 1: HTML - 포인트빌더 pane 전면 재구성
**변경 파일:** `src/Web/wwwroot/index.html`
### 작업 내용
1. 기존 "조건으로 테이블 작성" card 내부 완전 교체
2. 5개 그룹 카드 생성:
- 컨트롤러 포인트 #1 (`pb-group-controller1`)
- 아날로그 모니터링 포인트 #2 (`pb-group-analogmon1`)
- 디지털 포인트 #1 (`pb-group-digital1`)
- 디지털 포인트 #2 (`pb-group-digital2`)
- 사용자 정의 (`pb-group-custom`)
3. 각 그룹 카드 구성:
- 그룹명 제목 (`card-sub-cap`)
- 태그명 패턴 input (placeholder 포함)
- 속성 체크박스: pv, op, sp, md
- 사용자 정의 속성 input 2개 (작은 크기)
- 데이터 타입 select (전체, Double, i=7594, Boolean, String, Int16, Int32, UInt16, UInt32, Float, DateTime)
4. 수동 포인트 추가, 실시간 구독 제어, 메타데이터 관리 → 유지
5. 포인트 목록 → 유지
### 완성 기준
- [x] 기존 `pb-n1`~`pb-n8` select 드롭다운 제거됨 — **이미 완료** (이전 세션)
- [x] 기존 `pb-dt1`, `pb-dt2` input 제거됨 — **이미 완료**
- [x] 기존 `pb-custom1`, `pb-custom2` input 제거됨 — **이미 완료**
- [x] `pbLoad()` 버튼 제거됨 — **이미 완료**
- [x] 5개 그룹 카드 HTML 생성됨 — **이미 완료** (`index.html:421-598`)
- [x] 각 그룹에 태그명 패턴 input 1개 존재 — **이미 완료**
- [x] 각 그룹에 pv/op/sp/md 체크박스 4개 존재 — **이미 완료**
- [x] 각 그룹에 사용자 속성 input 2개 존재 — **이미 완료**
- [x] 각 그룹에 데이터 타입 select 존재 — **이미 완료**
- [x] 수동 포인트 추가 pane 유지됨 — **이미 완료**
- [x] 실시간 구독 제어 pane 유지됨 — **이미 완료**
- [x] 메타데이터 관리 pane 유지됨 — **이미 완료**
- [x] 포인트 목록 pane 유지됨 — **이미 완료**
- [x] HTML 유효성 검사 통과 (브라우저에서 렌더링 오류 없음) — **이미 완료**
---
## Step 2: CSS - 그룹 카드, 체크박스, 입력창 스타일
**변경 파일:** `src/Web/wwwroot/css/style.css`
### 작업 내용
1. `.pb-group-card` — 그룹 카드 컨테이너 스타일
2. `.pb-group-header` — 그룹명 + 활성화 체크박스
3. `.pb-pattern-input` — 태그명 패턴 input (전체 너비)
4. `.pb-attr-checkboxes` — 속성 체크박스 행 (flex)
5. `.pb-custom-attr-inputs` — 사용자 속성 input 2개 (작은 크기)
6. `.pb-datatype-select` — 데이터 타입 select
### 완성 기준
- [ ] CSS 클래스 정의됨
- [ ] 체크박스 라벨 정렬 정상
- [ ] 작은 input 2개는 inline 배치
- [ ] 반응형 레이아웃 유지 (900px 이하)
- [ ] 기존 스타일과 충돌 없음
---
## Step 3: JS - pbBuild() 그룹 기반 전송 로직
**변경 파일:** `src/Web/wwwroot/js/app.js`
### 작업 내용
1. **중복 `pbBuild()` 함수 삭제 (L671-686)** — 🔴 치명적 버그
- 두 번째 `pbBuild()``PB_NAME_IDS`(미정의 상수)를 참조하여 `ReferenceError` 발생
- JS에서同名 function declaration은 후자가 전자를 덮어씀 → 첫 번째(정상) `pbBuild()`가 동작하지 않음
2. 새 그룹 ID 상수 정의:
- `PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom']`
3. `pbCollectGroupData(groupKey)` 함수:
- 해당 그룹의 태그명 패턴 input 읽기 (쉼표 분할)
- 체크된 속성 체크박스 읽기
- 사용자 속성 input 2개 읽기
- 데이터 타입 select 읽기
- `{ tagPatterns: [], attributes: [], dataType: string|null }` 반환
4. `pbBuild()` 함수 재작성 (첫 번째 정의만 유지, L599-630):
- 5개 그룹 데이터 수집
- 빈 그룹(태그명 패턴 없음)은 제외
- `POST /api/pointbuilder/build` 전송
- 응답 처리 동일 유지
5. `pbRefresh()`, `pbRender()`, `pbAddManual()`, `pbDelete()` → 유지
### 완성 기준
- [x] `PB_GROUPS` 상수 정의됨 — **이미 완료** (`app.js:575`)
- [x] `pbCollectGroupData()` 함수 정의됨 — **이미 완료** (`app.js:577-597`)
- [x] `pbBuild()` 함수 정의됨 — **이미 완료** (`app.js:599-630`)
- [x] 쉼표 구분 패턴 분할 로직 작동 — **이미 완료**
- [x] 체크박스 상태 정확히 읽기 — **이미 완료**
- [x] JSON 구조: `{ controller1: {...}, analogmon1: {...}, ... }` (groups 래퍼 없음) — **이미 완료**
- [x] 빈 그룹은 전송 제외 — **이미 완료**
- [x] **중복 `pbBuild()` (L671-686) 삭제****완료** (2026-05-10)
- [x] 콘솔 에러 없음
---
## Step 4: DTO - PointBuilderGroupDto, PointBuilderBuildDto 재정의
**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`
### 작업 내용
1. 기존 `PointBuilderBuildDto` 클래스 제거
2.`PointBuilderGroupDto` 클래스 추가:
```csharp
public class PointBuilderGroupDto
{
public List<string> TagPatterns { get; set; } = new();
public List<string> Attributes { get; set; } = new();
public string? DataType { get; set; }
}
```
3. 새 `PointBuilderBuildDto` 클래스:
```csharp
public class PointBuilderBuildDto
{
public PointBuilderGroupDto Controller1 { get; set; } = new();
[JsonPropertyName("analogmon1")]
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
public PointBuilderGroupDto Digital1 { get; set; } = new();
public PointBuilderGroupDto Digital2 { get; set; } = new();
public PointBuilderGroupDto Custom { get; set; } = new();
}
```
### 완성 기준
- [x] 기존 `PointBuilderBuildDto` (Names, DataTypes, CustomPatterns) 제거됨 — **이미 완료**
- [x] `PointBuilderGroupDto` 클래스 정의됨 — **이미 완료** (`ExperionDtos.cs:54-59`)
- [x] 새 `PointBuilderBuildDto` 클래스 정의됨 — **이미 완료** (`ExperionDtos.cs:61-69`)
- [x] JSON camelCase 속성: `tagPatterns`, `attributes`, `dataType` — **이미 완료**
- [x] `[JsonPropertyName("analogmon1")]` 속성 적용 — **이미 완료** (`ExperionDtos.cs:64`)
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 5: Interface - BuildRealtimeTableAsync 시그니처 변경
**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`
### 작업 내용
1. 기존 시그니처:
```csharp
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes, IEnumerable<string> customPatterns);
```
2. 새 시그니처:
```csharp
Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups);
```
### 완성 기준
- [x] 시그니처 변경됨 — **이미 완료** (구현체 `ExperionDbContext.cs:514`가 이미 해당 시그니처 사용)
- [x] `PointBuilderGroupDto` 참조 가능 — **이미 완료**
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 6: DB - BuildRealtimeTableAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
### ⚠️ 진단 결과: 기존 EF Core LINQ 구현 유지
Plan이 UNION + Raw SQL 방식을 제안했으나, 진단 결과:
1. 기존 EF Core LINQ 구현(`ExperionDbContext.cs:514-573`)이 이미 정상 동작 중
2. 제안된 Raw SQL 코드는 `.Join()` 컴파일 에러 포함
3. Raw SQL 방식은 SQL Injection 위험 (parameterized query 아님)
4. 기존 LINQ 방식이 type-safe + EF Core 추적 + 테스트 커버리지 보유
**결정: 기존 코드 유지, 변경 없음.**
### 현재 구현 (이미 완료)
```csharp
public async Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return 0;
var allSources = new List<NodeMapMaster>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
allSources.AddRange(sources);
}
var distinctSources = allSources
.GroupBy(s => s.NodeId)
.Select(g => g.First())
.ToList();
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = distinctSources.Select(s => new RealtimePoint
{
TagName = ExtractTagName(s.NodeId),
NodeId = s.NodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
var saved = await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 빌드: {Count}건", saved);
return saved;
}
```
### 완성 기준
- [x] 그룹별 EF.Core LINQ 쿼리 구현됨 — **이미 완료** (`ExperionDbContext.cs:514-573`)
- [x] level = 3 필터 고정 적용 — **이미 완료**
- [x] TagPatterns → EF.Functions.Like OR 조건 — **이미 완료**
- [x] Attributes → Contains (name IN) 조건 — **이미 완료**
- [x] DataType → 등가 조건 (null 생략) — **이미 완료**
- [x] 빈 그룹은 쿼리에서 제외 — **이미 완료**
- [x] 중복 제거 (GroupBy nodeId) — **이미 완료**
- [x] TRUNCATE 후 INSERT 로직 — **이미 완료**
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 7: Controller - DTO 매핑
**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`
### 작업 내용
1. `Build` 액션 수정:
- 기존: `await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes, dto.CustomPatterns)`
- 새: `await _dbSvc.BuildRealtimeTableAsync([dto.Controller1, dto.AnalogMonitor1, dto.Digital1, dto.Digital2, dto.Custom])`
### 완성 기준
- [x] Build 액션에서 DTO 그룹 배열 전달 — **이미 완료** (`ExperionControllers.cs:290-293`)
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 8: 빌드 및 검증
### 작업 내용
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 확인 (0 warning, 0 error)
3. HTML 파일 브라우저에서 렌더링 확인 (선택)
### 완성 기준
- [ ] 빌드 성공
- [ ] 0 warning
- [ ] 0 error
- [ ] 모든 변경 파일 반영됨
---
## 진행 상태 추적
| Step | 상태 | 완료 기준 충족 | 비고 |
|------|------|---------------|------|
| 1. HTML | ✅ 완료 | 전체 체크 | 이전 세션에서 완료 |
| 2. CSS | ✅ 완료 | - | `width: auto` 이미 적용 |
| 3. JS | ✅ 완료 | 전체 체크 | 중복 pbBuild() 삭제 완료 |
| 4. DTO | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 5. Interface | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 6. DB | ✅ 완료 | 전체 체크 | 기존 EF Core LINQ 유지 |
| 7. Controller | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 8. 빌드 | ✅ 완료 | 전체 체크 | 0 Warning, 0 Error |
---
## 진단 결과 (2026-05-10 진단 체크리스트 8단계 적용)
> diagnosis-checklist.md STEP 1~8 순서대로 실행. STEP 6 교차 검증(Q1~Q4) 적용.
---
### 🔴 1. JS 중복 `pbBuild()` — 치명적 버그, 즉시 처리 필요 (HIGH)
**문제**: `app.js`에 `pbBuild()` 함수가 2개 정의되어 있음 (L599, L671). JS에서同名 function declaration은 후자가 전자를 덮어씁니다. L671의 `pbBuild()`는 `PB_NAME_IDS`, `PB_DT_IDS`, `PB_CUSTOM_IDS`를 참조하는데 이 상수들은 어디에도 정의되어 있지 않습니다. 즉 "테이블 작성하기" 버튼 클릭 시 즉시 `ReferenceError: PB_NAME_IDS is not defined` 발생.
**근거**: `app.js:671-686` — 두 번째 `pbBuild()` 정의; `PB_NAME_IDS` 미정의
**영향**: "테이블 작성하기" 버튼 클릭 시 즉시 JS 에러, 포인트 빌드 불가
**수정**: Step 3에 "중복 `pbBuild()` (L671-686) 삭제" 작업 항목 추가 — **본 문서에 반영 완료**
---
### ✅ 2. JS JSON 구조와 C# DTO 불일치 — 오진, 실제 코드에서는 문제 없음 (Q4 탈락)
**문제**: 이전 진단에서 "JS가 `{ groups: {...} }` 래퍼를 전송하여 C# 역직렬화 실패"라고 지적함.
하지만 실제 코드(`app.js:599-618`)는 `groups` 객체를 직접 전송합니다:
```js
const groups = {};
for (const gk of PB_GROUPS) { groups[gk] = gd; }
await api('POST', '/api/pointbuilder/build', groups);
```
C# DTO(`ExperionDtos.cs:64`)는 `[JsonPropertyName("analogmon1")]`으로 JS 키와 일치하므로 실제로 문제 없음.
**근거**: `app.js:599-618` — groups 래퍼 없이 직접 전송; `ExperionDtos.cs:64` — `[JsonPropertyName("analogmon1")]` 존재
**교차 검증**: STEP 6 Q4("실제 장애 시나리오가 있는가?") 탈락 — 재현 불가
**결론**: 이전 진단 삭제, 문서에서 제거
---
### ✅ 3. `.Join()` 컴파일 에러 — Plan 제안 코드의 문제, 실제 코드는 아님 (Q1 탈락)
**문제**: 이전 진단에서 `.Join(separator)` 미존재 메서드를 지적함. 그러나 이 코드는 `ExperionDbContext.cs`의 실제 구현이 아닙니다. 실제 코드는 EF Core LINQ를 사용하며 `Join()`을 전혀 호출하지 않음.
**근거**: `ExperionDbContext.cs:514-573` — 실제 코드는 EF Core LINQ 사용; Plan Step 6 제안 코드에만 `.Join()` 존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락 — 실제 코드에 존재하지 않음
**결론**: Plan Step 6에서 UNION+Raw SQL 제안 삭제, 기존 EF Core LINQ 유지 — **본 문서에 반영 완료**
---
### ✅ 4. Union Raw SQL 변경 — 불필요한 변경 (Q3 탈락)
**문제**: Plan Step 6이 기존 EF Core LINQ를 Raw SQL UNION으로 대체하라고 제안함. 그러나:
1. 기존 LINQ 방식이 이미 정상 동작 중
2. 제안된 Raw SQL 방식은 SQL Injection 위험
3. 제안 코드는 컴파일 에러 포함
**근거**: `ExperionDbContext.cs:514-573` — 기존 EF Core LINQ 구현 정상 동작 중
**교차 검증**: STEP 6 Q3("의도적 설계인가?") — EF Core LINQ가 의도적 설계
**결론**: Step 6에서 Raw SQL 제안 삭제 — **본 문서에 반영 완료**
---
### ✅ 5. `PB_NAME_IDS` 상수 제거 — 이미 존재하지 않음 (Q1 탈락)
**문제**: Plan Step 3에서 "기존 `PB_NAME_IDS`, `PB_DT_IDS`, `PB_CUSTOM_IDS` 상수 제거"라고 함. 그러나 이 상수들은 현재 코드베이스에 존재하지 않음.
**근거**: `app.js` 전체 grep 결과 `PB_NAME_IDS` 미존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 3 작업 내용에서 해당 문구 제거 — **본 문서에 반영 완료**
---
### ✅ 6. `pbLoad()` 함수 제거 — 이미 존재하지 않음 (Q1 탈락)
**문제**: Plan Step 3에서 "기존 `pbLoad()` 함수 제거"라고 함. 그러나 `app.js`에 `pbLoad()` 함수는 존재하지 않음.
**근거**: `app.js` 전체 grep 결과 `pbLoad` 미존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 3 작업 내용에서 해당 문구 제거 — **본 문서에 반영 완료**
---
### ✅ 7. Step 1 완성 기준 — 이미 완료됨 (Q1 탈락)
**문제**: `pb-n1`~`pb-n8` select, `pb-dt1`, `pb-dt2`, `pb-custom1`, `pb-custom2` 제거를 검증 항목으로 포함함. 그러나 현재 HTML에는 이 요소들이 이미 존재하지 않음.
**근거**: `index.html:400-600` — 해당 ID의 요소 없음
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: 완성 기준에서 "[x] 이미 완료"로 표시 — **본 문서에 반영 완료**
---
### ✅ 8. Step 5 Interface 시그니처 — 이미 완료됨 (Q1 탈락)
**문제**: Plan Step 5에서 시그니처 변경을 미완료로 표시함. 그러나 구현체(`ExperionDbContext.cs:514`)가 이미 해당 시그니처를 사용하고 있음.
**근거**: `ExperionDbContext.cs:514` — 이미 `IEnumerable<PointBuilderGroupDto>` 시그니처
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 5에서 "[x] 이미 완료"로 표시 — **본 문서에 반영 완료**
---
### 🟡 참고 — LIKE 패턴 내 `%`, `_` 메타문자
**문제**: EF.Functions.Like에서 `_`가 LIKE 와일드카드(임의 1문자)로 동작. `p-602` 대신 `p602`도 매칭될 수 있음.
**판단**: 사용자가 `%`를 직접 입력하는 방식으로 결정. `_` 문제는 현장에서 실용적으로 문제없음 → **수용**
---
### ✅ 이상 없는 항목
- Step 5 인터페이스 시그니처 변경: 구현체·컨트롤러와 일관됨 — **이미 완료**
- Step 7 컨트롤러 배열 전달: C# 12 컬렉션 표현식 `[...]` 문법 — .NET 8 이상에서 정상 컴파일 — **이미 완료**
- Step 6 TRUNCATE 후 INSERT 흐름: 정상 — **이미 완료**
- Step 3 빈 그룹 제외 로직: Step 6의 `activeGroups.Count == 0` 가드와 일관됨 — **이미 완료**
---
## 최종 요약
| 등급 | 수 | 내용 |
|------|---|------|
| 🔴 HIGH | 1 | 중복 `pbBuild()` (L671-686) 삭제 필요 — 즉시 처리 |
| ✅ 오진 | 7 | 이미 완료됨, 존재하지 않음, 재현 불가 → 문서 반영 완료 |
| 🟡 참고 | 1 | LIKE `_` 메타문자 — 수용 |
**현재 남은 작업:**
1. 🔴 `app.js` L671-686 중복 `pbBuild()` 삭제
2. Step 2 CSS: `.pb-custom-attr-inputs .inp`에 `width: auto` 추가 (이미 완료됨)
3. 빌드 검증 (`dotnet build`)

View File

@@ -0,0 +1,689 @@
# 포인트빌더 개선 방안 - 코딩 2
## 1. 문제 정의
현재 "테이블 작성하기" 버튼은 조건에 맞는 포인트를 즉시 `realtime_table`에 TRUNCATE + INSERT 합니다.
**문제:**
- 빌드 결과가 의도와 다른지 확인할 수 없음
- 잘못된 조건으로 빌드 시 기존 liveValue 데이터 손실
- 521,958개 node_map_master 레코드 중 실수로 전체 선택 시 대규모 데이터 삽입 가능
**목표:** 빌드 전에 결과 미리보기 → 개별 체크 → 원하는 것만 선택하여 적용
---
## 2. 효율적 UI 구성 방안
### 2.1 왜 모달(팝업)이 아닌 인라인 확장인가?
| 고려사항 | 모달 | 인라인 확장 |
|----------|------|-------------|
| 포인트 수 | 수백~수천 개 | 동일 |
| 스크롤 | 모달 내부 스크롤 + 페이지 스크롤 충돌 | 자연스러운 페이지 흐름 |
| 체크 상태 유지 | 모달 닫으면 초기화 | 유지 가능 |
| 테이블 비교 | 기존 테이블과 별도 창 | 바로 아래에 표시, 비교 용이 |
| 모바일 | 화면 절반 가림 | 자연스럽게 스크롤 |
**결정: 인라인 확장** — "테이블 작성하기" 옆에 "미리보기" 버튼 추가, 결과를 기존 포인트 목록 위에 인라인으로 표시.
### 2.2 UI 플로우
```
┌─────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
│ ┌─ 컨트롤러 포인트 #1 ──────────────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌─ 아날로그 모니터링 포인트 #2 ────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ (나머지 그룹...) │
│ │
│ [🔍 미리보기] [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
│ ▼ (미리보기 클릭 후 인라인 표시) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 미리보기 결과 (127개 포인트) │ │
│ │ [전체 선택] [전체 해제] [역전] 선택: 127/127 │ │
│ │ ┌──┬ ID ┬ TagName ┬ NodeType ┬ DataType┐ │ │
│ │ │☑ │ 1 │ FICQ-2113.PV │ pv │ Double │ │ │
│ │ │☑ │ 2 │ FICQ-2113.OP │ op │ Double │ │ │
│ │ │☐ │ 3 │ FICQ-2113.SP │ sp │ Double │ │ │
│ │ │☑ │ 4 │ TIC-2101.PV │ pv │ Double │ │ │
│ │ │ │ ... │ ... │ ... │ ... │ │ │
│ │ └──┴─────┴──────────────────┴──────────┴─────────┘ │ │
│ │ [취소] [선택된 126개 적용하기] │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.3 핵심 UX 결정
1. **미리보기는 READ-ONLY**: DB를 변경하지 않음. node_map_master에서 조건에 맞는 레코드만 조회
2. **기본 전체 체크**: 미리보기 시 모든 포인트가 체크된 상태로 표시 (대부분의 경우 전체 적용)
3. **그룹별 색상 라벨**: 어떤 그룹 조건에서 매칭되었는지 표시
4. **검색/필터**: 미리보기 테이블에 태그명 검색 입력창 제공 (수천 개 중 찾기)
5. **"테이블 작성하기"는 유지**: 기존처럼 조건 → 즉시 빌드 (미리보기 없이 빠른 빌드)
---
## 3. 아키텍처 변경
### 3.1 변경 파일
| 파일 | 변경 내용 |
|------|----------|
| `src/Web/wwwroot/index.html` | 미리보기 버튼 + 인라인 미리보기 영역 추가 |
| `src/Web/wwwroot/js/app.js` | `pbPreview()`, `pbCancelPreview()`, `pbApplySelected()`, `pbRenderPreview()` 추가 |
| `src/Web/wwwroot/css/style.css` | `.pb-preview` 스타일 추가 |
| `src/Core/Application/DTOs/ExperionDtos.cs` | `PointBuilderPreviewResultDto` 추가 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `PreviewRealtimeBuildAsync()` 인터페이스 추가 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `PreviewRealtimeBuildAsync()` 구현 |
| `src/Web/Controllers/ExperionControllers.cs` | `POST /api/pointbuilder/preview`, `POST /api/pointbuilder/apply` 추가 |
### 3.2 API 엔드포인트
```
GET /api/pointbuilder/points — 기존 유지 (realtime_table 조회)
POST /api/pointbuilder/build — 기존 유지 (즉시 빌드)
POST /api/pointbuilder/preview — NEW: 조건에 맞는 포인트 조회 (DB 변경 없음)
POST /api/pointbuilder/apply — NEW: 선택된 포인트만 realtime_table에 적용
POST /api/pointbuilder/add — 기존 유지 (수동 추가)
DELETE /api/pointbuilder/{id} — 기존 유지 (삭제)
```
### 3.3 데이터 흐름
```
┌────────────────────────────────────────────────────────────────────┐
│ [미리보기] 클릭 │
│ POST /api/pointbuilder/preview ← groups (기존 build와 동일) │
│ ↓ │
│ C#: PreviewRealtimeBuildAsync() │
│ → 각 그룹별 node_map_master 쿼리 (기존 BuildRealtimeTableAsync와 │
│ 동일한 쿼리 로직, 하지만 TRUNCATE/INSERT 없이 결과만 반환) │
│ → 중복 제거 (GroupBy nodeId) │
│ → { count, items: [{ nodeId, tagName, name, dataType, group }] } │
│ ↓ │
│ JS: pbRenderPreview() — 체크박스 테이블 렌더링 │
│ ↓ │
│ 사용자가 체크/해제 → [선택된 N개 적용하기] 클릭 │
│ ↓ │
│ POST /api/pointbuilder/apply ← { selectedNodeIds: [...] } │
│ ↓ │
│ C#: ApplySelectedPointsAsync() │
│ → TRUNCATE realtime_table │
│ → selectedNodeIds만 INSERT │
│ → pbRefresh() → 포인트 목록 갱신 │
└────────────────────────────────────────────────────────────────────┘
```
---
## 4. 코딩 Todo List
### Step 1: C# DTO — Preview 결과 DTO 추가
**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`
```csharp
public class PointBuilderPreviewItem
{
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; // pv, op, sp, md 등
public string DataType { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty; // 어떤 그룹에서 매칭
}
public class PointBuilderPreviewResult
{
public int Count { get; set; }
public List<PointBuilderPreviewItem> Items { get; set; } = new();
}
```
**완성 기준:**
- [ ] 두 클래스 추가됨
- [ ] 빌드 컴파일 성공
---
### Step 2: C# Interface — Preview 메서드 추가
**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`
```csharp
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
```
**참고:** `(string GroupKey, PointBuilderGroupDto Group)` 튜플 사용 — Step 3에서 그룹명 태그에 필요.
**완성 기준:**
- [ ] 두 메서드 시그니처 추가됨
- [ ] 빌드 컴파일 성공 (구현체는 다음 Step)
---
### Step 3: C# DB — PreviewRealtimeBuildAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
**구현 로직:**
- 기존 `BuildRealtimeTableAsync`와 동일한 쿼리 로직 재사용
- 각 그룹별로 쿼리 실행 → 결과에 `Group` 속성 태그
- `GroupBy(NodeId)` 중복 제거
- TRUNCATE/INSERT 없이 `PointBuilderPreviewResult` 반환
```csharp
public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return new PointBuilderPreviewResult();
var allSources = new List<(NodeMapMaster Node, string Group)>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
var groupName = GetGroupName(g); // Controller1, AnalogMonitor1 등
foreach (var s in sources)
allSources.Add((s, groupName));
}
var distinct = allSources
.GroupBy(x => x.Node.NodeId)
.Select(g => g.First())
.ToList();
var items = distinct.Select(x => new PointBuilderPreviewItem
{
NodeId = x.Node.NodeId,
TagName = ExtractTagName(x.Node.NodeId),
Name = x.Node.Name,
DataType = x.Node.DataType,
Group = x.Group
}).ToList();
return new PointBuilderPreviewResult { Count = items.Count, Items = items };
}
```
**문제:** `GetGroupName(g)``PointBuilderGroupDto`에는 그룹명이 없음. 해결: 컨트롤러에서 그룹 키와 함께 전달하도록 변경.
**수정안:** 인터페이스 시그니처를 `IEnumerable<(string GroupKey, PointBuilderGroupDto Group)>`로 변경.
**완성 기준:**
- [ ] 쿼리 로직 구현됨 (기존 BuildRealtimeTableAsync와 동일)
- [ ] TRUNCATE/INSERT 없음 (READ-ONLY)
- [ ] 중복 제거 (GroupBy nodeId)
- [ ] 그룹 정보 포함
- [ ] 빌드 컴파일 성공
---
### Step 4: C# DB — ApplySelectedPointsAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
```csharp
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
{
var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (nodeIds.Count == 0) return 0;
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = nodeIds.Select(nodeId => new RealtimePoint
{
TagName = ExtractTagName(nodeId),
NodeId = nodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
return points.Count;
}
```
**완성 기준:**
- [ ] TRUNCATE 후 선택된 nodeId만 INSERT
- [ ] 빌드 컴파일 성공
---
### Step 5: C# Controller — preview + apply 엔드포인트 추가
**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`
```csharp
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
{
var groups = new[]
{
("controller1", dto.Controller1),
("analogmon1", dto.AnalogMonitor1),
("digital1", dto.Digital1),
("digital2", dto.Digital2),
("custom", dto.Custom)
};
var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);
return Ok(new
{
count = result.Count,
items = result.Items.Select(i => new
{
nodeId = i.NodeId,
tagName = i.TagName,
name = i.Name,
dataType = i.DataType,
group = i.Group
})
});
}
[HttpPost("apply")]
public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
{
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);
return Ok(new { success = true, count, message = $"{count}개 포인트 적용 완료" });
}
```
**추가 DTO:** `ExperionDtos.cs`
```csharp
public class PointBuilderApplyDto
{
public List<string> SelectedNodeIds { get; set; } = new();
}
```
**완성 기준:**
- [ ] 두 엔드포인트 추가됨
- [ ] camelCase JSON 응답
- [ ] PointBuilderApplyDto 추가됨
- [ ] 빌드 컴파일 성공
---
### Step 6: HTML — 미리보기 버튼 + 영역 추가
**변경 파일:** `src/Web/wwwroot/index.html`
**6a. 버튼 행 수정 (기존 L600-603):**
```html
<div class="btn-row">
<button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
</div>
```
**6b. 미리보기 영역 추가 (조건으로 테이블 작성 card 내부, 버튼 행 바로 아래):**
```html
<div id="pb-preview" class="pb-preview hidden">
<div class="pb-preview-header">
<span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
<div class="pb-preview-actions">
<button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
<button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
<button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
<span id="pb-preview-selected" class="mut">선택: 0/0</span>
</div>
</div>
<div class="fg" style="margin-bottom:8px">
<input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
</div>
<div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
</div>
</div>
```
**완성 기준:**
- [ ] 미리보기 버튼 추가됨
- [ ] 미리보기 영역 HTML 추가됨 (hidden 기본)
- [ ] 전체 선택/해제/역전 버튼 포함
- [ ] 검색 입력창 포함
- [ ] 취소 + 적용 버튼 포함
- [ ] HTML 유효성 검사 통과
---
### Step 7: CSS — 미리보기 스타일 추가
**변경 파일:** `src/Web/wwwroot/css/style.css`
```css
.pb-preview {
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin-top: 10px;
}
.pb-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
font-size: 13px;
}
.pb-preview-actions {
display: flex;
gap: 6px;
align-items: center;
}
.pb-preview table th:first-child,
.pb-preview table td:first-child {
width: 36px;
text-align: center;
}
.pb-preview table input[type="checkbox"] {
cursor: pointer;
width: 15px;
height: 15px;
}
.pb-preview .group-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--ag);
color: var(--a);
font-weight: 600;
}
@media (max-width: 900px) {
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
.pb-preview-actions { flex-wrap: wrap; }
}
```
**완성 기준:**
- [ ] `.pb-preview` 스타일 정의됨
- [ ] 체크박스 컬럼 너비 조정
- [ ] 그룹 배지 스타일
- [ ] 반응형 대응
- [ ] 기존 스타일과 충돌 없음
---
### Step 8: JS — 미리보기 로직 추가
**변경 파일:** `src/Web/wwwroot/js/app.js`
**8a. 전역 변수 (L575 근처):**
```javascript
let pbPreviewData = []; // 미리보기 원본 데이터
```
**8b. 새 함수 추가:**
```javascript
async function pbPreview() {
const groups = {};
for (const gk of PB_GROUPS) {
const gd = pbCollectGroupData(gk);
if (gd.tagPatterns.length > 0) {
groups[gk] = gd;
}
}
const activeKeys = Object.keys(groups);
if (activeKeys.length === 0) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
return;
}
setGlobal('busy', '미리보기 조회 중');
try {
const d = await api('POST', '/api/pointbuilder/preview', groups);
pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
document.getElementById('pb-preview').classList.remove('hidden');
pbRenderPreview(pbPreviewData);
setGlobal('ok', `미리보기: ${d.count}개 포인트`);
} catch (e) {
setGlobal('err', '미리보기 실패');
}
}
function pbRenderPreview(data) {
const el = document.getElementById('pb-preview-table');
const filtered = pbGetFilteredPreview();
const pts = filtered.length > 0 ? filtered : data;
if (pts.length === 0) {
el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
pbUpdatePreviewCount();
return;
}
el.innerHTML = `
<table>
<thead>
<tr>
<th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
<th>ID</th>
<th>TagName</th>
<th>NodeType</th>
<th>DataType</th>
<th>Group</th>
</tr>
</thead>
<tbody>
${pts.map((p, i) => `
<tr style="${!p.selected ? 'opacity:0.5' : ''}">
<td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
<td class="mut">${i + 1}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<td>${esc(p?.name || '')}</td>
<td class="mut">${esc(p?.dataType || '')}</td>
<td><span class="group-badge">${esc(p?.group || '')}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
pbUpdatePreviewCount();
}
function pbPreviewToggleItem(idx) {
pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
pbUpdatePreviewCount();
}
function pbPreviewToggleAll(checked) {
pbPreviewData.forEach((item, idx) => {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (searchVal) {
const filtered = pbGetFilteredPreview();
if (filtered.includes(item)) {
item.selected = checked;
}
} else {
item.selected = checked;
}
});
pbRenderPreview(pbPreviewData);
}
function pbPreviewSelectAll() {
pbPreviewData.forEach(p => p.selected = true);
pbRenderPreview(pbPreviewData);
}
function pbPreviewDeselectAll() {
pbPreviewData.forEach(p => p.selected = false);
pbRenderPreview(pbPreviewData);
}
function pbPreviewInvert() {
pbPreviewData.forEach(p => p.selected = !p.selected);
pbRenderPreview(pbPreviewData);
}
function pbGetFilteredPreview() {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (!searchVal) return [];
return pbPreviewData.filter(p =>
(p.tagName || '').toLowerCase().includes(searchVal) ||
(p.nodeId || '').toLowerCase().includes(searchVal) ||
(p.name || '').toLowerCase().includes(searchVal)
);
}
function pbPreviewFilter() {
pbRenderPreview(pbPreviewData);
}
function pbUpdatePreviewCount() {
const selected = pbPreviewData.filter(p => p.selected).length;
const total = pbPreviewData.length;
document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
}
function pbCancelPreview() {
document.getElementById('pb-preview').classList.add('hidden');
pbPreviewData = [];
}
async function pbApplySelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '적용할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 적용 중`);
try {
const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '적용 오류');
}
}
```
**완성 기준:**
- [ ] `pbPreviewData` 전역 변수 추가됨
- [ ] `pbPreview()` 함수 추가됨
- [ ] `pbRenderPreview()` 함수 추가됨
- [ ] `pbPreviewToggleItem()`, `pbPreviewToggleAll()` 추가됨
- [ ] `pbPreviewSelectAll()`, `pbPreviewDeselectAll()`, `pbPreviewInvert()` 추가됨
- [ ] `pbGetFilteredPreview()`, `pbPreviewFilter()` 검색 기능 추가됨
- [ ] `pbUpdatePreviewCount()` 카운트 업데이트
- [ ] `pbCancelPreview()` 취소
- [ ] `pbApplySelected()` 선택된 것만 적용
- [ ] `pbPreviewData``idx` 속성 부여 (indexOf 버그 방지)
- [ ] 콘솔 에러 없음
---
### Step 9: 빌드 및 검증
**작업 내용:**
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 (0 error, 0 warning)
3. 브라우저에서 테스트:
- 조건 입력 → 미리보기 → 결과 확인
- 체크/해제/역전 동작
- 검색 필터 동작
- 선택된 것만 적용 → 포인트 목록 갱신
- 기존 "테이블 작성하기" 버튼도 정상 동작
**완성 기준:**
- [ ] 빌드 성공 (0 error, 0 warning)
- [ ] 미리보기 버튼 클릭 시 결과 표시
- [ ] 전체 선택/해제/역전 정상
- [ ] 검색 필터 정상
- [ ] 선택 적용 후 포인트 목록 갱신
- [ ] 기존 빌드 버튼 영향 없음
- [ ] 기존 pbRefresh/pbDelete 영향 없음
---
## 5. 진행 상태 추적
| Step | 상태 | 비고 |
|------|------|------|
| 1. DTO | ⬜ 미완료 | PointBuilderPreviewItem, PointBuilderPreviewResult, PointBuilderApplyDto |
| 2. Interface | ⬜ 미완료 | PreviewRealtimeBuildAsync, ApplySelectedPointsAsync |
| 3. DB Preview | ⬜ 미완료 | READ-ONLY 쿼리 |
| 4. DB Apply | ⬜ 미완료 | 선택된 nodeId만 TRUNCATE + INSERT |
| 5. Controller | ⬜ 미완료 | preview + apply 엔드포인트 |
| 6. HTML | ⬜ 미완료 | 미리보기 버튼 + 영역 |
| 7. CSS | ⬜ 미완료 | .pb-preview 스타일 |
| 8. JS | ⬜ 미완료 | 미리보기 로직 (중복 pbBuild 이미 삭제 완료) |
| 9. 빌드 | ⬜ 미완료 | 검증 |
---
## 6. 부가 사항
### 6.1 기존 버그 수정 (이미 완료)
- **중복 `pbBuild()` 함수 (app.js L671-686):** ✅ 이미 삭제 완료 (코딩.md 반영 시)
- **`pbRender()` 테이블 컬럼 불일치 (app.js L660-662):** ✅ 컬럼 순서 확인 완료
### 6.2 향후 개선 (별도 작업)
- 그룹별 색상 배지 (컨트롤러=파랑, 아날로그=초록, 디지털=주황)
- 미리보기 결과 Excel/PDF 내보내기
- 최근 미리보기 조건 저장 (localStorage)
- 페이지네이션 (1000개 이상 시)

View File

@@ -0,0 +1,242 @@
# 포인트빌더 개선 방안
## 1. 현재 문제점
### 1.1 구조적 문제
`node_map_master` 테이블의 level 3 레코드 구조:
| name | node_id | data_type | level |
|------|---------|-----------|-------|
| `pv` | `ns=1;s=sinamserver:ficq-2113.pv` | `Double` | 3 |
| `op` | `ns=1;s=sinamserver:ficq-2113.op` | `Double` | 3 |
| `sp` | `ns=1;s=sinamserver:ficq-2113.sp` | `Double` | 3 |
| `md` | `ns=1;s=sinamserver:ficq-2113.md` | `i=7594` | 3 |
| `pv` | `ns=1;s=sinamserver:p-602.pv` | `i=7594` | 3 |
| `op` | `ns=1;s=sinamserver:xv-402.op` | `i=7594` | 3 |
- `name`은 속성명만 (`pv`, `op`, `sp`, `md` 등) — 태그명 없음
- 태그명은 `node_id` 안에 포함됨
- **521,958개**의 level 3 레코드가 존재
- 현재 드롭다운이 `name` 전체를 로드 → `pv` 선택 시 **모든** 컨트롤러의 pv全选됨
### 1.2 속성별 데이터 타입 분포
| 속성 | data_type | 설명 |
|------|-----------|------|
| `pv` | Double / i=7594 | 아날로그/디지털 |
| `op` | Double / i=7594 | 아날로그/디지털 |
| `sp` | Double / Boolean | 아날로그/디지털 |
| `md` | i=7594 | 모드 표시 (StatusCode) |
| `mode` | i=7594 | 모드 (md와 중복 → 제외) |
| `a1~a4` | Boolean / Double | Aux parameter (Alarm 아님 → 제외) |
| `qv.value` | Double | 품질 값 |
### 1.3 태그명 규칙의 다양성
- 회사마다, 프로젝트마다 태그명 규칙이 다름
- 정형화된 드롭다운/조건으로는 모든 케이스 커버 불가
- 사용자 자유도가 필수
---
## 2. 개선 방안
### 2.1 UI 구성
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 컨트롤러 포인트 #1 ────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %fic%, %lic%, %tic%, %pic% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [Double ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 아날로그 모니터링 포인트 #2 ───────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %fi%, %ti%, %li%, %pia% ] │ │
│ │ ☑ pv ☐ op ☐ sp ☐ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [Double ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 디지털 포인트 #1 ──────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %p-%, %xv-%, %vp-% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [i=7594 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 디지털 포인트 #2 ──────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [i=7594 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 사용자 정의 ───────────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %ficq-6113%, %ti-6101% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [전체 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 각 그룹의 동작
**태그명 패턴 입력:**
- 쉼표(`,`)로 구분하여 여러 패턴 입력
- 각 패턴은 `node_id`의 태그명 부분에 LIKE 매칭
- `%` 자동 추가 (선택): 사용자가 `%fic%` 입력 시 `%fic%`로, `fic` 입력 시 `%fic%`로 변환
- 예: `fic, tic, lica``%fic%` OR `%tic%` OR `%lica%`
**속성 체크박스:**
- `pv`, `op`, `sp`, `md` 기본 체크박스
- 오른쪽 작은 입력창 2개: 사용자 정의 속성 (예: `qv.value`, `tagname`)
- 체크된 속성 + 입력된 사용자 속성이 AND 조건으로 필터
**데이터 타입 풀다운:**
- `전체` (필터 없음)
- `Double` (아날로그)
- `i=7594` (디지털, StatusCode)
- `Boolean`
- `String`
- `Int16`, `Int32`, `UInt16`, `UInt32`
- `Float`, `DateTime`
### 2.3 백엔드 쿼리 로직
각 그룹별로 독립적인 쿼리를 생성하고 UNION DISTINCT:
```sql
-- 그룹 1: 컨트롤러 포인트
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%fic%' OR node_id LIKE '%tic%' OR node_id LIKE '%lica%')
AND name IN ('pv', 'op', 'sp', 'md')
AND data_type = 'Double'
UNION
-- 그룹 2: 아날로그 모니터링
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%fi%' OR node_id LIKE '%ti%' OR node_id LIKE '%li%')
AND name IN ('pv')
AND data_type = 'Double'
UNION
-- 그룹 3: 디지털 포인트 #1
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%p-%' OR node_id LIKE '%xv-%' OR node_id LIKE '%vp-%')
AND name IN ('pv', 'op', 'sp', 'md')
AND data_type = 'i=7594'
UNION
-- 그룹 4: 디지털 포인트 #2
-- (그룹 3과 동일 구조, 다른 패턴)
UNION
-- 그룹 5: 사용자 정의
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%custom1%' OR node_id LIKE '%custom2%')
AND name IN ('pv', 'op', 'sp', 'md', 'customAttr1', 'customAttr2')
-- data_type 필터 없음 (전체)
```
### 2.4 DTO 구조
```csharp
public class PointBuilderGroupDto
{
public List<string> TagPatterns { get; set; } = new(); // 쉼표 구분 → 분할
public List<string> Attributes { get; set; } = new(); // 체크박스 + 사용자 속성
public string? DataType { get; set; } // null = 전체
}
public class PointBuilderBuildDto
{
public PointBuilderGroupDto Controller1 { get; set; } = new();
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
public PointBuilderGroupDto Digital1 { get; set; } = new();
public PointBuilderGroupDto Digital2 { get; set; } = new();
public PointBuilderGroupDto Custom { get; set; } = new();
}
```
---
## 3. 추가 제안
### 3.1 미리보기 기능
테이블 작성 전에 조건에 맞는 레코드 수와 샘플을 표시:
```
[🔍 미리보기] → "조건에 맞는 레코드: 1,247개 (샘플: ficq-2113.pv, ficq-2113.op, ...)"
```
### 3.2 그룹 활성화 토글
각 그룹에 체크박스 추가하여 비활성화 가능한 그룹 표시:
```
☑ 컨트롤러 포인트 #1
☐ 아날로그 모니터링 포인트 #2 ← 비활성화 (쿼리에서 제외)
```
### 3.3 최근 조건 저장/로드
사용자가 자주 사용하는 조건 조합을 로컬 스토리지에 저장:
```
[💾 조건 저장] [📂 조건 로드] [삭제]
```
### 3.4 태그명 패턴 자동완성
level 2 name (`ficq-2113`, `ficq-2114` 등)을 기반으로 자동완성 제공:
```
사용자 입력: "fic" → 제안: fic, ficq-111, ficq-113, ficq-122, ficq-124, ...
```
### 3.5 중복 제거
UNION으로 결합 시 중복 레코드 자동 제거 (UNION DISTINCT).
---
## 4. 변경 범위
| 파일 | 변경 내용 |
|------|----------|
| `wwwroot/index.html` | 포인트빌더 pane 전면 재구성 |
| `wwwroot/js/app.js` | `pbLoad()` 제거, `pbBuild()` 그룹 기반 전송 |
| `wwwroot/css/style.css` | 그룹 카드, 체크박스, 입력창 스타일 |
| `Core/DTOs/ExperionDtos.cs` | `PointBuilderGroupDto`, `PointBuilderBuildDto` 재정의 |
| `Core/Interfaces/IExperionServices.cs` | `BuildRealtimeTableAsync` 시그니처 변경 |
| `Infrastructure/Database/ExperionDbContext.cs` | UNION 기반 쿼리 로직 |
| `Web/Controllers/ExperionControllers.cs` | DTO 매핑 |
---
## 5. 데이터 타입 참고
| data_type | 설명 | 포인트 유형 |
|-----------|------|------------|
| `Double` | 부동소수점 | 아날로그 (pv/op/sp) |
| `i=7594` | StatusCode | 디지털 (pv/op/sp/md) |
| `Boolean` | 참/거짓 | sp (디지털), a1~a4 |
| `String` | 문자열 | desc, name, area |
| `Int16` | 정수 16bit | eulo, euhi, pvperiod |
| `Int32` | 정수 32bit | numberofparents |
| `DateTime` | 일시 | lastscannedtime |

View File

@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="netDxf" Version="2022.11.2" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Web\ExperionCrawler.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,62 +0,0 @@
using System;
using System.IO;
using System.Text;
using netDxf;
class TestDxfExtract
{
static void Main()
{
string filePath = "/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf";
Console.WriteLine("=== DXF 추출 로직 테스트 ===\n");
// 1. DXF 파일 로드 테스트
Console.WriteLine("1. DXF 파일 로드 테스트");
var doc = DxfDocument.Load(filePath);
Console.WriteLine($" ✓ 파일 로드 성공");
Console.WriteLine($" - Lines: {doc.Entities.Lines.Count()}");
Console.WriteLine($" - Circles: {doc.Entities.Circles.Count()}");
Console.WriteLine($" - Texts: {doc.Entities.Texts.Count()}");
Console.WriteLine($" - MTexts: {doc.Entities.MTexts.Count()}");
Console.WriteLine($" - Inserts: {doc.Entities.Inserts.Count()}");
// 2. TEXT 추출 테스트
Console.WriteLine("\n2. TEXT 추출 테스트");
var sb = new StringBuilder();
int textCount = 0;
foreach (var txt in doc.Entities.Texts)
{
sb.AppendLine(txt.Value);
textCount++;
}
Console.WriteLine($" ✓ TEXT 추출 성공: {textCount}개, {sb.Length} bytes");
// 3. MTEXT 추출 테스트
Console.WriteLine("\n3. MTEXT 추출 테스트");
int mtextCount = 0;
foreach (var mtxt in doc.Entities.MTexts)
{
sb.AppendLine(mtxt.PlainText());
mtextCount++;
}
Console.WriteLine($" ✓ MTEXT 추출 성공: {mtextCount}개");
// 4. AttributeDefinition 추출 테스트
Console.WriteLine("\n4. AttributeDefinition 추출 테스트");
int attrCount = 0;
foreach (var blk in doc.Blocks)
{
foreach (var attr in blk.AttributeDefinitions.Values)
{
sb.AppendLine(attr.Value);
attrCount++;
}
}
Console.WriteLine($" ✓ AttributeDefinition 추출 성공: {attrCount}개");
// 전체 추출 결과를 stdout으로 직접 출력 (MCP 서버 테스트용)
var text = sb.ToString();
Console.WriteLine(text);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,335 +0,0 @@
{
"success": true,
"count": 41,
"tags": [
{
"tagNo": "PIC-6211",
"equipmentName": null,
"instrumentType": "PIC",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LIA-10800",
"equipmentName": null,
"instrumentType": "LIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-6601",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10101",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TICA-10111",
"equipmentName": null,
"instrumentType": "TICA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-10111",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10111",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-10111",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10111",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-10111",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10111",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10115",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "PICA-10111",
"equipmentName": null,
"instrumentType": "PICA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LICA-10113",
"equipmentName": null,
"instrumentType": "LICA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LIA-10111",
"equipmentName": null,
"instrumentType": "LIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-10100",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TI-10103",
"equipmentName": null,
"instrumentType": "TI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10201",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TICA-10211",
"equipmentName": null,
"instrumentType": "TICA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-10211",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10211",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-10211",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10211",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TIA-10211",
"equipmentName": null,
"instrumentType": "TIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10211",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TI-10203",
"equipmentName": null,
"instrumentType": "TI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FICQ-10215",
"equipmentName": null,
"instrumentType": "FICQ",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-10201",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "PICA-10211",
"equipmentName": null,
"instrumentType": "PICA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LICA-10213",
"equipmentName": null,
"instrumentType": "LICA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LIA-10211",
"equipmentName": null,
"instrumentType": "LIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-10221",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-10200",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TI-10650",
"equipmentName": null,
"instrumentType": "TI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TI-10600",
"equipmentName": null,
"instrumentType": "TI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-10128",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LIA-3210",
"equipmentName": null,
"instrumentType": "LIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-9128",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LI-10128",
"equipmentName": null,
"instrumentType": "LI",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "LIA-3210",
"equipmentName": null,
"instrumentType": "LIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "PIA-2900",
"equipmentName": null,
"instrumentType": "PIA",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
}
],
"processing_time_sec": 237.9
}

View File

@@ -1,151 +0,0 @@
{
"success": true,
"count": 18,
"tags": [
{
"tagNo": "FCV-10101",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10201",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10116",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10216",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10114A",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10214",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10118",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10218",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10213",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10113",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10101",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "FCV-10201",
"equipmentName": null,
"instrumentType": "FCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TCV-10111",
"equipmentName": null,
"instrumentType": "TCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "TCV-10211",
"equipmentName": null,
"instrumentType": "TCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "XV-10111",
"equipmentName": null,
"instrumentType": "XV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "XV-10211",
"equipmentName": null,
"instrumentType": "XV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "PCV-10111",
"equipmentName": null,
"instrumentType": "PCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
},
{
"tagNo": "PCV-10211",
"equipmentName": null,
"instrumentType": "PCV",
"lineNumber": null,
"pidDrawingNo": null,
"confidence": 0.95
}
],
"processing_time_sec": 72.4
}

View File

@@ -1,130 +0,0 @@
using System;
using System.Text;
using System.Text.Json;
using System.Net.Http;
using System.Threading.Tasks;
namespace TestMcpClient
{
public class McpResponse
{
public string? jsonrpc { get; set; }
public string? id { get; set; }
public McpErrorBody? error { get; set; }
public McpResult? result { get; set; }
}
public class McpErrorBody
{
public int? code { get; set; }
public string? message { get; set; }
}
public class McpResult
{
public McpContentItem[]? content { get; set; }
}
public class McpContentItem
{
public string type { get; set; } = string.Empty;
public string? text { get; set; }
public string? data { get; set; }
}
class Program
{
static async Task Main(string[] args)
{
var client = new HttpClient();
// P-9100.dxf 파일의 텍스트 읽기
var dxfText = await File.ReadAllTextAsync("/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf");
Console.WriteLine($"DXF 텍스트 길이: {dxfText.Length}");
// MCP 서버에 추출 요청
var request = new
{
jsonrpc = "2.0",
id = "test-1",
method = "tools/call",
@params = new { name = "extract_pid_tags", arguments = new { text = dxfText, source_type = "dxf" } }
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5001/mcp")
{
Content = content
};
httpRequest.Headers.Add("Accept", "application/json");
httpRequest.Headers.Add("mcp-protocol-version", "2025-03-26");
Console.WriteLine("MCP 서버에 요청 중...");
var response = await client.SendAsync(httpRequest);
Console.WriteLine($"Status: {response.StatusCode}");
if (response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<McpResponse>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Console.WriteLine($"\nDeserialized successfully!");
Console.WriteLine($"result: {result?.result != null}");
Console.WriteLine($"result.content: {result?.result?.content?.Length ?? 0}");
if (result?.result?.content != null)
{
var sb = new StringBuilder();
foreach (var item in result.result.content)
{
if (item.type == "text")
{
Console.WriteLine($" content item: type={item.type}, text length={item.text?.Length ?? 0}");
sb.AppendLine(item.text);
// JSON 파싱 시도
try
{
var jsonResult = JsonSerializer.Deserialize<JsonElement>(item.text);
var success = jsonResult.TryGetProperty("success", out var successElem) && successElem.GetBoolean();
var count = jsonResult.TryGetProperty("count", out var countElem) ? countElem.GetInt32() : 0;
Console.WriteLine($" JSON success: {success}, count: {count}");
if (success && count > 0)
{
if (jsonResult.TryGetProperty("tags", out var tagsElem))
{
var tagCount = tagsElem.GetArrayLength();
Console.WriteLine($" tags 배열 길이: {tagCount}");
if (tagCount > 0)
{
Console.WriteLine(" 첫 3개 태그:");
for (int i = 0; i < Math.Min(3, tagCount); i++)
{
var tag = tagsElem[i];
var tagNo = tag.TryGetProperty("tagNo", out var tagNoElem) ? tagNoElem.GetString() : "N/A";
var equipmentName = tag.TryGetProperty("equipmentName", out var eqNameElem) ? eqNameElem.GetString() : "N/A";
Console.WriteLine($" {i+1}. {tagNo} - {equipmentName}");
}
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($" JSON 파싱 실패: {ex.Message}");
}
}
}
}
}
else
{
var errorBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorBody}");
}
}
}
}

View File

@@ -1,6 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

File diff suppressed because it is too large Load Diff

77
todo.md
View File

@@ -1,77 +0,0 @@
## 1. Experion Server에서 데이터를 리얼타임으로 가져와서 저장하는 테이블 만들기
- [x] 1.1 RealtimeTable은 tagname, node_id, livevalue, timestamp 컬럼으로 구성되어야 함.
- [x] 1.2 RealtimeTable node_map_master에서 조합 추출한다.
SELECT *
FROM node_map_master
WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue')
AND data_type = 'Double';
을 데이터 베이스 레코드로 삽입
- [x] 1.3 tagname 컬럼은 2.항에서 추출된 레코드의 node_id 에서 오른쪽 끝에서 ':'문자를 만나기 전까지의 문자열로 채운다 (실제로 운전자가 사용하는 태그명이 된다.)
- [x] 1.4 웹페이지 :테이블 만들기 기능은 별도의 웹페이지 '포인트빌더' 대시보드를 추가하여 구현한다.
SELECT *
FROM node_map_master
WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue')
AND data_type = 'Double';의 항목을 선택하는 드롭다운 메뉴 항목을
name = 8개
data_type = 2개 (data_type 드롭다운 항목은 노드맵대시보드 페이지의 '데이터 타입'항목 참조)
테이블 작성하기 버튼
- [x] 1.5 node_id 를 직접입력하여 수동 추가 하는 항목도 만들어줘
- [x] 1.6 약 2000여개의 데이터 이므로 테이블 구조 설계를 잘해야 함
# 2. 실시간 opcUA 서버 데이터 를 RealtimeTable 레코드의 livevalue 컬럼에 넣는 로직만들기
- [x] 2.1 opcUA 서버는 값이 변경되지 않으면 값을 주지 않는다, opcUA 통신 규약을 참조하여 실시간 데이터 업데이트 로직만들기
# 3. HistoryTable 만들기
- [x] 3.1 위의 RealtimeTable의 실시간 값을 정해진 시간마다 시계열 데이터로 저장하는 HistoryTable을 만들어서 레코드 기록하는 로직만들기
# 4. HistoryTable의 웹페이지 추가
- [x] 4.1 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게
- [x] 4.2 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시
# 5. OPC UA 서버 기능 (Phase 1) — 완료
- [x] 5.1 `OPCFoundation.NetStandard.Opc.Ua.Server` 패키지 추가
- [x] 5.2 `ExperionOpcServerNodeManager` — CustomNodeManager2 상속, Realtime 폴더에 태그별 변수 노드 생성
- [x] 5.3 `ExperionOpcServerService` — StandardServer 기반, IHostedService + IExperionOpcServerService
- [x] 5.4 FlushLoop 500ms 배치 후 OPC 서버 노드 값 동시 갱신 (DB 폴링 없음)
- [x] 5.5 포인트 NodeId → tagname 캐시 (`_pointCache`) 추가
- [x] 5.6 API: POST /api/opcserver/start·stop, GET /api/opcserver/status, POST /api/opcserver/rebuild
- [x] 5.7 웹 UI: 08 OPC UA 서버 탭 (상태 카드, 시작/중지/재구성 버튼, 5초 폴링)
- [x] 5.8 자동 재시작 플래그 `opcserver_autostart.json` (RealtimeService 패턴 동일)
- [x] 5.9 기존 클라이언트 인증서 재사용 (`ApplicationType.ClientAndServer`)
포트: 기본 4841 (4840은 Experion HS R530이 사용 가능)
# 6. OPC UA 서버 기능 (Phase 2)
- [x] 6.1 **Historical Access (HA) 구현**
- `ExperionOpcServerNodeManager``IHistoricalDataAccess` 구현
- `ReadRaw()``QueryHistoryAsync()``history_table` 조회 후 반환
- 각 Realtime 노드에 `Historizing = true` 설정
- TimescaleDB가 이미 설치되어 있어 대용량 이력도 고성능 처리 가능
- [ ] 6.2 **포인트빌더 빌드 시 주소 공간 자동 재구성**
- `ExperionPointBuilderController``Build` 엔드포인트에서
`_opcServer.RebuildAddressSpace(points)` 자동 호출
- 현재는 UI에서 수동 "↺ 주소공간 재구성" 버튼으로만 동작
- [ ] 6.3 **Username/Password 인증 추가**
- `appsettings.json``AllowedUsernames` / `AllowedPasswords` 를 실제 검증 로직에 연결
- `ExperionOpcServerService.BuildServerConfig()`에 UserNameToken 유효성 검사기 등록
- [ ] 6.4 **보안 정책 활성화 (Basic256Sha256)**
- `appsettings.json`에서 `EnableSecurity: true`로 설정 시
SignAndEncrypt 엔드포인트 자동 활성화 (코드는 이미 구현됨)
- 클라이언트 인증서 신뢰 관리 UI 검토
- [ ] 6.5 **연결 클라이언트 목록 웹 UI**
- 접속 중인 클라이언트 IP, 세션 ID, 구독 수를 웹 UI에 표시
- `_server.CurrentInstance.SessionManager.GetSessions()` 활용
# 7. Nvidia DGX Spark 로 이전
- [x] 7.1 vscode + roo code + Qwen3.6-32B-A3B로 개발환경 구축
- [x] git clone 후 Text to SQL 기능 추가 — 스키마 수정 완료 (measurements → history_hypertable, time → recorded_at, value 캐스팅 추가)
- [x] Text to SQL 서비스 node_map_master 테이블 참조로 수정
- [ ] PostgreSQL + BRIN + B-Tree + TimescalDB 로 시계열 데이터베이스 도커에 설치 아니면 다시 설치
- [ ] 기존 iiot-timescaledb 컨테이너에 실행되고 있는애 일단 컨테이너 내려놓고

View File

@@ -1,233 +0,0 @@
# W17-W20 MCP 프론트엔드 연결 검수요청
**작성일:** 2026년 4월 28일
**작업자:** Claude
**작업 범위:** work_state.md W17-W20
---
## 📋 작업 개요
Text-to-SQL 프론트엔드에 MCP (Model Context Protocol) 모드를 연결하여 LLM이 직접 시계열 데이터베이스 도구를 호출하는 1-hop 아키텍처를 구현함.
---
## ✅ 완료된 작업
### W17: MCP 모드 설정 추가 (app.js)
**수정 파일:** `../ExperionCrawler/src/Web/wwwroot/js/app.js`
**구현 내용:**
- **라인 1315:** `let t2sMode = 'legacy';` 전역 변수 추가
- `'legacy'` 또는 `'mcp'` 모드 선택 가능
- **라인 1320-1345:** `toggleMcpMode()` 함수 구현
```javascript
function toggleMcpMode() {
t2sMode = t2sMode === 'legacy' ? 'mcp' : 'legacy';
const parseBtn = document.getElementById('t2s-parse-btn');
const executeBtn = document.getElementById('t2s-execute-btn');
const analyzeBtn = document.getElementById('t2s-analyze-btn');
const logBox = document.getElementById('t2s-log');
if (t2sMode === 'mcp') {
// MCP 모드: 변환 단계 숨김 (직접 SQL 입력 필요)
if (parseBtn) parseBtn.classList.add('hidden');
if (executeBtn) executeBtn.classList.add('hidden');
if (analyzeBtn) analyzeBtn.classList.add('hidden');
if (logBox) logBox.classList.add('hidden');
setGlobal('ok', 'MCP 모드');
} else {
// Legacy 모드: 모든 기능 표시
if (parseBtn) parseBtn.classList.remove('hidden');
if (executeBtn) executeBtn.classList.remove('hidden');
if (analyzeBtn) analyzeBtn.classList.remove('hidden');
if (logBox) logBox.classList.remove('hidden');
setGlobal('ok', 'Legacy 모드');
}
}
```
---
### W18: 2-hop 실행 경로 구현 (app.js)
**수정 파일:** `../ExperionCrawler/src/Web/wwwroot/js/app.js`
**구현 내용:**
- **라인 1527-1629:** [`t2sChatSend()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1527) 함수에 MCP 모드 분기 추가
```javascript
// ... (사용자 메시지 추가 후)
try {
if (t2sMode === 'mcp') {
// MCP 모드: 1-hop 직접 실행
const limit = document.getElementById('t2s-limit').value
? parseInt(document.getElementById('t2s-limit').value)
: 1000;
const executeRes = await api('POST', '/api/text-to-sql/execute-mcp', {
sql: message,
limit
});
// 로딩 메시지 제거
const loadMsgs = document.querySelectorAll('[id^="t2s-chat-loading-"]');
loadMsgs.forEach(el => el.remove());
if (!executeRes.success) {
t2sAddChatMessage('system',
`<span class="t2s-error">쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}</span>`);
} else {
t2sRenderTable(executeRes);
const totalCount = executeRes.totalCount || 0;
t2sAddChatMessage('system', `✅ <b>${totalCount}</b>개 결과 조회 완료`);
}
} else {
// Legacy 모드: 2-hop Parse → Execute
// ... (t2sParse() 호출 → 결과가 태그 분석 및 결과 테이블에 렌더링)
}
```
---
### W19: MCP 도구 목록 동적 메뉴 (index.html)
**수정 파일:** `../ExperionCrawler/src/Web/wwwroot/index.html`
**구현 내용:**
- **라인 693-699:** MCP 도구 목록 버튼 추가
```html
<!-- ... 기존 자연어 쿼리 섹션 ... -->
<!-- MCP 도구 목록 버튼 -->
<div style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn-b" id="t2s-tools-btn" onclick="loadMcpTools()">📋 MCP 도구 목록</button>
<span id="t2s-tools-container" style="display:flex;gap:8px;flex-wrap:wrap"></span>
</div>
```
**app.js 추가 함수:**
- **라인 1461-1487:** [`loadMcpTools()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1461)
- **라인 1475-1487:** [`renderToolsChips()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1475)
- **라인 1490-1513:** [`callTool()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1490)
---
### W20: 통합 테스트 검증
**검증 항목:**
1. **MCP 분기 로직**
- `t2sMode === 'mcp'` 분기에서 [`/api/text-to-sql/execute-mcp`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1551) 호출 확인
- 파라미터 구조: `{ sql, limit }` (jobId, query, options 없음)
2. **UI 토글 기능**
- `toggleMcpMode()`가 올바른 버튼 class 추가/제거
- MCP 모드에서 t2sParse/t2sExecute/t2sAnalyze 숨김 처리
3. **도구 목록 렌더링**
- `loadMcpTools()`에서 [`/api/text-to-sql/tools`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1465) 호출
- `renderToolsChips()`로 도구칩 동적 생성
---
## 📌 구조 개선 필요 사항
### app.js 중복 함수 제거
이전 작업 도중 [`loadMcpTools()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1461), [`renderToolsChips()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1475), [`callTool()`](../ExperionCrawler/src/Web/wwwroot/js/app.js:1490) 함수들이 여러 번 추가되는 중복 발생.
**필요한 정리 작업:**
- 단일 버전으로 통합
- 함수 위치 재조정 (검색 결과: 라인 1659, 1832, 1891 위치 있음 — 문서 원본의 1461, 1703, 1876은 오기)
---
## 🧪 테스트 절차
### 1. 서버 기동
```bash
cd mcp-nl2sql-server
uv run server.py # streamable-http 모드
```
### 2. MCP 서버 테스트
```bash
curl http://localhost:8000/tools
# expected: {"success":true,"tools":[...]}
```
### 3. 프론트엔드 테스트
1. Text-to-SQL 탭 진입
2. "📋 MCP 도구 목록" 버튼 클릭 → 도구칩 렌더링 확인
3. 버튼 표시/숨김 여부 확인
4. MCP 모드 전환 → Parse 버튼 숨겨짐 확인
### 4. Legacy 모드 테스트
1. 미실행 모드 (Legacy) → 모든 버튼 활성
2. `t2sSetQuery()` → Chat 메시지 추가
3. 화면에서 답변을 확인
---
## 📁 연결된 파일 목록
| 파일 | 수정 내용 | 라인 |
|------|----------|------|
| `app.js` | t2sMode 변수, toggleMcpMode(), t2sChatSend() 분기 | 1315, 1320-1345, 1527-1629 |
| `index.html` | MCP 도구 목록 버튼 추가 | 693-699 |
---
## ⚠️ 검수 확인 항목
> **검수일:** 2026-04-28 — 코드 직접 재확인 결과 (전항목 통과)
- [x] MCP 모드 토글 버튼이 UI에 존재하는가?
- **✅ PASS** — `index.html:694` `<button id="t2s-mode-toggle-btn" onclick="toggleMcpMode()">🔄 모드 전환</button>` 존재.
- [x] MCP 모드에서 t2sParse 버튼이 숨겨지는가?
- **✅ PASS** — `index.html:691-693` 버튼 3개에 `id="t2s-parse-btn"` / `id="t2s-execute-btn"` / `id="t2s-analyze-btn"` 모두 부여됨. `toggleMcpMode()` 숨김 동작 정상.
- [x] `/api/text-to-sql/execute-mcp` 호출 시 파라미터가 올바른가?
- **✅ PASS** — `app.js:1565` `t2sRenderTable(executeRes.data)` 로 응답 언래핑 적용됨. `totalCount``executeRes.data?.totalCount || executeRes.totalCount` 이중 폴백 처리.
- [x] 도구 목록 버튼을 클릭하면 `/api/text-to-sql/tools`가 호출되는가?
- **✅ PASS** — `app.js:1664` `/api/text-to-sql/tools` 호출 확인.
- [x] 도구 목록이 추천 쿼리 칩 형태로 렌더링되는가?
- **✅ PASS** — `app.js:1666` `renderToolsChips(res.tools)` 호출됨. `t2s-tools-container` DOM에 칩 렌더링 정상.
---
## 🐛 발견된 버그 목록
| # | 심각도 | 파일 | 위치 | 내용 | 상태 |
|---|--------|------|------|------|------|
| B1 | 🔴 Critical | `index.html` | :694 | MCP 모드 토글 버튼 누락 | ✅ 수정완료 |
| B2 | 🔴 Critical | `index.html` | :691-693 | 버튼 3개 `id` 속성 없음 → `toggleMcpMode()` 숨김 무동작 | ✅ 수정완료 |
| B3 | 🔴 Critical | `app.js` | :1565 | `t2sRenderTable(executeRes)``executeRes.data`로 언래핑 필요 | ✅ 수정완료 |
| B4 | 🟠 High | `app.js` | :1666 | `loadMcpTools()` 내부에서 `renderToolsChips()` 미호출 | ✅ 수정완료 |
| B5 | 🟡 Medium | `app.js` | :1659, 1832, 1891 | `loadMcpTools` / `renderToolsChips` / `callTool` 3세트 중복 정의 | ✅ 수정완료 (1세트로 통합) |
---
## 🚀 다음 단계
모든 버그 수정 완료. 실서버 환경에서 통합 테스트 진행 가능.
1. MCP 서버 기동 후 `/api/text-to-sql/tools` 응답 확인
2. 모드 전환 → 버튼 숨김/표시 동작 육안 확인
3. 채팅창에서 SQL 직접 입력 후 MCP 모드 실행 결과 확인
---
## 🔗 참조 문서
- [`work_state.md`](work_state.md:535) - W17-W20 상세 사양
- [`../ExperionCrawler/src/Infrastructure/Mcp/McpService.cs`](../ExperionCrawler/src/Infrastructure/Mcp/McpService.cs) - MCP 서비스 구현
- [`../ExperionCrawler/src/Web/Controllers/TextToSqlController.cs`](../ExperionCrawler/src/Web/Controllers/TextToSqlController.cs) - API 컨트롤러