@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,278 @@
1
+ """LLM 기반 촬영지시 생성기.
2
+
3
+ 입력: 임의 체크리스트 (검측요청서) 항목 리스트.
4
+ 출력: 최소한의 사진으로 모든 검사항목을 커버하는 촬영지시 목록.
5
+ 각 촬영지시 = { target(무엇: 찍을 대상), how(어떻게: 자연스러운 한 문장) }.
6
+
7
+ 설계:
8
+ - 촬영지시 전에 라우팅(router.route_all)을 먼저 돌려 각 항목이 VLM 정성판정인지
9
+ CV 측정모델인지 파악.
10
+ - CV 항목은 그 모델의 input_spec(정면·카메라 수평정렬 등 촬영요구)을 프롬프트에
11
+ 함께 전달 → 측정 항목이 든 촬영지시의 'how' 문장에 그 요구가 녹아 들어감.
12
+ - 항목 배정(covers)·판정사유(왜)는 내부적으로만 쓰고 출력에는 노출하지 않음.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional, Sequence
19
+
20
+ from vlm import _extract_json, _normalize_message_content
21
+
22
+ # 비용 추적 (선택). 배포본에 cost_tracker.py 가 없어도 동작하도록 no-op 대체.
23
+ try:
24
+ import cost_tracker
25
+ except ImportError:
26
+ class _NoCost:
27
+ @staticmethod
28
+ def record(*a, **k): pass
29
+ @staticmethod
30
+ def reset(*a, **k): pass
31
+ @staticmethod
32
+ def drain(*a, **k): return []
33
+ @staticmethod
34
+ def snapshot(*a, **k): return None
35
+ @staticmethod
36
+ def snapshot_from(*a, **k): return None
37
+ cost_tracker = _NoCost()
38
+
39
+
40
+ _DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.5")
41
+ _REASONING_EFFORT = os.environ.get("REASONING_EFFORT", "high").strip()
42
+
43
+
44
+ @dataclass
45
+ class PlannedShot:
46
+ target: str # 무엇 — 찍을 대상/부위 (자유 명사구)
47
+ how: str # 어떻게 — 자연스러운 한 문장 촬영 지시
48
+
49
+
50
+ @dataclass
51
+ class ShotPlan:
52
+ shots: list[PlannedShot]
53
+ raw: str = "" # 디버그용 LLM 원본 응답
54
+
55
+
56
+ _PROMPT_TEMPLATE = """당신은 건설현장 시공감리 AI 의 촬영지시 생성기입니다.
57
+ 감리자가 찍은 사진은 두 판정기에 입력됩니다.
58
+ - VLM: 사진을 보고 양호/불량을 직접 판단 (일반 화질로 충분).
59
+ - CV 측정모델: 각도·치수를 정량 측정 (정면·카메라 수평정렬·전체 프레임 등 촬영요구가 까다로움).
60
+
61
+ 목표: 아래 검사항목 전부를 **최소한의 사진**으로 빠짐없이 커버하는 촬영지시 목록을 만드세요.
62
+
63
+ [검사항목 + 판정방식 ({n_items}개)]
64
+ {checklist_block}
65
+
66
+ 촬영지시 생성 규칙:
67
+ 1) 누락 0 — 모든 검사항목이 어느 한 촬영지시에 반드시 배정되게 하되, **배정 결과(어느 항목인지)는 출력하지 마세요.**
68
+ 2) 최소 매수 — 한 장으로 커버되는 항목은 한 촬영지시로 묶어 사진 수를 최소화하세요. 보통 2~3장.
69
+ 3) 사방 쪼개기 금지 — "둘레"는 한 바퀴를 한 촬영지시로 통합하세요. 상·하·좌·우를 각각 따로 만들지 마세요.
70
+ 4) CV 촬영요구 강제 — CV 측정 항목이 포함된 촬영지시는 그 모델 요구(정면·카메라 수평정렬·전체 프레임)를
71
+ 'how' 문장에 반드시 녹이세요. 단, 격자·수평계·줄자 같은 도구를 화면에 넣으라는 요구는 하지 마세요.
72
+ 5) 최대 4장 초과 금지.
73
+
74
+ 각 촬영지시 형식:
75
+ - target: 찍을 대상/부위. 자유 명사구 (예: "창호 정면 전체", "프레임-벽체 접합부 둘레").
76
+ - how: 자연스러운 **한 문장**의 촬영 지시. 거리 숫자(m, cm)는 쓰지 마세요. 토막 나열(·)도 금지.
77
+ 문장에 자연스럽게 녹여야 할 것:
78
+ · 프레이밍 — 전체가 한 화면에 들어오게 / 디테일이 보이도록 가까이서
79
+ · 각도 — 정면 / 좌측면 / 우측면 / 상부 / 하부 중 해당하는 것
80
+ · CV 측정 항목이 포함되면 — "카메라를 수평·수직으로 맞춰 비스듬하지 않게"
81
+
82
+ 반드시 아래 JSON 객체 하나만 출력하세요. 다른 텍스트/주석/코드블록 금지.
83
+
84
+ {{
85
+ "shots": [
86
+ {{
87
+ "target": "창호 정면 전체",
88
+ "how": "창호 전체가 한 화면에 들어오도록 정면에서 카메라를 수평·수직으로 맞춰 비스듬하지 않게 촬영하세요."
89
+ }},
90
+ {{
91
+ "target": "프레임-벽체 접합부 둘레",
92
+ "how": "창틀과 벽체가 만나는 둘레의 대표·의심 부위가 선명히 보이도록 가까이서 촬영하세요."
93
+ }}
94
+ ]
95
+ }}"""
96
+
97
+
98
+ # ── 판정방식 주석 (프롬프트 입력용 — 출력에는 노출 안 함) ──────────────────
99
+
100
+ def _judge_info(item_id: str, decisions_by_id: dict, cv_by_id: dict) -> tuple[str, str]:
101
+ """item_id → (label, capture_req).
102
+
103
+ label: VLM / CV측정(모델) 라벨
104
+ capture_req: CV 모델의 촬영 요구사항 (CV 일 때만, 아니면 "")
105
+ """
106
+ d = decisions_by_id.get(item_id)
107
+ if d is None or d.mode == "vlm":
108
+ return ("VLM 정성판정", "")
109
+ if d.mode == "cv":
110
+ m = cv_by_id.get(d.model_id)
111
+ if m is not None:
112
+ notes = m.input_spec.get("notes", "")
113
+ req_shots = m.input_spec.get("required_shots", [])
114
+ cap = f"촬영요구: 샷={req_shots}"
115
+ if notes:
116
+ cap += f"; {notes}"
117
+ return (f"CV측정({m.model_id})", cap)
118
+ return (f"CV측정({d.model_id})", "")
119
+ return ("측정필요·적합모델없음(사람판단)", "")
120
+
121
+
122
+ def _build_annotated_checklist(checklist: Sequence[dict], decisions_by_id: dict, cv_by_id: dict) -> str:
123
+ lines = []
124
+ for it in checklist:
125
+ cat = it.get("category", "")
126
+ cat_str = f" ({cat})" if cat else ""
127
+ label, cap = _judge_info(it["item_id"], decisions_by_id, cv_by_id)
128
+ line = f"- [{it['item_id']}]{cat_str} {it['text']}\n → 판정: {label}"
129
+ if cap:
130
+ line += f" / {cap}"
131
+ lines.append(line)
132
+ return "\n".join(lines) if lines else "(없음)"
133
+
134
+
135
+ def _call_text_json(
136
+ client,
137
+ prompt: str,
138
+ *,
139
+ model: str,
140
+ max_completion_tokens: int = 2500,
141
+ reasoning_effort: Optional[str] = None,
142
+ retries: int = 2,
143
+ ) -> tuple[Optional[dict], str]:
144
+ """텍스트 전용 JSON 응답 호출."""
145
+ effort = reasoning_effort if reasoning_effort is not None else _REASONING_EFFORT
146
+ messages = [{"role": "user", "content": prompt}]
147
+ last_raw = ""
148
+ for _ in range(retries + 1):
149
+ kwargs: Dict[str, Any] = {
150
+ "model": model,
151
+ "messages": messages,
152
+ "max_completion_tokens": max_completion_tokens,
153
+ }
154
+ # reasoning 모델이면 reasoning_effort 전달 (chat.completions 정식 파라미터)
155
+ if effort and effort != "none":
156
+ kwargs["reasoning_effort"] = effort
157
+ try:
158
+ response = client.chat.completions.create(**kwargs)
159
+ cost_tracker.record(model, getattr(response, "usage", None))
160
+ raw = _normalize_message_content(response.choices[0].message.content)
161
+ last_raw = raw
162
+ parsed = _extract_json(raw)
163
+ if parsed is not None:
164
+ return parsed, raw
165
+ except Exception as e:
166
+ err_msg = str(e)
167
+ last_raw = err_msg
168
+ # reasoning_effort 미지원 모델이면 빼고 재시도. temperature 는 건드리지
169
+ # 않음 (reasoning 모델은 temperature=0 을 거부함).
170
+ if "reasoning" in err_msg.lower() or "unsupported" in err_msg.lower():
171
+ kwargs.pop("reasoning_effort", None)
172
+ try:
173
+ response = client.chat.completions.create(**kwargs)
174
+ cost_tracker.record(model, getattr(response, "usage", None))
175
+ raw = _normalize_message_content(response.choices[0].message.content)
176
+ last_raw = raw
177
+ parsed = _extract_json(raw)
178
+ if parsed is not None:
179
+ return parsed, raw
180
+ except Exception as e2:
181
+ last_raw = f"{err_msg} | retry: {e2}"
182
+ return None, last_raw
183
+
184
+
185
+ def plan_shots(
186
+ checklist: Sequence[dict],
187
+ *,
188
+ client=None,
189
+ decisions=None,
190
+ registry=None,
191
+ model: Optional[str] = None,
192
+ max_completion_tokens: int = 6000,
193
+ reasoning_effort: Optional[str] = None,
194
+ ) -> ShotPlan:
195
+ """체크리스트 → 촬영지시 목록.
196
+
197
+ Args:
198
+ decisions: 항목별 라우팅 결정. None 이면 내부에서 router.route_all 로 먼저 라우팅.
199
+ registry: CV 모델 메타 리스트. None 이면 레지스트리에서 로드.
200
+ """
201
+ if not checklist:
202
+ return ShotPlan(shots=[], raw="체크리스트가 비어있음.")
203
+
204
+ if client is None:
205
+ from openai import OpenAI
206
+ client = OpenAI()
207
+ if registry is None:
208
+ from cv_model_registry import list_models
209
+ registry = list_models()
210
+ if decisions is None:
211
+ from router import route_all
212
+ decisions = route_all(checklist, client=client, registry=registry)
213
+ model = model or _DEFAULT_MODEL
214
+
215
+ decisions_by_id = {d.item_id: d for d in decisions}
216
+ cv_by_id = {m.model_id: m for m in registry}
217
+
218
+ prompt = _PROMPT_TEMPLATE.format(
219
+ n_items=len(checklist),
220
+ checklist_block=_build_annotated_checklist(checklist, decisions_by_id, cv_by_id),
221
+ )
222
+ data, raw = _call_text_json(
223
+ client=client,
224
+ prompt=prompt,
225
+ model=model,
226
+ max_completion_tokens=max_completion_tokens,
227
+ reasoning_effort=reasoning_effort,
228
+ )
229
+
230
+ if not data or "shots" not in data:
231
+ return ShotPlan(shots=[], raw=raw)
232
+
233
+ shots: list[PlannedShot] = []
234
+ for s in data.get("shots", []):
235
+ if not isinstance(s, dict):
236
+ continue
237
+ target = str(s.get("target") or "").strip()
238
+ how = str(s.get("how") or "").strip()
239
+ if not target and not how:
240
+ continue
241
+ shots.append(PlannedShot(
242
+ target=target or f"촬영지시 {len(shots) + 1}",
243
+ how=how,
244
+ ))
245
+
246
+ return ShotPlan(shots=shots, raw=raw)
247
+
248
+
249
+ def build_plan_markdown(plan: ShotPlan, checklist: Sequence[dict]) -> str:
250
+ """ShotPlan → 사람용 마크다운 (촬영지시 목록)."""
251
+ if not plan.shots:
252
+ return "## 촬영 가이드\n\n_(촬영지시 생성 실패 — 다시 시도해 주세요.)_"
253
+
254
+ lines = [
255
+ "## 촬영 가이드",
256
+ "",
257
+ f"검사항목 {len(checklist)}개를 아래 **{len(plan.shots)}장**으로 촬영하세요.",
258
+ "",
259
+ ]
260
+ for i, s in enumerate(plan.shots, 1):
261
+ lines.append(f"**촬영지시 {i} — {s.target}**")
262
+ lines.append("")
263
+ lines.append(s.how)
264
+ lines.append("")
265
+ return "\n".join(lines)
266
+
267
+
268
+ def plan_to_dict(plan: ShotPlan) -> dict:
269
+ """ShotPlan → JSON 직렬화 가능한 dict (API 응답용).
270
+
271
+ 각 촬영지시에 1-base 순번(shot_no)을 부여 — UI 가 "1/N, 2/N" 슬롯을 돌릴 수 있게.
272
+ """
273
+ return {
274
+ "shots": [
275
+ {"shot_no": i, "target": s.target, "how": s.how}
276
+ for i, s in enumerate(plan.shots, 1)
277
+ ],
278
+ }
@@ -0,0 +1,335 @@
1
+ """GPT vision 기반 VQA 판정 핸들러 — 검사 항목 정성 판정.
2
+
3
+ 호출 패턴:
4
+ - max_completion_tokens (not max_tokens)
5
+ - reasoning={"effort": ...} 파라미터 — temperature 와 상호배타
6
+ - reasoning 미지원 모델일 경우 자동 fallback
7
+ - 이미지 URL 에 detail="high"
8
+ - JSON 파싱 실패 시 retries
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import os
15
+ import re
16
+ from dataclasses import dataclass
17
+ from io import BytesIO
18
+ from typing import Any, Dict, List, Optional, Sequence
19
+
20
+ from PIL import Image
21
+
22
+ # 비용 추적 (선택). 플랫폼 배포본엔 cost_tracker.py 가 빠져 있어도 동작하도록 no-op 대체.
23
+ try:
24
+ import cost_tracker
25
+ except ImportError:
26
+ class _NoCost:
27
+ @staticmethod
28
+ def record(*a, **k): pass
29
+ @staticmethod
30
+ def reset(*a, **k): pass
31
+ @staticmethod
32
+ def drain(*a, **k): return []
33
+ @staticmethod
34
+ def snapshot(*a, **k): return None
35
+ @staticmethod
36
+ def snapshot_from(*a, **k): return None
37
+ cost_tracker = _NoCost()
38
+
39
+ Verdict = str # "pass" | "fail" | "hold"
40
+
41
+
42
+ @dataclass
43
+ class VLMResult:
44
+ verdict: Verdict # 판정 결과
45
+ confidence: float # 0.0 ~ 1.0
46
+ reason: str # 판정 근거 (한국어)
47
+ observation: str # 모델이 사진에서 본 것 (한국어)
48
+ used_images: List[int] # 모델이 실제 활용한 이미지 인덱스 (1-base)
49
+ recapture_shots: list = None # verdict=="hold" 일 때 재촬영할 샷 목록 [{target, how}]
50
+ raw: str = "" # 디버그용 원본 응답
51
+
52
+ def __post_init__(self):
53
+ if self.recapture_shots is None:
54
+ self.recapture_shots = []
55
+
56
+
57
+ _DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.5")
58
+ _REASONING_EFFORT = os.environ.get("REASONING_EFFORT", "high").strip()
59
+
60
+
61
+ def _pil_to_b64_jpeg(img: Image.Image, max_side: int = 1600, quality: int = 88) -> str:
62
+ """PIL 이미지를 JPEG base64 로 직렬화. 큰 이미지는 축소."""
63
+ if img.mode != "RGB":
64
+ img = img.convert("RGB")
65
+ w, h = img.size
66
+ m = max(w, h)
67
+ if m > max_side:
68
+ scale = max_side / m
69
+ img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
70
+ buf = BytesIO()
71
+ img.save(buf, format="JPEG", quality=quality)
72
+ return base64.b64encode(buf.getvalue()).decode()
73
+
74
+
75
+ def _extract_json(text: str) -> Optional[dict]:
76
+ """LLM 출력에서 첫 JSON 객체 추출. 실패 시 None."""
77
+ if not text:
78
+ return None
79
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
80
+ candidate = m.group(1) if m else None
81
+ if candidate is None:
82
+ s, e = text.find("{"), text.rfind("}")
83
+ if s != -1 and e != -1 and e > s:
84
+ candidate = text[s : e + 1]
85
+ if not candidate:
86
+ return None
87
+ try:
88
+ return json.loads(candidate)
89
+ except json.JSONDecodeError:
90
+ return None
91
+
92
+
93
+ def _normalize_message_content(raw: Any) -> str:
94
+ """OpenAI 응답 content 가 str/list 어떤 형식이든 평탄화."""
95
+ if raw is None:
96
+ return ""
97
+ if isinstance(raw, str):
98
+ return raw
99
+ if isinstance(raw, list):
100
+ parts = []
101
+ for item in raw:
102
+ if isinstance(item, dict):
103
+ t = item.get("text")
104
+ if isinstance(t, str):
105
+ parts.append(t)
106
+ elif isinstance(t, dict) and isinstance(t.get("text"), str):
107
+ parts.append(t["text"])
108
+ else:
109
+ t = getattr(item, "text", None)
110
+ if isinstance(t, str):
111
+ parts.append(t)
112
+ return "".join(parts)
113
+ t = getattr(raw, "text", None)
114
+ return t if isinstance(t, str) else str(raw)
115
+
116
+
117
+ # 시스템 프롬프트 — 공종 비종속. 항목 텍스트로만 판단.
118
+ _PROMPT_TEMPLATE = """당신은 건설현장 시공감리 AI 입니다.
119
+ 여러 공종 (목공사, 마감공사, 콘크리트, 방수, 전기, 설비 등) 의 검사항목을 사진으로 판정합니다.
120
+
121
+ 검사항목 [{item_id}]: {item_text}
122
+ 판단 질문: {question}
123
+
124
+ 입력: 현장 사진 {n_images}장 (Image #1 ~ Image #{n_images}).
125
+ 여러 장이 주어진 경우 각 사진은 같은 부재/구간의 다른 구도/근접도 사진입니다.
126
+ 모든 사진을 종합해서 판정하세요. 단일 사진만으로는 단정할 수 없어도 다른
127
+ 사진에서 확인 가능하면 그 사진을 명시(예: "Image #2 좌측 모서리")해서 사용.
128
+
129
+ [검측 맥락]
130
+ 이 사진들은 '시공 진행 중'에 수행되는 감리 검측이다. 완공·최종 인도 상태가 아니다.
131
+ 주어진 공종·부위·공정 정보를 보고, 이 검사항목이 '지금 시점에' 무엇을 확인하려는
132
+ 것인지 파악한 뒤 판단하라.
133
+ 단, '아직 시공 중이니 통과' 같은 식으로 판단을 건너뛰지 마라 —
134
+ 청소·보양 등도 그 자체가 검사 대상일 수 있다.
135
+
136
+ [관찰 기술 원칙]
137
+ observation 과 reason 에 '무엇을·어디서·왜' 를 명확히 하라.
138
+ - 대상: 무엇을 보고 판단했는가 (예: 흰 점, 물자국, 긁힘, 보양필름, 이격)
139
+ - 위치: Image #N 의 어느 부위인가 (예: "Image #1 유리 중앙")
140
+ - 사유: 그것이 왜 적합/부적합 근거가 되는가
141
+ - 확실성: 명확히 보이면 단정, 불확실하면 '추정' 이라고 표시
142
+ 정확한 개수·치수(mm)는 사진만으로 알 수 없으니 지어내지 마라.
143
+ ('여러 개', '일부', '전반' 같은 정성 표현은 가능하되 실제 본 것에 한정.)
144
+ '오염 다수' 처럼 대상·위치 없이 뭉뚱그리는 것은 금지.
145
+
146
+ 다음 절차를 따르세요.
147
+ 1) observation: 위 '관찰 기술 원칙'에 따라, 사진에서 관찰되는 사실을 대상·위치와 함께 서술.
148
+ 2) verdict: "pass" / "fail" / "hold" 중 하나.
149
+ - pass : 명확히 양호/적합.
150
+ - fail : 명확히 불량/부적합 (구체 증거가 사진에 보일 때).
151
+ - hold : 사진만으로 단정 불가, 또는 경계선·맥락 불확실. (hold = 사람이 최종 확인)
152
+ [판정 신중] 명백한 적합/부적합만 pass/fail 로 단정하라. 경계선이거나(정도 판단이
153
+ 갈릴 수 있음), 이 시점에 평가할 항목인지 맥락만으로 불확실하면 단정하지 말고 hold.
154
+ 3) confidence: 0.0 ~ 1.0 사이 (본인 판정의 확신도).
155
+ 4) reason: 위 판정의 한국어 근거 1~2문장. 대상·위치·사유를 명시하고, 불확실하면 그 이유도.
156
+ 5) used_images: 판정 근거로 실제 활용한 이미지 번호 리스트 (예: [1, 2]).
157
+ 6) recapture_shots: verdict 가 'hold' 인 경우에만, 판정을 내리려면 어떤 사진을 더
158
+ 찍어야 하는지 목록으로 적어라. 각 원소는 {{"target": 찍을 대상, "how": 촬영 방법}}
159
+ (촬영 가이드와 같은 형식, 거리 숫자(mm·m)는 쓰지 말 것). hold 가 아니면 빈 배열 [] 로 둔다.
160
+
161
+ 반드시 아래 JSON 객체 하나만 출력하세요. 다른 문장/주석/코드블록 금지.
162
+
163
+ {{
164
+ "observation": "<사진에서 본 것>",
165
+ "verdict": "pass" | "fail" | "hold",
166
+ "confidence": 0.0,
167
+ "reason": "<판정 근거>",
168
+ "used_images": [1],
169
+ "recapture_shots": [{{"target": "<찍을 대상>", "how": "<촬영 방법>"}}]
170
+ }}"""
171
+
172
+
173
+ def _call_vision_json(
174
+ client,
175
+ images: Sequence[Image.Image],
176
+ prompt: str,
177
+ *,
178
+ model: str,
179
+ max_completion_tokens: int = 1500,
180
+ reasoning_effort: Optional[str] = None,
181
+ retries: int = 2,
182
+ ) -> tuple[Optional[dict], str]:
183
+ """이미지들 + 프롬프트로 JSON 응답을 받아온다."""
184
+ effort = reasoning_effort if reasoning_effort is not None else _REASONING_EFFORT
185
+
186
+ content: list[Dict[str, Any]] = []
187
+ for idx, img in enumerate(images, 1):
188
+ b64 = _pil_to_b64_jpeg(img)
189
+ content.append({"type": "text", "text": f"[Image #{idx}]"})
190
+ content.append({
191
+ "type": "image_url",
192
+ "image_url": {
193
+ "url": f"data:image/jpeg;base64,{b64}",
194
+ "detail": "high",
195
+ },
196
+ })
197
+ content.append({"type": "text", "text": prompt})
198
+
199
+ last_raw = ""
200
+ for _ in range(retries + 1):
201
+ kwargs: Dict[str, Any] = {
202
+ "model": model,
203
+ "messages": [{"role": "user", "content": content}],
204
+ "max_completion_tokens": max_completion_tokens,
205
+ }
206
+ # reasoning 모델이면 reasoning_effort 전달 (chat.completions 정식 파라미터)
207
+ if effort and effort != "none":
208
+ kwargs["reasoning_effort"] = effort
209
+
210
+ try:
211
+ response = client.chat.completions.create(**kwargs)
212
+ cost_tracker.record(model, getattr(response, "usage", None))
213
+ raw = _normalize_message_content(response.choices[0].message.content)
214
+ last_raw = raw
215
+ parsed = _extract_json(raw)
216
+ if parsed is not None:
217
+ return parsed, raw
218
+ except Exception as e:
219
+ err_msg = str(e)
220
+ last_raw = err_msg
221
+ # reasoning_effort 미지원 모델이면 빼고 재시도. temperature 는 건드리지
222
+ # 않음 (reasoning 모델은 temperature=0 을 거부함).
223
+ if "reasoning" in err_msg.lower() or "unsupported" in err_msg.lower():
224
+ kwargs.pop("reasoning_effort", None)
225
+ try:
226
+ response = client.chat.completions.create(**kwargs)
227
+ cost_tracker.record(model, getattr(response, "usage", None))
228
+ raw = _normalize_message_content(response.choices[0].message.content)
229
+ last_raw = raw
230
+ parsed = _extract_json(raw)
231
+ if parsed is not None:
232
+ return parsed, raw
233
+ except Exception as e2:
234
+ last_raw = f"{err_msg} | retry: {e2}"
235
+
236
+ return None, last_raw
237
+
238
+
239
+ def vqa_judge(
240
+ images: Sequence[Image.Image],
241
+ item_id: str,
242
+ item_text: str,
243
+ question: str,
244
+ *,
245
+ client=None,
246
+ model: Optional[str] = None,
247
+ max_completion_tokens: int = 6000,
248
+ reasoning_effort: Optional[str] = None,
249
+ ) -> VLMResult:
250
+ """단일 체크리스트 항목을 VLM 으로 판정 (멀티 이미지 지원).
251
+
252
+ Args:
253
+ images: 현장 사진 리스트 (최소 1장).
254
+ item_id: 항목 식별자.
255
+ item_text: 체크리스트 원문.
256
+ question: 항목별 VQA 질문 (엔진이 항목 텍스트로 합성).
257
+ client: 기존 OpenAI 클라이언트 재사용용. None 이면 새로 생성.
258
+ model: 모델 이름. None 이면 OPENAI_MODEL 환경변수.
259
+ """
260
+ if not images:
261
+ return VLMResult(
262
+ verdict="hold", confidence=0.0,
263
+ reason="이미지가 전달되지 않음.", observation="", used_images=[], raw="",
264
+ )
265
+ if client is None:
266
+ from openai import OpenAI
267
+ client = OpenAI()
268
+ model = model or _DEFAULT_MODEL
269
+
270
+ prompt = _PROMPT_TEMPLATE.format(
271
+ item_id=item_id, item_text=item_text, question=question,
272
+ n_images=len(images),
273
+ )
274
+ data, raw = _call_vision_json(
275
+ client=client,
276
+ images=images,
277
+ prompt=prompt,
278
+ model=model,
279
+ max_completion_tokens=max_completion_tokens,
280
+ reasoning_effort=reasoning_effort,
281
+ )
282
+
283
+ if not data:
284
+ return VLMResult(
285
+ verdict="hold",
286
+ confidence=0.0,
287
+ reason=f"JSON 파싱 실패: {raw[:200]}",
288
+ observation="",
289
+ used_images=[],
290
+ raw=raw,
291
+ )
292
+
293
+ verdict = data.get("verdict")
294
+ if verdict not in ("pass", "fail", "hold"):
295
+ verdict = "hold"
296
+
297
+ try:
298
+ confidence = float(data.get("confidence", 0.0))
299
+ except (TypeError, ValueError):
300
+ confidence = 0.0
301
+ confidence = max(0.0, min(1.0, confidence))
302
+
303
+ used: List[int] = []
304
+ raw_used = data.get("used_images")
305
+ if isinstance(raw_used, list):
306
+ for x in raw_used:
307
+ try:
308
+ ix = int(x)
309
+ if 1 <= ix <= len(images):
310
+ used.append(ix)
311
+ except (TypeError, ValueError):
312
+ continue
313
+
314
+ # recapture_shots (hold 일 때만 의미 있음) — {target, how} 만 추려서 정규화
315
+ recapture: list = []
316
+ if verdict == "hold":
317
+ raw_rc = data.get("recapture_shots")
318
+ if isinstance(raw_rc, list):
319
+ for s in raw_rc:
320
+ if not isinstance(s, dict):
321
+ continue
322
+ target = str(s.get("target") or "").strip()
323
+ how = str(s.get("how") or "").strip()
324
+ if target or how:
325
+ recapture.append({"target": target, "how": how})
326
+
327
+ return VLMResult(
328
+ verdict=verdict,
329
+ confidence=confidence,
330
+ reason=str(data.get("reason") or "").strip() or raw.strip()[:200],
331
+ observation=str(data.get("observation") or "").strip(),
332
+ used_images=used,
333
+ recapture_shots=recapture,
334
+ raw=raw,
335
+ )