Files
HC900-Crawler/src/Core/Application/Services/PidExtractorService.cs
windpacer daeb5316a2 refactor: 태그 대소문자 register-map 기준 대문자로 전역 통일
- FeedforwardSupervisor: PvTag() ToUpperInvariant + empty FeedTag 가드
- FeedforwardConfigStore: 모든 ToLowerInvariant() 제거
- FeedRampAdvisorService: ToLowerInvariant 제거 + StringComparison.OrdinalIgnoreCase
- SimOverrideStore: ToLowerInvariant 제거
- Hc900RealtimeService: HealthCheck SERVING 판정, mapping 없는 태그 대소문자 유지
- PidExtractorService: ToLowerInvariant → OrdinalIgnoreCase 비교
- Hc900Entities: 주석 업데이트 (대문자 표준)
- load_state_labels.py: 소문자 변환 금지, controller_id 파라미터 추가
- Hc900Controllers: 대소문자 무시 정렬
- write.js: .MODE → AutoManState/RemLocSPState/SP_SelectState/TuneSetState
- setup.js/html: 중복 함수 제거, C5 컨트롤러 placeholder
2026-06-04 09:43:37 +09:00

1244 lines
53 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Core.Domain.Entities;
using Hc900Crawler.Infrastructure.Database;
using Hc900Crawler.Infrastructure.Mcp;
using Microsoft.EntityFrameworkCore;
using netDxf;
using UglyToad.PdfPig;
namespace Hc900Crawler.Core.Application.Services;
public class PidExtractorService : IPidExtractorService
{
private readonly McpClient _mcp;
private readonly Hc900DbContext _dbContext;
private readonly ILogger<PidExtractorService> _logger;
private readonly SemaphoreSlim _cacheLock = new(1, 1);
private List<PidPrefixRule>? _cachedRules;
public PidExtractorService(McpClient mcp, Hc900DbContext dbContext, ILogger<PidExtractorService> logger)
{
_mcp = mcp;
_dbContext = dbContext;
_logger = logger;
}
public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
{
await using var stream = File.OpenRead(filePath);
return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode);
}
public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
string text;
Dictionary<string, (double X, double Y, double H)>? coords = null;
if (ext == ".dxf")
(text, coords) = ExtractDxfText(stream);
else if (ext == ".pdf")
text = ExtractPdfText(stream);
else
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 _dbContext.RealtimePoints.Select(r => r.TagName).ToListAsync();
var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
var mappings = ParseMappingJson(mappingJson);
// 중복 체크: 기존 DB에 있는 TagNo는 제외 (대소문자 구분 없음)
var existingTagNos = new HashSet<string>(
await _dbContext.PidEquipment.Select(e => e.TagNo).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var newItems = extractedItems.Where(i => !existingTagNos.Contains(i.TagNo)).ToList();
int skippedCount = extractedItems.Count - newItems.Count;
if (skippedCount > 0)
_logger.LogInformation("P&ID 중복 제외: {Skipped}건 스킵 (이미 존재)", skippedCount);
// DB 저장
var prefixRules = await GetRulesCachedAsync();
var dbItems = new List<PidEquipment>();
foreach (var item in newItems)
{
mappings.TryGetValue(item.TagNo, out var matched);
var experionTag = matched != null
? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
: await FindFallbackTagAsync(item.TagNo);
var category = await MatchCategoryAsync(item.TagNo);
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
var tagClass = ClassifyTagClass(category, tagDcs);
var newItem = new PidEquipment
{
TagNo = item.TagNo,
EquipmentName = ResolveEquipmentName(item.InstrumentType, prefixRules)
?? item.EquipmentName,
InstrumentType = item.InstrumentType,
LineNumber = item.LineNumber,
PidDrawingNo = item.PidDrawingNo,
Confidence = item.Confidence,
ExperionTagId = experionTag?.Id,
Category = category,
TagDcs = tagDcs,
TagClass = tagClass,
ExtractedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
if (coords != null && coords.TryGetValue(item.TagNo, out var c))
{
newItem.PosX = c.X;
newItem.PosY = c.Y;
}
if (newItem.PidDrawingNo == null)
newItem.PidDrawingNo = fileName;
newItem.DrawingFile = fileName;
dbItems.Add(newItem);
}
if (dbItems.Count > 0)
{
await _dbContext.PidEquipment.AddRangeAsync(dbItems);
await _dbContext.SaveChangesAsync();
}
_logger.LogInformation(
"P&ID 추출 완료: {Total}건 저장, {Skipped}건 중복 스킵 (파일: {FileName})",
dbItems.Count, skippedCount, fileName);
return new PidExtractionResult(
TotalCount: dbItems.Count,
ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5),
SkippedDuplicates: skippedCount);
}
private (string Text, Dictionary<string, (double X, double Y, double H)> Coords) ExtractDxfText(Stream stream)
{
var tmp = Path.GetTempFileName() + ".dxf";
try
{
using (var fs = File.Create(tmp))
stream.CopyTo(fs);
var doc = DxfDocument.Load(tmp);
var sb = new StringBuilder();
var positioned = new List<(string Value, double X, double Y, double H)>();
foreach (var txt in doc.Entities.Texts)
{
sb.AppendLine(txt.Value);
if (!string.IsNullOrWhiteSpace(txt.Value))
positioned.Add((txt.Value.Trim(), txt.Position.X, txt.Position.Y, txt.Height));
}
foreach (var mtxt in doc.Entities.MTexts)
{
var plain = mtxt.PlainText();
sb.AppendLine(plain);
if (!string.IsNullOrWhiteSpace(plain))
positioned.Add((plain.Trim(), mtxt.Position.X, mtxt.Position.Y, mtxt.Height));
}
foreach (var blk in doc.Blocks)
foreach (var attr in blk.AttributeDefinitions.Values)
sb.AppendLine(attr.Value);
// CIRCLE 중심 좌표 매핑 — TEXT가 원 안에 있으면 원 중심으로 치환
var circles = doc.Entities.Circles
.Select(c => (X: c.Center.X, Y: c.Center.Y, R: c.Radius))
.ToList();
var circleCoords = new Dictionary<string, (double X, double Y)>(StringComparer.OrdinalIgnoreCase);
foreach (var (val, x, y, _) in positioned)
{
double bestR = double.MaxValue;
double cx = 0, cy = 0;
foreach (var (ccx, ccy, cr) in circles)
{
var dx = x - ccx;
var dy = y - ccy;
var d = Math.Sqrt(dx * dx + dy * dy);
if (d < cr && cr < bestR)
{
bestR = cr;
cx = ccx; cy = ccy;
}
}
if (bestR < double.MaxValue)
circleCoords.TryAdd(val, (cx, cy));
}
// 계기 풍선 재조합
var tagRe = new Regex(@"[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*", RegexOptions.IgnoreCase);
var balloons = ReconstructBalloonTags(positioned);
foreach (var (tag, _, _, _) in balloons)
sb.AppendLine(tag);
var text = FilterDxfText(sb.ToString());
// 필터 통과한 태그 집합
var filteredSet = new HashSet<string>(
text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
StringComparer.OrdinalIgnoreCase);
// 좌표 맵 — 태그당 첫 좌표만 저장 (CIRCLE 내부면 원 중심 우선)
var coords = new Dictionary<string, (double X, double Y, double H)>(StringComparer.OrdinalIgnoreCase);
foreach (var (val, x, y, h) in positioned)
{
if (!tagRe.IsMatch(val) || !filteredSet.Contains(val))
continue;
if (circleCoords.TryGetValue(val, out var cc))
coords.TryAdd(val, (cc.X, cc.Y, h));
else
coords.TryAdd(val, (x, y, h));
}
foreach (var (tag, x, y, h) in balloons)
{
if (!filteredSet.Contains(tag))
continue;
var hyphenPos = tag.IndexOf('-');
var func = hyphenPos > 0 ? tag[..hyphenPos] : null;
var num = hyphenPos > 0 && hyphenPos + 1 < tag.Length ? tag[(hyphenPos + 1)..] : null;
if ((func != null && circleCoords.TryGetValue(func, out var cc))
|| (num != null && circleCoords.TryGetValue(num, out cc)))
coords.TryAdd(tag, (cc.X, cc.Y, h));
else
coords.TryAdd(tag, (x, y, h));
}
return (text, coords);
}
finally
{
if (File.Exists(tmp)) File.Delete(tmp);
}
}
/// <summary>
/// DXF 텍스트에서 P&ID 태그 패턴에 해당하는 라인만 필터링
/// 불필요한 텍스트를 제거하여 MCP 서버 부하 감소 및 JSON 파싱 오류 방지
/// </summary>
private string FilterDxfText(string text)
{
var lines = text.Split('\n');
var filteredLines = new List<string>();
foreach (var line in lines)
{
var trimmed = line.Trim();
// P&ID 태그 패턴 포함 라인만 유지
// - 단일 글자 장비 태그 포함: P-10101, T-10100, E-10119, C-10111
// - 다중 글자 계측 태그: FCV-101, FICQ-6113, PSV-6203
// - 복합 태그: VG-6203-15A-F1A-n, CD-10513-40A
if (Regex.IsMatch(trimmed, @"[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*", RegexOptions.IgnoreCase))
{
filteredLines.Add(trimmed);
}
}
return string.Join("\n", filteredLines);
}
// ISA 계기 기능코드: 측정변수(첫 글자) + 후속문자(1~3).
// 첫 글자에 L·H 포함하되 후속문자 집합에서 L·H·P 제외 → LL/HH/HP 등
// 셋포인트·재질 라벨은 배제, FT/PT/LT/TR/TE/TIC/FICQ/PSV/PG 등은 통과.
private static readonly Regex _instrFuncRe = new(
@"^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$", RegexOptions.Compiled);
// 루프번호: 3~6자리 숫자 + 선택적 말미 접미 알파벳 (예: 9101, 9111A, 10900B)
private static readonly Regex _loopNumRe = new(
@"^\d{3,6}[A-Z]?$", RegexOptions.Compiled);
/// <summary>
/// 계기 풍선의 기능코드 TEXT와 루프번호 TEXT를 근접 좌표로 짝지어
/// <c>FUNC-NUMBER</c> 형태로 재조합. 임계값은 텍스트 높이에 비례시켜
/// 도면 스케일에 무관하게 동작. (TE/TI/TG가 같은 루프번호 공유 → 다대일 허용)
/// </summary>
private List<(string Tag, double X, double Y, double H)> ReconstructBalloonTags(
List<(string Value, double X, double Y, double H)> texts)
{
var funcs = texts.Where(t => _instrFuncRe.IsMatch(t.Value)).ToList();
var nums = texts.Where(t => _loopNumRe.IsMatch(t.Value)).ToList();
if (funcs.Count == 0 || nums.Count == 0)
return [];
var result = new List<(string Tag, double X, double Y, double H)>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var f in funcs)
{
double bestDist = double.MaxValue;
string? bestNum = null;
double nx = 0, ny = 0;
foreach (var n in nums)
{
var dx = f.X - n.X;
var dy = f.Y - n.Y;
var d = Math.Sqrt(dx * dx + dy * dy);
if (d < bestDist) { bestDist = d; bestNum = n.Value; nx = n.X; ny = n.Y; }
}
var threshold = f.H > 0 ? f.H * 5.0 : 12.0;
if (bestNum != null && bestDist <= threshold)
{
var tag = $"{f.Value}-{bestNum}";
if (seen.Add(tag))
result.Add((tag, (f.X + nx) / 2, (f.Y + ny) / 2, f.H));
}
}
return result;
}
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();
}
private List<ExtractedItem> ParseJson(string json)
{
try
{
// MCP 서버 응답 형식: {"success": ..., "count": ..., "tags": [...]}
// 또는 기존 형식: [...]
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// "tags" 필드가 있으면 중첩 구조로 간주
if (root.TryGetProperty("tags", out var tagsElement))
{
return JsonSerializer.Deserialize<List<ExtractedItem>>(tagsElement.GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
}
// 루트가 배열이면 직접 파싱
if (root.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<ExtractedItem>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
}
_logger.LogWarning("P&ID JSON 파싱 실패: 'tags' 필드 또는 배열 형식 없음");
return [];
}
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
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// MCP 응답: {"success": ..., "count": ..., "mappings": [...]}
JsonElement arrayEl = root.ValueKind == JsonValueKind.Array
? root
: root.TryGetProperty("mappings", out var m) ? m : default;
if (arrayEl.ValueKind != JsonValueKind.Array)
return [];
var list = JsonSerializer.Deserialize<List<MappingItem>>(arrayEl.GetRawText(),
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 []; }
}
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
{
var baseTag = tagNo.Split('.')[0];
return await _dbContext.RealtimePoints
.FirstOrDefaultAsync(t => t.TagName.Equals(baseTag, StringComparison.OrdinalIgnoreCase)
|| t.TagName.StartsWith(baseTag + ".", StringComparison.OrdinalIgnoreCase));
}
public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
string? tagNo, string? category, int page, int pageSize,
string? sortBy = null, bool sortDesc = false)
{
var q = _dbContext.PidEquipment.AsQueryable();
if (!string.IsNullOrEmpty(tagNo))
q = q.Where(e => e.TagNo.Contains(tagNo));
if (!string.IsNullOrEmpty(category))
{
if (category == "__unmatched__")
q = q.Where(e => e.Category == null);
else if (category == "instrument_dcs")
q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagDcs);
else if (category == "instrument_field")
q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && !e.TagDcs);
else
q = q.Where(e => e.Category == category);
}
var total = await q.CountAsync();
List<PidEquipment> items;
if (sortBy == "tagName")
{
// DB 전체 로드 후 C#에서 natural sort (prefix 알파 → 숫자 정수값 → suffix 알파)
var all = await q.ToListAsync();
if (sortDesc)
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ToList();
else
all = all.OrderBy(e => TagSortKey(e.TagNo)).ToList();
items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
}
else
{
items = await q.OrderByDescending(e => e.ExtractedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
// batch-load sub_area from tag_metadata
if (items.Count > 0)
{
var tagNos = items.Select(e => e.TagNo).ToHashSet(StringComparer.OrdinalIgnoreCase);
var subAreas = await _dbContext.TagMetadata
.Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area")
.Select(m => new { m.BaseTag, m.Value })
.ToListAsync();
var subMap = subAreas.ToDictionary(sa => sa.BaseTag, sa => sa.Value, StringComparer.OrdinalIgnoreCase);
foreach (var e in items)
{
if (subMap.TryGetValue(e.TagNo, out var sa))
e.SubArea = sa;
}
}
return (total, items);
}
private static string TagSortKey(string tagNo)
{
// (알파 prefix)(숫자부)(알파 suffix) 분해 → 숫자를 정수로 비교
// TI-6111B → "ti-" + 0000006111 + "b"
// TI-10103 → "ti-" + 0000010103 + ""
var tag = tagNo.ToLowerInvariant();
var m = Regex.Match(tag, @"^([^\d]+)(\d+)([a-z]*)$");
if (!m.Success)
return tag;
var prefix = m.Groups[1].Value;
var num = long.TryParse(m.Groups[2].Value, out var n) ? n : 0L;
var suffix = m.Groups[3].Value;
return $"{prefix}\x00{num:D12}\x00{suffix}";
}
public async Task<PidEquipment?> GetByIdAsync(long id)
=> await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);
public async Task UpdateConfidenceAsync(long id, double confidence)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return;
e.Confidence = confidence; e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
public async Task<PidEquipment> CreateEquipmentAsync(CreateEquipmentRequest request)
{
var e = new PidEquipment
{
TagNo = request.TagNo,
EquipmentName = request.EquipmentName,
InstrumentType = request.InstrumentType,
Category = request.Category,
TagDcs = request.TagDcs ?? false,
Role = request.Role,
FromTag = request.FromTag,
ToTag = request.ToTag,
FromAt = request.FromAt,
ToAt = request.ToAt,
TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
IsActive = true,
Confidence = 1.0,
ExtractedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
if (request.FromTag != null || request.ToTag != null)
e.ConnectionLocked = true;
_dbContext.PidEquipment.Add(e);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("[PID] Created equipment id={Id} tagNo={Tag}", e.Id, e.TagNo);
return e;
}
public async Task<bool> UpdateEquipmentAsync(long id, UpdateEquipmentRequest request)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return false;
if (request.TagNo != null) e.TagNo = request.TagNo;
if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName;
if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType;
if (request.Category != null) e.Category = request.Category;
if (request.TagDcs.HasValue) e.TagDcs = request.TagDcs.Value;
if (request.Role != null) e.Role = request.Role;
if (request.FromTag != null) e.FromTag = request.FromTag;
if (request.ToTag != null) e.ToTag = request.ToTag;
if (request.FromAt != null) e.FromAt = request.FromAt;
if (request.ToAt != null) e.ToAt = request.ToAt;
if (request.TagClass != null) e.TagClass = request.TagClass;
if (request.Category != null || request.TagDcs.HasValue)
e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
if (request.FromTag != null || request.ToTag != null)
e.ConnectionLocked = true;
e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
if (request.SubArea != null)
{
var baseTag = e.TagNo;
if (string.IsNullOrWhiteSpace(request.SubArea))
await _dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag);
else
await _dbContext.Database.ExecuteSqlRawAsync(
@"INSERT INTO tag_metadata (base_tag, attribute, value)
VALUES ({0}, 'sub_area', {1})
ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()",
baseTag, request.SubArea.Trim());
}
return true;
}
public async Task ActivateAsync(long id)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return;
e.IsActive = true; e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
public async Task DeactivateAsync(long id)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return;
e.IsActive = false; e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
public async Task<bool> DeleteAsync(long id)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return false;
_dbContext.PidEquipment.Remove(e);
await _dbContext.SaveChangesAsync();
return true;
}
public async Task<(bool Found, string? TagName)> AutoMapTagAsync(long id)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return (false, null);
var match = await FindFallbackTagAsync(e.TagNo ?? "");
if (match == null) return (false, null);
e.ExperionTagId = match.Id;
e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
return (true, match.TagName);
}
public async Task<int> DeleteAllEquipmentAsync()
{
var count = await _dbContext.PidEquipment.CountAsync();
if (count == 0) return 0;
await _dbContext.Database.ExecuteSqlRawAsync("DELETE FROM pid_equipment");
_logger.LogInformation("[PID] 전체 삭제 완료: {Count}건", count);
return count;
}
public Task<int> GetTotalCountAsync() => _dbContext.PidEquipment.CountAsync();
public Task<int> GetConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence >= 0.7);
public Task<int> GetLowConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence < 0.5);
public Task<int> GetDrawingCountAsync() => _dbContext.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync();
public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
{
var items = await _dbContext.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)
{
return await Task.Run(() =>
{
var sb = new StringBuilder();
sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId,Category,Role,From,To,TagClass");
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},{Csv(i.Category)},{Csv(i.Role)},{Csv(i.FromTag)},{Csv(i.ToTag)},{Csv(i.TagClass)}");
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;
}
public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
{
return await Task.Run(async () =>
{
using var package = new OfficeOpenXml.ExcelPackage();
var rules = await GetRulesCachedAsync();
var grouped = items
.GroupBy(i =>
{
var cat = string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!;
if (cat == PidEquipment.CategoryInstrument)
return i.TagDcs ? "instrument_dcs" : "instrument_field";
return cat;
})
.ToDictionary(g => g.Key, g => g.ToList());
var sheetOrder = new[]
{
"instrument_dcs",
"instrument_field",
PidEquipment.CategoryPowerEquipment,
PidEquipment.CategoryStorageEquipment,
PidEquipment.CategoryProcessEquipment,
PidEquipment.CategoryUtilityEquipment,
PidEquipment.CategoryPipings,
"__unmatched__"
};
var sheetNames = new Dictionary<string, string>
{
["instrument_dcs"] = "DCS 태그",
["instrument_field"] = "현장 계기",
[PidEquipment.CategoryPowerEquipment] = "Power Equipment",
[PidEquipment.CategoryStorageEquipment] = "Storage Equipment",
[PidEquipment.CategoryProcessEquipment] = "Process Equipment",
[PidEquipment.CategoryUtilityEquipment] = "Utility Equipment",
[PidEquipment.CategoryPipings] = "Pipings",
["__unmatched__"] = "Unmatched"
};
foreach (var cat in sheetOrder)
{
if (!grouped.TryGetValue(cat, out var groupItems) || groupItems.Count == 0)
continue;
var sheetName = sheetNames[cat];
var worksheet = package.Workbook.Worksheets.Add(sheetName);
worksheet.Cells[1, 1].Value = "태그번호";
worksheet.Cells[1, 2].Value = "장비명";
worksheet.Cells[1, 3].Value = "장비타입";
worksheet.Cells[1, 4].Value = "라인번호";
worksheet.Cells[1, 5].Value = "도면번호";
worksheet.Cells[1, 6].Value = "신뢰도";
worksheet.Cells[1, 7].Value = "상태";
worksheet.Cells[1, 8].Value = "추출일시";
worksheet.Cells[1, 9].Value = "Experion 태그";
worksheet.Cells[1, 10].Value = "카테고리";
worksheet.Cells[1, 11].Value = "Role";
worksheet.Cells[1, 12].Value = "From";
worksheet.Cells[1, 13].Value = "To";
worksheet.Cells[1, 14].Value = "From_at";
worksheet.Cells[1, 15].Value = "To_at";
worksheet.Cells[1, 16].Value = "태그분류";
worksheet.Cells[1, 17].Value = "id"; // 안정 키(라운드트립 매칭용) — col17 고정
worksheet.Cells[1, 18].Value = "DCS태그"; // tag_dcs: DCS 함수블록 여부
using var headerRange = worksheet.Cells[1, 1, 1, 18];
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid;
headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);
int row = 2;
foreach (var item in groupItems)
{
worksheet.Cells[row, 1].Value = item.TagNo;
worksheet.Cells[row, 2].Value =
ResolveEquipmentName(item.InstrumentType, rules)
?? item.EquipmentName ?? "";
worksheet.Cells[row, 3].Value = item.InstrumentType ?? "";
worksheet.Cells[row, 4].Value = item.LineNumber ?? "";
worksheet.Cells[row, 5].Value = item.PidDrawingNo ?? "";
worksheet.Cells[row, 6].Value = item.Confidence;
worksheet.Cells[row, 7].Value = item.IsActive ? "활성" : "비활성";
worksheet.Cells[row, 8].Value = item.ExtractedAt;
worksheet.Cells[row, 9].Value = item.ExperionTag?.TagName ?? "";
worksheet.Cells[row, 10].Value = item.Category ?? "";
worksheet.Cells[row, 11].Value = item.Role ?? "";
worksheet.Cells[row, 12].Value = item.FromTag ?? "";
worksheet.Cells[row, 13].Value = item.ToTag ?? "";
worksheet.Cells[row, 14].Value = item.FromAt ?? "";
worksheet.Cells[row, 15].Value = item.ToAt ?? "";
worksheet.Cells[row, 16].Value = item.TagClass switch
{
PidEquipment.TagClassSystem => "시스템(DCS)",
PidEquipment.TagClassField => "현장",
_ => ""
};
worksheet.Cells[row, 17].Value = item.Id; // 안정 키(라운드트립 매칭용)
worksheet.Cells[row, 18].Value = item.TagDcs ? "DCS" : "현장";
row++;
}
worksheet.Cells.AutoFitColumns();
}
return package.GetAsByteArray();
});
}
/// <summary>
/// 편집 엑셀(ExportToExcelAsync 포맷, 17컬럼) → pid_equipment UPSERT.
/// 매칭 키 = id(col17, 안정 키). id 있으면 그 행만 in-place UPDATE(다중경로 보존),
/// id 비어있으면 신규 INSERT. col17 헤더가 "id"가 아닌 옛 파일은 태그번호(col1) 매칭으로 폴백.
/// 빈 셀 → null 로 기록(엑셀에서 값을 비우면 DB에서도 삭제 = 라운드트립 교정 가능).
/// 갱신 컬럼: 장비명·장비타입·라인번호·도면번호·신뢰도·상태·카테고리·Role·From·To·From_at·To_at·태그분류.
/// 읽기전용(미반영): 추출일시(col8), Experion 태그(col9).
/// 갱신 행은 ConnectionLocked=true 로 표시되어, 이후 AnalyzeConnectionsAsync
/// 재실행 시 From/To 초기화·재계산에서 제외(사람 교정 보호).
/// </summary>
public async Task<PidImportResult> ImportFromExcelAsync(Stream stream)
{
// EPPlus 는 비동기 시킹 스트림이 필요해 메모리로 복사
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
ms.Position = 0;
using var package = new OfficeOpenXml.ExcelPackage(ms);
int sheets = 0, rowsRead = 0, rowsUpdated = 0, rowsInserted = 0;
var unmatched = new List<string>();
// 인덱스: id(안정 키, in-place 갱신) + TagNo(옛 파일 폴백)
var all = await _dbContext.PidEquipment.ToListAsync();
var byId = all.ToDictionary(e => e.Id);
var byTag = all
.Where(e => !string.IsNullOrWhiteSpace(e.TagNo))
.GroupBy(e => e.TagNo.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
static string? Norm(OfficeOpenXml.ExcelWorksheet ws, int r, int c)
{
var t = ws.Cells[r, c].Text?.Trim();
return string.IsNullOrEmpty(t) ? null : t;
}
foreach (var ws in package.Workbook.Worksheets)
{
// 데이터 시트만 처리 (헤더 col1 == "태그번호")
if (ws.Dimension == null) continue;
if (!string.Equals(ws.Cells[1, 1].Text?.Trim(), "태그번호",
StringComparison.Ordinal))
continue;
// col17 헤더가 "id" 면 안정 키 매칭, 아니면 옛 포맷(태그번호 매칭)으로 폴백
bool hasIdCol = string.Equals(ws.Cells[1, 17].Text?.Trim(), "id",
StringComparison.Ordinal);
// col18 헤더가 "DCS태그" 면 tag_dcs 읽기
bool hasDcsCol = string.Equals(ws.Cells[1, 18].Text?.Trim(), "DCS태그",
StringComparison.Ordinal);
sheets++;
for (int r = 2; r <= ws.Dimension.End.Row; r++)
{
var tagNo = Norm(ws, r, 1);
if (tagNo == null) continue;
rowsRead++;
var equipName = Norm(ws, r, 2);
var instType = Norm(ws, r, 3);
var lineNo = Norm(ws, r, 4);
var drawingNo = Norm(ws, r, 5);
var confTxt = Norm(ws, r, 6);
var stateTxt = Norm(ws, r, 7);
var category = Norm(ws, r, 10);
var role = Norm(ws, r, 11);
var fromTag = Norm(ws, r, 12);
var toTag = Norm(ws, r, 13);
var fromAt = Norm(ws, r, 14);
var toAt = Norm(ws, r, 15);
var clsTxt = Norm(ws, r, 16);
double? conf = double.TryParse(confTxt, out var cv) ? cv : null;
bool? active = stateTxt switch
{
"활성" => true,
"비활성" => false,
_ => (bool?)null
};
var tagClass = clsTxt switch
{
"시스템(DCS)" => PidEquipment.TagClassSystem,
"현장" => PidEquipment.TagClassField,
_ => null
};
void Apply(PidEquipment e)
{
e.EquipmentName = equipName;
e.InstrumentType = instType;
e.LineNumber = lineNo;
e.PidDrawingNo = drawingNo;
if (conf.HasValue) e.Confidence = conf.Value;
if (active.HasValue) e.IsActive = active.Value;
e.Category = category;
e.Role = role;
e.FromTag = fromTag;
e.ToTag = toTag;
e.FromAt = fromAt;
e.ToAt = toAt;
e.TagClass = tagClass;
// col18: DCS태그 (DCS=true, 현장=false). 헤더 없는 옛 파일은 기존 값 유지.
if (hasDcsCol)
{
var dcsVal = Norm(ws, r, 18);
e.TagDcs = dcsVal == "DCS";
}
// From/To 를 채운 행만 잠금(사람이 연결을 교정한 행).
// 둘 다 비우면 잠금 해제 → 연결분석이 다시 도출 가능.
e.ConnectionLocked = fromTag != null || toTag != null;
e.UpdatedAt = DateTime.UtcNow;
}
if (hasIdCol)
{
// id 있으면 그 행만 in-place UPDATE(다중경로 보존), 비어있으면 신규 INSERT
var idTxt = Norm(ws, r, 17);
if (idTxt != null && long.TryParse(idTxt, out var rid) &&
byId.TryGetValue(rid, out var hit))
{
Apply(hit);
rowsUpdated++;
}
else
{
var ne = new PidEquipment { TagNo = tagNo };
Apply(ne);
_dbContext.PidEquipment.Add(ne);
rowsInserted++;
}
}
else
{
// 옛 포맷(id 컬럼 없음): TagNo 매칭 — 같은 TagNo 다중 행이면 전부 갱신
if (!byTag.TryGetValue(tagNo, out var recs))
{
if (unmatched.Count < 200) unmatched.Add(tagNo);
continue;
}
foreach (var e in recs) { Apply(e); rowsUpdated++; }
}
}
}
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"[PID Import] 시트 {Sheets} · 읽음 {Read} · 갱신 {Upd} · 신규 {Ins} · 미매칭 {Un}",
sheets, rowsRead, rowsUpdated, rowsInserted, unmatched.Count);
return new PidImportResult
{
SheetsProcessed = sheets,
RowsRead = rowsRead,
RowsUpdated = rowsUpdated,
RowsInserted = rowsInserted,
Unmatched = unmatched.Count,
UnmatchedTags = unmatched
};
}
/// <summary>
/// 장비타입(InstrumentType: PSV, TR, C 등) → prefix 규칙 설명(Description).
/// 1) prefix 정확 매칭 우선 (예: 타입 "DP" → 규칙 "DP"=Differential Pressure,
/// 규칙 "DP-"=Diaphragm Pump 충돌을 정확매칭으로 해소)
/// 2) 없으면 후행 '-' 제거 매칭 (예: 타입 "C" → 규칙 "C-"=Column)
/// 매칭 실패 시 null → 호출부에서 기존 EquipmentName 유지.
/// </summary>
private static string? ResolveEquipmentName(string? instrumentType, List<PidPrefixRule> rules)
{
if (string.IsNullOrWhiteSpace(instrumentType)) return null;
var it = instrumentType.Trim();
var exact = rules
.Where(r => r.Prefix.Trim().Equals(it, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.SortOrder).FirstOrDefault();
if (exact != null) return exact.Description ?? exact.Prefix;
var stripped = rules
.Where(r => r.Prefix.Trim().TrimEnd('-').Equals(it, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.SortOrder).FirstOrDefault();
return stripped == null ? null : (stripped.Description ?? stripped.Prefix);
}
// ── Prefix Rule Cache ──────────────────────────────────────────────────────
private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
{
var rules = _cachedRules;
if (rules != null) return rules;
await _cacheLock.WaitAsync();
try
{
rules = _cachedRules;
if (rules != null) return rules;
rules = await _dbContext.PidPrefixRules
.OrderByDescending(r => r.Prefix.Length)
.ThenBy(r => r.SortOrder)
.ToListAsync();
_cachedRules = rules;
return rules;
}
finally
{
_cacheLock.Release();
}
}
private void InvalidateRulesCache()
{
Interlocked.Exchange(ref _cachedRules, null);
}
// 배관번호 패턴: SERVICE-LINENUM-SIZE(숫자+알파벳)-... 3번째 필드에 파이프 사이즈 존재
private static readonly Regex _pipeLineNoRe = new(
@"^[A-Z][A-Z0-9]{0,3}-\d{3,6}-\d{1,4}[A-Za-z]-",
RegexOptions.Compiled);
private async Task<string?> MatchCategoryAsync(string tagNo)
{
if (_pipeLineNoRe.IsMatch(tagNo))
return PidEquipment.CategoryPipings;
var rules = await GetRulesCachedAsync();
return rules.FirstOrDefault(r =>
tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category;
}
/// <summary>
/// prefix rule에서 tag_dcs 값을 조회. StartsWith 매칭으로 compound형(FICQ/FICA 등) 자동 커버.
/// 가장 긴 prefix 우선(FIC보다 FICQ가 더 구체적이면 FICQ rule 우선).
/// </summary>
private async Task<bool> ResolveTagDcsAsync(string tagNo)
{
var rules = await GetRulesCachedAsync();
var upper = tagNo.ToUpperInvariant();
var rule = rules
.Where(r => upper.StartsWith(r.Prefix.ToUpperInvariant()))
.OrderByDescending(r => r.Prefix.Length) // 가장 긴 prefix 우선
.FirstOrDefault();
return rule?.TagDcs ?? false;
}
/// <summary>
/// 계기(instrument) 하위 분류. tag_dcs(prefix rule)가 단일 기준.
/// tag_dcs=TRUE → system (DCS 함수블록: FIC/TIC/PIC류)
/// tag_dcs=FALSE → field (현장 물리 계기: FT/FCV류)
/// instrument 이외 카테고리는 null.
/// </summary>
private static string? ClassifyTagClass(string? category, bool tagDcs)
{
if (category != PidEquipment.CategoryInstrument) return null;
return tagDcs ? PidEquipment.TagClassSystem : PidEquipment.TagClassField;
}
// ── Prefix Rule CRUD ───────────────────────────────────────────────────────
public async Task<List<PidPrefixRule>> GetPrefixRulesAsync()
{
return await _dbContext.PidPrefixRules
.OrderBy(r => r.SortOrder)
.ThenBy(r => r.Prefix)
.ToListAsync();
}
public async Task<PidPrefixRule> CreatePrefixRuleAsync(CreatePidPrefixRuleRequest request)
{
var rule = new PidPrefixRule
{
Prefix = request.Prefix.Trim(),
Category = request.Category,
TagDcs = request.TagDcs,
Description = request.Description?.Trim(),
SortOrder = request.SortOrder,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_dbContext.PidPrefixRules.Add(rule);
await _dbContext.SaveChangesAsync();
InvalidateRulesCache();
return rule;
}
public async Task<PidPrefixRule?> UpdatePrefixRuleAsync(int id, UpdatePidPrefixRuleRequest request)
{
var rule = await _dbContext.PidPrefixRules.FindAsync(id);
if (rule == null) return null;
rule.Prefix = request.Prefix.Trim();
rule.Category = request.Category;
rule.TagDcs = request.TagDcs;
rule.Description = request.Description?.Trim();
rule.SortOrder = request.SortOrder;
rule.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
InvalidateRulesCache();
return rule;
}
public async Task<bool> DeletePrefixRuleAsync(int id)
{
var rule = await _dbContext.PidPrefixRules.FindAsync(id);
if (rule == null) return false;
_dbContext.PidPrefixRules.Remove(rule);
await _dbContext.SaveChangesAsync();
InvalidateRulesCache();
return true;
}
public async Task<int> ApplyCategoriesToExistingAsync()
{
const int batchSize = 1000;
int total = 0;
long lastId = 0;
while (true)
{
var batch = await _dbContext.PidEquipment
.Where(e => e.Id > lastId)
.OrderBy(e => e.Id)
.Take(batchSize)
.ToListAsync();
if (!batch.Any()) break;
foreach (var item in batch)
{
var category = await MatchCategoryAsync(item.TagNo);
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
var tagClass = ClassifyTagClass(category, tagDcs);
if (item.Category != category || item.TagDcs != tagDcs || item.TagClass != tagClass)
{
item.Category = category;
item.TagDcs = tagDcs;
item.TagClass = tagClass;
item.UpdatedAt = DateTime.UtcNow;
total++;
}
lastId = item.Id;
}
await _dbContext.SaveChangesAsync();
}
return total;
}
// ── 연결 분석 (from→to) ────────────────────────────────────────────────────
// 범례 영역 제외
private const double LegendXThreshold = 4500;
private const double LegendYThreshold = 5400;
private const double MaxConnectionDistance = 300.0;
private static readonly Regex _loopNumExtractRe = new(@"-(\d{3,})", RegexOptions.Compiled);
private static string? ExtractLoopNumber(string tagNo)
{
var m = _loopNumExtractRe.Match(tagNo);
return m.Success ? m.Groups[1].Value : null;
}
// mcp-server/sim_line_connection.py 가 생성하는 연결 JSON 후보 경로
private static readonly string[] _connJsonRoots =
{ "mcp-server/storage", "../mcp-server/storage", "../../mcp-server/storage" };
/// <summary>
/// P&ID 연결 분석 — 방향표지판(off-page connector) 태그매칭 + 방향성 기반.
/// 기존 loop+최단거리 anchor 방식을 폐기하고 다음 2단계로 재작성:
/// 1) Python geometric 파이프라인 산출물(<prefix>_connections.json)의 유향 엣지 적용
/// — ▶ 마커 V자방향(좌→우)으로 from_tag/to_tag 방향성까지 부여
/// 2) JSON 없거나 미커버 레코드: pipings line_number 체인(PosX 오름차순=하류)으로 보강
/// geometric V검출은 ezdxf 보유한 Python에 잔류, C#은 적용·영속만 담당(기존 아키텍처와 일치).
/// </summary>
public async Task<int> AnalyzeConnectionsAsync(string drawingFile)
{
var items = await _dbContext.PidEquipment
.Where(e => e.DrawingFile == drawingFile && e.IsActive)
.ToListAsync();
if (items.Count == 0) return 0;
// 재실행 결정성: 기존 연결 초기화 후 재계산.
// 단, 엑셀 import 로 사람이 교정한 행(ConnectionLocked)은 보존.
foreach (var it in items.Where(i => !i.ConnectionLocked))
{ it.FromTag = null; it.ToTag = null; }
// TagNo → 레코드(동일 태그 다중 위치 가능) 인덱스
var byTag = items
.Where(i => !string.IsNullOrWhiteSpace(i.TagNo))
.GroupBy(i => i.TagNo.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
int edgeApplied = 0;
var touched = new HashSet<long>();
// ── 1. Python 유향 엣지 JSON 적용 ──────────────────────────────
var edges = LoadDirectedEdges(drawingFile);
foreach (var (fromTag, toTag) in edges)
{
var f = NormalizeConnTag(fromTag);
var t = NormalizeConnTag(toTag);
if (f.Length == 0 || t.Length == 0 ||
string.Equals(f, t, StringComparison.OrdinalIgnoreCase))
continue;
// from 레코드(들)의 ToTag = t, to 레코드(들)의 FromTag = f
if (byTag.TryGetValue(f, out var fromRecs))
foreach (var r in fromRecs)
if (!r.ConnectionLocked && r.ToTag == null)
{ r.ToTag = t; touched.Add(r.Id); edgeApplied++; }
if (byTag.TryGetValue(t, out var toRecs))
foreach (var r in toRecs)
if (!r.ConnectionLocked && r.FromTag == null)
{ r.FromTag = f; touched.Add(r.Id); }
}
// ── 2. pipings line_number 체인 보강 (PosX 오름차순 = 하류 방향) ──
int chainApplied = 0;
var lineGroups = items
.Where(i => !string.IsNullOrWhiteSpace(i.LineNumber)
&& i.PosX.HasValue
&& string.Equals(i.Category, PidEquipment.CategoryPipings,
StringComparison.OrdinalIgnoreCase))
.GroupBy(i => i.LineNumber!.Trim(), StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 2);
foreach (var g in lineGroups)
{
var ordered = g.OrderBy(i => i.PosX!.Value).ToList();
for (int k = 0; k < ordered.Count - 1; k++)
{
var a = ordered[k];
var b = ordered[k + 1];
if (string.Equals(a.TagNo, b.TagNo, StringComparison.OrdinalIgnoreCase))
continue;
if (!a.ConnectionLocked && a.ToTag == null)
{ a.ToTag = b.TagNo; chainApplied++; }
if (!b.ConnectionLocked && b.FromTag == null)
{ b.FromTag = a.TagNo; }
}
}
await _dbContext.SaveChangesAsync();
int connectionCount = touched.Count + chainApplied;
_logger.LogInformation(
"[PID 분석] 연결 완료: 총 {Count}개 (JSON엣지 {Edge}건/{Touched}레코드, line_number체인 {Chain}건, drawing={File})",
connectionCount, edgeApplied, touched.Count, chainApplied, drawingFile);
return connectionCount;
}
/// <summary><prefix>_connections.json 의 edges[] → (from,to) 목록. 없으면 빈 목록.</summary>
private List<(string From, string To)> LoadDirectedEdges(string drawingFile)
{
var result = new List<(string, string)>();
var prefix = drawingFile.Split('_', '.').FirstOrDefault() ?? drawingFile;
var fileName = $"{prefix}_connections.json";
string? path = _connJsonRoots
.Select(r => Path.Combine(r, fileName))
.FirstOrDefault(File.Exists);
if (path == null)
{
_logger.LogInformation("[PID 분석] 연결 JSON 없음 (prefix={Prefix}) — line_number 체인만 사용", prefix);
return result;
}
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty("edges", out var edgesEl)
&& edgesEl.ValueKind == JsonValueKind.Array)
{
foreach (var e in edgesEl.EnumerateArray())
{
var from = e.TryGetProperty("from", out var fe) ? fe.GetString() : null;
var to = e.TryGetProperty("to", out var te) ? te.GetString() : null;
if (!string.IsNullOrWhiteSpace(from) && !string.IsNullOrWhiteSpace(to))
result.Add((from!, to!));
}
}
_logger.LogInformation("[PID 분석] 연결 JSON 로드: {Path} ({Count} 엣지)", path, result.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[PID 분석] 연결 JSON 파싱 실패: {Path}", path);
}
return result;
}
// 유효 P&ID 태그 패턴 (예: C-10111, FCV-10101). VLINE 플레이스홀더·한글
// 시설명·짧은 TEXT 조각(마커 태그 오할당 노이즈)을 from/to 에서 배제.
private static readonly Regex _validTagRe =
new(@"^[A-Za-z]{1,6}-\d{2,6}[A-Za-z]?$", RegexOptions.Compiled);
/// <summary>"P-10101@2050,5289" → "P-10101". 좌표 접미사/공백 제거 후 유효성 검사. 무효면 빈 문자열.</summary>
private static string NormalizeConnTag(string tag)
{
if (string.IsNullOrWhiteSpace(tag)) return string.Empty;
var at = tag.IndexOf('@');
var bare = (at >= 0 ? tag[..at] : tag).Trim();
return _validTagRe.IsMatch(bare) ? bare : string.Empty;
}
}
// ── 내부 파싱용 모델 ──────────────────────────────────────────────────────────
public 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;
}
public class MappingItem
{
public string PidTag { get; set; } = "";
public string? ExperionTag { get; set; }
public double Confidence { get; set; }
}