feat: LLM 모델명 외부 설정 + 포인트 추가 기능

- mcp-server: 하드코딩된 모델명을 llm-model.json 기반 config.py로 외부화
- C#: AppendPointsAsync로 기존 데이터 유지하면서 포인트 추가
- C#: LlmConfigController로 LLM 모델명 조회/저장 API
- Frontend: LLM 설정 UI 카드 + 포인트 빌더에서 추가하기 버튼
This commit is contained in:
windpacer
2026-05-11 17:55:18 +09:00
parent de728f013a
commit 5cacc5dbb5
15 changed files with 544 additions and 35 deletions

View File

@@ -0,0 +1,278 @@
---
name: experion-crawler
description: ExperionCrawler .NET 8 project — OPC UA data acquisition, PostgreSQL/TimescaleDB, P&ID DXF/PDF parsing, MCP server bridge, high-frequency data capture. Use when building, debugging, or modifying this project.
license: MIT
compatibility: opencode
metadata:
domain: iiot
framework: dotnet
---
## Build / Run / Test
| Action | Command | Working Dir |
|--------|---------|-------------|
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
| Run (dev) | `dotnet run` | `src/Web/` |
| Tests | `dotnet test` | repo root |
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` |
Single project, linux-arm64 target. `src/Core/` and `src/Infrastructure/` are included via `<Compile Include>` globs.
## Architecture
```
src/
├── Core/ — Interfaces, Domain entities, DTOs, Application Services
├── Infrastructure/ — OpcUa/, Database/, Certificates/, Csv/, Mcp/
└── Web/ — Program.cs, Controllers/, wwwroot/ (SPA)
```
- Controllers: `src/Web/Controllers/ExperionControllers.cs` (single file)
- Interfaces: `src/Core/Application/Interfaces/IExperionServices.cs` (single file)
## Database — PostgreSQL (TimescaleDB)
**Docker container** `iiot-timescaledb` (image: `timescale/timescaledb-ha:pg16`):
- Port: `localhost:5432`
- Volume: `iiot-pgdata``/var/lib/postgresql/data`
- Init scripts: `/opt/iiot-platform/timescaledb/init/`
- Container IP: `172.17.0.3`
- Network: `bridge` (default)
**Docker env credentials:**
- DB: `iiot_platform`, User: `iiot_admin`, Pass: `ChangeMe2026`
**appsettings.json connection strings** (`src/Web/appsettings.json`):
```
DefaultConnection: Host=localhost;Port=5432;Database=iiot_platform; Username=postgres;Password=postgres
ExperionDbConnection: Host=localhost;Port=5432;Database=postgres; Username=postgres;Password=postgres;Trust Server Certificate=true;Include Error Detail=true
```
**Key tables:** `raw_node_map`, `node_map_master`, `realtime_table`, `history_table`, `fast_session`, `fast_record`, `tag_metadata`, `pid_equipment`, `pid_graph_status`
**Quick CLI access:** `psql -U postgres -d iiot_platform -h localhost`
## Critical Convention — JSON camelCase
`PropertyNamingPolicy = null` so C# PascalCase becomes JSON keys. Frontend expects camelCase:
```csharp
// ✅ Correct
return Ok(new { id = x.Id, tagName = x.TagName, nodeId = x.NodeId, liveValue = x.LiveValue, timestamp = x.Timestamp });
// ❌ Broken — shorthand = PascalCase keys
return Ok(new { x.Id, x.TagName });
// ❌ Broken — typed object = PascalCase keys
return Ok(myDto);
```
**Checklist for every new endpoint:**
- [ ] All anonymous object keys are camelCase (`id`, `tagName`, `nodeId` ...)
- [ ] No `new { x.SomeProp }` shorthand anywhere
- [ ] No typed record/class passed directly to `Ok()`
- [ ] C# reserved words (`class`) prefixed with `@`
## API Endpoints (all in `src/Web/Controllers/ExperionControllers.cs`)
| Controller | Route | Method | Purpose |
|---|---|---|---|
| Certificate | `/api/certificate/status` | GET | Certificate status |
| | `/api/certificate/create` | POST | Create certificate |
| Connection | `/api/connection/test` | POST | Test OPC UA connection |
| | `/api/connection/read` | POST | Read single tag |
| | `/api/connection/browse` | POST | Browse nodes |
| Crawl | `/api/crawl/nodemap` | POST | Full node map crawl → CSV |
| | `/api/crawl/start` | POST | Sync crawl with tags |
| Database | `/api/database/files` | GET | List CSV files |
| | `/api/database/import` | POST | Import CSV → DB |
| | `/api/database/records` | GET | Query records |
| PointBuilder | `/api/pointbuilder/build` | POST | Build realtime_table from node_map_master |
| | `/api/pointbuilder/preview` | POST | Preview matching points |
| | `/api/pointbuilder/apply` | POST | Apply selected points |
| | `/api/pointbuilder/points` | GET | Get realtime_table points |
| | `/api/pointbuilder/add` | POST | Add point by node_id |
| | `/api/pointbuilder/{id}` | DELETE | Delete point |
| Tags | `/api/tags/metadata/reload` | POST | Reload metadata from OPC UA |
| | `/api/tags/metadata` | GET | Get tag metadata |
| Realtime | `/api/realtime/start` | POST | Start subscription |
| | `/api/realtime/stop` | POST | Stop subscription |
| | `/api/realtime/status` | GET | Subscription status |
| History | `/api/history/tagnames` | GET | Tagnames from realtime_table |
| | `/api/history/query` | GET | Query history (tags, time range, limit) |
| OpcServer | `/api/opcserver/status` | GET | OPC UA server status |
| | `/api/opcserver/start` | POST | Start OPC UA server |
| | `/api/opcserver/stop` | POST | Stop OPC UA server |
| | `/api/opcserver/rebuild` | POST | Rebuild address space |
| NodeMap | `/api/nodemap/names` | GET | Distinct name values |
| | `/api/nodemap/stats` | GET | node_map_master stats |
| | `/api/nodemap/query` | GET | Query with filters + pagination |
| Hypertable | `/api/experion/hypertable/status` | GET | TimescaleDB hypertable status |
| | `/api/experion/hypertable/create` | POST | Create hypertable manually |
| Fast | `/api/fast/start` | POST | Start fast session |
| | `/api/fast/{id}/stop` | POST | Stop session |
| | `/api/fast/sessions` | GET | List sessions |
| | `/api/fast/{id}` | GET | Session details |
| | `/api/fast/{id}/records` | GET | Records (long format) |
| | `/api/fast/{id}/csv` | GET | Export CSV streaming |
| | `/api/fast/{id}` | DELETE | Delete session |
| | `/api/fast/{id}/pin` | POST | Pin/unpin session |
| P&ID | `/api/pid/extract` | POST | Extract from DXF/PDF (100MB limit) |
| | `/api/pid/equipment` | GET | Equipment list (paginated) |
| | `/api/pid/statistics` | GET | P&ID statistics |
| | `/api/pid/{id}/confidence` | PUT | Update confidence (0-1) |
| | `/api/pid/{id}/activate` | POST | Activate equipment |
| | `/api/pid/{id}/deactivate` | POST | Deactivate equipment |
| | `/api/pid/mappings` | GET | Tag mappings (paginated) |
| | `/api/pid/mappings` | POST | Create mapping |
| | `/api/pid/mappings/{id}` | PUT | Update mapping |
| | `/api/pid/mappings/{id}` | DELETE | Clear mapping |
| | `/api/pid/mappings/available-tags` | GET | Available tags for mapping |
| | `/api/pid/export/csv` | GET | Export CSV |
| | `/api/pid/export/excel` | GET | Export Excel (.xlsx) |
P&ID controllers are conditional — enabled by `PidControllers:Enabled` in config (default: true).
## Service Interfaces & Implementations
All interfaces in `src/Core/Application/Interfaces/IExperionServices.cs`.
| Interface | Impl | Lifetime |
|---|---|---|
| `IExperionCertificateService` | `ExperionCertificateService` | Singleton |
| `IExperionStatusCodeService` | `ExperionStatusCodeService` | Singleton |
| `IOpcUaConfigProvider` | `OpcUaConfigProvider` | Singleton |
| `IExperionOpcClient` | `ExperionOpcClient` | Scoped |
| `IExperionCsvService` | `ExperionCsvService` | Scoped |
| `IExperionDbService` | `ExperionDbService` | Scoped |
| `IExperionRealtimeService` | `ExperionRealtimeService` | Singleton + HostedService |
| `IExperionOpcServerService` | `ExperionOpcServerService` | Singleton + HostedService |
| `IExperionFastService` | `ExperionFastService` | Singleton + HostedService |
| `IMetadataLoaderService` | `MetadataLoaderService` | Singleton |
| `ITextToSqlService` | — | Scoped |
| `IMcpService` | `McpService` | Singleton |
| `IPidExtractorService` | — | — |
| `ITagMappingService` | — | — |
Singleton + HostedService pattern: same instance shared via `sp.GetRequiredService<T>()`.
## Background Services
All Singleton + HostedService, registered in `Program.cs`:
- `ExperionRealtimeService` — OPC UA subscription, 500ms batch flush
- `ExperionHistoryService` — snapshot 60s realtime_table → history_table
- `ExperionOpcServerService` — OPC UA server (port 4841)
- `McpServerHostedService` — Python MCP server process lifecycle (`uv run server.py --http`)
- `ExperionFastService` — high-frequency data capture
- `ExperionFastCleanupService` — expired session cleanup
Autostart flag files: `realtime_autostart.json`, `opcserver_autostart.json`
## Frontend
Vanilla JS SPA in `wwwroot/`. No build step. Tab navigation, no auto-fire on entry.
**API helper:** `async function api(method, path, body)` → wraps fetch, JSON in/out, returns parsed JSON
**All responses use camelCase:** `d.success`, `d.records`, `d.total`, `d.tagNames`, `d.nodeId`, `d.running`, `d.subscribedCount`, `d.sessionId`
**`wwwroot/js/app.js`** (3075 lines) — main app logic
**`wwwroot/js/pid-viewer.js`** (416 lines) — canvas-based P&ID graph viewer
## appsettings.json — Key Config
| Key | Value | Notes |
|---|---|---|
| `ConnectionStrings:DefaultConnection` | PostgreSQL `iiot_platform` | Main app DB |
| `ConnectionStrings:ExperionDbConnection` | PostgreSQL `postgres` | Utility connection |
| `OpcUaServer:Port` | 4841 | OPC UA server port |
| `OpcUaServer:EnableSecurity` | false | No security for now |
| `PidControllers:Enabled` | true | P&ID feature flag |
| `Fast:MaxConcurrentSessions` | 3 | |
| `Fast:MaxRowsPerSession` | 5000000 | |
| `Fast:FlushIntervalMs` | 2000 | |
| `McpServer:WorkingDirectory` | `../../mcp-server` | |
| `Kestrel:Endpoints:Http:Url` | `http://0.0.0.0:5000` | App port |
## OPC UA Gotchas
- SDK v1.5.378.134 — use `DefaultSessionFactory.CreateAsync()` (not obsolete `Session.Create()`)
- `Subscription.Create()`/`Delete()`/`ApplyChanges()` → async variants
- Certificate validation AFTER `OpcUaConfigProvider.GetConfigAsync()`
- Wrap `SelectEndpointAsync` with 10s CancellationTokenSource (OS default 127s)
- Tag address format: `ns=3;s=ficq-6113.pv`
## MCP Server (Python)
**Location:** `mcp-server/` — Python FastMCP server on port `5001`
**Startup:** `McpServerHostedService` launches `uv run server.py --http` in `McpServer:WorkingDirectory` (default `../../mcp-server`). Pings `localhost:5001` up to 30s to confirm ready.
**C# → Python bridge:**
- `IMcpService``McpService``McpClient` (Singleton)
- `McpClient` uses JSON-RPC over HTTP to `http://localhost:5001/mcp`
- All DTOs use `[JsonPropertyName]` for snake_case JSON keys
- `McpQueryResult { Success, Error, Data }`
**Python MCP server dependencies:** `mcp[cli]`, `fastapi`, `qdrant-client`, `sentence-transformers`, `openai`, `httpx`, `psycopg`, `ezdxf`, `paddleocr`, `pymupdf`
**Python infra stack:**
- **LLM:** vLLM serving Qwen3.6-27B-FP8 at `http://localhost:8000/v1`
- **Embeddings:** Ollama `nomic-embed-text` at `http://localhost:11434`
- **Vector DB:** Qdrant at `http://localhost:6333` (2 collections: codebase + OPC docs)
- **Task workers:** `worker/rag_worker.py` (:5002), `worker/nl2sql_worker.py` (:5003)
### MCP Tools (exposed to C# via `IMcpService`)
| Tool | C# Method | Purpose |
|---|---|---|
| `run_sql` | `RunSqlAsync(sql)` | Execute SELECT SQL |
| `query_pv_history` | `QueryPvHistoryAsync(...)` | History query by tag/time |
| `get_tag_metadata` | `GetTagMetadataAsync(query, limit)` | Tag search |
| `list_drawings` | `ListDrawingsAsync(unitNo)` | Drawing list |
| `query_with_nl` | `QueryWithNlAsync(question)` | NL → LLM → SQL → pivot |
## RAG (Retrieval-Augmented Generation)
### Python-side RAG Tools (in `server.py`)
| Tool | Purpose |
|---|---|
| `search_codebase(query, top_k)` | Qdrant search in ExperionCrawler C# code collection |
| `search_r530_docs(query, top_k)` | Qdrant search in Honeywell Experion HS R530 docs collection (266 chunks from `.htm` files) |
| `ask_iiot_llm(question, context)` | Direct Qwen3.6 Q&A with optional context |
| `rag_query(question, search_code, search_docs)` | Search + LLM synthesis in one call |
### C#-side Text-to-SQL (local, no MCP)
**Service:** `ITextToSqlService``TextToSqlService` (Scoped), registered in `Program.cs`
**Pipeline:**
1. `ParseNaturalLanguageAsync(input)` — Korean NL parser (tag names, time ranges, aggregates), up to 8 tags
2. `SqlValidator` — 7-stage validation pipeline:
- SELECT-only, dangerous keywords block, forbidden clauses, function whitelist, table allowlist, subquery depth (max 4), injection patterns
3. `ExecuteQueryAsync(sql, limit)` — parameterized Npgsql, tag existence check
4. `AnalyzeAsync(dto)` — per-tag statistics (AVG, MIN, MAX, STDDEV, FIRST, LAST)
**Allowed tables (from `SqlValidatorOptions` in Program.cs):** `history_table`, `node_map_master`, `realtime_table`, `tag_metadata`, `v_tag_summary`
### Text-to-SQL Controller (`/api/text-to-sql/*`)
| Route | Method | Source | Description |
|---|---|---|---|
| `/parse` | POST | C# local | Korean NL → SQL |
| `/query-nl` | POST | MCP | NL → LLM → SQL → pivot |
| `/tools` | GET | MCP | List MCP tools |
| `/execute-mcp` | POST | MCP | Execute SQL via MCP |
| `/query-history` | POST | MCP | History query |
| `/tags/search` | GET | MCP | Tag search |
| `/drawings` | GET | MCP | Drawing list |
| `/suggest` | GET | C# local | Autocomplete suggestions |
| `/analyze` | POST | C# local | Time-series statistics |
| `/query-history-interval` | POST | C# local | Custom-interval aggregation |
### One-time Doc Indexing
`mcp-server/index_opc_docs.py` — reads `.htm` files from `/home/windpacer/projects/Experion_opcua_documents`, chunks (600 chars, 100 overlap), embeds with Ollama, upserts to Qdrant `experion-opc-docs`.
## Deploy
`sudo bash deploy.sh` → publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, runs as `www-data`.

21
mcp-server/config.py Normal file
View File

@@ -0,0 +1,21 @@
import json
import os
_SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
_MODEL_FILE = os.path.join(_SERVER_DIR, "llm-model.json")
_DEFAULT_MODEL = "Qwen3.6-27B-FP8"
def get_vllm_model() -> str:
env = os.environ.get("VLLM_MODEL")
if env:
return env
if not os.path.isfile(_MODEL_FILE):
return _DEFAULT_MODEL
try:
with open(_MODEL_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("vllm_model", _DEFAULT_MODEL)
except Exception:
return _DEFAULT_MODEL

View File

@@ -0,0 +1,3 @@
{
"vllm_model": "Qwen3.6-27B-FP8"
}

View File

@@ -13,10 +13,11 @@ class MappingResult(BaseModel):
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
class IntelligentMapper:
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None, model_name: str = "Qwen3.6-27B-FP8"):
self.graph = graph
self.system_tags = system_tags
self.client = api_client
self.model_name = model_name
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
@@ -84,7 +85,7 @@ class IntelligentMapper:
try:
response = await self.client.chat.completions.create(
model="Qwen3.6-27B-FP8", # MCP 서버 설정 모델 사용
model=model_name,
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
ExperionCrawler Unified MCP Server
- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen3.6-27B-FP8
- RAG: Qdrant + Ollama nomic-embed-text + vLLM (llm-model.json)
- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행
- 사용처:
stdio 모드 (기본): Claude Code MCP / Roo Code MCP
@@ -24,7 +24,8 @@ 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:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
from config import get_vllm_model
VLLM_MODEL = get_vllm_model()
# Qdrant 컬렉션
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
@@ -67,7 +68,7 @@ async def _embed(text: str) -> list[float]:
return await asyncio.to_thread(_call_embed)
# ── LLM (vLLM / Qwen3.6-27B-FP8) ─────────────────────────────────────
# ── LLM (vLLM) ──────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
@@ -377,7 +378,7 @@ async def search_r530_docs(query: str, top_k: int = 5) -> str:
@mcp.tool()
def ask_iiot_llm(question: str, context: str = "") -> str:
"""Qwen3.6-27B-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문).
"""LLM에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문).
사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨
종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문.
@@ -393,7 +394,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
)
user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_msg},
@@ -406,7 +407,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
@mcp.tool()
async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""검색 → Qwen3.6-27B-FP8 답변 생성 (통합 RAG).
"""검색 → LLM 답변 생성 (통합 RAG).
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
ExperionCrawler 코드도 함께 보려면 search_code=True 추가.
@@ -612,7 +613,7 @@ async def query_with_nl(question: str) -> str:
try:
def _call_llm():
return _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": question},
@@ -699,7 +700,7 @@ async def extract_pid_tags(text: str, source_type: str) -> str:
def _call_llm():
return _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"},
@@ -805,7 +806,7 @@ async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str:
def _call_llm():
return _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"},
@@ -896,7 +897,7 @@ async def parse_pid_dxf(filepath: str) -> str:
def _call_llm():
return _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"},
@@ -1009,7 +1010,7 @@ async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
def _call_llm():
return _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"},
@@ -1111,7 +1112,7 @@ async def build_pid_graph_parallel(filepath: str) -> str:
# Mapper 설정
from openai import AsyncOpenAI
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client, model_name=VLLM_MODEL)
# 분류별 노드 분리
nodes = list(builder.G.nodes())

View File

@@ -34,7 +34,8 @@ DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://post
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
from config import get_vllm_model
VLLM_MODEL = get_vllm_model()
logging.basicConfig(
level=logging.INFO,
@@ -150,7 +151,7 @@ async def _generate_sql(natural_language: str) -> str:
)
response = await client.chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},

View File

@@ -10,7 +10,7 @@
환경 변수:
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
VLLM_MODEL: 모델명 (기본: Qwen3.6-27B-FP8)
VLLM_MODEL: 모델명 (기본: llm-model.json 참조)
"""
import argparse
@@ -22,6 +22,9 @@ import sys
import time
from typing import List
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import get_vllm_model
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
@@ -84,7 +87,7 @@ def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> Lis
from openai import OpenAI
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
model = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
model = os.environ.get("VLLM_MODEL") or get_vllm_model()
client = OpenAI(base_url=base_url, api_key="dummy")

View File

@@ -30,7 +30,8 @@ import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
from config import get_vllm_model
VLLM_MODEL = get_vllm_model()
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
@@ -173,7 +174,7 @@ def _extract_pid_tags(text: str, source_type: str) -> str:
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
@@ -202,7 +203,7 @@ def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
@@ -247,7 +248,7 @@ def _parse_pid_dxf(filepath: str) -> str:
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
@@ -273,7 +274,7 @@ def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},

View File

@@ -31,7 +31,8 @@ import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
from config import get_vllm_model
VLLM_MODEL = get_vllm_model()
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
@@ -174,7 +175,7 @@ def _extract_pid_tags(text: str, source_type: str) -> str:
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
@@ -203,7 +204,7 @@ def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
@@ -248,7 +249,7 @@ def _parse_pid_dxf(filepath: str) -> str:
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
@@ -274,7 +275,7 @@ def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},

View File

@@ -32,7 +32,8 @@ import httpx
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
from config import get_vllm_model
VLLM_MODEL = get_vllm_model()
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
@@ -105,7 +106,7 @@ async def _ask_llm(question: str, context: str = "") -> str:
prompt = question
response = await client.chat.completions.create(
model="Qwen3.6-27B-FP8",
model=VLLM_MODEL,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt},

View File

@@ -74,6 +74,7 @@ public interface IExperionDbService
Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups);
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
Task<int> AppendPointsAsync(IEnumerable<string> nodeIds);
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
Task<bool> DeleteRealtimePointAsync(int id);

View File

@@ -704,6 +704,35 @@ public class ExperionDbService : IExperionDbService
return points.Count;
}
public async Task<int> AppendPointsAsync(IEnumerable<string> nodeIds)
{
var nodeIdsList = nodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (nodeIdsList.Count == 0) return 0;
var existingNodeIds = await _ctx.RealtimePoints
.Where(p => nodeIdsList.Contains(p.NodeId))
.Select(p => p.NodeId)
.ToListAsync();
var newPoints = nodeIdsList
.Where(n => !existingNodeIds.Contains(n))
.Select(nodeId => new RealtimePoint
{
TagName = ExtractTagName(nodeId),
NodeId = nodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
if (newPoints.Count == 0) return 0;
await _ctx.RealtimePoints.AddRangeAsync(newPoints);
await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 추가: {Count}건 (중복 제외)", newPoints.Count);
return newPoints.Count;
}
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
{
try

View File

@@ -380,6 +380,29 @@ public class ExperionPointBuilderController : ControllerBase
return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 적용 완료 (메타데이터: {metaCount}개)" });
}
/// <summary>선택된 포인트만 realtime_table에 추가 (기존 데이터 유지, 중복 제외)</summary>
[HttpPost("append")]
public async Task<IActionResult> Append([FromBody] PointBuilderApplyDto dto)
{
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
var count = await _dbSvc.AppendPointsAsync(dto.SelectedNodeIds);
var metaCount = 0;
try
{
var cfg = GetServerConfig();
metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 추가는 완료됨)");
}
return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 추가 완료 (메타데이터: {metaCount}개)" });
}
/// <summary>realtime_table 전체 조회</summary>
[HttpGet("points")]
public async Task<IActionResult> GetPoints()
@@ -1262,3 +1285,80 @@ public class EventHistoryController : ControllerBase
}
}
}
// ── LLM 설정 ──────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/llm")]
public class LlmConfigController : ControllerBase
{
private readonly IConfiguration _config;
private readonly ILogger<LlmConfigController> _logger;
public LlmConfigController(
IConfiguration config,
ILogger<LlmConfigController> logger)
{
_config = config;
_logger = logger;
}
string LlmModelPath
{
get
{
var mcpDir = _config["McpServer:WorkingDirectory"] ?? "../../mcp-server";
if (!Path.IsPathRooted(mcpDir))
mcpDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), mcpDir));
return Path.Combine(mcpDir, "llm-model.json");
}
}
[HttpGet("config")]
public IActionResult GetConfig()
{
try
{
var path = LlmModelPath;
if (!System.IO.File.Exists(path))
{
return Ok(new { success = false, error = "llm-model.json not found", vllmModel = "Qwen3.6-27B-FP8" });
}
var json = System.IO.File.ReadAllText(path);
var doc = System.Text.Json.JsonDocument.Parse(json);
var model = doc.RootElement.TryGetProperty("vllm_model", out var v)
? v.GetString() ?? "Qwen3.6-27B-FP8"
: "Qwen3.6-27B-FP8";
return Ok(new { success = true, vllmModel = model });
}
catch (Exception ex)
{
_logger.LogError(ex, "[LlmConfigController] 설정 조회 실패");
return Ok(new { success = false, error = ex.Message, vllmModel = (string?)null });
}
}
[HttpPost("config")]
public IActionResult SetConfig([FromBody] System.Text.Json.JsonElement body)
{
try
{
var path = LlmModelPath;
var model = body.TryGetProperty("vllm_model", out var v) ? v.GetString() : null;
if (string.IsNullOrEmpty(model))
{
return Ok(new { success = false, error = "vllm_model is required" });
}
var json = $"{{\"vllm_model\": \"{model}\"}}";
System.IO.File.WriteAllText(path, json);
_logger.LogInformation("[LlmConfigController] 모델 변경: {Model}", model);
return Ok(new { success = true, vllmModel = model });
}
catch (Exception ex)
{
_logger.LogError(ex, "[LlmConfigController] 설정 저장 실패");
return Ok(new { success = false, error = ex.Message });
}
}
}

View File

@@ -176,6 +176,16 @@
<div id="tag-box" class="tag-box hidden"></div>
</div>
<div class="card">
<div class="card-cap">LLM 설정</div>
<div class="row-inp">
<input id="llm-model" class="inp flex1" placeholder="모델명" />
<button class="btn-b" onclick="llmLoadConfig()">🔄 불러오기</button>
<button class="btn-a" onclick="llmSaveConfig()">💾 저장</button>
</div>
<div id="llm-status" class="kv-box" style="margin-top:8px"></div>
</div>
<div id="conn-log" class="logbox hidden"></div>
<div id="browse-wrap" class="bwrap hidden"></div>
</section>
@@ -624,6 +634,7 @@
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
<button class="btn-a" onclick="pbAppendSelected()"> 기존 데이터에 추가하기</button>
</div>
</div>
</div>

View File

@@ -156,18 +156,55 @@ async function connRead() {
}
}
async function llmLoadConfig() {
const statusEl = document.getElementById('llm-status');
const input = document.getElementById('llm-model');
statusEl.innerHTML = '<span class="placeholder">불러오는 중...</span>';
try {
const d = await api('GET', '/api/llm/config');
if (d.success) {
input.value = d.vllmModel || '';
statusEl.innerHTML = `<span style="color:var(--color-success)">✓ 현재 모델: ${esc(d.vllmModel)}</span>`;
} else {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(d.error || '조회 실패')}</span>`;
}
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(e.message)}</span>`;
}
}
async function llmSaveConfig() {
const statusEl = document.getElementById('llm-status');
const model = document.getElementById('llm-model').value.trim();
if (!model) {
statusEl.innerHTML = '<span style="color:var(--color-danger)">모델명을 입력하세요</span>';
return;
}
statusEl.innerHTML = '<span class="placeholder">저장 중...</span>';
try {
const d = await api('POST', '/api/llm/config', { vllm_model: model });
if (d.success) {
statusEl.innerHTML = `<span style="color:var(--color-success)">✓ 모델 변경 완료: ${esc(d.vllmModel)} (다음 LLM 요청 시 반영)</span>`;
} else {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(d.error || '저장 실패')}</span>`;
}
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(e.message)}</span>`;
}
}
async function connBrowse() {
const wrap = document.getElementById('browse-wrap');
const logEl = document.getElementById('conn-log');
setGlobal('busy', '노드 탐색');
// 로그 초기화
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">[진행] 노드 탐색 시작...</div>';
try {
logEl.innerHTML += '<div class="ll inf">[진행] 서버: ' + getServerCfg('x').serverHostName + ':' + getServerCfg('x').port + '</div>';
const d = await api('POST', '/api/connection/browse', {
serverConfig: getServerCfg('x'), startNodeId: null
});
@@ -780,6 +817,26 @@ async function pbApplySelected() {
}
}
async function pbAppendSelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '추가할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 추가 중`);
try {
const d = await api('POST', '/api/pointbuilder/append', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 추가 완료` : '추가 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '추가 오류');
}
}
async function pbRefresh() {
try {
const d = await api('GET', '/api/pointbuilder/points');