Initial commit: HC900 Crawler
Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL. 기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체. - industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버) - src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer) - mcp-server: Python FastMCP (RAG/NL2SQL/P&ID) - 다중 컨트롤러(N-Controller) 지원 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
349
mcp-server/worker/nl2sql_worker.py
Normal file
349
mcp-server/worker/nl2sql_worker.py
Normal file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NL2SQL 전용 워커 프로세스
|
||||
|
||||
Usage: python nl2sql_worker.py <port>
|
||||
|
||||
담당 도구:
|
||||
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
|
||||
|
||||
특징:
|
||||
- PostgreSQL 직접 연결
|
||||
- LLM SQL 생성 + DB 실행 분리
|
||||
- 메모리: ~1GB (SQL 생성용 LLM)
|
||||
- 생명주기: 메인 서버 종료 시까지 유지
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
# mcp-server 디렉토리를 Python 경로에 추가
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
import uvicorn
|
||||
import httpx
|
||||
|
||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_db_connection():
|
||||
import psycopg
|
||||
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
|
||||
|
||||
|
||||
async def _aget_db_connection():
|
||||
"""비동기 환경에서 안전하게 DB 연결 획득 (blocking connect를 to_thread로 격리)."""
|
||||
import asyncio
|
||||
return await asyncio.to_thread(_get_db_connection)
|
||||
|
||||
|
||||
# ── SQL 가드 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
SQL_MAX_ROWS = int(os.environ.get("SQL_MAX_ROWS", "1000"))
|
||||
SQL_STATEMENT_TIMEOUT_MS = int(os.environ.get("SQL_STATEMENT_TIMEOUT_MS", "30000"))
|
||||
|
||||
_RE_LIMIT_TAIL = re.compile(r"\bLIMIT\b\s+\d+(\s+OFFSET\s+\d+)?\s*$", re.IGNORECASE)
|
||||
_DANGEROUS_KW = ('EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY')
|
||||
|
||||
|
||||
def _validate_sql(sql: str) -> tuple[bool, str]:
|
||||
"""SELECT/WITH만 허용, 위험 키워드/다중 문장 차단."""
|
||||
if not sql or len(sql) > 2000:
|
||||
return False, "쿼리가 비어있거나 2000자를 초과했습니다."
|
||||
upper = sql.upper()
|
||||
for kw in _DANGEROUS_KW:
|
||||
if re.search(rf"\b{kw}\b", upper):
|
||||
return False, f"허용되지 않은 키워드 '{kw}'"
|
||||
head = upper.lstrip().lstrip('(').lstrip()
|
||||
if not (head.startswith('SELECT') or head.startswith('WITH')):
|
||||
return False, "SELECT 또는 WITH 쿼리만 허용됩니다."
|
||||
if ';' in sql.rstrip().rstrip(';'):
|
||||
return False, "다중 문장(세미콜론)은 허용되지 않습니다."
|
||||
return True, ""
|
||||
|
||||
|
||||
def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str:
|
||||
s = sql.strip().rstrip(';').strip()
|
||||
if _RE_LIMIT_TAIL.search(s):
|
||||
return s
|
||||
return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}"
|
||||
|
||||
|
||||
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _llm_client():
|
||||
from openai import AsyncOpenAI
|
||||
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
|
||||
# DB 스키마 + SQL system 프롬프트 — worker/sql_prompt.py 로 단일화(production+eval 공유)
|
||||
from sql_prompt import DB_SCHEMA, SQL_SYSTEM_PROMPT # noqa: E402,F401
|
||||
|
||||
async def _generate_sql(natural_language: str) -> str:
|
||||
"""자연어를 SQL로 변환."""
|
||||
client = _llm_client()
|
||||
|
||||
system = SQL_SYSTEM_PROMPT
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": natural_language},
|
||||
],
|
||||
max_tokens=8192,
|
||||
temperature=0.1,
|
||||
)
|
||||
sql = response.choices[0].message.content.strip()
|
||||
# 마크다운 코드 블록 제거
|
||||
if sql.startswith("```"):
|
||||
lines = sql.splitlines()
|
||||
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
||||
return sql
|
||||
|
||||
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""워커 헬스체크."""
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/execute")
|
||||
async def execute(request: Request):
|
||||
"""HTTP 요청을 MCP 도구 호출로 변환."""
|
||||
body = await request.json()
|
||||
tool = body["tool"]
|
||||
params = body["params"]
|
||||
|
||||
try:
|
||||
if tool == "run_sql":
|
||||
result = await _run_sql(**params)
|
||||
elif tool == "query_pv_history":
|
||||
result = await _query_pv_history(**params)
|
||||
elif tool == "get_tag_metadata":
|
||||
result = await _get_tag_metadata(**params)
|
||||
elif tool == "list_drawings":
|
||||
result = await _list_drawings(**params)
|
||||
elif tool == "query_with_nl":
|
||||
result = await _query_with_nl(**params)
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown tool: {tool}"}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing {tool}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _run_sql(sql: str) -> str:
|
||||
"""SQL 실행 (가드: SELECT/WITH만, auto-LIMIT, statement_timeout)."""
|
||||
valid, err = _validate_sql(sql)
|
||||
if not valid:
|
||||
return {"success": False, "error": f"SQL 검증 실패: {err}"}
|
||||
|
||||
capped_sql = _apply_sql_guards(sql)
|
||||
conn = await _aget_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
|
||||
cur.execute(capped_sql)
|
||||
if cur.description:
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
data = [dict(zip(columns, row)) for row in rows]
|
||||
return {
|
||||
"success": True,
|
||||
"columns": columns,
|
||||
"count": len(data),
|
||||
"row_limit": SQL_MAX_ROWS,
|
||||
"data": data,
|
||||
}
|
||||
else:
|
||||
conn.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"SQL 실행 실패: {e}"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
|
||||
"""과거 값(PV) 히스토리 조회."""
|
||||
if not tag_names:
|
||||
return {"success": False, "error": "tag_names is required"}
|
||||
|
||||
conn = await _aget_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recorded_at AS time, tagname AS tag_name, value
|
||||
FROM history_table
|
||||
WHERE tagname = ANY(%s)
|
||||
AND recorded_at >= %s
|
||||
AND recorded_at <= %s
|
||||
ORDER BY recorded_at DESC, tagname
|
||||
LIMIT %s
|
||||
""",
|
||||
(tag_names, time_from, time_to, limit),
|
||||
)
|
||||
columns = ["time", "tag_name", "value"]
|
||||
rows = cur.fetchall()
|
||||
data = [dict(zip(columns, row)) for row in rows]
|
||||
return {
|
||||
"success": True,
|
||||
"tag_names": tag_names,
|
||||
"time_range": {"from": time_from, "to": time_to},
|
||||
"limit": limit,
|
||||
"count": len(data),
|
||||
"data": data,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
|
||||
"""태그 메타데이터 검색."""
|
||||
conn = await _aget_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tagname, livevalue, timestamp, node_id
|
||||
FROM realtime_table
|
||||
WHERE tagname ILIKE %s
|
||||
ORDER BY tagname
|
||||
LIMIT %s
|
||||
""",
|
||||
(f"%{query}%", limit),
|
||||
)
|
||||
columns = ["tag_name", "current_value", "last_updated", "node_id"]
|
||||
rows = cur.fetchall()
|
||||
data = [
|
||||
{
|
||||
"tag_name": r[0],
|
||||
"current_value": r[1],
|
||||
"last_updated": r[2].isoformat() if r[2] else None,
|
||||
"node_id": r[3],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"count": len(data),
|
||||
"tags": data,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def _list_drawings(unit_no: str = None) -> str:
|
||||
"""단위별 도면 목록 조회."""
|
||||
conn = await _aget_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if unit_no:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT name
|
||||
FROM node_map_master
|
||||
WHERE name LIKE %s
|
||||
ORDER BY name
|
||||
""",
|
||||
(f"{unit_no}%",),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT name
|
||||
FROM node_map_master
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
names = [row[0] for row in rows]
|
||||
return {
|
||||
"success": True,
|
||||
"unit_no": unit_no,
|
||||
"count": len(names),
|
||||
"names": names,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def _query_with_nl(question: str) -> str:
|
||||
"""자연어로 SQL 쿼리 실행."""
|
||||
import json
|
||||
sql = await _generate_sql(question)
|
||||
|
||||
# SQL이 비어있으면 오류 반환
|
||||
if not sql:
|
||||
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
|
||||
|
||||
# LLM 생성 SQL도 동일 가드 적용
|
||||
valid, err = _validate_sql(sql)
|
||||
if not valid:
|
||||
return {"success": False, "sql": sql, "error": f"SQL 검증 실패: {err}"}
|
||||
capped_sql = _apply_sql_guards(sql)
|
||||
|
||||
conn = await _aget_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
|
||||
cur.execute(capped_sql)
|
||||
if cur.description:
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
data = [dict(zip(columns, row)) for row in rows]
|
||||
return {
|
||||
"success": True,
|
||||
"sql": sql,
|
||||
"columns": columns,
|
||||
"count": len(data),
|
||||
"row_limit": SQL_MAX_ROWS,
|
||||
"data": data,
|
||||
}
|
||||
else:
|
||||
conn.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"sql": sql,
|
||||
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
|
||||
}
|
||||
except Exception as db_error:
|
||||
return {
|
||||
"success": False,
|
||||
"sql": sql,
|
||||
"error": str(db_error),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ── 메인 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
|
||||
logging.info(f"Starting NL2SQL worker on port {port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
62
mcp-server/worker/pid_extract_gauge.py
Normal file
62
mcp-server/worker/pid_extract_gauge.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 게이지 추출기
|
||||
|
||||
PG, TG, LG 등 게이지 전용 추출.
|
||||
|
||||
사용법:
|
||||
python pid_extract_gauge.py --input full_text.txt --output gauge.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pid_extract_template import call_llm
|
||||
from pid_extract_prompts import _GAUGE_PROMPT
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("pid_extractor.gauge")
|
||||
|
||||
def extract(input_text: str, max_tokens: int = 65536) -> list:
|
||||
"""게이지 태그 추출."""
|
||||
return call_llm(_GAUGE_PROMPT, input_text, max_tokens=max_tokens)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="P&ID 게이지 추출기")
|
||||
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
|
||||
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
|
||||
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_text = f.read()
|
||||
|
||||
logger.info(f"입력 파일 읽기 완료: {len(input_text)}자")
|
||||
|
||||
t0 = time.time()
|
||||
tags = extract(input_text, max_tokens=args.max_tokens)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}초")
|
||||
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"tags": tags,
|
||||
"processing_time_sec": round(elapsed, 1),
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"결과 저장 완료: {args.output}")
|
||||
print(json.dumps({"success": True, "count": len(tags), "time": round(elapsed, 1)}, ensure_ascii=False))
|
||||
82
mcp-server/worker/pid_extract_prompts.py
Normal file
82
mcp-server/worker/pid_extract_prompts.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""P&ID 추출기용 계측기 유형별 프롬프트 정의"""
|
||||
|
||||
# 공통 프롬프트 헤더
|
||||
_PROMPT_HEADER = """You are a P&ID (Piping and Instrumentation Diagram) expert.
|
||||
Extract ONLY the specified instrument types from the provided DXF text.
|
||||
Return ONLY a valid JSON array. Each element must have exactly these fields:
|
||||
{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV","lineNumber":null,"pidDrawingNo":null,"confidence":0.95}
|
||||
|
||||
Rules:
|
||||
- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]
|
||||
- instrumentType: leading letters of tagNo
|
||||
- equipmentName: descriptive name if present near tag, else null
|
||||
- lineNumber/pidDrawingNo: null unless explicitly associated
|
||||
- confidence: 0.95 for clear tags, lower for ambiguous
|
||||
- Output ONLY the JSON array, no markdown, no explanation.
|
||||
- If no tags found, return: []
|
||||
"""
|
||||
|
||||
# 센서/계측기: FT, FIT, LT, PT, TE, PG, LG, TG
|
||||
_SENSOR_PROMPT = _PROMPT_HEADER + """
|
||||
Extract ONLY flow transmitters (FT/FIT), level transmitters (LT),
|
||||
pressure transmitters (PT), temperature elements (TE),
|
||||
pressure gauges (PG), level gauges (LG), temperature gauges (TG).
|
||||
|
||||
Target instrument types: FT, FIT, FIC, LIC, PIC, TIC, LT, PT, TE, PG, LG, TG,
|
||||
and their variants (e.g., FIT-XXXX, FT-XXXX).
|
||||
|
||||
Examples: FT-101, FIT-10115, PT-201, LT-301, TE-401, PG-501, LG-601, TG-701
|
||||
"""
|
||||
|
||||
# 밸브: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV
|
||||
_VALVE_PROMPT = _PROMPT_HEADER + """
|
||||
Extract ONLY control valves and on/off valves.
|
||||
|
||||
Target instrument types: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV,
|
||||
BCV, GV, and their variants (e.g., FCV-XXXX, PCV-XXXX, XV-XXXX).
|
||||
|
||||
Examples: FCV-101, TCV-201, LCV-301, PCV-401, XV-501, FV-601, LV-701, PV-801
|
||||
"""
|
||||
|
||||
# 시스템/제어기: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA
|
||||
_SYSTEM_PROMPT = _PROMPT_HEADER + """
|
||||
Extract ONLY indicating instruments, recorders, and controllers.
|
||||
|
||||
Target instrument types: LI, PI, TI, SI, HI, FIQ, FICQ, TICA, PICA, LICA,
|
||||
FIC, LIC, PIC, TIC, and their variants.
|
||||
|
||||
Examples: LI-101, PI-201, TI-301, FIQ-401, FICQ-501, TICA-601, PICA-701, LICA-801
|
||||
"""
|
||||
|
||||
# 게이지: PG, TG, LG
|
||||
_GAUGE_PROMPT = _PROMPT_HEADER + """
|
||||
Extract ONLY gauges (pressure, temperature, level).
|
||||
|
||||
Target instrument types: PG, TG, LG, SG, HG, and their variants.
|
||||
|
||||
Examples: PG-101, TG-201, LG-301, PG-10101, TG-10201
|
||||
"""
|
||||
|
||||
# 펌프: P-10101, VP-10117, DP-10101 등
|
||||
_PUMP_PROMPT = _PROMPT_HEADER + """
|
||||
Extract ONLY pumps and compressors (simple equipment tags, NO pipe size suffix).
|
||||
|
||||
Target equipment types: P (pump), VP (vertical pump), DP (dual pump),
|
||||
C (compressor), CP (centrifugal pump), BP (booster pump), SP (sump pump),
|
||||
and their variants.
|
||||
|
||||
Examples (4~5 digit loop numbers): P-10101, VP-10117, DP-10101, C-10201, P-9101, P-9116, VP-9201
|
||||
|
||||
IMPORTANT: Do NOT extract pipeline/line numbers that have a pipe size suffix (e.g. 25A, 50A, 100A).
|
||||
SKIP (pipeline, not a pump): P-10101-25A-F1A-n, P-9107-25A-F-n, CHR-9641-50A-F-C50
|
||||
INCLUDE (pump tag): P-10101, VP-10117, P-9101
|
||||
"""
|
||||
|
||||
# 프롬프트 매핑
|
||||
PROMPTS = {
|
||||
"sensor": _SENSOR_PROMPT,
|
||||
"valve": _VALVE_PROMPT,
|
||||
"system": _SYSTEM_PROMPT,
|
||||
"gauge": _GAUGE_PROMPT,
|
||||
"pump": _PUMP_PROMPT,
|
||||
}
|
||||
62
mcp-server/worker/pid_extract_pump.py
Normal file
62
mcp-server/worker/pid_extract_pump.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 펌프 추출기
|
||||
|
||||
P-10101, VP-10117, DP-10101 등 펌프/압축기 전용 추출.
|
||||
|
||||
사용법:
|
||||
python pid_extract_pump.py --input full_text.txt --output pump.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pid_extract_template import call_llm
|
||||
from pid_extract_prompts import _PUMP_PROMPT
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("pid_extractor.pump")
|
||||
|
||||
def extract(input_text: str, max_tokens: int = 65536) -> list:
|
||||
"""펌프/압축기 태그 추출."""
|
||||
return call_llm(_PUMP_PROMPT, input_text, max_tokens=max_tokens)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="P&ID 펌프 추출기")
|
||||
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
|
||||
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
|
||||
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_text = f.read()
|
||||
|
||||
logger.info(f"입력 파일 읽기 완료: {len(input_text)}자")
|
||||
|
||||
t0 = time.time()
|
||||
tags = extract(input_text, max_tokens=args.max_tokens)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}초")
|
||||
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"tags": tags,
|
||||
"processing_time_sec": round(elapsed, 1),
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"결과 저장 완료: {args.output}")
|
||||
print(json.dumps({"success": True, "count": len(tags), "time": round(elapsed, 1)}, ensure_ascii=False))
|
||||
67
mcp-server/worker/pid_extract_sensor.py
Normal file
67
mcp-server/worker/pid_extract_sensor.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 센서/계측기 추출기
|
||||
|
||||
FT, FIT, LT, PT, TE, PG, LG, TG 등 센서/계측기 전용 추출.
|
||||
|
||||
사용법:
|
||||
python pid_extract_sensor.py --input full_text.txt --output sensor.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# mcp-server/worker 디렉토리를 경로에 추가
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pid_extract_template import parse_json_array, call_llm, main as template_main
|
||||
from pid_extract_prompts import _SENSOR_PROMPT
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("pid_extractor.sensor")
|
||||
|
||||
def extract(input_text: str, max_tokens: int = 65536) -> list:
|
||||
"""센서/계측기 태그 추출."""
|
||||
return call_llm(_SENSOR_PROMPT, input_text, max_tokens=max_tokens)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --prompt를 자동으로 _SENSOR_PROMPT로 설정
|
||||
parser = argparse.ArgumentParser(description="P&ID 센서/계측기 추출기")
|
||||
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
|
||||
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
|
||||
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 입력 읽기
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_text = f.read()
|
||||
|
||||
logger.info(f"입력 파일 읽기 완료: {len(input_text)}자")
|
||||
|
||||
# LLM 호출
|
||||
t0 = time.time()
|
||||
tags = extract(input_text, max_tokens=args.max_tokens)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}초")
|
||||
|
||||
# 결과 저장
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"tags": tags,
|
||||
"processing_time_sec": round(elapsed, 1),
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"결과 저장 완료: {args.output}")
|
||||
print(json.dumps({"success": True, "count": len(tags), "time": round(elapsed, 1)}, ensure_ascii=False))
|
||||
62
mcp-server/worker/pid_extract_system.py
Normal file
62
mcp-server/worker/pid_extract_system.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 시스템/제어기 추출기
|
||||
|
||||
LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA 등 시스템/제어기 전용 추출.
|
||||
|
||||
사용법:
|
||||
python pid_extract_system.py --input full_text.txt --output system.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pid_extract_template import call_llm
|
||||
from pid_extract_prompts import _SYSTEM_PROMPT
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("pid_extractor.system")
|
||||
|
||||
def extract(input_text: str, max_tokens: int = 65536) -> list:
|
||||
"""시스템/제어기 태그 추출."""
|
||||
return call_llm(_SYSTEM_PROMPT, input_text, max_tokens=max_tokens)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="P&ID 시스템/제어기 추출기")
|
||||
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
|
||||
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
|
||||
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_text = f.read()
|
||||
|
||||
logger.info(f"입력 파일 읽기 완료: {len(input_text)}자")
|
||||
|
||||
t0 = time.time()
|
||||
tags = extract(input_text, max_tokens=args.max_tokens)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}초")
|
||||
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"tags": tags,
|
||||
"processing_time_sec": round(elapsed, 1),
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"결과 저장 완료: {args.output}")
|
||||
print(json.dumps({"success": True, "count": len(tags), "time": round(elapsed, 1)}, ensure_ascii=False))
|
||||
190
mcp-server/worker/pid_extract_template.py
Normal file
190
mcp-server/worker/pid_extract_template.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 태그 추출기 공통 템플릿
|
||||
|
||||
독립 프로세스로서 CLI에서 실행되며,
|
||||
입력 텍스트 파일에서 P&ID 태그를 추출하여 JSON 파일로 출력합니다.
|
||||
|
||||
사용법:
|
||||
python pid_extract_template.py --input full_text.txt --output result.json --prompt "system prompt text"
|
||||
python pid_extract_template.py --input full_text.txt --output result.json --prompt-file prompt.txt
|
||||
|
||||
환경 변수:
|
||||
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
|
||||
VLLM_MODEL: 모델명 (기본: llm-model.json 참조)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from config import get_vllm_model
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("pid_extractor")
|
||||
|
||||
|
||||
def parse_json_array(raw: str, finish_reason: str = "") -> list:
|
||||
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
|
||||
if raw.startswith("```"):
|
||||
lines = raw.splitlines()
|
||||
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
|
||||
|
||||
if finish_reason == "length":
|
||||
last_close = raw.rfind("}")
|
||||
if last_close != -1:
|
||||
raw = raw[:last_close + 1] + "]"
|
||||
|
||||
# 가장 긴 균형 잡힌 [...] 추출
|
||||
depth = 0
|
||||
start = -1
|
||||
best = ""
|
||||
for i, c in enumerate(raw):
|
||||
if c == "[":
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif c == "]":
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
cand = raw[start:i + 1]
|
||||
if len(cand) > len(best):
|
||||
best = cand
|
||||
raw = best if best else "[]"
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = []
|
||||
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
|
||||
try:
|
||||
data.append(json.loads(obj))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> List[dict]:
|
||||
"""
|
||||
vLLM에 LLM 호출하여 태그 목록 추출.
|
||||
|
||||
Args:
|
||||
system_prompt: 시스템 프롬프트
|
||||
user_text: 입력 텍스트
|
||||
max_tokens: 최대 토큰 수
|
||||
|
||||
Returns:
|
||||
추출된 태그 목록 (JSON 배열)
|
||||
"""
|
||||
from openai import OpenAI
|
||||
|
||||
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
model = os.environ.get("VLLM_MODEL") or get_vllm_model()
|
||||
|
||||
client = OpenAI(base_url=base_url, api_key="dummy")
|
||||
|
||||
logger.info(f"vLLM 호출: {base_url}, 모델: {model}, max_tokens: {max_tokens}")
|
||||
logger.info(f"입력 텍스트 길이: {len(user_text)}자")
|
||||
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_text},
|
||||
],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.1,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
)
|
||||
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
finish_reason = resp.choices[0].finish_reason
|
||||
|
||||
logger.info(f"LLM 응답: finish_reason={finish_reason}, 응답 길이={len(raw)}자")
|
||||
|
||||
data = parse_json_array(raw, finish_reason)
|
||||
|
||||
if finish_reason == "length":
|
||||
logger.warning(f"finish_reason=length: 응답이 잘렸습니다. 복구 시도됨. 추출된 태그 수: {len(data)}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="P&ID 태그 추출기")
|
||||
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
|
||||
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
|
||||
parser.add_argument("--prompt", type=str, default=None, help="시스템 프롬프트 (인라인)")
|
||||
parser.add_argument("--prompt-file", type=str, default=None, help="시스템 프롬프트 파일 경로")
|
||||
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수 (기본: 65536)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1. 입력 텍스트 읽기
|
||||
if not os.path.exists(args.input):
|
||||
logger.error(f"입력 파일을 찾을 수 없습니다: {args.input}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_text = f.read()
|
||||
|
||||
logger.info(f"입력 파일 읽기 완료: {len(input_text)}자")
|
||||
|
||||
# 2. 시스템 프롬프트 읽기
|
||||
system_prompt = None
|
||||
if args.prompt:
|
||||
system_prompt = args.prompt
|
||||
elif args.prompt_file:
|
||||
if not os.path.exists(args.prompt_file):
|
||||
logger.error(f"프롬프트 파일을 찾을 수 없습니다: {args.prompt_file}")
|
||||
sys.exit(1)
|
||||
with open(args.prompt_file, "r", encoding="utf-8") as f:
|
||||
system_prompt = f.read()
|
||||
else:
|
||||
logger.error("--prompt 또는 --prompt-file 중 하나를 지정해야 합니다.")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"시스템 프롬프트: {len(system_prompt)}자")
|
||||
|
||||
# 3. LLM 호출
|
||||
t0 = time.time()
|
||||
tags = call_llm(system_prompt, input_text, max_tokens=args.max_tokens)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}초")
|
||||
|
||||
# 4. 결과 JSON 쓰기
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"tags": tags,
|
||||
"processing_time_sec": round(elapsed, 1),
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"결과 저장 완료: {args.output}")
|
||||
|
||||
# 5. 요약 출력
|
||||
print(json.dumps({
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"time": round(elapsed, 1)
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
62
mcp-server/worker/pid_extract_valve.py
Normal file
62
mcp-server/worker/pid_extract_valve.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 밸브 추출기
|
||||
|
||||
FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV 등 밸브 전용 추출.
|
||||
|
||||
사용법:
|
||||
python pid_extract_valve.py --input full_text.txt --output valve.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pid_extract_template import call_llm
|
||||
from pid_extract_prompts import _VALVE_PROMPT
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("pid_extractor.valve")
|
||||
|
||||
def extract(input_text: str, max_tokens: int = 65536) -> list:
|
||||
"""밸브 태그 추출."""
|
||||
return call_llm(_VALVE_PROMPT, input_text, max_tokens=max_tokens)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="P&ID 밸브 추출기")
|
||||
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
|
||||
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
|
||||
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_text = f.read()
|
||||
|
||||
logger.info(f"입력 파일 읽기 완료: {len(input_text)}자")
|
||||
|
||||
t0 = time.time()
|
||||
tags = extract(input_text, max_tokens=args.max_tokens)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}초")
|
||||
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"count": len(tags),
|
||||
"tags": tags,
|
||||
"processing_time_sec": round(elapsed, 1),
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"결과 저장 완료: {args.output}")
|
||||
print(json.dumps({"success": True, "count": len(tags), "time": round(elapsed, 1)}, ensure_ascii=False))
|
||||
610
mcp-server/worker/pid_worker.py
Normal file
610
mcp-server/worker/pid_worker.py
Normal file
@@ -0,0 +1,610 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 파싱 전용 워커 프로세스
|
||||
|
||||
Usage: python pid_worker.py <port>
|
||||
|
||||
담당 도구:
|
||||
extract_pid_tags, match_pid_tags,
|
||||
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
|
||||
build_pid_graph_parallel, analyze_pid_impact
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
|
||||
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import io
|
||||
import json
|
||||
import asyncio
|
||||
import signal
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
import uvicorn
|
||||
|
||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
|
||||
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _llm():
|
||||
from openai import OpenAI
|
||||
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _ocr():
|
||||
from paddleocr import PaddleOCR
|
||||
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
|
||||
try:
|
||||
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
|
||||
except Exception:
|
||||
if use_gpu:
|
||||
os.environ["PADDLE_USE_GPU"] = "false"
|
||||
return _ocr()
|
||||
raise
|
||||
|
||||
# ── DB ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_db_connection():
|
||||
import psycopg
|
||||
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
|
||||
|
||||
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_text_from_dxf(filepath: str) -> str:
|
||||
import ezdxf
|
||||
from ezdxf.tools.text import plain_mtext
|
||||
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 _extract_text_from_pdf(filepath: str) -> str:
|
||||
import fitz
|
||||
doc = fitz.open(filepath)
|
||||
return "\n".join(page.get_text() for page in doc)
|
||||
|
||||
|
||||
def _extract_text_from_pdf_ocr(filepath: str) -> str:
|
||||
import fitz
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
doc = fitz.open(filepath)
|
||||
all_texts = []
|
||||
for page in doc:
|
||||
mat = fitz.Matrix(300 / 72)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
||||
result = _ocr().ocr(np.array(img), cls=True)
|
||||
if result and result[0]:
|
||||
all_texts.extend(line[1][0] for line in result[0])
|
||||
return "\n".join(all_texts)
|
||||
|
||||
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
|
||||
|
||||
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
|
||||
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
|
||||
if raw.startswith("```"):
|
||||
lines = raw.splitlines()
|
||||
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
|
||||
|
||||
if finish_reason == "length":
|
||||
last_close = raw.rfind("}")
|
||||
if last_close != -1:
|
||||
raw = raw[:last_close + 1] + "]"
|
||||
|
||||
# 가장 긴 균형 잡힌 [...] 추출
|
||||
depth = 0; start = -1; best = ""
|
||||
for i, c in enumerate(raw):
|
||||
if c == "[":
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif c == "]":
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
cand = raw[start:i + 1]
|
||||
if len(cand) > len(best):
|
||||
best = cand
|
||||
raw = best if best else "[]"
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = []
|
||||
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
|
||||
try:
|
||||
data.append(json.loads(obj))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return data
|
||||
|
||||
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
|
||||
|
||||
def _extract_pid_tags(text: str, source_type: str) -> str:
|
||||
system = (
|
||||
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||
"Extract all instrument and equipment tags from the provided text.\n"
|
||||
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
|
||||
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
|
||||
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
|
||||
"Rules:\n"
|
||||
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
|
||||
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
|
||||
"- instrumentType: leading letters of tagNo\n"
|
||||
"- equipmentName: descriptive name if present near tag, else null\n"
|
||||
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
|
||||
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
|
||||
"- Output ONLY the JSON array, no markdown, no explanation.\n"
|
||||
"- If no tags found, return: []\n"
|
||||
)
|
||||
truncated = text[:100000]
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
|
||||
],
|
||||
max_tokens=32768,
|
||||
temperature=0.1,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"count": len(data), "tags": data},
|
||||
"message": "태그 추출 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
|
||||
system = (
|
||||
"You are a P&ID to Experion tag matching expert.\n"
|
||||
"Match P&ID tags to Experion tags based on similarity.\n"
|
||||
"Return ONLY a JSON array:\n"
|
||||
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
|
||||
"- If no good match: confidence < 0.5, experionTag null\n"
|
||||
"- Output ONLY the JSON array.\n"
|
||||
)
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": (
|
||||
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
|
||||
f"Experion Tags:\n{chr(10).join(experion_tags)}"
|
||||
)},
|
||||
],
|
||||
max_tokens=16384,
|
||||
temperature=0.1,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"count": len(data), "mappings": data},
|
||||
"message": "태그 매핑 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
|
||||
|
||||
_TAG_EXTRACT_SYSTEM = (
|
||||
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||
"Extract instrument and equipment tags from the provided text.\n"
|
||||
"Return ONLY a JSON array:\n"
|
||||
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
|
||||
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
|
||||
"Rules:\n"
|
||||
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
|
||||
"- instrumentType: first 2-4 letters of tagNo\n"
|
||||
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
|
||||
"- confidence: 0.0 to 1.0\n"
|
||||
"- Output ONLY the JSON array, no markdown.\n"
|
||||
"- If no tags found, return: []\n"
|
||||
)
|
||||
|
||||
|
||||
def _parse_pid_dxf(filepath: str) -> str:
|
||||
text = _extract_text_from_dxf(filepath)
|
||||
if not text.strip():
|
||||
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
|
||||
],
|
||||
max_tokens=8192,
|
||||
temperature=0.1,
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"text": text[:10000], "count": len(data), "tags": data},
|
||||
"message": "DXF 파싱 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
|
||||
if not text.strip():
|
||||
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
|
||||
],
|
||||
max_tokens=4096,
|
||||
temperature=0.1,
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"text": text[:10000], "count": len(data), "tags": data},
|
||||
"message": "PDF 파싱 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _parse_pid_drawing(filepath: str) -> str:
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
if ext == ".dxf":
|
||||
return _parse_pid_dxf(filepath)
|
||||
elif ext == ".pdf":
|
||||
return _parse_pid_pdf(filepath)
|
||||
elif ext == ".dwg":
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
|
||||
"message": "지원하지 않는 파일 형식"
|
||||
}, ensure_ascii=False)
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
|
||||
|
||||
import time
|
||||
|
||||
async def _build_pid_graph_parallel(filepath: str) -> str:
|
||||
"""
|
||||
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
|
||||
|
||||
Phase 1: 도면 분할 + 기하 추출
|
||||
Phase 2: 전체 텍스트 1회 추출
|
||||
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
|
||||
Phase 4: 결과 통합 + tagNo 기준 중복 제거
|
||||
Phase 5: 위상 그래프 빌드 + 저장
|
||||
"""
|
||||
from pipeline.extractor import PidGeometricExtractor
|
||||
from pipeline.topology import PidTopologyBuilder
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
os.makedirs(STORAGE_DIR, exist_ok=True)
|
||||
t0 = time.time()
|
||||
basename = os.path.basename(filepath)
|
||||
worker_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
|
||||
|
||||
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
|
||||
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
|
||||
extractor = PidGeometricExtractor(filepath)
|
||||
regions = extractor.split_drawings()
|
||||
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
|
||||
|
||||
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
|
||||
extractor.extract_and_save(geo_data_path)
|
||||
with open(geo_data_path, "r", encoding="utf-8") as f:
|
||||
geo_data = json.load(f)
|
||||
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
|
||||
|
||||
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
|
||||
t2 = time.time()
|
||||
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
|
||||
full_text = _extract_text_from_dxf(filepath)
|
||||
|
||||
# 임시 디렉토리 생성 (프로세스 간 통신용)
|
||||
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
|
||||
text_path = os.path.join(temp_dir, "full_text.txt")
|
||||
with open(text_path, "w", encoding="utf-8") as f:
|
||||
f.write(full_text)
|
||||
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}자")
|
||||
|
||||
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
|
||||
t3 = time.time()
|
||||
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
|
||||
|
||||
extractors = [
|
||||
("sensor", "pid_extract_sensor.py"),
|
||||
("valve", "pid_extract_valve.py"),
|
||||
("system", "pid_extract_system.py"),
|
||||
("gauge", "pid_extract_gauge.py"),
|
||||
("pump", "pid_extract_pump.py"),
|
||||
]
|
||||
|
||||
results_dir = os.path.join(temp_dir, "results")
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
|
||||
processes = []
|
||||
for name, script in extractors:
|
||||
output_path = os.path.join(results_dir, f"{name}.json")
|
||||
script_path = os.path.join(worker_dir, script)
|
||||
cmd = [
|
||||
sys.executable, script_path,
|
||||
"--input", text_path,
|
||||
"--output", output_path,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
processes.append((name, proc, output_path))
|
||||
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
|
||||
except Exception as e:
|
||||
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
|
||||
|
||||
# 모든 프로세스 대기 (timeout=300초)
|
||||
for name, proc, output_path in processes:
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=300)
|
||||
if proc.returncode != 0:
|
||||
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
|
||||
else:
|
||||
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
|
||||
|
||||
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
|
||||
|
||||
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
|
||||
t4 = time.time()
|
||||
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
|
||||
|
||||
all_tags = []
|
||||
seen_tagnos = set()
|
||||
|
||||
for name, _, output_path in processes:
|
||||
if not os.path.exists(output_path):
|
||||
logging.warning(f"[{basename}] 결과 없음: {name}")
|
||||
continue
|
||||
try:
|
||||
with open(output_path, "r", encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
tags = result.get("tags", [])
|
||||
count_new = 0
|
||||
for tag in tags:
|
||||
tag_no = tag.get("tagNo", "")
|
||||
if tag_no and tag_no not in seen_tagnos:
|
||||
seen_tagnos.add(tag_no)
|
||||
all_tags.append(tag)
|
||||
count_new += 1
|
||||
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
|
||||
except Exception as e:
|
||||
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
|
||||
|
||||
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
|
||||
|
||||
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
|
||||
t5 = time.time()
|
||||
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
|
||||
|
||||
builder = PidTopologyBuilder(geo_data)
|
||||
builder.build_graph()
|
||||
|
||||
# 추출된 태그를 그래프에 추가
|
||||
from shapely.geometry import box as shapely_box
|
||||
for tag in all_tags:
|
||||
tag_no = tag.get("tagNo", "UNKNOWN")
|
||||
eq_name = tag.get("equipmentName")
|
||||
inst_type = tag.get("instrumentType")
|
||||
confidence = tag.get("confidence", 0.5)
|
||||
|
||||
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
|
||||
matched_bbox = None
|
||||
for entity in geo_data:
|
||||
if entity.get("clean_value", "").upper() == tag_no.upper():
|
||||
bbox = entity.get("bbox", {})
|
||||
matched_bbox = (
|
||||
bbox.get("min_x"), bbox.get("min_y"),
|
||||
bbox.get("max_x"), bbox.get("max_y")
|
||||
)
|
||||
break
|
||||
|
||||
node_id = f"tag_{tag_no}"
|
||||
if matched_bbox:
|
||||
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
|
||||
matched_bbox[2], matched_bbox[3])
|
||||
builder.G.add_node(node_id,
|
||||
type="TEXT",
|
||||
bbox=bbox_geom,
|
||||
value=tag_no,
|
||||
equipment_name=eq_name,
|
||||
instrument_type=inst_type,
|
||||
confidence=confidence)
|
||||
else:
|
||||
builder.G.add_node(node_id,
|
||||
type="TEXT",
|
||||
bbox=shapely_box(0, 0, 1, 1),
|
||||
value=tag_no,
|
||||
equipment_name=eq_name,
|
||||
instrument_type=inst_type,
|
||||
confidence=confidence)
|
||||
|
||||
# 태그-설비 연결
|
||||
equipments = [n for n, d in builder.G.nodes(data=True)
|
||||
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
|
||||
if equipments:
|
||||
eq_grid = builder._build_spatial_grid(equipments)
|
||||
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
|
||||
for tag_id in tag_ids:
|
||||
if tag_id in builder.G:
|
||||
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
|
||||
if best_match:
|
||||
builder.G.add_edge(tag_id, best_match, relation="associated_with")
|
||||
|
||||
graph_id = basename.replace(".dxf", "_graph.json")
|
||||
graph_path = os.path.join(STORAGE_DIR, graph_id)
|
||||
builder.save_graph(graph_path)
|
||||
|
||||
# 임시 디렉토리 정리
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
total_time = time.time() - t0
|
||||
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
|
||||
f"nodes={builder.G.number_of_nodes()} "
|
||||
f"edges={builder.G.number_of_edges()} "
|
||||
f"tags={len(all_tags)}")
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"graph_id": graph_id,
|
||||
"graph_path": graph_path,
|
||||
"nodes": builder.G.number_of_nodes(),
|
||||
"edges": builder.G.number_of_edges(),
|
||||
"tags_extracted": len(all_tags),
|
||||
"processing_time_sec": round(total_time, 1)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
|
||||
from pipeline.analyzer import PidAnalysisEngine
|
||||
|
||||
graph_path = os.path.join(STORAGE_DIR, graph_id)
|
||||
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
|
||||
analyzer = PidAnalysisEngine(graph_path, mapping_path)
|
||||
result = analyzer.analyze_impact(start_node_id)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _dispatch(tool: str, params: dict) -> str:
|
||||
try:
|
||||
match tool:
|
||||
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
|
||||
case "extract_pid_tags":
|
||||
return await asyncio.to_thread(_extract_pid_tags, **params)
|
||||
case "match_pid_tags":
|
||||
return await asyncio.to_thread(_match_pid_tags, **params)
|
||||
case "parse_pid_dxf":
|
||||
return await asyncio.to_thread(_parse_pid_dxf, **params)
|
||||
case "parse_pid_pdf":
|
||||
return await asyncio.to_thread(_parse_pid_pdf, **params)
|
||||
case "parse_pid_drawing":
|
||||
return await asyncio.to_thread(_parse_pid_drawing, **params)
|
||||
case "analyze_pid_impact":
|
||||
return await asyncio.to_thread(_analyze_pid_impact, **params)
|
||||
# 이미 async — 직접 await
|
||||
case "build_pid_graph_parallel":
|
||||
return await _build_pid_graph_parallel(**params)
|
||||
case _:
|
||||
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
|
||||
ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
|
||||
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
||||
|
||||
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _schedule_shutdown():
|
||||
"""
|
||||
응답 전송 완료 후 프로세스 종료 예약.
|
||||
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
|
||||
"""
|
||||
async def _do():
|
||||
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
|
||||
await asyncio.sleep(1.0)
|
||||
logging.info("One-shot worker shutting down...")
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
asyncio.create_task(_do())
|
||||
|
||||
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/execute")
|
||||
async def execute(request: Request):
|
||||
body = await request.json()
|
||||
return await _dispatch(body["tool"], body["params"])
|
||||
|
||||
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
@app.post("/execute/one_shot")
|
||||
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
|
||||
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
|
||||
body = await request.json()
|
||||
result = await _dispatch(body["tool"], body["params"])
|
||||
|
||||
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
|
||||
background_tasks.add_function(_schedule_shutdown)
|
||||
|
||||
return result
|
||||
|
||||
# ── 진입점 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
|
||||
os.makedirs(STORAGE_DIR, exist_ok=True)
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
|
||||
|
||||
|
||||
|
||||
526
mcp-server/worker/pid_worker_py.md
Normal file
526
mcp-server/worker/pid_worker_py.md
Normal file
@@ -0,0 +1,526 @@
|
||||
❯ pid_worker.py를 만들어줘
|
||||
|
||||
● 먼저 현재 디렉토리 구조를 확인하고 파일을 작성합니다.
|
||||
|
||||
Listed 1 directory (ctrl+o to expand)
|
||||
|
||||
● Bash(mkdir -p /home/windpacer/projects/ExperionCrawler/mcp-server/worker)
|
||||
⎿ Done
|
||||
|
||||
● Write(mcp-server/worker/pid_worker.py)
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Create file
|
||||
mcp-server/worker/pid_worker.py
|
||||
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
||||
1 #!/usr/bin/env python3
|
||||
2 """P&ID 파싱 전용 워커 프로세스
|
||||
3
|
||||
4 Usage: python pid_worker.py <port>
|
||||
5
|
||||
6 담당 도구:
|
||||
7 extract_pid_tags, match_pid_tags,
|
||||
8 parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
|
||||
9 build_pid_graph_parallel, analyze_pid_impact
|
||||
10 """
|
||||
11
|
||||
12 from __future__ import annotations
|
||||
13 import sys
|
||||
14 import os
|
||||
15
|
||||
16 # mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
|
||||
17 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
18
|
||||
19 import io
|
||||
20 import json
|
||||
21 import asyncio
|
||||
22 import signal
|
||||
23 import logging
|
||||
24 import re
|
||||
25 from functools import lru_cache
|
||||
26
|
||||
27 from fastapi import FastAPI, Request
|
||||
28 import uvicorn
|
||||
29
|
||||
30 # ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
31
|
||||
32 VLLM_BASE_URL = "http://localhost:8000/v1"
|
||||
33 VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8"
|
||||
34 DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform"
|
||||
35 DB_TIMEOUT = 10
|
||||
36
|
||||
37 _SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
38 STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
|
||||
39
|
||||
40 logging.basicConfig(
|
||||
41 level=logging.INFO,
|
||||
42 stream=sys.stderr,
|
||||
43 format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
|
||||
44 )
|
||||
45
|
||||
46 app = FastAPI()
|
||||
47
|
||||
48 # ── 싱글톤 ───────────────────────────────────────────────────────────────────
|
||||
49
|
||||
50 @lru_cache(maxsize=1)
|
||||
51 def _llm():
|
||||
52 from openai import OpenAI
|
||||
53 return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
54
|
||||
55
|
||||
56 @lru_cache(maxsize=1)
|
||||
57 def _ocr():
|
||||
58 from paddleocr import PaddleOCR
|
||||
59 use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
|
||||
60 try:
|
||||
61 return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
|
||||
62 except Exception:
|
||||
63 if use_gpu:
|
||||
64 os.environ["PADDLE_USE_GPU"] = "false"
|
||||
65 return _ocr()
|
||||
66 raise
|
||||
67
|
||||
68 # ── DB ───────────────────────────────────────────────────────────────────────
|
||||
69
|
||||
70 def _get_db_connection():
|
||||
71 import psycopg
|
||||
72 return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
|
||||
73
|
||||
74 # ── 텍스트 추출 ──────────────────────────────────────────────────────────────
|
||||
75
|
||||
76 def _extract_text_from_dxf(filepath: str) -> str:
|
||||
77 import ezdxf
|
||||
78 from ezdxf.tools.text import plain_mtext
|
||||
79 doc = ezdxf.readfile(filepath)
|
||||
80 msp = doc.modelspace()
|
||||
81 texts = []
|
||||
82 for entity in msp:
|
||||
83 if entity.dxftype() == "TEXT":
|
||||
84 texts.append(entity.dxf.text)
|
||||
85 elif entity.dxftype() == "MTEXT":
|
||||
86 try:
|
||||
87 plain = plain_mtext(entity.dxf.text)
|
||||
88 if plain.strip():
|
||||
89 texts.append(plain)
|
||||
90 except Exception:
|
||||
91 pass
|
||||
92 return "\n".join(texts)
|
||||
93
|
||||
94
|
||||
95 def _extract_text_from_pdf(filepath: str) -> str:
|
||||
96 import fitz
|
||||
97 doc = fitz.open(filepath)
|
||||
98 return "\n".join(page.get_text() for page in doc)
|
||||
99
|
||||
100
|
||||
101 def _extract_text_from_pdf_ocr(filepath: str) -> str:
|
||||
102 import fitz
|
||||
103 from PIL import Image
|
||||
104 import numpy as np
|
||||
105 doc = fitz.open(filepath)
|
||||
106 all_texts = []
|
||||
107 for page in doc:
|
||||
108 mat = fitz.Matrix(300 / 72)
|
||||
109 pix = page.get_pixmap(matrix=mat)
|
||||
110 img = Image.open(io.BytesIO(pix.tobytes("png")))
|
||||
111 result = _ocr().ocr(np.array(img), cls=True)
|
||||
112 if result and result[0]:
|
||||
113 all_texts.extend(line[1][0] for line in result[0])
|
||||
114 return "\n".join(all_texts)
|
||||
115
|
||||
116 # ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
|
||||
117
|
||||
118 def _parse_json_array(raw: str, finish_reason: str = "") -> list:
|
||||
119 """LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
|
||||
120 if raw.startswith("```"):
|
||||
121 lines = raw.splitlines()
|
||||
122 raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
|
||||
123
|
||||
124 if finish_reason == "length":
|
||||
125 last_close = raw.rfind("}")
|
||||
126 if last_close != -1:
|
||||
127 raw = raw[:last_close + 1] + "]"
|
||||
128
|
||||
129 # 가장 긴 균형 잡힌 [...] 추출
|
||||
130 depth = 0; start = -1; best = ""
|
||||
131 for i, c in enumerate(raw):
|
||||
132 if c == "[":
|
||||
133 if depth == 0:
|
||||
134 start = i
|
||||
135 depth += 1
|
||||
136 elif c == "]":
|
||||
137 depth -= 1
|
||||
138 if depth == 0 and start >= 0:
|
||||
139 cand = raw[start:i + 1]
|
||||
140 if len(cand) > len(best):
|
||||
141 best = cand
|
||||
142 raw = best if best else "[]"
|
||||
143
|
||||
144 try:
|
||||
145 return json.loads(raw)
|
||||
146 except json.JSONDecodeError:
|
||||
147 data = []
|
||||
148 for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
|
||||
149 try:
|
||||
150 data.append(json.loads(obj))
|
||||
151 except json.JSONDecodeError:
|
||||
152 pass
|
||||
153 return data
|
||||
154
|
||||
155 # ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
|
||||
156
|
||||
157 def _extract_pid_tags(text: str, source_type: str) -> str:
|
||||
158 system = (
|
||||
159 "You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||
160 "Extract all instrument and equipment tags from the provided text.\n"
|
||||
161 "Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
|
||||
162 '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
|
||||
163 '"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
|
||||
164 "Rules:\n"
|
||||
165 "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
|
||||
166 " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
|
||||
167 "- instrumentType: leading letters of tagNo\n"
|
||||
168 "- equipmentName: descriptive name if present near tag, else null\n"
|
||||
169 "- lineNumber/pidDrawingNo: null unless explicitly associated\n"
|
||||
170 "- confidence: 0.95 for clear tags, lower for ambiguous\n"
|
||||
171 "- Output ONLY the JSON array, no markdown, no explanation.\n"
|
||||
172 "- If no tags found, return: []\n"
|
||||
173 )
|
||||
174 truncated = text[:100000]
|
||||
175 resp = _llm().chat.completions.create(
|
||||
176 model=VLLM_MODEL,
|
||||
177 messages=[
|
||||
178 {"role": "system", "content": system},
|
||||
179 {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
|
||||
180 ],
|
||||
181 max_tokens=32768,
|
||||
182 temperature=0.1,
|
||||
183 extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
184 )
|
||||
185 raw = (resp.choices[0].message.content or "").strip()
|
||||
186 data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
187 logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
|
||||
188 return json.dumps({"success": True, "count": len(data), "tags": data},
|
||||
189 ensure_ascii=False, indent=2)
|
||||
190
|
||||
191
|
||||
192 def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
|
||||
193 system = (
|
||||
194 "You are a P&ID to Experion tag matching expert.\n"
|
||||
195 "Match P&ID tags to Experion tags based on similarity.\n"
|
||||
196 "Return ONLY a JSON array:\n"
|
||||
197 '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
|
||||
198 "- If no good match: confidence < 0.5, experionTag null\n"
|
||||
199 "- Output ONLY the JSON array.\n"
|
||||
200 )
|
||||
201 resp = _llm().chat.completions.create(
|
||||
202 model=VLLM_MODEL,
|
||||
203 messages=[
|
||||
204 {"role": "system", "content": system},
|
||||
205 {"role": "user", "content": (
|
||||
206 f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
|
||||
207 f"Experion Tags:\n{chr(10).join(experion_tags)}"
|
||||
208 )},
|
||||
209 ],
|
||||
210 max_tokens=16384,
|
||||
211 temperature=0.1,
|
||||
212 extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
213 )
|
||||
214 raw = (resp.choices[0].message.content or "").strip()
|
||||
215 data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
216 return json.dumps({"success": True, "count": len(data), "mappings": data},
|
||||
217 ensure_ascii=False, indent=2)
|
||||
218
|
||||
219 # ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
|
||||
220
|
||||
221 _TAG_EXTRACT_SYSTEM = (
|
||||
222 "You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||
223 "Extract instrument and equipment tags from the provided text.\n"
|
||||
224 "Return ONLY a JSON array:\n"
|
||||
225 '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
|
||||
226 '"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
|
||||
227 "Rules:\n"
|
||||
228 "- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
|
||||
229 "- instrumentType: first 2-4 letters of tagNo\n"
|
||||
230 "- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
|
||||
231 "- confidence: 0.0 to 1.0\n"
|
||||
232 "- Output ONLY the JSON array, no markdown.\n"
|
||||
233 "- If no tags found, return: []\n"
|
||||
234 )
|
||||
235
|
||||
236
|
||||
237 def _parse_pid_dxf(filepath: str) -> str:
|
||||
238 text = _extract_text_from_dxf(filepath)
|
||||
239 if not text.strip():
|
||||
240 return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
|
||||
241 ensure_ascii=False, indent=2)
|
||||
242
|
||||
243 resp = _llm().chat.completions.create(
|
||||
244 model=VLLM_MODEL,
|
||||
245 messages=[
|
||||
246 {"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
247 {"role": "user", "content": f"Source: dxf\n\nText:\n{text[:12000]}"},
|
||||
248 ],
|
||||
249 max_tokens=4096,
|
||||
250 temperature=0.1,
|
||||
251 )
|
||||
252 raw = (resp.choices[0].message.content or "").strip()
|
||||
253 data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
254 if not isinstance(data, list):
|
||||
255 data = []
|
||||
256 return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data},
|
||||
257 ensure_ascii=False, indent=2)
|
||||
258
|
||||
259
|
||||
260 def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
261 text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
|
||||
262 if not text.strip():
|
||||
263 return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
|
||||
264 ensure_ascii=False, indent=2)
|
||||
265
|
||||
266 resp = _llm().chat.completions.create(
|
||||
267 model=VLLM_MODEL,
|
||||
268 messages=[
|
||||
269 {"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
270 {"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
|
||||
271 ],
|
||||
272 max_tokens=4096,
|
||||
273 temperature=0.1,
|
||||
274 )
|
||||
275 raw = (resp.choices[0].message.content or "").strip()
|
||||
276 data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
277 if not isinstance(data, list):
|
||||
278 data = []
|
||||
279 return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data},
|
||||
280 ensure_ascii=False, indent=2)
|
||||
281
|
||||
282
|
||||
283 def _parse_pid_drawing(filepath: str) -> str:
|
||||
284 ext = os.path.splitext(filepath)[1].lower()
|
||||
285 if ext == ".dxf":
|
||||
286 return _parse_pid_dxf(filepath)
|
||||
287 elif ext == ".pdf":
|
||||
288 return _parse_pid_pdf(filepath)
|
||||
289 elif ext == ".dwg":
|
||||
290 return json.dumps({
|
||||
291 "success": False,
|
||||
292 "error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
|
||||
293 }, ensure_ascii=False)
|
||||
294 else:
|
||||
295 return json.dumps({
|
||||
296 "success": False,
|
||||
297 "error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
|
||||
298 }, ensure_ascii=False)
|
||||
299
|
||||
300 # ── 그래프 도구 ───────────────────────────────────────────────────────────────
|
||||
301
|
||||
302 async def _build_pid_graph_parallel(filepath: str) -> str:
|
||||
303 from pipeline.extractor import PidGeometricExtractor
|
||||
304 from pipeline.topology import PidTopologyBuilder
|
||||
305 from pipeline.mapper import IntelligentMapper
|
||||
306 from openai import AsyncOpenAI
|
||||
307
|
||||
308 os.makedirs(STORAGE_DIR, exist_ok=True)
|
||||
309
|
||||
310 # Phase 1: 기하 추출
|
||||
311 extractor = PidGeometricExtractor(filepath)
|
||||
312 geo_data_path = os.path.join(STORAGE_DIR, os.path.basename(filepath) + "_geo.json")
|
||||
313 extractor.extract_and_save(geo_data_path)
|
||||
314 with open(geo_data_path, "r", encoding="utf-8") as f:
|
||||
315 geo_data = json.load(f)
|
||||
316
|
||||
317 # 시스템 태그 조회
|
||||
318 system_tags: list[str] = []
|
||||
319 try:
|
||||
320 conn = _get_db_connection()
|
||||
321 with conn.cursor() as cur:
|
||||
322 cur.execute("SELECT tagname FROM realtime_table")
|
||||
323 system_tags = [r[0] for r in cur.fetchall()]
|
||||
324 except Exception as e:
|
||||
325 logging.warning(f"시스템 태그 조회 실패: {e}")
|
||||
326
|
||||
327 # Phase 2: 1차 위상 빌더 (Mapper용 그래프)
|
||||
328 builder = PidTopologyBuilder(geo_data)
|
||||
329 builder.build_graph()
|
||||
330
|
||||
331 # Phase 3: 병렬 LLM 매핑
|
||||
332 api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
333 mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
|
||||
334
|
||||
335 transmitter_nodes = [
|
||||
336 n for n, d in builder.G.nodes(data=True)
|
||||
337 if d.get("value", "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
|
||||
338 ]
|
||||
339 valve_nodes = [
|
||||
340 n for n, d in builder.G.nodes(data=True)
|
||||
341 if d.get("value", "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
|
||||
342 ]
|
||||
343 equipment_nodes = [
|
||||
344 n for n, d in builder.G.nodes(data=True)
|
||||
345 if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
|
||||
346 ]
|
||||
347
|
||||
348 extracted_results = await asyncio.gather(
|
||||
349 mapper.extract_transmitters(transmitter_nodes),
|
||||
350 mapper.extract_valves(valve_nodes),
|
||||
351 mapper.extract_equipment(equipment_nodes),
|
||||
352 )
|
||||
353
|
||||
354 # 매핑 결과 통합
|
||||
355 all_mapped_tags = []
|
||||
356 for res_dict in extracted_results:
|
||||
357 for node_id, mapping in res_dict.items():
|
||||
358 if mapping.resolved_tag != "UNKNOWN":
|
||||
359 node_data = builder.G.nodes[node_id]
|
||||
360 all_mapped_tags.append({
|
||||
361 "entity_id": node_id,
|
||||
362 "tagName": mapping.resolved_tag,
|
||||
363 "bbox": (
|
||||
364 node_data["bbox"].bounds
|
||||
365 if hasattr(node_data["bbox"], "bounds")
|
||||
366 else node_data["bbox"]
|
||||
367 ),
|
||||
368 "clean_value": mapping.resolved_tag,
|
||||
369 })
|
||||
370
|
||||
371 # Phase 4: 최종 위상 모델링 + 저장
|
||||
372 final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags)
|
||||
373 final_builder.build_graph()
|
||||
374
|
||||
375 graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json")
|
||||
376 graph_path = os.path.join(STORAGE_DIR, graph_id)
|
||||
377 final_builder.save_graph(graph_path)
|
||||
378
|
||||
379 logging.info(f"build_pid_graph_parallel graph_id={graph_id} "
|
||||
380 f"nodes={final_builder.G.number_of_nodes()} "
|
||||
381 f"edges={final_builder.G.number_of_edges()}")
|
||||
382 return json.dumps({
|
||||
383 "success": True,
|
||||
384 "graph_id": graph_id,
|
||||
385 "graph_path": graph_path,
|
||||
386 "nodes": final_builder.G.number_of_nodes(),
|
||||
387 "edges": final_builder.G.number_of_edges(),
|
||||
388 }, ensure_ascii=False)
|
||||
389
|
||||
390
|
||||
391 def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
|
||||
392 from pipeline.analyzer import PidAnalysisEngine
|
||||
393
|
||||
394 graph_path = os.path.join(STORAGE_DIR, graph_id)
|
||||
395 mapping_path = graph_path.replace("_graph.json", "_mapping.json")
|
||||
396 analyzer = PidAnalysisEngine(graph_path, mapping_path)
|
||||
397 result = analyzer.analyze_impact(start_node_id)
|
||||
398 return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
399
|
||||
400 # ── 요청 디스패처 ─────────────────────────────────────────────────────────────
|
||||
401
|
||||
402 async def _dispatch(tool: str, params: dict) -> str:
|
||||
403 try:
|
||||
404 match tool:
|
||||
405 case "extract_pid_tags":
|
||||
406 return _extract_pid_tags(**params)
|
||||
407 case "match_pid_tags":
|
||||
408 return _match_pid_tags(**params)
|
||||
409 case "parse_pid_dxf":
|
||||
410 return _parse_pid_dxf(**params)
|
||||
411 case "parse_pid_pdf":
|
||||
412 return _parse_pid_pdf(**params)
|
||||
413 case "parse_pid_drawing":
|
||||
414 return _parse_pid_drawing(**params)
|
||||
415 case "build_pid_graph_parallel":
|
||||
416 return await _build_pid_graph_parallel(**params)
|
||||
417 case "analyze_pid_impact":
|
||||
418 return _analyze_pid_impact(**params)
|
||||
419 case _:
|
||||
420 return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
|
||||
421 ensure_ascii=False)
|
||||
422 except Exception as e:
|
||||
423 logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
|
||||
424 return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
||||
425
|
||||
426 # ── 종료 예약 ─────────────────────────────────────────────────────────────────
|
||||
427
|
||||
428 def _schedule_shutdown():
|
||||
429 """응답 전송 완료 후 0.5초 뒤 프로세스 종료 예약."""
|
||||
430 async def _do():
|
||||
431 await asyncio.sleep(0.5)
|
||||
432 os.kill(os.getpid(), signal.SIGTERM)
|
||||
433 asyncio.create_task(_do())
|
||||
434
|
||||
435 # ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
|
||||
436
|
||||
437 @app.get("/health")
|
||||
438 async def health():
|
||||
439 return {"status": "ok"}
|
||||
├─────┼───────────────────────────────┼────────┤
|
||||
│ 9 │ atexit 정리 훅 │ 쉬움 │
|
||||
└─────┴───────────────────────────────┴────────┘
|
||||
|
||||
실제 워커 파일(worker/rag_worker.py 등)을 작성할까요, 아니면 계획서를 수정 반영하는 것으로 마무리할까요?
|
||||
|
||||
✻ Brewed for 1m 12s
|
||||
|
||||
※ recap: MCP 서버 병렬 아키텍처 설계 문서 진단 및 수정 방향 제시 완료. 다음 단계로 실제 워커 파일 작성 또는 계획서 수정
|
||||
반영 중 선택이 필요합니다. (disable recaps in /config)
|
||||
|
||||
❯ pid_worker.py를 만들어줘
|
||||
|
||||
● 먼저 현재 디렉토리 구조를 확인하고 파일을 작성합니다.
|
||||
|
||||
Listed 1 directory (ctrl+o to expand)
|
||||
|
||||
● Bash(mkdir -p /home/windpacer/projects/ExperionCrawler/mcp-server/worker)
|
||||
⎿ Done
|
||||
|
||||
● Write(mcp-server/worker/pid_worker.py)
|
||||
⎿ Wrote 461 lines to mcp-server/worker/pid_worker.py
|
||||
1 #!/usr/bin/env python3
|
||||
2 """P&ID 파싱 전용 워커 프로세스
|
||||
3
|
||||
4 Usage: python pid_worker.py <port>
|
||||
5
|
||||
6 담당 도구:
|
||||
7 extract_pid_tags, match_pid_tags,
|
||||
8 parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
|
||||
9 build_pid_graph_parallel, analyze_pid_impact
|
||||
10 """
|
||||
… +451 lines (ctrl+o to expand)
|
||||
|
||||
● 문법 오류 없는지 확인합니다.
|
||||
|
||||
● Bash(cd /home/windpacer/projects/ExperionCrawler/mcp-server && .venv/bin/python -m py_compile worker/pid_worker.py &&
|
||||
echo "OK")
|
||||
⎿ OK
|
||||
|
||||
● 문법 오류 없음. 완성된 파일을 확인합니다.
|
||||
|
||||
Read 1 file (ctrl+o to expand)
|
||||
|
||||
● mcp-server/worker/pid_worker.py (461줄) 생성 완료. 진단에서 발견한 수정 사항이 모두 반영됐습니다.
|
||||
|
||||
---
|
||||
적용된 수정 목록
|
||||
|
||||
┌──────────┬────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Fix │ 내용 │
|
||||
├──────────┼────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✅ Fix 1 │ RAG 워커 혼입 없음 — P&ID 도구만 포함 │
|
||||
├──────────┼────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✅ Fix 2 │ app = FastAPI() 선언 │
|
||||
├──────────┼────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✅ Fix 3 │ _schedule_shutdown() — asyncio.create_task로 0.5초 지연 후 SIGTERM, 응답 먼저 반환 │
|
||||
├──────────┼────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✅ Fix 4 │ Request.json() 바디 파싱 │
|
||||
├──────────┼────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✅ Fix 7 │ /health 엔드포인트 추가 (메인 서버 헬스체크 루프용) │
|
||||
├──────────┼────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✅ Fix 8 │ 워커는 stdout/stderr 제어 없음, 메인 서버에서 DEVNULL로 실행 │
|
||||
└──────────┴────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
구조 요약
|
||||
|
||||
/execute → 일반 요청 (워커 유지)
|
||||
/execute/one_shot → P&ID 요청 후 자동 종료
|
||||
/health → 워커 준비 확인용
|
||||
|
||||
pipeline 모듈은 sys.path 조작으로 mcp-server/pipeline/을 자동 인식합니다. 실행: python worker/pid_worker.py 5004
|
||||
284
mcp-server/worker/pid_worker_py_진단보고서.md
Normal file
284
mcp-server/worker/pid_worker_py_진단보고서.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# `pid_worker.py` 진단 보고서 (수정본)
|
||||
|
||||
**작성일**: 2026-05-03
|
||||
**파일 경로**: `mcp-server/worker/pid_worker.py`
|
||||
**총 라인 수**: 609줄
|
||||
**최종 검토자**: Roo
|
||||
**검토일**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 용도 | P&ID 도면 파싱 전용 MCP 워커 프로세스 (FastAPI 기반) |
|
||||
| 진입점 | `if __name__ == "__main__"` (560줄) |
|
||||
| 포트 | 기본 5004 (명령줄 인자로 변경 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 주요 기능 (7개 도구)
|
||||
|
||||
| 도구 | 설명 | 동기/비동기 | 라인 |
|
||||
|------|------|-------------|------|
|
||||
| [`extract_pid_tags()`](#21-extract_pid_tags) | 텍스트에서 P&ID 태그 추출 | 동기 | 157 |
|
||||
| [`match_pid_tags()`](#22-match_pid_tags) | P&ID 태그 ↔ Experion 태그 매핑 | 동기 | 192 |
|
||||
| [`parse_pid_dxf()`](#23-parse_pid_dxf) | DXF 파일 파싱 | 동기 | 237 |
|
||||
| [`parse_pid_pdf()`](#24-parse_pid_pdf) | PDF 파일 파싱 (OCR 포함) | 동기 | 260 |
|
||||
| [`parse_pid_drawing()`](#25-parse_pid_drawing) | DXF/PDF 자동 분기 | 동기 | 283 |
|
||||
| [`build_pid_graph_parallel()`](#26-build_pid_graph_parallel) | 병렬 P&ID 그래프 구축 | 비동기 | 302 |
|
||||
| [`analyze_pid_impact()`](#27-analyze_pid_impact) | 그래프 영향도 분석 | 동기 | 391 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 상세 기능 분석
|
||||
|
||||
### 3.1 `extract_pid_tags()`
|
||||
|
||||
```python
|
||||
def _extract_pid_tags(text: str, source_type: str) -> str:
|
||||
```
|
||||
|
||||
- **기능**: LLM을 사용하여 입력 텍스트에서 P&ID 태그(FCV-101, P-10101 등) 추출
|
||||
- **LLM 호출**: `max_tokens=32768`, `temperature=0.1`
|
||||
- **반환 형식**: `{"success": true, "count": N, "tags": [...]}`
|
||||
|
||||
### 3.2 `match_pid_tags()`
|
||||
|
||||
```python
|
||||
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
|
||||
```
|
||||
|
||||
- **기능**: P&ID 태그 목록과 Experion 태그 목록을 유사도 기반으로 매핑
|
||||
- **LLM 호출**: `max_tokens=16384`, `temperature=0.1`
|
||||
- **반환 형식**: `{"success": true, "count": N, "mappings": [...]}`
|
||||
|
||||
### 3.3 `parse_pid_dxf()`
|
||||
|
||||
```python
|
||||
def _parse_pid_dxf(filepath: str) -> str:
|
||||
```
|
||||
|
||||
- **기능**: DXF 파일에서 TEXT/MTEXT 엔티티 추출 → LLM으로 태그 파싱
|
||||
- **사용 라이브러리**: `ezdxf` (DXF 파싱), `openai` (LLM 호출)
|
||||
- **반환 형식**: `{"success": true, "text": "...", "count": N, "tags": [...]}`
|
||||
|
||||
### 3.4 `parse_pid_pdf()`
|
||||
|
||||
```python
|
||||
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
```
|
||||
|
||||
- **기능**: PDF 파일에서 텍스트 추출 (OCR 포함) → LLM으로 태그 파싱
|
||||
- **사용 라이브러리**: `fitz` (PyMuPDF), `paddleocr`, `PIL`, `numpy`
|
||||
- **OCR 옵션**: `use_ocr=True`이면 300 DPI로 이미지 변환 후 OCR
|
||||
|
||||
### 3.5 `parse_pid_drawing()`
|
||||
|
||||
```python
|
||||
def _parse_pid_drawing(filepath: str) -> str:
|
||||
```
|
||||
|
||||
- **기능**: 확장자에 따라 `_parse_pid_dxf()` 또는 `_parse_pid_pdf()` 호출
|
||||
- **지원 형식**: `.dxf`, `.pdf`
|
||||
- **거부 형식**: `.dwg` (직접 파싱 불가)
|
||||
|
||||
### 3.6 `build_pid_graph_parallel()`
|
||||
|
||||
```python
|
||||
async def _build_pid_graph_parallel(filepath: str) -> str:
|
||||
```
|
||||
|
||||
- **기능**: 4단계 파이프라인으로 P&ID 그래프 구축
|
||||
1. **Phase 1**: `PidGeometricExtractor`로 기하 정보 추출
|
||||
2. **Phase 2**: `PidTopologyBuilder`로 1차 그래프 구축
|
||||
3. **Phase 3**: `IntelligentMapper`로 병렬 LLM 매핑 (3개 gather)
|
||||
4. **Phase 4**: 최종 그래프 저장
|
||||
- **병렬 처리**: `asyncio.gather()`로 transmitters/valves/equipment 3개 LLM 호출 병렬 실행
|
||||
- **반환 형식**: `{"success": true, "graph_id": "...", "nodes": N, "edges": N}`
|
||||
|
||||
### 3.7 `analyze_pid_impact()`
|
||||
|
||||
```python
|
||||
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
|
||||
```
|
||||
|
||||
- **기능**: 그래프에서 특정 노드 장애 시 영향도 분석
|
||||
- **사용 라이브러리**: `pipeline.analyzer.PidAnalysisEngine`
|
||||
- **반환 형식**: 분석 결과 JSON
|
||||
|
||||
---
|
||||
|
||||
## 4. 주요 문제점
|
||||
|
||||
### 4.1 이벤트 루프 블로킹 문제 (HIGH) — ❌ **이미 해결됨**
|
||||
|
||||
**보고서 오류**: 보고서가 인용한 코드는 구버전 기준입니다.
|
||||
|
||||
**현재 코드** (406-417줄):
|
||||
```python
|
||||
async def _dispatch(tool: str, params: dict) -> str:
|
||||
try:
|
||||
match tool:
|
||||
case "extract_pid_tags":
|
||||
return await asyncio.to_thread(_extract_pid_tags, **params) # ✅
|
||||
case "match_pid_tags":
|
||||
return await asyncio.to_thread(_match_pid_tags, **params) # ✅
|
||||
case "parse_pid_dxf":
|
||||
return await asyncio.to_thread(_parse_pid_dxf, **params) # ✅
|
||||
case "parse_pid_pdf":
|
||||
return await asyncio.to_thread(_parse_pid_pdf, **params) # ✅
|
||||
case "parse_pid_drawing":
|
||||
return await asyncio.to_thread(_parse_pid_drawing, **params) # ✅
|
||||
case "analyze_pid_impact":
|
||||
return await asyncio.to_thread(_analyze_pid_impact, **params) # ✅
|
||||
case "build_pid_graph_parallel":
|
||||
return await _build_pid_graph_parallel(**params) # ✅ (이미 async)
|
||||
```
|
||||
|
||||
**판정**: ✅ **문제 없음** — `asyncio.to_thread()`로 이미 수정됨
|
||||
|
||||
---
|
||||
|
||||
### 4.2 에러 핸들링 불균형 (MED) — ❌ **진단 오류**
|
||||
|
||||
**보고서 오류**: 보고서는 `_extract_pid_tags()`에 try-catch가 없다고 지적했지만, `_dispatch()`가 이미 전체를 감싸고 있습니다.
|
||||
|
||||
**현재 코드** (402-426줄):
|
||||
```python
|
||||
async def _dispatch(tool: str, params: dict) -> str:
|
||||
try:
|
||||
match tool:
|
||||
# ... 모든 도구 호출
|
||||
except Exception as e:
|
||||
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
|
||||
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
||||
```
|
||||
|
||||
**설계 의도**:
|
||||
- 통합 예외 처리 패턴 — 개별 함수에 try-catch를 중복하면 예외가 두 번 잡혀 로그가 이중으로 찍히고, 에러 응답 형식도 제각각이 됩니다.
|
||||
- `_dispatch()`에서 통합 핸들링하는 것이 **의도적인 올바른 설계**입니다.
|
||||
|
||||
**판정**: ❌ **진단 오류** — 의도적인 계층 분리 패턴을 결함으로 오판
|
||||
|
||||
---
|
||||
|
||||
### 4.3 메모리 고정 문제 (MED) — ❌ **설계 오류 아님**
|
||||
|
||||
**보고서 오류**: `@lru_cache`로 `_llm()`과 `_ocr()`이 고정되어 메모리 증가한다고 지적했지만, 이는 **의도적인 올바른 설계**입니다.
|
||||
|
||||
**현재 코드** (50-66줄):
|
||||
```python
|
||||
@lru_cache(maxsize=1)
|
||||
def _llm():
|
||||
from openai import OpenAI
|
||||
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _ocr():
|
||||
from paddleocr import PaddleOCR
|
||||
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
|
||||
try:
|
||||
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
|
||||
except Exception:
|
||||
if use_gpu:
|
||||
os.environ["PADDLE_USE_GPU"] = "false"
|
||||
return _ocr()
|
||||
raise
|
||||
```
|
||||
|
||||
**설계 의도**:
|
||||
- OpenAI 클라이언트는 내부에 `httpx` 커넥션 풀을 가지므로, 요청마다 새로 생성하면 오히려 비효율입니다.
|
||||
- 워커는 one-shot 모드이므로 실질적 영향은 적습니다.
|
||||
- `@lru_cache`는 **의도적인 올바른 설계**입니다.
|
||||
|
||||
**판정**: ❌ **진단 오류** — `@lru_cache`가 올바른 패턴
|
||||
|
||||
---
|
||||
|
||||
### 4.4 중복 코드 (LOW) — ⚠️ **부분 오류**
|
||||
|
||||
**보고서 오류**: `_extract_pid_tags()`와 `_parse_pid_dxf()`가 LLM 호출 패턴을 반복한다고 지적했지만, 이는 **의도적인 파라미터 차이**입니다.
|
||||
|
||||
**실제 차이점**:
|
||||
|
||||
| 항목 | `_extract_pid_tags()` | `_parse_pid_dxf()` |
|
||||
|------|----------------------|-------------------|
|
||||
| `max_tokens` | 32768 | 4096 |
|
||||
| `extra_body` | `{"chat_template_kwargs": {"enable_thinking": False}}` | 없음 |
|
||||
| 입력 크기 | 최대 100,000자 | 최대 12,000자 |
|
||||
|
||||
**설명**:
|
||||
- `_extract_pid_tags()`는 긴 텍스트(100k)를 처리하고 `enable_thinking=False`로 최적화
|
||||
- `_parse_pid_dxf()`는 짧은 텍스트(12k)를 처리하고 기본 설정 사용
|
||||
- 이 차이를 공통 함수로 묶으면 파라미터 분기 로직이 오히려 복잡해집니다.
|
||||
|
||||
**판정**: ⚠️ **부분 오류** — 유사해 보여도 다른 목적의 호출
|
||||
|
||||
---
|
||||
|
||||
### 4.5 설정 하드코딩 (LOW) — ✅ **유효한 지적**
|
||||
|
||||
**현재 코드** (32-35줄):
|
||||
```python
|
||||
VLLM_BASE_URL = "http://localhost:8000/v1"
|
||||
VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8"
|
||||
DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform"
|
||||
DB_TIMEOUT = 10
|
||||
```
|
||||
|
||||
**권장**: 환경 변수 또는 설정 파일로 분리
|
||||
|
||||
```python
|
||||
import os
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-Coder-Next-FP8")
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
```
|
||||
|
||||
**판정**: ✅ **유효한 지적**
|
||||
|
||||
---
|
||||
|
||||
## 5. 양호한 점
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| ✅ 병렬 처리 구현 | `build_pid_graph_parallel()`에서 `asyncio.gather()`로 3개 LLM 호출 병렬 실행 |
|
||||
| ✅ 일관된 JSON 응답 | 모든 도구가 `{"success": bool, ...}` 형식으로 응답 |
|
||||
| ✅ 한국어 OCR 지원 | PaddleOCR를 `lang="korean"`으로 설정 |
|
||||
| ✅ 자동 폴더 생성 | `os.makedirs(STORAGE_DIR, exist_ok=True)`로 디렉토리 자동 생성 |
|
||||
| ✅ 종료 신호 처리 | `_schedule_shutdown()`으로 응답 후 0.5초 뒤 SIGTERM 전송 |
|
||||
| ✅ 이벤트 루프 비블로킹 | `_dispatch()`에서 `asyncio.to_thread()`로 동기 함수 오프로드 |
|
||||
| ✅ 통합 예외 처리 | `_dispatch()`에서 전체 예외를 통합 처리 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 최종 판정 요약
|
||||
|
||||
| 항목 | 보고서 심각도 | 실제 판정 | 설명 |
|
||||
|------|--------------|----------|------|
|
||||
| 4.1 이벤트 루프 블로킹 | 🔴 HIGH | ✅ **이미 해결됨** | `asyncio.to_thread()` 적용됨 |
|
||||
| 4.2 에러 핸들링 | 🟡 MED | ❌ **진단 오류** | 통합 예외 처리가 의도적인 설계 |
|
||||
| 4.3 메모리 고정 | 🟡 MED | ❌ **설계 오류 아님** | `@lru_cache`가 올바른 패턴 |
|
||||
| 4.4 중복 코드 | 🟢 LOW | ⚠️ **부분 오류** | 의도적인 파라미터 차이 |
|
||||
| 4.5 설정 하드코딩 | 🟢 LOW | ✅ **유효** | 환경 변수로 분리 권장 |
|
||||
|
||||
**총 결론**: 보고서의 5개 지적 중 **4개는 틀렸거나 이미 해결된 상태**입니다. 유일하게 유효한 지적은 **설정 하드코딩(4.5)** 뿐입니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 최종 개선 우선순위
|
||||
|
||||
| 우선순위 | 항목 | 작업 |
|
||||
|----------|------|------|
|
||||
| 🟢 LOW | 설정 외부화 | 환경 변수 또는 설정 파일 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Roo의 최종 평가
|
||||
|
||||
> "보고서의 5개 지적 중 4개는 틀렸거나 이미 해결된 상태입니다. 유일하게 유효한 지적은 설정 하드코딩(4.5)뿐입니다."
|
||||
|
||||
**pid_worker.py는 이미 올바르게 설계되어 있으며, 추가 수정이 필요하지 않습니다.**
|
||||
610
mcp-server/worker/pid_worker_test.py
Normal file
610
mcp-server/worker/pid_worker_test.py
Normal file
@@ -0,0 +1,610 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
|
||||
|
||||
Usage: python pid_worker_test.py <port>
|
||||
|
||||
담당 도구:
|
||||
extract_pid_tags, match_pid_tags,
|
||||
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
|
||||
build_pid_graph_parallel, analyze_pid_impact
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
import io
|
||||
import json
|
||||
import asyncio
|
||||
import signal
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import FastAPI, Request, BackgroundTasks
|
||||
import uvicorn
|
||||
|
||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
|
||||
_SERVER_DIR = PROJECT_ROOT
|
||||
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _llm():
|
||||
from openai import OpenAI
|
||||
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _ocr():
|
||||
from paddleocr import PaddleOCR
|
||||
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
|
||||
try:
|
||||
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
|
||||
except Exception:
|
||||
if use_gpu:
|
||||
os.environ["PADDLE_USE_GPU"] = "false"
|
||||
return _ocr()
|
||||
raise
|
||||
|
||||
# ── DB ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_db_connection():
|
||||
import psycopg
|
||||
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
|
||||
|
||||
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_text_from_dxf(filepath: str) -> str:
|
||||
import ezdxf
|
||||
from ezdxf.tools.text import plain_mtext
|
||||
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 _extract_text_from_pdf(filepath: str) -> str:
|
||||
import fitz
|
||||
doc = fitz.open(filepath)
|
||||
return "\n".join(page.get_text() for page in doc)
|
||||
|
||||
|
||||
def _extract_text_from_pdf_ocr(filepath: str) -> str:
|
||||
import fitz
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
doc = fitz.open(filepath)
|
||||
all_texts = []
|
||||
for page in doc:
|
||||
mat = fitz.Matrix(300 / 72)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
||||
result = _ocr().ocr(np.array(img), cls=True)
|
||||
if result and result[0]:
|
||||
all_texts.extend(line[1][0] for line in result[0])
|
||||
return "\n".join(all_texts)
|
||||
|
||||
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
|
||||
|
||||
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
|
||||
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
|
||||
if raw.startswith("```"):
|
||||
lines = raw.splitlines()
|
||||
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
|
||||
|
||||
if finish_reason == "length":
|
||||
last_close = raw.rfind("}")
|
||||
if last_close != -1:
|
||||
raw = raw[:last_close + 1] + "]"
|
||||
|
||||
# 가장 긴 균형 잡힌 [...] 추출
|
||||
depth = 0; start = -1; best = ""
|
||||
for i, c in enumerate(raw):
|
||||
if c == "[":
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif c == "]":
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
cand = raw[start:i + 1]
|
||||
if len(cand) > len(best):
|
||||
best = cand
|
||||
raw = best if best else "[]"
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = []
|
||||
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
|
||||
try:
|
||||
data.append(json.loads(obj))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return data
|
||||
|
||||
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
|
||||
|
||||
def _extract_pid_tags(text: str, source_type: str) -> str:
|
||||
system = (
|
||||
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||
"Extract all instrument and equipment tags from the provided text.\n"
|
||||
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
|
||||
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
|
||||
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
|
||||
"Rules:\n"
|
||||
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
|
||||
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
|
||||
"- instrumentType: leading letters of tagNo\n"
|
||||
"- equipmentName: descriptive name if present near tag, else null\n"
|
||||
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
|
||||
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
|
||||
"- Output ONLY the JSON array, no markdown, no explanation.\n"
|
||||
"- If no tags found, return: []\n"
|
||||
)
|
||||
truncated = text[:100000]
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
|
||||
],
|
||||
max_tokens=32768,
|
||||
temperature=0.1,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"count": len(data), "tags": data},
|
||||
"message": "태그 추출 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
|
||||
system = (
|
||||
"You are a P&ID to Experion tag matching expert.\n"
|
||||
"Match P&ID tags to Experion tags based on similarity.\n"
|
||||
"Return ONLY a JSON array:\n"
|
||||
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
|
||||
"- If no good match: confidence < 0.5, experionTag null\n"
|
||||
"- Output ONLY the JSON array.\n"
|
||||
)
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": (
|
||||
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
|
||||
f"Experion Tags:\n{chr(10).join(experion_tags)}"
|
||||
)},
|
||||
],
|
||||
max_tokens=16384,
|
||||
temperature=0.1,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"count": len(data), "mappings": data},
|
||||
"message": "태그 매핑 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
|
||||
|
||||
_TAG_EXTRACT_SYSTEM = (
|
||||
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||
"Extract instrument and equipment tags from the provided text.\n"
|
||||
"Return ONLY a JSON array:\n"
|
||||
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
|
||||
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
|
||||
"Rules:\n"
|
||||
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
|
||||
"- instrumentType: first 2-4 letters of tagNo\n"
|
||||
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
|
||||
"- confidence: 0.0 to 1.0\n"
|
||||
"- Output ONLY the JSON array, no markdown.\n"
|
||||
"- If no tags found, return: []\n"
|
||||
)
|
||||
|
||||
|
||||
def _parse_pid_dxf(filepath: str) -> str:
|
||||
text = _extract_text_from_dxf(filepath)
|
||||
if not text.strip():
|
||||
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
|
||||
],
|
||||
max_tokens=8192,
|
||||
temperature=0.1,
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"text": text[:10000], "count": len(data), "tags": data},
|
||||
"message": "DXF 파싱 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
|
||||
if not text.strip():
|
||||
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
|
||||
],
|
||||
max_tokens=4096,
|
||||
temperature=0.1,
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
data = _parse_json_array(raw, resp.choices[0].finish_reason)
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"data": {"text": text[:10000], "count": len(data), "tags": data},
|
||||
"message": "PDF 파싱 완료"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _parse_pid_drawing(filepath: str) -> str:
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
if ext == ".dxf":
|
||||
return _parse_pid_dxf(filepath)
|
||||
elif ext == ".pdf":
|
||||
return _parse_pid_pdf(filepath)
|
||||
elif ext == ".dwg":
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
|
||||
"message": "지원하지 않는 파일 형식"
|
||||
}, ensure_ascii=False)
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
|
||||
|
||||
import time
|
||||
|
||||
async def _build_pid_graph_parallel(filepath: str) -> str:
|
||||
"""
|
||||
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
|
||||
|
||||
Phase 1: 도면 분할 + 기하 추출
|
||||
Phase 2: 전체 텍스트 1회 추출
|
||||
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
|
||||
Phase 4: 결과 통합 + tagNo 기준 중복 제거
|
||||
Phase 5: 위상 그래프 빌드 + 저장
|
||||
"""
|
||||
from pipeline.extractor import PidGeometricExtractor
|
||||
from pipeline.topology import PidTopologyBuilder
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
os.makedirs(STORAGE_DIR, exist_ok=True)
|
||||
t0 = time.time()
|
||||
# 절대 경로로 변환하여 파일 경로 문제 해결
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = os.path.join(PROJECT_ROOT, filepath)
|
||||
filepath = os.path.abspath(filepath)
|
||||
basename = os.path.basename(filepath)
|
||||
worker_dir = os.path.join(PROJECT_ROOT, "mcp-server", "worker")
|
||||
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
|
||||
|
||||
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
|
||||
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
|
||||
extractor = PidGeometricExtractor(filepath)
|
||||
regions = extractor.split_drawings()
|
||||
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
|
||||
|
||||
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
|
||||
extractor.extract_and_save(geo_data_path)
|
||||
with open(geo_data_path, "r", encoding="utf-8") as f:
|
||||
geo_data = json.load(f)
|
||||
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
|
||||
|
||||
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
|
||||
t2 = time.time()
|
||||
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
|
||||
full_text = _extract_text_from_dxf(filepath)
|
||||
|
||||
# 임시 디렉토리 생성 (프로세스 간 통신용)
|
||||
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
|
||||
text_path = os.path.join(temp_dir, "full_text.txt")
|
||||
with open(text_path, "w", encoding="utf-8") as f:
|
||||
f.write(full_text)
|
||||
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}자")
|
||||
|
||||
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
|
||||
t3 = time.time()
|
||||
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
|
||||
|
||||
extractors = [
|
||||
("sensor", "pid_extract_sensor.py"),
|
||||
("valve", "pid_extract_valve.py"),
|
||||
("system", "pid_extract_system.py"),
|
||||
("gauge", "pid_extract_gauge.py"),
|
||||
("pump", "pid_extract_pump.py"),
|
||||
]
|
||||
|
||||
results_dir = os.path.join(temp_dir, "results")
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
|
||||
processes = []
|
||||
for name, script in extractors:
|
||||
output_path = os.path.join(results_dir, f"{name}.json")
|
||||
script_path = os.path.join(worker_dir, script)
|
||||
cmd = [
|
||||
sys.executable, script_path,
|
||||
"--input", text_path,
|
||||
"--output", output_path,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
processes.append((name, proc, output_path))
|
||||
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
|
||||
except Exception as e:
|
||||
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
|
||||
|
||||
# 모든 프로세스 대기 (timeout=300초)
|
||||
for name, proc, output_path in processes:
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=300)
|
||||
if proc.returncode != 0:
|
||||
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
|
||||
else:
|
||||
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
|
||||
|
||||
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
|
||||
|
||||
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
|
||||
t4 = time.time()
|
||||
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
|
||||
|
||||
all_tags = []
|
||||
seen_tagnos = set()
|
||||
|
||||
for name, _, output_path in processes:
|
||||
if not os.path.exists(output_path):
|
||||
logging.warning(f"[{basename}] 결과 없음: {name}")
|
||||
continue
|
||||
try:
|
||||
with open(output_path, "r", encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
tags = result.get("tags", [])
|
||||
count_new = 0
|
||||
for tag in tags:
|
||||
tag_no = tag.get("tagNo", "")
|
||||
if tag_no and tag_no not in seen_tagnos:
|
||||
seen_tagnos.add(tag_no)
|
||||
all_tags.append(tag)
|
||||
count_new += 1
|
||||
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
|
||||
except Exception as e:
|
||||
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
|
||||
|
||||
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
|
||||
|
||||
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
|
||||
t5 = time.time()
|
||||
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
|
||||
|
||||
builder = PidTopologyBuilder(geo_data)
|
||||
builder.build_graph()
|
||||
|
||||
# 추출된 태그를 그래프에 추가
|
||||
from shapely.geometry import box as shapely_box
|
||||
for tag in all_tags:
|
||||
tag_no = tag.get("tagNo", "UNKNOWN")
|
||||
eq_name = tag.get("equipmentName")
|
||||
inst_type = tag.get("instrumentType")
|
||||
confidence = tag.get("confidence", 0.5)
|
||||
|
||||
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
|
||||
matched_bbox = None
|
||||
for entity in geo_data:
|
||||
if entity.get("clean_value", "").upper() == tag_no.upper():
|
||||
bbox = entity.get("bbox", {})
|
||||
matched_bbox = (
|
||||
bbox.get("min_x"), bbox.get("min_y"),
|
||||
bbox.get("max_x"), bbox.get("max_y")
|
||||
)
|
||||
break
|
||||
|
||||
node_id = f"tag_{tag_no}"
|
||||
if matched_bbox:
|
||||
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
|
||||
matched_bbox[2], matched_bbox[3])
|
||||
builder.G.add_node(node_id,
|
||||
type="TEXT",
|
||||
bbox=bbox_geom,
|
||||
value=tag_no,
|
||||
equipment_name=eq_name,
|
||||
instrument_type=inst_type,
|
||||
confidence=confidence)
|
||||
else:
|
||||
builder.G.add_node(node_id,
|
||||
type="TEXT",
|
||||
bbox=shapely_box(0, 0, 1, 1),
|
||||
value=tag_no,
|
||||
equipment_name=eq_name,
|
||||
instrument_type=inst_type,
|
||||
confidence=confidence)
|
||||
|
||||
# 태그-설비 연결
|
||||
equipments = [n for n, d in builder.G.nodes(data=True)
|
||||
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
|
||||
if equipments:
|
||||
eq_grid = builder._build_spatial_grid(equipments)
|
||||
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
|
||||
for tag_id in tag_ids:
|
||||
if tag_id in builder.G:
|
||||
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
|
||||
if best_match:
|
||||
builder.G.add_edge(tag_id, best_match, relation="associated_with")
|
||||
|
||||
graph_id = basename.replace(".dxf", "_graph.json")
|
||||
graph_path = os.path.join(STORAGE_DIR, graph_id)
|
||||
builder.save_graph(graph_path)
|
||||
|
||||
# 임시 디렉토리 정리
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
total_time = time.time() - t0
|
||||
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
|
||||
f"nodes={builder.G.number_of_nodes()} "
|
||||
f"edges={builder.G.number_of_edges()} "
|
||||
f"tags={len(all_tags)}")
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"graph_id": graph_id,
|
||||
"graph_path": graph_path,
|
||||
"nodes": builder.G.number_of_nodes(),
|
||||
"edges": builder.G.number_of_edges(),
|
||||
"tags_extracted": len(all_tags),
|
||||
"processing_time_sec": round(total_time, 1)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
|
||||
from pipeline.analyzer import PidAnalysisEngine
|
||||
|
||||
graph_path = os.path.join(STORAGE_DIR, graph_id)
|
||||
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
|
||||
analyzer = PidAnalysisEngine(graph_path, mapping_path)
|
||||
result = analyzer.analyze_impact(start_node_id)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _dispatch(tool: str, params: dict) -> str:
|
||||
try:
|
||||
match tool:
|
||||
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
|
||||
case "extract_pid_tags":
|
||||
return await asyncio.to_thread(_extract_pid_tags, **params)
|
||||
case "match_pid_tags":
|
||||
return await asyncio.to_thread(_match_pid_tags, **params)
|
||||
case "parse_pid_dxf":
|
||||
return await asyncio.to_thread(_parse_pid_dxf, **params)
|
||||
case "parse_pid_pdf":
|
||||
return await asyncio.to_thread(_parse_pid_pdf, **params)
|
||||
case "parse_pid_drawing":
|
||||
return await asyncio.to_thread(_parse_pid_drawing, **params)
|
||||
case "analyze_pid_impact":
|
||||
return await asyncio.to_thread(_analyze_pid_impact, **params)
|
||||
# 이미 async — 직접 await
|
||||
case "build_pid_graph_parallel":
|
||||
return await _build_pid_graph_parallel(**params)
|
||||
case _:
|
||||
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
|
||||
ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
|
||||
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
||||
|
||||
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _schedule_shutdown():
|
||||
"""
|
||||
응답 전송 완료 후 프로세스 종료 예약.
|
||||
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
|
||||
"""
|
||||
async def _do():
|
||||
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
|
||||
await asyncio.sleep(1.0)
|
||||
logging.info("One-shot worker shutting down...")
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
asyncio.create_task(_do())
|
||||
|
||||
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/execute")
|
||||
async def execute(request: Request):
|
||||
body = await request.json()
|
||||
return await _dispatch(body["tool"], body["params"])
|
||||
|
||||
|
||||
@app.post("/execute/one_shot")
|
||||
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
|
||||
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
|
||||
body = await request.json()
|
||||
result = await _dispatch(body["tool"], body["params"])
|
||||
|
||||
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
|
||||
background_tasks.add_task(_schedule_shutdown)
|
||||
|
||||
return result
|
||||
|
||||
# ── 진입점 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
|
||||
os.makedirs(STORAGE_DIR, exist_ok=True)
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
|
||||
230
mcp-server/worker/rag_worker.py
Normal file
230
mcp-server/worker/rag_worker.py
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RAG 전용 워커 프로세스
|
||||
|
||||
Usage: python rag_worker.py <port>
|
||||
|
||||
담당 도구:
|
||||
search_codebase, search_r530_docs, ask_iiot_llm, rag_query
|
||||
|
||||
특징:
|
||||
- Ollama Embedding + Qdrant 검색 + vLLM LLM 조합
|
||||
- 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시)
|
||||
- 생명주기: 메인 서버 종료 시까지 유지
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
|
||||
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
import uvicorn
|
||||
import httpx
|
||||
|
||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
|
||||
|
||||
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
|
||||
COL_OPC_DOCS = os.environ.get("COL_OPC_DOCS", "experion-opc-docs")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s [rag_worker] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# ── HTTP 클라이언트 싱글톤 ────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_http_client():
|
||||
return httpx.AsyncClient(timeout=30)
|
||||
|
||||
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _embed(text: str) -> list[float]:
|
||||
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
|
||||
async with _get_http_client() as client:
|
||||
resp = await client.post(
|
||||
f"{OLLAMA_URL}/api/embeddings",
|
||||
json={"model": EMBED_MODEL, "prompt": text},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["embedding"]
|
||||
|
||||
# ── Qdrant 검색 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]:
|
||||
"""Qdrant에서 벡터 유사도 검색."""
|
||||
async with _get_http_client() as client:
|
||||
resp = await client.post(
|
||||
f"{QDRANT_URL}/collections/{collection}/points/search",
|
||||
json={
|
||||
"vector": query_vector,
|
||||
"limit": top_k,
|
||||
"with_payload": True,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("result", [])
|
||||
|
||||
# ── LLM (vLLM) ───────────────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _llm_client():
|
||||
from openai import AsyncOpenAI
|
||||
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
|
||||
async def _ask_llm(question: str, context: str = "") -> str:
|
||||
"""vLLM LLM으로 질문 응답."""
|
||||
client = _llm_client()
|
||||
|
||||
if context:
|
||||
prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요.
|
||||
|
||||
컨텍스트:
|
||||
{context}
|
||||
|
||||
질문:
|
||||
{question}
|
||||
|
||||
답변:"""
|
||||
else:
|
||||
prompt = question
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=4096,
|
||||
temperature=0.1,
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
# ── RAG 도구 구현 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""워커 헬스체크."""
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/execute")
|
||||
async def execute(request: Request):
|
||||
"""HTTP 요청을 MCP 도구 호출로 변환."""
|
||||
body = await request.json()
|
||||
tool = body["tool"]
|
||||
params = body["params"]
|
||||
|
||||
try:
|
||||
if tool == "search_codebase":
|
||||
result = await _search_codebase(**params)
|
||||
elif tool == "search_r530_docs":
|
||||
result = await _search_r530_docs(**params)
|
||||
elif tool == "ask_iiot_llm":
|
||||
result = await _ask_iiot_llm(**params)
|
||||
elif tool == "rag_query":
|
||||
result = await _rag_query(**params)
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown tool: {tool}"}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing {tool}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _search_codebase(query: str, top_k: int = 6) -> str:
|
||||
"""소스코드 검색."""
|
||||
query_vector = await _embed(query)
|
||||
results = await _qdrant_search(COL_CODEBASE, query_vector, top_k)
|
||||
|
||||
items = []
|
||||
for hit in results:
|
||||
payload = hit.get("payload", {})
|
||||
items.append({
|
||||
"score": hit.get("score", 0),
|
||||
"file": payload.get("file", "unknown"),
|
||||
"content": payload.get("content", "")[:500],
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _search_r530_docs(query: str, top_k: int = 5) -> str:
|
||||
"""Experion HS R530 공식 문서 검색."""
|
||||
query_vector = await _embed(query)
|
||||
results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k)
|
||||
|
||||
items = []
|
||||
for hit in results:
|
||||
payload = hit.get("payload", {})
|
||||
items.append({
|
||||
"score": hit.get("score", 0),
|
||||
"title": payload.get("title", "unknown"),
|
||||
"content": payload.get("content", "")[:500],
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _ask_iiot_llm(question: str, context: str = "") -> str:
|
||||
"""IIoT/OPC UA 질문 응답."""
|
||||
answer = await _ask_llm(question, context)
|
||||
return {
|
||||
"success": True,
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
}
|
||||
|
||||
async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
|
||||
"""통합 RAG 검색."""
|
||||
contexts = []
|
||||
|
||||
if search_code:
|
||||
query_vector = await _embed(question)
|
||||
code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3)
|
||||
for hit in code_results:
|
||||
contexts.append(hit.get("payload", {}).get("content", ""))
|
||||
|
||||
if search_docs:
|
||||
query_vector = await _embed(question)
|
||||
doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3)
|
||||
for hit in doc_results:
|
||||
contexts.append(hit.get("payload", {}).get("content", ""))
|
||||
|
||||
context = "\n\n".join(contexts[:5])
|
||||
answer = await _ask_llm(question, context)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"question": question,
|
||||
"context_count": len(contexts),
|
||||
"answer": answer,
|
||||
}
|
||||
|
||||
# ── 메인 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002
|
||||
logging.info(f"Starting RAG worker on port {port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
141
mcp-server/worker/sql_prompt.py
Normal file
141
mcp-server/worker/sql_prompt.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""NL2SQL 프롬프트 단일 소스 (production + eval 공유).
|
||||
|
||||
`nl2sql_worker._generate_sql` 와 `eval/run_eval.py` 가 **동일 프롬프트**를 쓰도록 여기서 정의한다.
|
||||
순수 문자열 상수만 — 무거운 의존성 없음(import 안전). 프롬프트를 고칠 땐 여기만 고치면 됨.
|
||||
(참고: server.py 에도 별도 _DB_SCHEMA 사본이 있음 — 추후 통합 대상)
|
||||
"""
|
||||
|
||||
# DB 스키마
|
||||
DB_SCHEMA = """
|
||||
PostgreSQL 시계열 데이터베이스 스키마
|
||||
|
||||
테이블: pid_equipment (P&ID 추출 장비/계기)
|
||||
tag_no TEXT - 태그번호 (예: FIC-6113, FT-6113)
|
||||
category TEXT - 'instrument' / 'power_equipment' / 'storage_equipment' / ...
|
||||
tag_dcs BOOL - TRUE=DCS 함수블록(FIC/TIC/PIC류), FALSE=현장 물리 계기(FT/FCV류)
|
||||
tag_class TEXT - 'field'(현장) / 'system'(DCS) — tag_dcs 기반
|
||||
instrument_type TEXT - ISA prefix (FT/FIC/P 등)
|
||||
from_tag TEXT - 연결 상류 태그
|
||||
to_tag TEXT - 연결 하류 태그
|
||||
※ DCS 태그: SELECT WHERE tag_dcs=TRUE, 현장 계기: WHERE tag_dcs=FALSE AND category='instrument'
|
||||
|
||||
테이블: history_table (시계열 이력)
|
||||
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
|
||||
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
|
||||
|
||||
테이블: realtime_table (실시간 최신값)
|
||||
tagname TEXT - 태그명 (모두 소문자)
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
livevalue TEXT - 현재값
|
||||
timestamp TIMESTAMPTZ - 최종 갱신 시각
|
||||
|
||||
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
||||
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
||||
attribute TEXT - 속성명 ('desc', 'area')
|
||||
value TEXT - 메타데이터 값
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
||||
|
||||
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
|
||||
base_tag TEXT - 기본 태그명
|
||||
pv TEXT - 현재 프로세스 값
|
||||
sp TEXT - 설정값
|
||||
op TEXT - 출력값
|
||||
instate0 TEXT - 상태 비트 0 (true/false)
|
||||
instate1 TEXT - 상태 비트 1 (true/false)
|
||||
instate2 TEXT - 상태 비트 2 (true/false)
|
||||
description TEXT - 장비 설명 (tag_metadata.desc)
|
||||
area TEXT - 소속 플랜트 (tag_metadata.area)
|
||||
|
||||
뷰: v_plant_running_state (area별 펌프 운전 판정 — "어떤 플랜트가 운전 중" 질문 1순위)
|
||||
area_code TEXT - 정규화 area (예: P3, P4, P5, P6, P8)
|
||||
status TEXT - 'RUNNING' / 'TRIPPED' / 'STOPPED' (펌프 1대라도 RUN이면 RUNNING)
|
||||
running_pumps INT - R-RUN/L-RUN 펌프 수
|
||||
tripped_pumps INT - R-TRIP/L-TRIP 펌프 수
|
||||
stopped_pumps INT - R-STOP/L-STOP 펌프 수
|
||||
total_pumps INT - 펌프 enum 보유 태그 수
|
||||
running_pump_tags TEXT[] - 현재 RUN 상태 펌프 base_tag 배열
|
||||
※ "운전 중인 플랜트/펌프", "트립 펌프" 류 질문은 이 뷰를 직접 SELECT (펌프 상태 SQL 직접 작성 금지)
|
||||
※ 결과에 없는 area = 펌프 미등록 → 운전 여부 단정 금지. 이 뷰는 area 레벨(sub_area 없음)
|
||||
|
||||
뷰: v_plant_running_state_corroborated (펌프별 실질 운전 — 유량/진공 교차검증, sub_area 지원)
|
||||
base_tag TEXT - 펌프 base_tag (예: 'p-6102', 'vp-6117')
|
||||
area_code TEXT - 정규화 area
|
||||
sub_area TEXT - 세부 area (예: 'P6-1'; 공용은 'P6-1,P6-2'). 필터는 LIKE '%P6-1%'
|
||||
corroborated_status TEXT - CONFIRMED_RUNNING / SUSPICIOUS_RUNNING / STALE / INDETERMINATE_RUNNING / STOPPED / TRIPPED
|
||||
flow_kg_hr DOUBLE PRECISION - 연결 유량(kg/hr)
|
||||
vacuum_torr DOUBLE PRECISION - 연결 진공압(torr=mmHg)
|
||||
※ "6-1차/6-2차" 등 sub_area 필터가 필요한 질문은 **반드시 이 뷰** 사용 (아래 agg/기본뷰는 sub_area 없음)
|
||||
|
||||
뷰: v_instrument_range (계기 단위/레인지 — tag_metadata에서 추출)
|
||||
base_tag TEXT - 기본 태그명, 접미사 없음 (예: 'ficq-6113', 'pica-6111')
|
||||
unit TEXT - 단위 (예: 'kg/hr', 'mmHg')
|
||||
eu_lo DOUBLE PRECISION - 레인지 하한
|
||||
eu_hi DOUBLE PRECISION - 레인지 상한
|
||||
※ 계기 레인지/상하한/단위 질문에 사용. base_tag는 '.pv' 등 접미사를 떼고 매칭
|
||||
|
||||
참고(직접 쓰지 말 것): v_plant_running_state_agg 도 있으나 area 레벨 집계라 sub_area가 없음.
|
||||
sub_area 질문엔 위 v_plant_running_state_corroborated 를 사용.
|
||||
|
||||
새로운 태그 타입:
|
||||
- 아날로그: ficq-6101.pv/sp/op (Double)
|
||||
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
||||
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
|
||||
- 메타데이터: desc (String), area (Enum)
|
||||
|
||||
BCD 상태 조회 팁:
|
||||
- instate0~7은 Boolean (true/false)
|
||||
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
|
||||
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
||||
|
||||
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||
1분 버킷: date_trunc('minute', recorded_at) AS bucket
|
||||
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
|
||||
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
|
||||
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
|
||||
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
|
||||
|
||||
예시 (2분 간격, 여러 태그, KST 표시):
|
||||
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AT TIME ZONE 'Asia/Seoul' AS bucket,
|
||||
tagname, AVG(value::double precision) AS avg_val
|
||||
FROM history_table
|
||||
WHERE tagname IN ('tag1', 'tag2')
|
||||
AND recorded_at >= NOW() - INTERVAL '3 hours'
|
||||
GROUP BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname
|
||||
ORDER BY to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120), tagname
|
||||
|
||||
규칙:
|
||||
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
|
||||
- tagname은 모두 소문자로 정확히 입력
|
||||
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
|
||||
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
|
||||
"""
|
||||
|
||||
# SQL 생성 system 프롬프트 (nl2sql_worker._generate_sql 와 동일)
|
||||
SQL_SYSTEM_PROMPT = (
|
||||
"You are a PostgreSQL SQL expert.\n"
|
||||
"Convert the user's question into a SELECT SQL using the schema below.\n"
|
||||
"IMPORTANT rules:\n"
|
||||
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
|
||||
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
|
||||
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
|
||||
"- INTERVAL rule:\n"
|
||||
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
|
||||
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
|
||||
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
|
||||
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
|
||||
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
|
||||
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert KST→UTC for WHERE: KST 12:00 = UTC 03:00.\n"
|
||||
"- Display times in KST: always apply AT TIME ZONE 'Asia/Seoul' on time columns in SELECT.\n"
|
||||
" * Non-aggregated: SELECT recorded_at AT TIME ZONE 'Asia/Seoul' AS recorded_at, ...\n"
|
||||
" * Aggregated bucket: GROUP BY the raw UTC expression, then convert only in SELECT:\n"
|
||||
" SELECT to_timestamp(...) AT TIME ZONE 'Asia/Seoul' AS bucket, AVG(...) AS avg_val\n"
|
||||
" FROM ... GROUP BY to_timestamp(...), tagname ORDER BY to_timestamp(...), tagname\n"
|
||||
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
|
||||
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
|
||||
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
||||
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
|
||||
f"{DB_SCHEMA}"
|
||||
)
|
||||
Reference in New Issue
Block a user