1078 lines
41 KiB
Markdown
1078 lines
41 KiB
Markdown
# P&ID AX Plan 2 — 로컬 LLM 기반 재설계
|
|
|
|
> **작성일**: 2026-04-30
|
|
> **목적**: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현
|
|
> **기준**: 기존 구현 현황 반영 (이미 완료된 항목 제외)
|
|
|
|
---
|
|
|
|
## 현재 구현 현황
|
|
|
|
| 파일 | 상태 | 비고 |
|
|
|------|------|------|
|
|
| `PidEquipment.cs` | ✅ 완료 | `int? ExperionTagId`, `double Confidence`, `[Table("pid_equipment")]` |
|
|
| `PidAuditLog.cs` | ✅ 완료 | `Source` 필드(UserId 대체) |
|
|
| `ExperionDbContext.cs` | ✅ 완료 | `DbSet<PidEquipment>`, `DbSet<PidAuditLog>` 등록 완료 |
|
|
| `TagMappingService.cs` | ✅ 완료 | 단일 DbContext JOIN, `ClearMappingAsync` 명칭 정리 완료 |
|
|
| `Program.cs` | ✅ 완료 | `IPidExtractorService`, `ITagMappingService` 등록 완료 |
|
|
| DTOs | ✅ 완료 | `PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos` |
|
|
| `PidExtractorService.cs` | ⚠️ 미완 | Anthropic 의존성 남음, DXF/PDF 파싱 미구현, `AnalyzeWithClaudeAsync` 빈 구현 |
|
|
| `PidController` | ❌ 없음 | `ExperionControllers.cs`에 미추가 |
|
|
| `McpClient` 신규 툴 | ❌ 없음 | `extract_pid_tags`, `match_pid_tags` 미추가 |
|
|
| Python MCP 서버 | ❌ 없음 | 신규 툴 2개 미추가 |
|
|
| Frontend (P&ID 탭) | ❌ 없음 | `index.html`, `app.js`, `style.css` 미추가 |
|
|
|
|
---
|
|
|
|
## 전체 아키텍처
|
|
|
|
```
|
|
파일 업로드 (DXF / PDF텍스트)
|
|
│
|
|
▼
|
|
PidExtractorService (C#)
|
|
├── .dxf ──► ExtractDxfText() [netDxf] ──► 텍스트
|
|
└── .pdf ──► ExtractPdfText() [PdfPig] ──► 텍스트
|
|
│
|
|
▼
|
|
McpClient.ExtractPidTagsAsync(text)
|
|
│
|
|
localhost:5001
|
|
│
|
|
Python MCP extract_pid_tags()
|
|
│
|
|
vLLM localhost:8000
|
|
Qwen3-Coder-Next-FP8
|
|
│
|
|
JSON 태그 목록 반환
|
|
│
|
|
▼
|
|
McpClient.MatchPidTagsAsync(pidTags, experionTags)
|
|
│
|
|
Python MCP match_pid_tags()
|
|
│
|
|
FT-101 → FT-101.PV 매핑 제안
|
|
│
|
|
▼
|
|
ExperionDbContext.PidEquipment 저장
|
|
|
|
[ Vision (스캔본/이미지) ] ─── 🔮 미래 계획 (Vision LLM 도입 시 구현) ───────────────
|
|
```
|
|
|
|
---
|
|
|
|
## 구현 단계
|
|
|
|
---
|
|
|
|
### 단계 1: NuGet 패키지 추가
|
|
|
|
**파일**: `src/Web/ExperionCrawler.csproj`
|
|
|
|
```xml
|
|
<PackageReference Include="netDxf" Version="3.2.1" />
|
|
<PackageReference Include="PdfPig" Version="0.1.9" />
|
|
```
|
|
|
|
> `CsvHelper`(v33.0.1)는 이미 설치되어 있음 — CSV 내보내기 재사용.
|
|
> `ClosedXML`은 Excel 내보내기 필요 시 추가. 현재 단계는 생략.
|
|
|
|
---
|
|
|
|
### 단계 2: McpClient 신규 메서드 추가
|
|
|
|
**파일**: `src/Infrastructure/Mcp/McpClient.cs`
|
|
|
|
기존 `QueryWithNlAsync` 아래에 추가:
|
|
|
|
```csharp
|
|
public Task<string> ExtractPidTagsAsync(string text, string sourceType) =>
|
|
CallToolAsync("extract_pid_tags", new Dictionary<string, object>
|
|
{
|
|
["text"] = text,
|
|
["source_type"] = sourceType // "dxf" | "pdf"
|
|
});
|
|
|
|
public Task<string> MatchPidTagsAsync(IEnumerable<string> pidTags, IEnumerable<string> experionTags) =>
|
|
CallToolAsync("match_pid_tags", new Dictionary<string, object>
|
|
{
|
|
["pid_tags"] = pidTags.ToList(),
|
|
["experion_tags"] = experionTags.ToList()
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 3: Python MCP 서버 툴 추가
|
|
|
|
**파일**: Python MCP 서버 (localhost:5001)
|
|
|
|
기존 툴 목록 끝에 두 툴 추가:
|
|
|
|
```python
|
|
@mcp.tool()
|
|
def extract_pid_tags(text: str, source_type: str = "dxf") -> str:
|
|
"""DXF/PDF 텍스트에서 P&ID 계기/장비 태그 추출. JSON 배열 반환."""
|
|
prompt = f"""다음 {source_type.upper()} 텍스트에서 P&ID 계기/장비 태그 정보를 추출하세요.
|
|
반드시 순수 JSON 배열만 응답하세요 (마크다운, 설명 금지):
|
|
[{{"tagNo":"FT-101","equipmentName":"Flow Transmitter","instrumentType":"FT","lineNumber":"6P-1001","confidence":0.95}}]
|
|
|
|
추출 규칙:
|
|
- tagNo: 계기 태그 번호 (예: FT-101, PT-2003, LT-100A)
|
|
- instrumentType: 태그 번호 앞 영문자 부분 (FT, PT, LT, CV, E, V, P 등)
|
|
- confidence: 텍스트에서 명확히 식별된 경우 0.9↑, 불확실한 경우 0.5↓
|
|
- 태그가 없는 텍스트(범례, 제목, 주석)는 무시
|
|
|
|
텍스트:
|
|
{text[:12000]}"""
|
|
|
|
response = llm_client.chat.completions.create(
|
|
model="Qwen/Qwen3-Coder-Next-FP8",
|
|
messages=[{"role": "user", "content": prompt}],
|
|
temperature=0.1,
|
|
max_tokens=4096
|
|
)
|
|
raw = response.choices[0].message.content.strip()
|
|
# 코드펜스 제거
|
|
import re
|
|
match = re.search(r'\[.*\]', raw, re.DOTALL)
|
|
return match.group(0) if match else "[]"
|
|
|
|
|
|
@mcp.tool()
|
|
def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str:
|
|
"""P&ID 태그명 → Experion 태그명 자동 매핑 제안. JSON 배열 반환."""
|
|
prompt = f"""P&ID 태그 목록을 Experion 태그 목록과 매핑하세요.
|
|
반드시 순수 JSON 배열만 응답하세요:
|
|
[{{"pidTag":"FT-101","experionTag":"FT-101.PV","confidence":0.92}}]
|
|
|
|
규칙:
|
|
- 동일하거나 유사한 태그번호를 매핑 (대소문자 무시, 하이픈/언더스코어 무시)
|
|
- FT-101 → FT-101.PV 처럼 접미사(.PV .SV 등)가 붙는 패턴 우선
|
|
- 매핑 불가능하면 해당 항목 제외
|
|
- confidence: 완전 일치 1.0, 유사 매칭 0.7~0.9
|
|
|
|
P&ID 태그: {pid_tags}
|
|
Experion 태그 (샘플): {experion_tags[:300]}"""
|
|
|
|
response = llm_client.chat.completions.create(
|
|
model="Qwen/Qwen3-Coder-Next-FP8",
|
|
messages=[{"role": "user", "content": prompt}],
|
|
temperature=0.1,
|
|
max_tokens=2048
|
|
)
|
|
raw = response.choices[0].message.content.strip()
|
|
import re
|
|
match = re.search(r'\[.*\]', raw, re.DOTALL)
|
|
return match.group(0) if match else "[]"
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 4: PidExtractorService 재작성
|
|
|
|
**파일**: `src/Core/Application/Services/PidExtractorService.cs`
|
|
|
|
전체 교체 — Anthropic 의존성 제거, McpClient 주입, 실제 DXF/PDF 파싱 구현:
|
|
|
|
```csharp
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ExperionCrawler.Core.Application.DTOs;
|
|
using ExperionCrawler.Core.Application.Interfaces;
|
|
using ExperionCrawler.Core.Domain.Entities;
|
|
using ExperionCrawler.Infrastructure.Database;
|
|
using ExperionCrawler.Infrastructure.Mcp;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using netDxf;
|
|
using UglyToad.PdfPig;
|
|
|
|
namespace ExperionCrawler.Core.Application.Services;
|
|
|
|
public class PidExtractorService : IPidExtractorService
|
|
{
|
|
private readonly McpClient _mcp;
|
|
private readonly ExperionDbContext _db;
|
|
private readonly ILogger<PidExtractorService> _logger;
|
|
|
|
public PidExtractorService(McpClient mcp, ExperionDbContext db,
|
|
ILogger<PidExtractorService> logger)
|
|
{
|
|
_mcp = mcp;
|
|
_db = db;
|
|
_logger = logger;
|
|
}
|
|
|
|
// ── 파일 진입점 ──────────────────────────────────────────────────────────
|
|
|
|
public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath)
|
|
{
|
|
await using var stream = File.OpenRead(filePath);
|
|
return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath));
|
|
}
|
|
|
|
public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName)
|
|
{
|
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
|
|
string text = ext switch
|
|
{
|
|
".dxf" => ExtractDxfText(stream),
|
|
".pdf" => ExtractPdfText(stream),
|
|
_ => throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)")
|
|
};
|
|
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return new PidExtractionResult(0, 0, 0);
|
|
|
|
// MCP → vLLM 태그 추출
|
|
var sourceType = ext.TrimStart('.');
|
|
var json = await _mcp.ExtractPidTagsAsync(text, sourceType);
|
|
var extractedItems = ParseJson(json);
|
|
|
|
if (extractedItems.Count == 0)
|
|
{
|
|
_logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName);
|
|
return new PidExtractionResult(0, 0, 0);
|
|
}
|
|
|
|
// MCP → vLLM 태그 매핑 제안
|
|
var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList();
|
|
var experionTagNames = await _db.RealtimePoints.Select(r => r.TagName).ToListAsync();
|
|
var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
|
|
var mappings = ParseMappingJson(mappingJson);
|
|
|
|
// DB 저장
|
|
var dbItems = new List<PidEquipment>();
|
|
foreach (var item in extractedItems)
|
|
{
|
|
mappings.TryGetValue(item.TagNo, out var matched);
|
|
var experionTag = matched != null
|
|
? await _db.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
|
|
: await FindFallbackTagAsync(item.TagNo);
|
|
|
|
dbItems.Add(new PidEquipment
|
|
{
|
|
TagNo = item.TagNo,
|
|
EquipmentName = item.EquipmentName,
|
|
InstrumentType = item.InstrumentType,
|
|
LineNumber = item.LineNumber,
|
|
PidDrawingNo = item.PidDrawingNo,
|
|
Confidence = item.Confidence,
|
|
ExperionTagId = experionTag?.Id,
|
|
ExtractedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
|
|
await _db.PidEquipment.AddRangeAsync(dbItems);
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("P&ID 추출 완료: {Total}건 저장 (파일: {FileName})", dbItems.Count, fileName);
|
|
|
|
return new PidExtractionResult(
|
|
TotalCount: dbItems.Count,
|
|
ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
|
|
LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5));
|
|
}
|
|
|
|
// ── DXF 텍스트 추출 ──────────────────────────────────────────────────────
|
|
|
|
private string ExtractDxfText(Stream stream)
|
|
{
|
|
// netDxf는 파일 경로를 요구하므로 임시 파일 사용
|
|
var tmp = Path.GetTempFileName() + ".dxf";
|
|
try
|
|
{
|
|
using (var fs = File.Create(tmp))
|
|
stream.CopyTo(fs);
|
|
|
|
var doc = DxfDocument.Load(tmp);
|
|
var sb = new StringBuilder();
|
|
|
|
foreach (var txt in doc.Entities.Texts)
|
|
sb.AppendLine(txt.Value);
|
|
foreach (var mtxt in doc.Entities.MTexts)
|
|
sb.AppendLine(mtxt.PlainText());
|
|
foreach (var blk in doc.Blocks)
|
|
foreach (var attr in blk.AttributeDefinitions.Values)
|
|
sb.AppendLine(attr.Value);
|
|
|
|
return sb.ToString();
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(tmp)) File.Delete(tmp);
|
|
}
|
|
}
|
|
|
|
// ── PDF 텍스트 추출 ──────────────────────────────────────────────────────
|
|
|
|
private string ExtractPdfText(Stream stream)
|
|
{
|
|
using var pdf = PdfDocument.Open(stream);
|
|
var sb = new StringBuilder();
|
|
foreach (var page in pdf.GetPages())
|
|
sb.AppendLine(page.Text);
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ── JSON 파싱 ─────────────────────────────────────────────────────────────
|
|
|
|
private List<ExtractedItem> ParseJson(string json)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<List<ExtractedItem>>(json,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning("P&ID JSON 파싱 실패: {Msg} / raw: {Raw}", ex.Message, json[..Math.Min(200, json.Length)]);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private Dictionary<string, string> ParseMappingJson(string json)
|
|
{
|
|
try
|
|
{
|
|
var list = JsonSerializer.Deserialize<List<MappingItem>>(json,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
|
|
return list
|
|
.Where(m => m.Confidence >= 0.7 && !string.IsNullOrEmpty(m.ExperionTag))
|
|
.ToDictionary(m => m.PidTag, m => m.ExperionTag!);
|
|
}
|
|
catch { return []; }
|
|
}
|
|
|
|
// ── Fallback 매칭 (LLM 불확실 시) ───────────────────────────────────────
|
|
|
|
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
|
|
{
|
|
var normalized = tagNo.Split('.')[0];
|
|
return await _db.RealtimePoints
|
|
.FirstOrDefaultAsync(t => t.TagName == normalized
|
|
|| t.TagName.StartsWith(normalized + "."));
|
|
}
|
|
|
|
// ── CRUD / 통계 (기존 유지) ───────────────────────────────────────────────
|
|
|
|
public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
|
|
string? tagNo, int page, int pageSize)
|
|
{
|
|
var q = _db.PidEquipment.AsQueryable();
|
|
if (!string.IsNullOrEmpty(tagNo))
|
|
q = q.Where(e => e.TagNo.Contains(tagNo));
|
|
var total = await q.CountAsync();
|
|
var items = await q.OrderByDescending(e => e.ExtractedAt)
|
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
return (total, items);
|
|
}
|
|
|
|
public async Task<PidEquipment?> GetByIdAsync(long id)
|
|
=> await _db.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);
|
|
|
|
public async Task UpdateConfidenceAsync(long id, double confidence)
|
|
{
|
|
var e = await _db.PidEquipment.FindAsync(id);
|
|
if (e == null) return;
|
|
e.Confidence = confidence; e.UpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task ActivateAsync(long id)
|
|
{
|
|
var e = await _db.PidEquipment.FindAsync(id);
|
|
if (e == null) return;
|
|
e.IsActive = true; e.UpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeactivateAsync(long id)
|
|
{
|
|
var e = await _db.PidEquipment.FindAsync(id);
|
|
if (e == null) return;
|
|
e.IsActive = false; e.UpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
public Task<int> GetTotalCountAsync() => _db.PidEquipment.CountAsync();
|
|
public Task<int> GetConfidenceItemsCountAsync() => _db.PidEquipment.CountAsync(e => e.Confidence >= 0.7);
|
|
public Task<int> GetLowConfidenceItemsCountAsync() => _db.PidEquipment.CountAsync(e => e.Confidence < 0.5);
|
|
public Task<int> GetDrawingCountAsync() => _db.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync();
|
|
|
|
public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
|
|
{
|
|
var items = await _db.PidEquipment.ToListAsync();
|
|
return new Dictionary<string, int>
|
|
{
|
|
["High (>=0.7)"] = items.Count(i => i.Confidence >= 0.7),
|
|
["Medium (0.5-0.7)"] = items.Count(i => i.Confidence >= 0.5 && i.Confidence < 0.7),
|
|
["Low (<0.5)"] = items.Count(i => i.Confidence < 0.5)
|
|
};
|
|
}
|
|
|
|
public async Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId");
|
|
foreach (var i in items)
|
|
sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.LineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId}");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string Csv(string? v)
|
|
{
|
|
if (string.IsNullOrEmpty(v)) return "";
|
|
return (v.Contains(',') || v.Contains('"') || v.Contains('\n'))
|
|
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
|
}
|
|
}
|
|
|
|
// ── 내부 파싱용 모델 ──────────────────────────────────────────────────────────
|
|
file class ExtractedItem
|
|
{
|
|
public string TagNo { get; set; } = "";
|
|
public string? EquipmentName { get; set; }
|
|
public string? InstrumentType { get; set; }
|
|
public string? LineNumber { get; set; }
|
|
public string? PidDrawingNo { get; set; }
|
|
public double Confidence { get; set; } = 0.5;
|
|
}
|
|
|
|
file class MappingItem
|
|
{
|
|
public string PidTag { get; set; } = "";
|
|
public string? ExperionTag { get; set; }
|
|
public double Confidence { get; set; }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 5: PidController 추가
|
|
|
|
**파일**: `src/Web/Controllers/ExperionControllers.cs` 끝에 추가
|
|
|
|
```csharp
|
|
// ── P&ID Controller ──────────────────────────────────────────────────────────
|
|
[ApiController]
|
|
[Route("api/pid")]
|
|
public class ExperionPidController : ControllerBase
|
|
{
|
|
private readonly IPidExtractorService _extractor;
|
|
private readonly ITagMappingService _mapping;
|
|
|
|
public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
|
|
{
|
|
_extractor = extractor;
|
|
_mapping = mapping;
|
|
}
|
|
|
|
[HttpPost("extract")]
|
|
[RequestSizeLimit(100 * 1024 * 1024)]
|
|
public async Task<IActionResult> Extract(IFormFile file)
|
|
{
|
|
if (file == null || file.Length == 0)
|
|
return BadRequest(new { error = "파일 없음" });
|
|
|
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
|
if (ext != ".dxf" && ext != ".pdf")
|
|
return BadRequest(new { error = "지원 형식: .dxf .pdf" });
|
|
|
|
using var stream = file.OpenReadStream();
|
|
var result = await _extractor.ExtractFromStreamAsync(stream, file.FileName);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpGet("equipment")]
|
|
public async Task<IActionResult> GetEquipment(
|
|
[FromQuery] string? tagNo, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
|
{
|
|
var (total, items) = await _extractor.GetEquipmentAsync(tagNo, page, pageSize);
|
|
return Ok(new { total, page, pageSize, items });
|
|
}
|
|
|
|
[HttpGet("statistics")]
|
|
public async Task<IActionResult> GetStatistics()
|
|
{
|
|
return Ok(new
|
|
{
|
|
total = await _extractor.GetTotalCountAsync(),
|
|
highConfidence = await _extractor.GetConfidenceItemsCountAsync(),
|
|
lowConfidence = await _extractor.GetLowConfidenceItemsCountAsync(),
|
|
drawingCount = await _extractor.GetDrawingCountAsync(),
|
|
mapped = await _mapping.GetMappedCountAsync(),
|
|
unmapped = await _mapping.GetUnmappedCountAsync(),
|
|
distribution = await _extractor.GetConfidenceDistributionAsync()
|
|
});
|
|
}
|
|
|
|
[HttpPut("{id:long}/confidence")]
|
|
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence)
|
|
{
|
|
if (confidence < 0 || confidence > 1)
|
|
return BadRequest(new { error = "신뢰도는 0~1 범위" });
|
|
await _extractor.UpdateConfidenceAsync(id, confidence);
|
|
return Ok(new { message = "업데이트 완료" });
|
|
}
|
|
|
|
[HttpPost("{id:long}/activate")]
|
|
public async Task<IActionResult> Activate(long id)
|
|
{
|
|
await _extractor.ActivateAsync(id);
|
|
return Ok(new { message = "활성화 완료" });
|
|
}
|
|
|
|
[HttpPost("{id:long}/deactivate")]
|
|
public async Task<IActionResult> Deactivate(long id)
|
|
{
|
|
await _extractor.DeactivateAsync(id);
|
|
return Ok(new { message = "비활성화 완료" });
|
|
}
|
|
|
|
// ── 매핑 API ──
|
|
[HttpGet("mappings")]
|
|
public async Task<IActionResult> GetMappings([FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
|
{
|
|
var (total, items) = await _mapping.GetMappingsAsync(page, pageSize);
|
|
return Ok(new { total, page, pageSize, items });
|
|
}
|
|
|
|
[HttpPost("mappings")]
|
|
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req)
|
|
{
|
|
var result = await _mapping.CreateMappingAsync(req);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpPut("mappings/{id:long}")]
|
|
public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req)
|
|
{
|
|
await _mapping.UpdateMappingAsync(id, req);
|
|
return Ok(new { message = "매핑 업데이트 완료" });
|
|
}
|
|
|
|
[HttpDelete("mappings/{id:long}")]
|
|
public async Task<IActionResult> ClearMapping(long id)
|
|
{
|
|
await _mapping.ClearMappingAsync(id);
|
|
return Ok(new { message = "매핑 해제 완료" });
|
|
}
|
|
|
|
[HttpGet("mappings/available-tags")]
|
|
public async Task<IActionResult> GetAvailableTags()
|
|
{
|
|
var tags = await _mapping.GetAvailableTagsAsync();
|
|
return Ok(new { tags });
|
|
}
|
|
|
|
[HttpGet("export/csv")]
|
|
public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo)
|
|
{
|
|
var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
|
|
var csv = await _extractor.ExportToCsvAsync(items);
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
|
|
return File(bytes, "text/csv", $"pid-equipment-{DateTime.Now:yyyyMMdd}.csv");
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 6: Frontend — index.html (P&ID 탭 추가)
|
|
|
|
**파일**: `src/Web/wwwroot/index.html`
|
|
|
|
사이드바 nav-item 추가 (기존 마지막 탭 `data-tab="fast"` 뒤에):
|
|
|
|
```html
|
|
<li class="nav-item" data-tab="pid">
|
|
<span class="nav-num">11</span>
|
|
<span class="nav-lbl">P&ID AX</span>
|
|
</li>
|
|
```
|
|
|
|
pane 추가 (`#pane-fast` 섹션 뒤에):
|
|
|
|
```html
|
|
<section class="pane" id="pane-pid">
|
|
<header class="pane-hdr">
|
|
<div class="pane-title">P&ID AX</div>
|
|
<div class="pane-tag">DXF / PDF → LLM → TAG</div>
|
|
</header>
|
|
|
|
<!-- 추출 섹션 -->
|
|
<div class="pid-section">
|
|
<div class="pid-row">
|
|
<label class="pid-lbl">도면 파일 (.dxf / .pdf 텍스트)</label>
|
|
<input type="file" id="pid-file" accept=".dxf,.pdf" class="pid-file-input">
|
|
<button class="btn" onclick="pidExtract()">▶ 추출 시작</button>
|
|
</div>
|
|
<div id="pid-log" class="log hidden"></div>
|
|
</div>
|
|
|
|
<!-- 결과 카드 -->
|
|
<div class="pid-stat-row" id="pid-stat-row" style="display:none">
|
|
<div class="srv-status-card">
|
|
<div class="kv"><span class="kk">총 추출</span><span class="kv2" id="pid-s-total">0</span></div>
|
|
<div class="kv"><span class="kk">신뢰도 ≥70%</span><span class="kv2 ok" id="pid-s-high">0</span></div>
|
|
<div class="kv"><span class="kk">신뢰도 <50%</span><span class="kv2 err" id="pid-s-low">0</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 장비 목록 -->
|
|
<div class="pid-section">
|
|
<div class="pid-row">
|
|
<input type="text" id="pid-search" class="ipt" placeholder="태그 번호 검색..." style="width:200px">
|
|
<button class="btn" onclick="pidLoadEquipment(1)">▼ 조회</button>
|
|
<button class="btn btn-sub" onclick="pidExportCsv()">CSV 내보내기</button>
|
|
<span id="pid-eq-total" class="pid-count"></span>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<table class="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>태그번호</th><th>장비명</th><th>계기유형</th>
|
|
<th>라인번호</th><th>도면번호</th><th>신뢰도</th>
|
|
<th>상태</th><th>Experion 태그</th><th>작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="pid-eq-body"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="pid-eq-pg" class="pg"></div>
|
|
</div>
|
|
|
|
<!-- 매핑 관리 -->
|
|
<div class="pid-section">
|
|
<div class="pid-row">
|
|
<span class="pid-lbl">태그 매핑</span>
|
|
<button class="btn" onclick="pidLoadMappings(1)">▼ 매핑 목록</button>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<table class="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>태그번호</th><th>장비명</th><th>신뢰도</th>
|
|
<th>Experion 태그</th><th>작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="pid-map-body"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="pid-map-pg" class="pg"></div>
|
|
</div>
|
|
|
|
<!-- 매핑 모달 -->
|
|
<div id="pid-modal" class="modal-overlay" style="display:none" onclick="pidCloseModal()">
|
|
<div class="modal-box" onclick="event.stopPropagation()">
|
|
<div class="modal-hdr">태그 매핑</div>
|
|
<input type="hidden" id="pid-modal-id">
|
|
<div class="kv">
|
|
<span class="kk">P&ID 태그</span>
|
|
<span class="kv2" id="pid-modal-tag"></span>
|
|
</div>
|
|
<div class="kv">
|
|
<span class="kk">Experion 태그</span>
|
|
<select id="pid-modal-select" class="ipt" style="width:260px"></select>
|
|
</div>
|
|
<div class="modal-ftr">
|
|
<button class="btn" onclick="pidConfirmMapping()">매핑</button>
|
|
<button class="btn btn-sub" onclick="pidCloseModal()">취소</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 7: Frontend — app.js (P&ID 함수 추가)
|
|
|
|
**파일**: `src/Web/wwwroot/js/app.js`
|
|
|
|
기존 탭 핸들러(`app.js:15-17`)에 P&ID 탭 추가:
|
|
|
|
```javascript
|
|
if (tab === 'pid') { /* 탭 진입 시 API 호출 없음 */ }
|
|
```
|
|
|
|
파일 끝에 P&ID 함수 추가:
|
|
|
|
```javascript
|
|
/* ─────────────────────────────────────────────────────────────
|
|
11 P&ID AX
|
|
───────────────────────────────────────────────────────────── */
|
|
let _pidEqPage = 1;
|
|
let _pidMapPage = 1;
|
|
const PID_PAGE = 50;
|
|
|
|
async function pidExtract() {
|
|
const fileInput = document.getElementById('pid-file');
|
|
const file = fileInput.files[0];
|
|
if (!file) { log('pid-log', [{ c: 'err', t: '파일을 선택하세요.' }]); return; }
|
|
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
if (ext !== 'dxf' && ext !== 'pdf') {
|
|
log('pid-log', [{ c: 'err', t: '지원 형식: .dxf .pdf (스캔본 이미지는 미지원)' }]);
|
|
return;
|
|
}
|
|
|
|
log('pid-log', [{ c: 'inf', t: `▶ 추출 중... (${file.name})` }]);
|
|
setGlobal('busy', '추출 중');
|
|
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const res = await fetch('/api/pid/extract', { method: 'POST', body: fd });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
const d = await res.json();
|
|
|
|
document.getElementById('pid-s-total').textContent = d.totalCount;
|
|
document.getElementById('pid-s-high').textContent = d.confidenceItems;
|
|
document.getElementById('pid-s-low').textContent = d.lowConfidenceItems;
|
|
document.getElementById('pid-stat-row').style.display = '';
|
|
|
|
log('pid-log', [
|
|
{ c: 'ok', t: `✅ 추출 완료: ${d.totalCount}건` },
|
|
{ c: 'inf', t: ` 신뢰도 ≥70%: ${d.confidenceItems}건` },
|
|
{ c: 'inf', t: ` 신뢰도 <50%: ${d.lowConfidenceItems}건` }
|
|
]);
|
|
setGlobal('ok', '추출 완료');
|
|
pidLoadEquipment(1);
|
|
} catch (e) {
|
|
log('pid-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
|
setGlobal('err', '오류');
|
|
}
|
|
}
|
|
|
|
async function pidLoadEquipment(page = 1) {
|
|
_pidEqPage = page;
|
|
const tagNo = document.getElementById('pid-search').value.trim();
|
|
let url = `/api/pid/equipment?page=${page}&pageSize=${PID_PAGE}`;
|
|
if (tagNo) url += `&tagNo=${encodeURIComponent(tagNo)}`;
|
|
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const d = await res.json();
|
|
|
|
document.getElementById('pid-eq-total').textContent = `총 ${d.total}건`;
|
|
|
|
const rows = d.items.map(i => {
|
|
const cls = i.confidence >= 0.7 ? 'ok' : i.confidence < 0.5 ? 'err' : 'warn';
|
|
return `<tr>
|
|
<td>${esc(i.tagNo)}</td>
|
|
<td>${esc(i.equipmentName||'')}</td>
|
|
<td>${esc(i.instrumentType||'')}</td>
|
|
<td>${esc(i.lineNumber||'')}</td>
|
|
<td>${esc(i.pidDrawingNo||'')}</td>
|
|
<td class="${cls}">${(i.confidence*100).toFixed(0)}%</td>
|
|
<td>${i.isActive ? '활성' : '<span class="err">비활성</span>'}</td>
|
|
<td>${esc(i.experionTagName||'미매핑')}</td>
|
|
<td>
|
|
${i.isActive
|
|
? `<button class="btn-sm" onclick="pidDeactivate(${i.id})">비활성화</button>`
|
|
: `<button class="btn-sm" onclick="pidActivate(${i.id})">활성화</button>`}
|
|
<button class="btn-sm" onclick="pidOpenModal(${i.id},'${esc(i.tagNo)}')">매핑</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
document.getElementById('pid-eq-body').innerHTML = rows || '<tr><td colspan="9">데이터 없음</td></tr>';
|
|
pidPagination('pid-eq-pg', d.total, page, PID_PAGE, pidLoadEquipment);
|
|
} catch (e) {
|
|
console.error('장비 목록 로드 실패:', e);
|
|
}
|
|
}
|
|
|
|
async function pidLoadMappings(page = 1) {
|
|
_pidMapPage = page;
|
|
try {
|
|
const res = await fetch(`/api/pid/mappings?page=${page}&pageSize=${PID_PAGE}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const d = await res.json();
|
|
|
|
const rows = d.items.map(i => {
|
|
const cls = i.confidence >= 0.7 ? 'ok' : i.confidence < 0.5 ? 'err' : 'warn';
|
|
return `<tr>
|
|
<td>${esc(i.tagNo)}</td>
|
|
<td>${esc(i.equipmentName||'')}</td>
|
|
<td class="${cls}">${(i.confidence*100).toFixed(0)}%</td>
|
|
<td>${esc(i.experionTagName||'미매핑')}</td>
|
|
<td>
|
|
${i.experionTagId
|
|
? `<button class="btn-sm" onclick="pidClearMapping(${i.pidEquipmentId})">해제</button>`
|
|
: `<button class="btn-sm" onclick="pidOpenModal(${i.pidEquipmentId},'${esc(i.tagNo)}')">매핑</button>`}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
document.getElementById('pid-map-body').innerHTML = rows || '<tr><td colspan="5">데이터 없음</td></tr>';
|
|
pidPagination('pid-map-pg', d.total, page, PID_PAGE, pidLoadMappings);
|
|
} catch (e) {
|
|
console.error('매핑 목록 로드 실패:', e);
|
|
}
|
|
}
|
|
|
|
async function pidActivate(id) { await fetch(`/api/pid/${id}/activate`, { method: 'POST' }); pidLoadEquipment(_pidEqPage); }
|
|
async function pidDeactivate(id) { await fetch(`/api/pid/${id}/deactivate`, { method: 'POST' }); pidLoadEquipment(_pidEqPage); }
|
|
|
|
async function pidClearMapping(id) {
|
|
await fetch(`/api/pid/mappings/${id}`, { method: 'DELETE' });
|
|
pidLoadMappings(_pidMapPage);
|
|
pidLoadEquipment(_pidEqPage);
|
|
}
|
|
|
|
async function pidOpenModal(id, tagNo) {
|
|
document.getElementById('pid-modal-id').value = id;
|
|
document.getElementById('pid-modal-tag').textContent = tagNo;
|
|
|
|
const res = await fetch('/api/pid/mappings/available-tags');
|
|
const d = await res.json();
|
|
const sel = document.getElementById('pid-modal-select');
|
|
sel.innerHTML = '<option value="">-- 선택 --</option>' +
|
|
(d.tags||[]).map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
|
|
|
document.getElementById('pid-modal').style.display = 'flex';
|
|
}
|
|
|
|
function pidCloseModal() { document.getElementById('pid-modal').style.display = 'none'; }
|
|
|
|
async function pidConfirmMapping() {
|
|
const id = parseInt(document.getElementById('pid-modal-id').value);
|
|
const tagName = document.getElementById('pid-modal-select').value;
|
|
if (!tagName) { alert('태그를 선택하세요.'); return; }
|
|
|
|
// tagName → RealtimePoint.Id 조회는 서버에서 처리
|
|
// available-tags API가 tagName 문자열을 반환하므로, 서버 측에서 tagName으로 lookup
|
|
// → CreateMappingRequest를 tagName 기반으로 변경하거나, 별도 lookup 엔드포인트 필요
|
|
// (단계 5 컨트롤러에 /api/pid/mappings/by-tagname 추가 권장)
|
|
await fetch('/api/pid/mappings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pidEquipmentId: id, experionTagName: tagName })
|
|
});
|
|
pidCloseModal();
|
|
pidLoadMappings(_pidMapPage);
|
|
pidLoadEquipment(_pidEqPage);
|
|
}
|
|
|
|
async function pidExportCsv() {
|
|
const tagNo = document.getElementById('pid-search').value.trim();
|
|
let url = '/api/pid/export/csv';
|
|
if (tagNo) url += `?tagNo=${encodeURIComponent(tagNo)}`;
|
|
window.location.href = url;
|
|
}
|
|
|
|
function pidPagination(elId, total, current, pageSize, fn) {
|
|
const pages = Math.ceil(total / pageSize);
|
|
if (pages <= 1) { document.getElementById(elId).innerHTML = ''; return; }
|
|
const from = Math.max(1, current - 3);
|
|
const to = Math.min(pages, current + 3);
|
|
let html = '';
|
|
if (from > 1) html += `<button class="pg-btn" onclick="${fn.name}(1)">«</button>`;
|
|
for (let i = from; i <= to; i++)
|
|
html += `<button class="pg-btn${i===current?' active':''}" onclick="${fn.name}(${i})">${i}</button>`;
|
|
if (to < pages) html += `<button class="pg-btn" onclick="${fn.name}(${pages})">»</button>`;
|
|
document.getElementById(elId).innerHTML = html;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 8: Frontend — style.css (P&ID 스타일 추가)
|
|
|
|
**파일**: `src/Web/wwwroot/css/style.css` 끝에 추가
|
|
|
|
```css
|
|
/* ── P&ID AX ─────────────────────────────────────────────────────────────── */
|
|
.pid-section { margin-bottom: 20px; }
|
|
.pid-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
.pid-lbl { font-size: 13px; color: #aaa; }
|
|
.pid-count { font-size: 12px; color: #888; }
|
|
.pid-stat-row { display: flex; gap: 12px; margin-bottom: 16px; }
|
|
.pid-file-input { color: #ccc; background: #2d2d2d; border: 1px solid #444;
|
|
padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
|
|
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
|
display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
|
.modal-box { background: #2d2d2d; border: 1px solid #555; border-radius: 6px;
|
|
padding: 20px; min-width: 340px; }
|
|
.modal-hdr { font-size: 14px; font-weight: bold; color: #ddd; margin-bottom: 14px; }
|
|
.modal-ftr { display: flex; gap: 8px; margin-top: 14px; justify-content: flex-end; }
|
|
```
|
|
|
|
---
|
|
|
|
### 단계 9: appsettings.json — Kestrel 업로드 크기 설정
|
|
|
|
**파일**: `src/Web/appsettings.json` — 기존 설정에 병합 추가:
|
|
|
|
```json
|
|
"Kestrel": {
|
|
"Limits": {
|
|
"MaxRequestBodySize": 104857600
|
|
}
|
|
}
|
|
```
|
|
|
|
> Anthropic API 키 설정은 **불필요** — 로컬 LLM 사용.
|
|
|
|
---
|
|
|
|
## 🔮 Vision 모드 (미래 계획 — 현재 미구현)
|
|
|
|
> **조건**: Vision 기능을 가진 로컬 모델(Qwen2-VL, InternVL 등) 또는 별도 Vision API 도입 시 구현
|
|
|
|
### 추가될 파일 처리 경로
|
|
|
|
| 입력 | 처리 방법 | 상태 |
|
|
|------|----------|------|
|
|
| `.dxf` | netDxf 텍스트 추출 → MCP | ✅ 현재 구현 |
|
|
| `.pdf` (텍스트) | PdfPig 텍스트 추출 → MCP | ✅ 현재 구현 |
|
|
| `.pdf` (스캔본) | 페이지 이미지 변환 → Vision LLM | 🔮 미래 |
|
|
| `.png` `.jpg` | 이미지 그대로 → Vision LLM | 🔮 미래 |
|
|
|
|
### 미래 구현 시 추가 작업
|
|
|
|
```
|
|
1. MCP 서버에 analyze_pid_image(image_base64) 툴 추가
|
|
→ Vision 모델 엔드포인트 호출 (별도 포트 또는 vLLM vision 모드)
|
|
|
|
2. PidExtractorService.ExtractFromStreamAsync에 분기 추가:
|
|
".pdf" → PdfPig 텍스트 추출 시도 → 텍스트 없으면 이미지 모드 fallback
|
|
".png"/".jpg" → 이미지 모드 직행
|
|
|
|
3. 업로드 UI에 "이미지 모드" 체크박스 활성화
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 구현 순서 및 체크리스트
|
|
|
|
### Phase 1 — 백엔드 (2-3일)
|
|
|
|
- [ ] **단계 1**: `ExperionCrawler.csproj`에 `netDxf`, `PdfPig` 패키지 추가 후 `dotnet build` 확인
|
|
- [ ] **단계 2**: `McpClient.cs`에 `ExtractPidTagsAsync`, `MatchPidTagsAsync` 메서드 추가
|
|
- [ ] **단계 3**: Python MCP 서버에 `extract_pid_tags`, `match_pid_tags` 툴 추가 및 테스트
|
|
- `curl`로 직접 툴 호출하여 JSON 응답 확인
|
|
- [ ] **단계 4**: `PidExtractorService.cs` 전체 교체
|
|
- `_anthropicApiKey` 의존성 제거 확인
|
|
- DXF 파싱 단위 테스트: 실제 DXF 파일로 텍스트 추출 확인
|
|
- PDF 파싱 단위 테스트: 텍스트 기반 PDF로 추출 확인
|
|
- [ ] **단계 5**: `ExperionControllers.cs`에 `ExperionPidController` 추가
|
|
- `dotnet build` 에러 0건 확인
|
|
- Swagger(`/swagger`)에서 `/api/pid/*` 엔드포인트 노출 확인
|
|
|
|
### Phase 2 — 프론트엔드 (1-2일)
|
|
|
|
- [ ] **단계 6**: `index.html`에 P&ID 탭 + pane 추가
|
|
- [ ] **단계 7**: `app.js`에 P&ID 함수 추가
|
|
- 탭 핸들러(`app.js:6-18`)에 `if (tab === 'pid')` 줄 추가
|
|
- [ ] **단계 8**: `style.css`에 P&ID 스타일 추가
|
|
- [ ] **단계 9**: `appsettings.json` Kestrel 설정 추가
|
|
|
|
### Phase 3 — 통합 테스트 (1-2일)
|
|
|
|
- [ ] 실제 DXF 파일 업로드 → 태그 추출 확인
|
|
- [ ] 실제 PDF(텍스트) 파일 업로드 → 태그 추출 확인
|
|
- [ ] 스캔본 PDF 업로드 시 명확한 에러 메시지 확인(`"지원 형식: .dxf .pdf"`)
|
|
- [ ] 추출 결과 → Experion 태그 자동 매핑 제안 확인
|
|
- [ ] 수동 매핑 UI 동작 확인
|
|
- [ ] CSV 내보내기 확인
|
|
- [ ] MCP 서버 다운 시 에러 처리 확인
|
|
|
|
---
|
|
|
|
## ⚠️ 주요 주의사항
|
|
|
|
| 항목 | 내용 |
|
|
|------|------|
|
|
| MCP 서버 의존성 | `localhost:5001` 응답 없으면 추출 실패 — UI에서 `ping` 사전 확인 권장 |
|
|
| netDxf 임시 파일 | DXF 파싱 시 `/tmp`에 임시 파일 생성/삭제 — 권한 및 디스크 여유 확인 |
|
|
| PDF 텍스트 추출 실패 | 스캔본 PDF는 `ExtractPdfText()`가 빈 문자열 반환 → Vision 미구현 안내 메시지 |
|
|
| 프롬프트 튜닝 | 도면 특성(한국어/영어, 태그 표기 방식)에 따라 MCP 서버 프롬프트 조정 필요 |
|
|
| 대용량 DXF | 1만 개 이상 엔티티 시 LLM 컨텍스트 초과 가능 → `text[:12000]` 슬라이싱으로 제한 중 |
|
|
| 태그 매핑 확신도 | `confidence < 0.7` 자동 매핑은 저장하지 않음 — 수동 매핑 유도 |
|
|
|
|
---
|
|
|
|
## 📝 코딩 가이드
|
|
|
|
### 1. 기존 패턴 엄수
|
|
|
|
```
|
|
새 기능 추가 시 반드시 기존 코드 패턴을 따를 것
|
|
|
|
✅ 엔티티: [Table("테이블명")], [Column("컬럼명")] 어트리뷰트 필수
|
|
✅ DbContext: ExperionDbContext 단일 컨텍스트 — 별도 DbContext 생성 금지
|
|
✅ 서비스 등록: Program.cs에 AddScoped<Interface, Implementation>() 형태
|
|
✅ 컨트롤러: ExperionControllers.cs 단일 파일에 추가 (별도 파일 생성 금지)
|
|
✅ 탭 진입: API 자동 호출 금지 — 버튼 클릭으로만 동작
|
|
✅ DOM 렌더링: innerHTML += 루프 금지 — rows 배열 .join('') 후 한번에 설정
|
|
✅ 로깅: Console.WriteLine 금지 — ILogger<T> 사용
|
|
```
|
|
|
|
### 2. MCP 서버 연동 패턴
|
|
|
|
```
|
|
C# 서비스에서 MCP 호출 시 McpClient를 직접 주입.
|
|
IMcpService를 통하지 않아도 됨 (McpClient는 Singleton 등록).
|
|
```
|
|
|
|
```csharp
|
|
// 올바른 패턴
|
|
public MyService(McpClient mcp, ExperionDbContext db, ILogger<MyService> logger)
|
|
|
|
// 금지
|
|
public MyService(IMcpService mcp) // 래핑 레이어가 필요할 때만 사용
|
|
```
|
|
|
|
### 3. Python MCP 툴 작성 규칙
|
|
|
|
```python
|
|
# 응답은 반드시 순수 JSON 문자열 반환
|
|
# 코드펜스(```json) 제거 후 반환
|
|
# LLM 응답에서 JSON 배열 추출: re.search(r'\[.*\]', raw, re.DOTALL)
|
|
# temperature=0.1 고정 (결정론적 출력)
|
|
# 텍스트 슬라이싱: text[:12000] (컨텍스트 초과 방지)
|
|
```
|
|
|
|
### 4. 프론트엔드 규칙
|
|
|
|
```
|
|
✅ 함수 기반 작성 — class 사용 금지
|
|
✅ 기존 헬퍼 함수 재사용: esc(), log(), setGlobal(), api()
|
|
✅ 페이지네이션: pidPagination() 패턴 — 전체 페이지 버튼 생성 금지 (±3 범위)
|
|
✅ 다크 테마 색상 사용: #1e1e1e, #2d2d2d, #ccc, .ok/.err/.warn CSS 클래스
|
|
✅ Bootstrap 클래스 사용 금지
|
|
✅ fetch 에러 처리: if (!res.ok) throw new Error(...)
|
|
```
|
|
|
|
### 5. 빌드 검증 체크포인트
|
|
|
|
```bash
|
|
# 각 단계 완료 후 실행
|
|
dotnet build src/Web/ExperionCrawler.csproj
|
|
|
|
# 목표: 경고 N건, 에러 0건
|
|
# netDxf/PdfPig 추가 후 새 경고 없어야 정상
|
|
```
|
|
|
|
### 6. MCP 툴 독립 테스트
|
|
|
|
```bash
|
|
# Python MCP 서버 직접 테스트 (C# 없이)
|
|
curl -X POST http://localhost:5001/mcp \
|
|
-H "Content-Type: application/json" \
|
|
-H "mcp-protocol-version: 2025-03-26" \
|
|
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call",
|
|
"params":{"name":"extract_pid_tags",
|
|
"arguments":{"text":"FT-101 Flow Transmitter\nPT-201 Pressure","source_type":"dxf"}}}'
|
|
# 기대 응답: [{"tagNo":"FT-101",...},{"tagNo":"PT-201",...}]
|
|
```
|