Files
ExperionCrawler/PID-추출-개선안1.md
windpacer 302183c97e feat: P&ID 연결 분석, LLM 에이전트 모드, KB 확장, MCP 서버 리팩토링
- P&ID: 연결 분석 API, Prefix 규칙 관리, 카테고리 분류, DXF 그래프 빌드
- LLM: 대화 요약, tool card 영구 보존, 시계열 차트(uPlot), 에이전트 모드
- KB: 청크 미리보기, Field Instrument Inference, 인증/Qdrant 클라이언트
- MCP: 서버 기능 확장, 파이프라인 수정, timeout 개선
- Frontend: P&ID UI, LLM UI, KB UI, OPC UA Write 탭 추가
- 설정: AGENTS.md, plant_context, README, opencode.json 업데이트
- 정리: 진단 체크리스트 문서 삭제
2026-05-21 23:36:57 +09:00

47 KiB

P&ID 추출 개선안 v1 — 카테고리 분류 시스템

1. 개요

1.1 목적

DXF/PDF P&ID 도면에서 추출한 장비/계기 태그를 5가지 비즈니스 카테고리로 분류하여:

  • 사용자 검토: Excel 다운로드 시 카테고리 컬럼 포함 → 검토/정제 용이
  • RAG 인덱싱: 카테고리별 KB 업로드, 태그 필터, 컬렉션 분리 기반 마련
  • Prefix 사전 사용자 정의: 도면 작성자/플랜트별 prefix 규칙을 UI에서 직접 설정

1.2 분류 체계

카테고리 설명 예시 태그
instrument 계측/제어 기기 FT-101, TT-201, FCV-101, PSV-6203, XV-101
power_equipment 동력/회전 기기 P-10101, C-6111, K-901A, F-10900
storage_equipment 저장 설비 T-10100, D-6113, V-10101
process_equipment 공정 설비 E-10103, H-10901, R-101, COL-101, S-101
utility_equipment 유틸리티 설비 CWR-10621, CWS-10600, IA-10900, ST-10511, WW-10191

1.3 아키텍처

사용자: Prefix 규칙 정의 (UI)
                    ↓
        pid_prefix_rules 테이블 저장
                    ↓
DXF 업로드 → 추출 실행 → prefix 매칭 → category 결정
                                         ↓
                              pid_equipment.category 저장
                                         ↓
                              Excel export (category 컬럼 포함)
                                         ↓
                              사용자 검토/정제
                                         ↓
                              KB 업로드 → RAG 인덱싱

2. DB 변경사항

2.1 pid_equipment — 컬럼 추가

ALTER TABLE pid_equipment
    ADD COLUMN category   TEXT,
    ADD COLUMN role       TEXT,
    ADD COLUMN from_tag   TEXT,
    ADD COLUMN to_tag     TEXT;
CREATE INDEX IF NOT EXISTS idx_pid_equipment_category
    ON pid_equipment(category);
컬럼 타입 설명 입력 주체
category TEXT 5개 카테고리 중 하나 추출 시 자동 (prefix 매칭)
role TEXT 해당 장비의 역할 (예: Cooling Water Supply, Process Feed) 사용자가 Excel에서 작성
from_tag TEXT 연결 원천 태그 (예: T-101, P-10101) 사용자가 Excel에서 작성
to_tag TEXT 연결 대상 태그 (예: E-10103, C-6111) 사용자가 Excel에서 작성

role, from_tag, to_tag는 추출 시점에는 빈 값이며, 사용자가 Excel을 다운로드하여 검토/보완한 후 KB에 업로드할 때 RAG가 인덱싱합니다.

2.2 pid_prefix_rules — 신규 테이블

CREATE TABLE IF NOT EXISTS pid_prefix_rules (
    id          SERIAL PRIMARY KEY,
    prefix      TEXT NOT NULL UNIQUE,
    category    TEXT NOT NULL,
    description TEXT,
    sort_order  INT NOT NULL DEFAULT 0,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_pid_prefix_rules_category
    ON pid_prefix_rules(category);

2.3 기본 시드 데이터

INSERT INTO pid_prefix_rules (prefix, category, description, sort_order) VALUES
    -- Instrument (계측/제어)
    ('FT',  'instrument',        'Flow Transmitter',              10),
    ('TT',  'instrument',        'Temperature Transmitter',       10),
    ('PT',  'instrument',        'Pressure Transmitter',          10),
    ('LT',  'instrument',        'Level Transmitter',             10),
    ('FCV', 'instrument',        'Flow Control Valve',            10),
    ('PCV', 'instrument',        'Pressure Control Valve',        10),
    ('LCV', 'instrument',        'Level Control Valve',           10),
    ('TCV', 'instrument',        'Temperature Control Valve',     10),
    ('PSV', 'instrument',        'Pressure Safety Valve',         10),
    ('XV',  'instrument',        'Shut-off Valve',                10),
    ('FIC', 'instrument',        'Flow Indicator Controller',     10),
    ('TIC', 'instrument',        'Temperature Indicator Controller', 10),
    ('PIC', 'instrument',        'Pressure Indicator Controller', 10),
    ('LIC', 'instrument',        'Level Indicator Controller',    10),
    ('DP',  'instrument',        'Differential Pressure',         10),
    ('FV',  'instrument',        'Flow Valve',                    10),
    ('TV',  'instrument',        'Temperature Valve',             10),
    ('PV',  'instrument',        'Pressure Valve',                10),
    ('LV',  'instrument',        'Level Valve',                   10),
    ('FG',  'instrument',        'Flow Gauge',                    10),
    ('TG',  'instrument',        'Temperature Gauge',             10),
    ('PG',  'instrument',        'Pressure Gauge',                10),
    ('LG',  'instrument',        'Level Gauge',                   10),
    ('FY',  'instrument',        'Flow Relay/Converter',          10),
    ('TY',  'instrument',        'Temperature Relay/Converter',   10),
    ('PY',  'instrument',        'Pressure Relay/Converter',      10),
    ('LY',  'instrument',        'Level Relay/Converter',         10),
    ('BV',  'instrument',        'Ball/Butterfly Valve',          10),
    ('VIP', 'instrument',        'Vibration Probe',               10),
    ('VIT', 'instrument',        'Vibration Transmitter',         10),

    -- Power Equipment (동력/회전)
    ('P-',  'power_equipment',   'Pump',                          20),
    ('C-',  'power_equipment',   'Compressor',                    20),
    ('K-',  'power_equipment',   'Agitator/Mixer',                20),
    ('F-',  'power_equipment',   'Fan/Blower',                    20),
    ('M-',  'power_equipment',   'Motor',                         20),

    -- Storage Equipment (저장)
    ('T-',  'storage_equipment', 'Tank',                          30),
    ('D-',  'storage_equipment', 'Drum',                          30),
    ('V-',  'storage_equipment', 'Vessel',                        30),
    ('TK-', 'storage_equipment', 'Tank (alt)',                    30),

    -- Process Equipment (공정)
    ('E-',  'process_equipment', 'Heat Exchanger',                40),
    ('H-',  'process_equipment', 'Heater/Furnace',                40),
    ('R-',  'process_equipment', 'Reactor',                       40),
    ('COL-','process_equipment', 'Column',                        40),
    ('S-',  'process_equipment', 'Separator',                     40),
    ('FIL-','process_equipment', 'Filter',                        40),
    ('SP-', 'process_equipment', 'Separator',                     40),

    -- Utility Equipment (유틸리티)
    ('CWR-','utility_equipment', 'Cooling Water Return',          50),
    ('CWS-','utility_equipment', 'Cooling Water Supply',          50),
    ('IA-', 'utility_equipment', 'Instrument Air',                50),
    ('ST-', 'utility_equipment', 'Steam',                         50),
    ('WW-', 'utility_equipment', 'Waste Water',                   50),
    ('PW-', 'utility_equipment', 'Process/Potable Water',         50),
    ('SW-', 'utility_equipment', 'Service Water',                 50),
    ('VG-', 'utility_equipment', 'Vent Gas',                      50),
    ('CD-', 'utility_equipment', 'Condensate',                    50),
    ('SAM-','utility_equipment', 'Sample Connection',             50),
    ('CHR-','utility_equipment', 'Chemical Return',               50),
    ('CHS-','utility_equipment', 'Chemical Supply',               50),
    ('CH-', 'utility_equipment', 'Chemical',                      50),
    ('NG-', 'utility_equipment', 'Natural Gas',                   50),
    ('BD-', 'utility_equipment', 'Blowdown',                      50),
    ('FL-', 'utility_equipment', 'Flare',                         50),
    ('LO-', 'utility_equipment', 'Lube Oil',                      50)
ON CONFLICT (prefix) DO NOTHING;

주의: ST-(Steam)는 S-(Separator)보다 prefix 길이가 길어 우선 매칭됩니다. SAM-(Sample)도 S-보다 길어 정상 매칭됩니다.

2.4 매칭 로직

prefix 길이 내림차순 정렬 후 tag_no가 prefix로 시작하는 첫 번째 규칙을 적용:

tag_no = "FT-101"   → "FT" matches (길이 2)  → category = instrument
tag_no = "P-10101"  → "P-" matches (길이 2)   → category = power_equipment
tag_no = "PSV-6203" → "PSV" matches (길이 3)  → category = instrument  ("P-"보다 PSV가 우선)
tag_no = "FCV-101"  → "FCV" matches (길이 3)  → category = instrument
tag_no = "COL-101"  → "COL-" matches (길이 4) → category = process_equipment
tag_no = "C-6111"   → "C-" matches (길이 2)   → category = power_equipment
tag_no = "E-10103"  → "E-" matches (길이 2)   → category = process_equipment
tag_no = "T-10100"  → "T-" matches (길이 2)   → category = storage_equipment
tag_no = "CWR-10621" → "CWR-" matches (길이 4) → category = utility_equipment
tag_no = "IA-10900"  → "IA-" matches (길이 3)  → category = utility_equipment
tag_no = "ST-10511"  → "ST-" matches (길이 3)  → category = utility_equipment ("S-"보다 ST-가 우선)
tag_no = "AX-9999"   → no match               → category = NULL

3. 백엔드 구현

3.1 Entity — PidEquipment.cs (변경)

PidEquipment.csCategory 속성 추가 (기존 52행 → 58행):

// PidEquipment.cs (기존 속성 아래에 추가)
public const string CategoryInstrument       = "instrument";
public const string CategoryPowerEquipment   = "power_equipment";
public const string CategoryStorageEquipment = "storage_equipment";
public const string CategoryProcessEquipment = "process_equipment";
public const string CategoryUtilityEquipment = "utility_equipment";

public static readonly string[] AllCategories =
    [CategoryInstrument, CategoryPowerEquipment, CategoryStorageEquipment,
     CategoryProcessEquipment, CategoryUtilityEquipment];

[MaxLength(30)]
[Column("category")]
public string? Category { get; set; }

[MaxLength(100)]
[Column("role")]
public string? Role { get; set; }

[MaxLength(50)]
[Column("from_tag")]
public string? FromTag { get; set; }

[MaxLength(50)]
[Column("to_tag")]
public string? ToTag { get; set; }

3.2 Entity — PidPrefixRule.cs (신규)

src/Core/Domain/Entities/PidPrefixRule.cs:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExperionCrawler.Core.Domain.Entities;

[Table("pid_prefix_rules")]
public class PidPrefixRule
{
    [Column("id")]
    public int Id { get; set; }

    [Required]
    [MaxLength(30)]
    [Column("prefix")]
    public string Prefix { get; set; } = string.Empty;

    [Required]
    [MaxLength(30)]
    [Column("category")]
    public string Category { get; set; } = string.Empty;

    [MaxLength(200)]
    [Column("description")]
    public string? Description { get; set; }

    [Column("sort_order")]
    public int SortOrder { get; set; }

    [Column("created_at")]
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    [Column("updated_at")]
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

3.3 DTO — PidEquipmentDto.cs (변경)

public record PidEquipmentDto(
    long Id,
    string TagNo,
    string? EquipmentName,
    string? InstrumentType,
    string? LineNumber,
    string? PidDrawingNo,
    double Confidence,
    bool IsActive,
    DateTime ExtractedAt,
    DateTime? UpdatedAt,
    int? ExperionTagId,
    string? ExperionTagName,
    string? Category,
    string? Role,
    string? FromTag,
    string? ToTag);

3.4 DTO — PidPrefixRuleDto.cs (신규)

src/Core/Application/DTOs/PidPrefixRuleDto.cs:

namespace ExperionCrawler.Core.Application.DTOs;

public record PidPrefixRuleDto(
    int Id,
    string Prefix,
    string Category,
    string? Description,
    int SortOrder,
    DateTime CreatedAt);

public record CreatePidPrefixRuleRequest(
    string Prefix,
    string Category,
    string? Description,
    int SortOrder = 0);

public record UpdatePidPrefixRuleRequest(
    string Prefix,
    string Category,
    string? Description,
    int SortOrder = 0);

3.5 Interface — IPidExtractorService.cs (변경)

public interface IPidExtractorService
{
    // 기존 메서드 유지 ...

    // Prefix 규칙
    Task<List<PidPrefixRule>> GetPrefixRulesAsync();
    Task<PidPrefixRule> CreatePrefixRuleAsync(CreatePidPrefixRuleRequest request);
    Task<PidPrefixRule?> UpdatePrefixRuleAsync(int id, UpdatePidPrefixRuleRequest request);
    Task<bool> DeletePrefixRuleAsync(int id);

    // 카테고리 적용
    Task<int> ApplyCategoriesToExistingAsync(); // 기존 데이터 재적용
}

3.6 Service — PidExtractorService.cs (변경)

3.6.1 캐시된 규칙 + 카테고리 매칭 로직 추가:

// PidExtractorService.cs — 캐시 필드
private List<PidPrefixRule>? _cachedRules;
private readonly SemaphoreSlim _cacheLock = new(1, 1);

private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
{
    if (_cachedRules != null) return _cachedRules;
    await _cacheLock.WaitAsync();
    try
    {
        if (_cachedRules != null) return _cachedRules; // double-check
        _cachedRules = await _dbContext.PidPrefixRules
            .OrderByDescending(r => r.Prefix.Length)
            .ThenBy(r => r.SortOrder)
            .ToListAsync();
        return _cachedRules;
    }
    finally
    {
        _cacheLock.Release();
    }
}

private void InvalidateRulesCache()
{
    Interlocked.Exchange(ref _cachedRules, null);
}

// PidExtractorService.cs — 매칭 메서드
private async Task<string?> MatchCategoryAsync(string tagNo)
{
    var rules = await GetRulesCachedAsync();
    return rules.FirstOrDefault(r =>
        tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category;
}

3.6.2 ExtractFromStreamAsync — category 적용 (기존 67-86행 사이):

// DB 저장 (기존 코드)
var dbItems = new List<PidEquipment>();
foreach (var item in extractedItems)
{
    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);

    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,
        Category = category,         // ← 추가
        ExtractedAt = DateTime.UtcNow,
        UpdatedAt = DateTime.UtcNow
    });
}

3.6.3 PrefixRule 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,
        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.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()
{
    var items = await _dbContext.PidEquipment
        .Where(e => e.Category == null)
        .ToListAsync();

    int count = 0;
    foreach (var item in items)
    {
        var category = await MatchCategoryAsync(item.TagNo);
        if (category != null)
        {
            item.Category = category;
            item.UpdatedAt = DateTime.UtcNow;
            count++;
        }
    }
    await _dbContext.SaveChangesAsync();
    return count;
}

3.6.4 ExportToExcelAsync — category + role + from/to 컬럼 추가 (CPU-bound 작업은 Task.Run으로 오프로드):

public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
{
    return await Task.Run(() =>
    {
        using var package = new OfficeOpenXml.ExcelPackage();
        var worksheet = package.Workbook.Worksheets.Add("P&ID Equipment");

        // 헤더
        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";           // ← 추가

        // 헤더 스타일
        using var headerRange = worksheet.Cells[1, 1, 1, 13];
        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 items)
        {
            worksheet.Cells[row, 1].Value = item.TagNo;
            worksheet.Cells[row, 2].Value = 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 ?? "";      // ← 추가
            row++;
        }

        worksheet.Cells.AutoFitColumns();
        return package.GetAsByteArray();
    });
}

3.6.5 ExportToCsvAsync — category 컬럼 추가:

public Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
{
    var sb = new StringBuilder();
    sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId,Category,Role,From,To");
    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)}");
    return Task.FromResult(sb.ToString());
}

3.7 DbContext — ExperionDbContext.cs (변경)

3.7.1 DbSet 추가:

public DbSet<PidPrefixRule> PidPrefixRules => Set<PidPrefixRule>();

3.7.2 OnModelCreating — PidPrefixRule 설정 추가 (기존 141행 }); 다음):

modelBuilder.Entity<PidPrefixRule>(entity =>
{
    entity.ToTable("pid_prefix_rules");
    entity.HasKey(e => e.Id);

    entity.Property(e => e.Prefix)
        .IsRequired()
        .HasMaxLength(30);

    entity.HasIndex(e => e.Prefix).IsUnique();

    entity.Property(e => e.Category)
        .IsRequired()
        .HasMaxLength(30);

    entity.Property(e => e.Description)
        .HasMaxLength(200);

    entity.Property(e => e.SortOrder)
        .HasDefaultValue(0);

    entity.Property(e => e.CreatedAt)
        .HasDefaultValueSql("NOW()");

    entity.Property(e => e.UpdatedAt)
        .HasDefaultValueSql("NOW()");

    entity.HasIndex(e => e.Category);
});

3.7.3 PidEquipment — Category + Role + From/To Fluent 설정 추가 (기존 125행 }); 전):

entity.Property(e => e.Category)
    .HasMaxLength(30);
entity.Property(e => e.Role)
    .HasMaxLength(100);
entity.Property(e => e.FromTag)
    .HasMaxLength(50);
entity.Property(e => e.ToTag)
    .HasMaxLength(50);
entity.HasIndex(e => e.Category);

3.7.4 EnsureCreatedAsync — DDL 추가 (기존 482행 CREATE INDEX 블록 뒤):

// pid_equipment 컬럼 추가 (migration 없이)
await _ctx.Database.ExecuteSqlRawAsync("""
    DO $$
    BEGIN
        IF NOT EXISTS (
            SELECT 1 FROM information_schema.columns
            WHERE table_name='pid_equipment' AND column_name='category'
        ) THEN
            ALTER TABLE pid_equipment
                ADD COLUMN category   TEXT,
                ADD COLUMN role       TEXT,
                ADD COLUMN from_tag   TEXT,
                ADD COLUMN to_tag     TEXT;
            CREATE INDEX IF NOT EXISTS idx_pid_equipment_category
                ON pid_equipment(category);
        END IF;
    END $$;
    """);

// pid_prefix_rules 테이블 (UNIQUE 제약으로 중복 방지)
await _ctx.Database.ExecuteSqlRawAsync("""
    CREATE TABLE IF NOT EXISTS pid_prefix_rules (
        id          SERIAL PRIMARY KEY,
        prefix      TEXT NOT NULL UNIQUE,
        category    TEXT NOT NULL,
        description TEXT,
        sort_order  INT NOT NULL DEFAULT 0,
        created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    CREATE INDEX IF NOT EXISTS idx_pid_prefix_rules_category
        ON pid_prefix_rules(category);
    """);

// 시드 데이터 (기존 시드 블록 안에 추가)
await _ctx.Database.ExecuteSqlRawAsync("""
    INSERT INTO pid_prefix_rules (prefix, category, description, sort_order) VALUES
        ('FT',  'instrument',        'Flow Transmitter',              10),
        ('TT',  'instrument',        'Temperature Transmitter',       10),
        ...
        ('FIL-','process_equipment', 'Filter',                        40),
        ('SP-', 'process_equipment', 'Separator',                     40),

    -- Utility Equipment
    ('CWR-','utility_equipment', 'Cooling Water Return',          50),
    ('CWS-','utility_equipment', 'Cooling Water Supply',          50),
    ('IA-', 'utility_equipment', 'Instrument Air',                50),
    ('ST-', 'utility_equipment', 'Steam',                         50),
    ('WW-', 'utility_equipment', 'Waste Water',                   50),
    ('PW-', 'utility_equipment', 'Process/Potable Water',         50),
    ('SW-', 'utility_equipment', 'Service Water',                 50),
    ('VG-', 'utility_equipment', 'Vent Gas',                      50),
    ('CD-', 'utility_equipment', 'Condensate',                    50),
    ('SAM-','utility_equipment', 'Sample Connection',             50),
    ('CHR-','utility_equipment', 'Chemical Return',               50),
    ('CHS-','utility_equipment', 'Chemical Supply',               50),
    ('CH-', 'utility_equipment', 'Chemical',                      50),
    ('NG-', 'utility_equipment', 'Natural Gas',                   50),
    ('BD-', 'utility_equipment', 'Blowdown',                      50),
    ('FL-', 'utility_equipment', 'Flare',                         50),
    ('LO-', 'utility_equipment', 'Lube Oil',                      50)
    ON CONFLICT (prefix) DO NOTHING;
    """);

3.8 Controller — PidController.cs (prefix-rule endpoints 추가)

// ── Prefix 규칙 CRUD ──────────────────────────────────────────────────

[HttpGet("prefix-rules")]
public async Task<IActionResult> GetPrefixRules()
{
    var rules = await _pidExtractor.GetPrefixRulesAsync();
    return Ok(new
    {
        items = rules.Select(r => new
        {
            id = r.Id,
            prefix = r.Prefix,
            category = r.Category,
            description = r.Description,
            sortOrder = r.SortOrder,
            createdAt = r.CreatedAt
        })
    });
}

[HttpPost("prefix-rules")]
public async Task<IActionResult> CreatePrefixRule([FromBody] CreatePidPrefixRuleRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Prefix))
        return BadRequest(new { error = "prefix는 필수입니다." });
    if (!PidEquipment.AllCategories.Contains(request.Category))
        return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });

    var rule = await _pidExtractor.CreatePrefixRuleAsync(request);
    return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
}

[HttpPut("prefix-rules/{id}")]
public async Task<IActionResult> UpdatePrefixRule(int id, [FromBody] UpdatePidPrefixRuleRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Prefix))
        return BadRequest(new { error = "prefix는 필수입니다." });
    if (!PidEquipment.AllCategories.Contains(request.Category))
        return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });

    var rule = await _pidExtractor.UpdatePrefixRuleAsync(id, request);
    if (rule == null) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
    return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
}

[HttpDelete("prefix-rules/{id}")]
public async Task<IActionResult> DeletePrefixRule(int id)
{
    var ok = await _pidExtractor.DeletePrefixRuleAsync(id);
    if (!ok) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
    return NoContent();
}

[HttpPost("apply-categories")]
public async Task<IActionResult> ApplyCategories()
{
    var count = await _pidExtractor.ApplyCategoriesToExistingAsync();
    return Ok(new { applied = count });
}

3.9 Controller — PidController.cs equipment 응답에 category 추가

// GetEquipment 메서드의 DTO 생성 부분
var equipmentDtos = itemList.Select(e => new
{
    id = e.Id,
    tagName = e.TagNo,
    equipmentName = e.EquipmentName,
    instrumentType = e.InstrumentType,
    lineNumber = e.LineNumber,
    pidDrawingNo = e.PidDrawingNo,
    confidence = e.Confidence,
    isActive = e.IsActive,
    extractedAt = e.ExtractedAt,
    updatedAt = e.UpdatedAt,
    experionTagId = e.ExperionTagId,
    experionTagName = e.ExperionTag?.TagName,
    category = e.Category,
    role = e.Role,
    fromTag = e.FromTag,
    toTag = e.ToTag
});

4. 프론트엔드 구현

4.1 index.html — Prefix 분류 정의 패널 (카테고리별 그룹화)

P&ID 탭 내, "② 서버 파일 선택 → 추출" 카드 아래에 추가. 단일 테이블 대신 카테고리별로 그룹화된 구조로 변경하여 각 카테고리의 prefix 정의를 한눈에 확인할 수 있습니다.

<!-- Prefix 분류 정의 (접이식) -->
<div class="card" style="margin-bottom:12px">
  <div class="card-cap" onclick="pidTogglePrefixPanel()" style="cursor:pointer;user-select:none">
    <span>Prefix 분류 정의 <span id="pid-prefix-toggle"></span></span>
    <span style="font-weight:400;font-size:12px;color:var(--t2)">
      태그 prefix 기준으로 카테고리 자동 분류
    </span>
  </div>
  <div id="pid-prefix-panel" style="display:none;padding-top:8px">
    <!-- 카테고리별 그룹 (JS 동적 렌더링) -->
    <div id="pid-prefix-groups">
      <div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>
    </div>
    <!-- 공통 버튼 -->
    <div style="margin-top:8px;display:flex;gap:4px;flex-wrap:wrap;align-items:center">
      <button class="btn-sm btn-b" onclick="pidRefreshPrefixRules()">새로고침</button>
      <button class="btn-sm btn-b" onclick="pidApplyCategories()" title="기존 미분류 항목에 category 재적용">재적용</button>
    </div>
  </div>
</div>

UI 구조:

  • #pid-prefix-groups — JS가 카테고리별로 .pid-cat-group 섹션을 동적 생성
  • 각 그룹: 헤더(카테고리 badge + 규칙 수 + inline 추가 폼) + 본문(prefix 목록)
  • 카테고리별 추가 폼은 해당 그룹 헤더에 내장 (카테고리 선택 드롭다운 불필요)

4.2 app.js — Prefix 규칙 함수 (카테고리별 그룹 렌더링)

prefix 패널 토글:

let pidPrefixPanelVisible = false;
function pidTogglePrefixPanel() {
    const panel = document.getElementById('pid-prefix-panel');
    const toggle = document.getElementById('pid-prefix-toggle');
    pidPrefixPanelVisible = !pidPrefixPanelVisible;
    if (panel) panel.style.display = pidPrefixPanelVisible ? 'block' : 'none';
    if (toggle) toggle.textContent = pidPrefixPanelVisible ? '▲' : '▼';
    if (pidPrefixPanelVisible) pidRefreshPrefixRules();
}

카테고리 메타데이터 + 정렬 순서:

const CATEGORY_META = {
    instrument:          { label: 'Instrument',         badge: 'ok' },
    power_equipment:     { label: 'Power Equipment',    badge: 'warn' },
    storage_equipment:   { label: 'Storage Equipment',  badge: 'inf' },
    process_equipment:   { label: 'Process Equipment',  badge: '' },
    utility_equipment:   { label: 'Utility Equipment',  badge: 'warn' }
};
const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment'];

규칙 목록 조회 — 카테고리별 그룹 렌더링:

async function pidRefreshPrefixRules() {
    const container = document.getElementById('pid-prefix-groups');
    if (!container) return;
    container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>';

    try {
        const res = await fetch('/api/pid/prefix-rules');
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        const items = data.items || [];

        if (items.length === 0) {
            container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">규칙이 없습니다. 아래에서 추가하세요.</div>';
            return;
        }

        // 카테고리별 그룹화
        const grouped = {};
        for (const r of items) {
            if (!grouped[r.category]) grouped[r.category] = [];
            grouped[r.category].push(r);
        }

        let html = '';
        for (const cat of CATEGORY_ORDER) {
            const rules = grouped[cat];
            if (!rules) continue;
            const meta = CATEGORY_META[cat] || { label: cat, badge: '' };
            rules.sort((a, b) => a.sortOrder - b.sortOrder);

            html += `<div class="pid-cat-group">
                <div class="pid-cat-header">
                    <span class="badge ${meta.badge}">${meta.label}</span>
                    <span class="pid-cat-count">${rules.length}</span>
                    <span class="pid-cat-add" style="margin-left:auto">
                        <input class="inp pid-cat-prefix-input" placeholder="예: FT" style="width:90px;font-family:var(--mono)" />
                        <input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
                        <input class="inp pid-cat-order-input" type="number" value="10" style="width:50px" />
                        <button class="btn-a btn-sm" onclick="pidAddPrefixRule('${cat}')">추가</button>
                    </span>
                </div>
                <div class="pid-cat-body">`;

            for (const r of rules) {
                html += `<div class="pid-cat-row">
                    <span class="pid-cat-prefix"><strong style="font-family:var(--mono)">${esc(r.prefix)}</strong></span>
                    <span class="pid-cat-desc" style="color:var(--t2)">${esc(r.description) || '-'}</span>
                    <span class="pid-cat-order">${r.sortOrder}</span>
                    <span class="pid-cat-actions">
                        <button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
                    </span>
                </div>`;
            }

            html += `</div></div>`;
        }

        container.innerHTML = html;
    } catch (e) {
        container.innerHTML = `<div style="text-align:center;padding:12px;color:var(--red)">오류: ${e.message}</div>`;
    }
}

규칙 추가 — 카테고리 매개변수 + event.target.closest:

async function pidAddPrefixRule(category) {
    const row = event.target.closest('.pid-cat-add');
    const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
    const desc = row.querySelector('.pid-cat-desc-input').value.trim();
    const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;

    if (!prefix) { alert('Prefix를 입력하세요.'); return; }

    try {
        const res = await fetch('/api/pid/prefix-rules', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ prefix, category, description: desc, sortOrder: order })
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ error: res.statusText }));
            throw new Error(err.error || res.statusText);
        }
        row.querySelector('.pid-cat-prefix-input').value = '';
        row.querySelector('.pid-cat-desc-input').value = '';
        await pidRefreshPrefixRules();
    } catch (e) {
        alert(`추가 실패: ${e.message}`);
    }
}

규칙 삭제:

async function pidDeletePrefixRule(id, prefix) {
    if (!confirm(`"${prefix}" 규칙을 삭제하시겠습니까?`)) return;
    try {
        const res = await fetch(`/api/pid/prefix-rules/${id}`, { method: 'DELETE' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        await pidRefreshPrefixRules();
    } catch (e) {
        alert(`삭제 실패: ${e.message}`);
    }
}

기존 데이터 category 재적용:

async function pidApplyCategories() {
    if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
    try {
        const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        alert(`✅ ${data.applied}건 category 적용 완료`);
        await pidLoadTable(pidCurrentPage);
        pidUpdateStats();
    } catch (e) {
        alert(`재적용 실패: ${e.message}`);
    }
}

테이블 렌더링 — category 컬럼 추가:

// pidLoadTable 함수의 tbody.innerHTML 부분 — category 컬럼 추가
tbody.innerHTML = data.items.map(item => `
    <tr>
        <td>${item.id}</td>
        <td><strong>${esc(item.tagName)}</strong></td>
        <td>${esc(item.equipmentName) || '-'}</td>
        <td>${esc(item.instrumentType) || '-'}</td>
        <td>${esc(item.lineNumber) || '-'}</td>
        <td>${esc(item.pidDrawingNo) || '-'}</td>
        <td style="text-align:center">${(item.confidence * 100).toFixed(1)}%</td>
        <td style="text-align:center">
            <span class="badge ${item.isActive ? 'ok' : 'warn'}">${item.isActive ? '활성' : '비활성'}</span>
        </td>
        <td>
            ${item.experionTagId
                ? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
                : `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
            }
        </td>
        <td>${item.category
            ? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
            : '-'}
        </td>
    </tr>
`).join('');

4.3 index.html — 테이블 헤더에 category 컬럼 추가

<thead>
    <tr>
        <th style="width:60px">ID</th>
        <th>태그번호</th>
        <th>장비명</th>
        <th>유형</th>
        <th>라인번호</th>
        <th>도면번호</th>
        <th style="width:80px">신뢰도</th>
        <th style="width:80px">상태</th>
        <th style="width:120px">매핑</th>
        <th style="width:120px">카테고리</th>   <!-- ← 추가 -->
    </tr>
</thead>

4.4 style.css — 카테고리 그룹 스타일 추가

/* .badge.inf — Storage Equipment 파란색 */
#pane-pid .badge.inf {
  background: rgba(59,130,246,.15);
  color: var(--blu);
}

/* 카테고리별 그룹 컨테이너 */
#pane-pid .pid-cat-group {
  margin-bottom: 8px;
  border: 1px solid var(--bd);
  border-radius: var(--r);
  overflow: hidden;
}

/* 그룹 헤더: badge + 규칙 수 + inline 추가 폼 */
#pane-pid .pid-cat-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: var(--s2);
  border-bottom: 1px solid var(--bd);
  font-size: 13px;
}

#pane-pid .pid-cat-count {
  font-size: 11px;
  color: var(--t2);
}

#pane-pid .pid-cat-add {
  display: flex;
  align-items: center;
  gap: 4px;
}

/* 그룹 본문 */
#pane-pid .pid-cat-body {
  padding: 0;
}

/* 개별 prefix 행 */
#pane-pid .pid-cat-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 10px;
  font-size: 12px;
  border-bottom: 1px solid var(--bd);
}

#pane-pid .pid-cat-row:last-child {
  border-bottom: none;
}

#pane-pid .pid-cat-prefix {
  width: 80px;
  flex-shrink: 0;
}

#pane-pid .pid-cat-desc {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#pane-pid .pid-cat-order {
  width: 40px;
  text-align: center;
  flex-shrink: 0;
  color: var(--t2);
}

#pane-pid .pid-cat-actions {
  width: 50px;
  text-align: center;
  flex-shrink: 0;
}

5. 전체 변경 파일 목록

파일 변경 유형 설명
src/Core/Domain/Entities/PidEquipment.cs 수정 Category(5종 상수) + Role/FromTag/ToTag 속성
src/Core/Domain/Entities/PidPrefixRule.cs 신규 prefix 규칙 엔티티
src/Core/Application/DTOs/PidEquipmentDto.cs 수정 Category + Role/FromTag/ToTag 필드 추가
src/Core/Application/DTOs/PidPrefixRuleDto.cs 신규 prefix 규칙 DTO 3종
src/Core/Application/Interfaces/IExperionServices.cs 수정 IPidExtractorService에 prefix 규칙 메서드 추가
src/Core/Application/Services/PidExtractorService.cs 수정 prefix 매칭, CRUD, export (category+role+from/to)
src/Infrastructure/Database/ExperionDbContext.cs 수정 DbSet, Fluent API, DDL 추가
src/Web/Controllers/PidController.cs 수정 prefix-rule CRUD + apply-categories 엔드포인트
src/Web/wwwroot/index.html 수정 prefix 패널 + 테이블 category 컬럼
src/Web/wwwroot/js/app.js 수정 prefix CRUD 함수 + 테이블 category 렌더링

6. RAG 인덱싱 연계 방안 (Phase 2)

6.1 Excel 업로드 시 category 활용

사용자가 검토/정제한 Excel을 KB에 업로드할 때:

POST /api/kb/upload
  file: pid-equipment-2026-05-16_clean.xlsx
  collectionKey: system_instrument
  title: "9차플랜트 P&ID 추출"
  tags: "pid, 9차플랜트"

system_instrument 컬렉션의 chunking_policy{"xlsx":"row+sheet"}로, XLSX 파서가 시트별/행별로 청크를 생성하고 각 청크에 sheet/row locator가 포함됩니다.

6.2 KB 검색 시 category 필터

Qdrant point의 tags payload에 category를 저장하면:

// search_kb MCP 도구 호출 시
{
  query: "FT-101",
  tags: ["instrument"],        // ← category를 tags로 활용
  collection_keys: ["system_instrument"]
}

6.3 향후 확장: category별 컬렉션 분리 (선택)

대량 데이터가 쌓이면 category별로 Qdrant 컬렉션을 분리:

  • kb_pid_instrument
  • kb_pid_power_equipment
  • kb_pid_storage_equipment
  • kb_pid_process_equipment
  • kb_pid_utility_equipment

→ Excel 업로드 시 category에 따라 다른 컬렉션으로 자동 분기

6.4 Role / From / To 활용 방안

Excel의 각 컬럼이 RAG에서 인덱싱될 때:

Excel 컬럼 Qdrant payload 검색 활용
태그번호 text 포함 "FT-101의 역할은?" → 청크 텍스트에서 검색
장비명 text 포함 "Cooling Water Pump 역할" 검색 가능
카테고리 tags 배열 category 필터: tags: ["power_equipment"]
Role text 포함 "Cooling Water Supply" role을 가진 장비 검색
From text 포함 "T-101에서 E-10103으로 연결" 질의 가능
To text 포함 위와 동일

XLSX 파서(xlsx_parser.py)가 각 행을 하나의 청크로 만들므로, text 필드에 전체 행 정보가 응축됨:

[9차플랜트] FT-101 | Flow Transmitter | instrument | Role: Cooling Water Flow | From: T-201 | To: E-10103 | Confidence: 0.95

7. 실행 순서

  1. DB 마이그레이션: 서버 재시작 시 ExperionDbContext.EnsureCreatedAsync()가 category 컬럼 ALTER + prefix_rules 테이블 생성
  2. 서버 재빌드: dotnet build src/Web/ExperionCrawler.csproj
  3. 서버 재시작: sudo systemctl restart experioncrawler
  4. 사용자 설정: P&ID 추출 탭 → Prefix 분류 정의 패널에서 규칙 확인/수정
  5. 기존 데이터 재적용: "재적용" 버튼으로 미분류 항목에 category 부여
  6. 재추출: 새 DXF 도면 추출 시 자동으로 category 적용
  7. Excel 다운로드: category 컬럼 포함된 Excel 다운로드 → 검토 → KB 업로드

8. 구현 적용 시 발견된 문제점 및 수정 내역

diagnosis-checklist.md 8단계 진단 + 실제 빌드/검증 과정에서 발견된 문제점을 기록합니다.

8.1 🔴 HIGH — EnsureCreatedAsync 시드 SQL 구문 오류

문제: DO $$ BEGIN ... END $$ 블록 내에서 INSERT ... VALUES (...) 사이에 SQL 주석(--)이 포함되어 PostgreSQL 구문 오류 발생.

-- ❌ 개선안 원문 (636-664행)
INSERT INTO pid_prefix_rules (...) VALUES
    ('FT',  'instrument', ...),
    ...
    ('SP-', 'process_equipment', 'Separator', 40),

    -- Utility Equipment         ← VALUES 내에서 주석 = 구문 오류
    ('CWR-','utility_equipment', ...),
    ...

ExecuteSqlRawAsync 호출 시 42601: syntax error at or near "Utility" 에러 → 서버 시작 시 DB 초기화 실패 → 시드 데이터 전량 미적용.

수정: 시드 INSERT 내 모든 -- 주석 제거. 단일 INSERT 문으로 통합 (ExperionDbContext.cs EnsureCreatedAsync 블록).

8.2 🟠 MED — GetRulesCachedAsync Race Condition

문제: double-check locking에서 early return 경로(if (_cachedRules != null) return _cachedRules;)가 lock 없이 _cachedRules 참조를 읽습니다. 읽기 후 반환 전에 InvalidateRulesCache()Interlocked.Exchange(ref _cachedRules, null)로 null 스왑하면 NRE.

// ❌ 개선안 원문
private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
{
    if (_cachedRules != null) return _cachedRules;  // ← lock 없음, 읽기-반환 사이에 null 스왑 가능
    await _cacheLock.WaitAsync();
    // ...
}

수정: early return을 로컬 변수로 복사 후 반환 (PidExtractorService.cs).

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

8.3 🟠 MED — ApplyCategoriesToExistingAsync 전체 메모리 로딩

문제: Category == NULL인 항목을 한 번에 ToListAsync()로 전체 메모리 로딩. 수만 건 시 EF Core 트래킹 + 엔티티 객체로 50-100MB, 일괄 저장 시 2-3배 증가.

// ❌ 개선안 원문
var items = await _dbContext.PidEquipment
    .Where(e => e.Category == null)
    .ToListAsync();  // ← 전체 메모리 로딩

수정: 1000건 단위 batch chunking 적용 (PidExtractorService.cs).

8.4 🟡 LOW — ExportToCsvAsync 가짜 async

문제: Task.FromResult()로 동기 결과를 랩핑하여 CPU-bound 작업이 요청 스레드에서 실행. Excel export는 Task.Run으로 오프로드하지만 CSV는 불일치.

수정: Task.Run(() => { ... })으로 감싸 Excel과 일관성 확보.

8.5 🟡 LOW — ExportToCsvAsync 컬럼 누락

문제: 개선안 3.6.5에서 CSV 헤더에 Category,Role,From,To를 추가했지만, 실제 기존 코드의 CSV 헤더는 UpdatedAtExperionTagName도 누락. 개선안 반영 시 기존 누락까지 함께 보정.

8.6 🟡 LOW — P- vs PSV 주석 오해

문제: 개선안 2.4에서 "P-"보다 PSV가 우선이라는 주석이 있지만, P-(하이픈 포함)는 PSV-6203과 전혀 충돌하지 않음. "PSV-6203".StartsWith("P-")false.

수정: 시드 데이터에서 주석 없이 정리. P-P-10101만 매칭하므로 별도 처리 불필요.

8.7 빌드 에러 — PidController.cs using 누락

문제: PidEquipment.AllCategories를 참조하지만 using ExperionCrawler.Core.Domain.Entities;가 누락되어 CS0103 빌드 에러.

수정: using 문 추가.

8.8 기존 코드와의 불일치 — export endpoint 경로

문제: 프론트엔드(/api/pid/export/csv)와 실제 컨트롤러(/api/pid/export/csvExperionPidController)가 다른 클래스에 있어 라우트 충돌 방지를 위한 분리 구조임. 개선안에서는 별도의 컨트롤러 수정 없이 기존 구조 유지.


9. 변경 파일 목록 (실제 적용)

파일 변경 유형 설명
src/Core/Domain/Entities/PidEquipment.cs 수정 Category(5종 상수) + Role/FromTag/ToTag 속성
src/Core/Domain/Entities/PidPrefixRule.cs 신규 prefix 규칙 엔티티
src/Core/Application/DTOs/PidEquipmentDto.cs 수정 Category + Role/FromTag/ToTag 필드 추가
src/Core/Application/DTOs/PidPrefixRuleDto.cs 신규 prefix 규칙 DTO 3종
src/Core/Application/Interfaces/IExperionServices.cs 수정 IPidExtractorService에 prefix 규칙 메서드 추가
src/Core/Application/Services/PidExtractorService.cs 수정 prefix 매칭, CRUD, export (category+role+from/to), Race Condition fix, batch chunking
src/Infrastructure/Database/ExperionDbContext.cs 수정 DbSet, Fluent API, DDL (SQL 주석 제거), 시드 데이터
src/Web/Controllers/PidController.cs 수정 prefix-rule CRUD + apply-categories 엔드포인트, using 추가
src/Web/wwwroot/index.html 수정 prefix 패널 카테고리별 그룹화 + 테이블 category 컬럼
src/Web/wwwroot/js/app.js 수정 카테고리별 그룹 렌더링, inline 추가 폼, CATEGORY_META
src/Web/wwwroot/css/style.css 수정 .badge.inf + .pid-cat-group 계열 스타일 추가