10 Commits

Author SHA1 Message Date
windpacer
30a3286d35 feat(report): 적산(.QV) 정확 메트릭 + 물질수지 폐합 + 웹 대시보드
PV-중앙값 근사 대신 유량 적산(.QV)으로 정확한 적분 메트릭 추가, 웹에서 바로 보기.

- QV 메트릭 4종: production_total·yield_qv·energy_intensity_qv·mass_balance_closure.
  적산 Δ 헬퍼(QvDeltaAsync)는 값 급감(reset/wrap)으로 구간분할 후 구간별 (마지막-처음)
  합산 → gap·양자화 강건, DCS 일일적산과 동일 의미(노이즈 과대계상 없음).
- 매핑은 appsettings SteamAdvisor:Columns 재사용(.QV 치환, 7컬럼). 폐합 스트림은
  Report:Closure:C-6111(Feed + Outputs[제품·경비물·중비물]).
- 웹 대시보드: GET /api/report/columns, /api/report/summary →
  리포트 탭에서 컬럼·날짜 선택 시 물질수지 신뢰블록(IN/OUT/폐합%, 98~101% 색상)
  + 메트릭 표를 즉시 렌더. 엑셀 export와 병존.
- 물리적 타당성 게이트: 수율>1.5·에너지원단위>5·≤0 → no_data+사유(garbage 차단).

검증(2026-05-15 C-6111): 생산 8455kg·수율 0.8739·에너지 0.7791·폐합 99.04%
(feed 9675/out 9582), 17주 폐합 99.1~99.8%. C-9111 garbage 수율 → no_data 처리 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 07:54:34 +09:00
windpacer
37a464ab96 feat(report): P0 셀프서비스 결정론 리포트 — 메트릭 3종·엑셀 토큰 채움·fastRecord 소스
운전원이 올린 엑셀 템플릿의 {{ metric=...; column=... }} 토큰을, 선택한 날짜의
결정론 계산값으로 채워 다운로드하는 셀프서비스 리포트 레이어(P0).

- 메트릭 3종: energy_efficiency·yield·control_residual (분 버킷 피벗, 클린필터,
  KST→UTC 경계). history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
- 컬럼→태그 매핑은 기존 appsettings SteamAdvisor:Columns 재사용(7컬럼 무료).
- EPPlus 토큰 치환 + 셀 주석에 해상도 메타(source/sampling/n/keep) 부착.
- report_template/report_run 테이블 + cells_json 감사 박제.
- 리포트 탭(웹 UI) + 샘플 템플릿.

검증: 빌드 0에러. E2E 라운드트립 실동작(2026-05-15 C-6111 효율 0.778·수율 0.872·
잔차 mean 0.746 — 오프라인 검증값과 일치). 결정론 검증 게이트 준수(표본0=N/A, 0 날조 금지).

⚠️ EPPlus는 NonCommercial 컨텍스트 — 상용 출시 전 라이선스 정리 필요.

설계: docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:14:35 +09:00
windpacer
aa2174118a docs: 작업지시서(PascalCase 통일)·AI운전원 제어 논의메모·HC900 IO 데이터시트 추가
- 작업지시서-API-필드-PascalCase-통일.md: PascalCase 통일 작업 지시서.
- 논의-AI운전원-제어-아이디어.md: AI 운전원 제어 아이디어 논의 메모.
- ControlEdge HC900 IO Modules Specifications.pdf: RTD/AI 모듈 스펙(레인지·정확도)
  데이터시트 — PT100 양자화 분석 근거 자료.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:33:32 +09:00
windpacer
7124b222c3 chore: gateway-config에 HCX 컨트롤러 항목 추가 + plant_context sub_area 도구호출 설명 정리
- gateway-config.json: HCX 컨트롤러 슬롯(grpcPort 50055) 추가.
- plant_context.md: active_alarms/find_tags/query_events 등 sub_area 인자 전달
  방식 설명을 prose로 정리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:31:24 +09:00
windpacer
f97be981a4 style(api): JSON 응답 필드 PascalCase 통일 (컨트롤러·DTO·JS)
C# 익명객체 응답 속성을 camelCase→PascalCase로 통일(key→Key, success→Success,
error→Error 등)하고, 프런트 JS의 응답 접근도 맞춰 변경(ramp.jobs→ramp.Jobs,
data.columns→data.Columns 등). Kb/Pid/Steam/Feedforward/Ollama/PointBuilder 등
전 컨트롤러와 대응 JS, ExperionDtos·TrendDtos, McpService 반영.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:31:14 +09:00
windpacer
cfef06c21e fix(nl2sql): query_with_nl 프롬프트 단일화 + 극값 CTE(전체반환·앵커) + 결정론 태그 검증 게이트
- server.py: inline _DB_SCHEMA 제거, worker/sql_prompt.SQL_SYSTEM_PROMPT로 단일화.
  query_pv_history/query_with_nl description 보강(집계·극값은 query_with_nl 유도).
- 결정론 태그 검증 게이트(_verify_sql_tags)+피드백 재시도(최대3회): 환각 태그는
  실행 거부·교정, 끝내 미존재면 빈 결과 대신 명시적 에러(날조 차단). 대소문자 교정.
- sql_prompt.py: 극값→관련태그 CTE를 'value=MAX(...) 모든 시각 반환 + max_occurrences
  + 앵커(극값기준 태그) 포함'으로 교체. 태그 대문자 규칙.
- 피벗 시 max_occurrences 등 스칼라 컬럼 보존.
- curate/golden: 극값 CTE(전체반환·앵커≠요청) 학습예제·eval 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:22:31 +09:00
windpacer
fc197465d4 docs: PT100 RTD 온도값 양자화 원인분석 — RTD 레인지(high/low) 설정 문제
TI-6111C/TI-9211C가 소수점 5자리까지 반복되는 현상의 원인 규명.
field_hist 원본 대조로 데이터·이관·OPC·하드웨어 기각, P2(low+F0) 대조군
실측으로 RTD 레인지 설정(high 1000°C스팬 vs low 500°C스팬)이 분해능·정확도
2배 차이의 단독 원인임을 확정. 해법=레인지를 low로 변경.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:21:31 +09:00
windpacer
dbad4a54cf fix: T2S vLLM URL, first()/last() compatibility, PascalCase unification, event date parsing
- Fix vLLM base URL from port 8001 to 8000 (server.py)
- Replace TimeScaleDB first()/last() with array_agg(ORDER BY) for standard PostgreSQL compatibility (TextToSqlService.cs)
- Unify all backend response fields to PascalCase (TextToSqlController.cs)
- Update frontend to match PascalCase response fields (t2s.js)
- Change T2S date inputs to calendar popup (same as hist.html) (t2s.html)
- Fix evt.js date parsing: empty string causes Invalid Date error (evt.js)
2026-06-11 06:11:28 +09:00
windpacer
f1c11931fe feat: learned-control 브랜치 main 머지
Point Builder, SteamAdvisor, Feedforward 제어, MCP RAG/NL2SQL,
multi-controller 지원 등 28커밋 분량 기능 통합.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 04:41:57 +09:00
windpacer
cb749568d0 chore: 완료된 작업지시서·계획 문서 삭제
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 04:41:40 +09:00
66 changed files with 3890 additions and 3115 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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
```

View File

@@ -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
}
]
}

Binary file not shown.

View 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로 Ω 환산.

View File

@@ -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)

Binary file not shown.

View 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이므로 당연. 정상상태 "같은 품질·더 적은 스팀" 마진 = p50p10 ≈ **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)

View File

@@ -0,0 +1,796 @@
# 구현 검증 보고서 — 작업지시서-API-필드-PascalCase-통일
**검증일**: 2026-06-11
**검증 대상**: 작업지시서 §5 Phase 03 구현 결과
**검증 방법**: `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`(L3850)만 소문자 잔존 → 즉시 수정 대상 |
| `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`(L3850) 응답을 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` 정리).*

View 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 }}` | 평균 잔차(PVSP) |
| `{{ 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`)** — 핵심. 값 급감(<prev1)으로 **리셋/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]].*

View 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` | (PVSP) 통계 (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]].*

View File

@@ -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"]}}

View File

@@ -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},
{"role": "user", "content": question},
],
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()
# 프롬프트 단일 소스: 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_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()
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)
except Exception as e:
return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, 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)

View File

@@ -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) 생성 + 검증 + 통계 ────────────────────────────────────────────────────

View File

@@ -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 공식 사용
"""

View File

@@ -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
View 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);

View File

@@ -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

View 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 등
}

View File

@@ -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; } = "";
}

View File

@@ -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>

View 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);
}

View 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);
}

View File

@@ -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

View File

@@ -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); }
}

View File

@@ -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
}),

View File

@@ -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)

View File

@@ -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")]

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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)
{

View File

@@ -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}" });
}
}
}

View File

@@ -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 = "작업이 시작되었습니다."
});
}

View File

@@ -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 });
}
}

View 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);
}
}

View File

@@ -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) });
}
}

View File

@@ -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);
}

View File

@@ -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 });
}
}
}

View File

@@ -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 }); }
}
}

View File

@@ -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 초기화 ─────────────────────────────────────────────────────────────────

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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 = '파일을 선택하세요';

View File

@@ -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');

View File

@@ -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);

View File

@@ -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;

View File

@@ -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', '조회 실패');

View File

@@ -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;
}

View File

@@ -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(() => {});
}

View File

@@ -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>`;
}

View File

@@ -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));
}
};

View File

@@ -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) {

View 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')} &nbsp;→&nbsp; <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;
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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); }
}

View File

@@ -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 }]);

View 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>

View File

@@ -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>

View File

@@ -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)

View 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]; }
}

View 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());
}

View 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); }
}

View 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); }
}