Compare commits
10 Commits
0057bd3a44
...
30a3286d35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a3286d35 | ||
|
|
37a464ab96 | ||
|
|
aa2174118a | ||
|
|
7124b222c3 | ||
|
|
f97be981a4 | ||
|
|
cfef06c21e | ||
|
|
fc197465d4 | ||
|
|
dbad4a54cf | ||
|
|
f1c11931fe | ||
|
|
cb749568d0 |
File diff suppressed because it is too large
Load Diff
@@ -1,413 +0,0 @@
|
||||
# HC900 Crawler — 탭 수정 작업지시서 (교정판)
|
||||
|
||||
> 본 문서는 실측 검증(DB 직접 조회 + 실행 중인 앱 API Content-Type 확인)으로 교정되었다.
|
||||
> 이전 판의 "문제 1(DB 스키마 불일치)"과 "문제 2(DI 캐스트 오류)"는 **오진으로 확인되어 삭제**했다.
|
||||
|
||||
---
|
||||
|
||||
## 진단 요약
|
||||
|
||||
빌드: ✅ 성공 / 앱: ✅ 실행 중(포트 5000) / DB 스키마: ✅ 정상
|
||||
|
||||
**진짜 근본 원인은 단 하나: JS가 존재하지 않는 API 경로를 호출한다.**
|
||||
|
||||
ASP.NET이 `MapFallbackToFile("index.html")`로 매핑되어 있어, 없는 GET 경로는
|
||||
**404가 아니라 200 + `index.html`(HTML)** 을 반환한다.
|
||||
프런트는 이 HTML을 `JSON.parse()` 시도 → 예외 → 사용자에게는 "DB/연결 오류"처럼 보인다.
|
||||
실제로는 DB도 연결도 정상이며, **경로 오타/미구현**이 전부다.
|
||||
|
||||
### 검증 방법 (재현 가능)
|
||||
```bash
|
||||
# text/html = 엔드포인트 없음(SPA 폴백), application/json = 실제 존재
|
||||
curl -s -o /dev/null -w "%{content_type}\n" http://localhost:5000/api/history/tags # application/json ✓
|
||||
curl -s -o /dev/null -w "%{content_type}\n" http://localhost:5000/api/history/tagnames # text/html ✗(없음)
|
||||
```
|
||||
|
||||
### 실측 결과 (오진 정정 포함)
|
||||
|
||||
| 이전 판 주장 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| controller_id 컬럼 미적용 → InitializeAsync 실패 | 5개 테이블 모두 컬럼 존재, v_tag_summary 정상 | **오진(삭제)** |
|
||||
| IHc900GatewayService DI 캐스트 오류 | 해당 등록 줄 이미 제거됨, 빌드 성공 | **오진(삭제)** |
|
||||
| /api/realtime/points DB오류 | 200 application/json 정상 | **오진** |
|
||||
| JS 경로 불일치 (hist/evt/write/fast/t2s) | text/html 또는 404 확인 | **정확(유지)** |
|
||||
|
||||
---
|
||||
|
||||
## 별도 이슈: 데이터가 비어 보이는 진짜 원인 (코드 아님)
|
||||
|
||||
경로를 다 고쳐도 **표에 값이 안 나올 수 있다.** 원인은 게이트웨이 Modbus 연결 실패다.
|
||||
|
||||
```
|
||||
GET /api/gateway/health →
|
||||
{ "status":"NotServing", "controllerIp":"192.168.0.230", "pollCount":0 }
|
||||
realtime_table rows = 0
|
||||
```
|
||||
|
||||
게이트웨이가 `192.168.0.230`에 연결 못 함(`NotServing`) → 수집 0건 → 모든 탭 데이터 공란.
|
||||
HC900 기본 IP는 `192.168.0.240`. **Setup 탭에서 컨트롤러 IP를 실제 값으로 교정 후 재시작.**
|
||||
(이건 코드 수정과 무관한 운영 설정 문제이며, 탭 로직 수정과 병행해야 데이터가 보인다.)
|
||||
|
||||
---
|
||||
|
||||
## 수정 대상 (모두 JS 경로 문제, DB 무관)
|
||||
|
||||
| # | 탭 | JS 호출 (없는 경로) | 실제/대체 경로 | 작업 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | hist | `GET /api/history/tagnames` | `GET /api/history/tags` | JS 1줄 |
|
||||
| 2 | hist | `GET /api/history/query` | `POST /api/history/query` | JS 메서드+body |
|
||||
| 3 | hist | `/api/experion/hypertable/*` | 없음 | 엔드포인트 신설 |
|
||||
| 4 | evt | `/api/event-history/*` (3개) | `POST /api/events/query` | JS 3함수 |
|
||||
| 5 | write | `/api/points/*` (4개) | `POST /api/gateway/write` | JS 4함수 |
|
||||
| 6 | fast | `/api/fast/*`, `/api/pointbuilder/points` | FastController 없음 | 컨트롤러 신설 |
|
||||
| 7 | t2s | `POST /api/text-to-sql/execute` | `execute-mcp`만 존재 | JS 통일 또는 신설 |
|
||||
|
||||
> pid / llmchat / kbadmin / docs / trend / ff 탭은 **코드 수정 불필요.**
|
||||
> 데이터 공란은 위 "게이트웨이 Modbus 연결" 이슈로만 설명된다.
|
||||
|
||||
---
|
||||
|
||||
## 수정 1: `hist.js` — 이력 조회 탭
|
||||
|
||||
### 1-1. tagnames → tags (hist.js:42)
|
||||
```javascript
|
||||
// 변경 전
|
||||
const d = await api('GET', '/api/history/tagnames'); // text/html (없음)
|
||||
// 변경 후
|
||||
const d = await api('GET', '/api/history/tags');
|
||||
```
|
||||
|
||||
### 1-2. query GET → POST (hist.js:98)
|
||||
`HistoryController`는 `[HttpPost("query")]`만 있다(GET 없음). GET 호출은 SPA 폴백으로 빠진다.
|
||||
|
||||
```javascript
|
||||
// 변경 전 (hist.js:98) — 쿼리스트링 GET
|
||||
const d = await api('GET', `/api/history/query?${params}`);
|
||||
|
||||
// 변경 후 — POST + body DTO
|
||||
const d = await api('POST', '/api/history/query', {
|
||||
tagNames: tags, // 배열
|
||||
from: fromDt, // ISO 또는 null
|
||||
to: toDt,
|
||||
limit: parseInt(limit) || 1000
|
||||
});
|
||||
```
|
||||
> 응답 형태(`d.rows`, `d.tagNames`)는 기존 코드와 동일하므로 파싱부는 그대로 둔다.
|
||||
|
||||
### 1-3. hypertable 엔드포인트 신설 (hist.js:241, 343)
|
||||
JS가 `/api/experion/hypertable/status`·`/create`를 호출하나 둘 다 없다.
|
||||
|
||||
`Hc900Controllers.cs`에 컨트롤러 추가:
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/experion/hypertable")]
|
||||
public class HypertableController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _db;
|
||||
public HypertableController(IExperionDbService db) => _db = db;
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status()
|
||||
=> Ok(await _db.GetHypertableStatsAsync()); // 기존 hypertable 조회 메서드 재사용
|
||||
|
||||
[HttpPost("create")]
|
||||
public IActionResult Create()
|
||||
=> Ok(new { success = true, message = "history_table 하이퍼테이블 설정됨" });
|
||||
}
|
||||
```
|
||||
> `IExperionDbService`에 하이퍼테이블 상태 조회 메서드가 이미 있으면 그대로,
|
||||
> 없으면 `Hc900DbContext.cs`의 `timescaledb_information.hypertables` 조회 로직(이미 존재)을
|
||||
> public 메서드로 노출.
|
||||
|
||||
---
|
||||
|
||||
## 수정 2: `evt.js` — 이벤트 히스토리 탭
|
||||
|
||||
`/api/event-history/*` 3개 경로 모두 없음. 실제 API는 `POST /api/events/query`(정상 작동).
|
||||
**백엔드 변경 없이 JS만 수정.**
|
||||
|
||||
```javascript
|
||||
// (1) 디지털 태그 목록 — 전용 API 없음 → realtime에서 클라이언트 필터
|
||||
// 주의: 응답은 PascalCase (TagName, LiveValue)
|
||||
async function evtLoadDigitalTags() {
|
||||
const pts = await api('GET', '/api/realtime/points');
|
||||
return pts.filter(p => (p.LiveValue ?? '').startsWith('{')); // 상태 레이블 형식 {n|LABEL|}
|
||||
}
|
||||
|
||||
// (2) 이벤트 조회 — GET /api/event-history → POST /api/events/query
|
||||
async function evtQuery() {
|
||||
const body = {
|
||||
tagName: document.getElementById('ev-tag').value.trim() || null,
|
||||
eventType: document.getElementById('ev-type').value || null,
|
||||
area: document.getElementById('ev-area').value.trim() || null,
|
||||
from: fromISO,
|
||||
to: toISO,
|
||||
limit: 500
|
||||
};
|
||||
const d = await api('POST', '/api/events/query', body);
|
||||
// d = EventHistoryRecord[] (PascalCase: TagName, EventType, EventTime, CurrValue ...)
|
||||
}
|
||||
|
||||
// (3) 요약 — 전용 엔드포인트 없음 → (2) 결과로 클라이언트 집계
|
||||
// 예: eventType별 count, 또는 최근 N건 그룹화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정 3: `write.js` — 태그 쓰기 탭
|
||||
|
||||
`/api/points/*` 4개 모두 없음. HC900 쓰기는 gRPC 기반 `POST /api/gateway/write` 단일 경로.
|
||||
`WriteTagDto = { ControllerId, TagName, Value }`.
|
||||
|
||||
```javascript
|
||||
// write/mode/control 전부 동일 엔드포인트로 통합
|
||||
async function writeValue() {
|
||||
const controllerId = document.getElementById('w-ctrl').value || 'HC1';
|
||||
const tagName = document.getElementById('w-tag').value.trim();
|
||||
const value = parseFloat(document.getElementById('w-val').value);
|
||||
const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value });
|
||||
}
|
||||
|
||||
// MODE 변경: tagName에 .MODE 접미사, value=정수
|
||||
async function writeMode() {
|
||||
const controllerId = document.getElementById('w-ctrl').value || 'HC1';
|
||||
const tagName = document.getElementById('m-tag').value.trim() + '.MODE';
|
||||
const value = parseInt(document.getElementById('m-mode').value);
|
||||
await api('POST', '/api/gateway/write', { controllerId, tagName, value });
|
||||
}
|
||||
|
||||
// read: 전용 read API 없음 → realtime에서 조회 (PascalCase 주의!)
|
||||
async function readTag() {
|
||||
const tagName = document.getElementById('r-tag').value.trim().toLowerCase();
|
||||
const pts = await api('GET', '/api/realtime/points');
|
||||
const point = pts.find(p => (p.TagName ?? '').toLowerCase() === tagName); // ← p.TagName (대문자)
|
||||
// point.LiveValue 표시
|
||||
}
|
||||
```
|
||||
> ⚠️ 이전 판은 `p.tagName`(소문자)으로 적었으나 직렬화가 PascalCase라 **항상 undefined**가 된다.
|
||||
> 반드시 `p.TagName`, `p.LiveValue`로 쓴다.
|
||||
|
||||
**컨트롤러 드롭다운** (`panes/write.html` + `write.js`):
|
||||
```html
|
||||
<div class="fg"><label>컨트롤러</label><select id="w-ctrl" class="inp"></select></div>
|
||||
```
|
||||
```javascript
|
||||
async function writeLoadControllers() {
|
||||
const d = await api('GET', '/api/gateway/status'); // { controllers: [{controllerId, controllerIp}, ...] }
|
||||
const sel = document.getElementById('w-ctrl');
|
||||
(d.controllers || []).forEach(c => {
|
||||
const id = c.controllerId ?? c.ControllerId;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = `${id} — ${c.controllerIp ?? c.ControllerIp}`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
```
|
||||
> `/api/gateway/status`의 `controllers` 필드는 `Hc900RealtimeService.ControllerConnected`
|
||||
> 직렬화 결과이므로 키 케이싱을 실제 응답으로 한 번 확인하고 맞춘다.
|
||||
|
||||
---
|
||||
|
||||
## 수정 4: `fast.js` — fastRecord 탭 (컨트롤러 신규)
|
||||
|
||||
`IExperionDbService`에 FastSession/FastRecord 메서드 존재(`IExperionServices.cs:44-61`).
|
||||
`GetRealtimeRecordsByTagNamesAsync`(L61)도 있어 실시간 값 채우기 가능. **컨트롤러만 신설.**
|
||||
|
||||
### 신규 파일: `src/Hc900Crawler/Controllers/FastController.cs`
|
||||
```csharp
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Core.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/fast")]
|
||||
public class FastController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _db;
|
||||
private static readonly Dictionary<int, CancellationTokenSource> _sessions = new();
|
||||
|
||||
public FastController(IExperionDbService db) => _db = db;
|
||||
|
||||
[HttpGet("sessions")]
|
||||
public async Task<IActionResult> GetSessions() => Ok(await _db.GetFastSessionsAsync());
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetSession(int id)
|
||||
{
|
||||
var s = await _db.GetFastSessionAsync(id);
|
||||
return s == null ? NotFound() : Ok(s);
|
||||
}
|
||||
|
||||
[HttpPost("start")]
|
||||
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest req)
|
||||
{
|
||||
var createReq = new FastSessionCreateRequest(
|
||||
req.Name, DateTime.UtcNow, "Running",
|
||||
req.SamplingMs, req.DurationSec,
|
||||
System.Text.Json.JsonSerializer.Serialize(req.TagNames),
|
||||
0, req.RetentionDays);
|
||||
var session = await _db.CreateFastSessionAsync(createReq);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
lock (_sessions) { _sessions[session.Id] = cts; }
|
||||
_ = Task.Run(() => RunSessionAsync(session.Id, req, cts.Token));
|
||||
return Ok(new { session.Id, status = "Running" });
|
||||
}
|
||||
|
||||
private async Task RunSessionAsync(int sessionId, FastSessionStartRequest req, CancellationToken ct)
|
||||
{
|
||||
var endAt = DateTime.UtcNow.AddSeconds(req.DurationSec);
|
||||
int rowCount = 0;
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested && DateTime.UtcNow < endAt)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
// 실시간 값 조회 후 기록 (빈 값 대신 실제 값)
|
||||
var live = (await _db.GetRealtimeRecordsByTagNamesAsync(req.TagNames))
|
||||
.ToDictionary(p => p.TagName, p => p.LiveValue);
|
||||
var records = req.TagNames.Select(t => new FastRecord
|
||||
{
|
||||
SessionId = sessionId,
|
||||
RecordedAt = now,
|
||||
TagName = t,
|
||||
Value = live.GetValueOrDefault(t)
|
||||
}).ToList();
|
||||
|
||||
await _db.BatchInsertFastRecordsAsync(records);
|
||||
rowCount += records.Count;
|
||||
await _db.UpdateFastSessionRowCountAsync(sessionId, rowCount);
|
||||
await Task.Delay(req.SamplingMs, ct);
|
||||
}
|
||||
await _db.UpdateFastSessionStatusAsync(sessionId, "Completed");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await _db.UpdateFastSessionStatusAsync(sessionId, "Stopped");
|
||||
}
|
||||
finally { lock (_sessions) { _sessions.Remove(sessionId); } }
|
||||
}
|
||||
|
||||
[HttpPost("{id}/stop")]
|
||||
public async Task<IActionResult> Stop(int id)
|
||||
{
|
||||
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
|
||||
await _db.UpdateFastSessionStatusAsync(id, "Stopped");
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
|
||||
await _db.DeleteFastSessionAsync(id);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("{id}/pin")]
|
||||
public async Task<IActionResult> Pin(int id, [FromBody] bool pinned)
|
||||
{
|
||||
await _db.UpdateFastSessionPinnedAsync(id, pinned);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet("{id}/records")]
|
||||
public async Task<IActionResult> GetRecords(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null)
|
||||
=> Ok(await _db.GetFastRecordsAsync(id, from, to));
|
||||
|
||||
[HttpGet("{id}/csv")]
|
||||
public async Task<IActionResult> ExportCsv(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
await _db.ExportFastRecordsToCsvAsync(id, stream, from, to);
|
||||
stream.Position = 0;
|
||||
return File(stream, "text/csv", $"fast_{id}.csv");
|
||||
}
|
||||
}
|
||||
```
|
||||
> `FastController`는 attribute routing이라 별도 DI 등록 불필요(`AddControllers`가 자동 스캔).
|
||||
> `FastSessionStartRequest`/`FastSessionCreateRequest`/`FastQueryResult` 레코드 정의를
|
||||
> `IExperionServices.cs:130~` 에서 확인하고 생성자 인자 순서를 맞출 것.
|
||||
|
||||
### fast.js:389 — 태그 목록
|
||||
```javascript
|
||||
// 변경 전: /api/pointbuilder/points (없음)
|
||||
// 변경 후: 기존 태그 API 재사용
|
||||
const res = await fetch('/api/gateway/tags?limit=500'); // 또는 /api/hc900/tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정 5: `t2s.js` — Text-to-SQL 탭
|
||||
|
||||
`/api/text-to-sql/execute` 없음(`execute-mcp`만 존재). 호출부 t2s.js:184, 449, 608.
|
||||
|
||||
**옵션 A (빠름) — JS를 execute-mcp로 통일:**
|
||||
```javascript
|
||||
// t2s.js의 '/api/text-to-sql/execute' 3곳을 모두 '/api/text-to-sql/execute-mcp' 로 교체
|
||||
```
|
||||
**옵션 B — 컨트롤러에 execute 신설** (MCP 없이 직접 실행이 필요할 때만):
|
||||
```csharp
|
||||
// TextToSqlController.cs
|
||||
[HttpPost("execute")]
|
||||
public async Task<IActionResult> ExecuteDirect([FromBody] ExecuteSqlRequest req)
|
||||
{
|
||||
var v = _validator.Validate(req.Sql);
|
||||
if (!v.IsValid) return BadRequest(new { error = v.ErrorMessage });
|
||||
// Npgsql 직접 실행 (execute-mcp에서 MCP pivot 제외한 버전)
|
||||
return Ok(result);
|
||||
}
|
||||
```
|
||||
> MCP 경로가 이미 정상 동작 중이면 **옵션 A 권장**(코드 최소화).
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
```
|
||||
[ ] 0. (운영) Setup 탭에서 컨트롤러 IP 192.168.0.230 → 실제 HC900 IP(예 0.240) 교정 후 재시작
|
||||
→ gateway health가 Serving 되어야 데이터가 표시됨 (코드와 무관, 병행 필수)
|
||||
|
||||
── JS만 수정 (백엔드 무변경) ──
|
||||
[ ] 1. hist.js:42 /api/history/tagnames → /api/history/tags
|
||||
[ ] 2. hist.js:98 query GET → POST + body DTO
|
||||
[ ] 3. evt.js 3함수: /api/event-history/* → POST /api/events/query (+ realtime 필터)
|
||||
[ ] 4. write.js 4함수: /api/points/* → POST /api/gateway/write (필드 PascalCase 주의)
|
||||
[ ] 5. write.html + write.js 컨트롤러 드롭다운 추가
|
||||
[ ] 6. fast.js:389 /api/pointbuilder/points → /api/gateway/tags
|
||||
[ ] 7. t2s.js /api/text-to-sql/execute → execute-mcp (옵션 A)
|
||||
|
||||
── 백엔드 신규 ──
|
||||
[ ] 8. FastController.cs 신규 작성 (수정 4)
|
||||
[ ] 9. HypertableController 신설 (수정 1-3) — hist 탭 하이퍼테이블 패널
|
||||
|
||||
[ ] 10. dotnet build 확인
|
||||
[ ] 11. 탭별 동작 확인: write → evt → hist → fast → t2s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 검증 부록 (재현 명령)
|
||||
|
||||
```bash
|
||||
# DB 스키마가 이미 정상임을 재확인 (오진 검증용)
|
||||
/tmp/hc900_venv/bin/python3 -c "
|
||||
import psycopg2
|
||||
c=psycopg2.connect(host='localhost',dbname='iiot_platform',user='postgres',password='postgres')
|
||||
cur=c.cursor()
|
||||
for t in ['realtime_table','history_table','event_history_table','hc900_map_master','tag_metadata']:
|
||||
cur.execute(\"SELECT 1 FROM information_schema.columns WHERE table_schema='hc900' AND table_name=%s AND column_name='controller_id'\",(t,))
|
||||
print(t, 'controller_id:', 'YES' if cur.fetchone() else 'NO')
|
||||
"
|
||||
|
||||
# 엔드포인트 존재 여부 (application/json=있음, text/html=없음)
|
||||
for p in /api/history/tags /api/history/tagnames /api/fast/sessions \
|
||||
/api/event-history/digital-tags /api/pointbuilder/points; do
|
||||
printf "%-40s %s\n" "$p" "$(curl -s -o /dev/null -w '%{content_type}' http://localhost:5000$p)"
|
||||
done
|
||||
|
||||
# 게이트웨이 실제 연결 상태
|
||||
curl -s http://localhost:5000/api/gateway/health
|
||||
```
|
||||
@@ -44,6 +44,16 @@
|
||||
"pollIntervalMs": 500,
|
||||
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-c4.json",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"id": "HCX",
|
||||
"name": "",
|
||||
"controllerIp": "",
|
||||
"controllerPort": 502,
|
||||
"grpcPort": 50055,
|
||||
"pollIntervalMs": 1000,
|
||||
"registerMapPath": "",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
docs/ControlEdge HC900 IO Modules Specifications.pdf
Normal file
BIN
docs/ControlEdge HC900 IO Modules Specifications.pdf
Normal file
Binary file not shown.
100
docs/PT100-RTD-양자화-원인분석-RTD레인지설정.md
Normal file
100
docs/PT100-RTD-양자화-원인분석-RTD레인지설정.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# PT100 RTD 온도값 양자화 원인 분석 — RTD 레인지 설정 문제
|
||||
|
||||
> 작성일: 2026-06-12
|
||||
> 대상: TI-6111C(P6/C3), TI-9211C(P9/C4) 등 일부 온도점의 값이 소수점 5자리까지 반복되는 현상
|
||||
> 결론: **데이터·이관·OPC·하드웨어 모두 정상. 원인은 해당 채널의 RTD 입력 레인지가 불필요하게 넓은 "high(-184~816°C)"로 설정된 것.** 레인지를 "low(-184~316°C)"로 바꾸면 분해능·정확도가 동시에 2배 개선됨.
|
||||
|
||||
---
|
||||
|
||||
## 1. 발단 (현상)
|
||||
|
||||
`hc900.history_table`에서 **TI-6111C.PV**의 24시간(2026-05-09, KST) 최대값 84.14166이 **30초 간격으로 정확히 46회 반복**됨. "현장 실데이터인데 소수점 5자리까지 수십 번 동일할 수 있는가?"라는 의문에서 출발.
|
||||
|
||||
- 하루 distinct 값이 **단 6개**(83.808 / 83.875 / 83.941 / 84.008 / 84.075 / 84.142), 간격 일정(0.06677).
|
||||
|
||||
## 2. 데이터 출처 확인
|
||||
|
||||
- 같은 PostgreSQL 서버에 별도 DB **`field_hist`** = 신암정유㈜ Experion 히스토리안 덤프 (dtat 범위 2026-02-05~06-05, local=KST).
|
||||
- 구조: `ptlist`(태그) → `mapping`(pid→tid/oit) → `tblist`(cont001~017) → `cont0NN`(dtat + col01~colNN).
|
||||
- 예: TI-6111C.PV = `/ASSETS/P6/TI-6111C.PV` = `cont009.col48`.
|
||||
- `field_hist` 원본과 `hc900.history_table` 값이 **완전히 일치** → 이관/저장 artifact 아님. **양자화는 원본부터 존재.**
|
||||
- 출처 체인: 센서(PT100) → HC900 → Experion 서버 → OPC Classic → DB 서버 덤프.
|
||||
|
||||
## 3. 가설 검증 (기각 과정)
|
||||
|
||||
| 가설 | 검증 | 결과 |
|
||||
|---|---|---|
|
||||
| 데이터 이관/저장 손상 | field_hist 원본과 일치 | ❌ 기각 |
|
||||
| 전송(OPC Classic/Experion) 양자화 | 양자화가 **저항(Ω) 영역에서 일정**(°C는 변해도 Ω 고정). OPC/Experion은 °C(EU)만 봄 | ❌ 기각 |
|
||||
| EU 레인지 스케일링 | TI-6111C(0~120) vs TICA-6111A(0~500) **레인지 다른데 °C step 동일** | ❌ 기각 |
|
||||
| "온도종류"/계기 둔감 | PICA-6111(압력)·P10 온도 8채널은 풀 해상도(연속) | ❌ 기각 |
|
||||
| 컨트롤러 하드웨어 세대(C3 구형 vs C4 신형) | 양자화 TI-9211C와 연속 TI-10111C가 **둘 다 C4** | ❌ 기각 |
|
||||
| 입력 모듈 버전 차이 | P9·P10 **동일 버전 AI 모듈** | ❌ 기각 |
|
||||
| ADC 비트수 부족(12-bit) | 데이터시트상 **15-bit** | ❌ (비트수 자체는 충분) |
|
||||
|
||||
## 4. 핵심 — 센서/ADC/필터/레인지의 역할 분리
|
||||
|
||||
- **PT100 센서**: 아날로그(온도비례 연속저항). 양자화 안 함.
|
||||
- **양자화 발생 지점**: HC900 Universal Analog Input 모듈의 **ADC + 선형화**(저항→디지털). 저항 영역에서 일정한 격자.
|
||||
- **모듈 분해능**: ControlEdge HC900 Universal Analog Input = **15-bit**.
|
||||
- **LSB = (RTD 레인지 저항 스팬) ÷ 2^bits** → 레인지가 넓을수록 LSB가 거칠어짐.
|
||||
|
||||
### 데이터시트(11p) RTD 정확도/레인지 (100 Pt.)
|
||||
|
||||
| 레인지 | °C 범위 | °C 스팬 | 정확도(±°C) |
|
||||
|---|---|---|---|
|
||||
| low | -184 ~ 316 | **500** | **±0.5** |
|
||||
| mid | -184 ~ 649 | 833 | ±0.8 |
|
||||
| high | -184 ~ 816 | **1000** | **±1.0** |
|
||||
|
||||
> 주의: 위 표의 0.5/0.8/1.0은 **정확도(accuracy, 참값 대비 오차)** 이며, **분해능(resolution, 최소 스텝)과 별개**. 분해능(~0.03~0.066°C)이 오히려 정확도(±0.5°C)보다 더 곱다.
|
||||
|
||||
## 5. 결정적 실측 (필터 0 고정, 레인지만 비교)
|
||||
|
||||
P2 채널들은 **low 레인지(-184~316°C) + FILTER 0** 으로 설정되어 있어, 필터를 0으로 고정한 채 레인지 효과만 단독 분리 가능 (대조군).
|
||||
|
||||
| 그룹 (설정) | 채널 | °C_LSB | Ω_LSB |
|
||||
|---|---|---|---|
|
||||
| **P2 (low, F0)** | TICA-2111, TI-2111A, TI-2111B, TIA-2121A, TI-2121B, TI-2121C | **0.0329** (6채널 동일) | **0.0126 Ω** |
|
||||
| 거친 기준 (F0) | TI-6111C | 0.0660 | 0.0253 Ω |
|
||||
| 거친 기준 (F0) | TI-9211C | 0.0656 | 0.0252 Ω |
|
||||
|
||||
- P2 6채널 모두 **0.0126Ω로 일치**, TI-6111C·TI-9211C는 **정확히 2.0배 거친 0.0253Ω**.
|
||||
- 양쪽 다 FILTER 0 → 차이는 **레인지 단독 효과**.
|
||||
- **°C 스팬 비율 low:high = 500:1000 = 정확히 2배** → 관측 LSB 비율(2.01배)과 일치 → **TI-6111C·TI-9211C는 "high" 레인지, P2는 "low" 레인지** 임이 수치로 확정.
|
||||
|
||||
## 6. 최종 결론 (완결 모델)
|
||||
|
||||
| 무엇이 | 무엇으로 결정 |
|
||||
|---|---|
|
||||
| **분해능(LSB)** | **RTD 레인지** (low=0.033°C 고움 / high=0.066°C 거침) |
|
||||
| **정확도** | **RTD 레인지** (low ±0.5°C / high ±1.0°C) |
|
||||
| **연속 vs 양자화 "겉모습"** | **입력 FILTER** (0=raw 격자 노출 / 1=디지털 평활로 연속처럼 보임) |
|
||||
|
||||
- **TI-6111C·TI-9211C가 거친 진짜 이유**: 동작점이 84°C인데 RTD 레인지가 불필요하게 넓은 **high(-184~816°C)** 로 설정됨 → 15-bit를 1000°C에 펴 쓰니 LSB 2배 거침 + 정확도 ±1.0°C.
|
||||
- **P10(TI-10111x)이 연속처럼 보이는 이유**: FILTER 1(평활). 참 분해능은 별개이며 필터는 겉모습 + 응답 지연만 추가(참 정확도는 ADC/레인지가 결정).
|
||||
- **TI-6111C가 6레벨로 보이는 이유**: high 레인지(거친 LSB) + 안정 공정(하루 0.33°C). 같은 필터 0이라도 P2 민감단처럼 넓게 움직이면 distinct가 많아짐.
|
||||
|
||||
## 7. 권장 조치
|
||||
|
||||
**TI-6111C, TI-9211C(및 동일 증상 채널)의 RTD 입력 레인지를 "100 Pt (low, -184~316°C)"로 변경.**
|
||||
|
||||
- 동작점 84°C가 low 범위에 충분히 포함됨.
|
||||
- 효과: **분해능 2배 개선(0.066 → 0.033°C)** + **정확도 2배 개선(±1.0 → ±0.5°C)**.
|
||||
- 필터·센서·모듈 교체 불필요. **HC900 Designer의 레인지 파라미터 변경만으로 해결.**
|
||||
- (선택) 겉보기 평활이 필요하면 FILTER도 정책적으로 통일. 단, 필터는 참 분해능을 올리지 못하고 응답을 느리게 함.
|
||||
|
||||
### 더 높은 raw 분해능이 필요하다면 (레인지 외 옵션)
|
||||
- PT1000 센서(저항 10배 → 같은 ADC에서 °C/LSB ~10배 개선), 또는 고분해능 입력 모듈. 단 동일 모듈 공유 시 전 채널 동일 적용.
|
||||
|
||||
## 부록 A. 참고 수치
|
||||
- PT100 IEC 60751: R(T)=100(1+A·T+B·T²), A=3.9083e-3, B=-5.775e-7. dR/dT ≈ 0.379~0.388 Ω/°C (0~100°C).
|
||||
- 15-bit = 32768 counts. LSB(°C) ≈ 레인지 °C 스팬 ÷ 유효 counts.
|
||||
- 관측 LSB: low 레인지 ≈ 0.0126Ω/0.033°C, high 레인지 ≈ 0.0253Ω/0.066°C.
|
||||
|
||||
## 부록 B. 조회 경로 (재현용)
|
||||
- 원본 DB: `postgresql://.../field_hist`
|
||||
- 태그→컬럼: `ptlist.ptname` → `mapping(pid→tid,oit)` → `tblist(tid→tblname)` → `cont0NN.colNN`
|
||||
- TI-6111C.PV=cont009.col48, TI-9211C.PV=cont013.col13, TICA-2111.PV=cont005.col20, TI-10111C.PV=cont015.col01
|
||||
- 컨트롤러 매핑: `hc900.tag_metadata.controller_id` (TI-6111C=C3, TI-9211C/TI-10111x=C4, TICA-2111=C2)
|
||||
- 양자화 LSB 측정: 해당 컬럼의 distinct 값 정렬 후 인접 최소 간격(min gap) → PT100 dR/dT로 Ω 환산.
|
||||
281
docs/plan.md
281
docs/plan.md
@@ -1,281 +0,0 @@
|
||||
# HC900 Gateway — 아키텍처 및 구현 계획
|
||||
|
||||
## 1. 개요
|
||||
|
||||
ExperionCrawler의 OPC UA → Experion HS R530 경로를
|
||||
**Modbus TCP → HC900** 경로로 대체.
|
||||
|
||||
```
|
||||
Before:
|
||||
HC900 ──Modbus TCP──▶ Experion R530 ──OPC UA──▶ ExperionCrawler ──▶ PostgreSQL
|
||||
|
||||
After:
|
||||
HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ HC900Crawler ──▶ PostgreSQL
|
||||
```
|
||||
|
||||
## 2. C++ 게이트웨이 (Modbus TCP → gRPC)
|
||||
|
||||
### 설계 원칙
|
||||
- **단순, 고효율, 경량** (예상 RSS 5~8 MB)
|
||||
- **Full poll 1초 주기** — 모든 레지스터 읽어서 캐시
|
||||
- **Scattered read는 안 함** — 어차피 Modbus는 batch가 빠름
|
||||
|
||||
### 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ C++ Gateway │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ Poller │─▶│ Cache │◀─│ gRPC Server│ │
|
||||
│ │ (1s 주기) │ │ (47KB) │ │ │ │
|
||||
│ │ 32+3+13 │ │ │ │ ReadTags │ │
|
||||
│ │ batch │ │ │ │ WriteTag │ │
|
||||
│ │ reads │ │ │ │ ListTags │ │
|
||||
│ └────┬──────┘ └──────────┘ └─────┬──────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ industrial-comm (libcomm_core) │ │
|
||||
│ │ ModbusTCP → HC900 @ 192.168.0.240 │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 폴링 전략
|
||||
- `read_holding_registers(addr, count=120)` batch
|
||||
- Loop 1~24: 32 reads (120 regs each)
|
||||
- Loop 25~32: 32 reads (120 regs each)
|
||||
- Variable area: 3 reads (0x18C0~, 336 regs)
|
||||
- Signal area: 13 reads (0x2000~, 1,508 regs)
|
||||
- **Total: 48 batch reads → ~117 ms full poll**
|
||||
- 1초 간격 poll, 캐시 갱신
|
||||
|
||||
### gRPC Service (`proto/modbus_gateway.proto`)
|
||||
|
||||
```protobuf
|
||||
service ModbusGateway {
|
||||
rpc ReadTags(ReadTagsRequest) returns (ReadTagsResponse); // 캐시 응답
|
||||
rpc WriteTag(WriteTagRequest) returns (WriteTagResponse); // Modbus write
|
||||
rpc StreamTags(StreamTagsRequest) returns (stream TagValue); // 실시간 구독
|
||||
rpc ListTags(ListTagsRequest) returns (ListTagsResponse); // 메타정보
|
||||
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
}
|
||||
```
|
||||
|
||||
- `ReadTags`: 캐시에서 즉시 응답 (sub-millisecond)
|
||||
- `WriteTag`: Modbus FC16 직접 write 후 캐시 갱신
|
||||
- `StreamTags`: 1초 poll 주기에 맞춰 push
|
||||
|
||||
### 레지스터 맵 로딩
|
||||
- 시작 시 `register-map.json` 파일 읽음 (174 KB)
|
||||
- 또는 `hc900_map_master` DB 테이블에서 읽음
|
||||
|
||||
## 3. hc900_map_master (DB 기반 태그 카탈로그)
|
||||
|
||||
### 개념
|
||||
- `node_map_master`와 동일한 패턴
|
||||
- HC Designer CSV 3종을 DB에 로드
|
||||
- 태그 prefix 기반 분류 (기존 PidPrefixRule 재활용)
|
||||
|
||||
### DDL
|
||||
|
||||
```sql
|
||||
CREATE TABLE hc900_map_master (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tag_name TEXT NOT NULL, -- 레지스터 맵 태그명 (e.g. 'FICQ3101.PV', 'FIQ6101')
|
||||
base_tag TEXT NOT NULL, -- 베이스 태그 (e.g. 'FICQ3101', 'FIQ6101')
|
||||
attribute TEXT, -- 속성 (e.g. 'PV', 'SP', 'QV') — NULL이면 베이스 태그 자체
|
||||
addr INTEGER NOT NULL, -- Modbus 주소
|
||||
count INTEGER NOT NULL, -- 레지스터 수 (1 or 2)
|
||||
type TEXT NOT NULL, -- 'float32' or 'uint16'
|
||||
access TEXT NOT NULL, -- 'R' or 'RW'
|
||||
description TEXT,
|
||||
eu TEXT,
|
||||
category TEXT, -- PidPrefixRule 기반 분류 ('instrument', 'power_equipment', ...)
|
||||
tag_class TEXT, -- 'field' or 'system'
|
||||
tag_dcs BOOLEAN, -- DCS function block 여부
|
||||
source TEXT NOT NULL, -- 'loop', 'signal', 'variable'
|
||||
group_name TEXT, -- PointBuilder 그룹 ('Controller1', 'Custom', ...)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_hc900_map_base ON hc900_map_master(base_tag);
|
||||
CREATE INDEX idx_hc900_map_addr ON hc900_map_master(addr);
|
||||
CREATE INDEX idx_hc900_map_group ON hc900_map_master(group_name);
|
||||
```
|
||||
|
||||
### 데이터 출처
|
||||
|
||||
| CSV 파일 | source | 건수 | 범위 |
|
||||
|---|---|---|---|
|
||||
| SummaryFunctionBlockReport.csv | `loop` | 32 loops × 25 params = 800 | 0x0040~0x7FFF |
|
||||
| SignalTags.csv | `signal` | 530 | 0x2000~0x25E4 |
|
||||
| Variables.csv | `variable` | 155 | 0x18C0~0x1A10 |
|
||||
|
||||
### Prefix Rules (PidPrefixRule 활용)
|
||||
|
||||
| Prefix | Category | 예시 |
|
||||
|---|---|---|
|
||||
| FIC, FICA, FICQ | instrument, tag_dcs=true | FICQ3101 (PID Loop) |
|
||||
| FIT, FIQ, FT | instrument, tag_dcs=false | FIT6101, FIQ6101 (Signal Tag) |
|
||||
| TI, TIC, TICA | instrument, tag_dcs=true | TICA6111A, TICA3202A |
|
||||
| LI, LIC, LICA | instrument | LICA6213, LICA5113 |
|
||||
| PI, PIC, PICA | instrument | PICA6111 |
|
||||
| XV | instrument | XV3208B_REM |
|
||||
| VP, P- | power_equipment | VP8117_HS, P-3101B_RUN |
|
||||
|
||||
### PointBuilder-like Selection
|
||||
|
||||
웹 UI에서 `hc900_map_master`를 대상으로:
|
||||
|
||||
| Group | 필터 조건 | 용도 |
|
||||
|---|---|---|
|
||||
| Controller1 | prefix=FIC/TIC/PIC/LIC, attribute=PV/SP/OP | 주요 PID 값 |
|
||||
| Custom | user-defined | QV, TRIP, ESD, HS 등 |
|
||||
|
||||
DB 조회 예시:
|
||||
```sql
|
||||
-- FICQ-6101 계열 모든 태그
|
||||
SELECT * FROM hc900_map_master
|
||||
WHERE base_tag IN (
|
||||
SELECT base_tag FROM hc900_map_master
|
||||
WHERE base_tag ILIKE 'ficq6101'
|
||||
);
|
||||
```
|
||||
|
||||
## 4. OPC UA 이름 매핑 (핵심 난제)
|
||||
|
||||
### 문제
|
||||
- DB (Experion OPC UA): `ficq-6101.pv`, `ficq-6101.qv` (lowercase, dash, dot attribute)
|
||||
- HC Designer CSV: `FICQ3101`, `FIQ6101` (uppercase, no dash, no prefix-dot suffix)
|
||||
- R530이 중간에서 이름 변환 + 서브 속성 구성하며, **이 매핑 정보는 R530 설정에만 존재**
|
||||
|
||||
```
|
||||
OPC UA (DB) HC Designer (CSV) Modbus
|
||||
──────────────────── ────────────────── ───────
|
||||
ficq-6101.pv FICA6101 (Loop #11) 0x0A40 ← prefix 다름 (FICQ vs FICA)
|
||||
ficq-6101.qv.value FIQ6101 (Signal Tag) 0x2006 ← 사용자 정의 규칙 (FIQ=QV)
|
||||
ficq-6101.sp FICA6101 (Loop #11) 0x0A44
|
||||
ficq-3101.pv FICQ3101 (Loop #1) 0x0040
|
||||
ficq-3101.qv.value FIQ3101 (Signal Tag) 0x2164
|
||||
```
|
||||
|
||||
prefix 예: `FICQ`/`FICA`/`FIT`/`FIQ` 모두 숫자부 `6101` 공유 → **prefix는 접근 방식(loop, PV, QV)을 나타내고, 숫자부가 실제 연결고리**
|
||||
|
||||
### 매핑 전략: 숫자부 조인 + prefix 검증
|
||||
|
||||
`realtime_table`의 OPC UA 이름과 `hc900_map_master`의 HC Designer 이름을 **숫자부(6101) 기준으로 매칭**하고, prefix rules로 검증:
|
||||
|
||||
```sql
|
||||
-- Phase A: 후보 추출 (숫자부 기반 매칭)
|
||||
WITH rt_base AS (
|
||||
SELECT DISTINCT
|
||||
split_part(tagname, '.', 1) AS opcua_tag,
|
||||
tagname,
|
||||
substring(tagname FROM '(\d+)') AS tag_number
|
||||
FROM realtime_table
|
||||
),
|
||||
hc_base AS (
|
||||
SELECT
|
||||
tag_name,
|
||||
base_tag,
|
||||
addr,
|
||||
substring(tag_name FROM '(\d+)') AS tag_number
|
||||
FROM hc900_map_master
|
||||
)
|
||||
SELECT
|
||||
r.opcua_tag,
|
||||
r.tagname AS opcua_name,
|
||||
h.tag_name AS hc_name,
|
||||
h.addr
|
||||
FROM rt_base r
|
||||
JOIN hc_base h ON r.tag_number = h.tag_number
|
||||
AND r.opcua_tag ~* h.base_tag -- base_tag regex 매칭
|
||||
ORDER BY r.opcua_tag, h.addr;
|
||||
```
|
||||
|
||||
### 결과물: opcua_aliases
|
||||
|
||||
```sql
|
||||
ALTER TABLE hc900_map_master ADD COLUMN opcua_aliases TEXT[];
|
||||
|
||||
-- 예시 (자동 생성 후 사용자 검증 필요):
|
||||
-- FIQ6101 → {"ficq-6101.qv", "ficq-6101.qv.value"}
|
||||
-- FICQ3101.PV → {"ficq-3101.pv"}
|
||||
-- FICA6101-WSP → {"ficq-6101.wsp"}
|
||||
```
|
||||
|
||||
### 사용자 검증 플로우
|
||||
|
||||
```
|
||||
CSV → hc900_map_master realtime_table (OPC UA)
|
||||
│ │
|
||||
└──── SQL 숫자부 조인 ───┘
|
||||
│
|
||||
alias 후보 목록
|
||||
│
|
||||
사용자 검증 (Y/N)
|
||||
│
|
||||
opcua_aliases 확정
|
||||
│
|
||||
C# 앱이 gRPC 호출 시
|
||||
aliases로 주소 조회
|
||||
```
|
||||
|
||||
C# 앱에서 gRPC 요청 시:
|
||||
```csharp
|
||||
// C# 앱은 DB 이름(ficq-6101.pv)으로 요청
|
||||
// 게이트웨이는 aliases 매칭으로 레지스터 주소 조회
|
||||
var resp = client.ReadTags(new[] { "ficq-6101.pv", "ficq-6101.qv" });
|
||||
```
|
||||
|
||||
## 5. 구현 단계
|
||||
|
||||
### Phase 1: C++ 게이트웨이 (완성)
|
||||
- [ ] `industrial-comm`에 gRPC 서버 추가
|
||||
- [ ] `register-map.json` 로더
|
||||
- [ ] Poller (full poll 1초)
|
||||
- [ ] gRPC 서비스 구현 (ReadTags, WriteTag, ListTags, HealthCheck)
|
||||
- [ ] CMakeLists.txt에 gRPC/Protobuf 종속성 추가
|
||||
- [ ] systemd unit file
|
||||
|
||||
### Phase 2: DB 및 백엔드
|
||||
- [ ] `hc900_map_master` 테이블 생성 SQL
|
||||
- [ ] `build_hc900_map.py` CSV → DB 로더 (Python)
|
||||
- [ ] `scripts/build_register_map.py` → DB 직접 적재
|
||||
- [ ] Prefix Rules 추가 (FIQ, FIT, FICA 등)
|
||||
- [ ] PointBuilder-like 웹 UI (기존 ExperionCrawler UI 확장)
|
||||
|
||||
### Phase 3: C# HC900Crawler
|
||||
- [ ] ExperionCrawler 포크 → gRPC Client 추가
|
||||
- [ ] 기존 OPC UA 계층 대체 (IExperionOpcClient → IModbusGatewayClient)
|
||||
- [ ] Hc900RealtimeService (캐시 폴링 기반)
|
||||
- [ ] Write 서비스
|
||||
- [ ] 기존 DB 스키마 재활용 (realtime_table, history_table, etc.)
|
||||
|
||||
### Phase 4: OPC UA 이름 매핑
|
||||
- [ ] node_map_master 조회로 실제 OPC UA 이름-레지스터 관계 분석
|
||||
- [ ] opcua_aliases DB 구축
|
||||
- [ ] 매핑 검증 툴
|
||||
|
||||
## 6. 검증된 사실
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| HC900 Modbus TCP 연결 | ✅ C70, port 502, IP=192.168.0.240 |
|
||||
| Float 포맷 | ✅ FP_B (IEEE 754 big-endian) |
|
||||
| Loop 주소 체계 | ✅ Loop #N = 0x40+(N-1)*0x100 (1~24), 0x7840+(N-25)*0x100 (25~32) |
|
||||
| batch read 성능 | ✅ 120 regs = 2.4ms, full poll = 117ms |
|
||||
| C70 최대 연결 | ✅ 10개 |
|
||||
| R530 사용 연결 | ✅ 1개 (Modbus TCP 채널) |
|
||||
| 우리 사용 가능 | ✅ 9개, 실제론 1~2개면 충분 |
|
||||
| Signal Tag FIQ6101 = QV | ✅ `ficq-6101.qv.value` = `FIQ6101` @ 0x2006 |
|
||||
| 사용자 정의 네이밍 | ✅ FIT=PV, FIQ=QV 등 prefix 기반 규칙 존재 |
|
||||
|
||||
## 7. 오픈 이슈
|
||||
|
||||
- [ ] **OPC UA ↔ Modbus 주소 매칭 (최대 난제)** — 숫자부 조인으로 후보 추출 후 사용자 검증 필요
|
||||
- [ ] HC900 Custom Map/User Defined 영역 태그 확인 (현재는 Fixed Map만 커버)
|
||||
- [ ] Write 시 Modbus FC16 테스트 필요
|
||||
- [ ] 캐시 무효화 전략 (C#에서 Write 후 gateway cache 갱신)
|
||||
- [ ] StreamTags 구현 방식 (1초 poll마다 변경분 push vs 전부 push)
|
||||
BIN
docs/templates/리포트템플릿-예시-C6111-daily.xlsx
vendored
Normal file
BIN
docs/templates/리포트템플릿-예시-C6111-daily.xlsx
vendored
Normal file
Binary file not shown.
189
docs/논의-AI운전원-제어-아이디어.md
Normal file
189
docs/논의-AI운전원-제어-아이디어.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 논의 정리: AI 운전원 / 직접제어 아이디어
|
||||
|
||||
> 2026-06-11 대화 정리. 스마트폰에서 진행한 브레인스토밍을 다시 논의하기 위해 기록.
|
||||
|
||||
## 출발 질문
|
||||
- 히스토리컬 데이터 기준으로 **현재 제어출력(OP)에서 앞으로 5분간 유량 예측**이 가능한가?
|
||||
|
||||
### 결론
|
||||
가능. 단 "예측"을 둘로 나눠야 함:
|
||||
- **OP → 유량 정상상태**: 유량 루프는 응답 빠른 계통(시정수 수 초~수십 초). 5분(300초)은 과도응답이 아니라 거의 정상상태. → OP에 대한 정상상태 게인 맵으로 예측 쉬움. (이미 learned-control의 steady-state map과 동일한 물건)
|
||||
- **5분 동안의 변동 요인**: 예측 오차의 진짜 원인.
|
||||
- OP 자체가 5분 안에 바뀜 (자동=컨트롤러, 수동=운전원) → OP 미래궤적부터 예측해야 함
|
||||
- 미측정 외란 (헤더 압력, 상류 공급, 다른 루프 간섭)
|
||||
|
||||
### 데이터 제약
|
||||
| 소스 | 주기 | 5분=점개수 | 용도 |
|
||||
|---|---|---|---|
|
||||
| `history_table` | 60초 | ~5점 | 정상상태 예측엔 충분, 동특성 식별엔 거침 |
|
||||
| `realtime_table` | ~1초 | ~300점 | FOPDT 동특성 식별에 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 우리 프로젝트의 진짜 자산 (moat)
|
||||
이 셋을 **한 시스템 안에** 다 가진 게 차별점 (대부분 시스템은 하나만 가짐):
|
||||
1. **컨트롤러 직접 쓰기 가능** — gateway WriteTag + mode write가 C3에서 실측 검증됨
|
||||
2. **운전원 행동 히스토리 + P&ID 그래프 + KB 문서** — trace_connections, build_pid_graph
|
||||
3. **LLM/RAG 추론 레이어**
|
||||
|
||||
## 후보 아이디어 (임팩트 순)
|
||||
1. 🔥 **"AI 운전원" — 자문에서 자율로**: 학습 모델이 선택된 MANUAL 루프에 직접 OP 쓰기. 쓰기 경로 이미 검증됨. 단계적(자문→1클릭 승인→화이트리스트 자율). 안전장치: 그래프/KB로 하류 영향 사전 체크.
|
||||
2. **그래프 기반 자동 근본원인 추적**: 값 비정상 시 P&ID 그래프 따라 상류 전파 추적 → 원인 노드 식별. 알람 홍수를 한 줄 인과로 압축.
|
||||
3. **외란 도착 예측 → 선제 피드포워드**: 그래프에 시간 입혀서 상류 외란 도착 시각 계산 → PV 움직이기 전에 OP 선제 조정. 5분 예측이 보고용이 아니라 선제 제어입력이 됨.
|
||||
4. **소프트 센서**: 필드계기는 고유 태그 없음. 측정 안 되는 양을 상관 태그+모델로 추정 → 비용 0으로 계측점 늘리기.
|
||||
|
||||
**추천 방향:** #1을 목표로, #2·#3을 안전·예측 엔진으로 깔기 = "설명 가능하고(그래프) 앞을 내다보는(예측) AI 운전원".
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 핵심 반론 (사용자 지적)
|
||||
지난 **4개월 운전원 수동 데이터** 온도 프로파일을 보면 **민감단 온도가 크게 안 흔들림 → 운전원이 이미 제어를 매우 잘함**.
|
||||
→ 우리가 직접 제어해서 "더 정밀하게" 이겨봐야 어필이 안 됨. 운전원의 정상상태 제어를 타이트하게 이기는 건 어렵고, 이겨도 감동이 없음.
|
||||
|
||||
### 대응 전략: 채점표를 바꿔라
|
||||
운전원이 이기는 축(정상상태 온도 타이트함)에서 싸우지 말고, 운전원이 **구조적으로 못 이기는** 세 축에서 싸운다.
|
||||
|
||||
**① 경제 마진 — 진짜 팔리는 숫자**
|
||||
- 민감단 온도가 안 흔들린다 = 운전원이 **보수적 마진**을 깔고 운전(환류/스팀 여유). 그 여유 = 매일 버려지는 에너지/처리량.
|
||||
- 어필: "온도 안정도는 운전원과 **동일**, 셋포인트를 최적점으로 당겨 스팀 X% 절감."
|
||||
- 제어를 이기는 게 아니라 **같은 안정도로 더 싸게**. APC가 30년간 돈 번 방식. 운전원은 다변수 동시 최적화 못 하니 구조적으로 못 따라옴.
|
||||
|
||||
**② 선제성(앞먹임) — 운전원은 PV 움직인 뒤에 반응**
|
||||
- 우리는 그래프로 상류 외란 도착 미리 계산 → 온도 움직이기 전 OP 조정.
|
||||
- 어필: "공급 변동 시 민감단 온도 최대 편차, 운전원 대비 절반." 운전원이 원리적으로 못 이기는 영역.
|
||||
|
||||
**③ 분산(variance) — 4개월 프로파일의 함정**
|
||||
- 깔끔한 프로파일은 잘하는 운전원의 좋은 날 평균. 교대조별·시간대별(새벽 3시)·신참으로 쪼개면 표준편차 다름.
|
||||
- 가치 = "항상 최고 운전원 수준, 24시간, 모든 교대조." 평균이 아니라 최악 시간대를 끌어올림.
|
||||
|
||||
---
|
||||
|
||||
## 다음 액션: 제어 건드리기 전 오프라인 검증
|
||||
4개월 히스토리로 **카운터팩추얼 분석** (컨트롤러 안 건드림, 데이터로만):
|
||||
1. 운전원이 깔고 있는 보수 마진 측정 (민감단 온도 vs 제약한계 거리, 환류/스팀 여유분)
|
||||
2. "온도 안정도 그대로 두고 마진만 당겼다면 절감액 = ?" 정량 추정
|
||||
3. 외란 구간 운전원 반응 지연 측정 → 앞먹임으로 줄일 수 있는 편차 정량화
|
||||
|
||||
→ 이 세 숫자가 나오면 제어 켜기 전에 어필 자료 완성. 셋 다 "운전원보다 정밀"이라는 어려운 약속이 아니라 "운전원이 못 하는 것"이라는 방어 가능한 약속.
|
||||
|
||||
### 첫 단추 (추천)
|
||||
**①(마진)부터.** 필요한 것: 민감단 온도 태그 + 환류/스팀 관련 태그. → 4개월치로 "버려지는 마진"이 실제 존재하는지 데이터로 확인.
|
||||
|
||||
## 데이터 검증 1회차 (2026-06-12) — ① 마진 가설 부분 반증
|
||||
|
||||
대상 확정: **P6-1 / C-6111 진공증류탑 (C3)**.
|
||||
⚠️ **데이터 창 (확정):** 현장 실데이터 = **2026-02-05 ~ 2026-06-05** (field_hist `dtat` KST 기준; UTC `recorded_at` 02-05 03:53 ~ **06-05 02:22**). field_hist 17개 cont 전수조회로 확인(주 테이블 336,519행). 그 이후 ~06-12는 **C3 시뮬레이션 소스**이므로 제외. **분석 컷오프 = `recorded_at < '2026-06-05 02:22 UTC'`** (사용자 (A) 선택). (메모리의 06-05는 정확; 이전 "5월말일"은 며칠 짧았음.)
|
||||
|
||||
**태그 매핑 확정 (정정: 민감단 = TI-6111C, NOT D):**
|
||||
- **민감단(품질·분리도 proxy) = `TI-6111C.PV` = T_C** (중상부). DB·문서·현 프로그램 모두 이 기준. 실창 p05~p95 = **83.9~86.0, sd 1.45** → **실제로 움직임**. (`작업플랜-민감단온도-전환복귀제어.md` L26)
|
||||
- 참고: `TI-6111D.PV`(상부)는 sd 0.19로 못박혀 있으나 **민감단 아님** — 품질 프록시로 쓰지 말 것.
|
||||
- 에너지 레버 = 스팀 `FIQ-6115.PV`(T_C 주조작변수), 리플럭스 `FICQ-6113.PV`
|
||||
- 처리량 = feed `FICQ-6101.PV` (p05~p95 = 393~918, **2.3배 변동**)
|
||||
- 하부온도(스팀 자동제어) = `TICA-6111A` (.PV/.SP/.OP)
|
||||
|
||||
**핵심 발견 (분 단위 피벗, feed·민감단 T_C 동시 고정 counterfactual; n=13,137):**
|
||||
| 검증 | 전체 변동(실창) | feed 600~700 & T_C(TI-6111C) 84.6~85.0 고정 후 |
|
||||
|---|---|---|
|
||||
| 스팀 FIQ-6115 | sd 146 (p05~p95 277~664) | **sd 12.5**, p10~p90 478~500 (±2.3%) |
|
||||
| 리플럭스 FICQ-6113 | sd 442 (p05~p95 905~2042) | **sd 37**, p10~p90 1470~1497 (±1%) |
|
||||
| feed FICQ-6101 | sd 197 (p05~p95 392~913) | (고정변수) |
|
||||
|
||||
→ **스팀·리플럭스 2.3~2.4배 변동은 거의 전부 feed/T_C로 설명됨.** 진짜 민감단 T_C로 고정하니 스팀 변동이 더 작아짐(sd 12.5) — 스팀이 곧 T_C를 잡는 MV이므로 당연. 정상상태 "같은 품질·더 적은 스팀" 마진 = p50−p10 ≈ **11 kg/h (~2.3%)**.
|
||||
|
||||
**결론:** ①(정상상태 에너지 마진)은 헤드라인 불가(~2.3%). 변동의 원천은 **feed 변화와 그 과도구간(transition)**, 그리고 **T_C 자체가 sd 1.45로 움직이는 구간**. 가치는 정상상태가 아니라 *전환·T_C 이탈*에 있음.
|
||||
|
||||
**🔑 핵심 발견 — 이 thread는 이미 작업라인:** `docs/작업플랜-민감단온도-전환복귀제어.md` 가 정확히 이 방향을 설계해 둠 (T_C 대역유지 → 이탈 시 전환류 도피 → bumpless 복귀). C# 인프라 일부 가동 중: `FeedforwardEngine`/`ColumnMode{Normal,Recovering,Returning}` 상태기계, `SteamAdvisor`(steam=f(feed,product,T_C) 맵 = T_C 목표역산·디커플링), `FeedRampAdvisor`. → 브레인스토밍이 기존 작업라인으로 수렴. 다음 단계는 **T_C 이탈 구간의 정량화**(빈도·크기·원인=feed/grade/주야)로 그 작업라인의 가치를 사이징하는 것.
|
||||
|
||||
**미해결 단서:** `TICA-6111A.SP` 이봉성 (p05=84.0, p50=88.4, p95=88.4) → 두 모드(저온~84/고온88.4) = **grade/feed 모드 전환** 추정. T_C 이탈과 묶이는지 확인 필요.
|
||||
|
||||
## 데이터 검증 2회차 (2026-06-12) — ★전환 후 재정착에 마진이 있다
|
||||
|
||||
T_C 이탈 정량화 결과 (2,861시간, 실데이터):
|
||||
- **시간 내(분 단위 제어): 98.9%가 폭 ≤0.5°C** (시간당 평균폭 0.26, sd 0.06). 이탈시간(폭>1°C) = 0.5%.
|
||||
- **시간 간(의도 이동): 99.3%가 ≤0.25°C/h**, p99=0.166. 최대 3.63°C(드문 큰 전환).
|
||||
- 주야 차이 거의 없음 (day/swing/night avg range 0.25/0.28/0.25, 이탈 0.42/0.74/0.42%) → ③ 교대조 분산 닫힘.
|
||||
→ T_C 총 2.3°C 폭은 **싸운 이탈이 아니라 의도적 조건변경의 누적**. 운전원은 단기·전환 모두 매우 잘함.
|
||||
|
||||
**핵심 컨텍스트(사용자): 지난 몇 달간 운전 조건을 많이 변경함.** → 데이터 = rich excitation 자연실험(moat #2 연료). 풀링 금지(레짐별로 봐야).
|
||||
|
||||
**주별 캠페인 (median):**
|
||||
| 주 | feed | T_C | 스팀 | 제품 | 스팀/제품 |
|
||||
|---|---|---|---|---|---|
|
||||
| 02-16 | 497 | 84.3 | 344 | 446 | 0.787 |
|
||||
| 02-23 | 798 | 85.4 | 560 | 717 | 0.783 |
|
||||
| 03-02 | 903 | 86.0 | 652 | 821 | 0.795 |
|
||||
| 04-06 | 804 | 85.5 | 608 | 717 | 0.847 |
|
||||
| **04-27** | 421 | 84.1 | 304 | 355 | **0.858** |
|
||||
| 05-04 | 403 | 84.0 | 295 | 355 | 0.834 |
|
||||
| **05-11** | 404 | 84.0 | 281 | 355 | **0.796** |
|
||||
| 05-18 | 406 | 84.0 | 282 | 355 | 0.792 |
|
||||
|
||||
- 운전점 자체가 크게 변동: feed 400~900(2.25배), T_C 목표 84.0(저율)~86.0(고율) = 캠페인별 의도 설정.
|
||||
- **★재정착 마진:** 04-27 vs 05-11 = **거의 동일 운전점**(feed~410, T_C 84.0, 제품 355)인데 스팀/제품 **0.858→0.796 (~8%)**. 조건변경 직후 보수적→수주에 걸쳐 효율점 재탐색(0.858→0.834→0.796→0.792). = **전환 후 재정착 손실**.
|
||||
|
||||
**확정된 어필 논리:** 운전원은 *정착하면* 프런티어 근처(정상상태 마진 ~2%, ②③ 닫힘). 그러나 *조건 변경 시* 효율점 재탐색에 수주가 걸려 그동안 ~8% 스팀 과다. 모델은 학습한 `steam=f(feed,T_C)` 바닥값을 **전환 즉시** 적용 → 재정착 손실 회수. **전환이 잦은 공장이라 반복 적용됨.** "제어를 이긴다"가 아니라 "운전원이 결국 찾는 최선을 전환 직후부터" = 방어 가능.
|
||||
|
||||
**다음 probe:** 4개월 전 구간에서 조건변경 이벤트를 자동 검출 → 각 전환의 (재정착 소요일 × 효율 페널티) 합산 = 회수가능 스팀 총량(연간 환산). 기존 `SteamAdvisor`(steam=f(feed,product,T_C) 맵)가 바닥값 산출 엔진.
|
||||
|
||||
## 데이터 검증 3회차 (2026-06-12) — 재정착 마진도 작음, 진짜 비용은 startup/off-spec
|
||||
|
||||
전환 settling 체계적 정량화 (일단위, 5캠페인/4전환; `/tmp/settling_probe.py`):
|
||||
| seg | 시작 | 일 | feed | T_C | eff | settle | penalty |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0 | 02-05 | 20 | 501 | 84.3 | 0.810 | 8d | 8,292kg (startup, 선례無→청구불가) |
|
||||
| 1 | 02-25 | 20 | 901 | 86.0 | 0.796 | 0 | 0 |
|
||||
| 2 | 03-17 | 35 | 801 | 85.4 | 0.830 | 0 | 0 |
|
||||
| 3 | 04-21 | 11 | 650 | 84.8 | 0.842 | 0 | 0 |
|
||||
| 4 | 05-02 | 35 | 404 | 84.0 | 0.811 | 3d | 2,044kg (선례有) |
|
||||
|
||||
- **운전원은 큰 조건변경 후에도 며칠 내 자기 효율바닥 도달** (seg1·2·3 settling=0).
|
||||
- 선례 있는 재정착 회수 = seg4의 2,044kg/3.9mo = **연 6,200kg ≈ 저율 연스팀의 0.25%** → 무시 수준.
|
||||
- **결론: 에너지 마진 전부 닫힘** — 정상상태 ~2%, 재정착 ~0.25%.
|
||||
|
||||
**남은 진짜 기회 (에너지 아님):**
|
||||
1. **캠페인 간 eff 격차(0.796~0.852, ~7%)** — 운전점 교란됨(저율=고정손실↑로 steam/prod 자연↑). 청구하려면 `eff=f(feed,T_C)` 맵(SteamAdvisor) 적합 후 **모델바닥 위 잔차**로 측정. seg2(35일 0.830)가 바닥 위였나가 핵심.
|
||||
2. **★startup/grade 전환 = 진짜 큰 과도비용** — seg0 1회 startup = 8,292kg(8일). 스팀보다 **off-spec 제품·time-to-spec**이 금액 큼(미측정). bumpless-복귀 플랜(`작업플랜-민감단온도-전환복귀제어`)이 노리는 지점.
|
||||
|
||||
**가치 메트릭 전환 필요:** "운전원보다 적은 스팀"(닫힘) → "**전환·startup의 time-to-spec / off-spec 제품 절감**" + "**24/7 무인 운전원-품질 보장**(labor)" + "**throughput 최대화**(미검증)". 제어 outperform이 아니라 운전원이 잘 못하는 *드문 어려운 순간*과 *지속 보장*.
|
||||
|
||||
## 재프레임된 후보 (임팩트 순)
|
||||
1. 🔥 **전환 최적화** — 이봉 SP 전환 이벤트 추출 → 전환 소요시간·정착중 품질편차·추가스팀 정량화. 어필: "전환시간/off-spec 절반."
|
||||
2. 🔥 **처리량 최대화** — 제약 한계(하부온도/진공/플러딩)까지 feed 추가 여력 있는 시간대 정량화.
|
||||
3. **분산/최악시간대(③)** — 교대조·시간대·전환구간 tail 편차로 쪼개기.
|
||||
4. ~~① 정상상태 에너지 마진~~ — 부수효과로만.
|
||||
|
||||
## 다시 논의할 때 정할 것
|
||||
- [x] 데이터 컷오프 = `< 2026-06-05 02:22 UTC` (A) · 민감단 = TI-6111C 확정
|
||||
- [ ] **다음 probe: T_C(TI-6111C) 이탈 구간 정량화** — 목표대역 밖 체류시간 %·이탈 크기·빈도, 그리고 feed 변화/SP 이봉전환/주야와의 상관. 이게 기존 `작업플랜-민감단온도-전환복귀제어` 작업라인의 가치(=전환·분산 절감)를 사이징함.
|
||||
- [ ] TICA-6111A.SP 이봉성의 정체 확인 (grade? feed 소스? 주야?) — T_C 이탈과 묶이는지
|
||||
- [ ] (병렬) 처리량 최대화 여력: 제약 한계 대비 feed 깔린 시간대
|
||||
- [ ] realtime 1초급 데이터가 실제로 쌓이는지 확인 (동특성 식별용) — 미확인
|
||||
|
||||
---
|
||||
|
||||
# 🔀 방향 전환 (2026-06-12): 제어 outperform → 편의성·리포트 제품화
|
||||
|
||||
> 위 1~4 후보(제어 성능으로 운전원 이기기)는 **데이터가 거의 다 닫음**. 사용자 결정: **"제어를 더 잘한다"가 아니라 "운전원/관리자가 하기 힘든 결정론적 분석·리포트를 자동·셀프서비스로 해주는 편의성"** 에 포커스. ← 이쪽이 더 돈에 가깝고 방어 가능.
|
||||
|
||||
## 통찰: 오늘 한 작업 자체가 제품의 프로토타입
|
||||
오늘 세션에서 내가 한 일 = 기간 선정 → 드롭아웃/양자화 필터 → 효율·잔차·편차·재정착을 **결정론 SQL로 계산**. 이걸 **운전원이 버튼으로** 하게 만드는 것이 제품. DCS/MES가 못 하고 전산팀을 안 거침.
|
||||
|
||||
## 제품 = 3개 아이디어가 한 파이프라인으로 체인
|
||||
**캡처(fastRecord) → 결정론 계산(메트릭) → 시각화(온도프로파일 방식) → 운전원 엑셀폼 export** — 전 과정이 전산팀/MES 우회, 운전원 소유.
|
||||
|
||||
1. **시각적 셀프서비스 분석** — 온도프로파일처럼 "기간 선택 → 효율/제어잔차/수율/편차를 자동 계산·시각화 → 특정시각 문제 즉시 확인". 작업외 시간↓.
|
||||
2. **운전원-정의 엑셀폼 리포트 (= 돈)** — 현 파이프라인(Experion→DB→MES→폼)은 *컬럼 하나 바꾸는 데도* 전산팀 협의·의도설명·장기 리드타임·재작업. 대안: **운전원이 만든 엑셀 폼을 계약(contract)으로 삼아**, DB에서 Daily/Monthly/Yearly(컬럼별 에너지효율·제어잔차·수율…)를 계산해 **그 폼에 즉시 꽂아줌**. 포맷 즉시 변경 = 상품성.
|
||||
3. **★fastRecord = 빠진 주춧돌** — UI fastRecord(태그·간격 s/min·기간 선택 레코딩)가 결정론 로직과 결합하면 **60초 history로 불가능한 동특성·루프진단**(FOPDT τ/deadtime, 오버슈트, 밸브 stiction/헌팅)이 열림. 외과적 step/bump test 셀프서비스 = 제어벤더가 비싸게 파는 카테고리. **보너스: 고해상 전환캡처 = SteamAdvisor/모델 학습 데이터** → 편의성으로 팔며 AI운전원 연료 축적(같은 기능 2번 일함).
|
||||
|
||||
## 결정적 기술 사실 (MVP 성립 근거)
|
||||
- **`fast_record`와 `history_table`가 동일 long 포맷**(`tagname, recorded_at, value`) → 메트릭 SQL이 **소스 테이블만 교체**해 양쪽에서 그대로 작동 = "해상도 가변"이 공짜.
|
||||
- **SheetJS(`xlsx.full.min.js`) 이미 클라이언트 탑재** → 엑셀폼 읽기/쓰기 인프라 존재.
|
||||
- `FastController`/`FastSession`/`fast_record` 가동 중(start/stop/sessions, 백그라운드 sampling 루프).
|
||||
- 의미층(moat) 이미 존재: `tag_metadata` + KB 문서(단일 진실원) + loop→필드계기 매핑 + `pid_equipment`. ← "컬럼 하나 즉시 변경"을 가능케 하는 핵심(전산팀이 매번 수작업 재구성하는 부분).
|
||||
|
||||
## 설계 철칙
|
||||
- **해상도-인지**: 모든 메트릭 출력에 `(source, sampling_ms, n, cleaned_fraction)` 메타 동봉 — 1초 리포트 vs 60초 리포트가 사과-오렌지 안 되게.
|
||||
- **결정론 검증 게이트**(메모리): 셀 무음 공란/날조 금지, 모든 셀이 메트릭+SQL로 추적, 실패는 명시적 에러.
|
||||
- **메트릭/매핑은 레지스트리·config** (하드코딩 아님) → "컬럼 변경 = 폼/레지스트리 편집, 전산팀 0".
|
||||
|
||||
→ MVP 아키텍처 상세: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md)
|
||||
796
docs/작업지시서-API-필드-PascalCase-통일.md
Normal file
796
docs/작업지시서-API-필드-PascalCase-통일.md
Normal file
@@ -0,0 +1,796 @@
|
||||
# 구현 검증 보고서 — 작업지시서-API-필드-PascalCase-통일
|
||||
|
||||
**검증일**: 2026-06-11
|
||||
**검증 대상**: 작업지시서 §5 Phase 0–3 구현 결과
|
||||
**검증 방법**: `diagnosis-checklist.md` 8단계 + 전수 코드 스캔 (28개 파일 변경, +631/-544줄)
|
||||
**빌드 상태**: ✅ `dotnet build` 0 Error, 0 Warning
|
||||
**전체 등급**: 🟠 MED — Phase 0(PascalCase parse) 완료, Phase 1·2 다수 누락, Phase 3 양호
|
||||
|
||||
---
|
||||
|
||||
## STEP 1 — 맥락 파악
|
||||
|
||||
**질문: 무엇이 구현되었고, 무엇을 검증하는가?**
|
||||
|
||||
작업지시서는 4개 Phase로 구성:
|
||||
- **Phase 0** — `TextToSqlController.parse` 핫픽스 (PascalCase 응답)
|
||||
- **Phase 1** — 15개 컨트롤러 익명객체 응답 PascalCase 통일
|
||||
- **Phase 2** — 17개 JS 파일 응답 읽기 PascalCase 통일
|
||||
- **Phase 3** — 내부용 `[JsonPropertyName]` 제거
|
||||
|
||||
구현 결과 28개 파일이 변경됨 — 컨트롤러 15개 전부, JS 10개, DTO 2개, 서비스 1개 수정. 본 검증은 각 Phase가 **완전히** 구현되었는지, **누락 및 회귀**가 없는지를 진단한다.
|
||||
|
||||
---
|
||||
|
||||
## STEP 2 — 구조 탐색
|
||||
|
||||
변경된 파일 계층:
|
||||
|
||||
```
|
||||
Controllers/*.cs (15) ──응답 PascalCase──▶ wwwroot/js/*.js (10) ──소비
|
||||
│ │
|
||||
└── DTOs/*.cs (2) ──[JsonPropertyName] 제거──┘
|
||||
│
|
||||
└── Service/*.cs (1) ──InputSchema 추가 (관련 없음)
|
||||
```
|
||||
|
||||
**28개 변경 파일 중 `git diff --stat` 기준**:
|
||||
- 컨트롤러: 15개 (모든 컨트롤러)
|
||||
- JS: 10개 (core.js·app.js·evt.js 제외한 나머지)
|
||||
- DTO: `ExperionDtos.cs`, `TrendDtos.cs`
|
||||
- 서비스: `McpService.cs`, `IMcpService.cs`
|
||||
- 기타: `prompts/plant_context.md`
|
||||
|
||||
---
|
||||
|
||||
## STEP 3 — 코드 읽기
|
||||
|
||||
### Phase 0: TextToSqlController.parse — ✅ PASS
|
||||
|
||||
```csharp
|
||||
// TextToSqlController.cs L44 — 변경됨
|
||||
return Ok(new { Success = true, Sql = sql }); // ← PascalCase ✅
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
```
|
||||
|
||||
이전: `new { success = true, sql }` → 현재: `new { Success = true, Sql = sql }`.
|
||||
`t2s.js`가 이미 `parseRes.Sql`·`parseRes.Success`로 읽고 있으므로 즉시 복구됨.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 컨트롤러 통일 — ❌ 11/15 누락
|
||||
|
||||
| 컨트롤러 | 상태 | 잔여 소문자 |
|
||||
|----------|------|-----------|
|
||||
| `FastController.cs` | ✅ CLEAN | 0 |
|
||||
| `TrendController.cs` | ✅ CLEAN | 0 |
|
||||
| `DocsController.cs` | ✅ CLEAN | 0 |
|
||||
| `PidGraphController.cs` | ✅ CLEAN | 0 |
|
||||
| `TextToSqlController.cs` | ✅ CLEAN | 0 |
|
||||
| `HypertableController.cs` | ⚠️ 1건 | `statusMessage = info.StatusMessage` L22 |
|
||||
| `KbAuthController.cs` | ⚠️ 1건 | `new { valid }` L50 |
|
||||
| `Hc900Controllers.cs` | ⚠️ 5건 | Gateway health/status/tags L36-79, `{ total, tags }` L415 |
|
||||
| `SetupController.cs` | ⚠️ ~6건 | controllers status/config L40-80 (주석: "camelCase 보장") |
|
||||
| `OllamaController.cs` | ⚠️ ~8건 | `GetModels`/`GetVllmModels` 응답 L291-594 (`success`, `error`, `models`, `excluded`), SSE 툴 이벤트 페이로드 L165-194 |
|
||||
| `SteamAdvisorController.cs` | ⚠️ ~5건 | ComputeStages 내부 익명객체 L555-577 |
|
||||
| `PointBuilderController.cs` | ⚠️ ~8건 | Items 매핑 L426-444, Sinam 파일 목록 L207-214 |
|
||||
| `KbController.cs` | ⚠️ ~12건 | Document/Job Select L155-264, error 응답 |
|
||||
| `PidController.cs` | ⚠️ ~22건 | Equipment DTO 매핑 L115-141, prefix rule CRUD |
|
||||
| `FeedforwardController.cs` | ⚠️ **150+건** | `MapConfig`·`MapRampJob`·`MapRamp`·`MapColumn` 전부 미변경 L213-519 |
|
||||
|
||||
> **Phase 1 판정**: 4/15만 CLEAN, 나머지 11개에 소문자 잔존. 특히 `FeedforwardController`는 전체 응답 객체 트리가 100% 소문자여서 효과가 전혀 없음. `PidController`·`KbController`도 대량 미변경.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: JS 파일 통일 — ❌ 12/17 파일에 소문자 잔존
|
||||
|
||||
**🔴 HIGH — trend.js (4개 버그)**
|
||||
|
||||
| 위치 | 문제 | 영향 |
|
||||
|------|------|------|
|
||||
| L659 | `pt.value` → `pt.Value` 미변경 | 라이브 틱 데이터가 차트에 반영 안 됨 |
|
||||
| L676 | `p.Desc` → `p.Description` (DTO 속성명 불일치) | 그룹 빌더 설명 필드 `undefined` |
|
||||
| L751 | `g.members` → `g.Members` 미변경 | 그룹 멤버 로딩 전면 실패 |
|
||||
| L752-758 | `m.tag`·`m.color`·`m.axis`·`m.desc` 등 전부 소문자 | 멤버 속성 전부 `undefined` |
|
||||
| L755-758 | `trAnalogMap[m.tag]?.desc` — map 저장은 `Desc`, 조회는 `desc` | 키 불일치로 항상 `undefined` |
|
||||
| L698-708 | `p.tagName`·`p.description`·`p.area`·`p.value` 등 전부 소문자 | 그룹 빌더 UI 전면 미동작 |
|
||||
|
||||
**🟠 MED — pb.js (~19건)**
|
||||
|
||||
| 패턴 | 위치 |
|
||||
|------|------|
|
||||
| `res.success` → `res.Success` | L135, 370, 434, 469 |
|
||||
| `res.error` → `res.Error` | L143, 434, 470 |
|
||||
| `res.message` → `res.Message` | L84, 100, 349, 362 |
|
||||
| `res.count` → `res.Count` | L84, 100, 111, 112, 238, 349, 362 |
|
||||
| `res.items` → `res.Items` | L230, 240, 275, 283 |
|
||||
|
||||
**🟠 MED — kbadmin.js (~12건)**
|
||||
|
||||
`data.success`·`data.error`·`data.chunks`·`data.affected`·`data.total` 등 전부 소문자 (L202-341)
|
||||
|
||||
**🟡 LOW — 기타 미변경 파일**
|
||||
|
||||
| 파일 | 건수 | 예시 |
|
||||
|------|------|------|
|
||||
| `setup.js` | ~6건 | `r.success ?? r.ok`, `r.message` |
|
||||
| `write.js` | ~4건 | `d.success`, `d.error`, `d.controllers` |
|
||||
| `steam.js` | ~3건 | `d.columns`, `data.suggestions`, `d.message` |
|
||||
| `fast.js` | ~2건 | `data.items`, 요청바디 `{ name, samplingMs }` |
|
||||
| `ff.js` | ~2건 | `data.columns` |
|
||||
| `pid.js` | ~5건 | `data.items`, `err.error` |
|
||||
| `llmchat.js` | 0건 (기존 폴백 유지) | `d.Success ?? d.success` — 방어적, 통일 후 제거 대상 |
|
||||
|
||||
> **Phase 2 판정**: `hist.js`·`evt.js`·`docs.js`·`core.js`·`app.js`는 CLEAN. `t2s.js`는 PascalCase로 올바름. 나머지 12개 파일에 소문자 잔존. 특히 **trend.js는 4개 런타임 버그**(그룹 로딩·라이브 틱·그룹 빌더·아날로그 맵)가 공존.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: `[JsonPropertyName]` 정리 — ✅ 양호 (1건 REMOVE 권장)
|
||||
|
||||
25개 `[JsonPropertyName]` 중 24개가 외부 계약으로 KEEP 판정 정확함.
|
||||
|
||||
**REMOVE 권장 1건**: `ExperionDtos.cs:64`
|
||||
```csharp
|
||||
[JsonPropertyName("analogmon1")] // ← 미사용 DTO. PointBuilderBuildDto 자체가 dead code
|
||||
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
|
||||
```
|
||||
→ `Hc900PointBuilderBuildDto`(같은 파일 L106-113)가 실제 사용 중이므로, 위 클래스 전체 삭제 권장.
|
||||
|
||||
---
|
||||
|
||||
### 추가 발견: `TrendDtos.cs` — `TrendMemberDto` `[JsonPropertyName]` 3개 제거됨
|
||||
|
||||
```csharp
|
||||
// BEFORE
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("color")] public string Color { get; set; } = "";
|
||||
[JsonPropertyName("axis")] public string Axis { get; set; } = "";
|
||||
// AFTER
|
||||
public string Tag { get; set; } = "";
|
||||
public string Color { get; set; } = "";
|
||||
public string Axis { get; set; } = "";
|
||||
```
|
||||
|
||||
**의도**: `PropertyNamingPolicy = null`에서 자연스럽게 PascalCase 직렬화.
|
||||
**영향**: `TrendGroupDto.Members`가 `[{"Tag":"FICQ-6101.PV","Color":"#e41a1c","Axis":"left"}, ...]`로 나감.
|
||||
**JS 영향**: `trend.js` L751-758이 `g.members`를 `g.Members`로, `m.tag`를 `m.Tag`로 바꾸지 않으면 그룹 기능 중단. 현재 둘 다 소문자여서 **그룹 기능 동작 안 함**.
|
||||
|
||||
---
|
||||
|
||||
## STEP 4 — 호출 계층 지도
|
||||
|
||||
Phase 0은 완료되었으나 Phase 1과 Phase 2의 **미완성으로 인한 의존 체인 단절**:
|
||||
|
||||
```
|
||||
Phase 1 미완료 (11/15 컨트롤러)
|
||||
↓
|
||||
Phase 2에서 수정한 JS가 PascalCase API를 호출
|
||||
↓
|
||||
미변경 컨트롤러가 camelCase → JS undefined
|
||||
↓
|
||||
Silent failure — 빈 테이블, 동작하지 않는 버튼
|
||||
```
|
||||
|
||||
반대 방향도 존재:
|
||||
|
||||
```
|
||||
Phase 1 완료 (4/15 컨트롤러 — Fast, Trend, Docs, PidGraph)
|
||||
↓
|
||||
Phase 2 미완료 (trend.js 등이 여전히 camelCase로 읽음)
|
||||
↓
|
||||
PascalCase API 응답 → JS undefined
|
||||
↓
|
||||
Silent failure
|
||||
```
|
||||
|
||||
**즉, 어떤 방향이든 현재 코드는 **컨트롤러 11개 + JS 12개에 걸쳐 미스매치 상태**다.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 5 — 패턴 매칭 (체크리스트)
|
||||
|
||||
### 🔴 HIGH — 미변경 camelCase 변수 참조
|
||||
|
||||
| 체크 | 항목 | 판단 |
|
||||
|------|------|------|
|
||||
| [x] | **trend.js `g.members` → `g.Members`** | L751 — `TrendGroupDto.Members`는 PascalClass, API는 `"Members"`, JS는 `g.members` → `undefined`. 그룹 로딩 전면 실패 |
|
||||
| [x] | **trend.js `m.tag` → `m.Tag` 등** | L752-758 — `TrendMemberDto.Tag`/`Color`/`Axis`가 PascalCase인데 JS가 소문자로 읽음 |
|
||||
| [x] | **trend.js `p.Desc` → `p.Description`** | L676 — `AnalogPointDto.Description`을 `Desc`로 오기입 |
|
||||
| [x] | **trend.js `pt.value` → `pt.Value`** | L659 — `TrendLivePointDto.Value`인데 소문자 `pt.value`는 `undefined` |
|
||||
| [x] | **trend.js 아날로그 맵 키 불일치** | L676 `{ Desc, Unit, ... }`로 저장 → L755-758 `.desc`, `.unit`으로 조회 |
|
||||
| [x] | **trRenderAnalog 전면 미변경** | L698-708 — 8개 필드 전부 소문자 |
|
||||
|
||||
### 🟠 MED — 불완전한 변경
|
||||
|
||||
`pb.js`·`kbadmin.js`·`setup.js`·`write.js`·`steam.js`·`ff.js`·`fast.js`·`pid.js` — 변경 자체가 누락.
|
||||
|
||||
### 🟢 LOW — Phase 3 DTO 정리
|
||||
|
||||
`ExperionDtos.cs`의 dead code 제거는 미이행되었으나 실행에 영향 없음.
|
||||
|
||||
---
|
||||
|
||||
## STEP 6 — 교차 검증
|
||||
|
||||
| Q | 질문 | 결과 |
|
||||
|---|------|------|
|
||||
| Q1 | 이미 수정된 문제인가? | trend.js 4개 버그는 **아직 수정되지 않음** (현재 파일 상태). pb.js 등 8개 파일도 미수정. |
|
||||
| Q2 | 다른 레이어에서 처리? | trend.js 그룹 로딩(`g.members`) → 우회 불가. JS에서 유일한 소비 계층. |
|
||||
| Q3 | 의도적 설계? | trend.js L676 `p.Desc`는 오기입(오타). 나머지는 단순 누락. |
|
||||
| Q4 | 재현 시나리오? | trend.js: 트렌드 탭 → 그룹 선택 → 빈 화면(멤버 로딩 실패). 그룹 빌더 → 모든 태그 필드 `undefined`. |
|
||||
|
||||
---
|
||||
|
||||
## STEP 7 — 심각도 분류 및 STEP 8 — 보고서
|
||||
|
||||
### 🔴 HIGH 1. trend.js 그룹 기능 전면 불능
|
||||
|
||||
**문제**: `TrendGroupDto.Members`·`TrendMemberDto.Tag/Color/Axis`가 PascalCase로 직렬화되지만, `trApplyGroup`(L751)이 `g.members`·`m.tag` 등 소문자로 읽음. `trAnalogMap`도 저장 키(Desc)와 조회 키(desc)가 불일치.
|
||||
|
||||
**근거**: `TrendDtos.cs` L11-16: `[JsonPropertyName]` 3개 제거, 속성명 `Tag`·`Color`·`Axis`.
|
||||
`trend.js` L751-758: `g.members.map(m => ({ tag: m.tag, color: m.color, axis: m.axis, desc: trAnalogMap[m.tag]?.desc }))`.
|
||||
|
||||
**영향**: 트렌드 탭 → 그룹 선택 시 멤버 0명 로드 → 차트 빈 화면. 라이브 틱(`pt.value` L659)도 누락되어 데이터 갱신 안 됨.
|
||||
|
||||
**수정**: L751 `g.members` → `g.Members`, L752-758 `m.tag` → `m.Tag`, `m.color` → `m.Color`, `m.axis` → `m.Axis`. L676 `p.Desc` → `p.Description`. L659 `pt.value` → `pt.Value`. L755-758 `.desc` → `.Desc`, `.unit` → `.Unit`, `.euLo` → `.EuLo`, `.euHi` → `.EuHi`. L698-708 `p.tagName` → `p.TagName`, `p.description` → `p.Description`, `p.area` → `p.Area`, `p.value` → `p.Value`, `p.unit` → `p.Unit`, `p.euLo` → `p.EuLo`, `p.euHi` → `p.EuHi`.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 MED 2. Phase 1 컨트롤러 11개 소문자 잔존
|
||||
|
||||
**문제**: 15개 컨트롤러 중 11개에 소문자 익명객체가 잔존. 특히 `FeedforwardController`(150+ 속성), `PidController`(22), `KbController`(12)가 대량.
|
||||
|
||||
**근거**: STEP 3 Phase 1 표 참조.
|
||||
|
||||
**영향**: 해당 컨트롤러의 JS(`ff.js`, `pid.js`, `kbadmin.js` 등)가 소문자를 읽는다면 현재는 우연히 맞지만, **Phase 2에서 JS를 PascalCase로 바꾸면 깨짐**. 또는 반대로 **JS가 PascalCase를 읽으면 현재부터 깨져 있음**.
|
||||
|
||||
**수정**: 각 컨트롤러의 모든 `return Ok(new { ... })`·`return BadRequest(new { ... })`에서 소문자 키를 PascalCase로 변경. 작업지시서 §6.1 grep 명령어로 탐색 후 일괄 수정.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 MED 3. Phase 2 JS 12개 파일 소문자 잔존
|
||||
|
||||
**문제**: `pb.js`(~19건), `kbadmin.js`(~12건), `setup.js`·`write.js`·`steam.js`·`ff.js`·`fast.js`·`pid.js` 등 12개 파일이 PascalCase API 응답을 소문자로 읽는 코드 잔존.
|
||||
|
||||
**근거**: STEP 3 Phase 2 표 참조.
|
||||
|
||||
**영향**: 해당 탭의 기능이 현재 작동 중이라면, 그것은 해당 컨트롤러가 아직 PascalCase로 바뀌지 않았기 때문(Phase 1도 미완료). **Phase 1이 완료되는 순간 이 JS들은 깨진다.**
|
||||
|
||||
**수정**: 각 파일에서 `.success` → `.Success`, `.error` → `.Error`, `.items` → `.Items` 등 일괄 치환. 작업지시서 §6.2 grep 명령어로 탐색 후 수정.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 LOW 4. `ExperionDtos.cs` dead code `[JsonPropertyName]` 잔존
|
||||
|
||||
**문제**: `[JsonPropertyName("analogmon1")]`이 미사용 DTO에 남아 있음.
|
||||
|
||||
**근거**: `ExperionDtos.cs:64`. `PointBuilderBuildDto` 클래스 전체가 `Hc900PointBuilderBuildDto`로 대체되어 미참조.
|
||||
|
||||
**영향**: 없음 (dead code).
|
||||
|
||||
**수정**: 해당 `[JsonPropertyName]` 라인 및 미사용 DTO 클래스 전체 삭제.
|
||||
|
||||
---
|
||||
|
||||
## STEP 8 자가 검증
|
||||
|
||||
- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킬 수 있는가? — trend.js L659·L676·L698-708·L751-758 등
|
||||
- [x] HIGH 항목은 재현 가능한 시나리오를 한 문장으로 말할 수 있는가? — 트렌드 탭에서 그룹 선택 시 빈 차트, 그룹 빌더에서 모든 필드 undefined
|
||||
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함되었는가? — 통과
|
||||
- [x] 보고서의 수정 예시가 현재 코드에 아직 적용되지 않은 내용인가? — 적용되지 않음 (현재 버그 상태임)
|
||||
- [x] Phase 1·2의 미완료는 "구현 중단"이 아닌 "구현 누락"으로 판단 — trend.js의 오기입(p.Desc)은 오타, 나머지는 단순 미변경
|
||||
|
||||
---
|
||||
|
||||
# 작업지시서 — 내부 REST API 필드명 PascalCase 전면 통일
|
||||
|
||||
**진단일**: 2026-06-11
|
||||
**진단 방법**: `diagnosis-checklist.md` 8단계 순차 적용
|
||||
**등급**: 🟠 MED — Plan 작성자의 현황 파악에 오류 1건 확인
|
||||
|
||||
---
|
||||
|
||||
## STEP 1 — 맥락 파악
|
||||
|
||||
**질문: 이 파일은 무엇을 하는 파일인가?**
|
||||
|
||||
작업지시서(이하 "Plan") — 백엔드 내부 REST API 응답 JSON 필드명을 PascalCase로 전면 통일하기 위한 실행 계획. `Program.cs`의 `PropertyNamingPolicy = null` 설정 아래에서 C# 코드의 속성명이 JSON으로 그대로 직렬화됨을 전제로, 15개 컨트롤러와 17개 JS 파일을 대상으로 카탈로그화·단계별 전환을 정의한다. 트리거는 커밋 `dbad4a5`가 `TextToSqlController`만 PascalCase로 바꾸고 `llmchat.js` 등 소비자를 누락해 발생한 무음 버그(silent failure).
|
||||
|
||||
Plan은 아래와 같은 구조로 읽힌다:
|
||||
1. 배경(§1): 근본 메커니즘 설명 + "PascalCase 통일" 결정
|
||||
2. 범위(§2): 변경 대상 vs 절대 변경 금지 대상을 표로 정리
|
||||
3. 현황 인벤토리(§3): 컨트롤러별 익명객체 소문자 응답 라인 수 + JS 파일별 주 컨트롤러 매핑
|
||||
4. 표준 규약(§4) + 실행 계획(§5, 4Phase) + 작업자 가이드(§6) + 검증 체크리스트(§7) + 리스크(§8) + DoD(§9)
|
||||
|
||||
> **STEP 1 결론**: Plan의 목적과 구조는 명확하며, §2(변경 금지 외부 계약)와 §4(표준 규약)의 판단 기준이 구체적이어서 실행자의 혼란을 방지한다. 다만 §3 현황 인벤토리가 **"이미 수정된 사항"과 "아직 수정되지 않은 사항"을 구분하지 않고 혼합 기재**하여, 문서만 읽는 실행자가 실제 현재 상태를 오인할 위험이 있다.
|
||||
|
||||
---
|
||||
|
||||
## STEP 2 — 구조 탐색
|
||||
|
||||
**도움이 될 관련 파일 목록 (Plan이 참조하는 실제 코드):**
|
||||
|
||||
| 파일 | Plan § | Plan에서 주장하는 역할 |
|
||||
|------|--------|----------------------|
|
||||
| `src/Hc900Crawler/Program.cs` | §1.1 | `PropertyNamingPolicy = null` (JSON 직렬화 원인) |
|
||||
| `src/Hc900Crawler/Controllers/TextToSqlController.cs` | §3.1, §5.0 | `parse`만 소문자 잔존 |
|
||||
| `src/Hc900Crawler/wwwroot/js/t2s.js` | §3.2, §5.0 | 이미 `parseRes.Sql` PascalCase 기대 |
|
||||
| `src/Hc900Crawler/wwwroot/js/hist.js` | §3.2, §3.3 | `d.Success ?? d.success` 폴백 있음 ← **미확인** |
|
||||
| `src/Hc900Crawler/wwwroot/js/trend.js` | §3.2, §3.3 | `d.Success ?? d.success` 폴백 있음 ← **미확인** |
|
||||
| `src/Hc900Crawler/Controllers/*.cs` (14개) | §3.1 현황 인벤토리 | 전수 조사 대상 |
|
||||
| `src/Hc900Crawler/wwwroot/js/*.js` (17개) | §3.2 JS 매핑 | 전수 조사 대상 |
|
||||
|
||||
> **STEP 2 결론**: 의존 파일 목록은 완전하다. 다만 hist.js·trend.js에 대한 §3.3의 주장(폴백 존재)은 **검증되지 않은 채 작성**되었다( 아래 STEP 3에서 확인).
|
||||
|
||||
---
|
||||
|
||||
## STEP 3 — 코드 읽기 ★ 주요 오류 발견
|
||||
|
||||
Plan의 핵심 주장 3가지를 실제 코드와 대조했다.
|
||||
|
||||
### 3.1 ✅ `TextToSqlController.parse` — 소문자 잔존 (Plan §3.1 L86)
|
||||
|
||||
```csharp
|
||||
// TextToSqlController.cs L44 (실제)
|
||||
return Ok(new { success = true, sql });
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
```
|
||||
|
||||
Plan의 주장과 **일치**. `parse` 엔드포인트만 `{ success, sql, error }` (camelCase)를 반환하고, 나머지(9개) 엔드포인트는 전부 `{ Success, Sql, Error }` (PascalCase). Plan은 이 차이를 정확히 지적했다.
|
||||
|
||||
### 3.2 ✅ `t2s.js` — 이미 PascalCase 기대 (Plan §5.0 L150)
|
||||
|
||||
```javascript
|
||||
// t2s.js L118-120 (실제 — parse 버튼 핸들러)
|
||||
const res = await api('POST', '/api/text-to-sql/parse', { query: input });
|
||||
if (res.Success) { // ← PascalCase를 읽지만
|
||||
sqlTextarea.value = res.Sql;
|
||||
} else {
|
||||
sqlTextarea.value = `오류: ${res.Error || '알 수 없는 오류'}`;
|
||||
}
|
||||
// t2s.js L428-432 (실제 — T2S 채팅 핸들러)
|
||||
const parseRes = await api('POST', '/api/text-to-sql/parse', { query: message });
|
||||
if (!parseRes.Success || !parseRes.Sql) { // ← PascalCase를 읽지만
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ...`);
|
||||
```
|
||||
|
||||
Plan의 주장과 **일치**. t2s.js는 3곳(버튼·T2S 채팅·API 채팅) 모두 `res.Success`, `res.Sql`, `res.Error`로 PascalCase를 읽고 있다. 그러나 실제 API는 `{ success, sql, error }`(camelCase)를 반환하므로, **지금 이 순간 `parse` 버튼과 T2S 채팅의 SQL 변환 기능은 조용히 깨져 있다**:
|
||||
- `res.Success`는 항상 `undefined` → if문이 항상 else로 빠짐
|
||||
- 화면에 "SQL 변환 실패: 알 수 없는 오류" 표시
|
||||
- 실제 SQL 생성은 정상이지만 **UI가 결과를 보여주지 못함**
|
||||
|
||||
### 3.3 ❌ `hist.js`·`trend.js` — 폴백 부재 (Plan §3.3 L119)
|
||||
|
||||
Plan §3.3(이미 적용된 수정)은 다음과 같이 기술한다:
|
||||
|
||||
> `hist.js`·`trend.js`는 `query-history-interval` 읽을 때 이미 `d.Success ?? d.success` 폴백이 적용돼 있어 현재 동작.
|
||||
|
||||
그러나 **실제 코드는 폴백이 없었다** (본 진단 작성자가 수정하기 전):
|
||||
|
||||
```javascript
|
||||
// hist.js L122-127 (수정 전) — 폴백 없음
|
||||
if (!d.success) { throw new Error(d.error || '조회 실패'); }
|
||||
const rows = d.rows || []; // API는 { Rows, TagNames } (PascalCase) 반환
|
||||
const tNames = d.tagNames || [];
|
||||
```
|
||||
|
||||
```javascript
|
||||
// trend.js L358 (수정 전) — 폴백 없음
|
||||
rows = (d && d.success !== false) ? (d.rows || []) : [];
|
||||
```
|
||||
|
||||
Plan 수립 당시 **이 코드는 동작하지 않았다**. `query-history-interval`은 `{ Success, Rows, TagNames }`(PascalCase)를 반환하지만 JS는 `d.success`, `d.rows`, `d.tagNames`(camelCase)로 읽어 모두 `undefined`였다. 즉:
|
||||
- hist.js의 **간격 조회 탭이 정상 동작하지 않았다** — 빈 테이블 렌더
|
||||
- trend.js의 **집계 경로(interval !== '1 minute')가 동작하지 않았다** — rows가 항상 빈 배열 → rawQuery로 폴백
|
||||
|
||||
> **STEP 3 결론**: Plan은 사실 확인을 충분히 하지 않은 주장(§3.3)을 포함하고 있다. 최소한 `grep "d\.success" hist.js trend.js` 한 줄이면 발견할 수 있었던 오류. §3.3은 "이미 적용된 수정"을 기술하는 섹션인데, 실제로는 적용되지 않은 상태였다. 여기에 기재된 3개 항목 중 **llmchat.js·McpToolDto·OllamaController 폴백만 적용**되어 있었고 hist.js·trend.js는 적용되지 않았다.
|
||||
|
||||
---
|
||||
|
||||
## STEP 4 — 호출 계층 지도 작성
|
||||
|
||||
Plan이 제안한 5개 Phase의 의존 관계 분석:
|
||||
|
||||
```
|
||||
Phase 0 — 핫픽스 (parse 엔드포인트)
|
||||
↓ 성공해야
|
||||
Phase 1 — 컨트롤러 백엔드 통일 (15개, 단위별 작업)
|
||||
↓ 각 컨트롤러 수정 직후
|
||||
Phase 2 — 프론트엔드 통일 (대응 JS)
|
||||
↓ 반복
|
||||
Phase 1↔2 완료 후
|
||||
Phase 3 — DTO [JsonPropertyName] 정리
|
||||
↓
|
||||
Phase 4 — 전체 회귀 검증 (체크리스트 §7)
|
||||
```
|
||||
|
||||
> **STEP 4 결론**: Phase 순서와 의존 관계는 타당하다. 특히 "백엔드+프론트 짝 커밋" 원칙(§8.6)이 강조된 점은 과거 실패(dbadd4a5의 부분 머지)를 반영한 조치로 적절하다. 다만 Phase 1의 우선순위(라인 수 오름차순)는 위험도 기반이 아니라 **작업량 기반**이라서, **영향 범위가 큰 컨트롤러**(예: OllamaController는 혼재로 인해 리스크가 높음)가 후순위로 밀릴 수 있다. 위험도 기반으로 `FeedforwardController`(44 lines, 단순)는 먼저, `OllamaController`(22 lines, ⚠️혼재)는 중간에 넣는 등 보강이 권장된다.
|
||||
|
||||
---
|
||||
|
||||
## STEP 5 — 패턴 매칭 (체크리스트 순회)
|
||||
|
||||
Plan을 8개 체크리스트 영역으로 평가. Plan은 **이미 존재하는 버그가 아니라 앞으로의 작업 계획**이므로, 아래는 Plan 자체의 완전성·정확성을 평가한다.
|
||||
|
||||
### 🟡 LOW — 미정의 변수·함수 없음 (양호)
|
||||
|
||||
Plan의 변수 참조(§6 grep 명령어, §7 체크리스트 항목)는 모두 정의되어 있다.
|
||||
|
||||
### 🟡 LOW — 하드코딩
|
||||
|
||||
Plan은 의도적으로 **하드코딩된 현황 인벤토리**(§3)를 포함한다. 이는 문서가 스냅샷 성격을 가지므로 적절하다. grep 명령어를 함께 제공(§6)해 독자가 최신 상태를 재생성할 수 있도록 한 점은 좋은 설계.
|
||||
|
||||
### 🟠 MED — 에러 응답 형식 불일치
|
||||
|
||||
Plan §4.2는 "공통 봉투 통일: 모든 응답은 최상위에 `Success`(bool)"라고 규정하지만, §3.1 현황 인벤토리 기준으로 **6개 컨트롤러**가 응답 형식이 파편화되어 있다:
|
||||
|
||||
| 컨트롤러 | 응답 형식 | 문제 |
|
||||
|----------|----------|------|
|
||||
| `HypertableController` | `{ info.IsHypertable, info.TableName }` | 최상위에 `Success` 없음, 엔티티 직접 반환 |
|
||||
| `FastController` | `{ items }` 또는 `{ session.Id, status }` | 혼재 — 일부만 `{ success = true }` |
|
||||
| `TrendController` | `{ items }` 또는 `{ error }` | 최상위 `Success` 없음 |
|
||||
| `OllamaController` | `{ success, models }` 또는 `{ reply }` | 내부 응답도 camelCase |
|
||||
| `PidGraphController` | `{ success, data, message }` | `data`가 내포 객체 |
|
||||
| `Hc900Controllers` | 엔티티 직접 반환(`Ok(points)` 또는 `Ok(result)`) | 래핑 없음, 형식 다양 |
|
||||
|
||||
Plan은 이 차이를 인지하고 §4.2에서 통일을 규정했으나, Phase 1의 작업 우선순위는 현황 복잡성을 반영하지 않았다. 응답 형식이 이미 `{ Success, Error }` 구조에 가까운 컨트롤러(HypertableController·FastController)는 변경량이 적고, 엔티티를 직접 반환하는 컨트롤러(Hc900Controllers)는 래핑이 추가로 필요하다.
|
||||
|
||||
### 🟡 LOW — 동시성 / async 관련
|
||||
|
||||
Plan의 Phase 1-2 작업은 순차적 엔드포인트 변경으로 동시성 이슈 없음.
|
||||
|
||||
### 🟡 LOW — 보안
|
||||
|
||||
SQL Injection 경로 트래버설 관련 논의는 Plan 범위 밖. 단, §2.2 변경 금지 목록은 적절하다.
|
||||
|
||||
> **STEP 5 결론**: Plan의 가장 큰 위험은 §3.3의 사실 오류(hist.js·trend.js 폴백 주장)와 Phase 1 우선순위가 위험도보다 작업량 중심인 점. §6 grep 명령어도 `new \{[^}]*`로는 **다중 라인 익명객체**를 잡지 못하는 한계가 있다.
|
||||
|
||||
---
|
||||
|
||||
## STEP 6 — 교차 검증
|
||||
|
||||
### Q1. 이미 수정된 문제인가?
|
||||
|
||||
| 지적 사항 | 수정 여부 | 판단 |
|
||||
|-----------|----------|------|
|
||||
| §3.3 hist.js/trend.js 폴백 주장 오류 | **본 진단 작성 시점에 수정 완료** | 진단 항목에서 **제거** (현재는 폴백 있음) |
|
||||
| §3.1 parse 소문자 잔존 | 미수정 | **🔴 HIGH — 유지** |
|
||||
| Phase 1 우선순위 작업량 중심 | N/A (Plan 설계 결정) | **🟡 LOW — 제안** |
|
||||
| 응답 형식 파편화 | 미수정 (Phase 1에서 처리 예정) | **유지** |
|
||||
|
||||
### Q2. 다른 레이어에서 처리되는가?
|
||||
|
||||
| 항목 | 레이어 처리 | 판단 |
|
||||
|------|-----------|------|
|
||||
| parse 소문자 문제 | JS에서 폴백 없이 직접 PascalCase 읽음 — 우회 없음 | **🔴 HIGH** |
|
||||
| hist.js/trend.js | 현재는 폴백 추가되어 우회됨 | **해소됨** |
|
||||
|
||||
### Q3. 의도적 설계인가?
|
||||
|
||||
| 항목 | 의도적? | 판단 |
|
||||
|------|---------|------|
|
||||
| Plan의 §3.3 오류 | 의도적 아님 — 검증 생략으로 인한 실수 | **보고** |
|
||||
| Phase 1 작업량 중심 우선순위 | 의도적 — 문서에 별도 위험 평가 없음 | **개선 제안** |
|
||||
| §4 PascalCase 규약 | 의도적 — 문서 §1.3에서 근거 제시 | **수용** |
|
||||
|
||||
### Q4. 재현 시나리오?
|
||||
|
||||
| 항목 | 재현 시나리오 | 판단 |
|
||||
|------|-------------|------|
|
||||
| parse 소문자 | Text-to-SQL 탭에서 SQL 질문 입력 → "변환" 버튼 클릭 → "SQL 변환 실패: 알 수 없는 오류" 표시. 브라우저 콘솔에서 `res.Success`가 `undefined`임 확인 가능 | **🔴 HIGH** |
|
||||
| hist.js (수정 전) | 이력 탭 → 간격 조회 → 빈 테이블 | **수정됨** |
|
||||
|
||||
> **STEP 6 결론**: 교차 검증 후 **1건 🔴 HIGH** (parse 소문자)와 **1건 🟡 LOW** (Phase 1 우선순위 개선 제안)가 남았다. §3.3의 hist.js/trend.js 오류는 현재 수정되어 문서에 반영할 필요가 없으나, **문서의 신뢰성에 영향을 준 사례로 기록**한다.
|
||||
|
||||
---
|
||||
|
||||
## STEP 7 — 심각도 분류 및 STEP 8 — 보고서
|
||||
|
||||
### 🔴 HIGH 1. TextToSqlController.parse() 응답 케이싱 불일치
|
||||
|
||||
**문제**: `POST /api/text-to-sql/parse`가 camelCase `{ success, sql, error }`를 반환하지만, `t2s.js`의 3개 호출 지점(§3.2 확인)이 전부 PascalCase `res.Success`·`res.Sql`·`res.Error`를 읽는다. t2s.js는 이미 PascalCase로 올바르게 작성되어 있으므로 **백엔드만 수정**하면 된다.
|
||||
|
||||
**근거**: `TextToSqlController.cs:44` — `return Ok(new { success = true, sql });` 대 `t2s.js:118-120, 428-432, 592-598` — `res.Success`, `parseRes.Sql`.
|
||||
|
||||
**영향**: Text-to-SQL 탭에서 "변환" 버튼 클릭 시 항상 "SQL 변환 실패: 알 수 없는 오류" 메시지. LLM 챗의 API 채팅/T2S 채팅에서도 동일. 실제 SQL 생성은 정상(로깅 가능)이나 **UI가 사용자에게 결과를 전달하지 못함**.
|
||||
|
||||
**수정 방향**: `TextToSqlController.cs:44,48`에서 `new { success = true, sql }` → `new { Success = true, Sql = sql }`로 변경. 이 수정은 즉시 전체 3개 호출 지점을 동시에 복구한다(Plan §5.0 Phase 0에 명시된 수정과 동일).
|
||||
|
||||
### 🟡 LOW 2. Phase 1 우선순위 — 작업량 vs 위험도 불일치
|
||||
|
||||
**문제**: Plan §5 Phase 1은 익명객체 라인 수 오름차순(1→2→5→…→44)으로 컨트롤러를 정렬했다. 이는 **작업량 중심** 순서로, **위험도 중심이 아니다**. 예를 들어 `OllamaController`(22줄)는 외부 API payload(변경 금지)와 내부 응답(변경 대상)이 혼재되어 있어 변경 시 리스크가 높지만, `KbController`(33줄)보다 먼저 배치되었다.
|
||||
|
||||
**근거**: Plan §5 Phase 1의 순서에 위험도 평가나 난이도 표시가 없음.
|
||||
|
||||
**영향**: OllamaController에서 실수로 외부 payload(`model`, `messages`, `stream`, `tools` 등 §2.2 변경 금지 키)를 건드리면 **LLM 채팅 전체 장애**. 작업자가 변경 금지 목록(§2.2)과 대조하며 작업해야 함.
|
||||
|
||||
**수정 제안**: Phase 1에 **리스트 업데이트** 주석 추가 — `OllamaController`는 §2.2 변경 금지 혼재로 **위험도: HIGH** 표시. 난이도·위험도에 따라 재정렬하거나, 각 컨트롤러 옆에 ⚠️ 표시 추가.
|
||||
|
||||
### 🟡 LOW 3. §3.3 사실 오류 (이미 수정 — 기록용)
|
||||
|
||||
**문제**: Plan §3.3은 "hist.js·trend.js에 `d.Success ?? d.success` 폴백 적용됨"이라고 주장하나, 실제 코드에는 폴백이 없었다.
|
||||
|
||||
**영향**: 문서 신뢰성 저하. Plan을 읽고 작업 순서를 결정하는 실행자가 hist.js·trend.js를 "이미 완료"로 오인할 수 있다.
|
||||
|
||||
**조치**: §3.3은 별도 수정 없이 본 진단 보고서를 문서 상단에 삽입함으로써 사실 관계를 정정한다. hist.js·trend.js는 본 진단 과정에서 dual-casing 폴백을 추가 완료.
|
||||
|
||||
---
|
||||
|
||||
## STEP 8 자가 검증
|
||||
|
||||
- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킬 수 있는가? — TextToSqlController.cs:44, t2s.js:118·432·592
|
||||
- [x] HIGH 항목은 재현 가능한 시나리오를 한 문장으로 말할 수 있는가? — Text-to-SQL 탭에서 질문 입력 → "변환" 버튼 → "SQL 변환 실패" 메시지
|
||||
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함되었는가? — §3.3 항목은 Q1(이미 수정)으로 본 항목에서 제외, 기록용으로만 기재
|
||||
- [x] 보고서의 수정 예시가 현재 코드에 아직 적용되지 않은 내용인가? — parse 수정은 Plan §5.0에 명시되어 있으나 아직 적용되지 않음
|
||||
- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다"를 혼동하지 않았는가? — Phase 1 우선순위는 "개선 제안"으로 명시
|
||||
|
||||
---
|
||||
|
||||
# 작업지시서 — 내부 REST API 필드명 PascalCase 전면 통일
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 작성일 | 2026-06-11 |
|
||||
| 대상 | `src/Hc900Crawler` 백엔드 컨트롤러 응답·요청 DTO + `wwwroot/js/*.js` 프론트엔드 |
|
||||
| 목적 | 백엔드↔프론트 JSON 필드 케이싱 불일치로 인한 무음 버그(silent failure) 제거 |
|
||||
| 트리거 | 커밋 `dbad4a5`(PascalCase 부분 통일)이 `TextToSqlController`만 바꾸고 일부 소비자(`llmchat.js`, `parse` 엔드포인트)를 누락 → LLM 채팅에서 도구가 vLLM에 전달되지 않아 도구 호출이 텍스트로 노출되는 버그 발생 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 — 왜 이 작업이 필요한가
|
||||
|
||||
### 1.1 근본 메커니즘
|
||||
|
||||
`Program.cs`의 JSON 직렬화 설정:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(opt =>
|
||||
{
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null; // ← C# 속성명을 그대로 직렬화
|
||||
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true; // ← 역직렬화(요청)는 케이싱 무시
|
||||
});
|
||||
```
|
||||
|
||||
- **`PropertyNamingPolicy = null`** → C# 코드에 쓴 속성명이 **그대로** JSON 필드명이 된다.
|
||||
- `return Ok(new { success = true })` → `{"success": true}` (소문자)
|
||||
- `return Ok(new { Success = true })` → `{"Success": true}` (PascalCase)
|
||||
- **`PropertyNameCaseInsensitive = true`** → **요청 바디**(C#가 받는 쪽)는 케이싱을 무시하고 바인딩한다. 따라서 **요청 방향은 깨지지 않는다.** 문제가 되는 것은 항상 **응답 방향**(JS가 읽는 쪽)이다.
|
||||
|
||||
### 1.2 현재 상태 — "우연히 동작 중"
|
||||
|
||||
현재 시스템은 **대부분의 컨트롤러가 소문자 익명객체(`new { success, data, error }`)를 반환**하고, **프론트도 소문자(`d.success`, `d.data`)로 읽어** 우연히 일치한다. 즉 사실상 컨벤션은 **camelCase/소문자**다.
|
||||
|
||||
커밋 `dbad4a5`가 `TextToSqlController`만 PascalCase로 바꾸면서 **컨벤션이 둘로 갈라졌고**, 한쪽만 바뀐 지점에서 `undefined` 무음 버그가 발생했다.
|
||||
|
||||
### 1.3 결정
|
||||
|
||||
> **전체를 PascalCase로 통일한다.** (커밋 `dbad4a5`의 방향을 끝까지 밀어붙임)
|
||||
|
||||
소문자로 되돌리는 선택지도 있으나, 이미 `TextToSqlController` + `t2s.js`가 PascalCase로 전환됐고 C# 속성/DTO는 본래 PascalCase가 관례이므로 **PascalCase 통일이 코드 일관성·유지보수 측면에서 우월**하다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위 — 무엇을 바꾸고 무엇을 절대 건드리지 않는가
|
||||
|
||||
### 2.1 ✅ 변경 대상 (PascalCase로 통일)
|
||||
|
||||
**Hc900Crawler 자체 REST API의 응답/요청 JSON 필드명** — 즉 `Controllers/*.cs`가 만들어 `wwwroot/js/*.js`가 소비하는 우리 내부 계약.
|
||||
|
||||
- 컨트롤러 익명객체 응답: `new { success = … }` → `new { Success = … }`
|
||||
- 응답 DTO 클래스 속성: 이미 PascalCase면 OK, `[JsonPropertyName("snake")]`로 강제 소문자된 내부 DTO는 검토 후 제거
|
||||
- 프론트의 응답 필드 읽기: `d.success` → `d.Success` 등
|
||||
|
||||
### 2.2 ⛔ 절대 변경 금지 (외부 계약·고정 식별자)
|
||||
|
||||
아래는 **외부 시스템·표준·저장소**와의 계약이므로 케이싱을 바꾸면 즉시 깨진다. **반드시 그대로 유지**한다.
|
||||
|
||||
| 영역 | 예시 필드 | 이유 |
|
||||
|---|---|---|
|
||||
| **vLLM / OpenAI API** | `model`, `messages`, `role`, `content`, `stream`, `tools`, `tool_choice`, `tool_calls`, `function`, `arguments`, `finish_reason`, `choices`, `delta`, `max_tokens`, `temperature` | OpenAI 호환 API 스펙. `OllamaController`의 vLLM 호출 payload/파싱 전부 |
|
||||
| **Ollama API** | `/api/chat`, `/api/tags`, `/api/show`, `name`, `model`, `system`, `messages`, `capabilities`, `done`, `response` | Ollama 네이티브 API 스펙 |
|
||||
| **MCP JSON-RPC** | `jsonrpc`, `method`, `params`, `name`, `arguments`, `tools`, `inputSchema`, `content` | MCP 프로토콜 스펙. `McpClient.cs`의 `[JsonPropertyName]`은 **유지** |
|
||||
| **MCP 도구 결과 데이터** | `base_tag`, `tag_name`, `event_type`, `recorded_at`, `livevalue`, `pv`, `sp`, `op`, `area`, `sub_area` 등 | Python `mcp-server/server.py`가 반환하는 snake_case 데이터. SQL 컬럼명과 동일. 프론트는 `r[col]`로 **동적 렌더**하므로 키 이름을 바꾸면 server.py + SQL까지 연쇄 변경 필요 → **이번 작업 범위 밖** |
|
||||
| **DB 컬럼 / SQL 식별자** | 모든 `snake_case` 컬럼 | PostgreSQL 스키마 |
|
||||
| **SSE 스트리밍 내부 shape** | `{ message: { content } }`, `json.response`, `event: message/tool_start/tool_result/done` | LLM 스트리밍 API shape를 그대로 미러링. `OllamaController` ↔ `llmchat.js` 양쪽이 동일 규약. **이 shape는 LLM API 정렬을 위해 소문자 유지** (단 `tool_start`/`tool_result`의 `id/name/ok/preview/payload`는 우리 내부 필드 → 선택적 PascalCase 가능하나 양쪽 동시 변경 필수) |
|
||||
| **localStorage 키** | `llmSessions`, `llmType` 등 | 브라우저 저장 키, JSON 필드 아님 |
|
||||
| **HTTP 헤더** | `X-Kb-Token`, `Content-Type` 등 | 표준/관례 |
|
||||
|
||||
> **판단 기준**: "이 필드가 우리 C# ↔ 우리 JS 사이에서만 오가는가?" → **Yes면 PascalCase 변경 대상**, 외부(LLM/MCP/DB/브라우저)와 닿으면 **제외**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 현황 인벤토리 (전수 조사 결과)
|
||||
|
||||
### 3.1 백엔드 — 컨트롤러별 소문자 익명객체 응답 (변경 필요)
|
||||
|
||||
| 컨트롤러 | 익명객체 응답 라인 수 | 비고 |
|
||||
|---|---:|---|
|
||||
| `FeedforwardController.cs` | 44 | `new { error }`, `new { success }` 다수 |
|
||||
| `KbController.cs` | 33 | |
|
||||
| `PidController.cs` | 32 | |
|
||||
| `PointBuilderController.cs` | 29 | 최근 작업 영역, 주의 |
|
||||
| `OllamaController.cs` | 22 | ⚠️ **외부(vLLM/Ollama) payload와 내부 응답 혼재** — 내부 응답만 선별 변경 |
|
||||
| `TextToSqlController.cs` | 19 | 🔶 **부분 완료**. `parse`(L38–50)만 소문자 잔존 → 즉시 수정 대상 |
|
||||
| `SteamAdvisorController.cs` | 15 | |
|
||||
| `DocsController.cs` | 13 | |
|
||||
| `TrendController.cs` | 13 | |
|
||||
| `Hc900Controllers.cs` | 10 | |
|
||||
| `KbAuthController.cs` | 9 | `token`, `expiresAt` 포함 |
|
||||
| `SetupController.cs` | 7 | |
|
||||
| `FastController.cs` | 5 | |
|
||||
| `PidGraphController.cs` | 2 | `error`, `details` |
|
||||
| `HypertableController.cs` | 1 | |
|
||||
|
||||
### 3.2 프론트엔드 — 응답 필드를 소문자로 읽는 JS (변경 필요)
|
||||
|
||||
전 `wwwroot/js/*.js`(벤더 `xlsx.full.min.js` 제외)가 대상. 탭 ↔ 파일 ↔ 주 컨트롤러 매핑:
|
||||
|
||||
| 탭/기능 | JS 파일 | 주 컨트롤러 |
|
||||
|---|---|---|
|
||||
| LLM 채팅 | `llmchat.js` (1031) | `OllamaController`, `TextToSqlController(/tools)` 🔶**부분완료** |
|
||||
| Text-to-SQL | `t2s.js` (740) | `TextToSqlController` 🔶**부분완료**(단 `parse` 응답 불일치 잔존) |
|
||||
| 트렌드 | `trend.js` (810) | `TrendController`, `TextToSqlController(/query-history-interval)` ※ 폴백 있음 |
|
||||
| 스팀 어드바이저 | `steam.js` (811) | `SteamAdvisorController` |
|
||||
| P&ID | `pid.js` (737), `pid-viewer.js` (416) | `PidController`, `PidGraphController` |
|
||||
| 피드포워드 | `ff.js` (739) | `FeedforwardController` |
|
||||
| 문서 | `docs.js` (713) | `DocsController` |
|
||||
| Point Builder | `pb.js` (546) | `PointBuilderController` |
|
||||
| Fast(고속) | `fast.js` (499) | `FastController` |
|
||||
| KB 관리 | `kbadmin.js` (397) | `KbController`, `KbAuthController` |
|
||||
| 이력 | `hist.js` (383) | `TextToSqlController(/query-history-interval)` ※ 폴백 있음 |
|
||||
| 설정 | `setup.js` (278) | `SetupController` |
|
||||
| 공통 | `core.js` (215), `app.js` (7) | (api 헬퍼) |
|
||||
| 쓰기 | `write.js` (101) | `Hc900Controllers` |
|
||||
| 이벤트 | `evt.js` (144) | `Hc900Controllers` |
|
||||
|
||||
> `hist.js`·`trend.js`는 `query-history-interval` 읽을 때 이미 `d.Success ?? d.success` 폴백이 적용돼 있어 현재 동작. 통일 작업 시 폴백 제거하고 PascalCase 단일화.
|
||||
|
||||
### 3.3 이미 적용된 수정 (작업 트리, 미커밋)
|
||||
|
||||
이번 디버깅 중 선행 적용된 변경 — 본 작업에 흡수/계승:
|
||||
|
||||
- `llmchat.js`: `/tools` 응답 `d.Success ?? d.success`, `d.Tools ?? d.tools` 폴백 추가 ✅
|
||||
- `IMcpService.cs` / `McpService.cs`: `McpToolDto`에 `InputSchema` 추가·전달 ✅
|
||||
- `OllamaController.cs`: Python 스타일 텍스트 도구 호출 감지 폴백 ✅ (방어선, 유지)
|
||||
- `prompts/plant_context.md`: 도구 예시의 Python 함수 문법 제거 ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. 표준 규약 (확정)
|
||||
|
||||
1. **내부 REST 응답 필드명 = PascalCase.** `Success`, `Error`, `Data`, `Message`, `Sql`, `Tools`, `Rows`, `Columns`, `Count`, `TagNames`, `Items`, `Id`, `Path`, `Token`, `ExpiresAt` 등.
|
||||
2. **공통 봉투(envelope) 통일**: 모든 응답은 최상위에 `Success`(bool)를 갖는다. 실패 시 `Error`(string). 데이터는 `Data` 또는 의미있는 명사형 PascalCase 키.
|
||||
3. **요청 바디**는 `PropertyNameCaseInsensitive = true` 덕에 케이싱 무관하나, **신규/수정 시 JS도 PascalCase 키로 전송**해 일관성 유지(`{ Sql, Limit }`). 단 외부 스펙 키는 예외.
|
||||
4. **응답 DTO 클래스**의 내부용 `[JsonPropertyName("snake")]`는 제거(자연 PascalCase 직렬화). **외부 계약용 `[JsonPropertyName]`은 유지** (McpClient, Ollama/vLLM payload, ExperionDtos 등 — §2.2).
|
||||
5. **폴백 금지**: 통일 완료 후 `?? d.success` 같은 이중 케이싱 폴백은 모두 제거(한 가지 진실).
|
||||
|
||||
---
|
||||
|
||||
## 5. 실행 계획 (단계별)
|
||||
|
||||
> 원칙: **엔드포인트 단위로 백엔드+프론트를 짝지어** 변경하고 즉시 검증. 빅뱅 일괄 변경 금지(무음 버그 재발 위험).
|
||||
|
||||
### Phase 0 — 즉시 핫픽스 (이미 진행 중, 우선 머지)
|
||||
|
||||
- [x] `llmchat.js` `/tools` 케이싱 폴백
|
||||
- [x] `McpToolDto.InputSchema` 추가
|
||||
- [ ] **`TextToSqlController.parse`(L38–50) 응답을 PascalCase로** (`success/sql/error` → `Success/Sql/Error`). t2s.js는 이미 `parseRes.Sql` 기대 → 이 한 줄이 t2s 파싱 버튼·채팅 흐름 2곳을 복구.
|
||||
|
||||
### Phase 1 — 컨트롤러별 백엔드 통일
|
||||
|
||||
각 컨트롤러에서 익명객체 소문자 키 → PascalCase. 작은 것부터:
|
||||
|
||||
- [ ] `HypertableController.cs` (1)
|
||||
- [ ] `PidGraphController.cs` (2)
|
||||
- [ ] `FastController.cs` (5)
|
||||
- [ ] `SetupController.cs` (7)
|
||||
- [ ] `KbAuthController.cs` (9) — `token`, `expiresAt` 포함
|
||||
- [ ] `Hc900Controllers.cs` (10)
|
||||
- [ ] `TrendController.cs` (13)
|
||||
- [ ] `DocsController.cs` (13)
|
||||
- [ ] `SteamAdvisorController.cs` (15)
|
||||
- [ ] `TextToSqlController.cs` (19) — 잔여 `parse` 외 전수 점검
|
||||
- [ ] `OllamaController.cs` (22) — ⚠️ **vLLM/Ollama payload·SSE shape는 제외**, `config/ping/models`의 내부 응답만
|
||||
- [ ] `PointBuilderController.cs` (29)
|
||||
- [ ] `PidController.cs` (32)
|
||||
- [ ] `KbController.cs` (33)
|
||||
- [ ] `FeedforwardController.cs` (44)
|
||||
|
||||
### Phase 2 — 프론트엔드 통일 (대응 JS)
|
||||
|
||||
각 컨트롤러 변경 직후 짝 JS를 함께 수정·검증(§3.2 매핑 순서대로). 폴백 제거.
|
||||
|
||||
### Phase 3 — DTO 정리
|
||||
|
||||
- [ ] `[JsonPropertyName]` 71곳 전수 검토 → 내부용 제거, 외부용 유지(주석으로 "// 외부 계약" 명시).
|
||||
- [ ] 응답 DTO들이 PascalCase로 직렬화되는지 확인.
|
||||
|
||||
### Phase 4 — 회귀 검증 (탭별)
|
||||
|
||||
§7 체크리스트 전 탭 수행.
|
||||
|
||||
---
|
||||
|
||||
## 6. 작업자 가이드 — 탐색/치환 도구
|
||||
|
||||
### 6.1 소문자 응답 잔존 탐지 (백엔드)
|
||||
|
||||
```bash
|
||||
# 익명객체에서 소문자로 시작하는 필드 = 변경 후보
|
||||
grep -rnE "new \{[^}]*\b[a-z][a-zA-Z]* =" src/Hc900Crawler/Controllers/
|
||||
|
||||
# 단, 외부 payload 키(model, messages, stream, tools 등)는 제외 — 수동 판별
|
||||
```
|
||||
|
||||
### 6.2 소문자 응답 읽기 탐지 (프론트)
|
||||
|
||||
```bash
|
||||
# d.success 류 소문자 응답 접근 (data 행의 snake_case 컬럼 r[col]은 제외)
|
||||
grep -rnE "\.(success|error|data|sql|tools|rows|columns|count|tagNames|message|token|expiresAt|items|path|reply|summary|suggestions)\b" \
|
||||
src/Hc900Crawler/wwwroot/js/ | grep -v xlsx.full.min.js
|
||||
```
|
||||
|
||||
### 6.3 변경 금지 식별 (이 키들이 보이면 손대지 말 것)
|
||||
|
||||
```
|
||||
model messages role content stream tools tool_choice tool_calls function
|
||||
arguments finish_reason choices delta max_tokens temperature ← vLLM/OpenAI
|
||||
name capabilities done response system ← Ollama
|
||||
jsonrpc method params inputSchema ← MCP
|
||||
base_tag tag_name event_type recorded_at livevalue pv sp op ← DB/도구 데이터
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 체크리스트 (탭별 회귀 — 완료 기준)
|
||||
|
||||
각 항목: 실제 UI에서 동작 + 브라우저 콘솔에 `undefined` 접근/에러 없음.
|
||||
|
||||
- [ ] **LLM 채팅**: 도구 사용+에이전트 모드 ON → `generate_status_report(area="P6-1", hours=24)` 류 질문 시 **도구 카드가 실행**되고 자연어 답변(텍스트로 함수명 노출 ❌)
|
||||
- [ ] **LLM 채팅**: 모델 목록 로드, 핑, 설정 저장
|
||||
- [ ] **Text-to-SQL**: NL 질의(`query-nl`), **SQL 변환(`parse`) 버튼**, MCP 실행(`execute-mcp`), 분석(`analyze`), 도구 칩(`tools`)
|
||||
- [ ] **이력(hist)**: 간격 조회 결과 테이블 렌더 (폴백 제거 후에도 정상)
|
||||
- [ ] **트렌드**: 집계/원시 경로 차트 + 이벤트/리밋/런밴드 레이어
|
||||
- [ ] **스팀 어드바이저**: 어드바이스 로드/적용
|
||||
- [ ] **P&ID / P&ID 뷰어**: 도면 목록·그래프
|
||||
- [ ] **피드포워드**: 어드바이저 상태/SP 쓰기(WriteGuard 메시지 포함)
|
||||
- [ ] **문서**: 목록/열람/업로드/이동/삭제(관리자)
|
||||
- [ ] **Point Builder**: 태그 추가/제거/미리보기
|
||||
- [ ] **Fast**: 고속 조회
|
||||
- [ ] **KB 관리**: 로그인(`token`/`expiresAt`), 검색, 업로드, 비밀번호 변경
|
||||
- [ ] **설정**: 저장/연결 테스트
|
||||
- [ ] **쓰기(write)/이벤트(evt)**: 값 쓰기, 이벤트 목록
|
||||
|
||||
### 빌드/정적 점검
|
||||
|
||||
```bash
|
||||
cd src/Hc900Crawler && dotnet build # 0 Error
|
||||
# §6.1/§6.2 grep 결과가 외부 키만 남았는지 확인 (내부 소문자 0건)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 리스크 및 주의사항
|
||||
|
||||
1. **무음 실패가 본질**: 케이싱 불일치는 컴파일·런타임 에러 없이 `undefined`로 흐른다. **반드시 실제 UI 클릭 검증**. 빌드 통과 ≠ 동작.
|
||||
2. **OllamaController 혼재**: 한 파일 안에 (a) vLLM/Ollama로 나가는 외부 payload, (b) SSE 스트리밍 shape, (c) 우리 내부 응답(`config`,`ping`,`models`,`vllmModel`)이 섞여 있다. **(c)만** 변경. (a)(b)는 §2.2.
|
||||
3. **MCP 도구 결과 테이블**: `llmRenderTable`/`t2sRenderTable`은 `r[col]`로 snake_case 컬럼을 동적 렌더 → **건드리지 말 것**. server.py·SQL 동반 변경은 별도 작업.
|
||||
4. **폴백의 함정**: 전환 중간 상태에서 `?? d.success` 폴백을 남기면 버그가 숨는다. Phase 완료 시 폴백 **제거**가 완료 기준.
|
||||
5. **요청 바디**: `PropertyNameCaseInsensitive=true`라 깨지지 않지만, 일관성 위해 JS 송신 키도 PascalCase로(외부 스펙 제외).
|
||||
6. **부분 머지 금지**: 컨트롤러만 PascalCase로 머지하고 JS를 안 바꾸면 그 탭이 죽는다. **백엔드+프론트 짝 커밋**.
|
||||
|
||||
---
|
||||
|
||||
## 9. 산출물 / 완료 정의 (DoD)
|
||||
|
||||
- [ ] 내부 REST 응답·요청 필드가 전부 PascalCase, 이중 케이싱 폴백 0건
|
||||
- [ ] 외부 계약(vLLM/Ollama/MCP)·DB 컬럼·SSE shape는 불변
|
||||
- [ ] `dotnet build` 0 Error
|
||||
- [ ] §7 전 탭 UI 회귀 통과(콘솔 에러 0)
|
||||
- [ ] §6.1/§6.2 grep에 내부 소문자 잔존 0건(외부 키만)
|
||||
- [ ] 변경은 엔드포인트 단위 백엔드+프론트 짝으로 커밋
|
||||
|
||||
---
|
||||
|
||||
*근거 조사: `Program.cs`(JSON 정책), `Controllers/*.cs` 15개, `wwwroot/js/*.js` 17개, `McpClient.cs`/`McpService.cs`, 커밋 `dbad4a5`. 본 문서는 단일 작업 기준서이며, 완료 후 삭제 대상(`chore` 정리).*
|
||||
544
docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
Normal file
544
docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# P0 MVP 상세설계 — 셀프서비스 결정론 리포트 (코딩 레벨)
|
||||
|
||||
> 2026-06-12. 상위: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md) → [`논의-AI운전원-제어-아이디어.md`](논의-AI운전원-제어-아이디어.md).
|
||||
> **P0 목표(한 문장):** 운전원이 올린 엑셀 템플릿에 `{{ metric|... }}` 토큰을 박아두면, 날짜 하나 선택 시 **C-6111 컬럼의 일일 효율·수율·제어잔차**가 그 폼 그대로 채워져 다운로드된다. 소스=`history_table`(60s), 동일 코드로 `fast_record`도 가능함을 1개 메트릭으로 실증.
|
||||
|
||||
## 0. P0 범위 (의도적 최소)
|
||||
| 포함 | 제외(P1+) |
|
||||
|---|---|
|
||||
| 메트릭 3종: `energy_efficiency`, `yield`, `control_residual` | `dynamics`(fast 전용), `excursion`, `settling` |
|
||||
| 대상 1컬럼: **C-6111** (KB 매핑 기존) | 멀티컬럼/멀티플랜트 |
|
||||
| 소스: `history_table`(주) + `fast_record`(효율 1종만 실증) | 멀티소스 일반화 |
|
||||
| Daily 1주기, **수동 트리거**(날짜 선택→생성) | Monthly/Yearly, 스케줄 자동생성 |
|
||||
| 엑셀 토큰 채움(EPPlus, 서버) + 다운로드 | 템플릿 업로드 UI(P2), 미리보기 |
|
||||
| 결과 표 + 토큰맵 JSON | named-range 고급문법 |
|
||||
|
||||
## 0.1 기존 자산 재사용 (신규 의존성 0)
|
||||
- **EPPlus 7.4.2** — `Hc900Crawler.csproj`에 이미 있음. `PidExtractorService.cs:620`에서 `new OfficeOpenXml.ExcelPackage()` 바로 사용 = **라이선스 컨텍스트 이미 설정됨**(동일 패턴 재사용).
|
||||
- **raw SQL** — `FeedforwardAuditService` 패턴(`_ctx.Database.GetDbConnection()` + `@param`) 그대로.
|
||||
- **패널 로딩** — `index.html` `data-src="/panes/X.html"` + nav `data-tab` 컨벤션.
|
||||
- **연결** — `appsettings.json:DefaultConnection`(`iiot_platform`, `Search Path=hc900`).
|
||||
- **KST 처리** — `KstClock`/`KoreanTimeRangeExtractor`(Program.cs:70-71) 존재. recorded_at=UTC.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터 모델 (DDL — 최소 2테이블)
|
||||
`hc900` 스키마. 마이그레이션 SQL(`scripts/sql/p0_report.sql`):
|
||||
|
||||
```sql
|
||||
-- 운전원이 올린 엑셀 템플릿 + 토큰맵
|
||||
CREATE TABLE IF NOT EXISTS hc900.report_template (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
owner text,
|
||||
xlsx_blob bytea NOT NULL, -- 원본 템플릿(.xlsx)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 생성 이력(감사). 어떤 정의로 어떤 기간을 뽑았는지 박제
|
||||
CREATE TABLE IF NOT EXISTS hc900.report_run (
|
||||
id bigserial PRIMARY KEY,
|
||||
template_id int REFERENCES hc900.report_template(id),
|
||||
period_kind text NOT NULL, -- 'DAILY'
|
||||
period_date date NOT NULL, -- KST 기준 날짜
|
||||
source_table text NOT NULL, -- 'history_table' | 'fast_record'
|
||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NOT NULL, -- 'ok' | 'partial' | 'error'
|
||||
cells_json jsonb, -- 채운 셀/값/메타 박제(추적)
|
||||
out_blob bytea -- 채워진 .xlsx (다운로드 캐시)
|
||||
);
|
||||
```
|
||||
|
||||
> 토큰맵은 별도 컬럼이 아니라 **템플릿 셀 주석/값에 직접** 박는다(§7). report_run.cells_json에 "어떤 셀=어떤 메트릭+파라미터+값+sampling메타"를 결정론적으로 박제 → 감사·재현.
|
||||
|
||||
## 2. 컬럼→메트릭 태그 매핑 (config, 하드코딩 금지)
|
||||
KB(`docs/kb/P6-1_플랜트_공정마스터.md`)에서 도출. 파일 `config/report-metric-map.json`(런타임 로드, 편집 시 재배포 불필요):
|
||||
|
||||
```json
|
||||
{
|
||||
"C-6111": {
|
||||
"label": "6-1차 PGMEA 진공증류탑",
|
||||
"metrics": {
|
||||
"energy_efficiency": { "steam": "FIQ-6115.PV", "product": "FICQ-6118.PV", "unit": "kg스팀/kg제품" },
|
||||
"yield": { "product": "FICQ-6118.PV", "feed": "FICQ-6101.PV", "unit": "비율" },
|
||||
"control_residual": { "pv": "TICA-6111A.PV", "sp": "TICA-6111A.SP", "unit": "degC" }
|
||||
},
|
||||
"clean": {
|
||||
"TICA-6111A.PV": [60,95], "TICA-6111A.SP": [60,95],
|
||||
"FIQ-6115.PV": [50,3000], "FICQ-6118.PV": [100,1500], "FICQ-6101.PV": [100,1500]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
> `clean` 범위 = 오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값. 채널별 양자화/RTD레인지 주의(메모리). 향후 tag_metadata에서 EU레인지로 자동도출 가능(P2).
|
||||
|
||||
---
|
||||
|
||||
## 3. 메트릭 엔진 (C#)
|
||||
|
||||
### 3.1 DTO — `src/Core/Application/DTOs/ReportDtos.cs`
|
||||
```csharp
|
||||
namespace Hc900Crawler.Core.Application.DTOs;
|
||||
|
||||
public sealed class MetricRequestDto
|
||||
{
|
||||
public string Column { get; set; } = "C-6111";
|
||||
public string Metric { get; set; } = ""; // energy_efficiency | yield | control_residual
|
||||
public DateTime PeriodDateKst { get; set; } // 운전원이 고른 KST 날짜(00:00 기준)
|
||||
public string SourceTable { get; set; } = "history_table"; // | fast_record (+ session_id)
|
||||
public int? SessionId { get; set; } // fast_record일 때
|
||||
}
|
||||
|
||||
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수).</summary>
|
||||
public sealed class MetricResultDto
|
||||
{
|
||||
public string Metric { get; set; } = "";
|
||||
public string Column { get; set; } = "";
|
||||
public double? Value { get; set; } // 대표값(없으면 null = 명시적 실패, 날조 금지)
|
||||
public string? Unit { get; set; }
|
||||
public string Status { get; set; } = "ok"; // ok | no_data | error
|
||||
public string? Error { get; set; }
|
||||
// ── 해상도-인지 메타 (사과-오렌지 방지) ──
|
||||
public string Source { get; set; } = "";
|
||||
public int SamplingMs { get; set; } // 60000(history) | fast_record.sampling_ms
|
||||
public int N { get; set; } // 클린 후 표본수
|
||||
public double CleanedFraction { get; set; } // 제거된 비율
|
||||
public Dictionary<string, double?> Extra { get; set; } = new(); // sd/p95/이탈% 등
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 인터페이스 — `src/Core/Application/Interfaces/IReportMetricService.cs`
|
||||
```csharp
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||
|
||||
public interface IReportMetricService
|
||||
{
|
||||
Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 구현 — `src/Infrastructure/Reporting/ReportMetricService.cs`
|
||||
raw SQL은 `FeedforwardAuditService` 패턴(`_ctx.Database.GetDbConnection()`)을 따른다. KST 날짜 → UTC 경계는 C#에서 계산(recorded_at=UTC).
|
||||
|
||||
```csharp
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
public sealed class ReportMetricService : IReportMetricService
|
||||
{
|
||||
private readonly Hc900DbContext _ctx;
|
||||
private readonly ILogger<ReportMetricService> _logger;
|
||||
private readonly MetricMap _map; // config/report-metric-map.json 로드
|
||||
|
||||
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, MetricMap map)
|
||||
{ _ctx = ctx; _logger = logger; _map = map; }
|
||||
|
||||
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
|
||||
{
|
||||
var res = new MetricResultDto { Metric = req.Metric, Column = req.Column,
|
||||
Source = req.SourceTable, SamplingMs = req.SourceTable == "fast_record" ? 0 : 60000 };
|
||||
if (!_map.TryGet(req.Column, req.Metric, out var roles, out var unit, out var clean))
|
||||
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; return res; }
|
||||
res.Unit = unit;
|
||||
|
||||
// KST 날짜 [00:00, +1d) → UTC
|
||||
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||
var toUtc = fromUtc.AddDays(1);
|
||||
// fast_record는 세션 전체(기간무관) 또는 세션의 해당일. P0: 세션 전체.
|
||||
bool isFast = req.SourceTable == "fast_record";
|
||||
string tbl = isFast ? "fast_record" : "history_table";
|
||||
|
||||
try
|
||||
{
|
||||
res = req.Metric switch
|
||||
{
|
||||
"energy_efficiency" => await RatioMetric(res, tbl, roles["steam"], roles["product"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
|
||||
"yield" => await RatioMetric(res, tbl, roles["product"], roles["feed"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
|
||||
"control_residual" => await ResidualMetric(res, tbl, roles["pv"], roles["sp"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
|
||||
_ => Fail(res, $"미구현 메트릭: {req.Metric}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Report] metric 실패 {M}", req.Metric); Fail(res, ex.Message); }
|
||||
return res;
|
||||
}
|
||||
|
||||
private static MetricResultDto Fail(MetricResultDto r, string msg) { r.Status="error"; r.Error=msg; r.Value=null; return r; }
|
||||
|
||||
// ── 비율 메트릭: median(numer)/median(denom). 분단위 피벗으로 정렬 후 robust 중앙값 ──
|
||||
private async Task<MetricResultDto> RatioMetric(
|
||||
MetricResultDto res, string tbl, string numerTag, string denomTag,
|
||||
IReadOnlyDictionary<string,(double lo,double hi)> clean,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct)
|
||||
{
|
||||
var (nlo,nhi) = clean[numerTag]; var (dlo,dhi) = clean[denomTag];
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// 분 버킷 피벗 → 둘 다 유효한 분만 → 비율의 중앙값/표본수 + 원천 표본수(클린율 산출)
|
||||
cmd.CommandText = $@"
|
||||
WITH raw AS (
|
||||
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname IN (@numer,@denom)
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
), tot AS ( SELECT count(*) c FROM raw ),
|
||||
piv AS (
|
||||
SELECT ts,
|
||||
max(v) FILTER (WHERE tagname=@numer) n,
|
||||
max(v) FILTER (WHERE tagname=@denom) d
|
||||
FROM raw GROUP BY ts
|
||||
), good AS (
|
||||
SELECT n, d FROM piv
|
||||
WHERE n BETWEEN @nlo AND @nhi AND d BETWEEN @dlo AND @dhi AND d <> 0
|
||||
)
|
||||
SELECT
|
||||
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n/d) FROM good) AS ratio_med,
|
||||
(SELECT count(*) FROM good) AS n_good,
|
||||
(SELECT c FROM tot) AS n_raw;";
|
||||
AddP(cmd,"@numer",numerTag); AddP(cmd,"@denom",denomTag);
|
||||
AddP(cmd,"@nlo",nlo); AddP(cmd,"@nhi",nhi); AddP(cmd,"@dlo",dlo); AddP(cmd,"@dhi",dhi);
|
||||
if (isFast) AddP(cmd,"@sid", sessionId ?? -1);
|
||||
else { AddP(cmd,"@from",fromUtc); AddP(cmd,"@to",toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
|
||||
{
|
||||
res.Value = rd.GetDouble(0);
|
||||
res.N = rd.GetInt32(1);
|
||||
var nRaw = rd.GetInt64(2);
|
||||
var nGood = res.N;
|
||||
res.CleanedFraction = nRaw > 0 ? 1.0 - (2.0*nGood)/nRaw : 0; // 2태그라 분모 보정
|
||||
if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct);
|
||||
}
|
||||
else { res.Status = "no_data"; res.Value = null; }
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── 제어잔차: (PV-SP) 통계. 분 피벗 후 잔차의 mean/sd/p95/이탈% ──
|
||||
private async Task<MetricResultDto> ResidualMetric(
|
||||
MetricResultDto res, string tbl, string pvTag, string spTag,
|
||||
IReadOnlyDictionary<string,(double lo,double hi)> clean,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct)
|
||||
{
|
||||
var (plo,phi) = clean[pvTag]; var (slo,shi) = clean[spTag];
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
WITH raw AS (
|
||||
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname IN (@pv,@sp)
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
), piv AS (
|
||||
SELECT ts, max(v) FILTER (WHERE tagname=@pv) pv, max(v) FILTER (WHERE tagname=@sp) sp
|
||||
FROM raw GROUP BY ts
|
||||
), good AS (
|
||||
SELECT pv - sp AS e FROM piv
|
||||
WHERE pv BETWEEN @plo AND @phi AND sp BETWEEN @slo AND @shi
|
||||
)
|
||||
SELECT avg(e), stddev(e), percentile_cont(0.95) WITHIN GROUP (ORDER BY abs(e)),
|
||||
count(*), 100.0*count(*) FILTER (WHERE abs(e) > 0.5)/NULLIF(count(*),0)
|
||||
FROM good;";
|
||||
AddP(cmd,"@pv",pvTag); AddP(cmd,"@sp",spTag);
|
||||
AddP(cmd,"@plo",plo); AddP(cmd,"@phi",phi); AddP(cmd,"@slo",slo); AddP(cmd,"@shi",shi);
|
||||
if (isFast) AddP(cmd,"@sid", sessionId ?? -1);
|
||||
else { AddP(cmd,"@from",fromUtc); AddP(cmd,"@to",toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct) && !rd.IsDBNull(3) && rd.GetInt64(3) > 0)
|
||||
{
|
||||
res.Value = rd.IsDBNull(0) ? null : rd.GetDouble(0); // 평균 잔차
|
||||
res.Extra["sd"] = rd.IsDBNull(1) ? null : rd.GetDouble(1);
|
||||
res.Extra["abs_p95"] = rd.IsDBNull(2) ? null : rd.GetDouble(2);
|
||||
res.Extra["out_pct_0_5"]= rd.IsDBNull(4) ? null : rd.GetDouble(4);
|
||||
res.N = (int)rd.GetInt64(3);
|
||||
if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct);
|
||||
}
|
||||
else { res.Status = "no_data"; res.Value = null; }
|
||||
return res;
|
||||
}
|
||||
|
||||
private static async Task<int> FastSamplingMsAsync(System.Data.Common.DbConnection conn, int sid, CancellationToken ct)
|
||||
{
|
||||
await using var c = conn.CreateCommand();
|
||||
c.CommandText = "SELECT sampling_ms FROM hc900.fast_session WHERE id=@id";
|
||||
AddP(c,"@id",sid);
|
||||
var o = await c.ExecuteScalarAsync(ct);
|
||||
return o is int i ? i : (o is null ? 0 : Convert.ToInt32(o));
|
||||
}
|
||||
|
||||
private static void AddP(System.Data.Common.DbCommand cmd, string name, object val)
|
||||
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
|
||||
}
|
||||
```
|
||||
|
||||
> **결정론 검증 게이트 준수**: 표본 0 → `Status=no_data`, `Value=null`(빈칸을 0으로 날조하지 않음). 매핑 미정의 → 명시적 error. (메모리 [[deterministic-verification-gate]])
|
||||
|
||||
### 3.4 매핑 로더 — `src/Infrastructure/Reporting/MetricMap.cs`
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
public sealed class MetricMap
|
||||
{
|
||||
private readonly JsonElement _root;
|
||||
public MetricMap(string jsonPath) => _root = JsonDocument.Parse(File.ReadAllText(jsonPath)).RootElement;
|
||||
|
||||
public bool TryGet(string column, string metric,
|
||||
out Dictionary<string,string> roles, out string? unit,
|
||||
out Dictionary<string,(double lo,double hi)> clean)
|
||||
{
|
||||
roles = new(); unit = null; clean = new();
|
||||
if (!_root.TryGetProperty(column, out var col)) return false;
|
||||
if (!col.GetProperty("metrics").TryGetProperty(metric, out var m)) return false;
|
||||
foreach (var p in m.EnumerateObject())
|
||||
if (p.Name == "unit") unit = p.Value.GetString(); else roles[p.Name] = p.Value.GetString()!;
|
||||
foreach (var p in col.GetProperty("clean").EnumerateObject())
|
||||
clean[p.Name] = (p.Value[0].GetDouble(), p.Value[1].GetDouble());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 엑셀 토큰 채움 (EPPlus, 서버) — `src/Infrastructure/Reporting/ReportFillService.cs`
|
||||
토큰 = **셀 값 텍스트** `{{ metric=energy_efficiency; column=C-6111 }}`. 엔진이 전 시트 스캔→토큰 셀을 결과값으로 치환, 메타는 셀 주석으로.
|
||||
|
||||
```csharp
|
||||
using OfficeOpenXml;
|
||||
using System.Text.RegularExpressions;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
public sealed class ReportFillService
|
||||
{
|
||||
private static readonly Regex TOKEN = new(@"\{\{\s*(?<body>.+?)\s*\}\}", RegexOptions.Compiled);
|
||||
private readonly IReportMetricService _metrics;
|
||||
public ReportFillService(IReportMetricService metrics) => _metrics = metrics;
|
||||
|
||||
public async Task<(byte[] xlsx, List<object> cells, string status)> FillAsync(
|
||||
byte[] template, DateTime periodKst, string sourceTable, int? sessionId, CancellationToken ct = default)
|
||||
{
|
||||
using var pkg = new ExcelPackage(new MemoryStream(template)); // 라이선스 컨텍스트 기설정(PidExtractorService 동일)
|
||||
var cells = new List<object>(); var anyErr = false; var anyOk = false;
|
||||
|
||||
foreach (var ws in pkg.Workbook.Worksheets)
|
||||
{
|
||||
var dim = ws.Dimension; if (dim == null) continue;
|
||||
for (int r = dim.Start.Row; r <= dim.End.Row; r++)
|
||||
for (int c = dim.Start.Column; c <= dim.End.Column; c++)
|
||||
{
|
||||
var txt = ws.Cells[r, c].Text;
|
||||
var mt = TOKEN.Match(txt); if (!mt.Success) continue;
|
||||
var kv = ParseToken(mt.Groups["body"].Value); // metric=..; column=..; field=..(opt)
|
||||
var req = new MetricRequestDto {
|
||||
Metric = kv.GetValueOrDefault("metric",""), Column = kv.GetValueOrDefault("column","C-6111"),
|
||||
PeriodDateKst = periodKst, SourceTable = sourceTable, SessionId = sessionId };
|
||||
var m = await _metrics.ComputeAsync(req, ct);
|
||||
|
||||
double? v = kv.TryGetValue("field", out var f) && m.Extra.TryGetValue(f, out var ev) ? ev : m.Value;
|
||||
if (m.Status == "ok" && v.HasValue) { ws.Cells[r,c].Value = v.Value; anyOk = true; }
|
||||
else { ws.Cells[r,c].Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }
|
||||
|
||||
ws.Cells[r,c].AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | clean={1-m.CleanedFraction:P0} | {m.Unit}", "report");
|
||||
cells.Add(new { sheet=ws.Name, r, c, m.Metric, m.Column, value=v, m.Status, m.Source, m.SamplingMs, m.N });
|
||||
}
|
||||
}
|
||||
var status = anyErr ? (anyOk ? "partial" : "error") : "ok";
|
||||
return (pkg.GetAsByteArray(), cells, status);
|
||||
}
|
||||
|
||||
private static Dictionary<string,string> ParseToken(string body) =>
|
||||
body.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(p => p.Split('=', 2)).Where(a => a.Length==2)
|
||||
.ToDictionary(a => a[0].Trim().ToLowerInvariant(), a => a[1].Trim());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 컨트롤러 — `src/Hc900Crawler/Controllers/ReportController.cs`
|
||||
```csharp
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Reporting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/report")]
|
||||
public class ReportController : ControllerBase
|
||||
{
|
||||
private readonly IReportMetricService _metrics;
|
||||
private readonly ReportFillService _fill;
|
||||
private readonly IReportTemplateStore _store; // report_template/report_run CRUD (raw SQL, 패턴 동일)
|
||||
public ReportController(IReportMetricService m, ReportFillService f, IReportTemplateStore s)
|
||||
{ _metrics = m; _fill = f; _store = s; }
|
||||
|
||||
// 단건 메트릭(미리보기/디버그)
|
||||
[HttpPost("metric")]
|
||||
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
|
||||
=> Ok(await _metrics.ComputeAsync(req, ct));
|
||||
|
||||
// 템플릿 업로드(P0: 단순 등록)
|
||||
[HttpPost("template")]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name, [FromForm] string? owner)
|
||||
{
|
||||
using var ms = new MemoryStream(); await file.CopyToAsync(ms);
|
||||
var id = await _store.CreateAsync(name, owner, ms.ToArray());
|
||||
return Ok(new { Id = id });
|
||||
}
|
||||
|
||||
// ★핵심: 템플릿+날짜 → 채워진 xlsx
|
||||
[HttpGet("generate")]
|
||||
public async Task<IActionResult> Generate(int templateId, DateTime date,
|
||||
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
|
||||
{
|
||||
var tpl = await _store.GetBlobAsync(templateId);
|
||||
if (tpl == null) return NotFound();
|
||||
var (xlsx, cells, status) = await _fill.FillAsync(tpl, date, source, sessionId, ct);
|
||||
await _store.RecordRunAsync(templateId, "DAILY", date, source, status, cells, xlsx);
|
||||
var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
|
||||
Response.Headers["X-Report-Status"] = status;
|
||||
return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
|
||||
}
|
||||
}
|
||||
```
|
||||
> `IReportTemplateStore`(+ `ReportTemplateStore`)는 `report_template`/`report_run` raw SQL CRUD — `FeedforwardAuditService` 패턴 복제(생략, §10 체크리스트 포함).
|
||||
|
||||
## 6. DI 등록 — `src/Hc900Crawler/Program.cs` (추가 라인)
|
||||
```csharp
|
||||
// ── P0 Report ──
|
||||
builder.Services.AddSingleton(new Hc900Crawler.Infrastructure.Reporting.MetricMap(
|
||||
Path.Combine(builder.Environment.ContentRootPath, "config", "report-metric-map.json")));
|
||||
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportMetricService,
|
||||
Hc900Crawler.Infrastructure.Reporting.ReportMetricService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Reporting.ReportFillService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportTemplateStore,
|
||||
Hc900Crawler.Infrastructure.Reporting.ReportTemplateStore>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 토큰 규약 (운전원이 엑셀에 입력)
|
||||
| 토큰(셀에 그대로 입력) | 채워지는 값 |
|
||||
|---|---|
|
||||
| `{{ metric=energy_efficiency; column=C-6111 }}` | 일일 스팀/제품 중앙값 |
|
||||
| `{{ metric=yield; column=C-6111 }}` | 일일 제품/원료 |
|
||||
| `{{ metric=control_residual; column=C-6111 }}` | 평균 잔차(PV−SP) |
|
||||
| `{{ metric=control_residual; column=C-6111; field=sd }}` | 잔차 표준편차 |
|
||||
| `{{ metric=control_residual; column=C-6111; field=out_pct_0_5 }}` | \|잔차\|>0.5℃ 시간비율 |
|
||||
|
||||
- 채운 셀엔 **주석**으로 `src/sampling/n/clean/unit` 자동 부착(해상도-인지).
|
||||
- 포맷 변경 = 운전원이 셀 옮기고 토큰 복붙. **전산팀 0, 리드타임 0.**
|
||||
|
||||
## 8. 프론트엔드 (최소 패널)
|
||||
**`index.html`** — nav + pane 등록(기존 컨벤션):
|
||||
```html
|
||||
<li class="nav-item" data-tab="reports"><span class="nl">리포트</span></li>
|
||||
...
|
||||
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
|
||||
```
|
||||
**`wwwroot/panes/reports.html`** (요지):
|
||||
```html
|
||||
<div class="report-pane">
|
||||
<input type="file" id="rpTpl" accept=".xlsx">
|
||||
<input type="date" id="rpDate">
|
||||
<select id="rpSource"><option value="history_table">history(60s)</option>
|
||||
<option value="fast_record">fastRecord</option></select>
|
||||
<input type="number" id="rpSession" placeholder="fast session id" hidden>
|
||||
<button id="rpGen">리포트 생성·다운로드</button>
|
||||
<div id="rpStatus"></div>
|
||||
</div>
|
||||
<script src="/js/reports.js"></script>
|
||||
```
|
||||
**`wwwroot/js/reports.js`** (요지 — 업로드 후 generate 호출, blob 다운로드):
|
||||
```js
|
||||
document.getElementById('rpGen').onclick = async () => {
|
||||
const f = document.getElementById('rpTpl').files[0];
|
||||
const fd = new FormData(); fd.append('file', f); fd.append('name', f.name);
|
||||
const up = await fetch('/api/report/template', {method:'POST', body:fd}).then(r=>r.json());
|
||||
const date = document.getElementById('rpDate').value;
|
||||
const src = document.getElementById('rpSource').value;
|
||||
const sid = document.getElementById('rpSession').value;
|
||||
const url = `/api/report/generate?templateId=${up.Id}&date=${date}&source=${src}`+(sid?`&sessionId=${sid}`:'');
|
||||
const resp = await fetch(url);
|
||||
document.getElementById('rpStatus').textContent = '상태: ' + resp.headers.get('X-Report-Status');
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = `report_${date}.xlsx`; a.click();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 수용 기준 (Acceptance) · 데모 스크립트
|
||||
1. C-6111 효율 토큰 1개 엑셀 → 2026-05-15 선택 → 생성 → 셀에 **~0.79 값**, 주석에 `src=history_table 60000ms n=~1400`.
|
||||
2. 같은 토큰을 `control_residual; field=sd`로 → TICA-6111A 잔차 sd 채워짐.
|
||||
3. 데이터 없는 날(예: 2026-01-01) → 셀 **`N/A`**(0 날조 아님), `X-Report-Status: partial/error`.
|
||||
4. fastRecord 세션 1개 띄워(예: 1초·5분) → `source=fast_record&sessionId=N` → 동일 효율 토큰이 **sampling 60000→1000ms**로 주석 바뀌어 채워짐(해상도 가변 실증).
|
||||
5. report_run에 cells_json 박제 확인(감사·재현).
|
||||
|
||||
> **검증용 결정론 쿼리(오늘 검증과 일치):** 2026-05 저율 캠페인 효율 ≈ 0.79~0.83 (검증 3회차 seg4). 엔진 값이 이 범위면 OK.
|
||||
|
||||
## 10. 파일 체크리스트 / 작업량
|
||||
| 파일 | 신규/수정 | 비고 |
|
||||
|---|---|---|
|
||||
| `scripts/sql/p0_report.sql` | 신규 | 2테이블 DDL |
|
||||
| `config/report-metric-map.json` | 신규 | C-6111 매핑 |
|
||||
| `Core/Application/DTOs/ReportDtos.cs` | 신규 | 3 DTO |
|
||||
| `Core/Application/Interfaces/IReportMetricService.cs` | 신규 | |
|
||||
| `Core/Application/Interfaces/IReportTemplateStore.cs` | 신규 | |
|
||||
| `Infrastructure/Reporting/ReportMetricService.cs` | 신규 | 메트릭 3종 SQL |
|
||||
| `Infrastructure/Reporting/MetricMap.cs` | 신규 | config 로더 |
|
||||
| `Infrastructure/Reporting/ReportFillService.cs` | 신규 | EPPlus 토큰 채움 |
|
||||
| `Infrastructure/Reporting/ReportTemplateStore.cs` | 신규 | raw SQL CRUD(패턴복제) |
|
||||
| `Hc900Crawler/Controllers/ReportController.cs` | 신규 | 3 엔드포인트 |
|
||||
| `Hc900Crawler/Program.cs` | 수정 | DI 4줄 |
|
||||
| `wwwroot/index.html` | 수정 | nav+pane 2줄 |
|
||||
| `wwwroot/panes/reports.html` + `js/reports.js` | 신규 | 최소 UI |
|
||||
|
||||
추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~1.5일, store/DDL ~0.5일, 프론트 ~0.5일, 검증/데모 ~0.5일 → **약 3일**.
|
||||
|
||||
## 12. 적산(.QV) 메트릭 추가 (2026-06-13 구현)
|
||||
|
||||
각 유량태그의 `.QV`(적산/totalizer)를 활용. PV-중앙값 근사 대신 **정확한 적분** → gap·양자화 무관, "비율의 중앙값" 모호성 해소. 폐합이 닫혀(99.1~99.8%) 적산기 신뢰성도 입증됨.
|
||||
|
||||
**추가 메트릭(4종):**
|
||||
| metric | 정의 | 비고 |
|
||||
|---|---|---|
|
||||
| `production_total` | ΔQV(제품) | 생산량 kg = MES 생산리포트 숫자 |
|
||||
| `yield_qv` | Δproduct/Δfeed | 정확 수율 |
|
||||
| `energy_intensity_qv` | Δsteam/Δproduct | 정확 에너지원단위 |
|
||||
| `mass_balance_closure` | 100·ΣΔout/Δfeed | 폐합 % + Extra(feed_qv/out_total/out0~2_qv) |
|
||||
|
||||
**적산 Δ 헬퍼(`QvDeltaAsync`)** — 핵심. 값 급감(<prev−1)으로 **리셋/wrap 구간 분할 후 구간별 (마지막−처음) 합산**:
|
||||
- gap 강건(끝점 차이가 정답), 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미), wrap(1e6) 꼭대기 잔량(소량)만 손실.
|
||||
- 검증(2026-05-15 C-6111): production 8454.7kg·yield 0.8739·energy 0.7791·closure 99.04%(feed 9675/out 9582). 폐합 17주 99.1~99.8%.
|
||||
|
||||
**매핑 재사용:** production/yield/energy = `SteamAdvisor:Columns`의 Feed/Product/SteamFlow를 `.QV`로 치환(7컬럼 무료). 폐합은 신규 `appsettings:Report:Closure:{col}`(Feed + Outputs[제품,경비물,중비물]) — C-6111만 등록(타컬럼은 P&ID 그래프 유도 후 확장).
|
||||
|
||||
**신뢰 블록(운전원 검산):** 템플릿에 `feed_qv`(IN)·`out_total`(OUT)·`mass_balance_closure`(%)를 한 표에 배치 → "9675 vs 9582 = 99% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영.
|
||||
|
||||
## 11. 다음(P1 훅)
|
||||
- `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.
|
||||
- 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기.
|
||||
- 스케줄 자동생성 BackgroundService(기존 `Hc900HistoryService` 패턴).
|
||||
- 컬럼 매핑을 tag_metadata EU레인지에서 자동도출.
|
||||
|
||||
---
|
||||
*근거: 본 세션 검증 1~3회차, `FastController`/`fast_record`(동일 long 포맷), EPPlus 7.4.2 기탑재(`PidExtractorService`), raw SQL 패턴(`FeedforwardAuditService`), 패널 `data-src` 컨벤션(`index.html`). 관련 메모리: [[product-pivot-selfservice-reporting]], [[deterministic-verification-gate]], [[plant-knowledge-document-first]].*
|
||||
104
docs/작업플랜-셀프서비스-분석리포트-MVP.md
Normal file
104
docs/작업플랜-셀프서비스-분석리포트-MVP.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 작업 플랜 — 셀프서비스 결정론 분석·리포트 레이어 (MVP 아키텍처 스케치)
|
||||
|
||||
> 2026-06-12. 출처: [`논의-AI운전원-제어-아이디어.md`](논의-AI운전원-제어-아이디어.md) §방향전환.
|
||||
> 제품 한 줄: **"운전원 소유의, 결정론적·해상도 가변 캡처→분석→리포트 레이어 — DCS/MES가 못 하고 전산팀을 안 거치는."**
|
||||
> 데이터 자체가 상품 → 거기에 *의미층(KB/태그매핑) + 결정론 계산 + 운전원 포맷*을 얹어 판다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 핵심 설계 전제 (이미 성립)
|
||||
| 전제 | 근거 (현 코드) |
|
||||
|---|---|
|
||||
| 캡처 2소스가 **동일 long 포맷** (`tagname, recorded_at, value`) | `history_table`(60s 상시) · `fast_record`(on-demand s/min, `FastController`) |
|
||||
| → 메트릭 SQL은 **소스 테이블만 파라미터**로 양쪽 동작 | "해상도 가변"이 추가비용 0 |
|
||||
| 엑셀 폼 읽기/쓰기 인프라 존재 | `wwwroot/js/xlsx.full.min.js` (SheetJS) 클라이언트 탑재 |
|
||||
| 캡처 UI/세션관리 가동 | `FastController`(start/stop/sessions), `FastSession`(sampling_ms·duration_sec·tag_list·retention) |
|
||||
| 의미층(moat) 존재 | `tag_metadata` + KB 문서(단일진실원) + loop→필드계기 매핑 + `pid_equipment` |
|
||||
| 시각화 패턴 존재 | 온도프로파일/`trend.js`, `fast.html` 패널 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 컴포넌트 (레이어)
|
||||
|
||||
```
|
||||
┌─ 캡처 레이어 (Source) ────────────────────────────────────┐
|
||||
│ history_table (상시 60s) │ fast_record (on-demand s/min) │
|
||||
│ └──────────── 동일 스키마 ─────────────┘ │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
▼
|
||||
┌─ 의미층 (Semantic / moat) ──────────────────────────────────┐
|
||||
│ tag_metadata · KB 문서 · loop→필드계기 매핑 · pid_equipment │
|
||||
│ "C-6111 효율" → 구체 태그/공식/단위 resolve │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
▼
|
||||
┌─ 메트릭 엔진 (결정론 로직 = 오늘 내가 짠 SQL의 승격) ────────────┐
|
||||
│ Metric(source, tags|loop|column, period) → value+meta │
|
||||
│ · 자동 데이터 클리닝(드롭아웃/양자화 필터) │
|
||||
│ · 출력 메타: source, sampling_ms, n, cleaned_fraction │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
┌─ 시각화 ────────────┐ ┌─ 리포트/폼 바인딩 (= 돈) ──────────────┐
|
||||
│ 기간선택 → 메트릭 │ │ 운전원 엑셀폼(named cells) = 계약 │
|
||||
│ 곡선+이상플래그+숫자 │ │ → 엔진이 값 채움 → Daily/Monthly/Yearly │
|
||||
│ (온도프로파일 일반화) │ │ → 다운로드 (SheetJS/ClosedXML) │
|
||||
└─────────────────────┘ └─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 메트릭 레지스트리 (오늘 작업의 제품화)
|
||||
각 메트릭 = **순수 함수**: `(source_table, 대상[column|loop|tags], period) → {values, meta}`.
|
||||
선언적 정의(레지스트리/config, 하드코딩 금지):
|
||||
|
||||
| metric id | 정의 | 필요 태그(의미층이 resolve) | 최소 sampling | 출처(오늘 검증) |
|
||||
|---|---|---|---|---|
|
||||
| `energy_efficiency` | steam/product (컬럼별) | 리보일러 스팀 / 제품유량 (C-6111: FIQ-6115/FICQ-6118) | 60s | 검증 2·3회차 |
|
||||
| `yield` | product/feed | FICQ-6118/FICQ-6101 | 60s | 〃 |
|
||||
| `control_residual` | (PV−SP) 통계 (mean/sd/p95/이탈%) | 루프 .PV/.SP | 60s | — |
|
||||
| `excursion_time` | 민감단 목표대역 밖 체류% · 크기 · 빈도 | T_C(TI-6111C) + 대역 | 60s | 검증 2회차 |
|
||||
| `settling` | 조건변경 후 재정착 일수 · 페널티 | feed/T_C/steam/prod | 일 | 검증 3회차 (`/tmp/settling_probe.py`) |
|
||||
| `dynamics` ★fast | FOPDT τ/deadtime, 오버슈트, 정착, **밸브 stiction/헌팅** | 루프 PV/OP/SP | **≤1s (fast_record 필수)** | 미구현(주춧돌) |
|
||||
|
||||
규칙:
|
||||
- 모든 메트릭은 **자동 클리닝** 내장(오늘 손으로 한 `value BETWEEN ...`, 0/스파이크 제거, KST/UTC). 채널별 양자화/RTD레인지 주의(메모리 참조).
|
||||
- 모든 출력에 **sampling 컨텍스트** 동봉 → 리포트 비교 안전성.
|
||||
- **결정론 검증 게이트**: 빈 결과/날조 금지, 실패는 명시적 에러(메모리 [[deterministic-verification-gate]]).
|
||||
|
||||
## 3. 리포트/폼 바인딩 (핵심 상품)
|
||||
**계약 = 운전원이 만든 .xlsx 템플릿.** 셀/네임드레인지에 메트릭 참조를 박는다:
|
||||
|
||||
```
|
||||
예) 셀 B5 주석/네임: {{ energy_efficiency | column=C-6111 | period=DAILY }}
|
||||
셀 C5: {{ control_residual.sd | loop=TICA-6111A | period=DAILY }}
|
||||
범위 A10:G40: {{ table: excursion_events | column=C-6111 | period=DAILY }}
|
||||
```
|
||||
- 엔진이 플레이스홀더 파싱 → 메트릭 실행 → **그 위치에 값/표/미니차트 주입** → 다운로드.
|
||||
- 포맷 변경 = 운전원이 엑셀에서 셀 옮기고 토큰 바꾸면 끝. **전산팀 0, 리드타임 0.**
|
||||
- 구현 선택지: 서버 `ClosedXML`/`EPPlus`(스케줄 배치용) + 클라이언트 `SheetJS`(즉시 미리보기/수동 export).
|
||||
|
||||
## 4. MVP 슬라이스 (가장 작은 판매가능 단위)
|
||||
1. 메트릭 3종(`energy_efficiency`, `yield`, `control_residual`)을 `history_table` 위 레지스트리 함수로 구현(오늘 SQL 승격).
|
||||
2. 운전원 엑셀 템플릿 1개 + 토큰 파서 → 선택 날짜로 채워 다운로드(ClosedXML).
|
||||
3. 같은 메트릭을 **`fast_record` 소스로도** 실행해 "해상도 가변" 실증(동일 코드, source만 교체).
|
||||
4. 시각화는 온도프로파일/`trend.js` 재활용해 메트릭 곡선 + 이상 플래그.
|
||||
→ 데모 시나리오: "운전원이 자기 엑셀폼 올림 → 어제 날짜 선택 → 컬럼별 효율·수율·제어잔차가 그 폼 그대로 채워져 내려옴."
|
||||
|
||||
## 5. 스키마/잡 추가(최소)
|
||||
- `metric_definition`(id, formula_ref, required_tags_rule, min_sampling, unit, version) — 감사·버전.
|
||||
- `report_template`(id, name, owner, xlsx_blob, token_map) · `report_run`(template_id, period, generated_at, source, status, file).
|
||||
- Daily/Monthly/Yearly 자동생성 = 기존 `Hc900HistoryService`류 **BackgroundService** 패턴으로 스케줄.
|
||||
|
||||
## 6. 리스크 / 반드시 못박을 것
|
||||
- **정의 거버넌스**: "효율/수율"의 컬럼별 공식은 합의·서명 필요 → 공식+버전을 리포트에 박아 **감사 가능**하게. 컬럼별 steam/product 매핑은 KB 문서(플랜트별)에서 확장.
|
||||
- **해상도 혼동**: §2 sampling 메타 필수(안 하면 1s vs 60s 통계 오해).
|
||||
- **데이터 품질**: 채널별 양자화/RTD레인지/드롭아웃 자동처리 — 메트릭이 "사람이 하기 힘든 부분"을 대신하는 핵심 가치.
|
||||
- **타임존**: recorded_at=UTC, 운전원 리포트=KST(+9) — 엔진 단일 처리.
|
||||
- **fast_record 보존/스토리지**: on-demand·retention_days로 폭증 방지(이미 필드 존재).
|
||||
|
||||
## 7. 단계 (제안)
|
||||
- **P0 (MVP)**: §4 슬라이스 — 메트릭 3종 + 엑셀폼 1개 + history 소스 + 1컬럼(C-6111). **상세 코딩설계 → [`작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md`](작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md)**
|
||||
- **P1**: fast_record 소스 + `dynamics` 메트릭(루프 stiction/헌팅, step-test 셀프서비스).
|
||||
- **P2**: 토큰 파서 일반화 + 템플릿 업로드 UI + Daily 스케줄 자동생성.
|
||||
- **P3**: 멀티컬럼/멀티플랜트(KB 매핑 확장) + Monthly/Yearly + 모델학습 데이터 적재 연계.
|
||||
|
||||
---
|
||||
*근거: 본 세션 검증 1~3회차(에너지/품질 결정론 통계), `FastController`/`fast_record`, `xlsx.full.min.js`, `tag_metadata`+KB. 관련: [[plant-knowledge-document-first]], [[deterministic-verification-gate]], [[loop-field-instrument-value-mapping]].*
|
||||
@@ -38,3 +38,5 @@
|
||||
{"id":"ground-04","category":"grounding","question":"PGMEA 측류추출에서 초고순도 제품은 탑의 어디에서 뽑아?","context":"측류추출: 탑 상부=경비물 제거, 탑 하부=중비물 제거, 탑 중간(Middle)=초고순도 PGMEA 제품 측류 추출(Side-stream Draw).","expect":{"answer_contains":["중간"]}}
|
||||
{"id":"ground-05","category":"grounding","question":"PGMEA 정제에 진공 증류가 필요한 이유는?","context":"PGMEA 상압 끓는점 약 146도, 분해온도 180도 이상에서 열분해 시작. 끓는점이 분해온도에 가까워 진공(약 50~100 Torr)으로 끓는점을 70~90도대로 낮춰 열분해를 방지한다.","expect":{"answer_contains":["열분해"]}}
|
||||
{"id":"ground-06","category":"grounding","question":"1차 플랜트에 등록된 태그 수는 몇 개야?","context":"area별 태그 수(참고): P1=87, P2=142, P3=50, P6=121.","expect":{"answer_contains":["87"]}}
|
||||
{"id":"nl2sql-11","category":"nl2sql","question":"FICQ-6101.PV 최근 48시간 최대값이 발생한 시각(들)의 PV·SP·OP를 전부 보여주고 반복 횟수도 알려줘","expect":{"sql_contains":["with","max(","recorded_at in","max_occurrences"],"must_not":["time_bucket","limit 1"]}}
|
||||
{"id":"nl2sql-12","category":"nl2sql","question":"TI-6111C 최근 24시간 최대값이 발생한 시각들의 TICA-6111A PV·SP·OP와 FIQ-6115 값을 전부 보여줘","expect":{"sql_contains":["with","max(","ti-6111c.pv","tica-6111a","fiq-6115","max_occurrences"],"must_not":["time_bucket","limit 1"]}}
|
||||
|
||||
@@ -42,9 +42,11 @@ def _kst_str(dt_iso: str | None) -> str:
|
||||
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8001/v1")
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
# NL2SQL 프롬프트 단일 소스 — worker/sql_prompt.py (production + eval 공유)
|
||||
from worker.sql_prompt import SQL_SYSTEM_PROMPT
|
||||
|
||||
# Qdrant 컬렉션
|
||||
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
||||
@@ -694,29 +696,8 @@ def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str:
|
||||
return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}"
|
||||
|
||||
|
||||
# Compact DB schema for LLM SQL generation
|
||||
_DB_SCHEMA = """
|
||||
Tables:
|
||||
history_table(tagname TEXT, value TEXT, recorded_at TIMESTAMPTZ)
|
||||
realtime_table(tagname TEXT, livevalue TEXT, timestamp TIMESTAMPTZ)
|
||||
tag_metadata(base_tag TEXT, attribute TEXT, value TEXT)
|
||||
event_history_table(tagname TEXT, prev_value TEXT, curr_value TEXT, event_type TEXT, event_time TIMESTAMPTZ, duration_seconds INT)
|
||||
pid_equipment(tag_no TEXT, category TEXT, tag_dcs BOOL, tag_class TEXT, instrument_type TEXT, from_tag TEXT, to_tag TEXT)
|
||||
-- tag_dcs=TRUE: DCS 함수블록(FIC/TIC/PIC류), FALSE: 현장 물리 계기(FT/FCV류)
|
||||
-- tag_class: 'field'(현장) / 'system'(DCS) — tag_dcs 기반
|
||||
-- from_tag(상류) → tag_no → to_tag(하류) 연결 추적
|
||||
|
||||
Views:
|
||||
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT, sub_area TEXT)
|
||||
|
||||
Rules:
|
||||
- SELECT only. tagname UPPERCASE exact match (e.g. 'FICQ-6113.PV').
|
||||
- If user input is lowercase, convert to UPPERCASE before querying.
|
||||
- value is TEXT; cast ::double precision when aggregating.
|
||||
- time_bucket() banned. For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60))
|
||||
- KST input = UTC-9 in DB.
|
||||
- sub_area는 "P6-1" 또는 공용 "P6-1,P6-2" 형식. 매칭은 항상 토큰 비교: 'P6-1' = ANY(string_to_array(sub_area, ','))
|
||||
"""
|
||||
# DB 스키마/SQL system 프롬프트는 worker/sql_prompt.py 로 단일화됨 (위에서 SQL_SYSTEM_PROMPT import).
|
||||
# 이전의 inline _DB_SCHEMA 사본은 제거 — query_with_nl 은 SQL_SYSTEM_PROMPT 를 직접 사용.
|
||||
|
||||
|
||||
def _area_or_subarea_filter(area: str | None, tagname_col: str, area_col: str) -> tuple[str, list]:
|
||||
@@ -1151,7 +1132,11 @@ async def upsert_pid_connection(
|
||||
|
||||
@mcp.tool()
|
||||
async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
|
||||
"""과거 값(PV) 히스토리 조회.
|
||||
"""과거 값(PV) **원시 시계열 행**만 그대로 반환 (집계/계산 없음).
|
||||
|
||||
⚠️ 최대/최소/평균 등 **집계**나 '극값이 발생한 **시각**의 관련 태그(SP/OP 등) 조회'가
|
||||
필요하면 이 도구가 아니라 **query_with_nl** 을 사용할 것 (CTE로 정확히 계산됨).
|
||||
이 도구로 원시 행을 받아 모델이 직접 max/시각매칭을 추정하면 오답이 발생함.
|
||||
|
||||
Args:
|
||||
tag_names: 태그 이름 목록 (예: ["ficq-6113.pv", "ti-6101.pv"])
|
||||
@@ -1311,10 +1296,78 @@ async def classify_intent(question: str) -> str:
|
||||
return json.dumps({"success": True, "question": question, "route": route}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ── 결정론적 SQL 태그 검증 게이트 (LLM 추론을 DB로 검증 → 피드백 루프) ──────────────
|
||||
_REALTAG_CACHE: dict = {"tags": None, "at": 0.0}
|
||||
_REALTAG_TTL = 300.0 # 5분
|
||||
# 계기 태그 literal: 영문 prefix(1~6) + '-' + 숫자 시작 + 선택 .suffix.
|
||||
# ('P6-1' 같은 area 코드는 prefix에 숫자가 있어 매칭 제외 → 오검출 방지)
|
||||
_SQL_TAG_RE = re.compile(r"'([A-Za-z]{1,6}-\d[0-9A-Za-z]*(?:\.[A-Za-z0-9]+)?)'")
|
||||
|
||||
|
||||
def _load_real_tagnames() -> set[str]:
|
||||
"""realtime_table 의 실제 태그명 집합(원본 대소문자) — 5분 캐시. SQL 실행 대상의 진실 공급원."""
|
||||
import time
|
||||
c = _REALTAG_CACHE
|
||||
if c["tags"] is not None and (time.time() - c["at"]) < _REALTAG_TTL:
|
||||
return c["tags"]
|
||||
conn = None
|
||||
try:
|
||||
conn = _get_db_connection_sync()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT DISTINCT tagname FROM realtime_table WHERE tagname IS NOT NULL")
|
||||
s = {r[0] for r in cur.fetchall() if r[0]}
|
||||
c["tags"], c["at"] = s, time.time()
|
||||
return s
|
||||
except Exception:
|
||||
return c["tags"] or set()
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _verify_sql_tags(sql: str):
|
||||
"""SQL 내 태그 literal 을 실제 DB 태그집합과 대조 (결정론적).
|
||||
|
||||
반환: (fixed_sql, problems)
|
||||
- fixed_sql: 실재하는 태그는 DB 표기(대소문자)로 교정
|
||||
- problems: [{"tag": <SQL표기>, "suggested": [후보...]}, ...]. 비어 있으면 전부 유효.
|
||||
"""
|
||||
real = _load_real_tagnames()
|
||||
if not real:
|
||||
return sql, [] # 태그집합 로드 실패 시 게이트는 통과(가용성 우선, 실행 단계에서 오류로 드러남)
|
||||
upper_map = {t.upper(): t for t in real}
|
||||
fixed = sql
|
||||
problems = []
|
||||
seen = set()
|
||||
for m in _SQL_TAG_RE.finditer(sql):
|
||||
lit = m.group(1)
|
||||
if lit in seen:
|
||||
continue
|
||||
seen.add(lit)
|
||||
U = lit.upper()
|
||||
if U in upper_map:
|
||||
actual = upper_map[U]
|
||||
if lit != actual: # 대소문자만 교정
|
||||
fixed = re.sub(r"'" + re.escape(lit) + r"'", "'" + actual + "'", fixed)
|
||||
continue
|
||||
# 존재하지 않는 태그 → base 토큰 부분일치로 실제 후보 제안
|
||||
base = U.split('.')[0]
|
||||
toks = [p for p in re.split(r'[-_]', base) if len(p) >= 2]
|
||||
cand = sorted({t for t in real if any(p in t.upper() for p in toks)})[:5]
|
||||
problems.append({"tag": lit, "suggested": cand})
|
||||
return fixed, problems
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def query_with_nl(question: str) -> str:
|
||||
"""자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다.
|
||||
|
||||
**집계·극값·다중태그 조인은 반드시 이 도구**(query_pv_history 아님). 예:
|
||||
- "TI-6111C 24시간 최대/평균/최소"
|
||||
- "FICQ-6101.PV 최대값이 발생한 **시각**의 SP·OP·관련태그 값" (극값 시각 → 그 순간의 다른 태그)
|
||||
- "어제 유량 합계", "N분 간격 평균"
|
||||
이런 질문은 SQL/CTE 로 DB에서 직접 계산해야 정확하다(원시 행을 끌어와 모델이 눈대중 집계하면 틀린다).
|
||||
|
||||
Args:
|
||||
question: 자연어 질문 (예: "FICQ-6113.PV 최근 1시간 값을 1분 단위로 표시")
|
||||
|
||||
@@ -1352,41 +1405,58 @@ async def query_with_nl(question: str) -> str:
|
||||
# 라우팅 실패 시 원래 SQL 경로로 fallback
|
||||
pass
|
||||
|
||||
system = (
|
||||
"You are a PostgreSQL SQL expert.\n"
|
||||
"Convert the user's question into a SELECT SQL.\n"
|
||||
"Return ONLY the SQL. No explanation, no markdown, NO <think> tags.\n"
|
||||
"Use PostgreSQL syntax. tagname UPPERCASE exact match (e.g. 'FICQ-6113.PV').\n"
|
||||
"If user input is lowercase, convert to UPPERCASE before querying.\n"
|
||||
"value is TEXT; cast ::double precision when aggregating.\n"
|
||||
"KST input = UTC-9. Example: KST 12:00 = UTC 03:00.\n"
|
||||
"For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)).\n"
|
||||
"No GROUP BY if no interval specified.\n\n"
|
||||
f"{_DB_SCHEMA}"
|
||||
)
|
||||
|
||||
try:
|
||||
def _call_llm():
|
||||
return _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
# 프롬프트 단일 소스: worker/sql_prompt.py 의 SQL_SYSTEM_PROMPT (풍부한 스키마·뷰·버킷·KST·CTE 예시).
|
||||
# 'No GROUP BY if no interval specified' 같은 단편 지시는 SQL_SYSTEM_PROMPT 의 정교한 INTERVAL 규칙으로 대체됨.
|
||||
messages = [
|
||||
{"role": "system", "content": SQL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": question},
|
||||
],
|
||||
max_tokens=8192,
|
||||
temperature=0.1,
|
||||
]
|
||||
MAX_TRIES = 3 # 결정론 검증 실패 시 LLM 자기교정 재시도 횟수
|
||||
sql = ""
|
||||
last_problems: list = []
|
||||
for attempt in range(MAX_TRIES):
|
||||
try:
|
||||
def _call_llm(_msgs=list(messages)):
|
||||
return _llm().chat.completions.create(
|
||||
model=VLLM_MODEL, messages=_msgs, max_tokens=8192, temperature=0.1,
|
||||
)
|
||||
|
||||
resp = await asyncio.to_thread(_call_llm)
|
||||
sql = _strip_think(resp.choices[0].message.content or "").strip()
|
||||
# 마크다운 코드 블록 제거
|
||||
if sql.startswith("```"):
|
||||
lines = sql.splitlines()
|
||||
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
||||
if not sql:
|
||||
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False)
|
||||
if not sql:
|
||||
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
|
||||
|
||||
# ── 결정론적 태그 검증 게이트: 실재하지 않는 태그면 실행 거부 + 자기교정 피드백 ──
|
||||
sql, last_problems = await asyncio.to_thread(_verify_sql_tags, sql)
|
||||
if not last_problems:
|
||||
break # 모든 태그 실재 확인(대소문자 교정 완료) → 실행 진행
|
||||
|
||||
if attempt < MAX_TRIES - 1:
|
||||
fb = []
|
||||
for p in last_problems:
|
||||
sug = ", ".join(p["suggested"]) if p["suggested"] else "(후보 없음 — find_tags 로 검색 필요)"
|
||||
fb.append(f"- '{p['tag']}' 는 DB에 존재하지 않음. 실제 후보: {sug}")
|
||||
feedback = (
|
||||
"직전 SQL에 존재하지 않는 태그가 있습니다:\n" + "\n".join(fb)
|
||||
+ "\n위 후보 중 정확한 실제 태그명만 사용해 SQL 전체를 다시 작성하세요. "
|
||||
"기간·CTE·집계 등 다른 로직은 그대로 두고 태그명만 교정. SQL만 반환."
|
||||
)
|
||||
messages.append({"role": "assistant", "content": sql})
|
||||
messages.append({"role": "user", "content": feedback})
|
||||
|
||||
# 재시도 소진 후에도 미존재 태그가 남으면 — 빈 결과 대신 명시적 에러 (날조 차단)
|
||||
if last_problems:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"sql": sql,
|
||||
"error": "존재하지 않는 태그가 SQL에 포함되어 실행을 거부했습니다 (결정론 검증 게이트). "
|
||||
"find_tags 로 정확한 태그명을 먼저 확인하세요.",
|
||||
"invalid_tags": last_problems,
|
||||
}, ensure_ascii=False, default=str)
|
||||
|
||||
# SQL 실행
|
||||
raw = await _execute_sql_internal(sql)
|
||||
@@ -1401,15 +1471,20 @@ async def query_with_nl(question: str) -> str:
|
||||
time_col = next((c for c in cols if c not in ("tagname", "value", "livevalue", "avg_val")), None)
|
||||
val_col = next((c for c in ("avg_val", "value") if c in cols), cols[-1])
|
||||
if time_col:
|
||||
# tagname/val_col/time_col 외 스칼라 컬럼(예: max_occurrences)은 피벗 후에도 보존
|
||||
extra_cols = [c for c in cols if c not in ("tagname", val_col, time_col)]
|
||||
tag_names_list = sorted(dict.fromkeys(row["tagname"] for row in data))
|
||||
pivoted: dict = {}
|
||||
for row in data:
|
||||
key = str(row[time_col])
|
||||
if key not in pivoted:
|
||||
pivoted[key] = {time_col: row[time_col]}
|
||||
base = {time_col: row[time_col]}
|
||||
for c in extra_cols:
|
||||
base[c] = row.get(c)
|
||||
pivoted[key] = base
|
||||
pivoted[key][row["tagname"]] = row.get(val_col)
|
||||
result["data"] = list(pivoted.values())
|
||||
result["columns"] = [time_col] + tag_names_list
|
||||
result["columns"] = [time_col] + extra_cols + tag_names_list
|
||||
result["count"] = len(result["data"])
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
@@ -311,6 +311,12 @@ NL2SQL = [
|
||||
"SELECT DISTINCT value AS sub_area\nFROM tag_metadata\nWHERE attribute = 'sub_area' AND value LIKE '%P6%'\nORDER BY sub_area"),
|
||||
("p-6201 의 sub_area (공용 여부 확인)",
|
||||
"SELECT value AS sub_area\nFROM tag_metadata\nWHERE base_tag = 'p-6201' AND attribute = 'sub_area'"),
|
||||
("ficq-6101.pv 최근 48시간 최대값이 발생한 시각의 pv sp op 함께 보여줘",
|
||||
"WITH peak AS (\n SELECT recorded_at\n FROM history_table\n WHERE tagname = 'ficq-6101.pv'\n AND recorded_at >= NOW() - INTERVAL '48 hours'\n AND value::double precision = (\n SELECT MAX(value::double precision)\n FROM history_table\n WHERE tagname = 'ficq-6101.pv'\n AND recorded_at >= NOW() - INTERVAL '48 hours'\n )\n)\nSELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, tagname, value,\n (SELECT COUNT(*) FROM peak) AS max_occurrences\nFROM history_table\nWHERE tagname IN ('ficq-6101.pv', 'ficq-6101.sp', 'ficq-6101.op')\n AND recorded_at IN (SELECT recorded_at FROM peak)\nORDER BY recorded_at, tagname"),
|
||||
("ti-6101.pv 어제 최저값 시각의 값 알려줘 (반복되면 전부)",
|
||||
"WITH trough AS (\n SELECT recorded_at\n FROM history_table\n WHERE tagname = 'ti-6101.pv'\n AND recorded_at >= (CURRENT_DATE - INTERVAL '1 day') + INTERVAL '-9 hours'\n AND recorded_at < CURRENT_DATE + INTERVAL '-9 hours'\n AND value::double precision = (\n SELECT MIN(value::double precision)\n FROM history_table\n WHERE tagname = 'ti-6101.pv'\n AND recorded_at >= (CURRENT_DATE - INTERVAL '1 day') + INTERVAL '-9 hours'\n AND recorded_at < CURRENT_DATE + INTERVAL '-9 hours'\n )\n)\nSELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, tagname, value,\n (SELECT COUNT(*) FROM trough) AS min_occurrences\nFROM history_table\nWHERE tagname = 'ti-6101.pv'\n AND recorded_at IN (SELECT recorded_at FROM trough)\nORDER BY recorded_at"),
|
||||
("ti-6111c 최근 24시간 최대값이 발생한 시각들의 tica-6111a pv sp op와 fiq-6115 값 (전부, 앵커 포함)",
|
||||
"WITH peak AS (\n SELECT recorded_at\n FROM history_table\n WHERE tagname = 'ti-6111c.pv'\n AND recorded_at >= NOW() - INTERVAL '24 hours'\n AND value::double precision = (\n SELECT MAX(value::double precision)\n FROM history_table\n WHERE tagname = 'ti-6111c.pv'\n AND recorded_at >= NOW() - INTERVAL '24 hours'\n )\n)\nSELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, tagname, value,\n (SELECT COUNT(*) FROM peak) AS max_occurrences\nFROM history_table\nWHERE tagname IN ('ti-6111c.pv', 'tica-6111a.pv', 'tica-6111a.sp', 'tica-6111a.op', 'fiq-6115.pv')\n AND recorded_at IN (SELECT recorded_at FROM peak)\nORDER BY recorded_at, tagname"),
|
||||
]
|
||||
|
||||
# ── 6) 생성 + 검증 + 통계 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,10 +106,41 @@ N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||
GROUP BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname
|
||||
ORDER BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname
|
||||
|
||||
예시 (극값 시각 → 그 시각의 관련 태그값 — CTE; ★극값 기준 태그(앵커)≠표시요청 태그인 경우):
|
||||
-- "TI-6111C 최대값이 발생한 시각(들)의 TICA-6111A PV/SP/OP, FIQ-6115 값을 보여줘"
|
||||
-- ★ 극값(최대/최소)은 양자화 sim 특성상 여러 시각에 반복될 수 있다 →
|
||||
-- LIMIT 1 로 하나만 고르지 말 것. value = MAX(...) 인 **모든 시각을 반환**하고 반복 횟수(max_occurrences) 명시.
|
||||
-- ★ 극값 기준 태그(여기선 TI-6111C.PV = '앵커')는 사용자가 표시 요청하지 않아도 SELECT 에 **반드시 포함**.
|
||||
-- 모든 행에서 그 값이 극값(=MAX)으로 고정되어, 독자가 "이 행들이 진짜 최대값 시각"임을 검증할 수 있다.
|
||||
WITH peak AS (
|
||||
SELECT recorded_at
|
||||
FROM history_table
|
||||
WHERE tagname = 'TI-6111C.PV'
|
||||
AND recorded_at >= NOW() - INTERVAL '24 hours'
|
||||
AND value::double precision = (
|
||||
SELECT MAX(value::double precision)
|
||||
FROM history_table
|
||||
WHERE tagname = 'TI-6111C.PV'
|
||||
AND recorded_at >= NOW() - INTERVAL '24 hours'
|
||||
)
|
||||
)
|
||||
SELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, tagname, value,
|
||||
(SELECT COUNT(*) FROM peak) AS max_occurrences
|
||||
FROM history_table
|
||||
WHERE tagname IN ('TI-6111C.PV', -- ★ 앵커(극값 기준) 반드시 포함
|
||||
'TICA-6111A.PV', 'TICA-6111A.SP', 'TICA-6111A.OP', 'FIQ-6115.PV')
|
||||
AND recorded_at IN (SELECT recorded_at FROM peak)
|
||||
ORDER BY recorded_at, tagname
|
||||
-- 최소값은 MAX(...) 를 MIN(...) 으로. max_occurrences = 극값이 반복된 시각 수.
|
||||
-- 결과 제시 시: 헤더에 "TI-6111C.PV 최대값 = <값> (N회 발생)" 을 명시하고, 앵커 열(전 행 고정값)을 표에 함께 보여줄 것.
|
||||
-- 봉우리마다 관련 태그값(예: 유량)이 다를 수 있으므로 전부 보여주는 것이 정직하다.
|
||||
|
||||
규칙:
|
||||
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
|
||||
- tagname은 모두 소문자로 정확히 입력
|
||||
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
|
||||
- SELECT/WITH(CTE) 허용 (INSERT/UPDATE/DELETE/DROP 등 불가). 극값→관련태그 류는 CTE를 적극 사용.
|
||||
- 극값→관련태그: 극값 기준 태그(앵커)를 SELECT/IN 목록에 **반드시 포함**(사용자가 그 태그를 요청하지 않았어도).
|
||||
결과 설명에는 **극값 수치와 반복 횟수(max_occurrences)** 를 명시한다.
|
||||
- tagname은 모두 대문자로 정확히 입력 (예: 'FICQ-6113.PV'). 입력이 소문자면 대문자로 변환.
|
||||
- value 컬럼은 TEXT이므로 집계/정렬 시 ::double precision 캐스트 필수
|
||||
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
|
||||
"""
|
||||
|
||||
|
||||
@@ -71,10 +71,10 @@ ExperionCrawler가 결정한 값이 **Single Source of Truth** 입니다.
|
||||
|
||||
### sub_area 도구 호출 방법
|
||||
|
||||
- `active_alarms(area="P6-1")` — '-'가 있으면 server.py가 자동으로 sub_area 토큰 매칭
|
||||
- `find_tags(query="펌프", sub_area="P6-1")` — 전용 sub_area 파라미터 사용
|
||||
- `query_events(area="P6-1")` / `summarize_events(area="P6-1")` / `generate_status_report(area="P6-1")` — 동일
|
||||
- area 코드("P6")를 그대로 주면 sub_area 구분 없이 area 전체가 조회됩니다(기존 동작).
|
||||
- `active_alarms` — area 파라미터에 `P6-1` 같이 '-'가 있으면 server.py가 자동으로 sub_area 토큰 매칭
|
||||
- `find_tags` — sub_area 전용 파라미터에 `P6-1` 전달
|
||||
- `query_events` / `summarize_events` / `generate_status_report` — area 파라미터에 `P6-1` 전달
|
||||
- area 파라미터에 `P6`(short form)를 쓰면 sub_area 구분 없이 area 전체가 조회됩니다.
|
||||
|
||||
### ⚠️ 공용(shared) 태그
|
||||
|
||||
|
||||
28
scripts/sql/p0_report.sql
Normal file
28
scripts/sql/p0_report.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- P0 셀프서비스 리포트 — 테이블 2개 (hc900 스키마)
|
||||
-- 적용: psql "host=localhost dbname=iiot_platform user=postgres" -f scripts/sql/p0_report.sql
|
||||
SET search_path TO hc900;
|
||||
|
||||
-- 운전원이 올린 엑셀 템플릿 + 메타
|
||||
CREATE TABLE IF NOT EXISTS hc900.report_template (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
owner text,
|
||||
xlsx_blob bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 생성 이력(감사·재현). 어떤 정의로 어떤 기간을 뽑았는지 박제
|
||||
CREATE TABLE IF NOT EXISTS hc900.report_run (
|
||||
id bigserial PRIMARY KEY,
|
||||
template_id int REFERENCES hc900.report_template(id) ON DELETE CASCADE,
|
||||
period_kind text NOT NULL, -- 'DAILY'
|
||||
period_date date NOT NULL, -- KST 기준 날짜
|
||||
source_table text NOT NULL, -- 'history_table' | 'fast_record'
|
||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NOT NULL, -- 'ok' | 'partial' | 'error'
|
||||
cells_json jsonb, -- 채운 셀=메트릭+파라미터+값+sampling메타 박제
|
||||
out_blob bytea -- 채워진 .xlsx (다운로드 캐시)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_report_run_template ON hc900.report_run(template_id, period_date);
|
||||
@@ -114,22 +114,22 @@ public class Hc900PointBuilderBuildDto
|
||||
|
||||
public class Hc900PointBuilderPreviewItem
|
||||
{
|
||||
[JsonPropertyName("tagName")] public string TagName { get; set; } = "";
|
||||
[JsonPropertyName("hc900Tag")] public string Hc900Tag { get; set; } = "";
|
||||
[JsonPropertyName("modbusAddr")] public int ModbusAddr { get; set; }
|
||||
[JsonPropertyName("paramType")] public string ParamType { get; set; } = "";
|
||||
[JsonPropertyName("dataType")] public string DataType { get; set; } = "";
|
||||
[JsonPropertyName("loopNo")] public int? LoopNo { get; set; }
|
||||
[JsonPropertyName("access")] public string Access { get; set; } = "R";
|
||||
[JsonPropertyName("controllerId")]public string ControllerId { get; set; } = "HC1";
|
||||
[JsonPropertyName("group")] public string Group { get; set; } = "";
|
||||
[JsonPropertyName("isActive")] public bool IsActive { get; set; }
|
||||
public string TagName { get; set; } = "";
|
||||
public string Hc900Tag { get; set; } = "";
|
||||
public int ModbusAddr { get; set; }
|
||||
public string ParamType { get; set; } = "";
|
||||
public string DataType { get; set; } = "";
|
||||
public int? LoopNo { get; set; }
|
||||
public string Access { get; set; } = "R";
|
||||
public string ControllerId { get; set; } = "HC1";
|
||||
public string Group { get; set; } = "";
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class Hc900PointBuilderPreviewResult
|
||||
{
|
||||
[JsonPropertyName("count")] public int Count { get; set; }
|
||||
[JsonPropertyName("items")] public List<Hc900PointBuilderPreviewItem> Items { get; set; } = new();
|
||||
public int Count { get; set; }
|
||||
public List<Hc900PointBuilderPreviewItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class Hc900PointBuilderApplyDto
|
||||
|
||||
30
src/Core/Application/DTOs/ReportDtos.cs
Normal file
30
src/Core/Application/DTOs/ReportDtos.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Hc900Crawler.Core.Application.DTOs;
|
||||
|
||||
/// <summary>단일 결정론 메트릭 요청.</summary>
|
||||
public sealed class MetricRequestDto
|
||||
{
|
||||
public string Column { get; set; } = "C-6111";
|
||||
public string Metric { get; set; } = ""; // energy_efficiency | yield | control_residual
|
||||
public DateTime PeriodDateKst { get; set; } // 운전원이 고른 KST 날짜(00:00 기준)
|
||||
public string SourceTable { get; set; } = "history_table"; // | fast_record
|
||||
public int? SessionId { get; set; } // fast_record일 때
|
||||
}
|
||||
|
||||
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지).
|
||||
/// 프로퍼티로 선언해야 System.Text.Json이 직렬화함(필드는 기본 미직렬화).</summary>
|
||||
public sealed class MetricResultDto
|
||||
{
|
||||
public string Metric { get; set; } = "";
|
||||
public string Column { get; set; } = "";
|
||||
public double? Value { get; set; } // 대표값(없으면 null = 명시적 실패, 0 날조 금지)
|
||||
public string? Unit { get; set; }
|
||||
public string Status { get; set; } = "ok"; // ok | no_data | error
|
||||
public string? Error { get; set; }
|
||||
|
||||
// ── 해상도-인지 메타 ──
|
||||
public string Source { get; set; } = ""; // history_table | fast_record
|
||||
public int SamplingMs { get; set; } // 60000(history) | fast_session.sampling_ms
|
||||
public int N { get; set; } // 클린 후 표본수
|
||||
public double CleanedFraction { get; set; } // 제거된 비율(0~1)
|
||||
public Dictionary<string, double?> Extra { get; set; } = new(); // sd/abs_p95/out_pct_0_5 등
|
||||
}
|
||||
@@ -2,74 +2,70 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace Hc900Crawler.Core.Application.DTOs;
|
||||
|
||||
// 이 프로젝트의 기본 JSON 직렬화는 PascalCase 패스스루다(기존 컨트롤러는 익명객체로 직접 camelCase 명명).
|
||||
// 트렌드 DTO는 프론트(camelCase) 계약에 맞추기 위해 [JsonPropertyName]으로 camelCase 고정.
|
||||
// (입력 바인딩은 MVC 기본 대소문자 무시라 무관, 출력 일관성 목적)
|
||||
|
||||
/// <summary>트렌드 그룹 멤버 — 태그 + 색상 + Y축(좌/우)</summary>
|
||||
public class TrendMemberDto
|
||||
{
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("color")] public string Color { get; set; } = ""; // #rrggbb
|
||||
[JsonPropertyName("axis")] public string Axis { get; set; } = "left"; // left | right
|
||||
public string Tag { get; set; } = "";
|
||||
public string Color { get; set; } = ""; // #rrggbb
|
||||
public string Axis { get; set; } = "left"; // left | right
|
||||
}
|
||||
|
||||
/// <summary>트렌드 그룹 (멤버는 JSONB 저장)</summary>
|
||||
public class TrendGroupDto
|
||||
{
|
||||
[JsonPropertyName("id")] public int Id { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("members")] public List<TrendMemberDto> Members { get; set; } = new();
|
||||
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
|
||||
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public List<TrendMemberDto> Members { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>그룹 생성/수정 요청</summary>
|
||||
public class TrendGroupUpsertDto
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("members")] public List<TrendMemberDto> Members { get; set; } = new();
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public List<TrendMemberDto> Members { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>아날로그 포인트 (그룹 빌더용 — 숫자 livevalue 한정)</summary>
|
||||
public class AnalogPointDto
|
||||
{
|
||||
[JsonPropertyName("tagName")] public string TagName { get; set; } = "";
|
||||
[JsonPropertyName("baseTag")] public string BaseTag { get; set; } = "";
|
||||
[JsonPropertyName("value")] public double? Value { get; set; }
|
||||
[JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; }
|
||||
[JsonPropertyName("unit")] public string? Unit { get; set; }
|
||||
[JsonPropertyName("euLo")] public double? EuLo { get; set; }
|
||||
[JsonPropertyName("euHi")] public double? EuHi { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("area")] public string? Area { get; set; }
|
||||
public string TagName { get; set; } = "";
|
||||
public string BaseTag { get; set; } = "";
|
||||
public double? Value { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
public double? EuLo { get; set; }
|
||||
public double? EuHi { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Area { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>실시간 tail 포인트 (현재값)</summary>
|
||||
public class TrendLivePointDto
|
||||
{
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("value")] public double? Value { get; set; }
|
||||
[JsonPropertyName("ts")] public DateTime Ts { get; set; }
|
||||
public string Tag { get; set; } = "";
|
||||
public double? Value { get; set; }
|
||||
public DateTime Ts { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>알람 한계선 (HI/LO/SP)</summary>
|
||||
public class TrendLimitDto
|
||||
{
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("hi")] public double? Hi { get; set; }
|
||||
[JsonPropertyName("lo")] public double? Lo { get; set; }
|
||||
[JsonPropertyName("sp")] public double? Sp { get; set; }
|
||||
[JsonPropertyName("unit")] public string? Unit { get; set; }
|
||||
public string Tag { get; set; } = "";
|
||||
public double? Hi { get; set; }
|
||||
public double? Lo { get; set; }
|
||||
public double? Sp { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>운전상태 밴드 (RUN/TRIP 구간)</summary>
|
||||
public class TrendBandDto
|
||||
{
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("t0")] public DateTime T0 { get; set; }
|
||||
[JsonPropertyName("t1")] public DateTime T1 { get; set; }
|
||||
[JsonPropertyName("state")] public string State { get; set; } = "";
|
||||
public string Tag { get; set; } = "";
|
||||
public DateTime T0 { get; set; }
|
||||
public DateTime T1 { get; set; }
|
||||
public string State { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ public class McpToolDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public System.Text.Json.JsonElement? InputSchema { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
9
src/Core/Application/Interfaces/IReportMetricService.cs
Normal file
9
src/Core/Application/Interfaces/IReportMetricService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
|
||||
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||
|
||||
/// <summary>기간/소스/대상을 받아 결정론적으로 메트릭 1건을 계산한다.</summary>
|
||||
public interface IReportMetricService
|
||||
{
|
||||
Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default);
|
||||
}
|
||||
11
src/Core/Application/Interfaces/IReportTemplateStore.cs
Normal file
11
src/Core/Application/Interfaces/IReportTemplateStore.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||
|
||||
/// <summary>report_template / report_run CRUD (raw SQL).</summary>
|
||||
public interface IReportTemplateStore
|
||||
{
|
||||
Task<int> CreateAsync(string name, string? owner, byte[] xlsx, CancellationToken ct = default);
|
||||
Task<byte[]?> GetBlobAsync(int templateId, CancellationToken ct = default);
|
||||
Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate,
|
||||
string sourceTable, string status, object cells, byte[] outBlob,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -203,11 +203,13 @@ public class TextToSqlService : ITextToSqlService
|
||||
}
|
||||
else
|
||||
{
|
||||
// first/last 쿼리 (TimeScaleDB 함수)
|
||||
var func = aggregate == "first" ? "first" : "last";
|
||||
// first/last 쿼리 — array_agg로 교체 (TimeScaleDB first()/last() 비호환)
|
||||
var func = aggregate == "first"
|
||||
? "(array_agg(value::double precision ORDER BY recorded_at))[1]"
|
||||
: "(array_agg(value::double precision ORDER BY recorded_at DESC))[1]";
|
||||
return (new List<string> {
|
||||
$"SELECT date_trunc('{timeBucket}', recorded_at) AS bucket, " +
|
||||
$"tagname, {func}(value::double precision, recorded_at) AS result " +
|
||||
$"tagname, {func} AS result " +
|
||||
$"FROM {HistoryTable} " +
|
||||
$"{whereTag} " +
|
||||
$"GROUP BY 1, 2 ORDER BY 1, 2"
|
||||
@@ -497,8 +499,8 @@ public class TextToSqlService : ITextToSqlService
|
||||
MIN(value::double precision) AS min_val,
|
||||
MAX(value::double precision) AS max_val,
|
||||
STDDEV(value::double precision) AS stddev_val,
|
||||
first(value::double precision, recorded_at) AS first_val,
|
||||
last(value::double precision, recorded_at) AS last_val,
|
||||
(array_agg(value::double precision ORDER BY recorded_at))[1] AS first_val,
|
||||
(array_agg(value::double precision ORDER BY recorded_at DESC))[1] AS last_val,
|
||||
COUNT(*) AS point_count
|
||||
FROM history_table
|
||||
WHERE tagname = @tagName
|
||||
|
||||
@@ -26,18 +26,18 @@ public class DocsController : ControllerBase
|
||||
private Task<bool> IsAdminAsync(CancellationToken ct)
|
||||
=> _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
|
||||
|
||||
private IActionResult Fail(string error) => Ok(new { success = false, error });
|
||||
private IActionResult Fail(string error) => Ok(new { Success = false, Error = error });
|
||||
|
||||
// ── 메타/설정 ───────────────────────────────────────────────
|
||||
[HttpGet("config")]
|
||||
public async Task<IActionResult> Config(CancellationToken ct)
|
||||
=> Ok(new
|
||||
{
|
||||
success = true,
|
||||
root = _docs.Root,
|
||||
canManage = await IsAdminAsync(ct),
|
||||
maxTextBytes = _docs.MaxTextBytes,
|
||||
maxUploadBytes = _docs.MaxUploadBytes,
|
||||
Success = true,
|
||||
Root = _docs.Root,
|
||||
CanManage = await IsAdminAsync(ct),
|
||||
MaxTextBytes = _docs.MaxTextBytes,
|
||||
MaxUploadBytes = _docs.MaxUploadBytes,
|
||||
});
|
||||
|
||||
// ── 디렉토리 목록 ───────────────────────────────────────────
|
||||
@@ -48,14 +48,14 @@ public class DocsController : ControllerBase
|
||||
{
|
||||
var entries = _docs.List(path).Select(e => new
|
||||
{
|
||||
name = e.Name,
|
||||
path = e.RelPath,
|
||||
type = e.IsDir ? "dir" : "file",
|
||||
size = e.Size,
|
||||
mtime = e.ModifiedUtc,
|
||||
ext = e.Ext,
|
||||
Name = e.Name,
|
||||
Path = e.RelPath,
|
||||
Type = e.IsDir ? "dir" : "file",
|
||||
Size = e.Size,
|
||||
Mtime = e.ModifiedUtc,
|
||||
Ext = e.Ext,
|
||||
});
|
||||
return Ok(new { success = true, path = path ?? "", entries });
|
||||
return Ok(new { Success = true, Path = path ?? "", Entries = entries });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
@@ -67,7 +67,7 @@ public class DocsController : ControllerBase
|
||||
try
|
||||
{
|
||||
var r = _docs.ReadText(path);
|
||||
return Ok(new { success = true, text = r.Text, truncated = r.Truncated, size = r.Size, ext = r.Ext });
|
||||
return Ok(new { Success = true, Text = r.Text, Truncated = r.Truncated, Size = r.Size, Ext = r.Ext });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public class DocsController : ControllerBase
|
||||
return File(r.Stream, "application/octet-stream", r.FileName);
|
||||
return File(r.Stream, r.ContentType); // inline (pdf 등)
|
||||
}
|
||||
catch (DocBrowserException ex) { return NotFound(new { success = false, error = ex.Message }); }
|
||||
catch (DocBrowserException ex) { return NotFound(new { Success = false, Error = ex.Message }); }
|
||||
}
|
||||
|
||||
// ── 텍스트 저장 (admin) ─────────────────────────────────────
|
||||
@@ -93,13 +93,13 @@ public class DocsController : ControllerBase
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
public async Task<IActionResult> WriteText([FromBody] WriteRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required");
|
||||
try
|
||||
{
|
||||
_docs.WriteText(req.Path, req.Content ?? "");
|
||||
_logger.LogInformation("[Docs] 저장 {Path}", req.Path);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
@@ -110,14 +110,14 @@ public class DocsController : ControllerBase
|
||||
[HttpPost("rename")]
|
||||
public async Task<IActionResult> Rename([FromBody] RenameRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
if (req == null || string.IsNullOrEmpty(req.From) || string.IsNullOrEmpty(req.To))
|
||||
return Fail("from/to required");
|
||||
try
|
||||
{
|
||||
var newRel = _docs.Rename(req.From, req.To);
|
||||
_logger.LogInformation("[Docs] 이름변경 {From} → {To}", req.From, newRel);
|
||||
return Ok(new { success = true, path = newRel });
|
||||
return Ok(new { Success = true, Path = newRel });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
@@ -128,12 +128,12 @@ public class DocsController : ControllerBase
|
||||
[HttpPost("mkdir")]
|
||||
public async Task<IActionResult> Mkdir([FromBody] MkdirRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required");
|
||||
try
|
||||
{
|
||||
var rel = _docs.MakeDir(req.Path);
|
||||
return Ok(new { success = true, path = rel });
|
||||
return Ok(new { Success = true, Path = rel });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
@@ -142,13 +142,13 @@ public class DocsController : ControllerBase
|
||||
[HttpDelete]
|
||||
public async Task<IActionResult> Delete([FromQuery] string path, [FromQuery] bool recursive, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
if (string.IsNullOrEmpty(path)) return Fail("path required");
|
||||
try
|
||||
{
|
||||
_docs.Delete(path, recursive);
|
||||
_logger.LogInformation("[Docs] 삭제 {Path} (recursive={R})", path, recursive);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
@@ -158,14 +158,14 @@ public class DocsController : ControllerBase
|
||||
[RequestSizeLimit(100_000_000)]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string? path, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
if (file == null || file.Length == 0) return Fail("file required");
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var rel = await _docs.SaveUploadAsync(path, file.FileName, stream, ct);
|
||||
_logger.LogInformation("[Docs] 업로드 {Path} ({Size} bytes)", rel, file.Length);
|
||||
return Ok(new { success = true, path = rel });
|
||||
return Ok(new { Success = true, Path = rel });
|
||||
}
|
||||
catch (DocBrowserException ex) { return Fail(ex.Message); }
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class FastController : ControllerBase
|
||||
? Array.Empty<string>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<string[]>(s.TagList) ?? Array.Empty<string>()
|
||||
});
|
||||
return Ok(new { items });
|
||||
return Ok(new { Items = items });
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -58,7 +58,7 @@ public class FastController : ControllerBase
|
||||
var cts = new CancellationTokenSource();
|
||||
lock (_sessions) { _sessions[session.Id] = cts; }
|
||||
_ = Task.Run(() => RunSessionAsync(session.Id, req, cts.Token));
|
||||
return Ok(new { session.Id, status = "Running" });
|
||||
return Ok(new { Id = session.Id, Status = "Running" });
|
||||
}
|
||||
|
||||
// 백그라운드 수집 루프. 요청 스코프는 응답 후 dispose되므로 매 반복 자체 스코프에서 DbContext를 새로 받는다.
|
||||
@@ -114,7 +114,7 @@ public class FastController : ControllerBase
|
||||
{
|
||||
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
|
||||
await _db.UpdateFastSessionStatusAsync(id, "Stopped");
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
@@ -122,14 +122,14 @@ public class FastController : ControllerBase
|
||||
{
|
||||
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
|
||||
await _db.DeleteFastSessionAsync(id);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpPost("{id}/pin")]
|
||||
public async Task<IActionResult> Pin(int id, [FromBody] bool pinned)
|
||||
{
|
||||
await _db.UpdateFastSessionPinnedAsync(id, pinned);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpGet("{id}/records")]
|
||||
@@ -139,8 +139,8 @@ public class FastController : ControllerBase
|
||||
return Ok(new
|
||||
{
|
||||
result.SessionId, result.From, result.To,
|
||||
tagNames = result.TagNames,
|
||||
items = result.Items.Select(r => new
|
||||
TagNames = result.TagNames,
|
||||
Items = result.Items.Select(r => new
|
||||
{
|
||||
r.SessionId, r.RecordedAt, r.TagName, r.Value
|
||||
}),
|
||||
|
||||
@@ -72,58 +72,58 @@ public sealed class FeedforwardController : ControllerBase
|
||||
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||||
{
|
||||
var cols = await _config.LoadAllAsync(ct);
|
||||
return Ok(new { columns = cols.Select(MapConfig) });
|
||||
return Ok(new { Columns = cols.Select(MapConfig) });
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
|
||||
{
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
|
||||
var id = await _config.SaveColumnAsync(body, ct);
|
||||
return Ok(new { success = true, id });
|
||||
return Ok(new { Success = true, Id = id });
|
||||
}
|
||||
|
||||
[HttpDelete("config/{id:int}")]
|
||||
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
||||
{
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
|
||||
await _config.DeleteColumnAsync(id, ct);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
// ── WO-6 전환류 ARM/취소 ──
|
||||
[HttpPost("recovery/{id:int}/arm")]
|
||||
public IActionResult ArmRecovery(int id) => Ok(new { success = _supervisor.Arm(id) });
|
||||
public IActionResult ArmRecovery(int id) => Ok(new { Success = _supervisor.Arm(id) });
|
||||
|
||||
[HttpPost("recovery/{id:int}/cancel")]
|
||||
public IActionResult CancelRecovery(int id) => Ok(new { success = _supervisor.Cancel(id) });
|
||||
public IActionResult CancelRecovery(int id) => Ok(new { Success = _supervisor.Cancel(id) });
|
||||
|
||||
// ── Phase II: 수동 SP 쓰기 ──
|
||||
[HttpPost("write/{columnId:int}/{streamKey}")]
|
||||
public async Task<IActionResult> WriteSp(int columnId, string streamKey, [FromBody] WriteSpBody body, CancellationToken ct)
|
||||
{
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
|
||||
var advisory = _store.Get(columnId);
|
||||
if (advisory is null) return NotFound(new { error = "advisory 없음" });
|
||||
if (advisory is null) return NotFound(new { Error = "advisory 없음" });
|
||||
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
||||
if (cfg is null) return NotFound(new { error = "config 없음" });
|
||||
if (cfg is null) return NotFound(new { Error = "config 없음" });
|
||||
var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
|
||||
if (sc is null) return NotFound(new { error = "stream 없음" });
|
||||
if (sc is null) return NotFound(new { Error = "stream 없음" });
|
||||
// SP 쓰기 대상 = flow 태그에서 WSP(.SP, offset 0x04 RW) 자동 파생. SpNodeId는 선택적 override.
|
||||
var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
|
||||
if (string.IsNullOrWhiteSpace(spTag))
|
||||
return BadRequest(new { error = "flow 태그가 없어 SP 대상 산출 불가" });
|
||||
return BadRequest(new { Error = "flow 태그가 없어 SP 대상 산출 불가" });
|
||||
var adv = advisory.Streams.FirstOrDefault(a => a.Key == streamKey);
|
||||
if (adv is null) return NotFound(new { error = "stream advisory 없음" });
|
||||
if (adv is null) return NotFound(new { Error = "stream advisory 없음" });
|
||||
|
||||
double spVal = body.value ?? (adv.RecommendedSp ?? double.NaN);
|
||||
if (double.IsNaN(spVal)) return BadRequest(new { error = "SP 값 없음" });
|
||||
if (double.IsNaN(spVal)) return BadRequest(new { Error = "SP 값 없음" });
|
||||
|
||||
// 범위 클램프(§3.3) 후 WriteGuard 검증 — manualOverride=true(AdvisoryOnly만 우회, 나머지 가드 유지)
|
||||
spVal = Math.Clamp(spVal, sc.SpMin, sc.SpMax);
|
||||
var check = _writeGuard.Check(cfg, adv, sc, advisory, manualOverride: true);
|
||||
if (!check.Allowed)
|
||||
return BadRequest(new { error = $"WriteGuard 차단: {check.Reason}" });
|
||||
return BadRequest(new { Error = $"WriteGuard 차단: {check.Reason}" });
|
||||
|
||||
// 되돌리기용: 쓰기 전 현재 WSP 값을 캡처(realtime_table)
|
||||
double? prevSp = await TryReadCurrentAsync(spTag, ct);
|
||||
@@ -131,7 +131,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
// 컨트롤러는 태그→controller_id 매핑에서 해석(DB). 컬럼 ControllerId에 의존하지 않음.
|
||||
var ctrlId = await _db.GetControllerIdForTagAsync(spTag);
|
||||
if (string.IsNullOrWhiteSpace(ctrlId))
|
||||
return BadRequest(new { error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" });
|
||||
return BadRequest(new { Error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" });
|
||||
|
||||
// HC900 gRPC 쓰기 (WSP 태그, 해석된 컨트롤러로 라우팅)
|
||||
var (success, error) = await _writeClient.WriteTagAsync(ctrlId, spTag, spVal);
|
||||
@@ -143,9 +143,9 @@ public sealed class FeedforwardController : ControllerBase
|
||||
OperatorName: "manual"), ct);
|
||||
|
||||
if (!success)
|
||||
return StatusCode(502, new { error = $"HC900 쓰기 실패: {error}" });
|
||||
return StatusCode(502, new { Error = $"HC900 쓰기 실패: {error}" });
|
||||
|
||||
return Ok(new { success = true, streamKey, nodeId = spTag, value = spVal, previousSp = prevSp });
|
||||
return Ok(new { Success = true, StreamKey = streamKey, NodeId = spTag, Value = spVal, PreviousSp = prevSp });
|
||||
}
|
||||
|
||||
// ── 측류 추종 ON/OFF/원복 ───────────────────────────────────────
|
||||
@@ -154,12 +154,12 @@ public sealed class FeedforwardController : ControllerBase
|
||||
public async Task<IActionResult> TrackOn(int columnId, string streamKey, CancellationToken ct)
|
||||
{
|
||||
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
||||
if (cfg is null) return NotFound(new { error = "config 없음" });
|
||||
if (cfg is null) return NotFound(new { Error = "config 없음" });
|
||||
var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
|
||||
if (sc is null) return NotFound(new { error = "stream 없음" });
|
||||
if (sc.Role == StreamRole.Monitor) return BadRequest(new { error = "Monitor 스트림은 추종 대상 아님" });
|
||||
if (sc is null) return NotFound(new { Error = "stream 없음" });
|
||||
if (sc.Role == StreamRole.Monitor) return BadRequest(new { Error = "Monitor 스트림은 추종 대상 아님" });
|
||||
var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
|
||||
if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { error = "flow 태그 없음 — SP 대상 산출 불가" });
|
||||
if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { Error = "flow 태그 없음 — SP 대상 산출 불가" });
|
||||
|
||||
_tracking.Set(new FfTrackingState
|
||||
{
|
||||
@@ -168,7 +168,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
});
|
||||
await _audit.LogAsync(new FfActionLogEntry(columnId, "track_on",
|
||||
StreamKey: streamKey, NodeId: spTag, Result: "started", OperatorName: "manual"), ct);
|
||||
return Ok(new { success = true, streamKey });
|
||||
return Ok(new { Success = true, StreamKey = streamKey });
|
||||
}
|
||||
|
||||
// OFF(=취소): 추종 중지. 컨트롤러는 마지막 값 유지.
|
||||
@@ -183,7 +183,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
});
|
||||
await _audit.LogAsync(new FfActionLogEntry(columnId, "track_off",
|
||||
StreamKey: streamKey, Result: "stopped", OperatorName: "manual"), ct);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
// ── 모듈1 shadow 검증: 최근 shadow 데이터 조회 ──
|
||||
@@ -191,21 +191,21 @@ public sealed class FeedforwardController : ControllerBase
|
||||
public IActionResult GetShadow(int columnId, [FromQuery] int count = 100)
|
||||
{
|
||||
var entries = _shadow.GetRecent(columnId, count);
|
||||
return Ok(new { columnId, count = entries.Count, entries });
|
||||
return Ok(new { ColumnId = columnId, Count = entries.Count, Entries = entries });
|
||||
}
|
||||
|
||||
[HttpPost("shadow/{columnId:int}/clear")]
|
||||
public IActionResult ClearShadow(int columnId)
|
||||
{
|
||||
_shadow.Clear(columnId);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
// ── Phase II: 감사 로그 조회 ──
|
||||
[HttpGet("audit")]
|
||||
public async Task<IActionResult> GetAudit([FromQuery] int? columnId, [FromQuery] int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
||||
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
|
||||
var rows = await _audit.QueryAsync(columnId, limit, ct);
|
||||
return Ok(new { rows });
|
||||
}
|
||||
@@ -257,7 +257,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
[FromQuery] double n = 1.8, CancellationToken ct = default)
|
||||
{
|
||||
var a = await _ramp.ComputeAsync(columnId, targetFeed, deltaIAllow, sensibleGain, feedTempRef, floodLimit, n, ct);
|
||||
if (a is null) return NotFound(new { error = "config 없음" });
|
||||
if (a is null) return NotFound(new { Error = "config 없음" });
|
||||
return Ok(MapRamp(a));
|
||||
}
|
||||
|
||||
@@ -268,17 +268,17 @@ public sealed class FeedforwardController : ControllerBase
|
||||
public async Task<IActionResult> StartFeedRamp(int columnId, [FromBody] FeedRampStartBody body, CancellationToken ct)
|
||||
{
|
||||
if (body is null || double.IsNaN(body.targetFeed) || double.IsInfinity(body.targetFeed))
|
||||
return BadRequest(new { error = "targetFeed 값 필요" });
|
||||
return BadRequest(new { Error = "targetFeed 값 필요" });
|
||||
|
||||
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
||||
if (cfg is null) return NotFound(new { error = "config 없음" });
|
||||
if (cfg is null) return NotFound(new { Error = "config 없음" });
|
||||
if (string.IsNullOrWhiteSpace(FfSpTag.Resolve(cfg.FeedTag, cfg.FeedSpNodeId)))
|
||||
return BadRequest(new { error = "Feed 태그 없음 — FEED SP 대상 산출 불가" });
|
||||
return BadRequest(new { Error = "Feed 태그 없음 — FEED SP 대상 산출 불가" });
|
||||
|
||||
// 현재 피드 확인 + 업램프만 허용
|
||||
var adv = await _ramp.ComputeAsync(columnId, body.targetFeed, 50, double.NaN, double.NaN, double.NaN, 1.8, ct);
|
||||
if (adv is null) return NotFound(new { error = "config 없음" });
|
||||
if (adv.Hold) return BadRequest(new { error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" });
|
||||
if (adv is null) return NotFound(new { Error = "config 없음" });
|
||||
if (adv.Hold) return BadRequest(new { Error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" });
|
||||
|
||||
bool dryRun = RampDryRun() || _sim.Enabled;
|
||||
var job = _rampJobs.Start(columnId, body.targetFeed, "manual", dryRun);
|
||||
@@ -287,7 +287,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
SpValue: body.targetFeed, NodeId: cfg.FeedSpNodeId,
|
||||
Result: dryRun ? "started(dry-run)" : "started", OperatorName: "manual"), ct);
|
||||
|
||||
return Ok(new { success = true, dryRun, job = MapRampJob(job) });
|
||||
return Ok(new { Success = true, DryRun = dryRun, Job = MapRampJob(job) });
|
||||
}
|
||||
|
||||
[HttpPost("feed-ramp/{columnId:int}/cancel")]
|
||||
@@ -297,19 +297,19 @@ public sealed class FeedforwardController : ControllerBase
|
||||
if (ok)
|
||||
await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_cancel",
|
||||
Result: "canceled", OperatorName: "manual"), ct);
|
||||
return Ok(new { success = ok });
|
||||
return Ok(new { Success = ok });
|
||||
}
|
||||
|
||||
[HttpGet("feed-ramp/{columnId:int}")]
|
||||
public IActionResult GetFeedRamp(int columnId)
|
||||
{
|
||||
var job = _rampJobs.Get(columnId);
|
||||
return Ok(new { dryRun = RampDryRun() || _sim.Enabled, job = job is null ? null : MapRampJob(job) });
|
||||
return Ok(new { DryRun = RampDryRun() || _sim.Enabled, Job = job is null ? null : MapRampJob(job) });
|
||||
}
|
||||
|
||||
[HttpGet("feed-ramp")]
|
||||
public IActionResult GetAllFeedRamp()
|
||||
=> Ok(new { dryRun = RampDryRun() || _sim.Enabled, jobs = _rampJobs.GetAll().Select(MapRampJob) });
|
||||
=> Ok(new { DryRun = RampDryRun() || _sim.Enabled, Jobs = _rampJobs.GetAll().Select(MapRampJob) });
|
||||
|
||||
private static object MapRampJob(FeedRampJob j) => new
|
||||
{
|
||||
@@ -333,57 +333,57 @@ public sealed class FeedforwardController : ControllerBase
|
||||
[HttpGet("sim/override")]
|
||||
public IActionResult GetSimOverride()
|
||||
{
|
||||
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성(Feedforward:SimOverrideEnabled=false)" });
|
||||
return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
|
||||
if (!SimEnabled()) return StatusCode(403, new { Error = "SimOverride 비활성(Feedforward:SimOverrideEnabled=false)" });
|
||||
return Ok(new { Enabled = _sim.Enabled, Values = _sim.Snapshot() });
|
||||
}
|
||||
|
||||
[HttpPost("sim/override")]
|
||||
public IActionResult SetSimOverride([FromBody] SimOverrideBody body)
|
||||
{
|
||||
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
|
||||
if (!SimEnabled()) return StatusCode(403, new { Error = "SimOverride 비활성" });
|
||||
_sim.SetMany(body.enabled, body.values ?? new Dictionary<string, double>());
|
||||
return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
|
||||
return Ok(new { Enabled = _sim.Enabled, Values = _sim.Snapshot() });
|
||||
}
|
||||
|
||||
[HttpDelete("sim/override")]
|
||||
public IActionResult ClearSimOverride()
|
||||
{
|
||||
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
|
||||
if (!SimEnabled()) return StatusCode(403, new { Error = "SimOverride 비활성" });
|
||||
_sim.Clear();
|
||||
return Ok(new { enabled = _sim.Enabled });
|
||||
return Ok(new { Enabled = _sim.Enabled });
|
||||
}
|
||||
|
||||
// ── WP5 3단계: 조성 분율 수동입력(랩) ──
|
||||
[HttpGet("composition")]
|
||||
public IActionResult GetComposition() => Ok(new { fractions = _composition.Snapshot() });
|
||||
public IActionResult GetComposition() => Ok(new { Fractions = _composition.Snapshot() });
|
||||
|
||||
[HttpPost("composition")]
|
||||
public IActionResult SetComposition([FromBody] CompositionBody b)
|
||||
{
|
||||
if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { error = "streamKey 필요" });
|
||||
if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { Error = "streamKey 필요" });
|
||||
_composition.Set(b.columnId, b.streamKey, b.fraction);
|
||||
return Ok(new { fractions = _composition.Snapshot() });
|
||||
return Ok(new { Fractions = _composition.Snapshot() });
|
||||
}
|
||||
|
||||
[HttpDelete("composition/{columnId:int}/{streamKey}")]
|
||||
public IActionResult ClearComposition(int columnId, string streamKey)
|
||||
{ _composition.Clear(columnId, streamKey); return Ok(new { fractions = _composition.Snapshot() }); }
|
||||
{ _composition.Clear(columnId, streamKey); return Ok(new { Fractions = _composition.Snapshot() }); }
|
||||
|
||||
private static double? Fin(double v) => (double.IsNaN(v) || double.IsInfinity(v)) ? (double?)null : v;
|
||||
private static object MapRamp(FeedRampAdvisory a) => new
|
||||
{
|
||||
columnId = a.ColumnId,
|
||||
currentFeed = Fin(a.CurrentFeed), targetFeed = Fin(a.TargetFeed), clampedTarget = Fin(a.ClampedTarget),
|
||||
ceiling = new { value = Fin(a.Ceiling.Value), binding = a.Ceiling.Binding },
|
||||
rampRate = new { value = Fin(a.RampRate.Value), binding = a.RampRate.Binding },
|
||||
ceiling = new { Value = Fin(a.Ceiling.Value), Binding = a.Ceiling.Binding },
|
||||
rampRate = new { Value = Fin(a.RampRate.Value), Binding = a.RampRate.Binding },
|
||||
rampTimeMin = Fin(a.RampTimeMin),
|
||||
steam = new { fiq6115From = a.Steam.Fiq6115From, fiq6115To = a.Steam.Fiq6115To, startOpPct = a.Steam.StartOpPct },
|
||||
steam = new { Fiq6115From = a.Steam.Fiq6115From, Fiq6115To = a.Steam.Fiq6115To, StartOpPct = a.Steam.StartOpPct },
|
||||
simOverrideActive = a.SimOverrideActive, hold = a.Hold, warnings = a.Warnings
|
||||
};
|
||||
|
||||
// ── Advisory (공개 읽기) ───────────────────────────────────────
|
||||
[HttpGet("advisory")]
|
||||
public IActionResult GetAll() => Ok(new { columns = _store.GetAll().Select(MapColumn) });
|
||||
public IActionResult GetAll() => Ok(new { Columns = _store.GetAll().Select(MapColumn) });
|
||||
|
||||
[HttpGet("advisory/{columnId:int}")]
|
||||
public IActionResult Get(int columnId)
|
||||
|
||||
@@ -46,7 +46,7 @@ public class GatewayController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Ok(new { status = "NOT_SERVING", error = ex.Message });
|
||||
return Ok(new { Status = "NOT_SERVING", Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ public class GatewayController : ControllerBase
|
||||
public async Task<IActionResult> Write([FromBody] WriteTagDto dto)
|
||||
{
|
||||
var (success, error) = await _write.WriteTagAsync(dto.ControllerId, dto.TagName, dto.Value);
|
||||
return Ok(new { success, error });
|
||||
return Ok(new { Success = success, Error = error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ public class Hc900TagManagerController : ControllerBase
|
||||
if (entry == null) return NotFound();
|
||||
entry.IsActive = active;
|
||||
await _ctx.SaveChangesAsync();
|
||||
return Ok(new { entry.Id, entry.TagName, entry.IsActive });
|
||||
return Ok(new { Id = entry.Id, TagName = entry.TagName, IsActive = entry.IsActive });
|
||||
}
|
||||
|
||||
[HttpPost("bulk-active")]
|
||||
@@ -318,7 +318,7 @@ public class Hc900TagManagerController : ControllerBase
|
||||
if (!string.IsNullOrEmpty(dto.ControllerId)) q = q.Where(x => x.ControllerId == dto.ControllerId);
|
||||
|
||||
var count = await q.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, dto.Active));
|
||||
return Ok(new { updated = count, active = dto.Active });
|
||||
return Ok(new { Updated = count, Active = dto.Active });
|
||||
}
|
||||
|
||||
[HttpGet("param-types")]
|
||||
@@ -370,22 +370,22 @@ public class Hc900TagManagerController : ControllerBase
|
||||
var byType = await _ctx.Hc900MapEntries
|
||||
.Where(x => x.ControllerId == controllerId)
|
||||
.GroupBy(x => x.ParamType)
|
||||
.Select(g => new { paramType = g.Key, total = g.Count(), active = g.Count(x => x.IsActive) })
|
||||
.OrderBy(x => x.paramType)
|
||||
.Select(g => new { ParamType = g.Key, Total = g.Count(), Active = g.Count(x => x.IsActive) })
|
||||
.OrderBy(x => x.ParamType)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { controllerId, catalog, assigned, live, config, unassigned, byType });
|
||||
return Ok(new { ControllerId = controllerId, Catalog = catalog, Assigned = assigned, Live = live, Config = config, Unassigned = unassigned, ByType = byType });
|
||||
}
|
||||
|
||||
var total = await _ctx.Hc900MapEntries.CountAsync();
|
||||
var active = await _ctx.Hc900MapEntries.CountAsync(x => x.IsActive);
|
||||
var byTypeAll = await _ctx.Hc900MapEntries
|
||||
.GroupBy(x => x.ParamType)
|
||||
.Select(g => new { paramType = g.Key, total = g.Count(), active = g.Count(x => x.IsActive) })
|
||||
.OrderBy(x => x.paramType)
|
||||
.Select(g => new { ParamType = g.Key, Total = g.Count(), Active = g.Count(x => x.IsActive) })
|
||||
.OrderBy(x => x.ParamType)
|
||||
.ToListAsync();
|
||||
var liveCount = await _ctx.RealtimePoints.CountAsync();
|
||||
return Ok(new { total, active, inactive = total - active, liveCount, byType = byTypeAll });
|
||||
return Ok(new { Total = total, Active = active, Inactive = total - active, LiveCount = liveCount, ByType = byTypeAll });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +419,7 @@ public class SubAreaController : ControllerBase
|
||||
public async Task<IActionResult> Update(string baseTag, [FromBody] string? subArea)
|
||||
{
|
||||
var ok = await _db.UpdateSubAreaAsync(baseTag, subArea);
|
||||
return Ok(new { success = ok });
|
||||
return Ok(new { Success = ok });
|
||||
}
|
||||
|
||||
[HttpPost("seed")]
|
||||
|
||||
@@ -43,7 +43,7 @@ public class HypertableController : ControllerBase
|
||||
CreateContinuousAggregate = dto.CreateContinuousAggregate
|
||||
};
|
||||
var result = await _db.CreateHypertableAsync(request);
|
||||
return Ok(new { success = result.Success, message = result.Message });
|
||||
return Ok(new { Success = result.Success, Message = result.Message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,14 @@ public class KbAuthController : ControllerBase
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct)
|
||||
{
|
||||
if (req == null || string.IsNullOrWhiteSpace(req.Password))
|
||||
return Ok(new { success = false, error = "password is required" });
|
||||
return Ok(new { Success = false, Error = "password is required" });
|
||||
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var result = await _auth.LoginAsync(req.Password, ip, ct);
|
||||
if (!result.Success)
|
||||
return Ok(new { success = false, error = result.Error ?? "login failed" });
|
||||
return Ok(new { Success = false, Error = result.Error ?? "login failed" });
|
||||
|
||||
return Ok(new { success = true, token = result.Token, expiresAt = result.ExpiresAt });
|
||||
return Ok(new { Success = true, Token = result.Token, ExpiresAt = result.ExpiresAt });
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
@@ -39,7 +39,7 @@ public class KbAuthController : ControllerBase
|
||||
var token = Request.Headers["X-Kb-Token"].ToString();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
await _auth.LogoutAsync(token, ct);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -55,14 +55,14 @@ public class KbAuthController : ControllerBase
|
||||
{
|
||||
var token = Request.Headers["X-Kb-Token"].ToString();
|
||||
if (!await _auth.ValidateAsync(token, ct))
|
||||
return Unauthorized(new { success = false, error = "invalid token" });
|
||||
return Unauthorized(new { Success = false, Error = "invalid token" });
|
||||
|
||||
if (req == null || string.IsNullOrWhiteSpace(req.OldPassword) || string.IsNullOrWhiteSpace(req.NewPassword))
|
||||
return Ok(new { success = false, error = "passwords required" });
|
||||
return Ok(new { Success = false, Error = "passwords required" });
|
||||
if (req.NewPassword.Length < 6)
|
||||
return Ok(new { success = false, error = "new password must be at least 6 chars" });
|
||||
return Ok(new { Success = false, Error = "new password must be at least 6 chars" });
|
||||
|
||||
var ok = await _auth.ChangePasswordAsync(req.OldPassword, req.NewPassword, ct);
|
||||
return Ok(new { success = ok });
|
||||
return Ok(new { Success = ok });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ public class KbController : ControllerBase
|
||||
.OrderBy(c => c.CollectionKey)
|
||||
.Select(c => new
|
||||
{
|
||||
key = c.CollectionKey,
|
||||
name = c.DisplayName,
|
||||
qdrant = c.QdrantName,
|
||||
description = c.Description
|
||||
Key = c.CollectionKey,
|
||||
Name = c.DisplayName,
|
||||
Qdrant = c.QdrantName,
|
||||
Description = c.Description
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -64,10 +64,10 @@ public class KbController : ControllerBase
|
||||
var byKey = docCounts.ToDictionary(x => x.Key, x => (x.Count, x.Chunks));
|
||||
var result = items.Select(c =>
|
||||
{
|
||||
byKey.TryGetValue(c.key, out var counts);
|
||||
return new { c.key, c.name, c.qdrant, c.description, docCount = counts.Count, chunkCount = counts.Chunks };
|
||||
byKey.TryGetValue(c.Key, out var counts);
|
||||
return new { c.Key, c.Name, c.Qdrant, c.Description, DocCount = counts.Count, ChunkCount = counts.Chunks };
|
||||
});
|
||||
return Ok(new { success = true, items = result });
|
||||
return Ok(new { Success = true, Items = result });
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
@@ -80,15 +80,15 @@ public class KbController : ControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { success = false, error = "file required" });
|
||||
return BadRequest(new { Success = false, Error = "file required" });
|
||||
if (string.IsNullOrWhiteSpace(collectionKey))
|
||||
return BadRequest(new { success = false, error = "collectionKey required" });
|
||||
return BadRequest(new { Success = false, Error = "collectionKey required" });
|
||||
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == collectionKey, ct);
|
||||
if (coll == null) return BadRequest(new { success = false, error = "unknown collectionKey" });
|
||||
if (coll == null) return BadRequest(new { Success = false, Error = "unknown collectionKey" });
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var stored = await _storage.SaveAsync(stream, file.FileName, ct);
|
||||
@@ -123,7 +123,7 @@ public class KbController : ControllerBase
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("[Kb] 업로드 {Id} {Title} ({Size} bytes)", doc.Id, doc.Title, doc.FileSize);
|
||||
return Ok(new { success = true, docId = doc.Id, status = doc.Status });
|
||||
return Ok(new { Success = true, DocId = doc.Id, Status = doc.Status });
|
||||
}
|
||||
|
||||
[HttpGet("documents")]
|
||||
@@ -154,45 +154,45 @@ public class KbController : ControllerBase
|
||||
.Take(pageSize)
|
||||
.Select(d => new
|
||||
{
|
||||
id = d.Id,
|
||||
title = d.Title,
|
||||
collection = d.CollectionKey,
|
||||
tags = d.Tags,
|
||||
status = d.Status,
|
||||
chunkCount = d.ChunkCount,
|
||||
fileSize = d.FileSize,
|
||||
uploadedAt = d.UploadedAt,
|
||||
indexedAt = d.IndexedAt,
|
||||
errorMessage = d.ErrorMessage
|
||||
Id = d.Id,
|
||||
Title = d.Title,
|
||||
Collection = d.CollectionKey,
|
||||
Tags = d.Tags,
|
||||
Status = d.Status,
|
||||
ChunkCount = d.ChunkCount,
|
||||
FileSize = d.FileSize,
|
||||
UploadedAt = d.UploadedAt,
|
||||
IndexedAt = d.IndexedAt,
|
||||
ErrorMessage = d.ErrorMessage
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return Ok(new { success = true, total, page, pageSize, items });
|
||||
return Ok(new { Success = true, Total = total, Page = page, PageSize = pageSize, Items = items });
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id:guid}")]
|
||||
public async Task<IActionResult> GetDocument(Guid id, CancellationToken ct)
|
||||
{
|
||||
var d = await _db.KbDocuments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
if (d == null) return NotFound(new { Success = false });
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
item = new
|
||||
Success = true,
|
||||
Item = new
|
||||
{
|
||||
id = d.Id,
|
||||
title = d.Title,
|
||||
collection = d.CollectionKey,
|
||||
tags = d.Tags,
|
||||
status = d.Status,
|
||||
chunkCount = d.ChunkCount,
|
||||
fileSize = d.FileSize,
|
||||
mimeType = d.MimeType,
|
||||
uploadedAt = d.UploadedAt,
|
||||
indexedAt = d.IndexedAt,
|
||||
disabledAt = d.DisabledAt,
|
||||
originalPath = d.OriginalPath,
|
||||
fileSha256 = d.FileSha256,
|
||||
errorMessage = d.ErrorMessage
|
||||
Id = d.Id,
|
||||
Title = d.Title,
|
||||
Collection = d.CollectionKey,
|
||||
Tags = d.Tags,
|
||||
Status = d.Status,
|
||||
ChunkCount = d.ChunkCount,
|
||||
FileSize = d.FileSize,
|
||||
MimeType = d.MimeType,
|
||||
UploadedAt = d.UploadedAt,
|
||||
IndexedAt = d.IndexedAt,
|
||||
DisabledAt = d.DisabledAt,
|
||||
OriginalPath = d.OriginalPath,
|
||||
FileSha256 = d.FileSha256,
|
||||
ErrorMessage = d.ErrorMessage
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -201,14 +201,14 @@ public class KbController : ControllerBase
|
||||
public async Task<IActionResult> GetChunks(Guid id, [FromQuery] int limit = 200, CancellationToken ct = default)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
if (d == null) return NotFound(new { Success = false });
|
||||
|
||||
var coll = await _db.KbCollections.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
|
||||
if (coll == null) return BadRequest(new { success = false, error = "collection not found" });
|
||||
if (coll == null) return BadRequest(new { Success = false, Error = "collection not found" });
|
||||
|
||||
limit = Math.Clamp(limit, 1, 500);
|
||||
var rows = await _qdrant.GetChunksByDocIdAsync(coll.QdrantName, d.Id, limit, ct);
|
||||
@@ -231,11 +231,11 @@ public class KbController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
docId = d.Id,
|
||||
collection = d.CollectionKey,
|
||||
count = chunks.Count,
|
||||
chunks
|
||||
Success = true,
|
||||
DocId = d.Id,
|
||||
Collection = d.CollectionKey,
|
||||
Count = chunks.Count,
|
||||
Chunks = chunks
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ public class KbController : ControllerBase
|
||||
startedAt = j.StartedAt,
|
||||
finishedAt = j.FinishedAt
|
||||
}).ToListAsync(ct);
|
||||
return Ok(new { success = true, items });
|
||||
return Ok(new { Success = true, Items = items });
|
||||
}
|
||||
|
||||
[HttpGet("download/{id:guid}")]
|
||||
@@ -283,7 +283,7 @@ public class KbController : ControllerBase
|
||||
public IActionResult DownloadInstrumentsDraft([FromQuery] string path)
|
||||
{
|
||||
var abs = _storage.Resolve(path);
|
||||
if (!System.IO.File.Exists(abs)) return NotFound(new { success = false, error = "file not found" });
|
||||
if (!System.IO.File.Exists(abs)) return NotFound(new { Success = false, Error = "file not found" });
|
||||
|
||||
var stream = new FileStream(abs, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, true);
|
||||
var fileName = Path.GetFileName(abs);
|
||||
@@ -294,10 +294,10 @@ public class KbController : ControllerBase
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
if (d == null) return NotFound(new { Success = false });
|
||||
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
|
||||
if (coll != null)
|
||||
@@ -307,17 +307,17 @@ public class KbController : ControllerBase
|
||||
|
||||
_db.KbDocuments.Remove(d);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpPost("documents/{id:guid}/reindex")]
|
||||
public async Task<IActionResult> Reindex(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
if (d == null) return NotFound(new { Success = false });
|
||||
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
|
||||
if (coll != null)
|
||||
@@ -330,21 +330,21 @@ public class KbController : ControllerBase
|
||||
|
||||
_db.KbIngestJobs.Add(new KbIngestJob { DocId = d.Id, Stage = "parse" });
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpPost("documents/{id:guid}/disable")]
|
||||
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
if (d == null) return NotFound(new { Success = false });
|
||||
d.Status = "disabled";
|
||||
d.DisabledAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
public sealed record BulkDisableRequest(string Title);
|
||||
@@ -353,16 +353,16 @@ public class KbController : ControllerBase
|
||||
public async Task<IActionResult> BulkDisable([FromBody] BulkDisableRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
if (req == null || string.IsNullOrWhiteSpace(req.Title))
|
||||
return BadRequest(new { success = false, error = "title required" });
|
||||
return BadRequest(new { Success = false, Error = "title required" });
|
||||
|
||||
var rows = await _db.KbDocuments
|
||||
.Where(d => d.Title == req.Title && d.Status != "disabled")
|
||||
.ExecuteUpdateAsync(set => set
|
||||
.SetProperty(x => x.Status, _ => "disabled")
|
||||
.SetProperty(x => x.DisabledAt, _ => DateTime.UtcNow), ct);
|
||||
return Ok(new { success = true, affected = rows });
|
||||
return Ok(new { Success = true, Affected = rows });
|
||||
}
|
||||
|
||||
public sealed record PurgeRequest(int? OlderThanDays);
|
||||
@@ -371,7 +371,7 @@ public class KbController : ControllerBase
|
||||
public async Task<IActionResult> PurgeDisabled([FromBody] PurgeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
var cutoff = req?.OlderThanDays is int days && days > 0
|
||||
? DateTime.UtcNow.AddDays(-days)
|
||||
@@ -389,7 +389,7 @@ public class KbController : ControllerBase
|
||||
}
|
||||
_db.KbDocuments.RemoveRange(victims);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true, deleted = victims.Count });
|
||||
return Ok(new { Success = true, Deleted = victims.Count });
|
||||
}
|
||||
|
||||
public sealed record InferInstrumentsRequest(bool UseLlm = false, string? SeedDocId = null);
|
||||
@@ -398,7 +398,7 @@ public class KbController : ControllerBase
|
||||
public async Task<IActionResult> InferInstruments([FromBody] InferInstrumentsRequest? req, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
return Unauthorized(new { Success = false, Error = "unauthorized" });
|
||||
|
||||
try
|
||||
{
|
||||
@@ -426,7 +426,7 @@ public class KbController : ControllerBase
|
||||
|
||||
var raw = await _mcp.CallToolAsync("infer_field_instruments", args, ct);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return BadRequest(new { success = false, error = "MCP 응답 없음" });
|
||||
return BadRequest(new { Success = false, Error = "MCP 응답 없음" });
|
||||
|
||||
// [H-2 수정] McpClient.CallToolAsync는 실패 시 평문 문자열 반환
|
||||
try
|
||||
@@ -435,7 +435,7 @@ public class KbController : ControllerBase
|
||||
if (jdoc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.False)
|
||||
{
|
||||
var err = jdoc.RootElement.TryGetProperty("message", out var e) ? e.GetString() : "unknown";
|
||||
return BadRequest(new { success = false, error = err });
|
||||
return BadRequest(new { Success = false, Error = err });
|
||||
}
|
||||
|
||||
var docPath = jdoc.RootElement.GetProperty("doc_path").GetString() ?? "";
|
||||
@@ -457,25 +457,25 @@ public class KbController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
downloadPath = stored.RelativePath,
|
||||
instrumentCount,
|
||||
powerEquipmentCount,
|
||||
unmatchedCount,
|
||||
message = jdoc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : ""
|
||||
Success = true,
|
||||
DownloadPath = stored.RelativePath,
|
||||
InstrumentCount = instrumentCount,
|
||||
PowerEquipmentCount = powerEquipmentCount,
|
||||
UnmatchedCount = unmatchedCount,
|
||||
Message = jdoc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : ""
|
||||
});
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// [H-2 수정] 평문 응답("도구 호출 실패: ...")일 경우
|
||||
_logger.LogWarning("[Kb][Infer] MCP 평문 응답: {Raw}", raw);
|
||||
return BadRequest(new { success = false, error = "MCP 도구 실패: " + raw.TrimEnd('\r', '\n', ' ') });
|
||||
return BadRequest(new { Success = false, Error = "MCP 도구 실패: " + raw.TrimEnd('\r', '\n', ' ') });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Kb][Infer] 실패");
|
||||
return BadRequest(new { success = false, error = ex.Message });
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
@@ -287,7 +288,7 @@ public class OllamaController : ControllerBase
|
||||
var cfg = LoadConfig();
|
||||
var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags");
|
||||
if (!res.IsSuccessStatusCode)
|
||||
return Ok(new { success = false, error = $"Ollama HTTP {(int)res.StatusCode}", models = Array.Empty<string>() });
|
||||
return Ok(new { Success = false, Error = $"Ollama HTTP {(int)res.StatusCode}", Models = Array.Empty<string>() });
|
||||
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -328,16 +329,16 @@ public class OllamaController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
models = chatModels.OrderBy(x => x).ToArray(),
|
||||
Success = true,
|
||||
Models = chatModels.OrderBy(x => x).ToArray(),
|
||||
// 진단/안내용 — UI에서 셀렉터 옆에 회색 안내로 노출 가능
|
||||
excluded = embeddingModels.OrderBy(x => x).ToArray()
|
||||
Excluded = embeddingModels.OrderBy(x => x).ToArray()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OllamaController] 모델 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message, models = Array.Empty<string>() });
|
||||
return Ok(new { Success = false, Error = ex.Message, Models = Array.Empty<string>() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +390,7 @@ public class OllamaController : ControllerBase
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync();
|
||||
return Ok(new { success = false, error = err });
|
||||
return Ok(new { Success = false, Error = err });
|
||||
}
|
||||
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
@@ -400,12 +401,12 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
reply = cnt.GetString();
|
||||
}
|
||||
return Ok(new { success = true, reply });
|
||||
return Ok(new { Success = true, Reply = reply });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OllamaController] 채팅 실패");
|
||||
return Ok(new { success = false, error = ex.Message, reply = (string?)null });
|
||||
return Ok(new { Success = false, Error = ex.Message, Reply = (string?)null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +490,7 @@ public class OllamaController : ControllerBase
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
var cfg = LoadConfig();
|
||||
return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl, vllmModel = LoadVllmModel() });
|
||||
return Ok(new { Success = true, Host = cfg.Host, Port = cfg.Port, BaseUrl = cfg.BaseUrl, VllmModel = LoadVllmModel() });
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
@@ -509,12 +510,12 @@ public class OllamaController : ControllerBase
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
System.IO.File.WriteAllText(path, json);
|
||||
_logger.LogInformation("[OllamaController] 설정 저장: {Host}:{Port}", cfg.Host, cfg.Port);
|
||||
return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl });
|
||||
return Ok(new { Success = true, Host = cfg.Host, Port = cfg.Port, BaseUrl = cfg.BaseUrl });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OllamaController] 설정 저장 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,11 +526,11 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
var cfg = LoadConfig();
|
||||
var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags");
|
||||
return Ok(new { success = res.IsSuccessStatusCode, host = cfg.Host, port = cfg.Port });
|
||||
return Ok(new { Success = res.IsSuccessStatusCode, Host = cfg.Host, Port = cfg.Port });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,7 +573,7 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
var res = await _vllmClient.GetAsync("/v1/models");
|
||||
if (!res.IsSuccessStatusCode)
|
||||
return Ok(new { success = false, error = $"vLLM HTTP {(int)res.StatusCode}", models = Array.Empty<string>() });
|
||||
return Ok(new { Success = false, Error = $"vLLM HTTP {(int)res.StatusCode}", Models = Array.Empty<string>() });
|
||||
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -585,12 +586,12 @@ public class OllamaController : ControllerBase
|
||||
models.Add(name.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
return Ok(new { success = true, models = models.OrderBy(x => x).ToArray() });
|
||||
return Ok(new { Success = true, Models = models.OrderBy(x => x).ToArray() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OllamaController] vLLM 모델 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message, models = Array.Empty<string>() });
|
||||
return Ok(new { Success = false, Error = ex.Message, Models = Array.Empty<string>() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,7 +620,7 @@ public class OllamaController : ControllerBase
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync();
|
||||
return Ok(new { success = false, error = err });
|
||||
return Ok(new { Success = false, Error = err });
|
||||
}
|
||||
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
@@ -631,12 +632,12 @@ public class OllamaController : ControllerBase
|
||||
if (first.TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var cnt))
|
||||
reply = cnt.GetString();
|
||||
}
|
||||
return Ok(new { success = true, reply });
|
||||
return Ok(new { Success = true, Reply = reply });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OllamaController] vLLM 채팅 실패");
|
||||
return Ok(new { success = false, error = ex.Message, reply = (string?)null });
|
||||
return Ok(new { Success = false, Error = ex.Message, Reply = (string?)null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,7 +648,7 @@ public class OllamaController : ControllerBase
|
||||
try
|
||||
{
|
||||
if (req?.Messages == null || req.Messages.Length == 0)
|
||||
return Ok(new { success = false, error = "messages required", summary = "" });
|
||||
return Ok(new { Success = false, Error = "messages required", Summary = "" });
|
||||
|
||||
// 대화를 평문으로 직렬화
|
||||
var convo = new StringBuilder();
|
||||
@@ -689,7 +690,7 @@ public class OllamaController : ControllerBase
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync();
|
||||
return Ok(new { success = false, error = err, summary = "" });
|
||||
return Ok(new { Success = false, Error = err, Summary = "" });
|
||||
}
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -700,12 +701,12 @@ public class OllamaController : ControllerBase
|
||||
if (first.TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var cnt))
|
||||
summary = cnt.GetString() ?? "";
|
||||
}
|
||||
return Ok(new { success = true, summary = summary.Trim() });
|
||||
return Ok(new { Success = true, Summary = summary.Trim() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OllamaController] 대화 요약 실패");
|
||||
return Ok(new { success = false, error = ex.Message, summary = "" });
|
||||
return Ok(new { Success = false, Error = ex.Message, Summary = "" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,6 +1088,49 @@ public class OllamaController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// Python 스타일 도구 호출 감지: tool_name(key="val", ...) — JSON 감지 실패 후 시도
|
||||
if (!string.IsNullOrEmpty(stopContent))
|
||||
{
|
||||
var knownTools = req.Tools?.Select(t => t.Function.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pythonCalls = ExtractPythonStyleToolCalls(stopContent, knownTools);
|
||||
if (pythonCalls.Count > 0)
|
||||
{
|
||||
bool pyExecuted = false;
|
||||
var pyResults = new List<string>();
|
||||
|
||||
foreach (var (callName, callArgs) in pythonCalls)
|
||||
{
|
||||
var pseudoId = $"pytc_{toolRound}_{Guid.NewGuid():N}";
|
||||
var argsJson = JsonSerializer.Serialize(callArgs);
|
||||
await EmitToolStart(pseudoId, callName, argsJson);
|
||||
try
|
||||
{
|
||||
var toolResult = await _mcpClient.CallToolAsync(
|
||||
callName, callArgs, HttpContext.RequestAborted,
|
||||
onTimeoutExtended: info => EmitToolExtending(pseudoId, info.ToolName, info.SoftTimeoutSec, info.ExtendedTimeoutSec));
|
||||
await EmitToolResult(pseudoId, callName, ok: true, payload: toolResult);
|
||||
pyResults.Add($"[{callName} 결과]\n{toolResult}");
|
||||
pyExecuted = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EmitToolResult(pseudoId, callName, ok: false, payload: ex.Message);
|
||||
_logger.LogWarning(ex, "[OllamaController] Python형 도구 호출 실패: {Tool}", callName);
|
||||
}
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested) return;
|
||||
}
|
||||
|
||||
if (pyExecuted)
|
||||
{
|
||||
var combined = string.Join("\n\n", pyResults);
|
||||
messages.Add(new { role = "assistant", content = stopContent });
|
||||
messages.Add(new { role = "user", content = $"{combined}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 첫 번째 비스트리밍 응답의 content를 직접 전달 (두 번째 LLM 호출 없이)
|
||||
if (!string.IsNullOrEmpty(stopContent))
|
||||
{
|
||||
@@ -1170,14 +1214,57 @@ public class OllamaController : ControllerBase
|
||||
try
|
||||
{
|
||||
var res = await _vllmClient.GetAsync("/v1/models");
|
||||
return Ok(new { success = res.IsSuccessStatusCode, model = LoadVllmModel() });
|
||||
return Ok(new { Success = res.IsSuccessStatusCode, Model = LoadVllmModel() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// Python 스타일 도구 호출 감지: funcname(key="val", key2="val2", ...)
|
||||
// knownTools 에 있는 이름만 매칭 (SQL 함수 등 false positive 방지)
|
||||
private static List<(string name, Dictionary<string, object> args)> ExtractPythonStyleToolCalls(
|
||||
string content, HashSet<string> knownTools)
|
||||
{
|
||||
var result = new List<(string, Dictionary<string, object>)>();
|
||||
if (string.IsNullOrWhiteSpace(content) || knownTools.Count == 0) return result;
|
||||
|
||||
var matches = Regex.Matches(content.Trim(), @"(\w+)\s*\(([^)]*)\)");
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
var name = m.Groups[1].Value;
|
||||
if (!knownTools.Contains(name)) continue;
|
||||
var args = ParsePythonCallArgs(m.Groups[2].Value);
|
||||
if (args.Count > 0)
|
||||
result.Add((name, args));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ParsePythonCallArgs(string argsStr)
|
||||
{
|
||||
var args = new Dictionary<string, object>();
|
||||
if (string.IsNullOrWhiteSpace(argsStr)) return args;
|
||||
|
||||
// 키=값 쌍 추출: key="val" | key='val' | key=bareword
|
||||
foreach (Match m in Regex.Matches(argsStr, @"(\w+)\s*=\s*(?:""([^""]*)""|'([^']*)'|(\S+?))(?:\s*,|$)"))
|
||||
{
|
||||
var key = m.Groups[1].Value;
|
||||
var raw = m.Groups[2].Success ? m.Groups[2].Value
|
||||
: m.Groups[3].Success ? m.Groups[3].Value
|
||||
: m.Groups[4].Value.TrimEnd(',').Trim();
|
||||
// 타입 추론
|
||||
if (int.TryParse(raw, out var i)) args[key] = (object)i;
|
||||
else if (double.TryParse(raw, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d)) args[key] = d;
|
||||
else if (raw.Equals("true", StringComparison.OrdinalIgnoreCase)) args[key] = true;
|
||||
else if (raw.Equals("false", StringComparison.OrdinalIgnoreCase)) args[key] = false;
|
||||
else args[key] = raw;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞뒤 텍스트 무시, 모델 무관)
|
||||
private static string ExtractFirstJsonObject(string content)
|
||||
{
|
||||
|
||||
@@ -36,11 +36,11 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> UploadFile(IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { error = "파일이 없습니다." });
|
||||
return BadRequest(new { Error = "파일이 없습니다." });
|
||||
|
||||
var safeName = Path.GetFileName(file.FileName);
|
||||
if (!IsValidFileName(safeName))
|
||||
return BadRequest(new { error = "지원 형식: .dxf, .pdf" });
|
||||
return BadRequest(new { Error = "지원 형식: .dxf, .pdf" });
|
||||
|
||||
Directory.CreateDirectory(UploadDir);
|
||||
var filePath = Path.Combine(UploadDir, safeName);
|
||||
@@ -50,44 +50,44 @@ public class PidController : ControllerBase
|
||||
|
||||
_logger.LogInformation("[PID] 파일 저장 완료: {FileName} ({Bytes:N0} bytes)", safeName, file.Length);
|
||||
|
||||
return Ok(new { fileName = safeName, fileSize = file.Length });
|
||||
return Ok(new { FileName = safeName, FileSize = file.Length });
|
||||
}
|
||||
|
||||
[HttpGet("server-files")]
|
||||
public IActionResult GetServerFiles()
|
||||
{
|
||||
if (!Directory.Exists(UploadDir))
|
||||
return Ok(new { files = Array.Empty<object>() });
|
||||
return Ok(new { Files = Array.Empty<object>() });
|
||||
|
||||
var files = Directory.GetFiles(UploadDir)
|
||||
.Where(f => IsValidFileName(Path.GetFileName(f)))
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.LastWriteTimeUtc)
|
||||
.Select(f => new { fileName = f.Name, fileSize = f.Length, uploadedAt = f.LastWriteTimeUtc })
|
||||
.Select(f => new { FileName = f.Name, FileSize = f.Length, UploadedAt = f.LastWriteTimeUtc })
|
||||
.ToArray();
|
||||
|
||||
return Ok(new { files });
|
||||
return Ok(new { Files = files });
|
||||
}
|
||||
|
||||
[HttpPost("extract")]
|
||||
public async Task<IActionResult> Extract([FromBody] ExtractRequest request)
|
||||
{
|
||||
if (!IsValidFileName(request.FileName))
|
||||
return BadRequest(new { error = "파일명이 유효하지 않습니다." });
|
||||
return BadRequest(new { Error = "파일명이 유효하지 않습니다." });
|
||||
|
||||
var filePath = Path.Combine(UploadDir, request.FileName);
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
return NotFound(new { error = $"서버에 파일이 없습니다: {request.FileName}" });
|
||||
return NotFound(new { Error = $"서버에 파일이 없습니다: {request.FileName}" });
|
||||
|
||||
_logger.LogInformation("[PID] 추출 시작: {FileName}, imageMode={ImageMode}", request.FileName, request.UseImageMode);
|
||||
var result = await _pidExtractor.ExtractFromFileAsync(filePath, request.UseImageMode);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalCount = result.TotalCount,
|
||||
confidenceItems = result.ConfidenceItems,
|
||||
lowConfidenceItems = result.LowConfidenceItems,
|
||||
skippedDuplicates = result.SkippedDuplicates
|
||||
TotalCount = result.TotalCount,
|
||||
ConfidenceItems = result.ConfidenceItems,
|
||||
LowConfidenceItems = result.LowConfidenceItems,
|
||||
SkippedDuplicates = result.SkippedDuplicates
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,12 +98,12 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> AnalyzeConnections([FromBody] ConnectionsRequest request)
|
||||
{
|
||||
if (!IsValidFileName(request.FileName))
|
||||
return BadRequest(new { error = "파일명이 유효하지 않습니다." });
|
||||
return BadRequest(new { Error = "파일명이 유효하지 않습니다." });
|
||||
|
||||
_logger.LogInformation("[PID] 연결 분석 시작: {FileName}", request.FileName);
|
||||
var count = await _pidExtractor.AnalyzeConnectionsAsync(request.FileName);
|
||||
|
||||
return Ok(new { connectionCount = count });
|
||||
return Ok(new { ConnectionCount = count });
|
||||
}
|
||||
|
||||
[HttpGet("equipment")]
|
||||
@@ -114,39 +114,39 @@ public class PidController : ControllerBase
|
||||
|
||||
var equipmentDtos = itemList.Select(e => new
|
||||
{
|
||||
id = e.Id,
|
||||
tagName = e.TagNo,
|
||||
equipmentName = e.EquipmentName,
|
||||
instrumentType = e.InstrumentType,
|
||||
lineNumber = e.LineNumber,
|
||||
pidDrawingNo = e.PidDrawingNo,
|
||||
confidence = e.Confidence,
|
||||
isActive = e.IsActive,
|
||||
extractedAt = e.ExtractedAt,
|
||||
updatedAt = e.UpdatedAt,
|
||||
experionTagId = e.ExperionTagId,
|
||||
experionTagName = e.ExperionTag?.TagName,
|
||||
category = e.Category,
|
||||
tagClass = e.TagClass,
|
||||
tagDcs = e.TagDcs,
|
||||
role = e.Role,
|
||||
fromTag = e.FromTag,
|
||||
fromAt = e.FromAt,
|
||||
toTag = e.ToTag,
|
||||
toAt = e.ToAt,
|
||||
subArea = e.SubArea,
|
||||
posX = e.PosX,
|
||||
posY = e.PosY,
|
||||
drawingFile = e.DrawingFile
|
||||
Id = e.Id,
|
||||
TagNo = e.TagNo,
|
||||
EquipmentName = e.EquipmentName,
|
||||
InstrumentType = e.InstrumentType,
|
||||
LineNumber = e.LineNumber,
|
||||
PidDrawingNo = e.PidDrawingNo,
|
||||
Confidence = e.Confidence,
|
||||
IsActive = e.IsActive,
|
||||
ExtractedAt = e.ExtractedAt,
|
||||
UpdatedAt = e.UpdatedAt,
|
||||
ExperionTagId = e.ExperionTagId,
|
||||
ExperionTagName = e.ExperionTag?.TagName,
|
||||
Category = e.Category,
|
||||
TagClass = e.TagClass,
|
||||
TagDcs = e.TagDcs,
|
||||
Role = e.Role,
|
||||
FromTag = e.FromTag,
|
||||
FromAt = e.FromAt,
|
||||
ToTag = e.ToTag,
|
||||
ToAt = e.ToAt,
|
||||
SubArea = e.SubArea,
|
||||
PosX = e.PosX,
|
||||
PosY = e.PosY,
|
||||
DrawingFile = e.DrawingFile
|
||||
});
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
confidenceRate = itemList.Count > 0 ? itemList.Average(e => e.Confidence) : 0,
|
||||
items = equipmentDtos
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
ConfidenceRate = itemList.Count > 0 ? itemList.Average(e => e.Confidence) : 0,
|
||||
Items = equipmentDtos
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,15 +154,15 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> AutoMap(long id)
|
||||
{
|
||||
var (found, tagName) = await _pidExtractor.AutoMapTagAsync(id);
|
||||
if (!found) return NotFound(new { error = "매칭되는 Experion 태그가 없습니다." });
|
||||
return Ok(new { tagName });
|
||||
if (!found) return NotFound(new { Error = "매칭되는 Experion 태그가 없습니다." });
|
||||
return Ok(new { TagName = tagName });
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateEquipment(long id, [FromBody] UpdateEquipmentRequest request)
|
||||
{
|
||||
var ok = await _pidExtractor.UpdateEquipmentAsync(id, request);
|
||||
if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." });
|
||||
if (!ok) return NotFound(new { Error = "레코드를 찾을 수 없습니다." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -170,18 +170,18 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> CreateEquipment([FromBody] CreateEquipmentRequest request)
|
||||
{
|
||||
var e = await _pidExtractor.CreateEquipmentAsync(request);
|
||||
return CreatedAtAction(nameof(CreateEquipment), new { id = e.Id }, new
|
||||
return CreatedAtAction(nameof(CreateEquipment), new { Id = e.Id }, new
|
||||
{
|
||||
id = e.Id,
|
||||
tagName = e.TagNo,
|
||||
equipmentName = e.EquipmentName,
|
||||
instrumentType = e.InstrumentType,
|
||||
category = e.Category,
|
||||
tagDcs = e.TagDcs,
|
||||
tagClass = e.TagClass,
|
||||
role = e.Role,
|
||||
fromTag = e.FromTag,
|
||||
toTag = e.ToTag
|
||||
Id = e.Id,
|
||||
TagName = e.TagNo,
|
||||
EquipmentName = e.EquipmentName,
|
||||
InstrumentType = e.InstrumentType,
|
||||
Category = e.Category,
|
||||
TagDcs = e.TagDcs,
|
||||
TagClass = e.TagClass,
|
||||
Role = e.Role,
|
||||
FromTag = e.FromTag,
|
||||
ToTag = e.ToTag
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,12 +197,12 @@ public class PidController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
confidenceItems,
|
||||
lowConfidenceItems,
|
||||
confidenceRange,
|
||||
drawingCount,
|
||||
unmappedCount,
|
||||
mappedCount
|
||||
ConfidenceItems = confidenceItems,
|
||||
LowConfidenceItems = lowConfidenceItems,
|
||||
ConfidenceRange = confidenceRange,
|
||||
DrawingCount = drawingCount,
|
||||
UnmappedCount = unmappedCount,
|
||||
MappedCount = mappedCount
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence)
|
||||
{
|
||||
if (confidence < 0 || confidence > 1)
|
||||
return BadRequest(new { error = "신뢰도는 0~1 사이여야 합니다." });
|
||||
return BadRequest(new { Error = "신뢰도는 0~1 사이여야 합니다." });
|
||||
|
||||
await _pidExtractor.UpdateConfidenceAsync(id, confidence);
|
||||
return NoContent();
|
||||
@@ -234,7 +234,7 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var ok = await _pidExtractor.DeleteAsync(id);
|
||||
if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." });
|
||||
if (!ok) return NotFound(new { Error = "레코드를 찾을 수 없습니다." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> DeleteAll()
|
||||
{
|
||||
var count = await _pidExtractor.DeleteAllEquipmentAsync();
|
||||
return Ok(new { deletedCount = count, message = $"{count}건 삭제됨" });
|
||||
return Ok(new { DeletedCount = count, Message = $"{count}건 삭제됨" });
|
||||
}
|
||||
|
||||
[HttpGet("mappings")]
|
||||
@@ -252,10 +252,10 @@ public class PidController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
items = items
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Items = items
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest request)
|
||||
{
|
||||
var result = await _tagMapping.CreateMappingAsync(request);
|
||||
return Ok(new { result = result });
|
||||
return Ok(new { Result = result });
|
||||
}
|
||||
|
||||
[HttpPut("mappings/{id}")]
|
||||
@@ -284,21 +284,21 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> GetUnmappedCount()
|
||||
{
|
||||
var count = await _tagMapping.GetUnmappedCountAsync();
|
||||
return Ok(new { count });
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
[HttpGet("mappings/mapped")]
|
||||
public async Task<IActionResult> GetMappedCount()
|
||||
{
|
||||
var count = await _tagMapping.GetMappedCountAsync();
|
||||
return Ok(new { count });
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
[HttpGet("mappings/available-tags")]
|
||||
public async Task<IActionResult> GetAvailableTags()
|
||||
{
|
||||
var tags = await _tagMapping.GetAvailableTagsAsync();
|
||||
return Ok(new { tags });
|
||||
return Ok(new { Tags = tags });
|
||||
}
|
||||
|
||||
// ── Prefix 규칙 CRUD ──────────────────────────────────────────────────
|
||||
@@ -309,15 +309,15 @@ public class PidController : ControllerBase
|
||||
var rules = await _pidExtractor.GetPrefixRulesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
items = rules.Select(r => new
|
||||
Items = rules.Select(r => new
|
||||
{
|
||||
id = r.Id,
|
||||
prefix = r.Prefix,
|
||||
category = r.Category,
|
||||
tagDcs = r.TagDcs,
|
||||
description = r.Description,
|
||||
sortOrder = r.SortOrder,
|
||||
createdAt = r.CreatedAt
|
||||
Id = r.Id,
|
||||
Prefix = r.Prefix,
|
||||
Category = r.Category,
|
||||
TagDcs = r.TagDcs,
|
||||
Description = r.Description,
|
||||
SortOrder = r.SortOrder,
|
||||
CreatedAt = r.CreatedAt
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -326,32 +326,32 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> CreatePrefixRule([FromBody] CreatePidPrefixRuleRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Prefix))
|
||||
return BadRequest(new { error = "prefix는 필수입니다." });
|
||||
return BadRequest(new { Error = "prefix는 필수입니다." });
|
||||
if (!PidEquipment.AllCategories.Contains(request.Category))
|
||||
return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
|
||||
return BadRequest(new { Error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
|
||||
|
||||
var rule = await _pidExtractor.CreatePrefixRuleAsync(request);
|
||||
return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
|
||||
return Ok(new { Id = rule.Id, Prefix = rule.Prefix, Category = rule.Category });
|
||||
}
|
||||
|
||||
[HttpPut("prefix-rules/{id}")]
|
||||
public async Task<IActionResult> UpdatePrefixRule(int id, [FromBody] UpdatePidPrefixRuleRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Prefix))
|
||||
return BadRequest(new { error = "prefix는 필수입니다." });
|
||||
return BadRequest(new { Error = "prefix는 필수입니다." });
|
||||
if (!PidEquipment.AllCategories.Contains(request.Category))
|
||||
return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
|
||||
return BadRequest(new { Error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
|
||||
|
||||
var rule = await _pidExtractor.UpdatePrefixRuleAsync(id, request);
|
||||
if (rule == null) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
|
||||
return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
|
||||
if (rule == null) return NotFound(new { Error = "규칙을 찾을 수 없습니다." });
|
||||
return Ok(new { Id = rule.Id, Prefix = rule.Prefix, Category = rule.Category });
|
||||
}
|
||||
|
||||
[HttpDelete("prefix-rules/{id}")]
|
||||
public async Task<IActionResult> DeletePrefixRule(int id)
|
||||
{
|
||||
var ok = await _pidExtractor.DeletePrefixRuleAsync(id);
|
||||
if (!ok) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
|
||||
if (!ok) return NotFound(new { Error = "규칙을 찾을 수 없습니다." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> ApplyCategories()
|
||||
{
|
||||
var count = await _pidExtractor.ApplyCategoriesToExistingAsync();
|
||||
return Ok(new { applied = count });
|
||||
return Ok(new { Applied = count });
|
||||
}
|
||||
|
||||
/// <summary>편집한 export 엑셀(.xlsx)을 업로드하여 pid_equipment 를 UPSERT.</summary>
|
||||
@@ -368,9 +368,9 @@ public class PidController : ControllerBase
|
||||
public async Task<IActionResult> ImportExcel(IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { error = "파일이 없습니다." });
|
||||
return BadRequest(new { Error = "파일이 없습니다." });
|
||||
if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new { error = "지원 형식: .xlsx (export 엑셀)" });
|
||||
return BadRequest(new { Error = "지원 형식: .xlsx (export 엑셀)" });
|
||||
|
||||
try
|
||||
{
|
||||
@@ -384,7 +384,7 @@ public class PidController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[PID] 엑셀 import 실패: {File}", file.FileName);
|
||||
return BadRequest(new { error = $"import 실패: {ex.Message}" });
|
||||
return BadRequest(new { Error = $"import 실패: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,20 +42,20 @@ public class PidGraphController : ControllerBase
|
||||
// 프론트엔드 camelCase 규칙 준수 및 PidResponse 래핑
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
startNode = result.StartNode,
|
||||
impactedNodes = result.ImpactedNodes,
|
||||
paths = result.Paths
|
||||
StartNode = result.StartNode,
|
||||
ImpactedNodes = result.ImpactedNodes,
|
||||
Paths = result.Paths
|
||||
},
|
||||
message = "분석 완료"
|
||||
Message = "분석 완료"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error during impact analysis");
|
||||
return StatusCode(500, new { error = "Internal server error", details = ex.Message });
|
||||
return StatusCode(500, new { Error = "Internal server error", Details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ public class PidGraphController : ControllerBase
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
taskId = status.TaskId,
|
||||
progress = status.Progress,
|
||||
status = status.Status,
|
||||
message = status.Message
|
||||
TaskId = status.TaskId,
|
||||
Progress = status.Progress,
|
||||
Status = status.Status,
|
||||
Message = status.Message
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -95,10 +95,10 @@ public class PidGraphController : ControllerBase
|
||||
{
|
||||
var data = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
taskId = status.TaskId,
|
||||
progress = status.Progress,
|
||||
status = status.Status,
|
||||
message = status.Message
|
||||
TaskId = status.TaskId,
|
||||
Progress = status.Progress,
|
||||
Status = status.Status,
|
||||
Message = status.Message
|
||||
});
|
||||
|
||||
await Response.WriteAsync($"data: {data}\n\n", ct);
|
||||
@@ -120,7 +120,7 @@ public class PidGraphController : ControllerBase
|
||||
public async Task<IActionResult> BuildGraph([FromBody] BuildGraphRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Filepath))
|
||||
return BadRequest(new { error = "Filepath is required" });
|
||||
return BadRequest(new { Error = "Filepath is required" });
|
||||
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await _statusStore.UpdateStatusAsync(new AnalysisStatus(taskId, 0, "Starting", "추출 준비 중..."));
|
||||
@@ -181,9 +181,9 @@ public class PidGraphController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new { taskId = taskId },
|
||||
message = "작업이 시작되었습니다."
|
||||
Success = true,
|
||||
Data = new { TaskId = taskId },
|
||||
Message = "작업이 시작되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ public class PointBuilderController : ControllerBase
|
||||
public IActionResult ListSinamFiles()
|
||||
{
|
||||
if (!Directory.Exists(SinamUploadDir))
|
||||
return Ok(new { success = true, files = Array.Empty<object>() });
|
||||
return Ok(new { Success = true, Files = Array.Empty<object>() });
|
||||
|
||||
var files = Directory.GetFiles(SinamUploadDir, "*.xlsx")
|
||||
.Select(f => new
|
||||
@@ -215,7 +215,7 @@ public class PointBuilderController : ControllerBase
|
||||
.OrderByDescending(f => f.modified)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { success = true, files });
|
||||
return Ok(new { Success = true, Files = files });
|
||||
}
|
||||
|
||||
[HttpPost("sinam/upload")]
|
||||
@@ -223,11 +223,11 @@ public class PointBuilderController : ControllerBase
|
||||
public async Task<IActionResult> UploadSinam(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { success = false, error = "file required" });
|
||||
return BadRequest(new { Success = false, Error = "file required" });
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext != ".xlsx")
|
||||
return BadRequest(new { success = false, error = ".xlsx 파일만 업로드 가능" });
|
||||
return BadRequest(new { Success = false, Error = ".xlsx 파일만 업로드 가능" });
|
||||
|
||||
Directory.CreateDirectory(SinamUploadDir);
|
||||
var savePath = Path.Combine(SinamUploadDir, $"Sinam_{DateTime.UtcNow:yyyyMMdd-HHmmss}.xlsx");
|
||||
@@ -236,33 +236,33 @@ public class PointBuilderController : ControllerBase
|
||||
await file.CopyToAsync(stream, ct);
|
||||
|
||||
_log.LogInformation("[Sinam] 업로드: {Path} ({Size} bytes)", savePath, file.Length);
|
||||
return Ok(new { success = true, file = savePath });
|
||||
return Ok(new { Success = true, File = savePath });
|
||||
}
|
||||
|
||||
[HttpPost("sinam/parse")]
|
||||
public async Task<IActionResult> ParseSinam([FromBody] SinamParseRequest dto, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.File))
|
||||
return BadRequest(new { success = false, error = "file required" });
|
||||
return BadRequest(new { Success = false, Error = "file required" });
|
||||
if (!System.IO.File.Exists(dto.File))
|
||||
return BadRequest(new { success = false, error = "file not found" });
|
||||
return BadRequest(new { Success = false, Error = "file not found" });
|
||||
|
||||
var validControllers = new[] { "C1", "C2", "C3", "C4" };
|
||||
if (!validControllers.Contains(dto.Controller))
|
||||
return BadRequest(new { success = false, error = $"controller must be one of: {string.Join(", ", validControllers)}" });
|
||||
return BadRequest(new { Success = false, Error = $"controller must be one of: {string.Join(", ", validControllers)}" });
|
||||
|
||||
var result = await RunSinamScriptAsync(dto.File, dto.Controller, dto.ApplyDb, ct);
|
||||
|
||||
if (!result.Success)
|
||||
return StatusCode(500, new { success = false, error = result.Error });
|
||||
return StatusCode(500, new { Success = false, Error = result.Error });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
registers = result.Registers,
|
||||
archive = result.Archive,
|
||||
loops = result.Loops,
|
||||
stdout = result.Stdout,
|
||||
Success = true,
|
||||
Registers = result.Registers,
|
||||
Archive = result.Archive,
|
||||
Loops = result.Loops,
|
||||
Stdout = result.Stdout,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,33 +303,33 @@ public class PointBuilderController : ControllerBase
|
||||
|
||||
var count = await _db.Hc900BuildRealtimeTableAsync(groups);
|
||||
_log.LogInformation("[PointBuilder] Build 완료: {Count}개 활성화", count);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 활성화 완료" });
|
||||
return Ok(new { Success = true, Count = count, Message = $"{count}개 포인트 활성화 완료" });
|
||||
}
|
||||
|
||||
[HttpPost("apply")]
|
||||
public async Task<IActionResult> Apply([FromBody] Hc900PointBuilderApplyDto dto)
|
||||
{
|
||||
if (dto.SelectedTagNames == null || dto.SelectedTagNames.Count == 0)
|
||||
return BadRequest(new { success = false, error = "selectedTagNames는 최소 1개 이상 필요합니다" });
|
||||
return BadRequest(new { Success = false, Error = "selectedTagNames는 최소 1개 이상 필요합니다" });
|
||||
if (string.IsNullOrEmpty(dto.ControllerId))
|
||||
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
|
||||
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
|
||||
|
||||
var count = await _db.Hc900ApplySelectedPointsAsync(dto.ControllerId, dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Apply 완료: {Count}개 활성화 (controller={Ctrl})", count, dto.ControllerId);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 활성화 완료" });
|
||||
return Ok(new { Success = true, Count = count, Message = $"{count}개 포인트 활성화 완료" });
|
||||
}
|
||||
|
||||
[HttpPost("append")]
|
||||
public async Task<IActionResult> Append([FromBody] Hc900PointBuilderApplyDto dto)
|
||||
{
|
||||
if (dto.SelectedTagNames == null || dto.SelectedTagNames.Count == 0)
|
||||
return BadRequest(new { success = false, error = "selectedTagNames는 최소 1개 이상 필요합니다" });
|
||||
return BadRequest(new { Success = false, Error = "selectedTagNames는 최소 1개 이상 필요합니다" });
|
||||
if (string.IsNullOrEmpty(dto.ControllerId))
|
||||
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
|
||||
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
|
||||
|
||||
var count = await _db.Hc900AppendPointsAsync(dto.ControllerId, dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Append 완료: {Count}개 추가 (controller={Ctrl})", count, dto.ControllerId);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 추가 완료" });
|
||||
return Ok(new { Success = true, Count = count, Message = $"{count}개 포인트 추가 완료" });
|
||||
}
|
||||
|
||||
public sealed record RealtimeBulkDto(List<int>? Ids, string? ControllerId);
|
||||
@@ -339,13 +339,13 @@ public class PointBuilderController : ControllerBase
|
||||
public async Task<IActionResult> RealtimeAdd([FromBody] RealtimeBulkDto dto)
|
||||
{
|
||||
if (dto.Ids == null || dto.Ids.Count == 0)
|
||||
return BadRequest(new { success = false, error = "ids는 최소 1개 이상 필요합니다" });
|
||||
return BadRequest(new { Success = false, Error = "ids는 최소 1개 이상 필요합니다" });
|
||||
if (string.IsNullOrEmpty(dto.ControllerId))
|
||||
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
|
||||
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
|
||||
|
||||
var count = await _db.Hc900RealtimeAddByIdsAsync(dto.ControllerId, dto.Ids);
|
||||
_log.LogInformation("[PointBuilder] 실시간 추가: {Count}개 (controller={Ctrl})", count, dto.ControllerId);
|
||||
return Ok(new { success = true, count, message = $"{count}개 실시간 편입 완료" });
|
||||
return Ok(new { Success = true, Count = count, Message = $"{count}개 실시간 편입 완료" });
|
||||
}
|
||||
|
||||
// 선택(id)한 태그를 realtime_table에서 제거
|
||||
@@ -353,13 +353,13 @@ public class PointBuilderController : ControllerBase
|
||||
public async Task<IActionResult> RealtimeRemove([FromBody] RealtimeBulkDto dto)
|
||||
{
|
||||
if (dto.Ids == null || dto.Ids.Count == 0)
|
||||
return BadRequest(new { success = false, error = "ids는 최소 1개 이상 필요합니다" });
|
||||
return BadRequest(new { Success = false, Error = "ids는 최소 1개 이상 필요합니다" });
|
||||
if (string.IsNullOrEmpty(dto.ControllerId))
|
||||
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
|
||||
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
|
||||
|
||||
var count = await _db.Hc900RealtimeRemoveByIdsAsync(dto.ControllerId, dto.Ids);
|
||||
_log.LogInformation("[PointBuilder] 실시간 제거: {Count}개 (controller={Ctrl})", count, dto.ControllerId);
|
||||
return Ok(new { success = true, count, message = $"{count}개 실시간 제거 완료" });
|
||||
return Ok(new { Success = true, Count = count, Message = $"{count}개 실시간 제거 완료" });
|
||||
}
|
||||
|
||||
[HttpGet("points")]
|
||||
@@ -448,18 +448,18 @@ public class PointBuilderController : ControllerBase
|
||||
public async Task<IActionResult> Add([FromBody] Hc900PointBuilderAddDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.TagName))
|
||||
return BadRequest(new { success = false, error = "tagName은 필수입니다" });
|
||||
return BadRequest(new { Success = false, Error = "tagName은 필수입니다" });
|
||||
|
||||
try
|
||||
{
|
||||
var point = await _db.Hc900AddRealtimePointAsync(
|
||||
dto.TagName, dto.Hc900Tag, dto.ModbusAddr,
|
||||
dto.DataType, dto.ParamType, dto.Access, dto.ControllerId);
|
||||
return Ok(new { success = true, point });
|
||||
return Ok(new { Success = true, Point = point });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { success = false, error = ex.Message });
|
||||
return Conflict(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +468,7 @@ public class PointBuilderController : ControllerBase
|
||||
{
|
||||
var entry = await _db.Hc900MapEntries.FindAsync(id);
|
||||
if (entry == null)
|
||||
return NotFound(new { success = false, error = "지정한 id의 태그가 존재하지 않습니다" });
|
||||
return NotFound(new { Success = false, Error = "지정한 id의 태그가 존재하지 않습니다" });
|
||||
|
||||
entry.IsActive = false;
|
||||
await _db.SaveChangesAsync();
|
||||
@@ -500,6 +500,6 @@ public class PointBuilderController : ControllerBase
|
||||
}
|
||||
|
||||
_log.LogInformation("[PointBuilder] 삭제: {TagName} (purgeHistory={Purge})", entry.TagName, purgeHistory);
|
||||
return Ok(new { success = true, tagName = entry.TagName });
|
||||
return Ok(new { Success = true, TagName = entry.TagName });
|
||||
}
|
||||
}
|
||||
|
||||
78
src/Hc900Crawler/Controllers/ReportController.cs
Normal file
78
src/Hc900Crawler/Controllers/ReportController.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Reporting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/report")]
|
||||
public class ReportController : ControllerBase
|
||||
{
|
||||
private readonly IReportMetricService _metrics;
|
||||
private readonly ReportFillService _fill;
|
||||
private readonly IReportTemplateStore _store;
|
||||
private readonly ReportColumnMap _map;
|
||||
|
||||
// 웹 대시보드 기본 메트릭 세트
|
||||
private static readonly string[] SUMMARY_METRICS =
|
||||
{ "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure", "control_residual" };
|
||||
|
||||
public ReportController(IReportMetricService metrics, ReportFillService fill,
|
||||
IReportTemplateStore store, ReportColumnMap map)
|
||||
{ _metrics = metrics; _fill = fill; _store = store; _map = map; }
|
||||
|
||||
/// <summary>설정된 컬럼 목록(웹 UI 셀렉트용).</summary>
|
||||
[HttpGet("columns")]
|
||||
public IActionResult Columns()
|
||||
=> Ok(_map.Columns().Select(c => new { Column = c, HasClosure = _map.HasClosure(c) }));
|
||||
|
||||
/// <summary>단건 메트릭(미리보기/디버그).</summary>
|
||||
[HttpPost("metric")]
|
||||
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
|
||||
=> Ok(await _metrics.ComputeAsync(req, ct));
|
||||
|
||||
/// <summary>웹에서 바로 보기 — 한 컬럼·날짜의 전 메트릭을 한 번에.</summary>
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary(string column = "C-6111", DateTime? date = null,
|
||||
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
|
||||
{
|
||||
var d = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본 = 어제(KST)
|
||||
var results = new List<MetricResultDto>();
|
||||
foreach (var m in SUMMARY_METRICS)
|
||||
results.Add(await _metrics.ComputeAsync(new MetricRequestDto
|
||||
{
|
||||
Column = column, Metric = m, PeriodDateKst = d,
|
||||
SourceTable = source, SessionId = sessionId
|
||||
}, ct));
|
||||
return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Metrics = results });
|
||||
}
|
||||
|
||||
/// <summary>엑셀 템플릿 등록.</summary>
|
||||
[HttpPost("template")]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name,
|
||||
[FromForm] string? owner, CancellationToken ct)
|
||||
{
|
||||
if (file == null || file.Length == 0) return BadRequest(new { Error = "파일 없음" });
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms, ct);
|
||||
var id = await _store.CreateAsync(name, owner, ms.ToArray(), ct);
|
||||
return Ok(new { Id = id });
|
||||
}
|
||||
|
||||
/// <summary>★템플릿+날짜 → 채워진 xlsx 다운로드.</summary>
|
||||
[HttpGet("generate")]
|
||||
public async Task<IActionResult> Generate(int templateId, DateTime date,
|
||||
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
|
||||
{
|
||||
var tpl = await _store.GetBlobAsync(templateId, ct);
|
||||
if (tpl == null) return NotFound(new { Error = $"템플릿 {templateId} 없음" });
|
||||
|
||||
var (xlsx, cells, status) = await _fill.FillAsync(tpl, date, source, sessionId, ct);
|
||||
await _store.RecordRunAsync(templateId, "DAILY", date, source, status, cells, xlsx, ct);
|
||||
|
||||
Response.Headers["X-Report-Status"] = status;
|
||||
var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
|
||||
return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,11 @@ public class SetupController : ControllerBase
|
||||
{
|
||||
ControllerProcessManager.SaveConfig(cfg);
|
||||
_procMgr.ReloadConfig();
|
||||
return Ok(new { success = true, message = "설정 저장 완료" });
|
||||
return Ok(new { Success = true, Message = "설정 저장 완료" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { success = false, message = ex.Message });
|
||||
return StatusCode(500, new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,13 +103,13 @@ public class SetupController : ControllerBase
|
||||
if (id != null)
|
||||
{
|
||||
var (ok, msg) = await _procMgr.StartAsync(id);
|
||||
return Ok(new { success = ok, message = msg });
|
||||
return Ok(new { Success = ok, Message = msg });
|
||||
}
|
||||
var results = new List<object>();
|
||||
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
|
||||
{
|
||||
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
|
||||
results.Add(new { ctrl.Id, ok, msg });
|
||||
results.Add(new { ctrl.Id, Ok = ok, Msg = msg });
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
@@ -121,13 +121,13 @@ public class SetupController : ControllerBase
|
||||
if (id != null)
|
||||
{
|
||||
var (ok, msg) = await _procMgr.StopAsync(id);
|
||||
return Ok(new { success = ok, message = msg });
|
||||
return Ok(new { Success = ok, Message = msg });
|
||||
}
|
||||
var results = new List<object>();
|
||||
foreach (var ctrl in _procMgr.Config.Controllers)
|
||||
{
|
||||
var (ok, msg) = await _procMgr.StopAsync(ctrl.Id);
|
||||
results.Add(new { ctrl.Id, ok, msg });
|
||||
results.Add(new { ctrl.Id, Ok = ok, Msg = msg });
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
@@ -142,7 +142,7 @@ public class SetupController : ControllerBase
|
||||
await _procMgr.StopAsync(id);
|
||||
await Task.Delay(500);
|
||||
var (ok, msg) = await _procMgr.StartAsync(id);
|
||||
return Ok(new { success = ok, message = msg });
|
||||
return Ok(new { Success = ok, Message = msg });
|
||||
}
|
||||
foreach (var ctrl in _procMgr.Config.Controllers)
|
||||
await _procMgr.StopAsync(ctrl.Id);
|
||||
@@ -151,7 +151,7 @@ public class SetupController : ControllerBase
|
||||
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
|
||||
{
|
||||
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
|
||||
results.Add(new { ctrl.Id, ok, msg });
|
||||
results.Add(new { ctrl.Id, Ok = ok, Msg = msg });
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
@@ -161,8 +161,8 @@ public class SetupController : ControllerBase
|
||||
public IActionResult GetLog([FromQuery] string? id = null, [FromQuery] int lines = 50)
|
||||
{
|
||||
var target = id ?? _procMgr.Config.Controllers.FirstOrDefault()?.Id;
|
||||
if (target == null) return Ok(new { log = Array.Empty<string>() });
|
||||
if (target == null) return Ok(new { Log = Array.Empty<string>() });
|
||||
var status = _procMgr.GetStatus(target);
|
||||
return Ok(new { log = status.RecentLog.TakeLast(lines) });
|
||||
return Ok(new { Log = status.RecentLog.TakeLast(lines) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
return Ok(new { loaded = _advisor.IsLoaded });
|
||||
return Ok(new { Loaded = _advisor.IsLoaded });
|
||||
}
|
||||
|
||||
[HttpGet("predict")]
|
||||
@@ -80,7 +80,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var modelDir = _config.GetValue<string>("SteamAdvisor:ModelDir")
|
||||
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
|
||||
if (!Directory.Exists(modelDir))
|
||||
return Ok(new { columns = Array.Empty<string>() });
|
||||
return Ok(new { Columns = Array.Empty<string>() });
|
||||
|
||||
var columns = Directory.GetFiles(modelDir, "*_model.json")
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
@@ -94,7 +94,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
.ToHashSet();
|
||||
|
||||
var defaultCol = _config.GetValue<string>("SteamAdvisor:DefaultColumn") ?? "C-6111";
|
||||
return Ok(new { columns, configured = configured.OrderBy(x => x).ToList(), defaultColumn = defaultCol });
|
||||
return Ok(new { Columns = columns, Configured = configured.OrderBy(x => x).ToList(), DefaultColumn = defaultCol });
|
||||
}
|
||||
|
||||
[HttpGet("backtest/{col}")]
|
||||
@@ -104,7 +104,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
|
||||
var path = Path.Combine(plotDir, $"{col}_plotdata.json");
|
||||
if (!System.IO.File.Exists(path))
|
||||
return NotFound(new { error = $"플롯데이터 없음: {col}" });
|
||||
return NotFound(new { Error = $"플롯데이터 없음: {col}" });
|
||||
|
||||
try
|
||||
{
|
||||
@@ -113,7 +113,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = $"플롯데이터 읽기 실패: {ex.Message}" });
|
||||
return StatusCode(500, new { Error = $"플롯데이터 읽기 실패: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var steamFlowTag = tags["SteamFlow"];
|
||||
|
||||
if (string.IsNullOrEmpty(feedTag) || string.IsNullOrEmpty(productTag) || string.IsNullOrEmpty(tcTag))
|
||||
return BadRequest(new { error = $"컬럼 {col} 태그 매핑 불완전" });
|
||||
return BadRequest(new { Error = $"컬럼 {col} 태그 매핑 불완전" });
|
||||
|
||||
var tagNames = new[] { feedTag, productTag, tcTag };
|
||||
if (!string.IsNullOrEmpty(steamOpTag)) tagNames = tagNames.Append(steamOpTag).ToArray();
|
||||
@@ -145,8 +145,8 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var missing = new[] { feedTag, productTag, tcTag }
|
||||
.Where(t => !live.ContainsKey(t))
|
||||
.ToList();
|
||||
return Ok(new { col, status = "missing_tags", missing,
|
||||
message = "일부 태그 실시간값 없음 — 게이트웨이 폴링 확인" });
|
||||
return Ok(new { Col = col, Status = "missing_tags", Missing = missing,
|
||||
Message = "일부 태그 실시간값 없음 — 게이트웨이 폴링 확인" });
|
||||
}
|
||||
|
||||
var feed = double.TryParse(live[feedTag], out var f) ? f : double.NaN;
|
||||
@@ -164,7 +164,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
col,
|
||||
Col = col,
|
||||
result.RecOp, result.RecSteam, result.Confidence,
|
||||
result.Mode, result.Ood, result.InEnv, result.Message,
|
||||
Feed = result.Feed,
|
||||
@@ -172,8 +172,8 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
TC = result.TC,
|
||||
ActualOp = steamOp,
|
||||
ActualSteamFlow = steamFlow,
|
||||
Tags = new { feed = feedTag, product = productTag, tC = tcTag, steamOp = steamOpTag, steamFlow = steamFlowTag },
|
||||
Descs = new { feed = GetDesc(feedTag), product = GetDesc(productTag), tC = GetDesc(tcTag), steamOp = GetDesc(steamOpTag), steamFlow = GetDesc(steamFlowTag) },
|
||||
Tags = new { Feed = feedTag, Product = productTag, TC = tcTag, SteamOp = steamOpTag, SteamFlow = steamFlowTag },
|
||||
Descs = new { Feed = GetDesc(feedTag), Product = GetDesc(productTag), TC = GetDesc(tcTag), SteamOp = GetDesc(steamOpTag), SteamFlow = GetDesc(steamFlowTag) },
|
||||
Timestamp = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
@@ -187,7 +187,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
[FromQuery] string? product = null)
|
||||
{
|
||||
var tref = await LoadTempRef(col);
|
||||
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
if (tref is null) return NotFound(new { Error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
|
||||
tref = MergeProductLabels(col, tref);
|
||||
|
||||
@@ -204,38 +204,38 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
column = tref.Column,
|
||||
period = tref.Period,
|
||||
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
|
||||
nProducts = tref.NProducts,
|
||||
stages,
|
||||
vacuum,
|
||||
spanAD,
|
||||
spanRef = prod?.SpanAD,
|
||||
Column = tref.Column,
|
||||
Period = tref.Period,
|
||||
MatchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
|
||||
NProducts = tref.NProducts,
|
||||
Stages = stages,
|
||||
Vacuum = vacuum,
|
||||
SpanAD = spanAD,
|
||||
SpanRef = prod?.SpanAD,
|
||||
// 소문자 키로 projection (TempProduct 레코드는 PascalCase 직렬화 → 프론트가 못 읽음)
|
||||
products = tref.Products.Select(p => new {
|
||||
label = p.Label,
|
||||
name = p.Name,
|
||||
rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
|
||||
vacMedian = p.Vacuum.Median,
|
||||
nRows = p.NRows,
|
||||
Products = tref.Products.Select(p => new {
|
||||
Label = p.Label,
|
||||
Name = p.Name,
|
||||
RebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
|
||||
VacMedian = p.Vacuum.Median,
|
||||
NRows = p.NRows,
|
||||
}).ToList(),
|
||||
timestamp = DateTime.UtcNow,
|
||||
flow = new {
|
||||
feed = flow["feed"],
|
||||
reflux = flow["reflux"],
|
||||
overhead = flow["overhead"],
|
||||
bottom = flow["bottom"],
|
||||
product = flow["product"],
|
||||
steam = flow["steam"],
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Flow = new {
|
||||
Feed = flow["feed"],
|
||||
Reflux = flow["reflux"],
|
||||
Overhead = flow["overhead"],
|
||||
Bottom = flow["bottom"],
|
||||
Product = flow["product"],
|
||||
Steam = flow["steam"],
|
||||
},
|
||||
flowTags = new {
|
||||
feed = $"{bases["feed"]}.PV",
|
||||
reflux = $"{bases["reflux"]}.PV",
|
||||
overhead = $"{bases["overhead"]}.PV",
|
||||
bottom = $"{bases["bottom"]}.PV",
|
||||
product = $"{bases["product"]}.PV",
|
||||
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
|
||||
FlowTags = new {
|
||||
Feed = $"{bases["feed"]}.PV",
|
||||
Reflux = $"{bases["reflux"]}.PV",
|
||||
Overhead = $"{bases["overhead"]}.PV",
|
||||
Bottom = $"{bases["bottom"]}.PV",
|
||||
Product = $"{bases["product"]}.PV",
|
||||
Steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -253,7 +253,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
try
|
||||
{
|
||||
var tref = await LoadTempRef(col);
|
||||
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
if (tref is null) return NotFound(new { Error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
|
||||
tref = MergeProductLabels(col, tref);
|
||||
|
||||
@@ -296,48 +296,48 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
|
||||
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected);
|
||||
object FlowRole(string baseTag, string role)
|
||||
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
|
||||
=> new { Pv = cur.GetValueOrDefault(role), Sp = V(row.Values, $"{baseTag}.SP"), Op = V(row.Values, $"{baseTag}.OP") };
|
||||
snapshots.Add(new
|
||||
{
|
||||
ts = row.TimeBucket,
|
||||
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
|
||||
stages,
|
||||
vacuum,
|
||||
spanAD,
|
||||
flow = new {
|
||||
feed = FlowRole(flowBases["feed"], "feed"),
|
||||
reflux = FlowRole(flowBases["reflux"], "reflux"),
|
||||
overhead = FlowRole(flowBases["overhead"], "overhead"),
|
||||
bottom = FlowRole(flowBases["bottom"], "bottom"),
|
||||
product = FlowRole(flowBases["product"], "product"),
|
||||
steam = new { pv = cur.GetValueOrDefault("steam") },
|
||||
Ts = row.TimeBucket,
|
||||
MatchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
|
||||
Stages = stages,
|
||||
Vacuum = vacuum,
|
||||
SpanAD = spanAD,
|
||||
Flow = new {
|
||||
Feed = FlowRole(flowBases["feed"], "feed"),
|
||||
Reflux = FlowRole(flowBases["reflux"], "reflux"),
|
||||
Overhead = FlowRole(flowBases["overhead"], "overhead"),
|
||||
Bottom = FlowRole(flowBases["bottom"], "bottom"),
|
||||
Product = FlowRole(flowBases["product"], "product"),
|
||||
Steam = new { Pv = cur.GetValueOrDefault("steam") },
|
||||
},
|
||||
flowTags = new {
|
||||
feed = $"{flowBases["feed"]}.PV",
|
||||
reflux = $"{flowBases["reflux"]}.PV",
|
||||
overhead = $"{flowBases["overhead"]}.PV",
|
||||
bottom = $"{flowBases["bottom"]}.PV",
|
||||
product = $"{flowBases["product"]}.PV",
|
||||
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
|
||||
FlowTags = new {
|
||||
Feed = $"{flowBases["feed"]}.PV",
|
||||
Reflux = $"{flowBases["reflux"]}.PV",
|
||||
Overhead = $"{flowBases["overhead"]}.PV",
|
||||
Bottom = $"{flowBases["bottom"]}.PV",
|
||||
Product = $"{flowBases["product"]}.PV",
|
||||
Steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
column = tref.Column,
|
||||
period = tref.Period,
|
||||
from,
|
||||
to,
|
||||
interval = "1 minute",
|
||||
n = snapshots.Count,
|
||||
snapshots,
|
||||
timestamp = DateTime.UtcNow,
|
||||
Column = tref.Column,
|
||||
Period = tref.Period,
|
||||
From = from,
|
||||
To = to,
|
||||
Interval = "1 minute",
|
||||
N = snapshots.Count,
|
||||
Snapshots = snapshots,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message, stack = ex.ToString() });
|
||||
return StatusCode(500, new { Error = ex.Message, Stack = ex.ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,9 +345,9 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
// MSDS 후보 (docs/MSDS_PMA_PGMEA_EL.md). 자유 입력도 허용하되 기본 제안 목록 제공.
|
||||
private static readonly object[] _productSuggestions =
|
||||
{
|
||||
new { name = "PMA", casNo = "108-65-6" },
|
||||
new { name = "PGMEA", casNo = "108-65-6" },
|
||||
new { name = "EL", casNo = "97-64-3" },
|
||||
new { Name = "PMA", CasNo = "108-65-6" },
|
||||
new { Name = "PGMEA", CasNo = "108-65-6" },
|
||||
new { Name = "EL", CasNo = "97-64-3" },
|
||||
};
|
||||
|
||||
private string ProductLabelsPath()
|
||||
@@ -362,20 +362,20 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
public async Task<IActionResult> GetProductLabels(string col)
|
||||
{
|
||||
var tref = await LoadTempRef(col);
|
||||
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
if (tref is null) return NotFound(new { Error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
tref = MergeProductLabels(col, tref);
|
||||
|
||||
var clusters = tref.Products.Select(p => new
|
||||
{
|
||||
cluster = p.Label,
|
||||
rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
|
||||
vacMedian = p.Vacuum.Median,
|
||||
nRows = p.NRows,
|
||||
Cluster = p.Label,
|
||||
RebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
|
||||
VacMedian = p.Vacuum.Median,
|
||||
NRows = p.NRows,
|
||||
// 현재 지정명: 매핑이 있으면 Name, 없으면 빈값(=미지정)
|
||||
name = string.IsNullOrEmpty(p.Name) || p.Name == p.Label ? "" : p.Name,
|
||||
Name = string.IsNullOrEmpty(p.Name) || p.Name == p.Label ? "" : p.Name,
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { column = col, clusters, suggestions = _productSuggestions });
|
||||
return Ok(new { Column = col, Clusters = clusters, Suggestions = _productSuggestions });
|
||||
}
|
||||
|
||||
public sealed record ProductLabelEntry
|
||||
@@ -389,7 +389,7 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
[HttpPost("productlabels/{col}")]
|
||||
public IActionResult SaveProductLabels(string col, [FromBody] List<ProductLabelEntry> entries)
|
||||
{
|
||||
if (entries is null) return BadRequest(new { error = "본문(entries) 누락" });
|
||||
if (entries is null) return BadRequest(new { Error = "본문(entries) 누락" });
|
||||
var path = ProductLabelsPath();
|
||||
|
||||
try
|
||||
@@ -422,11 +422,11 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
System.IO.File.WriteAllText(path, json);
|
||||
return Ok(new { success = true, saved = clean.Count, path });
|
||||
return Ok(new { Success = true, Saved = clean.Count, Path = path });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { success = false, error = ex.Message });
|
||||
return StatusCode(500, new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +519,8 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (var (role, baseTag) in ficqBases)
|
||||
result[role] = new { pv = P(live, $"{baseTag}.PV"), sp = P(live, $"{baseTag}.SP"), op = P(live, $"{baseTag}.OP") };
|
||||
result["steam"] = new { pv = P(live, steamTag) };
|
||||
result[role] = new { Pv = P(live, $"{baseTag}.PV"), Sp = P(live, $"{baseTag}.SP"), Op = P(live, $"{baseTag}.OP") };
|
||||
result["steam"] = new { Pv = P(live, steamTag) };
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -554,12 +554,12 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var z = Z(c, rs);
|
||||
return (object)new
|
||||
{
|
||||
stage = s,
|
||||
current = c,
|
||||
refMedian = rs?.Median,
|
||||
refStd = rs?.Std,
|
||||
z,
|
||||
deviated = z is double zz && Math.Abs(zz) > 2
|
||||
Stage = s,
|
||||
Current = c,
|
||||
RefMedian = rs?.Median,
|
||||
RefStd = rs?.Std,
|
||||
Z = z,
|
||||
Deviated = z is double zz && Math.Abs(zz) > 2
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -569,11 +569,11 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
|
||||
return (prod, stages, new
|
||||
{
|
||||
current = vac,
|
||||
refMedian = prod?.Vacuum.Median,
|
||||
refStd = prod?.Vacuum.Std,
|
||||
z = vz,
|
||||
deviated = vz is double v && Math.Abs(v) > 2
|
||||
Current = vac,
|
||||
RefMedian = prod?.Vacuum.Median,
|
||||
RefStd = prod?.Vacuum.Std,
|
||||
Z = vz,
|
||||
Deviated = vz is double v && Math.Abs(v) > 2
|
||||
}, spanAD);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@ public class TextToSqlController : ControllerBase
|
||||
try
|
||||
{
|
||||
var sql = await _textToSqlService.ParseNaturalLanguageAsync(dto.Query);
|
||||
return Ok(new { success = true, sql });
|
||||
return Ok(new { Success = true, Sql = sql });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,13 +56,13 @@ public class TextToSqlController : ControllerBase
|
||||
public async Task<IActionResult> QueryWithNl([FromBody] NaturalLanguageQueryDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Query))
|
||||
return BadRequest(new { success = false, error = "질문이 비어있음" });
|
||||
return BadRequest(new { Success = false, Error = "질문이 비어있음" });
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _mcpService.QueryWithNlAsync(dto.Query);
|
||||
if (!result.Success)
|
||||
return Ok(new { success = false, error = result.Error });
|
||||
return Ok(new { Success = false, Error = result.Error });
|
||||
|
||||
try
|
||||
{
|
||||
@@ -77,18 +77,18 @@ public class TextToSqlController : ControllerBase
|
||||
jsonData["data"] = parsedData!;
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = jsonData });
|
||||
return Ok(new { Success = true, Data = jsonData });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] query-nl JSON 디시리얼라이즈 실패: {Data}", result.Data);
|
||||
return Ok(new { success = true, data = result.Data });
|
||||
return Ok(new { Success = true, Data = result.Data });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] query-nl 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,12 +101,12 @@ public class TextToSqlController : ControllerBase
|
||||
try
|
||||
{
|
||||
var tools = await _mcpService.ListToolsAsync();
|
||||
return Ok(new { success = true, tools });
|
||||
return Ok(new { Success = true, Tools = tools });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] 도구 목록 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ public class TextToSqlController : ControllerBase
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Sql))
|
||||
{
|
||||
return BadRequest(new { success = false, error = "SQL이 비어있음" });
|
||||
return BadRequest(new { Success = false, Error = "SQL이 비어있음" });
|
||||
}
|
||||
|
||||
try
|
||||
@@ -131,8 +131,8 @@ public class TextToSqlController : ControllerBase
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
error = result.Error
|
||||
Success = false,
|
||||
Error = result.Error
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,17 +140,17 @@ public class TextToSqlController : ControllerBase
|
||||
try
|
||||
{
|
||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||
return Ok(new { success = true, data = jsonData });
|
||||
return Ok(new { Success = true, Data = jsonData });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Ok(new { success = true, data = result.Data });
|
||||
return Ok(new { Success = true, Data = result.Data });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] MCP 실행 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ public class TextToSqlController : ControllerBase
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
error = result.Error
|
||||
Success = false,
|
||||
Error = result.Error
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,23 +188,23 @@ public class TextToSqlController : ControllerBase
|
||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = jsonData
|
||||
Success = true,
|
||||
Data = jsonData
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = result.Data
|
||||
Success = true,
|
||||
Data = result.Data
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] History 쿼리 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +223,8 @@ public class TextToSqlController : ControllerBase
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
error = result.Error
|
||||
Success = false,
|
||||
Error = result.Error
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,23 +233,23 @@ public class TextToSqlController : ControllerBase
|
||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = jsonData
|
||||
Success = true,
|
||||
Data = jsonData
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = result.Data
|
||||
Success = true,
|
||||
Data = result.Data
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] 태그 검색 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,8 +267,8 @@ public class TextToSqlController : ControllerBase
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
error = result.Error
|
||||
Success = false,
|
||||
Error = result.Error
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,23 +277,23 @@ public class TextToSqlController : ControllerBase
|
||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = jsonData
|
||||
Success = true,
|
||||
Data = jsonData
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = result.Data
|
||||
Success = true,
|
||||
Data = result.Data
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] 도면 목록 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
return Ok(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ public class TextToSqlController : ControllerBase
|
||||
public async Task<IActionResult> Suggest([FromQuery] string input = "")
|
||||
{
|
||||
var suggestions = await _textToSqlService.SuggestQueriesAsync(input);
|
||||
return Ok(new { success = true, suggestions });
|
||||
return Ok(new { Success = true, Suggestions = suggestions });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -315,20 +315,20 @@ public class TextToSqlController : ControllerBase
|
||||
{
|
||||
var result = await _textToSqlService.AnalyzeAsync(dto);
|
||||
return Ok(new {
|
||||
success = result.Success,
|
||||
error = result.Error,
|
||||
tags = result.Tags?.Select(t => new {
|
||||
tagName = t.TagName,
|
||||
avg = t.Avg,
|
||||
mean = t.Mean,
|
||||
min = t.Min,
|
||||
max = t.Max,
|
||||
first = t.First,
|
||||
last = t.Last,
|
||||
pointCount = t.PointCount,
|
||||
stddev = t.StdDev,
|
||||
from = t.From,
|
||||
to = t.To
|
||||
Success = result.Success,
|
||||
Error = result.Error,
|
||||
Tags = result.Tags?.Select(t => new {
|
||||
TagName = t.TagName,
|
||||
Avg = t.Avg,
|
||||
Mean = t.Mean,
|
||||
Min = t.Min,
|
||||
Max = t.Max,
|
||||
First = t.First,
|
||||
Last = t.Last,
|
||||
PointCount = t.PointCount,
|
||||
StdDev = t.StdDev,
|
||||
From = t.From,
|
||||
To = t.To
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
@@ -353,15 +353,15 @@ public class TextToSqlController : ControllerBase
|
||||
|
||||
var response = new
|
||||
{
|
||||
success = true,
|
||||
tagNames = result.TagNames.ToList(),
|
||||
rows = result.Rows.Select(r => new
|
||||
Success = true,
|
||||
TagNames = result.TagNames.ToList(),
|
||||
Rows = result.Rows.Select(r => new
|
||||
{
|
||||
timeBucket = r.TimeBucket,
|
||||
values = r.Values
|
||||
TimeBucket = r.TimeBucket,
|
||||
Values = r.Values
|
||||
}).ToList(),
|
||||
baseIntervalSeconds = result.BaseIntervalSeconds,
|
||||
queryInterval = result.QueryInterval
|
||||
BaseIntervalSeconds = result.BaseIntervalSeconds,
|
||||
QueryInterval = result.QueryInterval
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
@@ -369,7 +369,7 @@ public class TextToSqlController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[TextToSql] QueryHistoryInterval 실패");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { success = false, error = ex.Message });
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { Success = false, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,15 +22,15 @@ public class TrendController : ControllerBase
|
||||
[HttpGet("analog-points")]
|
||||
public async Task<IActionResult> AnalogPoints([FromQuery] string? q, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(new { items = await _svc.GetAnalogPointsAsync(q ?? "", ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] analog-points 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
try { return Ok(new { Items = await _svc.GetAnalogPointsAsync(q ?? "", ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] analog-points 실패"); return StatusCode(500, new { Error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("groups")]
|
||||
public async Task<IActionResult> Groups(CancellationToken ct)
|
||||
{
|
||||
try { return Ok(new { items = await _svc.GetGroupsAsync(ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] groups 조회 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
try { return Ok(new { Items = await _svc.GetGroupsAsync(ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] groups 조회 실패"); return StatusCode(500, new { Error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("groups/{id:int}")]
|
||||
@@ -44,31 +44,31 @@ public class TrendController : ControllerBase
|
||||
public async Task<IActionResult> Create([FromBody] TrendGroupUpsertDto dto, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
return BadRequest(new { error = "name 은 필수입니다." });
|
||||
return BadRequest(new { Error = "name 은 필수입니다." });
|
||||
try { return Ok(await _svc.CreateGroupAsync(dto, ct)); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] 그룹 생성 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] 그룹 생성 실패"); return StatusCode(500, new { Error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] TrendGroupUpsertDto dto, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
return BadRequest(new { error = "name 은 필수입니다." });
|
||||
return BadRequest(new { Error = "name 은 필수입니다." });
|
||||
var g = await _svc.UpdateGroupAsync(id, dto, ct);
|
||||
return g is null ? NotFound() : Ok(g);
|
||||
}
|
||||
|
||||
[HttpDelete("groups/{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
=> await _svc.DeleteGroupAsync(id, ct) ? Ok(new { success = true }) : NotFound();
|
||||
=> await _svc.DeleteGroupAsync(id, ct) ? Ok(new { Success = true }) : NotFound();
|
||||
|
||||
/// <summary>실시간 tail — 그룹 멤버 현재값(아날로그)</summary>
|
||||
[HttpGet("live")]
|
||||
public async Task<IActionResult> Live([FromQuery] string? tags, CancellationToken ct)
|
||||
{
|
||||
var list = (tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
try { return Ok(new { items = await _svc.GetLiveAsync(list, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
try { return Ok(new { Items = await _svc.GetLiveAsync(list, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { Error = ex.Message }); }
|
||||
}
|
||||
|
||||
/// <summary>알람 한계선 (HI/LO/SP 수평선용)</summary>
|
||||
@@ -76,15 +76,15 @@ public class TrendController : ControllerBase
|
||||
public async Task<IActionResult> Limits([FromQuery] string? tags, CancellationToken ct)
|
||||
{
|
||||
var list = (tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
try { return Ok(new { items = await _svc.GetLimitsAsync(list, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] limits 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
try { return Ok(new { Items = await _svc.GetLimitsAsync(list, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] limits 실패"); return StatusCode(500, new { Error = ex.Message }); }
|
||||
}
|
||||
|
||||
/// <summary>운전상태 밴드 (RUN/TRIP 구간)</summary>
|
||||
[HttpGet("runbands")]
|
||||
public async Task<IActionResult> RunBands([FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] string? area, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(new { items = await _svc.GetRunBandsAsync(from, to, area, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] runbands 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
try { return Ok(new { Items = await _svc.GetRunBandsAsync(from, to, area, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] runbands 실패"); return StatusCode(500, new { Error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EPPlus 라이선스 컨텍스트 (리포트 엑셀 생성용). ⚠️ 상용 출시 전 라이선스 정리 필요(NonCommercial 한정).
|
||||
#pragma warning disable CS0618
|
||||
OfficeOpenXml.ExcelPackage.LicenseContext = OfficeOpenXml.LicenseContext.NonCommercial;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
|
||||
var mvcBuilder = builder.Services.AddControllers()
|
||||
.AddJsonOptions(opt =>
|
||||
@@ -168,6 +173,14 @@ builder.Services.AddCors(opt =>
|
||||
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:5000");
|
||||
|
||||
// ── P0 셀프서비스 리포트 ──────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.ReportColumnMap>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportMetricService,
|
||||
Hc900Crawler.Infrastructure.Reporting.ReportMetricService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Reporting.ReportFillService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportTemplateStore,
|
||||
Hc900Crawler.Infrastructure.Reporting.ReportTemplateStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -86,6 +86,11 @@
|
||||
"C-10211": { "Feed": "FICQ-10201.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C.PV", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" }
|
||||
}
|
||||
},
|
||||
"Report": {
|
||||
"Closure": {
|
||||
"C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] }
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
<span class="ni">13</span>
|
||||
<span class="nl">스팀 Advisory</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="reports">
|
||||
<span class="ni">14</span>
|
||||
<span class="nl">리포트</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -133,6 +137,7 @@
|
||||
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html?v=20260604"></section>
|
||||
<section class="pane" id="pane-steam" data-src="/panes/steam.html?v=20260606"></section>
|
||||
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -232,5 +237,6 @@
|
||||
<script src="/js/trend.js?v=20260611"></script>
|
||||
<script src="/js/ff.js?v=20260604"></script>
|
||||
<script src="/js/steam.js?v=20260606"></script>
|
||||
<script src="/js/reports.js?v=20260612"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -73,9 +73,9 @@ async function docsUnlock() {
|
||||
body: JSON.stringify({ password: pw }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { alert('로그인 실패: ' + (d.error || '')); return; }
|
||||
sessionStorage.setItem('kbToken', d.token);
|
||||
try { kbToken = d.token; } catch (e) {}
|
||||
if (!d.Success) { alert('로그인 실패: ' + (d.Error || '')); return; }
|
||||
sessionStorage.setItem('kbToken', d.Token);
|
||||
try { kbToken = d.Token; } catch (e) {}
|
||||
await docsLoadConfig();
|
||||
docsToast('관리 잠금해제됨');
|
||||
} catch (e) { alert('로그인 오류: ' + e.message); }
|
||||
@@ -85,8 +85,8 @@ async function docsUnlock() {
|
||||
async function docsFetchTree(path) {
|
||||
const r = await fetch('/api/docs/tree?path=' + encodeURIComponent(path || ''), { headers: docsHeaders() });
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error || '목록 조회 실패');
|
||||
return d.entries;
|
||||
if (!d.Success) throw new Error(d.Error || '목록 조회 실패');
|
||||
return d.Entries;
|
||||
}
|
||||
|
||||
async function docsRefresh() {
|
||||
@@ -200,7 +200,7 @@ function docsOpenPath(path) {
|
||||
async function docsFetchText(path) {
|
||||
const r = await fetch('/api/docs/text?path=' + encodeURIComponent(path), { headers: docsHeaders() });
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error || '파일 읽기 실패');
|
||||
if (!d.Success) throw new Error(d.Error || '파일 읽기 실패');
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -223,10 +223,10 @@ async function docsViewText(path) {
|
||||
try {
|
||||
const d = await docsFetchText(path);
|
||||
v.innerHTML = '';
|
||||
if (d.truncated) v.appendChild(docsTruncBanner(d.size));
|
||||
if (d.Truncated) v.appendChild(docsTruncBanner(d.Size));
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'docs-text-pre';
|
||||
pre.textContent = d.text;
|
||||
pre.textContent = d.Text;
|
||||
v.appendChild(pre);
|
||||
} catch (e) { docsViewerError(e); }
|
||||
}
|
||||
@@ -246,11 +246,11 @@ async function docsViewMarkdown(path) {
|
||||
await docsEnsureMdLibs();
|
||||
const d = await docsFetchText(path);
|
||||
v.innerHTML = '';
|
||||
if (d.truncated) v.appendChild(docsTruncBanner(d.size));
|
||||
if (d.Truncated) v.appendChild(docsTruncBanner(d.Size));
|
||||
const body = document.createElement('div');
|
||||
body.className = 'md-body';
|
||||
v.appendChild(body);
|
||||
docsRenderMarkdownInto(body, d.text);
|
||||
docsRenderMarkdownInto(body, d.Text);
|
||||
} catch (e) { docsViewerError(e); }
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ async function docsEditStart() {
|
||||
${isMd ? '<div id="docs-edit-prev" class="md-body docs-edit-prev"></div>' : ''}
|
||||
</div>`;
|
||||
const ta = document.getElementById('docs-edit-ta');
|
||||
ta.value = d.text;
|
||||
ta.value = d.Text;
|
||||
if (isMd) {
|
||||
await docsEnsureMdLibs();
|
||||
const prev = document.getElementById('docs-edit-prev');
|
||||
@@ -570,7 +570,7 @@ async function docsEditSave() {
|
||||
body: JSON.stringify({ path: f.path, content: ta.value }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { alert('저장 실패: ' + (d.error || r.status)); return; }
|
||||
if (!d.Success) { alert('저장 실패: ' + (d.Error || r.status)); return; }
|
||||
docsState.editing = false;
|
||||
docsOpenPath(f.path);
|
||||
docsToast('저장됨');
|
||||
@@ -592,7 +592,7 @@ async function docsMkdirPrompt() {
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { alert('실패: ' + (d.error || '')); return; }
|
||||
if (!d.Success) { alert('실패: ' + (d.Error || '')); return; }
|
||||
await docsRefresh();
|
||||
docsToast('폴더 생성됨');
|
||||
} catch (e) { alert(e.message); }
|
||||
@@ -614,9 +614,9 @@ async function docsUploadDo() {
|
||||
const r = await fetch('/api/docs/upload', { method: 'POST', headers: docsHeaders(), body: fd });
|
||||
const d = await r.json();
|
||||
input.value = '';
|
||||
if (!d.success) { alert('업로드 실패: ' + (d.error || '')); return; }
|
||||
if (!d.Success) { alert('업로드 실패: ' + (d.Error || '')); return; }
|
||||
await docsRefresh();
|
||||
docsToast('업로드됨: ' + d.path);
|
||||
docsToast('업로드됨: ' + d.Path);
|
||||
} catch (e) { input.value = ''; alert(e.message); }
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@ async function docsRenamePrompt(path) {
|
||||
body: JSON.stringify({ from: path, to: next }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { alert('실패: ' + (d.error || '')); return; }
|
||||
if (!d.Success) { alert('실패: ' + (d.Error || '')); return; }
|
||||
await docsRefresh();
|
||||
docsToast('이름 변경됨');
|
||||
} catch (e) { alert(e.message); }
|
||||
@@ -647,7 +647,7 @@ async function docsDeletePrompt(path, type) {
|
||||
const url = '/api/docs?path=' + encodeURIComponent(path) + (isDir ? '&recursive=true' : '');
|
||||
const r = await fetch(url, { method: 'DELETE', headers: docsHeaders() });
|
||||
const d = await r.json();
|
||||
if (!d.success) { alert('삭제 실패: ' + (d.error || '')); return; }
|
||||
if (!d.Success) { alert('삭제 실패: ' + (d.Error || '')); return; }
|
||||
if (docsState.curFile && docsState.curFile.path === path) {
|
||||
docsState.curFile = null;
|
||||
document.getElementById('docs-cur-path').textContent = '파일을 선택하세요';
|
||||
|
||||
@@ -32,8 +32,8 @@ async function evtQuery() {
|
||||
area: area || null,
|
||||
subArea: subArea || null,
|
||||
limit: parseInt(limit),
|
||||
from: fromRaw ? new Date(fromRaw).toISOString() : null,
|
||||
to: toRaw ? new Date(toRaw).toISOString() : null
|
||||
from: fromRaw && fromRaw.trim() ? new Date(fromRaw).toISOString() : null,
|
||||
to: toRaw && toRaw.trim() ? new Date(toRaw).toISOString() : null
|
||||
};
|
||||
|
||||
const infoEl = document.getElementById('evt-result-info');
|
||||
|
||||
@@ -65,7 +65,7 @@ async function fastSessionsLoad() {
|
||||
const list = document.getElementById('fast-session-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (!data.Items || data.Items.length === 0) {
|
||||
list.innerHTML = '<span style="color:var(--t3);font-size:12px;padding:4px 0">세션이 없습니다. + 신규를 눌러 시작하세요.</span>';
|
||||
return;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ async function fastSessionsLoad() {
|
||||
Failed: '실패', RowLimitReached: '행제한', Pending: '대기'
|
||||
};
|
||||
|
||||
data.items.forEach(s => {
|
||||
data.Items.forEach(s => {
|
||||
const isActive = s.Id === fastCurrentSessionId;
|
||||
const dot = statusColor[s.Status] ?? '#aaa';
|
||||
const label = statusLabel[s.Status] ?? s.Status;
|
||||
@@ -137,7 +137,7 @@ async function fastStart() {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert('오류: ' + (err.error ?? '알 수 없는 오류'));
|
||||
alert('오류: ' + (err.Error ?? '알 수 없는 오류'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,14 +228,14 @@ async function fastRenderChart() {
|
||||
|
||||
const container = document.getElementById('fast-chart-container');
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (!data.Items || data.Items.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-muted pt-5">수집된 데이터가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Long 포맷 → PIVOT (recorded_at 기준 그룹화)
|
||||
const grouped = {};
|
||||
for (const r of data.items) {
|
||||
for (const r of data.Items) {
|
||||
if (!grouped[r.RecordedAt]) grouped[r.RecordedAt] = {};
|
||||
grouped[r.RecordedAt][r.TagName] = parseFloat(r.Value) || null;
|
||||
}
|
||||
@@ -244,10 +244,10 @@ async function fastRenderChart() {
|
||||
const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds
|
||||
|
||||
// uPlot data: [[x...], [y1...], [y2...], ...]
|
||||
const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))];
|
||||
const uData = [timesNum, ...data.TagNames.map(tag => times.map(t => grouped[t][tag] ?? null))];
|
||||
|
||||
// 동일 태그 구성이면 setData()로 인플레이스 업데이트 → zoom/pan 상태 유지
|
||||
const tagsKey = data.tagNames.join('\0');
|
||||
const tagsKey = data.TagNames.join('\0');
|
||||
if (fastChart && fastChartTagNames === tagsKey) {
|
||||
fastChart.setData(uData, false); // false = 스케일 유지 (zoom 보존)
|
||||
return;
|
||||
@@ -272,7 +272,7 @@ async function fastRenderChart() {
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...data.tagNames.map(tag => ({
|
||||
...data.TagNames.map(tag => ({
|
||||
label: tag,
|
||||
stroke: fastTagColor(tag),
|
||||
width: 2
|
||||
@@ -458,15 +458,15 @@ paneInit.fast = function() {
|
||||
|
||||
// Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달)
|
||||
const timeMap = {};
|
||||
for (const r of data.items) {
|
||||
for (const r of data.Items) {
|
||||
if (!timeMap[r.RecordedAt]) timeMap[r.RecordedAt] = {};
|
||||
timeMap[r.RecordedAt][r.TagName] = r.Value;
|
||||
}
|
||||
|
||||
const rows = [['recorded_at', ...data.tagNames]];
|
||||
const rows = [['recorded_at', ...data.TagNames]];
|
||||
for (const t of Object.keys(timeMap).sort()) {
|
||||
rows.push([new Date(t).toLocaleString('ko-KR'),
|
||||
...data.tagNames.map(tag => timeMap[t][tag] ?? '')]);
|
||||
...data.TagNames.map(tag => timeMap[t][tag] ?? '')]);
|
||||
}
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
|
||||
@@ -175,8 +175,8 @@ async function ffLoadDash() {
|
||||
catch (e) { return; }
|
||||
try {
|
||||
const ramp = await api('GET', '/api/ff/feed-ramp');
|
||||
ffRampJobs = {}; (ramp.jobs || []).forEach(j => ffRampJobs[j.columnId] = j);
|
||||
ffRampDryRun = !!ramp.dryRun;
|
||||
ffRampJobs = {}; (ramp.Jobs || []).forEach(j => ffRampJobs[j.columnId] = j);
|
||||
ffRampDryRun = !!ramp.DryRun;
|
||||
const modeEl = document.getElementById('ff-ramp-mode');
|
||||
if (modeEl) modeEl.textContent = ffRampDryRun ? '모의(DryRun)' : '실쓰기 모드';
|
||||
} catch (e) { /* 무시 */ }
|
||||
@@ -185,7 +185,7 @@ async function ffLoadDash() {
|
||||
// FEED Target SP 입력 중이면 재렌더 보류(타이핑/포커스 유지)
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.classList && ae.classList.contains('ff-rt')) return;
|
||||
const cols = data.columns || [];
|
||||
const cols = data.Columns || [];
|
||||
if (!cols.length) { host.innerHTML = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
|
||||
host.innerHTML = cols.map(ffCard).join('');
|
||||
}
|
||||
@@ -468,16 +468,16 @@ function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textCo
|
||||
async function ffLoadConfig() {
|
||||
const data = await ffApi('GET', '/api/ff/config');
|
||||
const host = document.getElementById('ff-cfg-list');
|
||||
host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
|
||||
host.innerHTML = (data.Columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
|
||||
host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () =>
|
||||
ffEditColumn(data.columns.find(c => c.id == b.dataset.edit)));
|
||||
ffEditColumn(data.Columns.find(c => c.id == b.dataset.edit)));
|
||||
host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del));
|
||||
}
|
||||
function ffCfgRow(c) {
|
||||
return `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
|
||||
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
|
||||
<button class="btn sm" data-edit="${c.id}">편집</button>
|
||||
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
|
||||
return `<div class="ff-cfg-item"><b>${esc(c.Name)}</b> (id ${c.Id}) — feed ${esc(c.FeedTag)},
|
||||
스트림 ${c.Streams.length}개, ${c.Enabled?'활성':'비활성'}
|
||||
<button class="btn sm" data-edit="${c.Id}">편집</button>
|
||||
<button class="btn sm danger" data-del="${c.Id}">삭제</button></div>`;
|
||||
}
|
||||
async function ffDelete(id) {
|
||||
if (!confirm(`컬럼 ${id} 삭제?`)) return;
|
||||
|
||||
@@ -119,14 +119,14 @@ async function histQuery() {
|
||||
try {
|
||||
const d = await api('POST', '/api/text-to-sql/query-history-interval', body);
|
||||
|
||||
if (!d.success) {
|
||||
throw new Error(d.error || '조회 실패');
|
||||
if (!d.Success) {
|
||||
throw new Error(d.Error || '조회 실패');
|
||||
}
|
||||
|
||||
const rows = d.rows || [];
|
||||
const tNames = d.tagNames || [];
|
||||
const rows = d.Rows || [];
|
||||
const tNames = d.TagNames || [];
|
||||
|
||||
renderHistoryTable(rows, tNames, interval, d.baseIntervalSeconds, d.queryInterval);
|
||||
renderHistoryTable(rows, tNames, interval, d.BaseIntervalSeconds, d.QueryInterval);
|
||||
} catch (e) {
|
||||
histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`);
|
||||
setGlobal('err', '조회 실패');
|
||||
|
||||
@@ -26,7 +26,7 @@ async function kbFetch(method, path, body, opt) {
|
||||
paneInit.kbadmin = async function() {
|
||||
if (kbToken) {
|
||||
const r = await kbFetch('GET', '/api/kb/auth/status');
|
||||
if (r.ok && r.data && r.data.valid) {
|
||||
if (r.ok && r.data && r.data.Valid) {
|
||||
kbShowMain();
|
||||
} else {
|
||||
kbShowLogin('세션이 만료되었습니다. 다시 로그인하세요.');
|
||||
@@ -76,12 +76,12 @@ async function kbLogin() {
|
||||
body: JSON.stringify({ password: pw })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.success) {
|
||||
msg.textContent = '❌ ' + (data.error || '로그인 실패');
|
||||
if (!r.ok || !data.Success) {
|
||||
msg.textContent = '❌ ' + (data.Error || '로그인 실패');
|
||||
return;
|
||||
}
|
||||
kbToken = data.token;
|
||||
kbExpiresAt = data.expiresAt;
|
||||
kbToken = data.Token;
|
||||
kbExpiresAt = data.ExpiresAt;
|
||||
sessionStorage.setItem('kbToken', kbToken);
|
||||
sessionStorage.setItem('kbExpiresAt', kbExpiresAt || '');
|
||||
document.getElementById('kb-pw').value = '';
|
||||
@@ -97,14 +97,14 @@ async function kbLogout() {
|
||||
// ── 컬렉션 ─────────────────────────────────────────────
|
||||
async function kbLoadCollections() {
|
||||
const r = await kbFetch('GET', '/api/kb/collections');
|
||||
if (!r.ok || !r.data || !r.data.success) return;
|
||||
kbCollections = r.data.items || [];
|
||||
if (!r.ok || !r.data || !r.data.Success) return;
|
||||
kbCollections = r.data.Items || [];
|
||||
const fSel = document.getElementById('kb-f-coll');
|
||||
const uSel = document.getElementById('kb-up-coll');
|
||||
fSel.innerHTML = '<option value="">전체</option>' +
|
||||
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
|
||||
kbCollections.map(c => `<option value="${c.Key}">${c.Name}</option>`).join('');
|
||||
uSel.innerHTML = '<option value="">-- 선택 --</option>' +
|
||||
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
|
||||
kbCollections.map(c => `<option value="${c.Key}">${c.Name}</option>`).join('');
|
||||
}
|
||||
|
||||
// ── 목록 ───────────────────────────────────────────────
|
||||
@@ -119,11 +119,11 @@ async function kbRefresh() {
|
||||
qs.set('pageSize', '200');
|
||||
|
||||
const r = await kbFetch('GET', '/api/kb/documents?' + qs.toString());
|
||||
if (!r.ok || !r.data || !r.data.success) {
|
||||
if (!r.ok || !r.data || !r.data.Success) {
|
||||
document.getElementById('kb-doc-table').innerHTML = '<div class="placeholder">조회 실패</div>';
|
||||
return;
|
||||
}
|
||||
kbRenderDocs(r.data.items, r.data.total);
|
||||
kbRenderDocs(r.data.Items, r.data.Total);
|
||||
}
|
||||
|
||||
function kbRenderDocs(items, total) {
|
||||
@@ -134,26 +134,26 @@ function kbRenderDocs(items, total) {
|
||||
tbl.innerHTML = '<div class="placeholder">문서 없음</div>';
|
||||
return;
|
||||
}
|
||||
const collMap = Object.fromEntries(kbCollections.map(c => [c.key, c.name]));
|
||||
const collMap = Object.fromEntries(kbCollections.map(c => [c.Key, c.Name]));
|
||||
const rows = items.map(d => {
|
||||
const tags = (d.tags || []).map(t => `<span class="kb-tag">${t}</span>`).join(' ');
|
||||
const dt = d.uploadedAt ? new Date(d.uploadedAt).toLocaleString('ko-KR') : '';
|
||||
const size = d.fileSize ? kbFmtSize(d.fileSize) : '';
|
||||
const tags = (d.Tags || []).map(t => `<span class="kb-tag">${t}</span>`).join(' ');
|
||||
const dt = d.UploadedAt ? new Date(d.UploadedAt).toLocaleString('ko-KR') : '';
|
||||
const size = d.FileSize ? kbFmtSize(d.FileSize) : '';
|
||||
return `<tr>
|
||||
<td class="mono">${kbShortId(d.id)}</td>
|
||||
<td>${kbEscape(d.title)}</td>
|
||||
<td>${collMap[d.collection] || d.collection}</td>
|
||||
<td class="mono">${kbShortId(d.Id)}</td>
|
||||
<td>${kbEscape(d.Title)}</td>
|
||||
<td>${collMap[d.Collection] || d.Collection}</td>
|
||||
<td>${tags}</td>
|
||||
<td class="mono">${size}</td>
|
||||
<td><span class="kb-status kb-st-${d.status}">${d.status}</span>${d.errorMessage ? `<div class="kb-err" title="${kbEscape(d.errorMessage)}">${kbEscape(d.errorMessage.slice(0,60))}…</div>`:''}</td>
|
||||
<td class="mono">${d.chunkCount || 0}</td>
|
||||
<td><span class="kb-status kb-st-${d.Status}">${d.Status}</span>${d.ErrorMessage ? `<div class="kb-err" title="${kbEscape(d.ErrorMessage)}">${kbEscape(d.ErrorMessage.slice(0,60))}…</div>`:''}</td>
|
||||
<td class="mono">${d.ChunkCount || 0}</td>
|
||||
<td class="mono">${dt}</td>
|
||||
<td>
|
||||
<button class="btn-b btn-sm" onclick="kbDownload('${d.id}')">⬇</button>
|
||||
${(d.chunkCount || 0) > 0 ? `<button class="btn-b btn-sm" onclick="kbShowChunks('${d.id}','${kbEscape(d.title)}')">🔍</button>` : ''}
|
||||
<button class="btn-b btn-sm" onclick="kbReindex('${d.id}')">↻</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDisable('${d.id}')">🚫</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDelete('${d.id}','${kbEscape(d.title)}')">✖</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDownload('${d.Id}')">⬇</button>
|
||||
${(d.ChunkCount || 0) > 0 ? `<button class="btn-b btn-sm" onclick="kbShowChunks('${d.Id}','${kbEscape(d.Title)}')">🔍</button>` : ''}
|
||||
<button class="btn-b btn-sm" onclick="kbReindex('${d.Id}')">↻</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDisable('${d.Id}')">🚫</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDelete('${d.Id}','${kbEscape(d.Title)}')">✖</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -199,8 +199,8 @@ async function kbUploadSubmit() {
|
||||
msg.textContent = '업로드 중...';
|
||||
const r = await fetch('/api/kb/upload', { method: 'POST', headers: kbHeaders(), body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.success) {
|
||||
msg.textContent = '❌ ' + (data.error || ('HTTP ' + r.status));
|
||||
if (!r.ok || !data.Success) {
|
||||
msg.textContent = '❌ ' + (data.Error || ('HTTP ' + r.status));
|
||||
return;
|
||||
}
|
||||
msg.textContent = '✅ 업로드 완료. 인덱싱 진행 중...';
|
||||
@@ -218,8 +218,8 @@ async function kbShowChunks(id, title) {
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
const r = await kbFetch('GET', '/api/kb/documents/' + id + '/chunks?limit=200');
|
||||
if (!r.ok || !r.data || !r.data.success) {
|
||||
body.innerHTML = '<div class="placeholder">조회 실패: ' + ((r.data && r.data.error) || r.status) + '</div>';
|
||||
if (!r.ok || !r.data || !r.data.Success) {
|
||||
body.innerHTML = '<div class="placeholder">조회 실패: ' + ((r.data && r.data.Error) || r.status) + '</div>';
|
||||
return;
|
||||
}
|
||||
kbRenderChunks(r.data.chunks || []);
|
||||
@@ -262,7 +262,7 @@ function kbDownload(id) {
|
||||
async function kbReindex(id) {
|
||||
if (!confirm('재인덱싱하시겠습니까? (Qdrant 기존 청크 삭제 후 다시 처리)')) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/' + id + '/reindex');
|
||||
if (!r.ok) alert('실패: ' + (r.data && r.data.error ? r.data.error : r.status));
|
||||
if (!r.ok) alert('실패: ' + (r.data && r.data.Error ? r.data.Error : r.status));
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ async function kbBulkDisable() {
|
||||
const title = prompt('일괄 비활성화할 제목을 정확히 입력하세요:');
|
||||
if (!title) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/bulk-disable', { title });
|
||||
if (r.ok && r.data && r.data.success) alert(`${r.data.affected}건 비활성화 완료`);
|
||||
if (r.ok && r.data && r.data.Success) alert(`${r.data.Affected}건 비활성화 완료`);
|
||||
else alert('실패');
|
||||
kbRefresh();
|
||||
}
|
||||
@@ -299,7 +299,7 @@ async function kbPurgeDisabled() {
|
||||
}
|
||||
if (!confirm('정말 영구삭제하시겠습니까? (되돌릴 수 없습니다)')) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/purge-disabled', body);
|
||||
if (r.ok && r.data && r.data.success) alert(`${r.data.deleted}건 영구삭제 완료`);
|
||||
if (r.ok && r.data && r.data.Success) alert(`${r.data.Deleted}건 영구삭제 완료`);
|
||||
else alert('실패');
|
||||
kbRefresh();
|
||||
}
|
||||
@@ -321,11 +321,11 @@ async function kbChangePwSubmit() {
|
||||
if (!oldPw || !newPw) { msg.textContent = '❌ 비밀번호를 입력하세요.'; return; }
|
||||
if (newPw.length < 6) { msg.textContent = '❌ 새 비밀번호는 6자 이상.'; return; }
|
||||
const r = await kbFetch('POST', '/api/kb/auth/change-password', { oldPassword: oldPw, newPassword: newPw });
|
||||
if (r.ok && r.data && r.data.success) {
|
||||
if (r.ok && r.data && r.data.Success) {
|
||||
msg.textContent = '✅ 변경 완료. 다시 로그인해 주세요.';
|
||||
setTimeout(() => { kbChangePwClose(); kbLogout(); }, 800);
|
||||
} else {
|
||||
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : '변경 실패');
|
||||
msg.textContent = '❌ ' + (r.data && r.data.Error ? r.data.Error : '변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ function kbStartPoll() {
|
||||
const r = await kbFetch('GET', '/api/kb/documents?status=parsing&pageSize=1');
|
||||
const r2 = await kbFetch('GET', '/api/kb/documents?status=embedding&pageSize=1');
|
||||
const r3 = await kbFetch('GET', '/api/kb/documents?status=pending&pageSize=1');
|
||||
const active = [r, r2, r3].some(x => x.ok && x.data && x.data.total > 0);
|
||||
const active = [r, r2, r3].some(x => x.ok && x.data && x.data.Total > 0);
|
||||
if (active) kbRefresh();
|
||||
}, 1500);
|
||||
}
|
||||
@@ -369,8 +369,8 @@ async function kbInferStart() {
|
||||
seedDocId: ''
|
||||
});
|
||||
|
||||
if (!r.ok || !r.data || !r.data.success) {
|
||||
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : 'HTTP ' + r.status);
|
||||
if (!r.ok || !r.data || !r.data.Success) {
|
||||
msg.textContent = '❌ ' + (r.data && r.data.Error ? r.data.Error : 'HTTP ' + r.status);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -359,13 +359,13 @@ function llmRenderToolPayload(name, ok, preview, payload) {
|
||||
// search_kb 형태
|
||||
if (Array.isArray(j.hits)) return llmRenderKbHits(j.hits);
|
||||
// run_sql/query_with_nl 형태: {success, columns:[], data:[{...}, ...]}
|
||||
if (j.success && Array.isArray(j.columns) && Array.isArray(j.data)) {
|
||||
if (j.Success && Array.isArray(j.Columns) && Array.isArray(j.data)) {
|
||||
const ts = llmDetectTimeSeries(j.data);
|
||||
const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : '';
|
||||
return sparkHtml + llmRenderTable(j.columns, j.data);
|
||||
return sparkHtml + llmRenderTable(j.Columns, j.data);
|
||||
}
|
||||
// query_pv_history 형태: {success, data:[{tag_name, timestamp, value}, ...]}
|
||||
if (j.success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') {
|
||||
// query_pv_history 형태: {Success, data:[{tag_name, timestamp, value}, ...]}
|
||||
if (j.Success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') {
|
||||
const cols = Object.keys(j.data[0]);
|
||||
const ts = llmDetectTimeSeries(j.data);
|
||||
const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : '';
|
||||
@@ -502,8 +502,8 @@ async function llmLoadModels() {
|
||||
const d = await api('GET', `${prefix}/models`);
|
||||
sel.innerHTML = '<option value="">-- 모델을 선택하세요 --</option>';
|
||||
|
||||
if (d.success && d.models) {
|
||||
d.models.forEach(m => {
|
||||
if (d.Success && d.Models) {
|
||||
d.Models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
@@ -515,14 +515,14 @@ async function llmLoadModels() {
|
||||
if (llmType === 'vllm') {
|
||||
try {
|
||||
const cfg = await api('GET', '/api/ollama/config');
|
||||
if (cfg.success && cfg.vllmModel) {
|
||||
if (![...sel.options].some(o => o.value === cfg.vllmModel)) {
|
||||
if (cfg.Success && cfg.VllmModel) {
|
||||
if (![...sel.options].some(o => o.value === cfg.VllmModel)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cfg.vllmModel;
|
||||
opt.textContent = cfg.vllmModel;
|
||||
opt.value = cfg.VllmModel;
|
||||
opt.textContent = cfg.VllmModel;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = cfg.vllmModel;
|
||||
sel.value = cfg.VllmModel;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -534,8 +534,8 @@ async function llmLoadModels() {
|
||||
|
||||
const dot = document.getElementById('llm-conn-status');
|
||||
if (dot) {
|
||||
dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
|
||||
dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`;
|
||||
dot.className = d.Success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
|
||||
dot.title = d.Success ? `${label} 연결됨` : `${label} 연결 실패`;
|
||||
}
|
||||
} catch (e) {
|
||||
const dot = document.getElementById('llm-conn-status');
|
||||
@@ -578,8 +578,11 @@ async function llmLoadMcpTools() {
|
||||
if (!llmUseTools) { llmMcpTools = []; return; }
|
||||
try {
|
||||
const d = await api('GET', '/api/text-to-sql/tools');
|
||||
if (d.success && d.tools) {
|
||||
llmMcpTools = d.tools.map(t => ({
|
||||
// 백엔드 응답 필드는 PascalCase(Success/Tools) — camelCase 폴백 병행
|
||||
const ok = d.Success ?? d.success;
|
||||
const toolList = d.Tools ?? d.tools;
|
||||
if (ok && toolList) {
|
||||
llmMcpTools = toolList.map(t => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.Name || t.name,
|
||||
@@ -638,8 +641,8 @@ async function llmEnsureSummary(sess, model) {
|
||||
|
||||
try {
|
||||
const r = await api('POST', '/api/ollama/summarize', { model, messages: merged });
|
||||
if (r && r.success && r.summary) {
|
||||
sess.summary = r.summary;
|
||||
if (r && r.Success && r.Summary) {
|
||||
sess.summary = r.Summary;
|
||||
sess.summarizedUpTo = targetEnd;
|
||||
llmSaveSessions();
|
||||
}
|
||||
@@ -948,10 +951,10 @@ async function llmSaveConfig() {
|
||||
const port = parseInt(document.getElementById('llm-port').value) || 11434;
|
||||
try {
|
||||
const d = await api('POST', '/api/ollama/config', { host, port });
|
||||
if (d.success) {
|
||||
if (d.Success) {
|
||||
alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.');
|
||||
} else {
|
||||
alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류'));
|
||||
alert('설정 저장 실패: ' + (d.Error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('설정 저장 실패: ' + e.message);
|
||||
@@ -965,10 +968,10 @@ async function llmTestConnection() {
|
||||
const d = await api('GET', `${prefix}/ping`);
|
||||
const dot = document.getElementById('llm-conn-status');
|
||||
if (dot) {
|
||||
dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
|
||||
dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패: ${d.error || ''}`;
|
||||
dot.className = d.Success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
|
||||
dot.title = d.Success ? `${label} 연결됨` : `${label} 연결 실패: ${d.Error || ''}`;
|
||||
}
|
||||
alert(d.success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.error || ''}`);
|
||||
alert(d.Success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.Error || ''}`);
|
||||
} catch (e) {
|
||||
alert(`${label} 연결 테스트 실패: ` + e.message);
|
||||
}
|
||||
@@ -976,25 +979,25 @@ async function llmTestConnection() {
|
||||
|
||||
function llmLoadConfigToUI() {
|
||||
api('GET', '/api/ollama/config').then(d => {
|
||||
if (d.success) {
|
||||
if (d.Success) {
|
||||
const hostEl = document.getElementById('llm-host');
|
||||
const portEl = document.getElementById('llm-port');
|
||||
if (hostEl && d.host) hostEl.value = d.host;
|
||||
if (portEl && d.port) portEl.value = d.port;
|
||||
if (hostEl && d.Host) hostEl.value = d.Host;
|
||||
if (portEl && d.Port) portEl.value = d.Port;
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
api('GET', '/api/ollama/config').then(d => {
|
||||
if (d.success && d.vllmModel) {
|
||||
if (d.Success && d.VllmModel) {
|
||||
const sel = document.getElementById('llm-model-select');
|
||||
if (!sel) return;
|
||||
if (![...sel.options].some(o => o.value === d.vllmModel)) {
|
||||
if (![...sel.options].some(o => o.value === d.VllmModel)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.vllmModel;
|
||||
opt.textContent = d.vllmModel;
|
||||
opt.value = d.VllmModel;
|
||||
opt.textContent = d.VllmModel;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = d.vllmModel;
|
||||
sel.value = d.VllmModel;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ async function pbBuild() {
|
||||
if (!confirm('모든 활성 태그를 해제하고 조건에 맞는 태그만 활성화합니다. 계속하시겠습니까?')) return;
|
||||
const payload = pbCollectAllGroups();
|
||||
const res = await pbApi('/api/pointbuilder/build', 'POST', payload);
|
||||
alert(res.message || `${res.count}개 활성화됨`);
|
||||
alert(res.Message || `${res.Count}개 활성화됨`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
@@ -97,7 +97,7 @@ async function pbApply() {
|
||||
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
|
||||
if (!confirm(`[${ctrl}] 기존 활성화를 모두 해제하고 선택한 ${selected.length}개만 활성화합니다. 계속하시겠습니까?`)) return;
|
||||
const res = await pbApi('/api/pointbuilder/apply', 'POST', { selectedTagNames: selected, controllerId: ctrl });
|
||||
alert(res.message || `${res.count}개 활성화됨`);
|
||||
alert(res.Message || `${res.Count}개 활성화됨`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
@@ -108,8 +108,8 @@ async function pbAppend() {
|
||||
if (selected.length === 0) { alert('미리보기에서 추가할 태그를 선택해주세요.'); return; }
|
||||
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
|
||||
const res = await pbApi('/api/pointbuilder/append', 'POST', { selectedTagNames: selected, controllerId: ctrl });
|
||||
if (res.count > 0) {
|
||||
alert(`${res.count}개 추가됨`);
|
||||
if (res.Count > 0) {
|
||||
alert(`${res.Count}개 추가됨`);
|
||||
} else {
|
||||
alert('추가할 새 태그가 없습니다 (이미 모두 활성화되어 있음)');
|
||||
}
|
||||
@@ -132,7 +132,7 @@ async function pbAddManual() {
|
||||
};
|
||||
|
||||
const res = await pbApi('/api/pointbuilder/add', 'POST', payload);
|
||||
if (res.success) {
|
||||
if (res.Success) {
|
||||
alert(`등록 완료: ${tagName}`);
|
||||
document.getElementById('pb-add-tag').value = '';
|
||||
document.getElementById('pb-add-addr').value = '';
|
||||
@@ -140,7 +140,7 @@ async function pbAddManual() {
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
} else {
|
||||
alert(`오류: ${res.error}`);
|
||||
alert(`오류: ${res.Error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ function pbRenderPreview(res) {
|
||||
const tbody = document.getElementById('pb-preview-tbody');
|
||||
const count = document.getElementById('pb-preview-count');
|
||||
|
||||
if (!res.items || res.items.length === 0) {
|
||||
if (!res.Items || res.Items.length === 0) {
|
||||
area.style.display = 'block';
|
||||
count.textContent = '(0건)';
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px;color:#555">조건에 맞는 태그가 없습니다</td></tr>';
|
||||
@@ -235,20 +235,20 @@ function pbRenderPreview(res) {
|
||||
}
|
||||
|
||||
area.style.display = 'block';
|
||||
count.textContent = `(${res.count}건)`;
|
||||
count.textContent = `(${res.Count}건)`;
|
||||
|
||||
tbody.innerHTML = res.items.map(item => {
|
||||
const badgeClass = `param-badge p-${item.paramType}`.replace(/[^a-zA-Z0-9\s-]/g, '');
|
||||
tbody.innerHTML = res.Items.map(item => {
|
||||
const badgeClass = `param-badge p-${item.ParamType}`.replace(/[^a-zA-Z0-9\s-]/g, '');
|
||||
return `<tr>
|
||||
<td><input type="checkbox" value="${item.tagName}" ${item.isActive ? 'checked' : ''}></td>
|
||||
<td>${item.tagName}</td>
|
||||
<td><span class="${badgeClass}">${item.paramType || '—'}</span></td>
|
||||
<td>${item.dataType}</td>
|
||||
<td>0x${(item.modbusAddr ?? 0).toString(16).toUpperCase().padStart(4,'0')}</td>
|
||||
<td>${item.loopNo ?? '—'}</td>
|
||||
<td>${item.access}</td>
|
||||
<td><span class="group-badge">${item.group}</span></td>
|
||||
<td>${item.isActive ? '✓' : ''}</td>
|
||||
<td><input type="checkbox" value="${item.TagName}" ${item.IsActive ? 'checked' : ''}></td>
|
||||
<td>${item.TagName}</td>
|
||||
<td><span class="${badgeClass}">${item.ParamType || '—'}</span></td>
|
||||
<td>${item.DataType}</td>
|
||||
<td>0x${(item.ModbusAddr ?? 0).toString(16).toUpperCase().padStart(4,'0')}</td>
|
||||
<td>${item.LoopNo ?? '—'}</td>
|
||||
<td>${item.Access}</td>
|
||||
<td><span class="group-badge">${item.Group}</span></td>
|
||||
<td>${item.IsActive ? '✓' : ''}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -346,7 +346,7 @@ async function pbRealtimeAdd() {
|
||||
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
|
||||
if (!confirm(`[${ctrl}] 선택한 ${ids.length}개를 실시간(realtime_table)에 추가합니다. 계속하시겠습니까?`)) return;
|
||||
const res = await pbApi('/api/pointbuilder/realtime-add', 'POST', { ids, controllerId: ctrl });
|
||||
alert(res.message || `${res.count}개 실시간 편입`);
|
||||
alert(res.Message || `${res.Count}개 실시간 편입`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
@@ -359,7 +359,7 @@ async function pbRealtimeRemove() {
|
||||
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
|
||||
if (!confirm(`[${ctrl}] 선택한 ${ids.length}개를 실시간(realtime_table)에서 제거합니다. 계속하시겠습니까?`)) return;
|
||||
const res = await pbApi('/api/pointbuilder/realtime-remove', 'POST', { ids, controllerId: ctrl });
|
||||
alert(res.message || `${res.count}개 실시간 제거`);
|
||||
alert(res.Message || `${res.Count}개 실시간 제거`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
@@ -367,7 +367,7 @@ async function pbRealtimeRemove() {
|
||||
async function pbDeleteOne(id) {
|
||||
if (!confirm('이 태그를 비활성화하고 realtime_table에서 제거합니다. 계속하시겠습니까?')) return;
|
||||
const res = await pbApi(`/api/pointbuilder/${id}`, 'DELETE');
|
||||
if (res.success) {
|
||||
if (res.Success) {
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
@@ -431,10 +431,10 @@ async function pbSinamUploadBtn() {
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/pointbuilder/sinam/upload', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'upload failed');
|
||||
if (!data.Success) throw new Error(data.Error || 'upload failed');
|
||||
|
||||
await pbSinamLoadExisting();
|
||||
_pbSinamSetFile(data.file);
|
||||
_pbSinamSetFile(data.File);
|
||||
fileInput.value = '';
|
||||
} catch (e) {
|
||||
alert('업로드 오류: ' + e.message);
|
||||
@@ -466,17 +466,17 @@ async function pbSinamParse() {
|
||||
try {
|
||||
const res = await pbApi('/api/pointbuilder/sinam/parse', 'POST', { file: filePath, controller: ctrl, applyDb });
|
||||
|
||||
if (!res.success) {
|
||||
resultEl.innerHTML = `<span style="color:#c55">오류: ${res.error || '알 수 없는 오류'}</span>`;
|
||||
if (!res.Success) {
|
||||
resultEl.innerHTML = `<span style="color:#c55">오류: ${res.Error || '알 수 없는 오류'}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = document.getElementById('pb-sinam-existing');
|
||||
const fileName = sel?.options[sel.selectedIndex]?.text || '';
|
||||
const lines = [`<span style="color:#3c3">✓ 완료</span>`];
|
||||
if (res.registers !== undefined) lines.push(`레지스터: ${res.registers}개`);
|
||||
if (res.archive !== undefined) lines.push(`archive: ${res.archive}개`);
|
||||
if (res.loops !== undefined) lines.push(`루프: ${res.loops}개`);
|
||||
if (res.Registers !== undefined) lines.push(`레지스터: ${res.Registers}개`);
|
||||
if (res.Archive !== undefined) lines.push(`archive: ${res.Archive}개`);
|
||||
if (res.Loops !== undefined) lines.push(`루프: ${res.Loops}개`);
|
||||
lines.push(`<span style="color:#888;font-size:11px">${fileName}</span>`);
|
||||
resultEl.innerHTML = lines.join('<br>');
|
||||
|
||||
@@ -500,9 +500,9 @@ async function pbSinamGwRestart() {
|
||||
resultEl.innerHTML = '<span style="color:#888">재시작 중...</span>';
|
||||
try {
|
||||
const res = await pbApi(`/api/setup/gateway/restart?id=${ctrl}`, 'POST');
|
||||
resultEl.innerHTML = res.success
|
||||
? `<span style="color:#3c3">✓ ${res.message || '재시작 완료'}</span>`
|
||||
: `<span style="color:#c55">${res.message || '재시작 실패'}</span>`;
|
||||
resultEl.innerHTML = res.Success
|
||||
? `<span style="color:#3c3">✓ ${res.Message || '재시작 완료'}</span>`
|
||||
: `<span style="color:#c55">${res.Message || '재시작 실패'}</span>`;
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:#c55">오류: ${e.message}</span>`;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function pidHandleError(error, context = '작업') {
|
||||
|
||||
let msg = '알 수 없는 오류가 발생했습니다.';
|
||||
if (error && typeof error === 'object') {
|
||||
msg = error.message || error.error || error.details || (error instanceof Error ? error.message : msg);
|
||||
msg = error.message || error.Error || error.Details || (error instanceof Error ? error.message : msg);
|
||||
} else if (typeof error === 'string') {
|
||||
msg = error;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async function pidRestoreBuildStatus() {
|
||||
try {
|
||||
const statusRes = await api('GET', `/api/pidgraph/status/${savedTaskId}`);
|
||||
|
||||
if (statusRes.status === 'Processing' || statusRes.status === 'Starting') {
|
||||
if (statusRes.Status === 'Processing' || statusRes.Status === 'Starting') {
|
||||
progWrap.classList.remove('hidden');
|
||||
|
||||
const startTime = parseInt(savedStartTime);
|
||||
@@ -90,18 +90,18 @@ async function pidRestoreBuildStatus() {
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const res = JSON.parse(event.data);
|
||||
progBar.style.width = `${res.progress}%`;
|
||||
statusTxt.textContent = res.message;
|
||||
progBar.style.width = `${res.Progress}%`;
|
||||
statusTxt.textContent = res.Message;
|
||||
updateTimer();
|
||||
|
||||
if (res.status === 'Completed') {
|
||||
if (res.Status === 'Completed') {
|
||||
statusTxt.textContent = '추출이 완료되었습니다.';
|
||||
progWrap.classList.add('hidden');
|
||||
localStorage.removeItem('pid_build_task_id');
|
||||
localStorage.removeItem('pid_build_start_time');
|
||||
eventSource.close();
|
||||
} else if (res.status === 'Failed') {
|
||||
statusTxt.textContent = `오류 발생: ${res.message}`;
|
||||
} else if (res.Status === 'Failed') {
|
||||
statusTxt.textContent = `오류 발생: ${res.Message}`;
|
||||
progWrap.classList.add('hidden');
|
||||
localStorage.removeItem('pid_build_task_id');
|
||||
localStorage.removeItem('pid_build_start_time');
|
||||
@@ -119,7 +119,7 @@ async function pidRestoreBuildStatus() {
|
||||
|
||||
setInterval(updateTimer, 1000);
|
||||
startSse(savedTaskId);
|
||||
} else if (statusRes.status === 'Completed') {
|
||||
} else if (statusRes.Status === 'Completed') {
|
||||
localStorage.removeItem('pid_build_task_id');
|
||||
localStorage.removeItem('pid_build_start_time');
|
||||
}
|
||||
@@ -193,7 +193,7 @@ async function pidBuildGraph(filepath) {
|
||||
// 1. 빌드 요청
|
||||
statusTxt.textContent = '추출 요청 중...';
|
||||
const res = await api('POST', '/api/pidgraph/build', { filepath });
|
||||
const taskId = res.taskId;
|
||||
const taskId = res.Data.TaskId;
|
||||
|
||||
// 상태 복구를 위해 localStorage에 저장
|
||||
localStorage.setItem('pid_build_task_id', taskId);
|
||||
@@ -205,17 +205,17 @@ async function pidBuildGraph(filepath) {
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const statusRes = JSON.parse(event.data);
|
||||
progBar.style.width = `${statusRes.progress}%`;
|
||||
statusTxt.textContent = statusRes.message;
|
||||
progBar.style.width = `${statusRes.Progress}%`;
|
||||
statusTxt.textContent = statusRes.Message;
|
||||
updateTimer();
|
||||
|
||||
if (statusRes.status === 'Completed') {
|
||||
if (statusRes.Status === 'Completed') {
|
||||
eventSource.close();
|
||||
setGlobal('ok', '추출 완료');
|
||||
resolve();
|
||||
} else if (statusRes.status === 'Failed') {
|
||||
} else if (statusRes.Status === 'Failed') {
|
||||
eventSource.close();
|
||||
reject(new Error(statusRes.message));
|
||||
reject(new Error(statusRes.Message));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ async function pidUpload() {
|
||||
|
||||
const res = await fetch('/api/pid/upload', { method: 'POST', body: formData });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
const err = await res.json().catch(() => ({ Error: res.statusText }));
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.fileName} (${(data.fileSize / 1024).toFixed(0)} KB)`;
|
||||
if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.FileName} (${(data.FileSize / 1024).toFixed(0)} KB)`;
|
||||
|
||||
await pidLoadServerFiles(data.fileName);
|
||||
await pidLoadServerFiles(data.FileName);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = `❌ 전송 실패: ${e.message}`;
|
||||
}
|
||||
@@ -64,12 +64,12 @@ async function pidLoadServerFiles(selectFileName) {
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
if (!data.Files || data.Files.length === 0) {
|
||||
sel.innerHTML = '<option value="">-- 서버에 파일 없음 --</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = data.files.map(f =>
|
||||
`<option value="${esc(f.fileName)}">${esc(f.fileName)} (${(f.fileSize / 1024).toFixed(0)} KB)</option>`
|
||||
sel.innerHTML = data.Files.map(f =>
|
||||
`<option value="${esc(f.FileName)}">${esc(f.FileName)} (${(f.FileSize / 1024).toFixed(0)} KB)</option>`
|
||||
).join('');
|
||||
|
||||
if (selectFileName) sel.value = selectFileName;
|
||||
@@ -120,19 +120,19 @@ async function pidExtract() {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
const err = await res.json().catch(() => ({ Error: res.statusText }));
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
pidExtracting = false;
|
||||
const skipMsg = data.skippedDuplicates > 0 ? ` (${data.skippedDuplicates}건 중복 제외)` : '';
|
||||
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}건${skipMsg}`;
|
||||
const skipMsg = data.SkippedDuplicates > 0 ? ` (${data.SkippedDuplicates}건 중복 제외)` : '';
|
||||
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.TotalCount}건${skipMsg}`;
|
||||
log('pid-log', [
|
||||
{ c: 'ok', t: `✅ 추출 완료: ${data.totalCount}건${skipMsg}` },
|
||||
{ c: 'inf', t: ` 신뢰도 70%+: ${data.confidenceItems}건` },
|
||||
{ c: 'inf', t: ` 신뢰도 50%미만: ${data.lowConfidenceItems}건` },
|
||||
...(data.skippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.skippedDuplicates}건` }] : [])
|
||||
{ c: 'ok', t: `✅ 추출 완료: ${data.TotalCount}건${skipMsg}` },
|
||||
{ c: 'inf', t: ` 신뢰도 70%+: ${data.ConfidenceItems}건` },
|
||||
{ c: 'inf', t: ` 신뢰도 50%미만: ${data.LowConfidenceItems}건` },
|
||||
...(data.SkippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.SkippedDuplicates}건` }] : [])
|
||||
]);
|
||||
|
||||
pidCurrentPage = 1;
|
||||
@@ -189,7 +189,7 @@ async function pidLoadTable(page = 1) {
|
||||
const data = await res.json();
|
||||
pidLastResult = data;
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (!data.Items || data.Items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="14" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
|
||||
document.getElementById('pid-pagination').innerHTML = '';
|
||||
return;
|
||||
@@ -200,48 +200,48 @@ async function pidLoadTable(page = 1) {
|
||||
|
||||
// 현재 카테고리 → 가상 키 변환 (instrument + tagDcs 조합)
|
||||
function pidVcat(item) {
|
||||
if (item.category === 'instrument') return item.tagDcs ? 'instrument_dcs' : 'instrument_field';
|
||||
return item.category || '';
|
||||
if (item.Category === 'instrument') return item.TagDcs ? 'instrument_dcs' : 'instrument_field';
|
||||
return item.Category || '';
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.items.map(item => {
|
||||
tbody.innerHTML = data.Items.map(item => {
|
||||
const vcat = pidVcat(item);
|
||||
const catOpts = CATEGORY_ORDER.map(k =>
|
||||
`<option value="${k}" ${vcat === k ? 'selected' : ''}>${CATEGORY_META[k]?.label || k}</option>`
|
||||
).join('');
|
||||
return `<tr>
|
||||
<td>${item.id}</td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="tagNo" value="${esc((item.tagName||'').toUpperCase())}" style="width:100%;font-family:var(--mono)"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="equipmentName" value="${esc(item.equipmentName) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="instrumentType" value="${esc((item.instrumentType||'').toUpperCase())}" style="width:100%"/></td>
|
||||
<td>${item.Id}</td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="tagNo" value="${esc((item.TagNo||'').toUpperCase())}" style="width:100%;font-family:var(--mono)"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="equipmentName" value="${esc(item.EquipmentName) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="instrumentType" value="${esc((item.InstrumentType||'').toUpperCase())}" style="width:100%"/></td>
|
||||
<td>
|
||||
<select class="inp pid-edit-vcat" data-id="${item.id}" style="width:100%">
|
||||
<select class="inp pid-edit-vcat" data-id="${item.Id}" style="width:100%">
|
||||
<option value="">-</option>
|
||||
${catOpts}
|
||||
</select>
|
||||
</td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="role" value="${esc(item.role) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromTag" value="${esc(item.fromTag) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromAt" value="${esc(item.fromAt) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toTag" value="${esc(item.toTag) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toAt" value="${esc(item.toAt) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.id}" data-field="subArea" value="${esc(item.subArea) || ''}" placeholder="예: P9-1" style="width:100%;font-family:var(--mono)"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="role" value="${esc(item.Role) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="fromTag" value="${esc(item.FromTag) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="fromAt" value="${esc(item.FromAt) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="toTag" value="${esc(item.ToTag) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="toAt" value="${esc(item.ToAt) || ''}" style="width:100%"/></td>
|
||||
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="subArea" value="${esc(item.SubArea) || ''}" placeholder="예: P9-1" style="width:100%;font-family:var(--mono)"/></td>
|
||||
<td>
|
||||
${item.experionTagId
|
||||
? `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">✅</button>`
|
||||
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
|
||||
${item.ExperionTagId
|
||||
? `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.Id}, this)">✅</button>`
|
||||
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.Id}, this)">매핑</button>`
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:center">
|
||||
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.id})">💾</button>
|
||||
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.Id})">💾</button>
|
||||
</td>
|
||||
<td style="text-align:center">
|
||||
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">✕</button>
|
||||
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.Id})" title="삭제">✕</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
pidRenderPagination(data.total, page);
|
||||
pidRenderPagination(data.Total, page);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="14" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
|
||||
}
|
||||
@@ -331,8 +331,8 @@ async function pidCreateRow(btn) {
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
const err = await res.json().catch(() => ({ Error: res.statusText }));
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
row.remove();
|
||||
await pidLoadTable(pidCurrentPage);
|
||||
@@ -371,7 +371,7 @@ async function pidDeleteRow(id) {
|
||||
const res = await fetch(`/api/pid/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || res.statusText);
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
await pidLoadTable(pidCurrentPage);
|
||||
pidUpdateStats();
|
||||
@@ -381,11 +381,11 @@ async function pidDeleteRow(id) {
|
||||
}
|
||||
|
||||
function pidUpdateStats() {
|
||||
if (!pidLastResult || !pidLastResult.items) return;
|
||||
if (!pidLastResult || !pidLastResult.Items) return;
|
||||
|
||||
const total = pidLastResult.total || 0;
|
||||
const highConf = pidLastResult.items.filter(i => i.confidence >= 0.7).length;
|
||||
const mapped = pidLastResult.items.filter(i => i.experionTagId).length;
|
||||
const total = pidLastResult.Total || 0;
|
||||
const highConf = pidLastResult.Items.filter(i => i.Confidence >= 0.7).length;
|
||||
const mapped = pidLastResult.Items.filter(i => i.ExperionTagId).length;
|
||||
|
||||
const elTotal = document.getElementById('pid-stat-total');
|
||||
const elHigh = document.getElementById('pid-stat-high');
|
||||
@@ -424,13 +424,13 @@ async function pidAnalyzeConnections() {
|
||||
body: JSON.stringify({ fileName })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
const err = await res.json().catch(() => ({ Error: res.statusText }));
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.connectionCount}건`;
|
||||
if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.ConnectionCount}건`;
|
||||
log('pid-log', [
|
||||
{ c: 'ok', t: `✅ 연결 분석 완료: ${data.connectionCount}개 from→to 연결` }
|
||||
{ c: 'ok', t: `✅ 연결 분석 완료: ${data.ConnectionCount}개 from→to 연결` }
|
||||
]);
|
||||
pidCurrentPage = 1;
|
||||
await pidLoadTable();
|
||||
@@ -457,8 +457,8 @@ async function pidDeleteAll() {
|
||||
const res = await fetch('/api/pid/all', { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (statusEl) statusEl.textContent = `🗑 ${data.message}`;
|
||||
log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.deletedCount}건` }]);
|
||||
if (statusEl) statusEl.textContent = `🗑 ${data.Message}`;
|
||||
log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.DeletedCount}건` }]);
|
||||
pidCurrentPage = 1;
|
||||
await pidLoadTable();
|
||||
pidUpdateStats();
|
||||
@@ -490,7 +490,7 @@ const CATEGORY_META = {
|
||||
process_equipment:{ label: 'Process Equipment', badge: '', dbCat: 'process_equipment', tagDcs: false },
|
||||
utility_equipment:{ label: 'Utility Equipment', badge: 'warn', dbCat: 'utility_equipment', tagDcs: false },
|
||||
pipings: { label: 'Pipings', badge: 'ok', dbCat: 'pipings', tagDcs: false },
|
||||
// ── 장비 테이블 배지용 (equipment 목록의 r.category 값과 매핑) ──
|
||||
// ── 장비 테이블 배지용 (equipment 목록의 r.Category 값과 매핑) ──
|
||||
instrument: { label: 'Instrument', badge: 'ok' },
|
||||
};
|
||||
const CATEGORY_ORDER = [
|
||||
@@ -511,7 +511,7 @@ async function pidRefreshPrefixRules() {
|
||||
const res = await fetch('/api/pid/prefix-rules');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
const items = data.Items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">규칙이 없습니다. 아래에서 추가하세요.</div>';
|
||||
@@ -521,8 +521,8 @@ async function pidRefreshPrefixRules() {
|
||||
// instrument → tag_dcs 기준으로 가상 분할
|
||||
const grouped = {};
|
||||
for (const r of items) {
|
||||
let key = r.category;
|
||||
if (r.category === 'instrument') {
|
||||
let key = r.Category;
|
||||
if (r.Category === 'instrument') {
|
||||
key = r.tagDcs ? 'instrument_dcs' : 'instrument_field';
|
||||
}
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
@@ -550,11 +550,11 @@ async function pidRefreshPrefixRules() {
|
||||
if (rules) {
|
||||
for (const r of rules) {
|
||||
html += `<div class="pid-cat-row" data-sort-order="${r.sortOrder}">
|
||||
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
|
||||
<input class="inp pid-cat-prefix-input" value="${esc(r.Prefix)}" style="width:80px;font-family:var(--mono)" />
|
||||
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
|
||||
<span class="pid-cat-actions">
|
||||
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
|
||||
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
|
||||
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.Id}, this)">수정</button>
|
||||
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.Id}, '${esc(r.Prefix)}')">삭제</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -594,8 +594,8 @@ async function pidAddPrefixRule(vcat) {
|
||||
body: JSON.stringify({ prefix, category, tagDcs, description: desc, sortOrder: order })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
const err = await res.json().catch(() => ({ Error: res.statusText }));
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
row.querySelector('.pid-cat-prefix-input').value = '';
|
||||
row.querySelector('.pid-cat-desc-input').value = '';
|
||||
@@ -626,8 +626,8 @@ async function pidUpdatePrefixRule(id, btn) {
|
||||
body: JSON.stringify({ prefix, category, tagDcs, description: desc, sortOrder: order })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
const err = await res.json().catch(() => ({ Error: res.statusText }));
|
||||
throw new Error(err.Error || res.statusText);
|
||||
}
|
||||
await pidRefreshPrefixRules();
|
||||
} catch (e) {
|
||||
@@ -652,7 +652,7 @@ async function pidApplyCategories() {
|
||||
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
alert(`${data.applied}건 category 적용 완료`);
|
||||
alert(`${data.Applied}건 category 적용 완료`);
|
||||
await pidLoadTable(pidCurrentPage);
|
||||
pidUpdateStats();
|
||||
} catch (e) {
|
||||
|
||||
120
src/Hc900Crawler/wwwroot/js/reports.js
Normal file
120
src/Hc900Crawler/wwwroot/js/reports.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/* P0 리포트 패널 — ① 웹에서 바로 보기 + ② 엑셀 export.
|
||||
pane HTML은 innerHTML 주입이라 <script> 미실행 → 전역 로드 + paneInit 등록(멱등). */
|
||||
paneInit['reports'] = function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const yKst = () => new Date(Date.now() + 9 * 3600e3 - 86400e3).toISOString().slice(0, 10);
|
||||
|
||||
// ── 공통: 소스 select → session 입력 토글 ──
|
||||
const tog = (sel, wrap) => { const s = $(sel), w = $(wrap); if (s && w) { s.onchange = () => w.style.display = s.value === 'fast_record' ? '' : 'none'; s.onchange(); } };
|
||||
tog('rvSource', 'rvSessWrap'); tog('rpSource', 'rpSessionWrap');
|
||||
|
||||
// ── ① 웹에서 바로 보기 ──
|
||||
const col = $('rvCol'), date = $('rvDate'), go = $('rvGo'), out = $('rvOut');
|
||||
if (date && !date.value) date.value = yKst();
|
||||
|
||||
if (col && !col.dataset.loaded) {
|
||||
fetch('/api/report/columns').then(r => r.json()).then(list => {
|
||||
col.innerHTML = list.map(c => `<option value="${c.Column}">${c.Column}${c.HasClosure ? '' : ' (폐합X)'}</option>`).join('');
|
||||
col.dataset.loaded = '1';
|
||||
}).catch(() => { col.innerHTML = '<option value="C-6111">C-6111</option>'; });
|
||||
}
|
||||
|
||||
if (go) go.onclick = async () => {
|
||||
out.innerHTML = '<span class="mono">⏳ 조회 중...</span>';
|
||||
try {
|
||||
let url = `/api/report/summary?column=${encodeURIComponent(col.value)}&date=${date.value}&source=${$('rvSource').value}`;
|
||||
if ($('rvSource').value === 'fast_record' && $('rvSess').value) url += `&sessionId=${$('rvSess').value}`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('조회 실패 ' + r.status);
|
||||
out.innerHTML = renderSummary(await r.json());
|
||||
} catch (e) { out.innerHTML = `<span class="mono" style="color:#e66">❌ ${typeof esc === 'function' ? esc(e.message) : e.message}</span>`; }
|
||||
};
|
||||
|
||||
// ── ② 엑셀 export ──
|
||||
const tpl = $('rpTpl'), gen = $('rpGen'), status = $('rpStatus');
|
||||
if ($('rpDate') && !$('rpDate').value) $('rpDate').value = yKst();
|
||||
if (gen) gen.onclick = async () => {
|
||||
if (!tpl.files[0]) { status.textContent = '⚠️ 엑셀 템플릿을 선택하세요.'; return; }
|
||||
gen.disabled = true; status.textContent = '⏳ 생성 중...';
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', tpl.files[0]); fd.append('name', tpl.files[0].name);
|
||||
const up = await fetch('/api/report/template', { method: 'POST', body: fd });
|
||||
if (!up.ok) throw new Error('업로드 실패 ' + up.status);
|
||||
const { Id } = await up.json();
|
||||
let url = `/api/report/generate?templateId=${Id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
|
||||
if ($('rpSource').value === 'fast_record' && $('rpSession').value) url += `&sessionId=${$('rpSession').value}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('생성 실패 ' + resp.status);
|
||||
const st = resp.headers.get('X-Report-Status') || '?';
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(await resp.blob());
|
||||
a.download = `report_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
|
||||
status.textContent = `✅ 완료 (상태=${st})`;
|
||||
} catch (e) { status.textContent = '❌ ' + e.message; } finally { gen.disabled = false; }
|
||||
};
|
||||
};
|
||||
|
||||
/* ── 렌더 헬퍼 ───────────────────────────────────────────── */
|
||||
function rpFmt(v, kind) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (kind === 'kg') return Math.round(v).toLocaleString();
|
||||
if (kind === 'ratio') return Number(v).toFixed(4);
|
||||
if (kind === 'pct') return Number(v).toFixed(2);
|
||||
return Number(v).toFixed(3);
|
||||
}
|
||||
function rpMeta(m) {
|
||||
if (m.Status !== 'ok') return `<span style="color:#e66">${m.Status}${m.Error ? ' · ' + m.Error : ''}</span>`;
|
||||
return `<span style="color:var(--t2)">src=${m.Source} ${m.SamplingMs}ms · n=${m.N}</span>`;
|
||||
}
|
||||
function rpVal(m, kind) { return m.Status === 'ok' && m.Value != null ? rpFmt(m.Value, kind) : (m.Status === 'no_data' ? 'N/A' : 'ERR'); }
|
||||
|
||||
function renderSummary(d) {
|
||||
const by = {}; (d.Metrics || []).forEach(m => by[m.Metric] = m);
|
||||
const cl = by['mass_balance_closure'];
|
||||
let html = `<div class="mono" style="margin-bottom:10px;color:var(--t2)">${d.Column} · ${d.Date} · ${d.Source}</div>`;
|
||||
|
||||
// 물질수지 신뢰블록
|
||||
if (cl) {
|
||||
const pct = cl.Status === 'ok' ? cl.Value : null;
|
||||
const ok = pct != null && pct >= 98 && pct <= 101;
|
||||
const color = pct == null ? '#888' : (ok ? '#2ea043' : '#e5534b');
|
||||
const e = cl.Extra || {};
|
||||
html += `
|
||||
<div style="border:1px solid ${color};border-radius:8px;padding:14px;margin-bottom:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--t2)">물질수지 폐합 (운전원 검산)</div>
|
||||
<div><b>원료(IN)</b> ${rpFmt(e.feed_qv,'kg')} → <b>회수(OUT)</b> ${rpFmt(e.out_total,'kg')} kg</div>
|
||||
<div style="font-size:12px;color:var(--t2);margin-top:2px">
|
||||
제품 ${rpFmt(e.out0_qv,'kg')} · 경비물 ${rpFmt(e.out1_qv,'kg')} · 중비물 ${rpFmt(e.out2_qv,'kg')}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:30px;font-weight:700;color:${color}">${pct == null ? 'N/A' : rpFmt(pct,'pct') + '%'}</div>
|
||||
<div style="font-size:11px;color:var(--t2)">${pct == null ? '' : (ok ? '✓ 정상(98~101%)' : '⚠ 베이스라인 이탈 — 계량 점검')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 메트릭 표
|
||||
const rows = [
|
||||
['생산량 (제품 적산Δ)', by['production_total'], 'kg'],
|
||||
['수율 (제품/원료)', by['yield_qv'], 'ratio'],
|
||||
['에너지원단위 (스팀/제품)', by['energy_intensity_qv'], 'ratio'],
|
||||
['하부온도 평균잔차', by['control_residual'], 'degC'],
|
||||
].filter(r => r[1]);
|
||||
html += `<table style="border-collapse:collapse;width:100%;font-size:14px">
|
||||
<tr style="text-align:left;color:var(--t2);border-bottom:1px solid var(--bd,#333)">
|
||||
<th style="padding:6px 8px">항목</th><th>값</th><th>단위</th><th>근거</th></tr>`;
|
||||
for (const [label, m, kind] of rows) {
|
||||
html += `<tr style="border-bottom:1px solid var(--bd,#222)">
|
||||
<td style="padding:6px 8px">${label}</td>
|
||||
<td style="font-weight:600">${rpVal(m, kind)}</td>
|
||||
<td style="color:var(--t2)">${m.Unit || ''}</td>
|
||||
<td style="font-size:11px">${rpMeta(m)}</td></tr>`;
|
||||
if (m.Metric === 'control_residual' && m.Extra && m.Extra.sd != null)
|
||||
html += `<tr style="border-bottom:1px solid var(--bd,#222)"><td style="padding:4px 8px 4px 20px;color:var(--t2)">└ 잔차 표준편차</td>
|
||||
<td>${rpFmt(m.Extra.sd,'degC')}</td><td colspan="2"></td></tr>`;
|
||||
}
|
||||
html += `</table>`;
|
||||
return html;
|
||||
}
|
||||
@@ -161,19 +161,19 @@ async function refreshAll() {
|
||||
|
||||
async function startCtrl(id) {
|
||||
const r = await _setupApi('POST', '/gateway/start?id=' + encodeURIComponent(id));
|
||||
_setupMsg('msg-' + id, r.success ?? r.ok, r.message ?? (r.ok ? '시작됨' : '실패'));
|
||||
_setupMsg('msg-' + id, r.Success ?? r.ok, r.Message ?? (r.ok ? '시작됨' : '실패'));
|
||||
setTimeout(refreshAll, 2500);
|
||||
}
|
||||
|
||||
async function stopCtrl(id) {
|
||||
const r = await _setupApi('POST', '/gateway/stop?id=' + encodeURIComponent(id));
|
||||
_setupMsg('msg-' + id, r.success ?? r.ok, r.message ?? (r.ok ? '중지됨' : '실패'));
|
||||
_setupMsg('msg-' + id, r.Success ?? r.ok, r.Message ?? (r.ok ? '중지됨' : '실패'));
|
||||
setTimeout(refreshAll, 1000);
|
||||
}
|
||||
|
||||
async function restartCtrl(id) {
|
||||
const r = await _setupApi('POST', '/gateway/restart?id=' + encodeURIComponent(id));
|
||||
_setupMsg('msg-' + id, r.success ?? r.ok, r.message ?? (r.ok ? '재시작됨' : '실패'));
|
||||
_setupMsg('msg-' + id, r.Success ?? r.ok, r.Message ?? (r.ok ? '재시작됨' : '실패'));
|
||||
setTimeout(refreshAll, 3000);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ async function saveCtrl(id) {
|
||||
const enEl = document.getElementById('enabled-' + id);
|
||||
if (enEl) ctrl.enabled = enEl.checked;
|
||||
const r = await _setupApi('POST', '/config', cfg);
|
||||
_setupMsg('msg-' + id, r.success, r.message);
|
||||
_setupMsg('msg-' + id, r.Success, r.Message);
|
||||
}
|
||||
|
||||
async function deleteCtrl(id) {
|
||||
@@ -204,8 +204,8 @@ async function deleteCtrl(id) {
|
||||
cfg.controllers = cfg.controllers.filter(c => (c.Id ?? c.id) !== id);
|
||||
if (cfg.controllers.length === before) return;
|
||||
const r = await _setupApi('POST', '/config', cfg);
|
||||
_setupMsg('add-msg', r.success, r.message);
|
||||
if (r.success) loadAll();
|
||||
_setupMsg('add-msg', r.Success, r.Message);
|
||||
if (r.Success) loadAll();
|
||||
}
|
||||
|
||||
async function saveSharedConfig() {
|
||||
@@ -214,7 +214,7 @@ async function saveSharedConfig() {
|
||||
cfg.shared.ldLibraryPath = document.getElementById('cfg-ld').value.trim();
|
||||
cfg.shared.logDir = document.getElementById('cfg-logdir').value.trim();
|
||||
const r = await _setupApi('POST', '/config', cfg);
|
||||
_setupMsg('shared-msg', r.success, r.message);
|
||||
_setupMsg('shared-msg', r.Success, r.Message);
|
||||
}
|
||||
|
||||
async function addController() {
|
||||
@@ -230,8 +230,8 @@ async function addController() {
|
||||
enabled: true,
|
||||
});
|
||||
const r = await _setupApi('POST', '/config', cfg);
|
||||
_setupMsg('add-msg', r.success, r.message);
|
||||
if (r.success) {
|
||||
_setupMsg('add-msg', r.Success, r.Message);
|
||||
if (r.Success) {
|
||||
loadAll();
|
||||
document.getElementById('new-id').value = '';
|
||||
document.getElementById('new-name').value = '';
|
||||
@@ -247,26 +247,26 @@ async function refreshLog() {
|
||||
const d = await _setupApi('GET', '/gateway/log?id=' + encodeURIComponent(id) + '&lines=80');
|
||||
const lb = document.getElementById('log-box');
|
||||
if (lb) {
|
||||
lb.textContent = (d.log || []).join('\n') || '로그 없음';
|
||||
lb.textContent = (d.Log || []).join('\n') || '로그 없음';
|
||||
lb.scrollTop = 9999;
|
||||
}
|
||||
}
|
||||
|
||||
async function startAll() {
|
||||
const r = await _setupApi('POST', '/gateway/start');
|
||||
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.success ? '전체 시작됨' : '실패'));
|
||||
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.Ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.Success ? '전체 시작됨' : '실패'));
|
||||
setTimeout(refreshAll, 2500);
|
||||
}
|
||||
|
||||
async function stopAll() {
|
||||
const r = await _setupApi('POST', '/gateway/stop');
|
||||
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.success ? '전체 중지됨' : '실패'));
|
||||
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.Ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.Success ? '전체 중지됨' : '실패'));
|
||||
setTimeout(refreshAll, 1000);
|
||||
}
|
||||
|
||||
async function restartAll() {
|
||||
const r = await _setupApi('POST', '/gateway/restart');
|
||||
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.success ? '전체 재시작됨' : '실패'));
|
||||
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.Ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.Success ? '전체 재시작됨' : '실패'));
|
||||
setTimeout(refreshAll, 3000);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@ async function stLoadColumns() {
|
||||
const d = await api('GET', '/api/steam/models');
|
||||
const sel1 = document.getElementById('st-col');
|
||||
const sel2 = document.getElementById('st-bt-col');
|
||||
const cols = d.configured || d.columns || [];
|
||||
const defaultCol = d.defaultColumn || 'C-6111';
|
||||
const cols = d.Configured || d.Columns || [];
|
||||
const defaultCol = d.DefaultColumn || 'C-6111';
|
||||
[sel1, sel2].forEach(sel => {
|
||||
sel.innerHTML = cols.map(c => `<option value="${c}" ${c===defaultCol?'selected':''}>${c}</option>`).join('');
|
||||
});
|
||||
@@ -100,8 +100,8 @@ async function stLoadProdOptions(col) {
|
||||
if (!sel) return;
|
||||
let data;
|
||||
try { data = await api('GET', `/api/steam/productlabels/${col}`); } catch (_) { return; }
|
||||
const opts = (data.clusters || []).map(c => {
|
||||
const v = c.name || c.cluster; // 매핑된 이름 우선, 없으면 클러스터 라벨(P0…)
|
||||
const opts = (data.Clusters || []).map(c => {
|
||||
const v = c.Name || c.Cluster; // 매핑된 이름 우선, 없으면 클러스터 라벨(P0…)
|
||||
return `<option value="${escHtml(v)}">${escHtml(v)}</option>`;
|
||||
}).join('');
|
||||
const saved = stGetSavedProduct(col);
|
||||
@@ -185,18 +185,18 @@ async function stOpenProdModal() {
|
||||
|
||||
// 제안 목록(datalist)
|
||||
const dl = document.getElementById('st-prod-suggest');
|
||||
dl.innerHTML = (data.suggestions || []).map(s => `<option value="${escHtml(s.name)}">`).join('');
|
||||
dl.innerHTML = (data.Suggestions || []).map(s => `<option value="${escHtml(s.Name)}">`).join('');
|
||||
|
||||
// 클러스터별 행 (cluster를 data 속성으로 보관)
|
||||
rows.innerHTML = (data.clusters || []).map(c => `
|
||||
rows.innerHTML = (data.Clusters || []).map(c => `
|
||||
<tr>
|
||||
<td><strong>${escHtml(c.cluster)}</strong></td>
|
||||
<td class="num">${stFmt(c.rebMedian)}</td>
|
||||
<td class="num">${stFmt(c.vacMedian)}</td>
|
||||
<td class="num">${c.nRows ?? '—'}</td>
|
||||
<td><strong>${escHtml(c.Cluster)}</strong></td>
|
||||
<td class="num">${stFmt(c.RebMedian)}</td>
|
||||
<td class="num">${stFmt(c.VacMedian)}</td>
|
||||
<td class="num">${c.NRows ?? '—'}</td>
|
||||
<td><input class="inp st-prod-name" list="st-prod-suggest"
|
||||
data-cluster="${escHtml(c.cluster)}"
|
||||
value="${escHtml(c.name || '')}" placeholder="${escHtml(c.cluster)} (미지정)"></td>
|
||||
data-cluster="${escHtml(c.Cluster)}"
|
||||
value="${escHtml(c.Name || '')}" placeholder="${escHtml(c.Cluster)} (미지정)"></td>
|
||||
</tr>`).join('') || '<tr><td colspan="5" style="color:#789">클러스터 없음</td></tr>';
|
||||
}
|
||||
|
||||
@@ -208,13 +208,13 @@ async function stSaveProdLabels() {
|
||||
const col = document.getElementById('st-temp-col').value;
|
||||
const msg = document.getElementById('st-prod-modal-msg');
|
||||
const entries = [...document.querySelectorAll('#st-prod-rows .st-prod-name')].map(inp => ({
|
||||
cluster: inp.dataset.cluster,
|
||||
name: inp.value.trim(),
|
||||
Cluster: inp.dataset.cluster,
|
||||
Name: inp.value.trim(),
|
||||
}));
|
||||
msg.textContent = '저장 중…'; msg.className = 'st-modal-msg';
|
||||
try {
|
||||
const r = await api('POST', `/api/steam/productlabels/${col}`, entries);
|
||||
msg.textContent = `저장됨 (${r.saved}개 지정)`; msg.className = 'st-modal-msg ok';
|
||||
msg.textContent = `저장됨 (${r.Saved}개 지정)`; msg.className = 'st-modal-msg ok';
|
||||
setTimeout(() => { stCloseProdModal(); stTempLoad(); }, 600); // 저장 후 재조회로 즉시 반영
|
||||
} catch (e) {
|
||||
msg.textContent = '저장 실패: ' + e.message; msg.className = 'st-modal-msg err';
|
||||
@@ -241,10 +241,10 @@ async function stTempLoad() {
|
||||
// 제품 선택 드롭다운 옵션을 API 응답(products)으로 채움. 사용자 선택은 유지.
|
||||
function stUpdateProdDropdown(resp) {
|
||||
const sel = document.getElementById('st-temp-prod-sel');
|
||||
if (!sel || !resp || !Array.isArray(resp.products)) return;
|
||||
if (!sel || !resp || !Array.isArray(resp.Products)) return;
|
||||
const prev = stTempSelectedProduct;
|
||||
const opts = resp.products.map(p => {
|
||||
const v = p.name || p.label;
|
||||
const opts = resp.Products.map(p => {
|
||||
const v = p.Name || p.Label;
|
||||
return `<option value="${escHtml(v)}">${escHtml(v)}</option>`;
|
||||
}).join('');
|
||||
sel.innerHTML = `<option value="">제품 선택…</option>` + opts;
|
||||
@@ -298,7 +298,7 @@ async function stTempHistLoad() {
|
||||
try {
|
||||
const h = await api('GET', url);
|
||||
console.log('[steam] hist response:', JSON.stringify(h).slice(0, 300));
|
||||
stTempSnapshots = h && h.snapshots || [];
|
||||
stTempSnapshots = h && h.Snapshots || [];
|
||||
} catch (e) {
|
||||
console.warn('[steam] hist load fail:', e);
|
||||
stTempSnapshots = [];
|
||||
@@ -308,11 +308,11 @@ async function stTempHistLoad() {
|
||||
if (n >= 2) {
|
||||
slider.max = (n - 1).toString();
|
||||
slider.value = (n - 1).toString();
|
||||
document.getElementById('st-scrub-from').textContent = stFmtDT(stTempSnapshots[0].ts);
|
||||
document.getElementById('st-scrub-to').textContent = stFmtDT(stTempSnapshots[n - 1].ts);
|
||||
document.getElementById('st-scrub-from').textContent = stFmtDT(stTempSnapshots[0].Ts);
|
||||
document.getElementById('st-scrub-to').textContent = stFmtDT(stTempSnapshots[n - 1].Ts);
|
||||
} else {
|
||||
slider.max = '0'; slider.value = '0';
|
||||
document.getElementById('st-scrub-ts').textContent = n === 0 ? '히스토리 없음' : stFmtDT(stTempSnapshots[0].ts);
|
||||
document.getElementById('st-scrub-ts').textContent = n === 0 ? '히스토리 없음' : stFmtDT(stTempSnapshots[0].Ts);
|
||||
document.getElementById('st-scrub-from').textContent = '—';
|
||||
document.getElementById('st-scrub-to').textContent = '—';
|
||||
}
|
||||
@@ -332,7 +332,7 @@ function stTempScrub() {
|
||||
return;
|
||||
}
|
||||
stTempIsLive = false;
|
||||
document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.ts);
|
||||
document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.Ts);
|
||||
stRenderTemp(snap);
|
||||
}
|
||||
|
||||
@@ -343,9 +343,9 @@ function stTempUpdateBadges(src) {
|
||||
|
||||
// 제품명은 드롭다운이 표시 — 별도 배지 없음 (중복 제거)
|
||||
const vb = document.getElementById('st-temp-vac');
|
||||
vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
|
||||
vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
||||
vb.style.color = s.vacuum.deviated ? '#f66' : '#66f';
|
||||
vb.textContent = `진공 ${stFmt(s.Vacuum.Current)} (기준 ${stFmt(s.Vacuum.RefMedian)})` + (s.Vacuum.Deviated ? ' ⚠이격' : '');
|
||||
vb.style.background = s.Vacuum.Deviated ? '#3a1a1a' : '#1a1a3a';
|
||||
vb.style.color = s.Vacuum.Deviated ? '#f66' : '#66f';
|
||||
}
|
||||
|
||||
let stRenderTempCol = '';
|
||||
@@ -363,13 +363,13 @@ function stRenderTemp(snap) {
|
||||
stTempUpdateBadges(snapSrc);
|
||||
|
||||
stRenderTempCol = document.getElementById('st-temp-col').value;
|
||||
const stages = stTempLive.stages;
|
||||
const stages = stTempLive.Stages;
|
||||
if (!stages || !stages.length) return;
|
||||
|
||||
const cats = stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage);
|
||||
const lo = stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null);
|
||||
const band = stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null);
|
||||
const med = stages.map(s => s.refMedian);
|
||||
const cats = stages.map(s => ST_STAGE_LABEL[s.Stage] || s.Stage);
|
||||
const lo = stages.map(s => (s.RefMedian != null && s.RefStd != null) ? +(s.RefMedian - 2 * s.RefStd).toFixed(2) : null);
|
||||
const band = stages.map(s => s.RefStd != null ? +(4 * s.RefStd).toFixed(2) : null);
|
||||
const med = stages.map(s => s.RefMedian);
|
||||
|
||||
const col = stRenderTempCol;
|
||||
const el = document.getElementById('st-chart-temp');
|
||||
@@ -385,13 +385,13 @@ function stRenderTemp(snap) {
|
||||
];
|
||||
|
||||
if (snap) {
|
||||
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
|
||||
const snapD = snapSrc.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#fa3' } }));
|
||||
const curD = stTempLive.Stages.map(s => ({ value: s.Current, itemStyle: { color: s.Deviated ? '#f66' : '#6cf' } }));
|
||||
const snapD = snapSrc.Stages.map(s => ({ value: s.Current, itemStyle: { color: s.Deviated ? '#f66' : '#fa3' } }));
|
||||
series.push({ name: '실시간참조', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
|
||||
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
|
||||
leg.push('실시간참조', '선택시점');
|
||||
} else {
|
||||
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
|
||||
const curD = stTempLive.Stages.map(s => ({ value: s.Current, itemStyle: { color: s.Deviated ? '#f66' : '#6cf' } }));
|
||||
series.push({ name: '실시간', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
|
||||
leg.push('실시간');
|
||||
}
|
||||
@@ -399,12 +399,12 @@ function stRenderTemp(snap) {
|
||||
chart.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
|
||||
text: snap ? `${col} · ${stFmtDT(snap.Ts)}` : `${col} 단면 온도 프로파일`,
|
||||
subtext: snap
|
||||
? `기준제품 ${snap.matchedProduct || '미선택'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}`
|
||||
: `기준제품 ${stTempLive.matchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`,
|
||||
? `기준제품 ${snap.MatchedProduct || '미선택'} · 진공 ${stFmt(snap.Vacuum?.Current)} (기준 ${stFmt(snap.Vacuum?.RefMedian)})${snap.Vacuum?.Deviated ? ' ⚠' : ''}`
|
||||
: `기준제품 ${stTempLive.MatchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.Vacuum.Current)} (기준 ${stFmt(stTempLive.Vacuum.RefMedian)})${stTempLive.Vacuum.Deviated ? ' ⚠' : ''}`,
|
||||
textStyle: { color: '#ccc', fontSize: 13 },
|
||||
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
|
||||
subtextStyle: { color: snap ? (snap.Vacuum?.Deviated ? '#f66' : '#888') : (stTempLive.Vacuum.Deviated ? '#f66' : '#888'), fontSize: 11 } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: leg, textStyle: { color: '#888' }, top: 6, right: 10 },
|
||||
grid: { top: 64, left: 48, right: 20, bottom: 28 },
|
||||
@@ -420,41 +420,41 @@ function stRenderTemp(snap) {
|
||||
|
||||
// 왼쪽 표: 온도
|
||||
// 표는 D(탑)→C→B→A(보텀) 순서로 표시 (차트는 A→B→C→D 유지)
|
||||
let stgRows = snapSrc.stages.map(s => {
|
||||
const label = ST_STAGE_LABEL[s.stage] || s.stage;
|
||||
const cur = stFmt(s.current) + '℃';
|
||||
const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
|
||||
let stgRows = snapSrc.Stages.map(s => {
|
||||
const label = ST_STAGE_LABEL[s.Stage] || s.Stage;
|
||||
const cur = stFmt(s.Current) + '℃';
|
||||
const ref = s.RefMedian != null ? `${stFmt(s.RefMedian)}±${stFmt(s.RefStd)}` : '—';
|
||||
return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
|
||||
}).reverse();
|
||||
if (snapSrc.spanAD != null) {
|
||||
stgRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
|
||||
if (snapSrc.SpanAD != null) {
|
||||
stgRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.SpanAD)}℃</td><td>—</td></tr>`);
|
||||
}
|
||||
if (snapSrc.vacuum) {
|
||||
const v = snapSrc.vacuum;
|
||||
const cur = stFmt(v.current);
|
||||
const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
|
||||
if (snapSrc.Vacuum) {
|
||||
const v = snapSrc.Vacuum;
|
||||
const cur = stFmt(v.Current);
|
||||
const ref = v.RefMedian != null ? `${stFmt(v.RefMedian)}±${stFmt(v.RefStd)}` : '—';
|
||||
stgRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
|
||||
}
|
||||
|
||||
// 오른쪽 표: 유량
|
||||
let flowHtml = '';
|
||||
if (snapSrc.flow) {
|
||||
const f = snapSrc.flow;
|
||||
const ft = snapSrc.flowTags || {};
|
||||
if (snapSrc.Flow) {
|
||||
const f = snapSrc.Flow;
|
||||
const ft = snapSrc.FlowTags || {};
|
||||
const flowHeaders = [
|
||||
'<th></th>',
|
||||
`<th>FEED<br><small>${escHtml((ft.feed || 'FICQ-??01.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>REFLUX<br><small>${escHtml((ft.reflux || 'FICQ-??13.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>제품추출<br><small>${escHtml((ft.product || 'FICQ-??18.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>경비물<br><small>${escHtml((ft.overhead || 'FICQ-??14.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>중비물<br><small>${escHtml((ft.bottom || 'FICQ-??16.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>스팀<br><small>${escHtml((ft.steam || 'FIQ-???15.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>FEED<br><small>${escHtml((ft.Feed || 'FICQ-??01.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>REFLUX<br><small>${escHtml((ft.Reflux || 'FICQ-??13.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>제품추출<br><small>${escHtml((ft.Product || 'FICQ-??18.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>경비물<br><small>${escHtml((ft.Overhead || 'FICQ-??14.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>중비물<br><small>${escHtml((ft.Bottom || 'FICQ-??16.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>스팀<br><small>${escHtml((ft.Steam || 'FIQ-???15.PV').replace('.PV',''))}</small></th>`,
|
||||
].join('');
|
||||
const stPv = stFmt(f.steam?.pv);
|
||||
const stPv = stFmt(f.Steam?.Pv);
|
||||
const flowRows = [
|
||||
`<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td><td>${stPv}</td></tr>`,
|
||||
`<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td><td>—</td></tr>`,
|
||||
`<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td><td>—</td></tr>`,
|
||||
`<tr><td>PV</td><td>${stFmt(f.Feed?.Pv)}</td><td>${stFmt(f.Reflux?.Pv)}</td><td>${stFmt(f.Product?.Pv)}</td><td>${stFmt(f.Overhead?.Pv)}</td><td>${stFmt(f.Bottom?.Pv)}</td><td>${stPv}</td></tr>`,
|
||||
`<tr><td>SP</td><td>${stFmt(f.Feed?.Sp)}</td><td>${stFmt(f.Reflux?.Sp)}</td><td>${stFmt(f.Product?.Sp)}</td><td>${stFmt(f.Overhead?.Sp)}</td><td>${stFmt(f.Bottom?.Sp)}</td><td>—</td></tr>`,
|
||||
`<tr><td>OP</td><td>${stFmt(f.Feed?.Op)}</td><td>${stFmt(f.Reflux?.Op)}</td><td>${stFmt(f.Product?.Op)}</td><td>${stFmt(f.Overhead?.Op)}</td><td>${stFmt(f.Bottom?.Op)}</td><td>—</td></tr>`,
|
||||
].join('');
|
||||
flowHtml = `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>`;
|
||||
}
|
||||
@@ -481,8 +481,8 @@ async function stLiveTick() {
|
||||
const col = document.getElementById('st-col').value;
|
||||
try {
|
||||
const d = await api('GET', `/api/steam/live?col=${col}`);
|
||||
if (d.status === 'missing_tags') {
|
||||
document.getElementById('st-live-msg').textContent = `⚠ 태그 없음: ${d.missing?.join(', ') || '—'} (${d.message || '게이트웨이 폴링 확인'})`;
|
||||
if (d.Status === 'missing_tags') {
|
||||
document.getElementById('st-live-msg').textContent = `⚠ 태그 없음: ${d.Missing?.join(', ') || '—'} (${d.Message || '게이트웨이 폴링 확인'})`;
|
||||
return;
|
||||
}
|
||||
stUpdateLive(d);
|
||||
@@ -492,35 +492,35 @@ async function stLiveTick() {
|
||||
}
|
||||
|
||||
function stUpdateLive(d) {
|
||||
document.getElementById('st-mode').textContent = d.mode || '—';
|
||||
document.getElementById('st-conf').textContent = d.confidence || '—';
|
||||
document.getElementById('st-mode').textContent = d.Mode || '—';
|
||||
document.getElementById('st-conf').textContent = d.Confidence || '—';
|
||||
const oodEl = document.getElementById('st-ood');
|
||||
if (d.ood) { oodEl.style.display = 'inline-block'; } else { oodEl.style.display = 'none'; }
|
||||
if (d.Ood) { oodEl.style.display = 'inline-block'; } else { oodEl.style.display = 'none'; }
|
||||
|
||||
document.getElementById('st-op-rec').textContent = d.recOp != null ? d.recOp.toFixed(1) : '—';
|
||||
document.getElementById('st-op-act').textContent = d.actualOp != null ? d.actualOp.toFixed(1) : '—';
|
||||
document.getElementById('st-op-rec').textContent = d.RecOp != null ? d.RecOp.toFixed(1) : '—';
|
||||
document.getElementById('st-op-act').textContent = d.ActualOp != null ? d.ActualOp.toFixed(1) : '—';
|
||||
const opTag = document.getElementById('st-op-act-tag');
|
||||
if (opTag) opTag.textContent = (d.Tags?.steamOp || '') + (d.Descs?.steamOp ? ' · ' + d.Descs.steamOp : '');
|
||||
if (opTag) opTag.textContent = (d.Tags?.SteamOp || '') + (d.Descs?.SteamOp ? ' · ' + d.Descs.SteamOp : '');
|
||||
|
||||
// 게이지
|
||||
const fill = document.getElementById('st-gauge-fill');
|
||||
if (d.recOp != null) fill.style.width = Math.min(100, Math.max(0, d.recOp)) + '%';
|
||||
if (d.RecOp != null) fill.style.width = Math.min(100, Math.max(0, d.RecOp)) + '%';
|
||||
|
||||
// 입력값 + 태그명 + 설명
|
||||
document.getElementById('st-live-feed').textContent = d.feed != null ? d.feed.toFixed(1) : '—';
|
||||
document.getElementById('st-live-feed-tag').textContent = (d.Tags?.feed || '') + (d.Descs?.feed ? ' · ' + d.Descs.feed : '');
|
||||
document.getElementById('st-live-product').textContent = d.product != null ? d.product.toFixed(1) : '—';
|
||||
document.getElementById('st-live-product-tag').textContent = (d.Tags?.product || '') + (d.Descs?.product ? ' · ' + d.Descs.product : '');
|
||||
document.getElementById('st-live-tc').textContent = d.tC != null ? d.tC.toFixed(1) : '—';
|
||||
document.getElementById('st-live-tc-tag').textContent = (d.Tags?.tC || '') + (d.Descs?.tC ? ' · ' + d.Descs.tC : '');
|
||||
document.getElementById('st-live-sf').textContent = d.actualSteamFlow != null ? d.actualSteamFlow.toFixed(1) : '—';
|
||||
document.getElementById('st-live-sf-tag').textContent = (d.Tags?.steamFlow || '') + (d.Descs?.steamFlow ? ' · ' + d.Descs.steamFlow : '');
|
||||
document.getElementById('st-live-feed').textContent = d.Feed != null ? d.Feed.toFixed(1) : '—';
|
||||
document.getElementById('st-live-feed-tag').textContent = (d.Tags?.Feed || '') + (d.Descs?.Feed ? ' · ' + d.Descs.Feed : '');
|
||||
document.getElementById('st-live-product').textContent = d.Product != null ? d.Product.toFixed(1) : '—';
|
||||
document.getElementById('st-live-product-tag').textContent = (d.Tags?.Product || '') + (d.Descs?.Product ? ' · ' + d.Descs.Product : '');
|
||||
document.getElementById('st-live-tc').textContent = d.TC != null ? d.TC.toFixed(1) : '—';
|
||||
document.getElementById('st-live-tc-tag').textContent = (d.Tags?.TC || '') + (d.Descs?.TC ? ' · ' + d.Descs.TC : '');
|
||||
document.getElementById('st-live-sf').textContent = d.ActualSteamFlow != null ? d.ActualSteamFlow.toFixed(1) : '—';
|
||||
document.getElementById('st-live-sf-tag').textContent = (d.Tags?.SteamFlow || '') + (d.Descs?.SteamFlow ? ' · ' + d.Descs.SteamFlow : '');
|
||||
|
||||
document.getElementById('st-live-msg').textContent = d.message || '';
|
||||
document.getElementById('st-live-msg').textContent = d.Message || '';
|
||||
|
||||
// uPlot data
|
||||
const now = Date.now() / 1000;
|
||||
stLiveData.push({ ts: now, recOp: d.recOp, actOp: d.actualOp });
|
||||
stLiveData.push({ ts: now, recOp: d.RecOp, actOp: d.ActualOp });
|
||||
if (stLiveData.length > ST_MAX_POINTS) stLiveData.splice(0, stLiveData.length - ST_MAX_POINTS);
|
||||
stUpdateUplot();
|
||||
}
|
||||
|
||||
@@ -78,24 +78,24 @@ async function t2sParse() {
|
||||
try {
|
||||
const res = await api('POST', '/api/text-to-sql/query-nl', { query: input });
|
||||
console.log('[t2sParse] MCP 응답:', res);
|
||||
if (res.success) {
|
||||
const d = (typeof res.data === 'object' && res.data !== null) ? res.data : {};
|
||||
if (res.Success) {
|
||||
const d = (typeof res.Data === 'object' && res.Data !== null) ? res.Data : {};
|
||||
console.log('[t2sParse] 데이터 파싱:', d, 'type:', typeof d);
|
||||
console.log('[t2sParse] d.data:', d.data, 'd.columns:', d.columns, 'd.count:', d.count);
|
||||
console.log('[t2sParse] d.Data:', d.Data, 'd.Columns:', d.Columns, 'd.Count:', d.Count);
|
||||
|
||||
// d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인
|
||||
const dataObj = d.data || (Array.isArray(d) ? d : null);
|
||||
// d.Data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인
|
||||
const dataObj = d.Data || (Array.isArray(d) ? d : null);
|
||||
console.log('[t2sParse] dataObj:', dataObj);
|
||||
|
||||
if (d.success === false) {
|
||||
sqlTextarea.value = `오류: ${d.error || 'SQL 생성 실패'}`;
|
||||
resultContainer.innerHTML = `<div class="t2s-error">MCP 오류: ${esc(d.error || 'SQL 생성 실패')}</div>`;
|
||||
if (d.Success === false) {
|
||||
sqlTextarea.value = `오류: ${d.Error || 'SQL 생성 실패'}`;
|
||||
resultContainer.innerHTML = `<div class="t2s-error">MCP 오류: ${esc(d.Error || 'SQL 생성 실패')}</div>`;
|
||||
} else {
|
||||
sqlTextarea.value = d.sql || '(SQL 없음)';
|
||||
// d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인
|
||||
const rows = (dataObj && Array.isArray(dataObj.rows)) ? dataObj.rows : (Array.isArray(dataObj) ? dataObj : []);
|
||||
const columns = (dataObj && Array.isArray(dataObj.columns)) ? dataObj.columns : [];
|
||||
const totalCount = (dataObj && typeof dataObj.count === 'number') ? dataObj.count : (Array.isArray(dataObj) ? dataObj.length : 0);
|
||||
sqlTextarea.value = d.Sql || '(SQL 없음)';
|
||||
// d.Data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인
|
||||
const rows = (dataObj && Array.isArray(dataObj.Rows)) ? dataObj.Rows : (Array.isArray(dataObj) ? dataObj : []);
|
||||
const columns = (dataObj && Array.isArray(dataObj.Columns)) ? dataObj.Columns : [];
|
||||
const totalCount = (dataObj && typeof dataObj.Count === 'number') ? dataObj.Count : (Array.isArray(dataObj) ? dataObj.length : 0);
|
||||
console.log('[t2sParse] 최종 rows.length:', rows.length, 'columns:', columns);
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -105,7 +105,7 @@ async function t2sParse() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`;
|
||||
sqlTextarea.value = `오류: ${res.Error || '알 수 없는 오류'}`;
|
||||
resultContainer.innerHTML = '';
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -116,10 +116,10 @@ async function t2sParse() {
|
||||
sqlTextarea.value = '변환 중...';
|
||||
try {
|
||||
const res = await api('POST', '/api/text-to-sql/parse', { query: input });
|
||||
if (res.success) {
|
||||
sqlTextarea.value = res.sql || 'SQL 생성 실패';
|
||||
if (res.Success) {
|
||||
sqlTextarea.value = res.Sql || 'SQL 생성 실패';
|
||||
} else {
|
||||
sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`;
|
||||
sqlTextarea.value = `오류: ${res.Error || '알 수 없는 오류'}`;
|
||||
}
|
||||
} catch (err) {
|
||||
sqlTextarea.value = `연결 오류: ${err.message}`;
|
||||
@@ -171,23 +171,23 @@ async function t2sExecute() {
|
||||
if (t2sMode === 'mcp') {
|
||||
const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql, limit });
|
||||
console.log('[t2sExecute] MCP execute 응답:', res);
|
||||
if (res.success) {
|
||||
const d = res.data || {};
|
||||
console.log('[t2sExecute] d.data:', d.data, 'd.columns:', d.columns);
|
||||
const { columns, rows } = t2sPivot(d.columns || [], d.data || []);
|
||||
console.log('[t2sExecute] pivot 후 rows.length:', rows.length);
|
||||
t2sRenderTable({ rows, columns, totalCount: rows.length });
|
||||
if (res.Success) {
|
||||
const d = res.Data || {};
|
||||
console.log('[t2sExecute] d.Data:', d.Data, 'd.Columns:', d.Columns);
|
||||
const { Columns, Rows } = t2sPivot(d.Columns || [], d.Data || []);
|
||||
console.log('[t2sExecute] pivot 후 rows.length:', Rows.length);
|
||||
t2sRenderTable({ rows: Rows, columns: Columns, totalCount: Rows.length });
|
||||
} else {
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.Error || '알 수 없는 오류'}</div>`;
|
||||
}
|
||||
} else {
|
||||
const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql, limit });
|
||||
if (res.success) {
|
||||
const d = res.data || {};
|
||||
const { columns, rows } = t2sPivot(d.columns || [], d.data || []);
|
||||
t2sRenderTable({ rows, columns, totalCount: rows.length });
|
||||
if (res.Success) {
|
||||
const d = res.Data || {};
|
||||
const { Columns, Rows } = t2sPivot(d.Columns || [], d.Data || []);
|
||||
t2sRenderTable({ rows: Rows, columns: Columns, totalCount: Rows.length });
|
||||
} else {
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.Error || '알 수 없는 오류'}</div>`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -203,10 +203,10 @@ function t2sRenderTable(result) {
|
||||
|
||||
console.log('[t2sRenderTable] 입력 결과:', result);
|
||||
|
||||
// 백엔드 응답: columns, rows, totalCount (소문자)
|
||||
const rows = result.rows || [];
|
||||
const columns = result.columns || [];
|
||||
const totalCount = result.totalCount || 0;
|
||||
// 백엔드 응답: Columns, Rows, TotalCount (대문자)
|
||||
const rows = result.Rows || [];
|
||||
const columns = result.Columns || [];
|
||||
const totalCount = result.TotalCount || 0;
|
||||
|
||||
console.log('[t2sRenderTable] rows.length:', rows.length, 'columns:', columns);
|
||||
|
||||
@@ -313,8 +313,8 @@ async function t2sAnalyze() {
|
||||
const interval = document.getElementById('t2s-interval').value;
|
||||
const limit = document.getElementById('t2s-limit-analyze').value || '100';
|
||||
|
||||
const dateFrom = document.getElementById('t2s-date-from').value;
|
||||
const dateTo = document.getElementById('t2s-date-to').value;
|
||||
const dateFrom = document.getElementById('hf-t2s-from').value;
|
||||
const dateTo = document.getElementById('hf-t2s-to').value;
|
||||
|
||||
const resultContainer = document.getElementById('t2s-analysis-results');
|
||||
resultContainer.innerHTML = '<div class="t2s-loading">분석 중...</div>';
|
||||
@@ -327,10 +327,10 @@ async function t2sAnalyze() {
|
||||
to: dateTo || undefined
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
if (res.Success) {
|
||||
t2sRenderAnalysis(res);
|
||||
} else {
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.Error || '알 수 없는 오류'}</div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
|
||||
@@ -343,22 +343,22 @@ async function t2sAnalyze() {
|
||||
function t2sRenderAnalysis(result) {
|
||||
const container = document.getElementById('t2s-analysis-results');
|
||||
|
||||
if (!result.tags || result.tags.length === 0) {
|
||||
if (!result.Tags || result.Tags.length === 0) {
|
||||
container.innerHTML = '<div class="t2s-empty">분석 결과가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="t2s-result-info">총 <b>' + result.tags.length + '</b>개 태그 분석</div>';
|
||||
let html = '<div class="t2s-result-info">총 <b>' + result.Tags.length + '</b>개 태그 분석</div>';
|
||||
html += '<div class="t2s-analysis-grid">';
|
||||
|
||||
result.tags.forEach(tag => {
|
||||
result.Tags.forEach(tag => {
|
||||
html += '<div class="t2s-tag-card">';
|
||||
html += `<h4>${esc(tag.tagName.toUpperCase())}</h4>`;
|
||||
html += `<h4>${esc(tag.TagName.toUpperCase())}</h4>`;
|
||||
html += '<div class="t2s-tag-stats">';
|
||||
html += `<div class="t2s-stat-row"><span>평균:</span><span class="t2s-value">${tag.mean?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>최대:</span><span class="t2s-value t2s-max">${tag.max?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>최소:</span><span class="t2s-value t2s-min">${tag.min?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>표준편차:</span><span class="t2s-value">${tag.stdDev?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>평균:</span><span class="t2s-value">${tag.Mean?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>최대:</span><span class="t2s-value t2s-max">${tag.Max?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>최소:</span><span class="t2s-value t2s-min">${tag.Min?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += `<div class="t2s-stat-row"><span>표준편차:</span><span class="t2s-value">${tag.StdDev?.toFixed(2) || 'N/A'}</span></div>`;
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
@@ -403,19 +403,19 @@ async function t2sChatSend() {
|
||||
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>`);
|
||||
if (!executeRes.Success) {
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">실패: ${executeRes.Error || '알 수 없는 오류'}</span>`);
|
||||
} else {
|
||||
const d = executeRes.data || {};
|
||||
if (d.sql) {
|
||||
document.getElementById('t2s-sql').value = d.sql;
|
||||
t2sAddChatMessage('system', `✅ SQL 생성:<br><pre class="t2s-chat-sql">${esc(d.sql)}</pre>`);
|
||||
const d = executeRes.Data || {};
|
||||
if (d.Sql) {
|
||||
document.getElementById('t2s-sql').value = d.Sql;
|
||||
t2sAddChatMessage('system', `✅ SQL 생성:<br><pre class="t2s-chat-sql">${esc(d.Sql)}</pre>`);
|
||||
}
|
||||
if (d.success === false) {
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">실행 오류: ${d.error || '알 수 없는 오류'}</span>`);
|
||||
if (d.Success === false) {
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">실행 오류: ${d.Error || '알 수 없는 오류'}</span>`);
|
||||
} else {
|
||||
t2sRenderTable({ rows: d.data || [], columns: d.columns || [], totalCount: d.count || 0 });
|
||||
t2sAddChatMessage('system', `✅ <b>${d.count || 0}</b>개 결과 조회 완료`);
|
||||
t2sRenderTable({ rows: d.Data || [], columns: d.Columns || [], totalCount: d.Count || 0 });
|
||||
t2sAddChatMessage('system', `✅ <b>${d.Count || 0}</b>개 결과 조회 완료`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -429,8 +429,8 @@ async function t2sChatSend() {
|
||||
const loadingEl = document.getElementById(loadingId);
|
||||
if (loadingEl) loadingEl.remove();
|
||||
|
||||
if (!parseRes.success || !parseRes.sql) {
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}</span>`);
|
||||
if (!parseRes.Success || !parseRes.Sql) {
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ${parseRes.Error || '알 수 없는 오류'}</span>`);
|
||||
input.disabled = false;
|
||||
document.getElementById('t2s-chat-send-btn').disabled = false;
|
||||
input.focus();
|
||||
@@ -438,32 +438,32 @@ async function t2sChatSend() {
|
||||
}
|
||||
|
||||
// SQL 텍스트박스에도 반영
|
||||
document.getElementById('t2s-sql').value = parseRes.sql;
|
||||
document.getElementById('t2s-sql').value = parseRes.Sql;
|
||||
|
||||
// 시스템 메시지: 변환된 SQL 표시
|
||||
t2sAddChatMessage('system', `✅ SQL 변환 완료:<br><pre class="t2s-chat-sql">${esc(parseRes.sql)}</pre>`);
|
||||
t2sAddChatMessage('system', `✅ SQL 변환 완료:<br><pre class="t2s-chat-sql">${esc(parseRes.Sql)}</pre>`);
|
||||
|
||||
// 2. SQL 자동 실행
|
||||
t2sAddChatMessage('system', '<span class="t2s-typing">쿼리 실행 중...</span>');
|
||||
|
||||
const limitInput = document.getElementById('t2s-limit');
|
||||
const limit = limitInput.value ? parseInt(limitInput.value) : 1000;
|
||||
const executeRes = await api('POST', '/api/text-to-sql/execute-mcp', { sql: parseRes.sql, limit });
|
||||
const executeRes = await api('POST', '/api/text-to-sql/execute-mcp', { sql: parseRes.Sql, 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>`);
|
||||
if (!executeRes.Success) {
|
||||
t2sAddChatMessage('system', `<span class="t2s-error">쿼리 실행 실패: ${executeRes.Error || '알 수 없는 오류'}</span>`);
|
||||
} else {
|
||||
// 결과 테이블 업데이트
|
||||
const d = executeRes.data || {};
|
||||
const { columns, rows } = t2sPivot(d.columns || [], d.data || []);
|
||||
t2sRenderTable({ rows, columns, totalCount: rows.length });
|
||||
const d = executeRes.Data || {};
|
||||
const { Columns, Rows } = t2sPivot(d.Columns || [], d.Data || []);
|
||||
t2sRenderTable({ rows: Rows, columns: Columns, totalCount: Rows.length });
|
||||
|
||||
// 결과 수 표시
|
||||
const totalCount = executeRes.totalCount || 0;
|
||||
const totalCount = executeRes.TotalCount || 0;
|
||||
t2sAddChatMessage('system', `✅ <b>${totalCount}</b>개 결과 조회 완료`);
|
||||
}
|
||||
}
|
||||
@@ -514,9 +514,9 @@ function t2sAddChatMessage(type, content, id) {
|
||||
async function loadMcpTools() {
|
||||
try {
|
||||
const res = await api('GET', '/api/text-to-sql/tools');
|
||||
if (res.success && res.tools && res.tools.length > 0) {
|
||||
renderToolsChips(res.tools);
|
||||
return res.tools;
|
||||
if (res.Success && res.Tools && res.Tools.length > 0) {
|
||||
renderToolsChips(res.Tools);
|
||||
return res.Tools;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('MCP 도구 로드 실패:', e);
|
||||
@@ -555,10 +555,10 @@ async function callTool(toolName) {
|
||||
|
||||
try {
|
||||
const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql: input });
|
||||
if (res.success) {
|
||||
if (res.Success) {
|
||||
t2sRenderTable(res);
|
||||
} else {
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
|
||||
resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.Error || '알 수 없는 오류'}</div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
|
||||
@@ -595,8 +595,8 @@ async function apiChatSend() {
|
||||
const loadingEl = document.getElementById(loadingId);
|
||||
if (loadingEl) loadingEl.remove();
|
||||
|
||||
if (!parseRes.success || !parseRes.sql) {
|
||||
apiAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ${parseRes.error || '알 수 없는 오류'}</span>`);
|
||||
if (!parseRes.Success || !parseRes.Sql) {
|
||||
apiAddChatMessage('system', `<span class="t2s-error">SQL 변환 실패: ${parseRes.Error || '알 수 없는 오류'}</span>`);
|
||||
input.disabled = false;
|
||||
document.getElementById('api-chat-send-btn').disabled = false;
|
||||
input.focus();
|
||||
@@ -604,26 +604,26 @@ async function apiChatSend() {
|
||||
}
|
||||
|
||||
// SQL 표시
|
||||
apiAddChatMessage('assistant', `📝 변환된 SQL:<br><pre class="api-chat-sql">${esc(parseRes.sql)}</pre>`);
|
||||
apiAddChatMessage('assistant', `📝 변환된 SQL:<br><pre class="api-chat-sql">${esc(parseRes.Sql)}</pre>`);
|
||||
|
||||
// 2. SQL 자동 실행
|
||||
apiAddChatMessage('assistant', '<span class="t2s-typing">쿼리 실행 중...</span>');
|
||||
|
||||
const executeRes = await api('POST', '/api/text-to-sql/execute-mcp', { sql: parseRes.sql, limit: 1000 });
|
||||
const executeRes = await api('POST', '/api/text-to-sql/execute-mcp', { sql: parseRes.Sql, limit: 1000 });
|
||||
|
||||
// 로딩 메시지 제거
|
||||
const loadMsgs = document.querySelectorAll('[id^="api-chat-loading-"]');
|
||||
loadMsgs.forEach(el => el.remove());
|
||||
|
||||
if (!executeRes.success) {
|
||||
apiAddChatMessage('system', `<span class="t2s-error">쿼리 실행 실패: ${executeRes.error || '알 수 없는 오류'}</span>`);
|
||||
if (!executeRes.Success) {
|
||||
apiAddChatMessage('system', `<span class="t2s-error">쿼리 실행 실패: ${executeRes.Error || '알 수 없는 오류'}</span>`);
|
||||
} else {
|
||||
// 결과를 응답 창에 표시
|
||||
const d = executeRes.data || {};
|
||||
apiRenderResponse({ rows: d.data || [], totalCount: d.count || 0 });
|
||||
const d = executeRes.Data || {};
|
||||
apiRenderResponse({ rows: d.Data || [], totalCount: d.Count || 0 });
|
||||
|
||||
// 결과 수 표시
|
||||
const totalCount = executeRes.totalCount || 0;
|
||||
const totalCount = executeRes.TotalCount || 0;
|
||||
apiAddChatMessage('assistant', `✅ <b>${totalCount}</b>개 결과 조회 완료`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -708,7 +708,7 @@ function apiChatClear() {
|
||||
function apiRenderResponse(data) {
|
||||
const container = document.getElementById('api-response-content');
|
||||
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
if (!data.Rows || data.Rows.length === 0) {
|
||||
container.innerHTML = '<span class="placeholder">조회된 결과가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
@@ -717,14 +717,14 @@ function apiRenderResponse(data) {
|
||||
let html = '<table class="api-response-table"><thead><tr>';
|
||||
|
||||
// 헤더 생성
|
||||
const columns = Object.keys(data.rows[0]);
|
||||
const columns = Object.keys(data.Rows[0]);
|
||||
columns.forEach(col => {
|
||||
html += `<th>${esc(col)}</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// 데이터 행 생성
|
||||
data.rows.forEach(row => {
|
||||
data.Rows.forEach(row => {
|
||||
html += '<tr>';
|
||||
columns.forEach(col => {
|
||||
const value = row[col];
|
||||
@@ -734,7 +734,7 @@ function apiRenderResponse(data) {
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
html += `<p style="margin-top:8px;font-size:12px;color:var(--t2)">총 ${data.totalCount || 0}개 결과</p>`;
|
||||
html += `<p style="margin-top:8px;font-size:12px;color:var(--t2)">총 ${data.TotalCount || 0}개 결과</p>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ async function trLoadLimits() {
|
||||
try {
|
||||
const d = await api('GET', `/api/trend/limits?tags=${encodeURIComponent(tags.join(','))}`);
|
||||
trState.cache.limits = {};
|
||||
for (const l of (d.items || [])) trState.cache.limits[l.tag] = l;
|
||||
for (const l of (d.Items || [])) trState.cache.limits[l.Tag] = l;
|
||||
} catch (e) { console.error('trLoadLimits:', e); trState.cache.limits = {}; }
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ async function trLoadRunbands() {
|
||||
p.set('from', new Date(from).toISOString());
|
||||
p.set('to', new Date(to).toISOString());
|
||||
const d = await api('GET', `/api/trend/runbands?${p}`);
|
||||
trState.cache.runbands = (d.items || []).map(b => ({
|
||||
tag: b.tag, t0: new Date(b.t0).getTime(), t1: new Date(b.t1).getTime(), state: b.state
|
||||
trState.cache.runbands = (d.Items || []).map(b => ({
|
||||
Tag: b.Tag, T0: new Date(b.T0).getTime(), T1: new Date(b.T1).getTime(), State: b.State
|
||||
}));
|
||||
} catch (e) { console.error('trLoadRunbands:', e); trState.cache.runbands = []; }
|
||||
}
|
||||
@@ -337,7 +337,7 @@ async function trQuery() {
|
||||
if (from) body.from = new Date(from).toISOString();
|
||||
if (to) body.to = new Date(to).toISOString();
|
||||
const d = await api('POST', '/api/history/query', body);
|
||||
const raw = d.rows || d.Rows || [];
|
||||
const raw = d.Rows || [];
|
||||
const rows = raw.map(r => ({
|
||||
recordedAt: r.recordedAt ?? r.RecordedAt,
|
||||
values: r.values ?? r.Values
|
||||
@@ -355,7 +355,7 @@ async function trQuery() {
|
||||
to: to ? new Date(to).toISOString() : null,
|
||||
interval, limit: 5000
|
||||
});
|
||||
rows = (d && d.success !== false) ? (d.rows || []) : [];
|
||||
rows = (d && d.Success !== false) ? d.Rows : [];
|
||||
tk = rows[0]?.timeBucket != null ? 'timeBucket' : 'RecordedAt';
|
||||
// 집계가 0행이면(엔드포인트 이슈 등) 원시로 폴백 → 차트 공백 방지
|
||||
if (!rows.length) { const r = await rawQuery(); rows = r.rows; tk = r.tk; }
|
||||
@@ -650,11 +650,11 @@ async function trLiveTick() {
|
||||
try {
|
||||
const d = await api('GET', `/api/trend/live?tags=${encodeURIComponent(tags.join(','))}`);
|
||||
let changed = false;
|
||||
for (const pt of (d.items || [])) {
|
||||
if (pt.value == null) continue;
|
||||
trState.liveNow[pt.tag] = +pt.value; // 현재값은 매 틱 갱신(타임스탬프 동일해도)
|
||||
const arr = trState.seriesData[pt.tag]; if (!arr) continue;
|
||||
const ms = new Date(pt.ts).getTime();
|
||||
for (const pt of (d.Items || [])) {
|
||||
if (pt.Value == null) continue;
|
||||
trState.liveNow[pt.Tag] = +pt.Value;
|
||||
const arr = trState.seriesData[pt.Tag]; if (!arr) continue;
|
||||
const ms = new Date(pt.Ts).getTime();
|
||||
if (!isFinite(ms)) continue;
|
||||
if (!arr.length || arr[arr.length - 1][0] !== ms) { arr.push([ms, +pt.value]); changed = true; }
|
||||
}
|
||||
@@ -671,9 +671,9 @@ function trAnalyze() { document.getElementById('tr-ins-body').textContent = 'P3
|
||||
async function trEnsureAnalogMap() {
|
||||
if (trAnalogMap) return trAnalogMap;
|
||||
const d = await api('GET', '/api/trend/analog-points');
|
||||
trAnalogAll = d.items || [];
|
||||
trAnalogAll = d.Items || [];
|
||||
trAnalogMap = {};
|
||||
for (const p of trAnalogAll) trAnalogMap[p.tagName] = { desc: p.description, unit: p.unit, euLo: p.euLo, euHi: p.euHi };
|
||||
for (const p of trAnalogAll) trAnalogMap[p.TagName] = { Desc: p.Desc, Unit: p.Unit, EuLo: p.EuLo, EuHi: p.EuHi };
|
||||
return trAnalogMap;
|
||||
}
|
||||
|
||||
@@ -740,7 +740,7 @@ async function trLoadGroupList() {
|
||||
const sel = document.getElementById('tr-group-select');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— 그룹 선택 —</option>' +
|
||||
(d.items || []).map(g => `<option value="${g.id}">${esc(g.name)} (${(g.members || []).length})</option>`).join('');
|
||||
(d.Items || []).map(g => `<option value="${g.Id}">${esc(g.Name)} (${(g.Members || []).length})</option>`).join('');
|
||||
if (cur) sel.value = cur;
|
||||
} catch (e) { console.error('trLoadGroupList:', e); }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ async function wrWriteTag() {
|
||||
try {
|
||||
const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value });
|
||||
log('wr-log', [
|
||||
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Write ' + esc(tagName) + ' = ' + value + (d.error ? ' → ' + esc(d.error) : '') },
|
||||
{ c: d.Success ? 'ok' : 'err', t: (d.Success ? '✅ ' : '❌ ') + 'Write ' + esc(tagName) + ' = ' + value + (d.Error ? ' → ' + esc(d.Error) : '') },
|
||||
]);
|
||||
} catch (e) {
|
||||
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
@@ -53,7 +53,7 @@ async function wrSetMode() {
|
||||
try {
|
||||
const d = await api('POST', '/api/gateway/write', { controllerId, tagName: tagName + '.' + modeTag, value: writeValue });
|
||||
log('wr-log', [
|
||||
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(tagName) + ' → ' + mode + ' (' + modeTag + ')' + (d.error ? ' → ' + esc(d.error) : '') },
|
||||
{ c: d.Success ? 'ok' : 'err', t: (d.Success ? '✅ ' : '❌ ') + 'Mode ' + esc(tagName) + ' → ' + mode + ' (' + modeTag + ')' + (d.Error ? ' → ' + esc(d.Error) : '') },
|
||||
]);
|
||||
} catch (e) {
|
||||
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
@@ -71,7 +71,7 @@ async function wrControlOp() {
|
||||
try {
|
||||
const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value: opValue });
|
||||
log('wr-log', [
|
||||
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.error ? ' — ' + esc(d.error) : '') },
|
||||
{ c: d.Success ? 'ok' : 'err', t: (d.Success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.Error ? ' — ' + esc(d.Error) : '') },
|
||||
]);
|
||||
} catch (e) {
|
||||
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
|
||||
61
src/Hc900Crawler/wwwroot/panes/reports.html
Normal file
61
src/Hc900Crawler/wwwroot/panes/reports.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<div class="report-pane" style="padding:20px;max-width:880px">
|
||||
<h2 style="margin-top:0">리포트 (P0)</h2>
|
||||
|
||||
<!-- ── ① 웹에서 바로 보기 ──────────────────────────────── -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h3 style="margin:0 0 8px">① 웹에서 바로 보기</h3>
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
||||
<label>컬럼<br><select id="rvCol" style="margin-top:4px"></select></label>
|
||||
<label>날짜(KST)<br><input type="date" id="rvDate" style="margin-top:4px"></label>
|
||||
<label>소스<br>
|
||||
<select id="rvSource" style="margin-top:4px">
|
||||
<option value="history_table">history (60초)</option>
|
||||
<option value="fast_record">fastRecord</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="rvSessWrap" style="display:none">session id<br><input type="number" id="rvSess" style="margin-top:4px"></label>
|
||||
<button id="rvGo" class="btn">조회</button>
|
||||
</div>
|
||||
<div id="rvOut" style="margin-top:16px"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── ② 엑셀 폼으로 내보내기 ──────────────────────────── -->
|
||||
<section style="border-top:1px solid var(--bd,#333);padding-top:18px">
|
||||
<h3 style="margin:0 0 8px">② 엑셀 폼으로 내보내기</h3>
|
||||
<p style="color:var(--t2);font-size:13px;margin-top:0">
|
||||
엑셀 템플릿 셀에 <code>{{ metric=mass_balance_closure; column=C-6111 }}</code> 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.
|
||||
</p>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;max-width:560px">
|
||||
<label>① 엑셀 템플릿(.xlsx)<input type="file" id="rpTpl" accept=".xlsx,.xlsm" style="display:block;margin-top:4px"></label>
|
||||
<label>② 날짜(KST)<input type="date" id="rpDate" style="display:block;margin-top:4px"></label>
|
||||
<label>③ 소스
|
||||
<select id="rpSource" style="display:block;margin-top:4px">
|
||||
<option value="history_table">history (60초)</option>
|
||||
<option value="fast_record">fastRecord</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="rpSessionWrap" style="display:none">session id<input type="number" id="rpSession" style="display:block;margin-top:4px"></label>
|
||||
<button id="rpGen" class="btn" style="align-self:flex-start">리포트 생성·다운로드</button>
|
||||
</div>
|
||||
<div id="rpStatus" class="mono" style="margin-top:12px;white-space:pre-wrap"></div>
|
||||
|
||||
<details style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--t2)">토큰 치트시트</summary>
|
||||
<pre style="font-size:12px;background:var(--bg2,#1a1a1a);padding:10px;border-radius:6px">
|
||||
■ 적산(.QV) 기반 — 정확·gap강건 (권장)
|
||||
{{ metric=production_total; column=C-6111 }} → 생산량 적산 Δ (kg)
|
||||
{{ metric=yield_qv; column=C-6111 }} → 수율(제품Δ/원료Δ)
|
||||
{{ metric=energy_intensity_qv; column=C-6111 }} → 에너지원단위(스팀Δ/제품Δ)
|
||||
{{ metric=mass_balance_closure; column=C-6111 }} → 폐합률 % (OUT/IN)
|
||||
{{ metric=mass_balance_closure; column=C-6111; field=feed_qv }} → 원료 적산 Δ (IN)
|
||||
{{ metric=mass_balance_closure; column=C-6111; field=out_total }} → 회수 합 (OUT)
|
||||
{{ metric=mass_balance_closure; column=C-6111; field=out0_qv }} → 제품(경비/중비는 out1/out2)
|
||||
|
||||
■ PV 기반 (근사)
|
||||
{{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP)
|
||||
{{ metric=control_residual; column=C-6111; field=sd }} → 잔차 표준편차
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
<script>/* reports.js 가 paneInit['reports'] 등록 */</script>
|
||||
@@ -56,17 +56,19 @@
|
||||
<div class="cols-2" style="margin-top:12px">
|
||||
<div class="fg">
|
||||
<label>시작일 <em>(비우면 최근 24시간)</em></label>
|
||||
<input id="t2s-date-from" class="inp" type="datetime-local"/>
|
||||
<input type="hidden" id="hf-t2s-from"/>
|
||||
<div class="dt-display inp" id="dtp-t2s-from-display" onclick="dtOpen('t2s-from')">— 선택 안 함 —</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>종료일 <em>(비우면 현재)</em></label>
|
||||
<input id="t2s-date-to" class="inp" type="datetime-local"/>
|
||||
<input type="hidden" id="hf-t2s-to"/>
|
||||
<div class="dt-display inp" id="dtp-t2s-to-display" onclick="dtOpen('t2s-to')">— 선택 안 함 —</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<div class="fg">
|
||||
<label>분석 데이터 제한</label>
|
||||
<input id="t2s-limit-analyze" class="inp" type="number" value="100"/>
|
||||
<input id="t2s-limit-analyze" class="inp" type="number" value="100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ public class McpService : IMcpService
|
||||
public async Task<List<McpToolDto>> ListToolsAsync()
|
||||
{
|
||||
var tools = await _client.ListToolsAsync();
|
||||
return tools.Select(t => new McpToolDto { Name = t.Name, Description = t.Description }).ToList();
|
||||
return tools.Select(t => new McpToolDto { Name = t.Name, Description = t.Description, InputSchema = t.InputSchema }).ToList();
|
||||
}
|
||||
|
||||
public async Task<McpQueryResult> RunSqlAsync(string sql)
|
||||
|
||||
131
src/Infrastructure/Reporting/ReportColumnMap.cs
Normal file
131
src/Infrastructure/Reporting/ReportColumnMap.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
/// <summary>메트릭 1건의 태그 역할 + 클린범위. 비율메트릭: A=분자,B=분모. 잔차: A=PV,B=SP.</summary>
|
||||
public sealed record MetricTag(string Tag, double Lo, double Hi);
|
||||
public sealed record MetricSpec(string Unit, MetricTag A, MetricTag B);
|
||||
|
||||
/// <summary>적산(.QV) 메트릭 스펙. Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOutputs/ΔA(=feed).</summary>
|
||||
public enum QvKind { Single, Ratio, Closure }
|
||||
/// <summary>Max = 비율의 물리적 상한(초과 시 no_data). null이면 무검사.</summary>
|
||||
public sealed record QvSpec(string Unit, QvKind Kind, string A, string? B, IReadOnlyList<string> Outputs, double? Max = null);
|
||||
|
||||
/// <summary>
|
||||
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
|
||||
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2).
|
||||
/// </summary>
|
||||
public sealed class ReportColumnMap
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
public ReportColumnMap(IConfiguration config) => _config = config;
|
||||
|
||||
// 역할별 기본 클린범위 (오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값)
|
||||
private static readonly (double lo, double hi) TEMP = (60, 95);
|
||||
private static readonly (double lo, double hi) STEAM = (50, 3000);
|
||||
private static readonly (double lo, double hi) FLOW = (100, 1500);
|
||||
|
||||
/// <summary>설정된 컬럼 키 목록(SteamAdvisor:Columns).</summary>
|
||||
public IReadOnlyList<string> Columns()
|
||||
=> _config.GetSection("SteamAdvisor:Columns").GetChildren().Select(c => c.Key).ToList();
|
||||
|
||||
/// <summary>해당 컬럼에 폐합(물질수지) 설정이 있는지.</summary>
|
||||
public bool HasClosure(string column)
|
||||
=> _config.GetSection($"Report:Closure:{column}").Exists();
|
||||
|
||||
public bool TryResolve(string column, string metric, out MetricSpec? spec)
|
||||
{
|
||||
spec = null;
|
||||
var sec = _config.GetSection($"SteamAdvisor:Columns:{column}");
|
||||
if (!sec.Exists()) return false;
|
||||
|
||||
string? feed = Norm(sec["Feed"]);
|
||||
string? product= Norm(sec["Product"]);
|
||||
string? steam = Norm(sec["SteamFlow"]); // 예: "FIQ-6115" → "FIQ-6115.PV"
|
||||
string? steamOp= sec["SteamOp"]; // 예: "TICA-6111A.OP"
|
||||
|
||||
switch (metric)
|
||||
{
|
||||
case "energy_efficiency":
|
||||
if (steam is null || product is null) return false;
|
||||
spec = new MetricSpec("kg스팀/kg제품",
|
||||
new MetricTag(steam, STEAM.lo, STEAM.hi),
|
||||
new MetricTag(product, FLOW.lo, FLOW.hi));
|
||||
return true;
|
||||
|
||||
case "yield":
|
||||
if (product is null || feed is null) return false;
|
||||
spec = new MetricSpec("제품/원료",
|
||||
new MetricTag(product, FLOW.lo, FLOW.hi),
|
||||
new MetricTag(feed, FLOW.lo, FLOW.hi));
|
||||
return true;
|
||||
|
||||
case "control_residual":
|
||||
if (steamOp is null) return false;
|
||||
var loopBase = StripAttr(steamOp); // TICA-6111A.OP → TICA-6111A
|
||||
spec = new MetricSpec("degC",
|
||||
new MetricTag(loopBase + ".PV", TEMP.lo, TEMP.hi),
|
||||
new MetricTag(loopBase + ".SP", TEMP.lo, TEMP.hi));
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 적산(.QV) 메트릭 해석. production_total/yield_qv/energy_intensity_qv 는 SteamAdvisor:Columns 재사용,
|
||||
/// mass_balance_closure 는 appsettings `Report:Closure:{col}`(Feed + Outputs[]) 사용.
|
||||
/// </summary>
|
||||
public bool TryResolveQv(string column, string metric, out QvSpec? spec)
|
||||
{
|
||||
spec = null;
|
||||
var sec = _config.GetSection($"SteamAdvisor:Columns:{column}");
|
||||
string? feed = ToQv(sec["Feed"]);
|
||||
string? product = ToQv(sec["Product"]);
|
||||
string? steam = ToQv(sec["SteamFlow"]);
|
||||
|
||||
switch (metric)
|
||||
{
|
||||
case "production_total":
|
||||
if (product is null) return false;
|
||||
spec = new QvSpec("kg", QvKind.Single, product, null, Array.Empty<string>());
|
||||
return true;
|
||||
|
||||
case "yield_qv": // 제품/원료: 질량 보존상 ≤ ~1
|
||||
if (product is null || feed is null) return false;
|
||||
spec = new QvSpec("제품/원료", QvKind.Ratio, product, feed, Array.Empty<string>(), Max: 1.5);
|
||||
return true;
|
||||
|
||||
case "energy_intensity_qv": // 스팀/제품: 물리적으로 수 이내
|
||||
if (steam is null || product is null) return false;
|
||||
spec = new QvSpec("kg스팀/kg제품", QvKind.Ratio, steam, product, Array.Empty<string>(), Max: 5.0);
|
||||
return true;
|
||||
|
||||
case "mass_balance_closure":
|
||||
var cl = _config.GetSection($"Report:Closure:{column}");
|
||||
if (!cl.Exists()) return false;
|
||||
var f = ToQv(cl["Feed"]);
|
||||
var outs = cl.GetSection("Outputs").Get<string[]>()
|
||||
?.Select(ToQv).Where(x => x != null).Select(x => x!).ToList();
|
||||
if (f is null || outs is null || outs.Count == 0) return false;
|
||||
spec = new QvSpec("%", QvKind.Closure, f, null, outs);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>속성(.PV 등) 없으면 .PV 부여.</summary>
|
||||
private static string? Norm(string? tag)
|
||||
=> string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV");
|
||||
|
||||
/// <summary>속성 무시하고 베이스+.QV (적산값).</summary>
|
||||
private static string? ToQv(string? tag)
|
||||
=> string.IsNullOrWhiteSpace(tag) ? null : StripAttr(tag!) + ".QV";
|
||||
|
||||
/// <summary>마지막 '.' 이후(속성) 제거 → 루프/계기 베이스.</summary>
|
||||
private static string StripAttr(string tag)
|
||||
{ var i = tag.LastIndexOf('.'); return i < 0 ? tag : tag[..i]; }
|
||||
}
|
||||
71
src/Infrastructure/Reporting/ReportFillService.cs
Normal file
71
src/Infrastructure/Reporting/ReportFillService.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using OfficeOpenXml;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// 운전원 엑셀 템플릿의 `{{ metric=...; column=...; field=... }}` 토큰을 메트릭 값으로 치환.
|
||||
/// 채운 셀엔 해상도 메타를 주석으로 부착. EPPlus(서버) — 의존성 기탑재.
|
||||
/// </summary>
|
||||
public sealed class ReportFillService
|
||||
{
|
||||
private static readonly Regex TOKEN = new(@"\{\{\s*(?<body>.+?)\s*\}\}", RegexOptions.Compiled);
|
||||
private readonly IReportMetricService _metrics;
|
||||
public ReportFillService(IReportMetricService metrics) => _metrics = metrics;
|
||||
|
||||
public async Task<(byte[] Xlsx, List<object> Cells, string Status)> FillAsync(
|
||||
byte[] template, DateTime periodKst, string sourceTable, int? sessionId, CancellationToken ct = default)
|
||||
{
|
||||
using var pkg = new ExcelPackage(new MemoryStream(template));
|
||||
var cells = new List<object>();
|
||||
bool anyErr = false, anyOk = false, anyToken = false;
|
||||
|
||||
foreach (var ws in pkg.Workbook.Worksheets)
|
||||
{
|
||||
var dim = ws.Dimension;
|
||||
if (dim == null) continue;
|
||||
for (int r = dim.Start.Row; r <= dim.End.Row; r++)
|
||||
for (int c = dim.Start.Column; c <= dim.End.Column; c++)
|
||||
{
|
||||
var cell = ws.Cells[r, c];
|
||||
var mt = TOKEN.Match(cell.Text);
|
||||
if (!mt.Success) continue;
|
||||
anyToken = true;
|
||||
|
||||
var kv = ParseToken(mt.Groups["body"].Value);
|
||||
var req = new MetricRequestDto
|
||||
{
|
||||
Metric = kv.GetValueOrDefault("metric", ""),
|
||||
Column = kv.GetValueOrDefault("column", "C-6111"),
|
||||
PeriodDateKst = periodKst,
|
||||
SourceTable = sourceTable,
|
||||
SessionId = sessionId
|
||||
};
|
||||
var m = await _metrics.ComputeAsync(req, ct);
|
||||
|
||||
double? v = kv.TryGetValue("field", out var f) && m.Extra.TryGetValue(f, out var ev) ? ev : m.Value;
|
||||
if (m.Status == "ok" && v.HasValue) { cell.Value = v.Value; anyOk = true; }
|
||||
else { cell.Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }
|
||||
|
||||
if (cell.Comment == null)
|
||||
cell.AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | keep={1 - m.CleanedFraction:P0} | {m.Unit}"
|
||||
+ (m.Error != null ? $" | {m.Error}" : ""), "report");
|
||||
|
||||
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column,
|
||||
field = kv.GetValueOrDefault("field", ""), value = v, m.Status,
|
||||
m.Source, m.SamplingMs, m.N, m.Unit });
|
||||
}
|
||||
}
|
||||
|
||||
string status = !anyToken ? "error" : anyErr ? (anyOk ? "partial" : "error") : "ok";
|
||||
return (pkg.GetAsByteArray(), cells, status);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseToken(string body) =>
|
||||
body.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(p => p.Split('=', 2))
|
||||
.Where(a => a.Length == 2)
|
||||
.ToDictionary(a => a[0].Trim().ToLowerInvariant(), a => a[1].Trim());
|
||||
}
|
||||
248
src/Infrastructure/Reporting/ReportMetricService.cs
Normal file
248
src/Infrastructure/Reporting/ReportMetricService.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// 결정론 메트릭 엔진. history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
|
||||
/// raw SQL은 FeedforwardAuditService 패턴(_ctx.Database.GetDbConnection() + @param).
|
||||
/// 표본 0 → no_data(0 날조 금지), 매핑 미정의 → error (결정론 검증 게이트).
|
||||
/// </summary>
|
||||
public sealed class ReportMetricService : IReportMetricService
|
||||
{
|
||||
private readonly Hc900DbContext _ctx;
|
||||
private readonly ILogger<ReportMetricService> _logger;
|
||||
private readonly ReportColumnMap _map;
|
||||
private const string NUMERIC = "^-?[0-9]+(\\.[0-9]+)?$";
|
||||
|
||||
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map)
|
||||
{ _ctx = ctx; _logger = logger; _map = map; }
|
||||
|
||||
private static readonly HashSet<string> QV_METRICS =
|
||||
new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
|
||||
|
||||
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
|
||||
{
|
||||
bool isFast = req.SourceTable == "fast_record";
|
||||
var res = new MetricResultDto
|
||||
{
|
||||
Metric = req.Metric, Column = req.Column,
|
||||
Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000
|
||||
};
|
||||
|
||||
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
|
||||
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||
var toUtc = fromUtc.AddDays(1);
|
||||
string tbl = isFast ? "fast_record" : "history_table";
|
||||
|
||||
try
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
if (QV_METRICS.Contains(req.Metric))
|
||||
{
|
||||
if (!_map.TryResolveQv(req.Column, req.Metric, out var qspec) || qspec is null)
|
||||
{ res.Status = "error"; res.Error = $"미정의 QV 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
|
||||
res.Unit = qspec.Unit;
|
||||
await ComputeQvAsync(res, conn, tbl, qspec, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null)
|
||||
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
|
||||
res.Unit = spec.Unit;
|
||||
if (req.Metric == "control_residual")
|
||||
await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||
else
|
||||
await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||
}
|
||||
|
||||
if (isFast && res.Status == "ok") res.SamplingMs = await FastSamplingMsAsync(conn, req.SessionId ?? -1, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Report] metric 실패 {Col}/{M}", req.Column, req.Metric);
|
||||
res.Status = "error"; res.Error = ex.Message; res.Value = null;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed ──
|
||||
private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
if (s.Kind == QvKind.Single)
|
||||
{
|
||||
var (tot, n, resets) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
|
||||
if (tot is null) { res.Status = "no_data"; res.Value = null; return; }
|
||||
res.Value = tot; res.N = n; res.Extra["resets"] = resets;
|
||||
}
|
||||
else if (s.Kind == QvKind.Ratio)
|
||||
{
|
||||
var (a, na, ra) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
|
||||
var (b, nb, rb) = await QvDeltaAsync(conn, tbl, s.B!, fromUtc, toUtc, isFast, sid, ct);
|
||||
if (a is null || b is null || b == 0) { res.Status = "no_data"; res.Value = null; return; }
|
||||
res.Value = a / b; res.N = Math.Min(na, nb);
|
||||
res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["resets"] = ra + rb;
|
||||
// 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등)
|
||||
if (res.Value <= 0 || (s.Max is double mx && res.Value > mx))
|
||||
{
|
||||
res.Status = "no_data";
|
||||
res.Error = $"비물리적 비율 {res.Value:F1} (분모 적산 Δ={b:F0} 비정상 추정)";
|
||||
res.Value = null;
|
||||
}
|
||||
}
|
||||
else // Closure
|
||||
{
|
||||
var (feed, nf, rf) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
|
||||
if (feed is null || feed == 0) { res.Status = "no_data"; res.Value = null; return; }
|
||||
double outSum = 0; int resets = rf;
|
||||
for (int i = 0; i < s.Outputs.Count; i++)
|
||||
{
|
||||
var (d, _, ri) = await QvDeltaAsync(conn, tbl, s.Outputs[i], fromUtc, toUtc, isFast, sid, ct);
|
||||
outSum += d ?? 0; resets += ri;
|
||||
res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서)
|
||||
}
|
||||
res.Value = 100.0 * outSum / feed; // 폐합 %
|
||||
res.N = nf;
|
||||
res.Extra["feed_qv"] = feed;
|
||||
res.Extra["out_total"] = outSum;
|
||||
res.Extra["product_qv"] = s.Outputs.Count > 0 ? res.Extra["out0_qv"] : null;
|
||||
res.Extra["resets"] = resets;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건,
|
||||
/// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실.
|
||||
/// </summary>
|
||||
private static async Task<(double? Total, int NSteps, int NResets)> QvDeltaAsync(
|
||||
DbConnection conn, string tbl, string tag, DateTime fromUtc, DateTime toUtc,
|
||||
bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
WITH s AS (
|
||||
SELECT recorded_at, value::float v,
|
||||
lag(value::float) OVER (ORDER BY recorded_at) prev
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname=@tag AND value ~ '{NUMERIC}'
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
), seg AS (
|
||||
SELECT recorded_at, v,
|
||||
sum(CASE WHEN prev IS NOT NULL AND v < prev - 1 THEN 1 ELSE 0 END)
|
||||
OVER (ORDER BY recorded_at) AS seg_id
|
||||
FROM s
|
||||
), perseg AS (
|
||||
SELECT (array_agg(v ORDER BY recorded_at))[1] AS first_v,
|
||||
(array_agg(v ORDER BY recorded_at DESC))[1] AS last_v
|
||||
FROM seg GROUP BY seg_id
|
||||
)
|
||||
SELECT (SELECT sum(last_v - first_v) FROM perseg),
|
||||
(SELECT count(*) FROM s),
|
||||
(SELECT count(*) FROM s WHERE prev IS NOT NULL AND v < prev - 1);";
|
||||
AddP(cmd, "@tag", tag);
|
||||
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
|
||||
return (rd.GetDouble(0), (int)rd.GetInt64(1), (int)rd.GetInt64(2));
|
||||
return (null, 0, 0);
|
||||
}
|
||||
|
||||
// ── 비율: median(A/B) (분 버킷 피벗, 둘 다 유효한 분만). 클린율 = 1 - 2*good/raw ──
|
||||
private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
WITH raw AS (
|
||||
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname IN (@a,@b) AND value ~ '{NUMERIC}'
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
), tot AS (SELECT count(*) c FROM raw),
|
||||
piv AS (
|
||||
SELECT ts, max(v) FILTER (WHERE tagname=@a) a, max(v) FILTER (WHERE tagname=@b) b
|
||||
FROM raw GROUP BY ts
|
||||
), good AS (
|
||||
SELECT a, b FROM piv
|
||||
WHERE a BETWEEN @alo AND @ahi AND b BETWEEN @blo AND @bhi AND b <> 0
|
||||
)
|
||||
SELECT (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY a/b) FROM good),
|
||||
(SELECT count(*) FROM good),
|
||||
(SELECT c FROM tot);";
|
||||
AddP(cmd, "@a", s.A.Tag); AddP(cmd, "@b", s.B.Tag);
|
||||
AddP(cmd, "@alo", s.A.Lo); AddP(cmd, "@ahi", s.A.Hi);
|
||||
AddP(cmd, "@blo", s.B.Lo); AddP(cmd, "@bhi", s.B.Hi);
|
||||
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
|
||||
{
|
||||
res.Value = rd.GetDouble(0);
|
||||
res.N = (int)rd.GetInt64(1);
|
||||
var nRaw = rd.GetInt64(2);
|
||||
res.CleanedFraction = nRaw > 0 ? Math.Clamp(1.0 - (2.0 * res.N) / nRaw, 0, 1) : 0;
|
||||
}
|
||||
else { res.Status = "no_data"; res.Value = null; }
|
||||
}
|
||||
|
||||
// ── 제어잔차: (PV-SP) mean/sd/abs_p95/이탈% ──
|
||||
private async Task ResidualAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
WITH raw AS (
|
||||
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname IN (@pv,@sp) AND value ~ '{NUMERIC}'
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
), piv AS (
|
||||
SELECT ts, max(v) FILTER (WHERE tagname=@pv) pv, max(v) FILTER (WHERE tagname=@sp) sp
|
||||
FROM raw GROUP BY ts
|
||||
), good AS (
|
||||
SELECT pv - sp AS e FROM piv
|
||||
WHERE pv BETWEEN @plo AND @phi AND sp BETWEEN @slo AND @shi
|
||||
)
|
||||
SELECT avg(e), stddev(e),
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY abs(e)),
|
||||
count(*),
|
||||
100.0 * count(*) FILTER (WHERE abs(e) > 0.5) / NULLIF(count(*),0)
|
||||
FROM good;";
|
||||
AddP(cmd, "@pv", s.A.Tag); AddP(cmd, "@sp", s.B.Tag);
|
||||
AddP(cmd, "@plo", s.A.Lo); AddP(cmd, "@phi", s.A.Hi);
|
||||
AddP(cmd, "@slo", s.B.Lo); AddP(cmd, "@shi", s.B.Hi);
|
||||
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct) && !rd.IsDBNull(3) && rd.GetInt64(3) > 0)
|
||||
{
|
||||
res.Value = rd.IsDBNull(0) ? null : rd.GetDouble(0);
|
||||
res.Extra["sd"] = rd.IsDBNull(1) ? null : rd.GetDouble(1);
|
||||
res.Extra["abs_p95"] = rd.IsDBNull(2) ? null : rd.GetDouble(2);
|
||||
res.N = (int)rd.GetInt64(3);
|
||||
res.Extra["out_pct_0_5"] = rd.IsDBNull(4) ? null : rd.GetDouble(4);
|
||||
}
|
||||
else { res.Status = "no_data"; res.Value = null; }
|
||||
}
|
||||
|
||||
private static async Task<int> FastSamplingMsAsync(DbConnection conn, int sid, CancellationToken ct)
|
||||
{
|
||||
await using var c = conn.CreateCommand();
|
||||
c.CommandText = "SELECT sampling_ms FROM hc900.fast_session WHERE id=@id";
|
||||
AddP(c, "@id", sid);
|
||||
var o = await c.ExecuteScalarAsync(ct);
|
||||
return o is null or DBNull ? 0 : Convert.ToInt32(o);
|
||||
}
|
||||
|
||||
private static void AddP(DbCommand cmd, string name, object val)
|
||||
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
|
||||
}
|
||||
65
src/Infrastructure/Reporting/ReportTemplateStore.cs
Normal file
65
src/Infrastructure/Reporting/ReportTemplateStore.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
/// <summary>report_template / report_run raw SQL CRUD (FeedforwardAuditService 패턴).</summary>
|
||||
public sealed class ReportTemplateStore : IReportTemplateStore
|
||||
{
|
||||
private readonly Hc900DbContext _ctx;
|
||||
public ReportTemplateStore(Hc900DbContext ctx) => _ctx = ctx;
|
||||
|
||||
public async Task<int> CreateAsync(string name, string? owner, byte[] xlsx, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO hc900.report_template(name, owner, xlsx_blob) VALUES (@n,@o,@b) RETURNING id";
|
||||
AddP(cmd, "@n", name);
|
||||
AddP(cmd, "@o", (object?)owner ?? DBNull.Value);
|
||||
AddP(cmd, "@b", xlsx);
|
||||
var o = await cmd.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt32(o);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetBlobAsync(int templateId, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT xlsx_blob FROM hc900.report_template WHERE id=@id";
|
||||
AddP(cmd, "@id", templateId);
|
||||
var o = await cmd.ExecuteScalarAsync(ct);
|
||||
return o is byte[] b ? b : null;
|
||||
}
|
||||
|
||||
public async Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate,
|
||||
string sourceTable, string status, object cells, byte[] outBlob, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO hc900.report_run(template_id, period_kind, period_date, source_table, status, cells_json, out_blob)
|
||||
VALUES (@t, @pk, @pd, @src, @st, @cells::jsonb, @blob)";
|
||||
AddP(cmd, "@t", templateId);
|
||||
AddP(cmd, "@pk", periodKind);
|
||||
AddP(cmd, "@pd", periodDate.Date);
|
||||
AddP(cmd, "@src", sourceTable);
|
||||
AddP(cmd, "@st", status);
|
||||
AddP(cmd, "@cells", JsonSerializer.Serialize(cells));
|
||||
AddP(cmd, "@blob", outBlob);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<DbConnection> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
return conn;
|
||||
}
|
||||
|
||||
private static void AddP(DbCommand cmd, string name, object val)
|
||||
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
|
||||
}
|
||||
Reference in New Issue
Block a user