@dssp/dcsp 1.0.2 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist-client/bootstrap.js +13 -10
  2. package/dist-client/bootstrap.js.map +1 -1
  3. package/dist-client/components/kpi-2d-lookup-chart.d.ts +50 -0
  4. package/dist-client/components/kpi-2d-lookup-chart.js +263 -0
  5. package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -0
  6. package/dist-client/components/kpi-boxplot-chart.d.ts +31 -0
  7. package/dist-client/components/kpi-boxplot-chart.js +306 -0
  8. package/dist-client/components/kpi-boxplot-chart.js.map +1 -0
  9. package/dist-client/components/kpi-lookup-chart.d.ts +58 -0
  10. package/dist-client/components/kpi-lookup-chart.js +464 -0
  11. package/dist-client/components/kpi-lookup-chart.js.map +1 -0
  12. package/dist-client/components/kpi-mini-trend-chart.d.ts +14 -0
  13. package/dist-client/components/kpi-mini-trend-chart.js +180 -0
  14. package/dist-client/components/kpi-mini-trend-chart.js.map +1 -0
  15. package/dist-client/components/kpi-radar-chart.d.ts +17 -0
  16. package/dist-client/components/kpi-radar-chart.js +259 -0
  17. package/dist-client/components/kpi-radar-chart.js.map +1 -0
  18. package/dist-client/components/kpi-single-boxplot-chart.d.ts +36 -0
  19. package/dist-client/components/kpi-single-boxplot-chart.js +417 -0
  20. package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -0
  21. package/dist-client/components/kpi-step-lookup-chart.d.ts +33 -0
  22. package/dist-client/components/kpi-step-lookup-chart.js +181 -0
  23. package/dist-client/components/kpi-step-lookup-chart.js.map +1 -0
  24. package/dist-client/components/kpi-trend-chart.d.ts +25 -0
  25. package/dist-client/components/kpi-trend-chart.js +241 -0
  26. package/dist-client/components/kpi-trend-chart.js.map +1 -0
  27. package/dist-client/google-map/common-google-map.d.ts +35 -0
  28. package/dist-client/google-map/common-google-map.js +349 -0
  29. package/dist-client/google-map/common-google-map.js.map +1 -0
  30. package/dist-client/google-map/google-map-loader.d.ts +6 -0
  31. package/dist-client/google-map/google-map-loader.js +23 -0
  32. package/dist-client/google-map/google-map-loader.js.map +1 -0
  33. package/dist-client/pages/kpi-admin/dssp-kpi-overview.d.ts +47 -0
  34. package/dist-client/pages/kpi-admin/dssp-kpi-overview.js +393 -0
  35. package/dist-client/pages/kpi-admin/dssp-kpi-overview.js.map +1 -0
  36. package/dist-client/pages/kpi-admin/kpi-system-guide.d.ts +18 -0
  37. package/dist-client/pages/kpi-admin/kpi-system-guide.js +696 -0
  38. package/dist-client/pages/kpi-admin/kpi-system-guide.js.map +1 -0
  39. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.d.ts +46 -0
  40. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +945 -0
  41. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -0
  42. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +34 -0
  43. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +238 -0
  44. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -0
  45. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +37 -0
  46. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +689 -0
  47. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -0
  48. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +57 -0
  49. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +866 -0
  50. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -0
  51. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.d.ts +69 -0
  52. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js +790 -0
  53. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js.map +1 -0
  54. package/dist-client/pages/project-complete-tabs/pc-tab2-rating.d.ts +22 -0
  55. package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js +344 -0
  56. package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js.map +1 -0
  57. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.d.ts +21 -0
  58. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js +335 -0
  59. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js.map +1 -0
  60. package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.d.ts +51 -0
  61. package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js +693 -0
  62. package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js.map +1 -0
  63. package/dist-client/pages/sv-project-complete.d.ts +31 -0
  64. package/dist-client/pages/sv-project-complete.js +364 -0
  65. package/dist-client/pages/sv-project-complete.js.map +1 -0
  66. package/dist-client/pages/sv-project-detail.d.ts +73 -0
  67. package/dist-client/pages/sv-project-detail.js +1387 -0
  68. package/dist-client/pages/sv-project-detail.js.map +1 -0
  69. package/dist-client/route.d.ts +1 -1
  70. package/dist-client/route.js +15 -0
  71. package/dist-client/route.js.map +1 -1
  72. package/dist-client/shared/complete-api.d.ts +45 -0
  73. package/dist-client/shared/complete-api.js +274 -0
  74. package/dist-client/shared/complete-api.js.map +1 -0
  75. package/dist-client/shared/func.d.ts +2 -0
  76. package/dist-client/shared/func.js +22 -0
  77. package/dist-client/shared/func.js.map +1 -0
  78. package/dist-client/shared/integration-fetch.d.ts +35 -0
  79. package/dist-client/shared/integration-fetch.js +53 -0
  80. package/dist-client/shared/integration-fetch.js.map +1 -0
  81. package/dist-client/shared/kpi-project-sync.d.ts +16 -0
  82. package/dist-client/shared/kpi-project-sync.js +129 -0
  83. package/dist-client/shared/kpi-project-sync.js.map +1 -0
  84. package/dist-client/tsconfig.tsbuildinfo +1 -1
  85. package/dist-client/viewparts/menu-tools.d.ts +7 -0
  86. package/dist-client/viewparts/menu-tools.js +168 -21
  87. package/dist-client/viewparts/menu-tools.js.map +1 -1
  88. package/dist-server/index.d.ts +2 -0
  89. package/dist-server/index.js +5 -0
  90. package/dist-server/index.js.map +1 -1
  91. package/dist-server/middlewares/index.d.ts +7 -0
  92. package/dist-server/middlewares/index.js +100 -0
  93. package/dist-server/middlewares/index.js.map +1 -0
  94. package/dist-server/service/index.d.ts +4 -0
  95. package/dist-server/service/index.js +19 -0
  96. package/dist-server/service/index.js.map +1 -0
  97. package/dist-server/service/kpi-metric-value/index.d.ts +4 -0
  98. package/dist-server/service/kpi-metric-value/index.js +8 -0
  99. package/dist-server/service/kpi-metric-value/index.js.map +1 -0
  100. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.d.ts +198 -0
  101. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +1067 -0
  102. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -0
  103. package/dist-server/service/kpi-metric-value/kpi-metric-value-query.d.ts +15 -0
  104. package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js +72 -0
  105. package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js.map +1 -0
  106. package/dist-server/service/kpi-stat/index.d.ts +4 -0
  107. package/dist-server/service/kpi-stat/index.js +8 -0
  108. package/dist-server/service/kpi-stat/index.js.map +1 -0
  109. package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +13 -0
  110. package/dist-server/service/kpi-stat/kpi-stat-query.js +751 -0
  111. package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -0
  112. package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +32 -0
  113. package/dist-server/service/kpi-stat/kpi-stat-types.js +126 -0
  114. package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -0
  115. package/dist-server/service/kpi-value/index.d.ts +3 -0
  116. package/dist-server/service/kpi-value/index.js +7 -0
  117. package/dist-server/service/kpi-value/index.js.map +1 -0
  118. package/dist-server/service/kpi-value/kpi-value-query.d.ts +8 -0
  119. package/dist-server/service/kpi-value/kpi-value-query.js +90 -0
  120. package/dist-server/service/kpi-value/kpi-value-query.js.map +1 -0
  121. package/dist-server/tsconfig.tsbuildinfo +1 -1
  122. package/package.json +8 -7
  123. package/python/RUNNER_CONVERSION_GUIDE.md +81 -0
  124. package/python/doc_summary_engine_handoff/.env.example +21 -0
  125. package/python/doc_summary_engine_handoff/HANDOFF.md +266 -0
  126. package/python/doc_summary_engine_handoff/README.md +71 -0
  127. package/python/doc_summary_engine_handoff/__main__.py +159 -0
  128. package/python/doc_summary_engine_handoff/api.py +165 -0
  129. package/python/doc_summary_engine_handoff/extract.py +290 -0
  130. package/python/doc_summary_engine_handoff/requirements.txt +15 -0
  131. package/python/doc_summary_engine_handoff/summarize.py +247 -0
  132. package/python/inspection_ai_engine_handoff/.env.example +22 -0
  133. package/python/inspection_ai_engine_handoff/HANDOFF.md +541 -0
  134. package/python/inspection_ai_engine_handoff/README.md +83 -0
  135. package/python/inspection_ai_engine_handoff/__main__.py +169 -0
  136. package/python/inspection_ai_engine_handoff/activities.py +147 -0
  137. package/python/inspection_ai_engine_handoff/api.py +273 -0
  138. package/python/inspection_ai_engine_handoff/checklist.py +53 -0
  139. package/python/inspection_ai_engine_handoff/cv_handlers.py +46 -0
  140. package/python/inspection_ai_engine_handoff/cv_model_registry.py +252 -0
  141. package/python/inspection_ai_engine_handoff/cv_models/README.md +58 -0
  142. package/python/inspection_ai_engine_handoff/cv_models/_example_frame_alignment/handler.py +39 -0
  143. package/python/inspection_ai_engine_handoff/cv_models/_example_frame_alignment/model.json +22 -0
  144. package/python/inspection_ai_engine_handoff/engine.py +333 -0
  145. package/python/inspection_ai_engine_handoff/requirements.txt +12 -0
  146. package/python/inspection_ai_engine_handoff/router.py +200 -0
  147. package/python/inspection_ai_engine_handoff/shot_catalog.py +79 -0
  148. package/python/inspection_ai_engine_handoff/shot_planner.py +278 -0
  149. package/python/inspection_ai_engine_handoff/vlm.py +335 -0
  150. package/python/spec-matching-0.5.5/README.md +113 -0
  151. package/python/spec-matching-0.5.5/__main__.py +331 -0
  152. package/python/spec-matching-0.5.5/kcs.csv +6028 -0
  153. package/python/spec-matching-0.5.5/kcs_embeddings.meta.json +1 -0
  154. package/python/spec-matching-0.5.5/kcs_embeddings.npy +0 -0
  155. package/python/spec-matching-0.5.5/requirements.txt +3 -0
  156. package/python/spec-matching-0.5.5//342/230/2057 SF-TD4 /341/204/221/341/205/263/341/204/205/341/205/251/341/204/200/341/205/263/341/204/205/341/205/242/341/206/267/341/204/211/341/205/245/341/206/257/341/204/200/341/205/250/341/204/211/341/205/245 /341/204/213/341/205/243/341/206/274/341/204/211/341/205/265/341/206/250_V1.0.hwp +0 -0
  157. package/schema.graphql +1274 -22
  158. package/things-factory.config.js +7 -1
  159. package/translations/en.json +2 -0
  160. package/translations/ja.json +2 -0
  161. package/translations/ko.json +2 -0
@@ -0,0 +1,113 @@
1
+ # Spec Matching - Operato Runner Package
2
+
3
+ 건설공사 **검사항목**을 ① **KCS 표준 조항**(`kcs.csv`)과 ② **현장 시방서**(프로젝트별 PDF) 양쪽에서 의미적으로 가장 유사한 조항에 자동 매칭하는 시스템입니다. **SBERT(문장 임베딩)** 기반으로 외부 LLM API 없이 로컬에서 동작합니다.
4
+
5
+ ## 데이터 구조
6
+
7
+ | 데이터 | 역할 | 출처 |
8
+ | --- | --- | --- |
9
+ | **검사항목 (query)** | 매칭할 질의 (공통 체크리스트 항목) | 호출 시 JSON으로 전달 |
10
+ | **KCS 표준** | 표준 조항 6천여 건 | `kcs.csv` (패키지에 영구 보관) |
11
+ | **현장 시방서** | 프로젝트별 실제 시공 기준 | PDF URL (호출 시 전달, 다운로드/파싱) |
12
+
13
+ → 검사항목 1건 입력 → `kcs_matches`(KCS 조항) + `site_spec_matches`(현장 시방서 조항)를 각각 반환.
14
+
15
+ ## 사용법
16
+
17
+ ### Operato Runner에서 실행
18
+
19
+ ```python
20
+ input_data = {
21
+ "query": "합성수지제 창호의 보양 및 검사를 적합하게 시행하였는가",
22
+ "site_spec_pdf_url": "https://dcsp.hatiolab.com/attachment/xxxx.pdf",
23
+ "top_k": 5
24
+ }
25
+
26
+ result = main(input_data)
27
+ ```
28
+
29
+ ### 명령행(CLI) 실행
30
+
31
+ ```bash
32
+ source .venv/bin/activate
33
+ python __main__.py '{"query": "먹놓기는 적합하게 시공되었는가", "top_k": 3}'
34
+ ```
35
+
36
+ 인자 없이 실행하면 기본 테스트 입력(KCS 매칭만)으로 동작합니다.
37
+
38
+ ## 입력 파라미터
39
+
40
+ | 키 | 타입 | 필수 | 설명 |
41
+ | --- | --- | --- | --- |
42
+ | `query` | str | ✅ | 매칭할 검사항목 텍스트 |
43
+ | `site_spec_pdf_url` | str | ⬜ | 현장 시방서 PDF URL (없으면 KCS만 매칭) |
44
+ | `headers` | dict | ⬜ | PDF 다운로드 시 HTTP 헤더 (인증 토큰/쿠키 등) |
45
+ | `top_k` | int | ⬜ | 각 항목별 반환 후보 수 (기본 5) |
46
+
47
+ ## 응답 형식
48
+
49
+ ```json
50
+ {
51
+ "status": "success",
52
+ "result": {
53
+ "query": "합성수지제 창호의 보양 및 검사를 적합하게 시행하였는가",
54
+ "kcs_matches": [
55
+ {
56
+ "file_label": "(개정) KCS 41 55 03 합성수지제 창호공사",
57
+ "clause_no": "3.3",
58
+ "clause_title": "보양 및 검사",
59
+ "text": "...",
60
+ "score": 0.72
61
+ }
62
+ ],
63
+ "site_spec_matches": [
64
+ { "text": "3.3 보양 및 검사 합성수지제 창호는 설치 후 ...", "score": 0.73 }
65
+ ]
66
+ }
67
+ }
68
+ ```
69
+
70
+ - `site_spec_pdf_url`을 안 주면 `site_spec_matches`는 빈 배열입니다.
71
+ - PDF 처리 중 오류가 나도 KCS 결과는 그대로 반환되며, `result.site_spec_error`에 사유가 담깁니다.
72
+
73
+ ## 출력 필드
74
+
75
+ - `kcs_matches`: KCS 표준 조항 후보 (유사도 내림차순)
76
+ - `file_label`: KCS 문서명(공사명), `clause_no`: 조항 번호, `clause_title`: 조항 제목, `text`: 조항 본문, `score`: 코사인 유사도
77
+ - `site_spec_matches`: 현장 시방서 조항 후보 (`text`, `score`)
78
+
79
+ ## 에러 응답
80
+
81
+ ```json
82
+ { "status": "error", "error": "에러 메시지" }
83
+ ```
84
+
85
+ 주요 에러: `query` 누락, `kcs.csv` 없음.
86
+
87
+ ## 환경 설정
88
+
89
+ Python 3.12 권장 (torch 호환).
90
+
91
+ ```bash
92
+ python3.12 -m venv .venv
93
+ source .venv/bin/activate
94
+ pip install -r requirements.txt
95
+ ```
96
+
97
+ > 최초 실행 시 SBERT 모델(약 수백 MB)을 Hugging Face Hub에서 자동 다운로드합니다.
98
+
99
+ ## 동작 메모
100
+
101
+ - **KCS 임베딩 캐시**: 최초 1회 `kcs.csv` 전체(약 6천 건)를 임베딩해 `kcs_embeddings.npy`로 저장합니다(1~2분 소요). 이후 호출은 캐시를 재사용해 빠르게 동작합니다. `kcs.csv`가 바뀌면 자동으로 다시 계산합니다.
102
+ - **현장 시방서 캐시**: 같은 PDF URL은 프로세스 동안 1회만 다운로드/파싱합니다.
103
+ - **조항 분할**: PDF 텍스트는 조항 번호 헤더(예: `3.3`, `2.1.5`) 기준으로 우선 분할하고, 패턴이 없으면 길이 기준으로 분할합니다. 실제 현장 시방서 PDF의 추출 형식에 따라 분할 품질이 달라질 수 있어 필요 시 튜닝합니다.
104
+ - 외부 LLM API를 사용하지 않으므로 API 키·과금이 없습니다.
105
+
106
+ ## 구성 파일
107
+
108
+ | 파일명 | 설명 |
109
+ | --- | --- |
110
+ | `__main__.py` | operato-runner용 메인 처리 함수(`main(input)`) + CLI 진입점 |
111
+ | `kcs.csv` | KCS 표준 조항 데이터 (영구 보관) |
112
+ | `kcs_embeddings.npy` / `*.meta.json` | KCS 임베딩 디스크 캐시 (자동 생성) |
113
+ | `requirements.txt` | 필요한 Python 패키지 |
@@ -0,0 +1,331 @@
1
+ import os
2
+ import re
3
+ import json
4
+ import csv
5
+ import tempfile
6
+ import urllib.request
7
+ import numpy as np
8
+ from sentence_transformers import SentenceTransformer
9
+ from sklearn.metrics.pairwise import cosine_similarity
10
+ from pypdf import PdfReader
11
+
12
+
13
+ # =========================
14
+ # Config
15
+ # =========================
16
+ SBERT_MODEL_NAME = "snunlp/KR-SBERT-V40K-klueNLI-augSTS"
17
+ script_dir = os.path.dirname(os.path.abspath(__file__))
18
+
19
+ KCS_CSV_PATH = os.path.join(script_dir, "kcs.csv") # 영구 보관되는 KCS 표준 데이터
20
+ KCS_EMB_CACHE = os.path.join(script_dir, "kcs_embeddings.npy") # KCS 임베딩 디스크 캐시
21
+ KCS_META_CACHE = os.path.join(script_dir, "kcs_embeddings.meta.json")
22
+ EMBED_SCHEME = "file_label+clause_title+text/v2" # 임베딩 텍스트 구성 방식 (바뀌면 캐시 무효화)
23
+
24
+
25
+ # =========================
26
+ # Utilities
27
+ # =========================
28
+ def normalize_name(s: str) -> str:
29
+ # 공백 정리 등 텍스트 정규화
30
+ s = str(s or "").strip()
31
+ s = re.sub(r"\s+", " ", s)
32
+ return s
33
+
34
+
35
+ def _size_chunks(text: str, target_size: int, min_size: int):
36
+ # 라인 단위로 나눈 뒤 target_size 근처까지 그리디하게 병합 (폴백용)
37
+ lines = [ln.strip() for ln in re.split(r"[\n\r]+", text) if ln.strip()]
38
+ chunks = []
39
+ buf = ""
40
+ for ln in lines:
41
+ if not buf:
42
+ buf = ln
43
+ elif len(buf) + 1 + len(ln) <= target_size:
44
+ buf += " " + ln
45
+ else:
46
+ chunks.append(buf)
47
+ buf = ln
48
+ if buf:
49
+ chunks.append(buf)
50
+ return [c for c in chunks if len(c) >= min_size]
51
+
52
+
53
+ def chunk_text(full_text: str, target_size: int = 500, min_size: int = 15):
54
+ """
55
+ PDF 전체 텍스트를 조항 단위에 가까운 청크로 분할.
56
+ 1) 조항 번호 헤더(예: '3.3 보양', '2.1.5 일반') 앞에서 우선 분할
57
+ 2) 조항 분할이 안 되면 size 기반 병합으로 폴백
58
+ """
59
+ text = re.sub(r"[ \t]+", " ", full_text)
60
+ # 조항 번호 헤더 앞에 분할점 삽입 (번호 뒤에 한글 제목이 오는 패턴)
61
+ parts = re.split(r"(?=(?<![\d.])\d+(?:\.\d+){0,3}\s+[가-힣])", text)
62
+ parts = [re.sub(r"\s+", " ", p).strip() for p in parts]
63
+ parts = [p for p in parts if len(p) >= min_size]
64
+
65
+ if len(parts) >= 2:
66
+ chunks = []
67
+ for p in parts:
68
+ # 한 조항이 너무 길면 size 기준으로 추가 분할
69
+ if len(p) <= target_size * 2:
70
+ chunks.append(p)
71
+ else:
72
+ chunks.extend(_size_chunks(p, target_size, min_size))
73
+ return chunks
74
+
75
+ # 폴백: 조항 패턴이 없을 때
76
+ return _size_chunks(text, target_size, min_size)
77
+
78
+
79
+ # =========================
80
+ # SBERT Matching Engine
81
+ # =========================
82
+ class SBERTIndex:
83
+ """텍스트 임베딩 인덱스. meta로 각 텍스트의 부가정보를 함께 보관."""
84
+
85
+ def __init__(self, model: SentenceTransformer):
86
+ self.model = model
87
+ self.texts = []
88
+ self.meta = []
89
+ self.emb = None
90
+
91
+ def build(self, texts, meta=None, batch_size=64, precomputed_emb=None):
92
+ # 텍스트 리스트를 정규화 후 임베딩 (precomputed_emb 있으면 재사용)
93
+ self.texts = [normalize_name(t) for t in texts]
94
+ self.meta = meta if meta is not None else [{} for _ in self.texts]
95
+ if precomputed_emb is not None:
96
+ self.emb = precomputed_emb
97
+ else:
98
+ self.emb = self.model.encode(
99
+ self.texts,
100
+ normalize_embeddings=True,
101
+ batch_size=batch_size,
102
+ show_progress_bar=False
103
+ )
104
+
105
+ def search(self, query: str, top_k=5):
106
+ # 쿼리와 코사인 유사도가 높은 top_k 의 (text, meta, score) 반환
107
+ if self.emb is None or len(self.texts) == 0:
108
+ return []
109
+ q = normalize_name(query)
110
+ q_emb = self.model.encode([q], normalize_embeddings=True, batch_size=1)
111
+ sims = cosine_similarity(q_emb, self.emb)[0]
112
+ idx = sims.argsort()[::-1][:top_k]
113
+ return [(self.texts[i], self.meta[i], float(sims[i])) for i in idx]
114
+
115
+
116
+ # 모델 로딩은 비싸므로 프로세스 단위로 1회만 (lazy 싱글톤)
117
+ _MODEL = None
118
+ _KCS_INDEX = None
119
+ _SITE_SPEC_CACHE = {} # url -> SBERTIndex
120
+
121
+
122
+ def get_model() -> SentenceTransformer:
123
+ # SBERT 모델 싱글톤
124
+ global _MODEL
125
+ if _MODEL is None:
126
+ _MODEL = SentenceTransformer(SBERT_MODEL_NAME, device="cpu") # CUDA OOM 방지
127
+ return _MODEL
128
+
129
+
130
+ def _load_kcs_rows():
131
+ # KCS 표준 CSV 로드 (BOM 처리)
132
+ rows = []
133
+ with open(KCS_CSV_PATH, "r", encoding="utf-8-sig", newline="") as f:
134
+ reader = csv.DictReader(f)
135
+ for r in reader:
136
+ text = (r.get("text") or "").strip()
137
+ clause_title = (r.get("clause_title") or "").strip()
138
+ if not text and not clause_title:
139
+ continue
140
+ rows.append({
141
+ "file_label": (r.get("file_label") or "").strip(),
142
+ "section_no": (r.get("section_no") or "").strip(),
143
+ "clause_no": (r.get("clause_no") or "").strip(),
144
+ "clause_title": clause_title,
145
+ "text": text,
146
+ })
147
+ return rows
148
+
149
+
150
+ def _kcs_cache_valid(rows_count: int) -> bool:
151
+ # 캐시 메타가 현재 CSV/스킴과 일치하는지 검증
152
+ if not (os.path.exists(KCS_EMB_CACHE) and os.path.exists(KCS_META_CACHE)):
153
+ return False
154
+ try:
155
+ with open(KCS_META_CACHE, "r", encoding="utf-8") as f:
156
+ meta = json.load(f)
157
+ except Exception:
158
+ return False
159
+ st = os.stat(KCS_CSV_PATH)
160
+ return (
161
+ meta.get("scheme") == EMBED_SCHEME
162
+ and meta.get("model") == SBERT_MODEL_NAME
163
+ and meta.get("csv_size") == st.st_size
164
+ and meta.get("rows") == rows_count
165
+ )
166
+
167
+
168
+ def get_kcs_index() -> SBERTIndex:
169
+ # KCS 인덱스 싱글톤 (임베딩은 디스크 캐시 재사용, 없으면 계산 후 저장)
170
+ global _KCS_INDEX
171
+ if _KCS_INDEX is not None:
172
+ return _KCS_INDEX
173
+
174
+ rows = _load_kcs_rows()
175
+ # 매칭에 사용할 텍스트: 공사명(file_label) + 조항제목 + 본문
176
+ # (공사 도메인이 검색에 반영되도록 file_label 포함)
177
+ embed_texts = [
178
+ normalize_name(f"{r['file_label']} {r['clause_title']} {r['text']}") for r in rows
179
+ ]
180
+
181
+ model = get_model()
182
+ idx = SBERTIndex(model)
183
+
184
+ if _kcs_cache_valid(len(rows)):
185
+ emb = np.load(KCS_EMB_CACHE)
186
+ idx.build(embed_texts, meta=rows, precomputed_emb=emb)
187
+ else:
188
+ idx.build(embed_texts, meta=rows, batch_size=64)
189
+ np.save(KCS_EMB_CACHE, idx.emb)
190
+ st = os.stat(KCS_CSV_PATH)
191
+ with open(KCS_META_CACHE, "w", encoding="utf-8") as f:
192
+ json.dump({
193
+ "scheme": EMBED_SCHEME,
194
+ "model": SBERT_MODEL_NAME,
195
+ "csv_size": st.st_size,
196
+ "rows": len(rows),
197
+ }, f, ensure_ascii=False)
198
+
199
+ _KCS_INDEX = idx
200
+ return idx
201
+
202
+
203
+ def _download_pdf(url: str, headers: dict = None) -> str:
204
+ # PDF를 URL에서 임시파일로 다운로드하고 경로 반환
205
+ req = urllib.request.Request(url, headers=headers or {"User-Agent": "spec-matching/1.0"})
206
+ fd, path = tempfile.mkstemp(suffix=".pdf")
207
+ os.close(fd)
208
+ with urllib.request.urlopen(req, timeout=60) as resp, open(path, "wb") as out:
209
+ out.write(resp.read())
210
+ return path
211
+
212
+
213
+ def get_site_spec_index(pdf_url: str, headers: dict = None) -> SBERTIndex:
214
+ # 현장 시방서 PDF 인덱스 (URL별 프로세스 캐시)
215
+ if pdf_url in _SITE_SPEC_CACHE:
216
+ return _SITE_SPEC_CACHE[pdf_url]
217
+
218
+ pdf_path = _download_pdf(pdf_url, headers=headers)
219
+ try:
220
+ reader = PdfReader(pdf_path)
221
+ full_text = "\n".join([p.extract_text() or "" for p in reader.pages])
222
+ finally:
223
+ try:
224
+ os.remove(pdf_path)
225
+ except OSError:
226
+ pass
227
+
228
+ chunks = chunk_text(full_text)
229
+ idx = SBERTIndex(get_model())
230
+ idx.build(chunks, meta=[{"chunk_index": i} for i in range(len(chunks))], batch_size=64)
231
+ _SITE_SPEC_CACHE[pdf_url] = idx
232
+ return idx
233
+
234
+
235
+ def match_kcs(query: str, top_k=5):
236
+ # 검사항목 → KCS 표준 조항 매칭
237
+ hits = get_kcs_index().search(query, top_k=top_k)
238
+ out = []
239
+ for _text, meta, score in hits:
240
+ out.append({
241
+ "file_label": meta.get("file_label", ""),
242
+ "clause_no": meta.get("clause_no", ""),
243
+ "clause_title": meta.get("clause_title", ""),
244
+ "text": meta.get("text", ""),
245
+ "score": score,
246
+ })
247
+ return out
248
+
249
+
250
+ def match_site_spec(query: str, pdf_url: str, top_k=5, headers: dict = None):
251
+ # 검사항목 → 현장 시방서(PDF) 조항 매칭
252
+ hits = get_site_spec_index(pdf_url, headers=headers).search(query, top_k=top_k)
253
+ return [{"text": text, "score": score} for text, _meta, score in hits]
254
+
255
+
256
+ def main(input):
257
+ """
258
+ operato-runner에서 호출되는 메인 함수.
259
+
260
+ 검사항목(query) 1건을 받아, KCS 표준 조항(kcs.csv)과 현장 시방서(PDF URL)
261
+ 양쪽에서 의미적으로 가장 유사한 조항을 각각 반환한다. 외부 LLM API 미사용.
262
+
263
+ Args:
264
+ input (dict):
265
+ - 'query' (str, 필수): 검사항목 텍스트
266
+ - 'site_spec_pdf_url' (str, 선택): 현장 시방서 PDF URL (없으면 KCS만)
267
+ - 'headers' (dict, 선택): PDF 다운로드 시 사용할 HTTP 헤더(인증 등)
268
+ - 'top_k' (int, 선택, 기본 5): 각 항목별 반환 후보 수
269
+
270
+ Returns:
271
+ dict: 실행 결과
272
+ """
273
+ try:
274
+ raw_input = input # 원본(감싸진 형태 포함) 보존
275
+ # input 키로 몇 겹 감싸져 들어와도 query가 나올 때까지 벗김
276
+ while isinstance(input, dict) and "query" not in input and isinstance(input.get("input"), dict):
277
+ input = input["input"]
278
+ query = normalize_name(input.get("query", ""))
279
+ pdf_url = (input.get("site_spec_pdf_url") or "").strip()
280
+ headers = input.get("headers") or None
281
+ top_k = int(input.get("top_k", 5))
282
+
283
+ if not query:
284
+ return {
285
+ "status": "error",
286
+ "error": "query가 제공되지 않았습니다.",
287
+ "received_type": type(raw_input).__name__,
288
+ "received_keys": list(raw_input.keys()) if isinstance(raw_input, dict) else None,
289
+ "received": raw_input,
290
+ }
291
+ if not os.path.exists(KCS_CSV_PATH):
292
+ return {"status": "error", "error": f"KCS 표준 데이터를 찾을 수 없습니다: {KCS_CSV_PATH}"}
293
+
294
+ result = {
295
+ "query": query,
296
+ "kcs_matches": match_kcs(query, top_k=top_k),
297
+ "site_spec_matches": [],
298
+ }
299
+
300
+ # 현장 시방서 PDF가 주어지면 매칭, 실패해도 KCS 결과는 유지
301
+ if pdf_url:
302
+ try:
303
+ result["site_spec_matches"] = match_site_spec(
304
+ query, pdf_url, top_k=top_k, headers=headers
305
+ )
306
+ except Exception as e:
307
+ result["site_spec_error"] = f"현장 시방서 처리 실패: {str(e)}"
308
+
309
+ return {"status": "success", "result": result}
310
+
311
+ except Exception as e:
312
+ return {
313
+ "status": "error",
314
+ "error": f"처리 중 오류가 발생했습니다: {str(e)}",
315
+ }
316
+
317
+
318
+ if __name__ == "__main__":
319
+ import sys
320
+ if len(sys.argv) > 1:
321
+ # 명령행 인자로 JSON 입력 받기
322
+ input_data = json.loads(sys.argv[1])
323
+ else:
324
+ # 기본 테스트 입력 (PDF 없이 KCS 매칭만)
325
+ input_data = {
326
+ "query": "합성수지제 창호의 보양 및 검사를 적합하게 시행하였는가",
327
+ "top_k": 3
328
+ }
329
+
330
+ result = main(input_data)
331
+ print(json.dumps(result, ensure_ascii=False, indent=2))