Files
ExperionCrawler/AGENTS.md
windpacer 7c26aa7361 feat: Phase II auto-write (WriteGuard, audit, auth) + WO-2~7 완료
Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
  POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)

WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
  total reflux recovery, config form expansion
2026-05-31 20:30:06 +09:00

193 lines
11 KiB
Markdown

# ExperionCrawler — Agent Instructions
## Build / Run / Test
| Action | Command | Working Dir |
|--------|---------|-------------|
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
| Run (dev) | `dotnet run` | `src/Web/` |
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` |
| Tests | `dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj` | repo root |
Single project: `src/Web/ExperionCrawler.csproj`. Core and Infrastructure are included via `<Compile Include>` globs — there are no separate projects to build. Runtime target is `linux-arm64`.
## Architecture
```
src/
├── Core/ — Interfaces (IExperionServices.cs), Domain entities, DTOs, Application Services
├── Infrastructure/ — OpcUa/, Database/, Certificates/, Csv/, Mcp/
└── Web/ — Program.cs, Controllers/, wwwroot/ (SPA)
```
All controllers are in `src/Web/Controllers/ExperionControllers.cs` (single file). All interfaces are in `src/Core/Application/Interfaces/IExperionServices.cs` (single file).
## Database
**PostgreSQL** (NOT SQLite — README is stale). Connection strings in `src/Web/appsettings.json`. TimescaleDB extension may be enabled on `history_table` via DDL only; no app code changes needed.
### ⚠️ Critical — `duration_seconds` semantics
`event_history_table.duration_seconds` = **직전 상태의 지속 시간(초)**. 현재 이벤트의 지속 시간이 아니다.
- `DigitalEventDetectorService`가 이전 상태를 처음 관측한 시점부터 값이 바뀔 때까지의 경과 시간(wall-clock)을 기록
- 예: `prev=R-RUN, curr=R-TRIP, duration_seconds=1423`**R-RUN 상태가 1423초간 유지되다가** R-TRIP으로 전환된 것. TRIP이 1423초간 지속된 게 아님
- MCP 툴(`query_events`, `active_alarms`) JSON 출력 시 `prev_state_duration_s` 필드명으로 변환하여 반환하므로 LLM이 필드명만 보고 의미를 알 수 있음
- LLM 프롬프트에 이벤트 데이터를 넘길 때는 `(직전상태유지={duration}s)` 형식으로 전달
### sub_area (세부 Area: P6-1/P6-2 등)
하나의 area(P6)는 2개 Column(증류탑)으로 나뉜다. 태그 단위 sub_area를 별도 저장한다.
- 저장: `tag_metadata``attribute='sub_area'` (EAV). Single Source of Truth는 tag_metadata (OPC UA 아님).
- 값 형식: 단일 `"P6-1"` 또는 **공용 `"P6-1,P6-2"`**(여러 sub_area 공용 설비, 예: 진공펌프 vp-2127a/b).
- **매칭은 항상 토큰 비교**: `'P6-1' = ANY(string_to_array(value, ','))``value = 'P6-1'` 직접 비교 금지(공용 누락).
- `MetadataLoaderService``attribute IN ('desc','area')`만 건드림 → sub_area는 절대 덮어쓰지 않음.
- `event_history_table`에는 저장 안 함 → 이벤트 조회 시 `tag_metadata``split_part(tagname,'.',1)`로 JOIN.
- seed: `IExperionDbService.SeedSubAreaAsync(dryRun)` — area-scoped 번호 prefix + pid_equipment 공용(role ILIKE '%공용%') 검출. prefix 미적용 태그는 NULL(수동 분류). `POST /api/tags/sub-area/seed`.
- MCP: `find_tags(sub_area=...)` 또는 `active_alarms/query_events/...(area="P6-1")` — '-' 포함 시 server.py가 자동으로 sub_area 토큰 매칭.
### ⏰ Timezone — KST
DB는 UTC 저장, LLM에는 **KST(UTC+9) 변환**해서 전달.
- `server.py``_kst_str()` 함수로 UTC ISO 문자열 → KST ISO 문자열 변환
- 모든 시스템 프롬프트에 "모든 시각은 KST"라고 명시
- MCP 서버 재기동 필요 시 `uv run server.py --http`
## Critical Convention — JSON camelCase
`PropertyNamingPolicy = null` in Program.cs means C# PascalCase becomes JSON keys. **Frontend expects camelCase**. Every controller `Ok(...)` response MUST use explicit anonymous objects with camelCase keys:
```csharp
// ✅ Correct
return Ok(new { id = x.Id, tagName = x.TagName });
// ❌ Broken — JS gets undefined
return Ok(new { x.Id, x.TagName });
return Ok(myDto); // typed object
```
See `CODING_CONVENTIONS.md` for full details and checklist.
## Background Services (HostedServices in Program.cs)
- `ExperionRealtimeService` — OPC UA subscription, 500ms batch flush to DB
- `ExperionHistoryService` — periodic snapshot (60s) from realtime_table → history_table
- `ExperionOpcServerService` — exposes realtime data as OPC UA server (port 4841)
- `McpServerHostedService` — Python MCP server bridge
- `ExperionFastService` — high-frequency data capture sessions
- `ExperionFastCleanupService` — expired session cleanup
All registered as Singleton + HostedService. RealtimeService and OpcServerService share autostart flag files (`realtime_autostart.json`, `opcserver_autostart.json`) in the working directory.
## Frontend
Vanilla JS SPA. `wwwroot/index.html` + `js/app.js` + `css/style.css`. No build step. Tab-based navigation; tabs do NOT auto-fire API calls on entry (performance fix).
## OPC UA SDK Gotchas
- SDK v1.5.378.134 — `Session.Create()` is `[Obsolete]`; use `DefaultSessionFactory.CreateAsync()`
- `Subscription.Create()` / `Delete()` / `ApplyChanges()` also deprecated → async variants preferred
- Certificate validation must be attached AFTER `OpcUaConfigProvider.GetConfigAsync()` returns the config
- TCP connect timeout: wrap `SelectEndpointAsync` with a 10s `CancellationTokenSource` (OS default is 127s)
## Deploy
`sudo bash deploy.sh` — publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, sets up PKI dirs. Service runs as `www-data`.
---
## Frontend Architecture (refactored 2026-05-24)
### Directory layout
```
wwwroot/
├── index.html ← data-src shell (229 lines, -87%)
├── panes/ ← 16 pane HTML partials (lazy-loaded on tab click)
├── js/
│ ├── core.js ← esc/setGlobal/log/api/fmtTs/fmtVal/parseEnumPv
│ │ activateTab/paneInit + dt* date picker
│ ├── app.js ← placeholder (10 lines)
│ ├── cert.js conn.js … ← 15 tab-specific files
│ └── docs.js ← separately maintained
├── css/ ← style.css monolithic (Phase 4 pending)
└── lib/ ← unchanged
```
### Tab lifecycle
1. User clicks `.nav-item[data-tab="X"]`
2. `core.js:activateTab(X)` fires:
- If `pane-X` not loaded → `fetch(/panes/X.html)``innerHTML` inject
- Activate pane + nav highlight
- `paneInit[X]?.()` — each tab file registers its own init
### PaneInit registration
| Tab | Init function | File |
|-----|--------------|------|
| docs | `paneInit.docs = docsInit` | `docs.js` |
| opcsvr | `paneInit.opcsvr = srvLoad` | `opcsvr.js` |
| t2s | `paneInit.t2s = t2sInitMode` | `t2s.js` |
| fast | `paneInit.fast = function() { … }` | `fast.js` |
| pid | `paneInit.pid = async function() { … }` | `pid.js` |
| llmchat | `paneInit.llmchat = function() { … }` | `llmchat.js` |
| kbadmin | `paneInit.kbadmin = async function() { … }` | `kbadmin.js` |
| cert/conn/crawl/db/nm-dash/pb/hist/evt/write | init 불필요 | — |
### JS load order (index.html)
`core.js``app.js` → cert/conn/crawl/db/nm-dash/pb/hist/opcsvr/t2s/fast/pid/evt/llmchat/kbadmin/write → `docs.js`
### Phase 4 pending — CSS 분리
`style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조.
---
## Anchored Summary — Phase II (Auto-Write, WriteGuard, Audit, Auth)
### Done
- WO-2 (PCT monitor), WO-3 (θ auto-tune), WO-4 (slow bias), WO-5 (front position indicator), WO-6 (total reflux recovery), WO-7 (config form expansion): all **built, tested (22/22), JS OK, sign-off: windpacer 2026-05-31**.
- **Phase II auto-write (23 files)**:
- `FfOperatorAction` entity (`src/Core/Domain/Entities/FfOperatorAction.cs`)
- `ff_operator_action` DDL + `DbSet` + `OnModelCreating` in `ExperionDbContext.cs`
- `IFeedforwardWriteGuard` + `FeedforwardWriteGuard` (SP bounds, grade C, transient, NaN checks)
- `IFeedforwardAuditService` + `FeedforwardAuditService` (raw ADO.NET insert/query)
- `FeedforwardSupervisor.AutoWriteAsync` — per-stream OPC UA write after Tick (rate-limited, guarded, logged)
- `FeedforwardConfigStore``advisory_only` no longer hardcoded; reads/writes DB; `sp_node_id` column added
- `FeedforwardController` — auth (X-Kb-Token) on config/delete/write/audit; `POST /api/ff/write/{id}/{key}` manual SP write; `GET /api/ff/audit` audit query; write results merged in `MapColumn`
- `Program.cs``IFeedforwardAuditService` (Scoped), `IFeedforwardWriteGuard` (Singleton) registered
- `ff.js``ffToken()` + X-Kb-Token header; auto-write badge; per-stream write result; `spNodeId` field in stream table; `advisoryOnly` checkbox in form
- `ff.css``.ff-write-badge`, `.ff-write`, `.ff-write-err`, `.ff-wg-blocked`
- **Build 0W/0E, test 22/22, JS OK. Sign-off: windpacer 2026-05-31.**
### Key Design Decisions
- `IFeedforwardWriteGuard` is **Singleton** (stateless pure check functions) — no per-request instance needed.
- `IFeedforwardAuditService` is **Scoped** (depends on `ExperionDbContext` which is Scoped). Supervisor resolves it from `IServiceScopeFactory` scope.
- SP writes go through `IExperionOpcWriteClient` (Scoped) — each call creates + destroys an OPC UA session (acceptable for low-frequency writes).
- `sp_node_id` is stored per-stream in `ff_stream_config`. If null, auto-write is skipped for that stream (no write).
- Rate-limit: minimum `ScanSec * 2` between writes to the same stream (avoids double-writes on rapid ticks).
- Auth: `X-Kb-Token` header validated via `IKbAuthService.ValidateAsync()` — same mechanism as RAG KB admin. Token stored in `sessionStorage` by `kbadmin.js`.
- `AdvisoryResult.AutoWriteActive` is set by the Supervisor after Tick (not by the Engine). Engine remains pure computaton.
- `WriteGuardBlockedSp` / `WriteGuardReason` on `AdvisoryResult` are informative only — set when streams exist but all are blocked.
### Relevant Files
| File | Purpose |
|------|---------|
| `src/Infrastructure/Control/FeedforwardWriteGuard.cs` | New — SP safety checks |
| `src/Infrastructure/Control/FeedforwardAuditService.cs` | New — operator action log |
| `src/Core/Domain/Entities/FfOperatorAction.cs` | New — audit log entity |
| `src/Infrastructure/Control/FeedforwardSupervisor.cs` | Modified — `AutoWriteAsync` + `GetLastWrite` + IConfiguration |
| `src/Infrastructure/Control/FeedforwardConfigStore.cs` | Modified — reads/writes `advisory_only` from DB, `sp_node_id` |
| `src/Web/Controllers/FeedforwardController.cs` | Modified — auth, write, audit endpoints; write results in MapColumn |
| `src/Web/Program.cs` | Modified — register audit + write guard |
| `src/Web/wwwroot/js/ff.js` | Modified — token, write status, spNodeId, advisoryOnly form |
| `src/Web/wwwroot/css/ff.css` | Modified — auto-write/blocked styles |
| `src/Infrastructure/Database/ExperionDbContext.cs` | Modified — FfOperatorAction DbSet + DDL + OnModelCreating |
### Next Steps
- Phase II complete. Consider Phase III (operator dashboard, write confirmation dialog, trend overlay) when ordered.