@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.
- package/dist-client/bootstrap.js +13 -10
- package/dist-client/bootstrap.js.map +1 -1
- package/dist-client/components/kpi-2d-lookup-chart.d.ts +50 -0
- package/dist-client/components/kpi-2d-lookup-chart.js +263 -0
- package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -0
- package/dist-client/components/kpi-boxplot-chart.d.ts +31 -0
- package/dist-client/components/kpi-boxplot-chart.js +306 -0
- package/dist-client/components/kpi-boxplot-chart.js.map +1 -0
- package/dist-client/components/kpi-lookup-chart.d.ts +58 -0
- package/dist-client/components/kpi-lookup-chart.js +464 -0
- package/dist-client/components/kpi-lookup-chart.js.map +1 -0
- package/dist-client/components/kpi-mini-trend-chart.d.ts +14 -0
- package/dist-client/components/kpi-mini-trend-chart.js +180 -0
- package/dist-client/components/kpi-mini-trend-chart.js.map +1 -0
- package/dist-client/components/kpi-radar-chart.d.ts +17 -0
- package/dist-client/components/kpi-radar-chart.js +259 -0
- package/dist-client/components/kpi-radar-chart.js.map +1 -0
- package/dist-client/components/kpi-single-boxplot-chart.d.ts +36 -0
- package/dist-client/components/kpi-single-boxplot-chart.js +417 -0
- package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -0
- package/dist-client/components/kpi-step-lookup-chart.d.ts +33 -0
- package/dist-client/components/kpi-step-lookup-chart.js +181 -0
- package/dist-client/components/kpi-step-lookup-chart.js.map +1 -0
- package/dist-client/components/kpi-trend-chart.d.ts +25 -0
- package/dist-client/components/kpi-trend-chart.js +241 -0
- package/dist-client/components/kpi-trend-chart.js.map +1 -0
- package/dist-client/google-map/common-google-map.d.ts +35 -0
- package/dist-client/google-map/common-google-map.js +349 -0
- package/dist-client/google-map/common-google-map.js.map +1 -0
- package/dist-client/google-map/google-map-loader.d.ts +6 -0
- package/dist-client/google-map/google-map-loader.js +23 -0
- package/dist-client/google-map/google-map-loader.js.map +1 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.d.ts +47 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.js +393 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.js.map +1 -0
- package/dist-client/pages/kpi-admin/kpi-system-guide.d.ts +18 -0
- package/dist-client/pages/kpi-admin/kpi-system-guide.js +696 -0
- package/dist-client/pages/kpi-admin/kpi-system-guide.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.d.ts +46 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +945 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +34 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +238 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +37 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +689 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +57 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +866 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab1-plan.d.ts +69 -0
- package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js +790 -0
- package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab2-rating.d.ts +22 -0
- package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js +344 -0
- package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab3-upload.d.ts +21 -0
- package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js +335 -0
- package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js.map +1 -0
- package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.d.ts +51 -0
- package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js +693 -0
- package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js.map +1 -0
- package/dist-client/pages/sv-project-complete.d.ts +31 -0
- package/dist-client/pages/sv-project-complete.js +364 -0
- package/dist-client/pages/sv-project-complete.js.map +1 -0
- package/dist-client/pages/sv-project-detail.d.ts +73 -0
- package/dist-client/pages/sv-project-detail.js +1387 -0
- package/dist-client/pages/sv-project-detail.js.map +1 -0
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +15 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/shared/complete-api.d.ts +45 -0
- package/dist-client/shared/complete-api.js +274 -0
- package/dist-client/shared/complete-api.js.map +1 -0
- package/dist-client/shared/func.d.ts +2 -0
- package/dist-client/shared/func.js +22 -0
- package/dist-client/shared/func.js.map +1 -0
- package/dist-client/shared/integration-fetch.d.ts +35 -0
- package/dist-client/shared/integration-fetch.js +53 -0
- package/dist-client/shared/integration-fetch.js.map +1 -0
- package/dist-client/shared/kpi-project-sync.d.ts +16 -0
- package/dist-client/shared/kpi-project-sync.js +129 -0
- package/dist-client/shared/kpi-project-sync.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/viewparts/menu-tools.d.ts +7 -0
- package/dist-client/viewparts/menu-tools.js +168 -21
- package/dist-client/viewparts/menu-tools.js.map +1 -1
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +5 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/middlewares/index.d.ts +7 -0
- package/dist-server/middlewares/index.js +100 -0
- package/dist-server/middlewares/index.js.map +1 -0
- package/dist-server/service/index.d.ts +4 -0
- package/dist-server/service/index.js +19 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/kpi-metric-value/index.d.ts +4 -0
- package/dist-server/service/kpi-metric-value/index.js +8 -0
- package/dist-server/service/kpi-metric-value/index.js.map +1 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.d.ts +198 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +1067 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-query.d.ts +15 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js +72 -0
- package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js.map +1 -0
- package/dist-server/service/kpi-stat/index.d.ts +4 -0
- package/dist-server/service/kpi-stat/index.js +8 -0
- package/dist-server/service/kpi-stat/index.js.map +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +13 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js +751 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +32 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js +126 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -0
- package/dist-server/service/kpi-value/index.d.ts +3 -0
- package/dist-server/service/kpi-value/index.js +7 -0
- package/dist-server/service/kpi-value/index.js.map +1 -0
- package/dist-server/service/kpi-value/kpi-value-query.d.ts +8 -0
- package/dist-server/service/kpi-value/kpi-value-query.js +90 -0
- package/dist-server/service/kpi-value/kpi-value-query.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/python/RUNNER_CONVERSION_GUIDE.md +81 -0
- package/python/doc_summary_engine_handoff/.env.example +21 -0
- package/python/doc_summary_engine_handoff/HANDOFF.md +266 -0
- package/python/doc_summary_engine_handoff/README.md +71 -0
- package/python/doc_summary_engine_handoff/__main__.py +159 -0
- package/python/doc_summary_engine_handoff/api.py +165 -0
- package/python/doc_summary_engine_handoff/extract.py +290 -0
- package/python/doc_summary_engine_handoff/requirements.txt +15 -0
- package/python/doc_summary_engine_handoff/summarize.py +247 -0
- package/python/inspection_ai_engine_handoff/.env.example +22 -0
- package/python/inspection_ai_engine_handoff/HANDOFF.md +541 -0
- package/python/inspection_ai_engine_handoff/README.md +83 -0
- package/python/inspection_ai_engine_handoff/__main__.py +169 -0
- package/python/inspection_ai_engine_handoff/activities.py +147 -0
- package/python/inspection_ai_engine_handoff/api.py +273 -0
- package/python/inspection_ai_engine_handoff/checklist.py +53 -0
- package/python/inspection_ai_engine_handoff/cv_handlers.py +46 -0
- package/python/inspection_ai_engine_handoff/cv_model_registry.py +252 -0
- package/python/inspection_ai_engine_handoff/cv_models/README.md +58 -0
- package/python/inspection_ai_engine_handoff/cv_models/_example_frame_alignment/handler.py +39 -0
- package/python/inspection_ai_engine_handoff/cv_models/_example_frame_alignment/model.json +22 -0
- package/python/inspection_ai_engine_handoff/engine.py +333 -0
- package/python/inspection_ai_engine_handoff/requirements.txt +12 -0
- package/python/inspection_ai_engine_handoff/router.py +200 -0
- package/python/inspection_ai_engine_handoff/shot_catalog.py +79 -0
- package/python/inspection_ai_engine_handoff/shot_planner.py +278 -0
- package/python/inspection_ai_engine_handoff/vlm.py +335 -0
- package/python/spec-matching-0.5.5/README.md +113 -0
- package/python/spec-matching-0.5.5/__main__.py +331 -0
- package/python/spec-matching-0.5.5/kcs.csv +6028 -0
- package/python/spec-matching-0.5.5/kcs_embeddings.meta.json +1 -0
- package/python/spec-matching-0.5.5/kcs_embeddings.npy +0 -0
- package/python/spec-matching-0.5.5/requirements.txt +3 -0
- 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
- package/schema.graphql +1274 -22
- package/things-factory.config.js +7 -1
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- 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,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
|
+
}
|