@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,333 @@
1
+ """검사 오케스트레이션 엔진.
2
+
3
+ 핵심 함수:
4
+ - inspect_one(item, decision, images, client) : 1개 항목 실행
5
+ - inspect_all(checklist, images, client=None) : 전체 실행
6
+ - build_summary_markdown(result_dict) : 결과 → 마크다운
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import traceback
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from typing import Callable, List, Optional, Sequence
15
+
16
+ from PIL import Image
17
+
18
+ # 비용 추적 (선택). 배포본에 cost_tracker.py 가 없어도 동작하도록 no-op 대체.
19
+ try:
20
+ import cost_tracker
21
+ except ImportError:
22
+ class _NoCost:
23
+ @staticmethod
24
+ def record(*a, **k): pass
25
+ @staticmethod
26
+ def reset(*a, **k): pass
27
+ @staticmethod
28
+ def drain(*a, **k): return []
29
+ @staticmethod
30
+ def snapshot(*a, **k): return None
31
+ @staticmethod
32
+ def snapshot_from(*a, **k): return None
33
+ cost_tracker = _NoCost()
34
+
35
+ from checklist import CHECKLIST_VERSION, validate_checklist
36
+ from cv_handlers import CVResult
37
+ from cv_model_registry import list_models, resolve_handler
38
+ from router import RouteDecision, decision_to_dict, route_all
39
+ from vlm import VLMResult, vqa_judge
40
+
41
+
42
+ _VERDICT_ORDER = {"fail": 0, "hold": 1, "pass": 2, "pending": 3, "error": 4}
43
+
44
+ # 감리자용 한글 라벨 (JSON 기계값 pass/fail/... 은 그대로 두고, 화면 표시만 한글)
45
+ _VERDICT_BADGE = {
46
+ "pass": "🟢 적합",
47
+ "fail": "🔴 부적합",
48
+ "hold": "🟡 보류 (추가 확인 필요)",
49
+ "pending": "⏳ AI 미지원 (수동 확인)",
50
+ "error": "❌ 분석 오류",
51
+ }
52
+
53
+ # 짧은 한글 라벨 (요약 카운트용)
54
+ _VERDICT_SHORT = {
55
+ "pass": "적합", "fail": "부적합", "hold": "보류",
56
+ "pending": "AI미지원", "error": "오류",
57
+ }
58
+
59
+ # 처리 방식(내부 routing) → 감리자용 표현
60
+ _ROUTING_LABEL = {
61
+ "vlm": "AI 사진 판독",
62
+ "cv": "자동 측정",
63
+ "cv_missing": "자동 측정 필요 (미지원)",
64
+ }
65
+
66
+
67
+ def _synthesize_vqa_question(item: dict) -> str:
68
+ """체크리스트 항목 텍스트만으로 VLM 질문 프롬프트 합성."""
69
+ cat = item.get("category", "")
70
+ cat_prefix = f"({cat}) " if cat else ""
71
+ return (
72
+ f"{cat_prefix}검사 항목: {item['text']}\n"
73
+ f"위 항목을 사진(들) 만으로 판정하세요. 사진에 명확한 근거가 보이면 그 위치를 "
74
+ f"'Image #N' 으로 인용해서 reason 에 적고, 사진만으로 단정할 수 없으면 hold "
75
+ f"로 처리하면서 어떤 추가 사진이 필요한지 reason 에 명시하세요."
76
+ )
77
+
78
+
79
+ # ─── per-item ─────────────────────────────────────────────────────────────
80
+
81
+ def inspect_one(
82
+ item: dict,
83
+ decision: RouteDecision,
84
+ images: Sequence[Image.Image],
85
+ client=None,
86
+ ) -> dict:
87
+ """체크리스트 1개 항목 실행 (라우팅 결정에 따라 분기)."""
88
+ base = {
89
+ "item_id": item["item_id"],
90
+ "category": item.get("category", ""),
91
+ "text": item["text"],
92
+ "routing": decision.mode, # "vlm" | "cv" | "cv_missing"
93
+ "route_rationale": decision.rationale,
94
+ "route_confidence": decision.confidence,
95
+ }
96
+ started = time.time()
97
+
98
+ try:
99
+ if decision.mode == "vlm":
100
+ r: VLMResult = vqa_judge(
101
+ images=images,
102
+ item_id=item["item_id"],
103
+ item_text=item["text"],
104
+ question=_synthesize_vqa_question(item),
105
+ client=client,
106
+ )
107
+ return {
108
+ **base,
109
+ "verdict": r.verdict,
110
+ "confidence": round(r.confidence, 3),
111
+ "reason": r.reason,
112
+ "observation": r.observation,
113
+ "used_images": r.used_images,
114
+ "recapture_shots": r.recapture_shots, # 보류 시 재촬영할 샷 목록(슬롯용)
115
+ "elapsed_sec": round(time.time() - started, 2),
116
+ }
117
+
118
+ if decision.mode == "cv":
119
+ try:
120
+ handler = resolve_handler(decision.model_id or "")
121
+ except Exception as e:
122
+ return {
123
+ **base,
124
+ "model_id": decision.model_id,
125
+ "verdict": "error",
126
+ "reason": f"CV 핸들러 해석 실패: {type(e).__name__}: {e}",
127
+ "elapsed_sec": round(time.time() - started, 2),
128
+ }
129
+ cv: CVResult = handler(images)
130
+ return {
131
+ **base,
132
+ "model_id": decision.model_id,
133
+ "verdict": cv.verdict,
134
+ "reason": cv.reason,
135
+ "measurements": cv.measurements,
136
+ "used_images": cv.used_images,
137
+ "overlay_b64": cv.overlay_b64,
138
+ "elapsed_sec": round(time.time() - started, 2),
139
+ }
140
+
141
+ if decision.mode == "cv_missing":
142
+ return {
143
+ **base,
144
+ "model_id": None,
145
+ "verdict": "pending",
146
+ "reason": (
147
+ "이 항목은 치수·각도 등 자동 측정이 필요한데, 해당 측정 기능이 "
148
+ "아직 준비되지 않았습니다. 감리자가 직접 확인해 주세요."
149
+ ),
150
+ "measurements": {},
151
+ "used_images": [],
152
+ "elapsed_sec": round(time.time() - started, 2),
153
+ }
154
+
155
+ return {
156
+ **base,
157
+ "verdict": "error",
158
+ "reason": f"알 수 없는 라우팅 mode: {decision.mode}",
159
+ "elapsed_sec": round(time.time() - started, 2),
160
+ }
161
+
162
+ except Exception as e:
163
+ return {
164
+ **base,
165
+ "verdict": "error",
166
+ "reason": f"{type(e).__name__}: {e}",
167
+ "traceback": traceback.format_exc(),
168
+ "elapsed_sec": round(time.time() - started, 2),
169
+ }
170
+
171
+
172
+ # ─── all items ────────────────────────────────────────────────────────────
173
+
174
+ ProgressCallback = Callable[[int, int, dict, Optional[dict]], None]
175
+
176
+
177
+ def inspect_all(
178
+ checklist: Sequence[dict],
179
+ images: Sequence[Image.Image],
180
+ *,
181
+ client=None,
182
+ progress: Optional[ProgressCallback] = None,
183
+ precomputed_decisions: Optional[Sequence[RouteDecision]] = None,
184
+ ) -> dict:
185
+ """체크리스트 전체 실행."""
186
+ ok, err = validate_checklist(list(checklist))
187
+ if not ok:
188
+ return {
189
+ "checklist_version": CHECKLIST_VERSION,
190
+ "error": f"체크리스트 유효성 검증 실패: {err}",
191
+ "summary": {"pass": 0, "fail": 0, "hold": 0, "pending": 0, "error": 0, "total": 0},
192
+ "items": [],
193
+ "items_sorted": [],
194
+ "routing": [],
195
+ }
196
+ if not images:
197
+ return {
198
+ "checklist_version": CHECKLIST_VERSION,
199
+ "n_images": 0,
200
+ "error": "이미지가 전달되지 않음.",
201
+ "summary": {"pass": 0, "fail": 0, "hold": 0, "pending": 0, "error": 0, "total": 0},
202
+ "items": [],
203
+ "items_sorted": [],
204
+ "routing": [],
205
+ }
206
+
207
+ if client is None:
208
+ from openai import OpenAI
209
+ client = OpenAI()
210
+
211
+ t0 = time.time()
212
+
213
+ # 1) 라우팅 (배치 1회). 비용은 메인 스레드 기록에서 거둠.
214
+ cost_tracker.reset()
215
+ if precomputed_decisions is None:
216
+ decisions = route_all(checklist, client=client)
217
+ else:
218
+ decisions = list(precomputed_decisions)
219
+ all_records = cost_tracker.drain()
220
+
221
+ decisions_by_id = {d.item_id: d for d in decisions}
222
+
223
+ def _fallback_decision(item_id: str) -> RouteDecision:
224
+ from router import RouteDecision as _RD
225
+ return _RD(item_id=item_id, mode="vlm", model_id=None,
226
+ rationale="라우팅 누락 → vlm 폴백", confidence=0.0)
227
+
228
+ # 2) 항목 분석을 병렬로 (각 워커 스레드의 비용은 drain 으로 따로 거둠)
229
+ def _run(idx_item):
230
+ idx, item = idx_item
231
+ d = decisions_by_id.get(item["item_id"]) or _fallback_decision(item["item_id"])
232
+ cost_tracker.reset() # 이 워커 스레드 기록 초기화
233
+ r = inspect_one(item, d, images, client)
234
+ return idx, r, cost_tracker.drain() # 이 항목이 쓴 비용 기록 반환
235
+
236
+ total = len(checklist)
237
+ results: List[dict] = [None] * total # type: ignore[list-item]
238
+ workers = max(1, min(8, total))
239
+ with ThreadPoolExecutor(max_workers=workers) as ex:
240
+ futures = [ex.submit(_run, (i, it)) for i, it in enumerate(checklist)]
241
+ completed = 0
242
+ for fut in as_completed(futures):
243
+ idx, r, recs = fut.result()
244
+ results[idx] = r
245
+ all_records.extend(recs)
246
+ completed += 1
247
+ if progress:
248
+ progress(completed, total, checklist[idx], r)
249
+
250
+ summary = {
251
+ "pass": sum(1 for r in results if r["verdict"] == "pass"),
252
+ "fail": sum(1 for r in results if r["verdict"] == "fail"),
253
+ "hold": sum(1 for r in results if r["verdict"] == "hold"),
254
+ "pending": sum(1 for r in results if r["verdict"] == "pending"),
255
+ "error": sum(1 for r in results if r["verdict"] == "error"),
256
+ "total": len(results),
257
+ }
258
+ items_sorted = sorted(
259
+ results, key=lambda r: (_VERDICT_ORDER.get(r["verdict"], 99), r["item_id"])
260
+ )
261
+
262
+ return {
263
+ "checklist_version": CHECKLIST_VERSION,
264
+ "n_images": len(images),
265
+ "n_items": len(checklist),
266
+ "registry_summary": [
267
+ {"model_id": m.model_id, "name": m.name, "status": m.status}
268
+ for m in list_models()
269
+ ],
270
+ "routing": [decision_to_dict(d) for d in decisions],
271
+ "summary": summary,
272
+ "items": results,
273
+ "items_sorted": items_sorted,
274
+ "elapsed_total_sec": round(time.time() - t0, 1),
275
+ "cost": cost_tracker.snapshot_from(all_records),
276
+ }
277
+
278
+
279
+ # ─── presentation ─────────────────────────────────────────────────────────
280
+
281
+ def build_summary_markdown(result: dict) -> str:
282
+ """결과 dict → 감리자용 마크다운 (검측 용어로 표시)."""
283
+ s = result.get("summary", {})
284
+ elapsed = result.get("elapsed_total_sec")
285
+ elapsed_str = f" · 소요시간: **{elapsed:.0f}초**" if elapsed is not None else ""
286
+ lines = [
287
+ "# 시공감리 검사 결과",
288
+ "",
289
+ f"입력 사진: **{result.get('n_images', 0)}장** · 총 항목: **{s.get('total', 0)}개**{elapsed_str}",
290
+ f"🔴 부적합 {s.get('fail',0)} · 🟡 보류 {s.get('hold',0)} · "
291
+ f"🟢 적합 {s.get('pass',0)} · ⏳ AI미지원 {s.get('pending',0)} · "
292
+ f"❌ 오류 {s.get('error',0)}",
293
+ "",
294
+ "_정렬 순서: 부적합 → 보류 → 적합 → AI미지원 → 오류_",
295
+ "",
296
+ ]
297
+ for r in result.get("items_sorted", []):
298
+ badge = _VERDICT_BADGE.get(r["verdict"], r["verdict"])
299
+ used = r.get("used_images") or []
300
+ used_str = f" · 사용한 사진: {used}" if used else ""
301
+ method = _ROUTING_LABEL.get(r.get("routing", ""), "")
302
+ method_str = f" · {method}" if method else ""
303
+ cat = r.get("category", "")
304
+ cat_str = f" · _{cat}_" if cat else ""
305
+ lines.append(
306
+ f"### [{r['item_id']}] {r['text']} \n"
307
+ f"{badge}{method_str}{cat_str}{used_str}"
308
+ )
309
+ if r.get("routing") == "vlm":
310
+ conf = r.get("confidence")
311
+ if conf is not None:
312
+ lines.append(f"- AI 확신도: **{conf:.2f}**")
313
+ obs = r.get("observation")
314
+ if obs:
315
+ lines.append(f"- 관찰 내용: {obs}")
316
+ if r.get("routing") == "cv":
317
+ meas = r.get("measurements") or {}
318
+ if meas:
319
+ lines.append("- 측정값:")
320
+ for k, v in meas.items():
321
+ lines.append(f" - `{k}`: {v}")
322
+ reason = r.get("reason") or ""
323
+ if reason:
324
+ lines.append(f"- 판정 사유: {reason}")
325
+ lines.append("")
326
+ cost = result.get("cost")
327
+ if cost:
328
+ lines.append(cost_tracker.format_cost_markdown(cost))
329
+ return "\n".join(lines)
330
+
331
+
332
+ def result_to_json_string(result: dict) -> str:
333
+ return json.dumps(result, ensure_ascii=False, indent=2)
@@ -0,0 +1,12 @@
1
+ openai>=1.30
2
+ Pillow>=9.0
3
+ pillow-heif>=1.0 # HEIC/HEIF (아이폰·아이패드 기본 촬영 포맷) 디코딩
4
+ python-dotenv>=1.0
5
+
6
+ # HTTP API 서버
7
+ fastapi>=0.110
8
+ uvicorn[standard]>=0.27
9
+ python-multipart>=0.0.9
10
+
11
+ # 데모 UI (demo_ui.py 만 쓰지 않으면 생략 가능)
12
+ gradio>=4.0
@@ -0,0 +1,200 @@
1
+ """체크리스트 항목별 라우터 — VLM 단독 처리 vs CV 모델 처리 결정.
2
+
3
+ 각 검사항목에 대해 LLM 이:
4
+ - "사진만 보고 VLM 이 직접 판단 가능" → mode="vlm"
5
+ - "수치 측정/검출이 필요. 레지스트리의 X 모델 써야함" → mode="cv", model_id="X"
6
+ - "측정이 필요하지만 적합한 CV 모델이 레지스트리에 없음" → mode="cv_missing"
7
+
8
+ 항목별 1회만 결정 — 이후 엔진이 같은 결정을 사진 분석 단계에서 그대로 사용.
9
+ 사진 자체를 보지는 않고 텍스트만 봅니다.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, List, Literal, Optional, Sequence
16
+
17
+ from cv_model_registry import CVModelMeta, list_models, summarize_for_router
18
+ from shot_planner import _call_text_json # 같은 텍스트 LLM 패턴 재사용
19
+
20
+
21
+ _DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.5")
22
+ _REASONING_EFFORT = os.environ.get("REASONING_EFFORT", "high").strip()
23
+
24
+ RouteMode = Literal["vlm", "cv", "cv_missing"]
25
+
26
+
27
+ @dataclass
28
+ class RouteDecision:
29
+ item_id: str
30
+ mode: RouteMode
31
+ model_id: Optional[str] # mode=="cv" 일 때만 채워짐
32
+ rationale: str # 왜 이 결정인지 한국어 1~2문장
33
+ confidence: float # 0.0~1.0
34
+ raw: str = "" # 디버그용
35
+
36
+
37
+ _BATCH_PROMPT_TEMPLATE = """당신은 건설현장 시공감리 AI 의 **라우터** 입니다.
38
+ 아래 검사항목들을 각각 어떤 방식으로 판정해야 가장 정확할지 한 번에 분류하세요.
39
+
40
+ 선택지 (항목마다 하나):
41
+ (A) "vlm" — 사진을 비전 언어 모델이 보고 직접 정성 판정 (있다/없다, 양호/불량,
42
+ 균열·파손 유무, 청소·충전·마감 상태 등 눈으로 판단 가능한 것).
43
+ (B) "cv" — 정량 측정·검출이 필요 (각도·치수·비율·IoU 등). 아래 등록된 CV 모델 중
44
+ 적합한 게 있으면 그 model_id 를 고름.
45
+ (C) "cv_missing" — 측정이 필요하지만 등록된 CV 모델 중 적합한 게 없을 때.
46
+
47
+ [사용 가능한 CV 모델 레지스트리]
48
+ {registry_block}
49
+
50
+ [검사항목 ({n_items}개)]
51
+ {items_block}
52
+
53
+ 판단 가이드:
54
+ 1) "수직/수평/정렬/치수/각도/크기/도면 일치/기울기" 등 숫자·측정이 본질이면 cv 후보.
55
+ 2) "파손/오염/청소/사춤 밀실/고정철물 설치/균열/마감/도장" 등 시각적 정성 판정이면 vlm.
56
+ 3) cv 라도 위 레지스트리에 명확히 맞는 모델이 없으면 cv_missing.
57
+ 4) 애매하면 vlm.
58
+
59
+ 반드시 아래 JSON 객체 하나만 출력하세요. 모든 항목을 빠짐없이 포함하세요.
60
+ 다른 텍스트/주석/코드블록 금지.
61
+
62
+ {{
63
+ "decisions": [
64
+ {{"item_id": "...", "mode": "vlm" | "cv" | "cv_missing",
65
+ "model_id": "<mode==cv 일 때만, 아니면 null>",
66
+ "rationale": "왜 이 결정인지 1문장.", "confidence": 0.0}}
67
+ ]
68
+ }}"""
69
+
70
+
71
+ def _build_registry_block(models: Sequence[CVModelMeta]) -> str:
72
+ if not models:
73
+ return "(등록된 CV 모델 없음)"
74
+ lines = []
75
+ summaries = summarize_for_router(models)
76
+ for s in summaries:
77
+ status_mark = "ready" if s["status"] == "ready" else "stub"
78
+ lines.append(
79
+ f"- {s['model_id']} [{status_mark}] {s['name']}\n"
80
+ f" 설명: {s['description']}\n"
81
+ f" 적용: {', '.join(s['applicable_to'])}\n"
82
+ f" 출력: {', '.join(s['output_keys'])}"
83
+ )
84
+ return "\n".join(lines)
85
+
86
+
87
+ def _build_items_block(checklist: Sequence[dict]) -> str:
88
+ lines = []
89
+ for i, it in enumerate(checklist, 1):
90
+ cat = it.get("category", "")
91
+ cat_str = f" ({cat})" if cat else ""
92
+ lines.append(f"{i}. [{it['item_id']}]{cat_str} {it['text']}")
93
+ return "\n".join(lines)
94
+
95
+
96
+ def _parse_decision(d: dict, valid_ids: set, raw: str) -> Optional[RouteDecision]:
97
+ """배치 응답의 항목 하나(dict) → RouteDecision. item_id 없으면 None."""
98
+ if not isinstance(d, dict):
99
+ return None
100
+ iid = str(d.get("item_id") or "").strip()
101
+ if not iid:
102
+ return None
103
+
104
+ mode = str(d.get("mode") or "").strip().lower()
105
+ if mode not in ("vlm", "cv", "cv_missing"):
106
+ mode = "vlm"
107
+
108
+ mid = d.get("model_id")
109
+ mid = mid.strip() if isinstance(mid, str) and mid.strip() else None
110
+
111
+ try:
112
+ conf = max(0.0, min(1.0, float(d.get("confidence") or 0.0)))
113
+ except (TypeError, ValueError):
114
+ conf = 0.0
115
+ rationale = str(d.get("rationale") or "").strip()
116
+
117
+ # cv 인데 레지스트리에 없는 model_id → cv_missing 으로 강등
118
+ if mode == "cv" and mid not in valid_ids:
119
+ return RouteDecision(
120
+ item_id=iid, mode="cv_missing", model_id=None,
121
+ rationale=f"LLM 이 model_id='{mid}' 를 골랐으나 레지스트리에 없음 → cv_missing. {rationale}",
122
+ confidence=conf, raw=raw,
123
+ )
124
+
125
+ return RouteDecision(
126
+ item_id=iid, mode=mode, # type: ignore[arg-type]
127
+ model_id=mid if mode == "cv" else None,
128
+ rationale=rationale, confidence=conf, raw=raw,
129
+ )
130
+
131
+
132
+ def route_all(
133
+ checklist: Sequence[dict],
134
+ *,
135
+ client=None,
136
+ registry: Optional[Sequence[CVModelMeta]] = None,
137
+ model: Optional[str] = None,
138
+ max_completion_tokens: int = 6000,
139
+ reasoning_effort: Optional[str] = None,
140
+ ) -> list[RouteDecision]:
141
+ """체크리스트 전체를 **한 번의 LLM 호출**로 라우팅 (배치)."""
142
+ if not checklist:
143
+ return []
144
+ if registry is None:
145
+ registry = list_models()
146
+ if client is None:
147
+ from openai import OpenAI
148
+ client = OpenAI()
149
+ model = model or _DEFAULT_MODEL
150
+
151
+ prompt = _BATCH_PROMPT_TEMPLATE.format(
152
+ n_items=len(checklist),
153
+ registry_block=_build_registry_block(registry),
154
+ items_block=_build_items_block(checklist),
155
+ )
156
+ data, raw = _call_text_json(
157
+ client=client, prompt=prompt, model=model,
158
+ max_completion_tokens=max_completion_tokens,
159
+ reasoning_effort=reasoning_effort,
160
+ )
161
+
162
+ valid_ids = {m.model_id for m in registry}
163
+ by_id: dict[str, RouteDecision] = {}
164
+ if data and isinstance(data.get("decisions"), list):
165
+ for entry in data["decisions"]:
166
+ dec = _parse_decision(entry, valid_ids, raw)
167
+ if dec is not None:
168
+ by_id[dec.item_id] = dec
169
+
170
+ # 누락된 항목은 vlm 폴백
171
+ out: list[RouteDecision] = []
172
+ for it in checklist:
173
+ iid = it["item_id"]
174
+ out.append(by_id.get(iid) or RouteDecision(
175
+ item_id=iid, mode="vlm", model_id=None,
176
+ rationale="배치 라우팅에서 누락 → vlm 폴백.",
177
+ confidence=0.0, raw=raw,
178
+ ))
179
+ return out
180
+
181
+
182
+ def route_item(
183
+ item: dict,
184
+ *,
185
+ client=None,
186
+ registry: Optional[Sequence[CVModelMeta]] = None,
187
+ model: Optional[str] = None,
188
+ ) -> RouteDecision:
189
+ """단일 항목 라우팅 (배치 route_all 재사용)."""
190
+ return route_all([item], client=client, registry=registry, model=model)[0]
191
+
192
+
193
+ def decision_to_dict(d: RouteDecision) -> dict:
194
+ return {
195
+ "item_id": d.item_id,
196
+ "mode": d.mode,
197
+ "model_id": d.model_id,
198
+ "rationale": d.rationale,
199
+ "confidence": round(d.confidence, 3),
200
+ }
@@ -0,0 +1,79 @@
1
+ """자주 쓰는 샷 종류 카탈로그 — LLM 샷 플래너의 *힌트* 로만 사용.
2
+
3
+ v3 에서는 체크리스트가 동적이므로 항목별 required_shots 매핑을 미리 못 합니다.
4
+ LLM 이 매번 체크리스트를 보고 통합 샷 플랜을 짜는데, 그 때 LLM 에게
5
+ "이런 식의 샷이 흔히 쓰입니다" 라는 참고용으로 이 카탈로그를 전달합니다.
6
+ LLM 은 카탈로그 샷을 그대로 골라도 되고, 새 샷 종류를 정의해도 됩니다.
7
+
8
+ 새 샷 패턴이 자주 쓰이면 여기 추가하면 LLM 출력이 더 안정적이 됩니다.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import TypedDict
13
+
14
+
15
+ class ShotSpec(TypedDict):
16
+ shot_id: str # 샷 식별자 (영문 snake_case)
17
+ title: str # 짧은 한국어 제목
18
+ instruction: str # 사용자에게 보여줄 촬영 지시
19
+ priority: int # 1=필수, 2=권장, 3=선택
20
+
21
+
22
+ SHOT_CATALOG: dict[str, ShotSpec] = {
23
+ "full_front": {
24
+ "shot_id": "full_front",
25
+ "title": "전체 풀샷 (정면)",
26
+ "instruction": (
27
+ "검사 부재 전체가 한 프레임에 들어오도록 정면에서 1~2m 떨어져 촬영. "
28
+ "상·하·좌·우 사방이 모두 화면에 잡혀야 함. 카메라가 수평·수직이 되도록 "
29
+ "(기울이지 말 것)."
30
+ ),
31
+ "priority": 1,
32
+ },
33
+ "surface_closeup": {
34
+ "shot_id": "surface_closeup",
35
+ "title": "표면 마감 클로즈업",
36
+ "instruction": (
37
+ "마감 표면의 질감, 색상, 얼룩, 흠집을 30cm 이내 근접으로 촬영. "
38
+ "의심 구간이 있으면 그 부분을 우선적으로. 최소 1장."
39
+ ),
40
+ "priority": 1,
41
+ },
42
+ "joint_closeup": {
43
+ "shot_id": "joint_closeup",
44
+ "title": "이음새·실런트·사춤 클로즈업",
45
+ "instruction": (
46
+ "이음새, 실런트, 사춤(틈새 충전재) 시공면을 30cm 이내 근접으로 촬영. "
47
+ "들뜸·공극·균열이 보이는 구간이 있다면 우선적으로 잡을 것."
48
+ ),
49
+ "priority": 1,
50
+ },
51
+ "anchor_corners": {
52
+ "shot_id": "anchor_corners",
53
+ "title": "고정철물 / 모서리 근접",
54
+ "instruction": (
55
+ "고정철물 (앵커/브라켓/피스) 이나 부재 모서리를 근접 촬영. "
56
+ "사방 4면 모두 1장씩 권장. 시간이 부족하면 가장 의심되는 면 위주로 1~2장."
57
+ ),
58
+ "priority": 2,
59
+ },
60
+ "cleanliness": {
61
+ "shot_id": "cleanliness",
62
+ "title": "표면 청소상태 / 보양",
63
+ "instruction": (
64
+ "표면, 주변 바닥의 청소상태가 보이도록 촬영. 보양 스티커 잔존, "
65
+ "모르타르·실란트 잔재, 폐자재, 먼지 등이 있는지 한눈에 보이게."
66
+ ),
67
+ "priority": 2,
68
+ },
69
+ "alignment_reference": {
70
+ "shot_id": "alignment_reference",
71
+ "title": "수직·수평 기준선 비교",
72
+ "instruction": (
73
+ "부재의 직선 성분(모서리, 프레임 라인)이 수직·수평축에 맞는지 보기 위해 "
74
+ "정면 풀샷. 가능하면 옆에 다림추, 수평계, 그리드 격자 같은 기준선이 "
75
+ "같이 잡히도록."
76
+ ),
77
+ "priority": 2,
78
+ },
79
+ }