# P&ID Parser 분산 처리 코딩 플랜

## 📋 개요

DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.

**핵심 변경점**: 기존 단일 LLM 처리 방식에서 **분산 병렬 처리**로 전환하여 처리 시간 지연 및 버퍼 수신 문제 해결

### 🔄 아키텍처 변경 (2026-05-01)

**기존 문제점**: 단일 프로세스 내 subprocess로 순차 실행 → 실제 병렬 처리 불가

**개선 방안**:
- 5개의 **별도 프로세스**가 동시에 실행되어야 함
- 각 프로세스가 **MCP 서버에 병렬 요청**을 보내야 함
- MCP 서버는 병렬 처리를 위해 **별도의 인스턴스** 또는 **비동기 요청**으로 처리

---

## 🎯 목표

1. P&ID 도면에서 장비 정보를 추출
2. 추출된 정보를 PostgreSQL 로 저장
3. 기존 Experion 데이터와 연동
4. 웹에서 시각화 및 관리
5. **분산 처리로 처리 시간 단축 및 안정성 향상**

### 📊 성능 목표

| 지표 | 기존 | 개선 후 |
|------|------|---------|
| 처리 시간 | ~30분 | ~6-10분 |
| KV Cache 사용률 | ~100% | ~30% 이하 |
| 병렬도 | 1 | 5 (병렬) |

---

## 🎯 목표

1. P&ID 도면에서 장비 정보를 추출
2. 추출된 정보를 PostgreSQL 로 저장
3. 기존 Experion 데이터와 연동
4. 웹에서 시각화 및 관리
5. **분산 처리로 처리 시간 단축 및 안정성 향상**

---

## 📦 폴더 구조

```
ExperionCrawler/
├── src/
│   ├── Core/
│   │   ├── Application/
│   │   │   ├── Interfaces/
│   │   │   │   ├── IPidExtractorService.cs (기존)
│   │   │   │   └── ITagMappingService.cs (기존)
│   │   │   ├── Services/
│   │   │   │   ├── PidExtractorService.cs (기존)
│   │   │   │   └── TagMappingService.cs (기존)
│   │   │   └── Dtos/
│   │   │       ├── PidEquipmentDto.cs (기존)
│   │   │       └── PidExtractionResult.cs (기존)
│   │   └── Domain/
│   │       ├── Entities/
│   │       │   ├── PidEquipment.cs (기존)
│   │       │   └── PidAuditLog.cs (기존)
│   │       └── ValueObjects/
│   │           ├── ConfidenceScore.cs (신규)
│   │           └── MeasurementUnit.cs (신규)
│   ├── Infrastructure/
│   │   ├── Database/
│   │   │   ├── PidDbContext.cs (신규)
│   │   │   └── ExperionDbContext.cs (확장)
│   │   └── OpcUa/
│   │       └── (기존)
│   └── Web/
│       ├── Controllers/
│       │   └── PidController.cs (기존)
│       └── wwwroot/
│           └── js/
│               └── app.js (확장)
└── futurePlan/
    ├── temp/
    │   ├── pid_extractor.py (AI 추출기)
    │   ├── schema.sql (추구용 DB 스키마)
    │   └── requirements.txt (Python 의존성)
    └── p&id_ax-coding_plan.md (이 파일)
```

---

## 📋 코딩 단계 (15단계)

### 단계 1: P&ID 도메인 엔티티 생성

**파일**: [`src/Core/Domain/Entities/PidEquipment.cs`](../src/Core/Domain/Entities/PidEquipment.cs)

```csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExperionCrawler.Core.Domain.Entities;

/// <summary>P&ID 도면에서 추출한 장비/계기 정보</summary>
[Table("pid_equipment")]
public class PidEquipment
{
    public long Id { get; set; }
    
    [Required]
    [MaxLength(50)]
    public string TagNo { get; set; } = string.Empty;
    
    [MaxLength(200)]
    public string? EquipmentName { get; set; }
    
    [MaxLength(10)]
    public string? InstrumentType { get; set; }
    
    [MaxLength(100)]
    public string? LineNumber { get; set; }
    
    [MaxLength(50)]
    public string? PidDrawingNo { get; set; }
    
    public double Confidence { get; set; }
    
    public bool IsActive { get; set; } = true;
    
    public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
    
    public DateTime? UpdatedAt { get; set; }
    
    // 외래 키 - 기존 RealtimePoint.Id는 int 타입
    public int? ExperionTagId { get; set; }
    
    // FK 네비게이션 프로퍼티
    public RealtimePoint? ExperionTag { get; set; }
}
```

**파일**: [`src/Core/Domain/Entities/PidAuditLog.cs`](../src/Core/Domain/Entities/PidAuditLog.cs)

```csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExperionCrawler.Core.Domain.Entities;

/// <summary>P&ID 추출/수정 작업 감사 로그</summary>
[Table("pid_audit_log")]
public class PidAuditLog
{
    public long Id { get; set; }
    
    // 사용자 인증 시스템 부재 → Source 필드로 대체
    [MaxLength(50)]
    public string Source { get; set; } = string.Empty;
    
    [MaxLength(50)]
    public string Action { get; set; } = string.Empty;
    
    [MaxLength(50)]
    public string TargetTagNo { get; set; } = string.Empty;
    
    public string? OldValue { get; set; }
    
    public string? NewValue { get; set; }
    
    public DateTime LoggedAt { get; set; } = DateTime.UtcNow;
}
```

---

### 단계 2: DTO 정의

**파일**: [`src/Core/Application/DTOs/PidEquipmentDto.cs`](../src/Core/Application/DTOs/PidEquipmentDto.cs)

```csharp
namespace ExperionCrawler.Core.Application.DTOs;

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);
```

**파일**: [`src/Core/Application/DTOs/PidExtractionResult.cs`](../src/Core/Application/DTOs/PidExtractionResult.cs)

```csharp
namespace ExperionCrawler.Core.Application.DTOs;

public record PidExtractionResult(
    int TotalCount,
    int ConfidenceItems,
    int LowConfidenceItems);
```

---

### 단계 3: Value Object 생성

**파일**: [`src/Core/Domain/ValueObjects/ConfidenceScore.cs`](../src/Core/Domain/ValueObjects/ConfidenceScore.cs)

```csharp
namespace ExperionCrawler.Core.Domain.ValueObjects;

public record ConfidenceScore(double Value)
{
    public static ConfidenceScore FromDouble(double value) => 
        new ConfidenceScore(Math.Clamp(value, 0.0, 1.0));
    
    public static implicit operator double(ConfidenceScore score) => score.Value;
    public static implicit operator ConfidenceScore(double value) => FromDouble(value);
    
    public bool IsHigh => Value >= 0.8;
    public bool IsMedium => Value >= 0.5 && Value < 0.8;
    public bool IsLow => Value < 0.5;
}
```

**파일**: [`src/Core/Domain/ValueObjects/MeasurementUnit.cs`](../src/Core/Domain/ValueObjects/MeasurementUnit.cs)

```csharp
namespace ExperionCrawler.Core.Domain.ValueObjects;

public enum MeasurementUnit
{
    None,
    Flow,
    Pressure,
    Level,
    Temperature,
    ValvePosition
}
```

---

### 단계 4: 인터페이스 정의

**파일**: [`src/Core/Application/Interfaces/IPidExtractorService.cs`](../src/Core/Application/Interfaces/IPidExtractorService.cs)

```csharp
namespace ExperionCrawler.Core.Application.Interfaces;

public interface IPidExtractorService
{
    Task<PidExtractionResult> ExtractFromDxfAsync(string filePath, string drawingNo);
    Task<PidExtractionResult> ExtractFromPdfAsync(string filePath, string drawingNo);
    Task<IEnumerable<PidEquipmentDto>> GetEquipmentByDrawingAsync(string drawingNo);
    Task<PidEquipmentDto?> GetEquipmentByTagAsync(string tagNo);
    Task<bool> UpdateEquipmentAsync(PidEquipmentDto dto);
    Task<bool> DeleteEquipmentAsync(long id);
}
```

---

### 단계 5: 서비스 구현

**파일**: [`src/Core/Application/Services/PidExtractorService.cs`](../src/Core/Application/Services/PidExtractorService.cs)

```csharp
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace ExperionCrawler.Core.Application.Services;

public class PidExtractorService : IPidExtractorService
{
    private readonly ExperionDbContext _context;
    
    public PidExtractorService(ExperionDbContext context)
    {
        _context = context;
    }
    
    public async Task<PidExtractionResult> ExtractFromDxfAsync(string filePath, string drawingNo)
    {
        // 분산 처리 로직 구현
        // 1. ezdxf로 전처리
        // 2. 5개 서브 프로그램 병렬 실행
        // 3. 결과 집계 및 DB 저장
        throw new NotImplementedException();
    }
    
    public async Task<PidExtractionResult> ExtractFromPdfAsync(string filePath, string drawingNo)
    {
        // PDF 추출 로직 구현
        throw new NotImplementedException();
    }
    
    public async Task<IEnumerable<PidEquipmentDto>> GetEquipmentByDrawingAsync(string drawingNo)
    {
        var equipment = await _context.PidEquipment
            .Where(e => e.PidDrawingNo == drawingNo)
            .Select(e => new PidEquipmentDto(
                e.Id,
                e.TagNo,
                e.EquipmentName,
                e.InstrumentType,
                e.LineNumber,
                e.PidDrawingNo,
                e.Confidence,
                e.IsActive,
                e.ExtractedAt,
                e.UpdatedAt,
                e.ExperionTagId,
                e.ExperionTag?.TagName
            ))
            .ToListAsync();
        
        return equipment;
    }
    
    public async Task<PidEquipmentDto?> GetEquipmentByTagAsync(string tagNo)
    {
        var equipment = await _context.PidEquipment
            .Where(e => e.TagNo == tagNo)
            .Select(e => new PidEquipmentDto(
                e.Id,
                e.TagNo,
                e.EquipmentName,
                e.InstrumentType,
                e.LineNumber,
                e.PidDrawingNo,
                e.Confidence,
                e.IsActive,
                e.ExtractedAt,
                e.UpdatedAt,
                e.ExperionTagId,
                e.ExperionTag?.TagName
            ))
            .FirstOrDefaultAsync();
        
        return equipment;
    }
    
    public async Task<bool> UpdateEquipmentAsync(PidEquipmentDto dto)
    {
        var equipment = await _context.PidEquipment.FindAsync(dto.Id);
        if (equipment == null) return false;
        
        equipment.TagNo = dto.TagNo;
        equipment.EquipmentName = dto.EquipmentName;
        equipment.InstrumentType = dto.InstrumentType;
        equipment.LineNumber = dto.LineNumber;
        equipment.PidDrawingNo = dto.PidDrawingNo;
        equipment.Confidence = dto.Confidence;
        equipment.UpdatedAt = DateTime.UtcNow;
        
        await _context.SaveChangesAsync();
        return true;
    }
    
    public async Task<bool> DeleteEquipmentAsync(long id)
    {
        var equipment = await _context.PidEquipment.FindAsync(id);
        if (equipment == null) return false;
        
        _context.PidEquipment.Remove(equipment);
        await _context.SaveChangesAsync();
        return true;
    }
}
```

---

### 단계 6: 컨트롤러 구현

**파일**: [`src/Web/Controllers/PidController.cs`](../src/Web/Controllers/PidController.cs)

```csharp
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace ExperionCrawler.Web.Controllers;

[ApiController]
[Route("api/[controller]")]
public class PidController : ControllerBase
{
    private readonly IPidExtractorService _service;
    
    public PidController(IPidExtractorService service)
    {
        _service = service;
    }
    
    [HttpPost("extract/dxf")]
    public async Task<IActionResult> ExtractFromDxf([FromBody] ExtractRequest request)
    {
        var result = await _service.ExtractFromDxfAsync(request.FilePath, request.DrawingNo);
        return Ok(new
        {
            total = result.TotalCount,
            confidenceItems = result.ConfidenceItems,
            lowConfidenceItems = result.LowConfidenceItems
        });
    }
    
    [HttpGet("drawing/{drawingNo}")]
    public async Task<IActionResult> GetByDrawing(string drawingNo)
    {
        var equipment = await _service.GetEquipmentByDrawingAsync(drawingNo);
        return Ok(new
        {
            items = equipment.Select(e => new
            {
                id = e.Id,
                tagNo = e.TagNo,
                equipmentName = e.EquipmentName,
                instrumentType = e.InstrumentType,
                lineNumber = e.LineNumber,
                confidence = e.Confidence,
                isActive = e.IsActive
            })
        });
    }
    
    [HttpGet("tag/{tagNo}")]
    public async Task<IActionResult> GetByTag(string tagNo)
    {
        var equipment = await _service.GetEquipmentByTagAsync(tagNo);
        if (equipment == null) return NotFound();
        
        return Ok(new
        {
            id = equipment.Id,
            tagNo = equipment.TagNo,
            equipmentName = equipment.EquipmentName,
            instrumentType = equipment.InstrumentType,
            lineNumber = equipment.LineNumber,
            confidence = equipment.Confidence,
            isActive = equipment.IsActive
        });
    }
    
    [HttpPut("equipment")]
    public async Task<IActionResult> Update([FromBody] PidEquipmentDto dto)
    {
        var success = await _service.UpdateEquipmentAsync(dto);
        if (!success) return NotFound();
        
        return Ok(new { success = true });
    }
    
    [HttpDelete("equipment/{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var success = await _service.DeleteEquipmentAsync(id);
        if (!success) return NotFound();
        
        return Ok(new { success = true });
    }
}

public record ExtractRequest(string FilePath, string DrawingNo);
```

---

### 단계 7: DB 컨텍스트 확장

**파일**: [`src/Infrastructure/Database/ExperionDbContext.cs`](../src/Infrastructure/Database/ExperionDbContext.cs)

```csharp
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace ExperionCrawler.Infrastructure.Database;

public class ExperionDbContext : DbContext
{
    public ExperionDbContext(DbContextOptions<ExperionDbContext> options) : base(options) { }
    
    // 기존 테이블들...
    public DbSet<RealtimePoint> RealtimePoints { get; set; } = default!;
    public DbSet<HistoryPoint> HistoryPoints { get; set; } = default!;
    
    // P&ID 관련 테이블
    public DbSet<PidEquipment> PidEquipment { get; set; } = default!;
    public DbSet<PidAuditLog> PidAuditLog { get; set; } = default!;
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // PidEquipment 설정
        modelBuilder.Entity<PidEquipment>(entity =>
        {
            entity.ToTable("pid_equipment");
            
            entity.Property(e => e.Id)
                .HasColumnName("id")
                .ValueGeneratedOnAdd();
            
            entity.Property(e => e.TagNo)
                .HasColumnName("tag_no")
                .IsRequired()
                .HasMaxLength(50);
            
            entity.Property(e => e.EquipmentName)
                .HasColumnName("equipment_name")
                .HasMaxLength(200);
            
            entity.Property(e => e.InstrumentType)
                .HasColumnName("instrument_type")
                .HasMaxLength(10);
            
            entity.Property(e => e.LineNumber)
                .HasColumnName("line_number")
                .HasMaxLength(100);
            
            entity.Property(e => e.PidDrawingNo)
                .HasColumnName("pid_drawing_no")
                .HasMaxLength(50);
            
            entity.Property(e => e.Confidence)
                .HasColumnName("confidence")
                .HasDefaultValue(1.0);
            
            entity.Property(e => e.IsActive)
                .HasColumnName("is_active")
                .HasDefaultValue(true);
            
            entity.Property(e => e.ExtractedAt)
                .HasColumnName("extracted_at")
                .HasDefaultValueSql("NOW()");
            
            entity.Property(e => e.UpdatedAt)
                .HasColumnName("updated_at");
            
            entity.Property(e => e.ExperionTagId)
                .HasColumnName("experion_tag_id");
            
            entity.HasOne(e => e.ExperionTag)
                .WithMany()
                .HasForeignKey(e => e.ExperionTagId)
                .HasConstraintName("fk_pid_equipment_experion_tag");
        });
        
        // PidAuditLog 설정
        modelBuilder.Entity<PidAuditLog>(entity =>
        {
            entity.ToTable("pid_audit_log");
            
            entity.Property(e => e.Id)
                .HasColumnName("id")
                .ValueGeneratedOnAdd();
            
            entity.Property(e => e.Source)
                .HasColumnName("source")
                .IsRequired()
                .HasMaxLength(50);
            
            entity.Property(e => e.Action)
                .HasColumnName("action")
                .IsRequired()
                .HasMaxLength(50);
            
            entity.Property(e => e.TargetTagNo)
                .HasColumnName("target_tag_no")
                .IsRequired()
                .HasMaxLength(50);
            
            entity.Property(e => e.OldValue)
                .HasColumnName("old_value");
            
            entity.Property(e => e.NewValue)
                .HasColumnName("new_value");
            
            entity.Property(e => e.LoggedAt)
                .HasColumnName("logged_at")
                .HasDefaultValueSql("NOW()");
        });
    }
}
```

---

### 단계 8: 프로그램 등록

**파일**: [`src/Web/Program.cs`](../src/Web/Program.cs)

```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// DB 컨텍스트 등록
builder.Services.AddDbContext<ExperionDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

// 서비스 등록
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();

// JSON 직렬화 설정 (PascalCase 유지)
builder.Services.AddControllers()
    .AddJsonOptions(opt =>
    {
        opt.JsonSerializerOptions.PropertyNamingPolicy = null;  // PascalCase 그대로
    });

var app = builder.Build();

// 중간ware 등록...
app.MapControllers();

app.Run();
```

---

### 단계 9: ezdxf 전처리 모듈

**파일**: [`futurePlan/dxf_preprocessor.py`](../futurePlan/dxf_preprocessor.py)

```python
#!/usr/bin/env python3
"""
DXF 파일 전처리 모듈
ezdxf를 사용하여 텍스트 추출 및 정제
"""

import ezdxf
from ezdxf.tools.text import plain_mtext
import json
import sys


def extract_text_from_dxf(filepath: str) -> str:
    """DXF 파일에서 모든 텍스트 추출"""
    doc = ezdxf.readfile(filepath)
    msp = doc.modelspace()
    
    texts = []
    for entity in msp:
        if entity.dxftype() == 'TEXT':
            texts.append(entity.dxf.text)
        elif entity.dxftype() == 'MTEXT':
            try:
                plain = plain_mtext(entity.dxf.text)
                if plain.strip():
                    texts.append(plain)
            except Exception:
                pass
    
    return '\n'.join(texts)


def save_preprocessed_data(text: str, output_path: str):
    """전처리 데이터 저장"""
    data = {
        'text': text,
        'length': len(text),
        'timestamp': __import__('datetime').datetime.now().isoformat()
    }
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print("Usage: python dxf_preprocessor.py <input_dxf> <output_json>")
        sys.exit(1)
    
    filepath = sys.argv[1]
    output_path = sys.argv[2]
    
    text = extract_text_from_dxf(filepath)
    save_preprocessed_data(text, output_path)
    print(f"Preprocessed {len(text)} characters to {output_path}")
```

---

### 단계 10: 분산 처리 메인 프로그램 (MCP 서버 병렬 호출)

**파일**: [`futurePlan/pid_parser_main.py`](../futurePlan/pid_parser_main.py)

```python
#!/usr/bin/env python3
"""
P&ID Parser 메인 프로그램
ezdxf 전처리 후 5개 MCP 서버 인스턴스에 병렬 요청
"""

import asyncio
import json
import sys
import os
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any
import httpx


class MCPClient:
    """MCP 서버 클라이언트 - 병렬 요청 처리"""
    
    def __init__(self, base_url: str = "http://localhost:8000"):
        self.base_url = base_url
        self.client = httpx.AsyncClient(timeout=1800.0)  # 30분 타임아웃
    
    async def extract_tags(self, text: str, system_prompt: str, user_prompt: str) -> Dict[str, Any]:
        """MCP 서버에 태그 추출 요청"""
        try:
            response = await self.client.post(
                f"{self.base_url}/v1/chat/completions",
                json={
                    "model": "Qwen/Qwen3-Coder-Next-FP8",
                    "messages": [
                        {"role": "system", "content": system_prompt},
                        {"role": "user", "content": user_prompt.format(text=text[:65536])}
                    ],
                    "max_tokens": 65536,
                    "temperature": 0.1,
                    "extra_body": {"chat_template_kwargs": {"enable_thinking": False}}
                }
            )
            response.raise_for_status()
            result = response.json()
            content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
            return {"success": True, "content": content}
        except Exception as e:
            return {"success": False, "error": str(e)}
    
    async def close(self):
        await self.client.aclose()


async def run_parallel_extraction(text: str, drawing_no: str) -> Dict[str, Any]:
    """5개의 MCP 서버 인스턴스에 병렬로 요청"""
    
    # 각 추출기의 프롬프트 정의
    extractors = {
        "transmitter": {
            "system": (
                "You are a P&ID expert. Extract sensor tags only.\n"
                "Return ONLY a JSON array.\n"
                '\n'
                'Instrument types to extract: FT, FIT, LT, PT, TE\n'
                'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
            ),
            "user": 'Extract ALL tags of FT, FIT, LT, PT, TE from the text below:\n\n{text}'
        },
        "valve": {
            "system": (
                "You are a P&ID expert. Extract control valve tags only.\n"
                "Return ONLY a JSON array.\n"
                '\n'
                'Instrument types to extract: FCV, LCV, TCV, PCV, XV\n'
                'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
            ),
            "user": 'Extract ALL tags of FCV, LCV, TCV, PCV, XV from the text below:\n\n{text}'
        },
        "gauge": {
            "system": (
                "You are a P&ID expert. Extract gauge tags only.\n"
                "Return ONLY a JSON array.\n"
                '\n'
                'Instrument types to extract: PG, TG, LG\n'
                'Format: [{"tagNo":"PG-101","confidence":0.95},...]\n'
            ),
            "user": 'Extract ALL tags of PG, TG, LG from the text below:\n\n{text}'
        },
        "equipment": {
            "system": (
                "You are a P&ID expert. Extract equipment tags only.\n"
                "Return ONLY a JSON array.\n"
                '\n'
                'Equipment types to extract: C- (Distilation Column), T- (Tank), '
                'F- (Filter), D- (Drum/Condensor), E- (Heat Exchanger), '
                'B- (BOILER), CT- (COOLING TOWER), CH- (CHILLER), K- (COMPRESSOR)\n'
                'Format: [{"tagNo":"T-101","equipmentType":"Tank","confidence":0.90},...]\n'
            ),
            "user": 'Extract ALL equipment tags from the text below:\n\n{text}'
        },
        "pump": {
            "system": (
                "You are a P&ID expert. Extract pump tags only.\n"
                "Return ONLY a JSON array.\n"
                '\n'
                'Pump types to extract: P-, VP-\n'
                'Format: [{"tagNo":"P-10106","equipmentType":"Pump","confidence":0.90},...]\n'
            ),
            "user": 'Extract ALL pump tags from the text below:\n\n{text}'
        }
    }
    
    # MCP 클라이언트 생성
    client = MCPClient(base_url="http://localhost:8000")
    
    try:
        # 병렬 실행
        tasks = [
            client.extract_tags(
                text,
                extractors[name]["system"],
                extractors[name]["user"]
            )
            for name in extractors.keys()
        ]
        
        results = await asyncio.gather(*tasks)
        
        # 결과 매핑
        output = {}
        for name, result in zip(extractors.keys(), results):
            output[name] = {
                "status": "success" if result["success"] else "failed",
                "content": result.get("content", ""),
                "error": result.get("error", "")
            }
        
        return output
    
    finally:
        await client.close()


def save_results(results: Dict[str, Any], drawing_no: str):
    """결과 파일 저장"""
    for name, data in results.items():
        if data["status"] == "success":
            output_file = f"/tmp/pid_{name}_result.json"
            try:
                # JSON 파싱
                import re
                json_match = re.search(r'\[.*\]', data["content"], re.DOTALL)
                if json_match:
                    tags = json.loads(json_match.group())
                else:
                    tags = []
                
                output = {
                    "name": name,
                    "drawingNo": drawing_no,
                    "extractedAt": datetime.now().isoformat(),
                    "tags": tags,
                    "count": len(tags)
                }
                
                with open(output_file, 'w', encoding='utf-8') as f:
                    json.dump(output, f, ensure_ascii=False, indent=2)
                
                print(f"  {name}: {len(tags)} tags saved to {output_file}")
            except Exception as e:
                print(f"  {name}: Failed to save - {e}")
        else:
            print(f"  {name}: Failed - {data.get('error', 'Unknown error')}")


def main():
    if len(sys.argv) < 3:
        print("Usage: python pid_parser_main.py <dxf_file> <drawing_no>")
        sys.exit(1)
    
    dxf_file = sys.argv[1]
    drawing_no = sys.argv[2]
    
    # 1. 전처리
    print(f"[1/4] Preprocessing {dxf_file}...")
    preprocessed_file = f"/tmp/pid_preprocessed_{Path(dxf_file).stem}.json"
    
    # 전처리 스크립트 실행
    import subprocess
    preprocessor = subprocess.run([
        sys.executable,
        "dxf_preprocessor.py",
        dxf_file,
        preprocessed_file
    ], capture_output=True, text=True)
    
    if preprocessor.returncode != 0:
        print(f"Preprocessing failed: {preprocessor.stderr}")
        sys.exit(1)
    
    print(f"Preprocessed data saved to {preprocessed_file}")
    
    # 전처리 데이터 읽기
    with open(preprocessed_file, 'r', encoding='utf-8') as f:
        data = json.load(f)
    text = data['text']
    
    # 2. MCP 서버에 병렬 요청
    print("[2/4] Sending parallel requests to MCP server...")
    
    results = asyncio.run(run_parallel_extraction(text, drawing_no))
    
    # 3. 결과 저장
    print("[3/4] Saving results...")
    save_results(results, drawing_no)
    
    # 4. 후처리 (DB 저장)
    print("[4/4] Post-processing (database save)...")
    # TODO: DB 저장 로직 구현
    
    print("P&ID parsing completed!")


if __name__ == '__main__':
    main()
```

---

### 단계 11-15: MCP 서버 병렬 요청 (내장)

**설명**: 단계 11-15는 더 이상 별도의 서브 프로그램이 아닙니다. 메인 프로그램(`pid_parser_main.py`) 내에서 `run_parallel_extraction()` 함수가 5개의 MCP 서버 요청을 병렬로 실행합니다.

**구조**:
```
pid_parser_main.py
├── MCPClient class (비동기 HTTP 클라이언트)
├── run_parallel_extraction() (asyncio.gather로 5개 요청 병렬 실행)
│   ├── transmitter 요청 (FIT, FT, LT, PT, TE)
│   ├── valve 요청 (FCV, LCV, TCV, PCV, XV)
│   ├── gauge 요청 (PG, TG, LG)
│   ├── equipment 요청 (C-, T-, F-, D-, E-, B-, CT-, CH-, K-)
│   └── pump 요청 (P-, VP-)
└── save_results() (결과 파일 저장)
```

**병렬 실행 코드** (단계 10 참조):
```python
async def run_parallel_extraction(text: str, drawing_no: str) -> Dict[str, Any]:
    extractors = {
        "transmitter": {...},
        "valve": {...},
        "gauge": {...},
        "equipment": {...},
        "pump": {...}
    }
    
    client = MCPClient(base_url="http://localhost:8000")
    tasks = [
        client.extract_tags(text, extractors[name]["system"], extractors[name]["user"])
        for name in extractors.keys()
    ]
    results = await asyncio.gather(*tasks)  # ← 5개 요청이 동시에 실행됨
    await client.close()
    return results
```

**장점**:
- 단일 프로세스 내에서 병렬 실행 → 프로세스 생성 오버헤드 없음
- MCP 서버가 병렬 처리 가능하도록 구성되어 있음
- KV Cache 사용률 감소 (각 요청이 65536 토큰 제한 내에서 실행)

---

---

## 📊 DB 스키마

### pid_equipment 테이블

```sql
CREATE TABLE IF NOT EXISTS pid_equipment (
    id                  SERIAL PRIMARY KEY,
    pid_drawing_no      VARCHAR(100),           -- P&ID 도면번호
    tag_no              VARCHAR(100),           -- 태그번호 (e.g. FT-1001)
    equipment_name      VARCHAR(255),           -- 장비명
    instrument_type     VARCHAR(50),            -- 계기타입 (FT, PT, LT ...)
    line_number         VARCHAR(100),           -- 라인번호
    service_description TEXT,                   -- 서비스 설명
    confidence          FLOAT DEFAULT 1.0,      -- AI 신뢰도 (0.0~1.0)
    source_file         VARCHAR(255),           -- 원본 파일명
    extracted_at        TIMESTAMPTZ,            -- 추출 일시
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    updated_at          TIMESTAMPTZ DEFAULT NOW(),
    review_status       VARCHAR(20) DEFAULT 'pending',
    reviewer_note       TEXT,
    
    -- 외래 키
    experion_tag_id     INTEGER REFERENCES realtime_point(id)
);

CREATE INDEX IF NOT EXISTS idx_pid_tag_no ON pid_equipment(tag_no);
CREATE INDEX IF NOT EXISTS idx_pid_drawing_no ON pid_equipment(pid_drawing_no);
CREATE INDEX IF NOT EXISTS idx_pid_instrument_type ON pid_equipment(instrument_type);
CREATE INDEX IF NOT EXISTS idx_pid_review_status ON pid_equipment(review_status);
```

### pid_audit_log 테이블

```sql
CREATE TABLE IF NOT EXISTS pid_audit_log (
    id              SERIAL PRIMARY KEY,
    source          VARCHAR(50) NOT NULL,
    action          VARCHAR(50) NOT NULL,
    target_tag_no   VARCHAR(50) NOT NULL,
    old_value       TEXT,
    new_value       TEXT,
    logged_at       TIMESTAMPTZ DEFAULT NOW()
);
```

---

## 🧪 테스트 및 검증 계획

### 단위 테스트

1. **ezdxf 전처리 테스트**
   - DXF 파일에서 텍스트 추출 정확성
   - 빈 파일/이상 파일 처리

2. **서브 프로그램 테스트**
   - 각 타입별 태그 추출 정확성
   - 중복 제거 로직
   - 신뢰도 계산

3. **통합 테스트**
   - 전체 파이프라인 실행
   - DB 저장/조회
   - API 응답 검증

### 성능 테스트

1. **기존 방식 vs 분산 처리 비교**
   - 처리 시간
   - 메모리 사용량
   - KV Cache 사용률

2. **병렬 실행 최적화**
   - 서브 프로그램 수 조정
   - 타임아웃 설정

---

## 📝 참고 사항

1. **LLM 타임아웃**: 각 서브 프로그램은 1800초(30분) 타임아웃 설정
2. **Max Context Length**: 65536 토큰 설정
3. **결과 파일**: `/tmp/pid_{name}_result.json`에 저장
4. **신뢰도 기준**: 
   - Transmitter/Valve/Gauge: 0.95
   - Equipment/Pump: 0.90

---

## 🚀 실행 방법

```bash
# 1. 전처리 및 분산 처리 실행
python futurePlan/pid_parser_main.py src/Web/uploads/pid/p-9100.dxf P-9100

# 2. 결과 확인
cat /tmp/pid_transmitter_result.json
cat /tmp/pid_valve_result.json
cat /tmp/pid_equipment_result.json

# 3. API를 통해 조회
curl http://localhost:5000/api/pid/drawing/P-9100
```
