chore: 프로젝트 파일 구조 정리 - 루트 파일 폴더별 이동, 테스트/구버전 삭제
루트 파일 정리: - DXF/P&ID 관련 → dxf-graph/ - fastTable 관련 → fastTable/ - plan/ → plans/ 통합 (최신 버전 유지) - 테스트 출력 파일, 구버전 프로젝트 삭제 - 불필요한 루트 문서 삭제
This commit is contained in:
@@ -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초 미만 |
|
||||
@@ -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분)
|
||||
- **부분 정보 버전**: 일부 데이터를 숨겨 추론 능력 평가
|
||||
- **실시간 버전**: 데이터가 점진적으로 제공되는 시나리오
|
||||
- **다국어 버전**: 다른 언어로 동일한 시나리오 제공
|
||||
@@ -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개
|
||||
106
bench_qwen3.py
106
bench_qwen3.py
@@ -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()
|
||||
@@ -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()
|
||||
146
digit-trunc.md
146
digit-trunc.md
@@ -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 프로젝트 빌드 성공 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
440
plans/포인트빌더-개선방안-코딩.md
Normal file
440
plans/포인트빌더-개선방안-코딩.md
Normal 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`)
|
||||
689
plans/포인트빌더-개선방안-코딩2.md
Normal file
689
plans/포인트빌더-개선방안-코딩2.md
Normal 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개 이상 시)
|
||||
242
plans/포인트빌더-개선방안.md
Normal file
242
plans/포인트빌더-개선방안.md
Normal 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 |
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
77
todo.md
@@ -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 컨테이너에 실행되고 있는애 일단 컨테이너 내려놓고
|
||||
@@ -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 컨트롤러
|
||||
Reference in New Issue
Block a user