@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,169 @@
1
+ """시공감리 AI 검측 — operato-runner 진입점.
2
+
3
+ action 값으로 두 기능을 분기한다:
4
+ - "plan-shots": 체크리스트 → 통합 촬영 가이드 (activities.generate_shot_guide)
5
+ - "inspect": 체크리스트 + 현장 사진들 → 자동 판정 (activities.run_inspection)
6
+
7
+ 사진은 operato-runner 파일 업로드(input_files) 또는 image_urls 로 전달한다.
8
+ """
9
+ import io
10
+ import json
11
+ import os
12
+ import urllib.request
13
+
14
+ script_dir = os.path.dirname(os.path.abspath(__file__))
15
+
16
+ try:
17
+ from dotenv import load_dotenv
18
+ load_dotenv(os.path.join(script_dir, ".env"))
19
+ except ImportError:
20
+ pass
21
+
22
+ # HEIC/HEIF 지원 (아이폰·아이패드 기본 촬영 포맷)
23
+ try:
24
+ from pillow_heif import register_heif_opener
25
+ register_heif_opener()
26
+ except ImportError:
27
+ pass
28
+
29
+ from PIL import Image
30
+
31
+ from activities import generate_shot_guide, run_inspection
32
+ from checklist import CHECKLIST_VERSION, validate_checklist
33
+
34
+ MAX_IMAGES = int(os.environ.get("INSPECTION_API_MAX_IMAGES", "8"))
35
+ MAX_BYTES = int(os.environ.get("INSPECTION_API_MAX_BYTES", str(15 * 1024 * 1024)))
36
+
37
+
38
+ def _download(url: str, headers: dict = None) -> bytes:
39
+ # 사진을 URL에서 다운로드하여 바이트로 반환
40
+ req = urllib.request.Request(url, headers=headers or {"User-Agent": "inspection-ai/1.0"})
41
+ with urllib.request.urlopen(req, timeout=120) as resp:
42
+ return resp.read()
43
+
44
+
45
+ def _load_images(input: dict) -> list:
46
+ """input_files(runner 파일 업로드) 또는 image_urls 에서 PIL 이미지를 로드한다."""
47
+ blobs: list[tuple[str, bytes]] = []
48
+
49
+ for f in input.get("input_files") or []:
50
+ path = (f.get("path") or "").strip()
51
+ if path:
52
+ with open(path, "rb") as fp:
53
+ blobs.append((f.get("filename") or os.path.basename(path), fp.read()))
54
+
55
+ headers = input.get("headers") or None
56
+ for url in input.get("image_urls") or []:
57
+ if url:
58
+ blobs.append((url, _download(url, headers=headers)))
59
+
60
+ if not blobs:
61
+ raise ValueError("사진이 없습니다 (input_files 또는 image_urls 필요).")
62
+ if len(blobs) > MAX_IMAGES:
63
+ raise ValueError(f"이미지 개수 초과 (최대 {MAX_IMAGES}장).")
64
+
65
+ images = []
66
+ for i, (name, data) in enumerate(blobs, 1):
67
+ if not data:
68
+ raise ValueError(f"빈 파일: image #{i} ({name})")
69
+ if len(data) > MAX_BYTES:
70
+ raise ValueError(f"파일 크기 초과 image #{i} ({len(data)} > {MAX_BYTES} bytes)")
71
+ try:
72
+ images.append(Image.open(io.BytesIO(data)).convert("RGB"))
73
+ except Exception as e:
74
+ raise ValueError(f"이미지 디코딩 실패 image #{i} ({name}): {e}")
75
+ return images
76
+
77
+
78
+ def _parse_checklist(input: dict) -> list:
79
+ """checklist 를 파싱/검증한다. 배열 또는 JSON 문자열 모두 허용."""
80
+ checklist = input.get("checklist")
81
+ if isinstance(checklist, str):
82
+ checklist = json.loads(checklist)
83
+ if not checklist:
84
+ raise ValueError("checklist 가 필요합니다 (검측요청서 항목 배열).")
85
+ ok, err = validate_checklist(checklist)
86
+ if not ok:
87
+ raise ValueError(f"체크리스트 유효성 검증 실패: {err}")
88
+ return checklist
89
+
90
+
91
+ def main(input):
92
+ """
93
+ operato-runner에서 호출되는 메인 함수.
94
+
95
+ Args:
96
+ input (dict):
97
+ - 'action' (str): "plan-shots" | "inspect" (기본: 사진 있으면 inspect, 없으면 plan-shots)
98
+ - 'checklist' (list|str): 검측요청서 [{item_id, text, category?, criteria?}] (필수)
99
+ - 'input_files': runner 파일 업로드 시 자동 주입 (inspect용 사진)
100
+ - 'image_urls' (list, 선택): 사진 URL 목록 (input_files 대신 사용 가능)
101
+ - 'headers' (dict, 선택): 사진 다운로드 시 HTTP 헤더
102
+
103
+ Returns:
104
+ dict: {"status": "success", "action": ..., "result": {...}}
105
+ plan-shots → {shots:[{shot_no,target,how}], total_shots, markdown}
106
+ inspect → {items, items_sorted, groups, summary, summary_markdown, ...}
107
+ """
108
+ try:
109
+ raw_input = input
110
+ # input 키로 몇 겹 감싸져 들어와도 checklist 가 나올 때까지 벗김
111
+ while (
112
+ isinstance(input, dict)
113
+ and "checklist" not in input
114
+ and isinstance(input.get("input"), dict)
115
+ ):
116
+ input = input["input"]
117
+
118
+ if not isinstance(input, dict):
119
+ return {
120
+ "status": "error",
121
+ "error": "input 이 객체가 아닙니다.",
122
+ "received_type": type(raw_input).__name__,
123
+ }
124
+
125
+ # runner는 input_files를 최상위에 주입하므로, 벗겨낸 경우에도 유지
126
+ if isinstance(raw_input, dict) and raw_input is not input and raw_input.get("input_files"):
127
+ input.setdefault("input_files", raw_input["input_files"])
128
+
129
+ checklist = _parse_checklist(input)
130
+
131
+ has_images = bool(input.get("input_files") or input.get("image_urls"))
132
+ action = (input.get("action") or "").strip() or ("inspect" if has_images else "plan-shots")
133
+
134
+ if action == "plan-shots":
135
+ guide = generate_shot_guide(checklist)
136
+ return {
137
+ "status": "success",
138
+ "action": action,
139
+ "result": {"checklist_version": CHECKLIST_VERSION, **guide},
140
+ }
141
+
142
+ if action == "inspect":
143
+ images = _load_images(input)
144
+ result = run_inspection(checklist, images)
145
+ return {"status": "success", "action": action, "result": result}
146
+
147
+ return {"status": "error", "error": f"알 수 없는 action: {action} (plan-shots | inspect)"}
148
+
149
+ except Exception as e:
150
+ return {"status": "error", "error": f"처리 중 오류가 발생했습니다: {str(e)}"}
151
+
152
+
153
+ if __name__ == "__main__":
154
+ import sys
155
+ if len(sys.argv) > 1:
156
+ # 명령행 인자로 JSON 입력 받기
157
+ input_data = json.loads(sys.argv[1])
158
+ else:
159
+ # 기본 테스트 입력 (plan-shots)
160
+ input_data = {
161
+ "action": "plan-shots",
162
+ "checklist": [
163
+ {"item_id": "C-01", "text": "청소상태는 양호한가?", "category": "수장공사 · 바닥 먹매김"},
164
+ {"item_id": "C-02", "text": "문틀/창틀의 수직, 수평 상태 확인", "category": "수장공사 · 바닥 먹매김"},
165
+ ],
166
+ }
167
+
168
+ result = main(input_data)
169
+ print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,147 @@
1
+ """「AI 검측」 액티비티 진입점.
2
+
3
+ 시공감리 플랫폼의 단일 「AI 검측」 버튼이 돌리는 흐름을 함수로 모았습니다.
4
+ 화면(단계) ↔ 함수 ↔ 결과가 1:1 로 대응되어, 연동 시 이 파일만 보면 됩니다.
5
+ (기존 로직 파일들은 '부품'이고, 이 파일이 '버튼이 호출하는 진입점' 입니다.)
6
+
7
+ ────────────────────────────────────────────────────────────
8
+ [AI 검측] 흐름
9
+
10
+ 1단계) 촬영 가이드 화면
11
+ "전체가 보이게 한 장 찍어주세요" + 촬영 가이드 + 진행바
12
+ → generate_shot_guide(checklist)
13
+
14
+ 2단계) 사진 촬영 후 → 분석 결과 화면
15
+ · 자동 판정됨 / 추가 촬영 필요 / 수동 확인 으로 분류
16
+ · '추가 촬영 필요'(보류) 항목은 reason 에 어떤 사진이 더 필요한지 안내
17
+ → run_inspection(checklist, images)
18
+
19
+ (재촬영) 추가 사진을 더해 run_inspection 을 다시 호출하면 재판정.
20
+ ────────────────────────────────────────────────────────────
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Optional, Sequence
25
+
26
+ from PIL import Image
27
+
28
+ from engine import (
29
+ _ROUTING_LABEL,
30
+ _VERDICT_SHORT,
31
+ build_summary_markdown,
32
+ inspect_all,
33
+ )
34
+ from shot_planner import build_plan_markdown, plan_shots, plan_to_dict
35
+
36
+
37
+ def generate_shot_guide(checklist: Sequence[dict], *, client=None) -> dict:
38
+ """[AI 검측 · 1단계] 촬영 가이드 생성.
39
+
40
+ 입력: 검측요청서 항목 [{item_id, text, category, criteria(선택)}]
41
+ 처리: 항목별 처리방식(사진판독/측정) 분석 → 최소 매수 촬영지시 생성
42
+ (측정이 필요한 항목이 있으면 정면·수평 등 촬영 요구가 지시문에 반영됨)
43
+ 출력: {
44
+ "shots": [{ "target": 찍을 대상, "how": 촬영 방법 }, ...],
45
+ "markdown": 화면에 그대로 띄울 수 있는 가이드 마크다운,
46
+ }
47
+ """
48
+ if client is None:
49
+ from openai import OpenAI
50
+ client = OpenAI()
51
+ plan = plan_shots(checklist, client=client)
52
+ shots = plan_to_dict(plan)["shots"]
53
+ return {
54
+ "shots": shots, # 각 항목에 shot_no(1-base) 포함 → 슬롯 UI 용
55
+ "total_shots": len(shots), # "shot_no / total_shots" 진행바 표기
56
+ "markdown": build_plan_markdown(plan, list(checklist)),
57
+ }
58
+
59
+
60
+ def _group_results(items: Sequence[dict]) -> dict:
61
+ """결과 화면용 분류 (시나리오의 '자동 판정 / 추가 촬영 필요' 그룹).
62
+
63
+ - auto_judged : 적합/부적합 (AI가 판정 완료)
64
+ - needs_recapture: 보류 (사진 더 필요 — reason 에 안내)
65
+ - manual_check : AI 미지원/오류 (감리자 수동 확인)
66
+ """
67
+ auto, recapture, manual = [], [], []
68
+ for it in items:
69
+ v = it.get("verdict")
70
+ if v in ("pass", "fail"):
71
+ auto.append(it["item_id"])
72
+ elif v == "hold":
73
+ recapture.append(it["item_id"])
74
+ else: # pending(AI 미지원), error
75
+ manual.append(it["item_id"])
76
+ return {
77
+ "auto_judged": auto,
78
+ "needs_recapture": recapture,
79
+ "manual_check": manual,
80
+ }
81
+
82
+
83
+ def _supervisor_mark(verdict: Optional[str]) -> Optional[str]:
84
+ """감리자 적합/부적합 칸 자동 채울 값. 보류·AI미지원·오류는 None(미체크)."""
85
+ return {"pass": "적합", "fail": "부적합"}.get(verdict or "")
86
+
87
+
88
+ def _ai_report(item: dict) -> str:
89
+ """검측 체크리스트 '첨부자료'에 'AI판단' 문서로 붙일 텍스트."""
90
+ v = item.get("verdict")
91
+ lines = [
92
+ "[AI 판단]",
93
+ f"검사항목: {item.get('text', '')}",
94
+ f"판정: {_VERDICT_SHORT.get(v, v)}",
95
+ ]
96
+ method = _ROUTING_LABEL.get(item.get("routing", ""), "")
97
+ if method:
98
+ conf = item.get("confidence")
99
+ if conf is not None and item.get("routing") == "vlm":
100
+ method += f" (AI 확신도 {conf:.2f})"
101
+ lines.append(f"판정 방식: {method}")
102
+ if item.get("observation"):
103
+ lines.append(f"관찰 내용: {item['observation']}")
104
+ if item.get("reason"):
105
+ lines.append(f"판정 사유: {item['reason']}")
106
+ if item.get("used_images"):
107
+ lines.append(f"근거 사진: {item['used_images']}")
108
+ return "\n".join(lines)
109
+
110
+
111
+ def run_inspection(
112
+ checklist: Sequence[dict],
113
+ images: Sequence[Image.Image],
114
+ *,
115
+ client=None,
116
+ ) -> dict:
117
+ """[AI 검측 · 2단계] 사진 분석 → 항목별 판정.
118
+
119
+ 입력: 검측요청서 + 현장 사진(복수)
120
+ 처리: 항목별 처리방식 분석(라우팅) → 사진판독(VLM)/측정(CV) 병렬 판정
121
+ 출력: 검사 결과 dict
122
+ - items / items_sorted : 항목별 판정. 각 항목에 검사 등록용 필드 포함:
123
+ · verdict : 기계값 (pass/fail/hold/pending/error)
124
+ · supervisor_mark: 감리자 칸 자동 채울 값 ("적합"/"부적합"/None=미체크)
125
+ · used_images : 근거 사진 번호 (첨부용)
126
+ · ai_report : '첨부자료'에 붙일 AI판단 문서 텍스트
127
+ - groups : {auto_judged, needs_recapture, manual_check} (화면 그룹용)
128
+ - elapsed_total_sec : 전체 소요시간
129
+ - cost : 추정 비용
130
+ - summary_markdown : 감리자용 한글 결과(그대로 렌더 가능)
131
+ """
132
+ if client is None:
133
+ from openai import OpenAI
134
+ client = OpenAI()
135
+ result = inspect_all(checklist, images, client=client)
136
+
137
+ # 검사 등록(write-back)에 바로 쓸 필드를 항목마다 추가:
138
+ # supervisor_mark : 감리자 칸 자동 채울 값 (적합/부적합/None)
139
+ # ai_report : '첨부자료'에 붙일 AI판단 문서 텍스트
140
+ # used_images : (이미 있음) 첨부할 근거 사진 번호
141
+ for it in result.get("items", []):
142
+ it["supervisor_mark"] = _supervisor_mark(it.get("verdict"))
143
+ it["ai_report"] = _ai_report(it)
144
+
145
+ result["groups"] = _group_results(result.get("items", []))
146
+ result["summary_markdown"] = build_summary_markdown(result)
147
+ return result
@@ -0,0 +1,273 @@
1
+ """시공감리 AI — HTTP API (FastAPI).
2
+
3
+ 엔드포인트:
4
+ GET /health 서버 상태
5
+ GET /cv-models CV 모델 레지스트리 조회
6
+ POST /plan-shots 체크리스트 → 통합 촬영 가이드
7
+ POST /route 체크리스트 → 항목별 라우팅 결정 (선택)
8
+ POST /inspect 체크리스트 + 사진들 → 자동 판정
9
+
10
+ 실행:
11
+ python api.py
12
+ → http://localhost:8000 (Swagger: /docs)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import json
18
+ import os
19
+ import time
20
+ from typing import List, Optional
21
+
22
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import JSONResponse
25
+ from PIL import Image
26
+ from pydantic import BaseModel, Field
27
+
28
+ try:
29
+ from dotenv import load_dotenv
30
+ load_dotenv()
31
+ except ImportError:
32
+ pass
33
+
34
+ # HEIC/HEIF 지원 (아이폰·아이패드 기본 촬영 포맷). pillow-heif 가 설치돼 있으면 등록.
35
+ try:
36
+ from pillow_heif import register_heif_opener
37
+ register_heif_opener()
38
+ except ImportError:
39
+ pass
40
+
41
+ from activities import generate_shot_guide, run_inspection
42
+ from checklist import CHECKLIST_VERSION, validate_checklist
43
+ from cv_model_registry import list_models, summarize_for_router
44
+ from router import decision_to_dict, route_all
45
+
46
+
47
+ DEFAULT_PORT = int(os.environ.get("INSPECTION_API_PORT", "8000"))
48
+ MAX_IMAGES = int(os.environ.get("INSPECTION_API_MAX_IMAGES", "8"))
49
+ MAX_BYTES = int(os.environ.get("INSPECTION_API_MAX_BYTES", str(15 * 1024 * 1024)))
50
+ ALLOWED_ORIGINS = os.environ.get("INSPECTION_API_ALLOWED_ORIGINS", "*").split(",")
51
+
52
+ app = FastAPI(
53
+ title="시공감리 AI API",
54
+ version="1.0.0",
55
+ description=(
56
+ "시공감리 플랫폼 (아이패드/웹) 에서 동적 검측요청서 + 현장 사진을 받아\n"
57
+ "통합 촬영 가이드 → 항목별 라우팅 (VLM | CV 모델) → 자동 판정 결과를 반환합니다.\n\n"
58
+ "체크리스트가 매 요청마다 들어오므로 공종 무관 (목공사, 마감, 콘크리트, 방수 등).\n"
59
+ "CV 모델은 외부 cv_models.json 레지스트리에서 관리."
60
+ ),
61
+ )
62
+
63
+ app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=ALLOWED_ORIGINS,
66
+ allow_credentials=True,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+
72
+ # ─── 응답/요청 모델 ──────────────────────────────────────────────────────
73
+
74
+ class HealthResponse(BaseModel):
75
+ ok: bool
76
+ checklist_version: str
77
+ server_time: float
78
+ n_cv_models: int
79
+
80
+
81
+ class ChecklistItemModel(BaseModel):
82
+ item_id: str
83
+ text: str
84
+ category: Optional[str] = ""
85
+
86
+
87
+ class CVModelPublic(BaseModel):
88
+ model_id: str
89
+ name: str
90
+ description: str
91
+ applicable_to: list[str]
92
+ required_shots: list[str]
93
+ output_keys: list[str]
94
+ status: str
95
+
96
+
97
+ class CVModelsResponse(BaseModel):
98
+ schema_version: str
99
+ n_models: int
100
+ models: list[CVModelPublic]
101
+
102
+
103
+ class PlanShotsRequest(BaseModel):
104
+ checklist: list[ChecklistItemModel] = Field(
105
+ ...,
106
+ description="검측요청서 항목 리스트. {item_id, text, category?}.",
107
+ )
108
+
109
+
110
+ # ─── 엔드포인트 ───────────────────────────────────────────────────────────
111
+
112
+ @app.get("/health", response_model=HealthResponse)
113
+ def health():
114
+ return HealthResponse(
115
+ ok=True,
116
+ checklist_version=CHECKLIST_VERSION,
117
+ server_time=time.time(),
118
+ n_cv_models=len(list_models()),
119
+ )
120
+
121
+
122
+ @app.get("/cv-models", response_model=CVModelsResponse)
123
+ def get_cv_models():
124
+ """등록된 CV 모델 레지스트리 조회.
125
+
126
+ 플랫폼 측에서 "이 시스템이 어떤 측정 모델을 갖고 있나" 확인할 때 사용.
127
+ 레지스트리 파일 자체 수정은 서버 운영자가 cv_models.json 을 직접 편집.
128
+ """
129
+ summaries = summarize_for_router()
130
+ return CVModelsResponse(
131
+ schema_version="1.0",
132
+ n_models=len(summaries),
133
+ models=[CVModelPublic(**s) for s in summaries],
134
+ )
135
+
136
+
137
+ @app.post("/plan-shots")
138
+ def post_plan_shots(payload: PlanShotsRequest):
139
+ """체크리스트 → 통합 촬영 가이드 (LLM 생성).
140
+
141
+ 아이패드 앱: 감리자가 검측요청서를 받으면, 이 엔드포인트를 먼저 호출해서
142
+ "이렇게 N장 찍으세요" 가이드를 화면에 띄움. 그 다음 사진을 모아서 /inspect.
143
+ """
144
+ checklist = [it.model_dump() for it in payload.checklist]
145
+ ok, err = validate_checklist(checklist)
146
+ if not ok:
147
+ raise HTTPException(status_code=400, detail=f"체크리스트 유효성 검증 실패: {err}")
148
+
149
+ try:
150
+ from openai import OpenAI
151
+ client = OpenAI()
152
+ except Exception as e:
153
+ raise HTTPException(
154
+ status_code=500,
155
+ detail=f"OpenAI 클라이언트 초기화 실패 (OPENAI_API_KEY 확인): {e}",
156
+ )
157
+
158
+ guide = generate_shot_guide(checklist, client=client) # [AI 검측 1단계]
159
+ return JSONResponse({
160
+ "checklist_version": CHECKLIST_VERSION,
161
+ **guide,
162
+ })
163
+
164
+
165
+ @app.post("/route")
166
+ def post_route(payload: PlanShotsRequest):
167
+ """체크리스트 → 항목별 라우팅 결정 (LLM).
168
+
169
+ /inspect 가 내부적으로 자동 실행하지만, 플랫폼 측에서 '어떤 항목이 CV로 가는지'
170
+ 미리 확인하고 싶을 때 사용 가능.
171
+ """
172
+ checklist = [it.model_dump() for it in payload.checklist]
173
+ ok, err = validate_checklist(checklist)
174
+ if not ok:
175
+ raise HTTPException(status_code=400, detail=f"체크리스트 유효성 검증 실패: {err}")
176
+
177
+ try:
178
+ from openai import OpenAI
179
+ client = OpenAI()
180
+ except Exception as e:
181
+ raise HTTPException(
182
+ status_code=500,
183
+ detail=f"OpenAI 클라이언트 초기화 실패: {e}",
184
+ )
185
+
186
+ decisions = route_all(checklist, client=client)
187
+ return JSONResponse({
188
+ "checklist_version": CHECKLIST_VERSION,
189
+ "decisions": [decision_to_dict(d) for d in decisions],
190
+ })
191
+
192
+
193
+ @app.post("/inspect")
194
+ async def post_inspect(
195
+ images: List[UploadFile] = File(..., description="현장 사진 1~N장. JPEG/PNG/WebP/HEIC."),
196
+ checklist: Optional[str] = Form(
197
+ None,
198
+ description=(
199
+ "검측요청서 JSON 문자열 (필수). 배열 형식: "
200
+ '[{"item_id":"...", "text":"...", "category":"...", "criteria":"..."}, ...].'
201
+ ),
202
+ ),
203
+ ):
204
+ """현장 사진들 + 체크리스트 → 자동 판정.
205
+
206
+ multipart/form-data:
207
+ - images: 파일 (반복 가능). 1~MAX_IMAGES 장.
208
+ - checklist: 검측요청서 JSON 문자열 (필수).
209
+
210
+ 응답 구조는 HANDOFF.md 참고.
211
+ """
212
+ if not checklist:
213
+ raise HTTPException(status_code=400, detail="checklist 가 필요합니다 (검측요청서 JSON).")
214
+ try:
215
+ checklist_items = json.loads(checklist)
216
+ except json.JSONDecodeError as e:
217
+ raise HTTPException(
218
+ status_code=400,
219
+ detail=f"checklist JSON 파싱 실패: {e}",
220
+ )
221
+
222
+ ok, err = validate_checklist(checklist_items)
223
+ if not ok:
224
+ raise HTTPException(status_code=400, detail=f"체크리스트 유효성 검증 실패: {err}")
225
+
226
+ if not images:
227
+ raise HTTPException(status_code=400, detail="이미지가 첨부되지 않았습니다.")
228
+ if len(images) > MAX_IMAGES:
229
+ raise HTTPException(
230
+ status_code=413,
231
+ detail=f"이미지 개수 초과 (최대 {MAX_IMAGES}장).",
232
+ )
233
+
234
+ pil_images: list[Image.Image] = []
235
+ for i, up in enumerate(images, 1):
236
+ data = await up.read()
237
+ if not data:
238
+ raise HTTPException(status_code=400, detail=f"빈 파일: image #{i}")
239
+ if len(data) > MAX_BYTES:
240
+ raise HTTPException(
241
+ status_code=413,
242
+ detail=f"파일 크기 초과 image #{i} ({len(data)} > {MAX_BYTES} bytes).",
243
+ )
244
+ try:
245
+ img = Image.open(io.BytesIO(data)).convert("RGB")
246
+ except Exception as e:
247
+ raise HTTPException(
248
+ status_code=400,
249
+ detail=f"이미지 디코딩 실패 image #{i}: {e}",
250
+ )
251
+ pil_images.append(img)
252
+
253
+ try:
254
+ from openai import OpenAI
255
+ client = OpenAI()
256
+ except Exception as e:
257
+ raise HTTPException(
258
+ status_code=500,
259
+ detail=f"OpenAI 클라이언트 초기화 실패 (OPENAI_API_KEY 확인): {e}",
260
+ )
261
+
262
+ result = run_inspection(checklist_items, pil_images, client=client) # [AI 검측 2단계]
263
+ return JSONResponse(result)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ import uvicorn
268
+ uvicorn.run(
269
+ "api:app",
270
+ host="0.0.0.0",
271
+ port=DEFAULT_PORT,
272
+ reload=False,
273
+ )
@@ -0,0 +1,53 @@
1
+ """체크리스트 스키마 검증 + 버전.
2
+
3
+ 운영에서는 시공감리 플랫폼이 매 요청마다 검측요청서를 HTTP body 로 전송합니다.
4
+ 이 파일은 그 입력의 **형식 검증(validate_checklist)** 과 **버전** 만 담당합니다.
5
+ (샘플/데모용 체크리스트 데이터는 배포본에 포함하지 않습니다.)
6
+
7
+ 체크리스트 항목 스키마 (최소):
8
+ {
9
+ "item_id": "고유 ID (문자열, 필수)",
10
+ "text": "한국어 검사 항목 원문 (필수)",
11
+ "category": "상위 분류 (선택)",
12
+ "criteria": "검사 기준 (선택) — 있으면 판정에 반영"
13
+ }
14
+
15
+ 라우팅/CV 핸들러 정보는 보내지 마세요 — 엔진의 LLM 라우터가 그때그때 결정합니다.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import TypedDict
20
+
21
+
22
+ class ChecklistItem(TypedDict, total=False):
23
+ item_id: str # 필수
24
+ category: str # 권장
25
+ text: str # 필수
26
+ criteria: str # 선택 (검사 기준)
27
+
28
+
29
+ CHECKLIST_VERSION = "inspection_engine_v1"
30
+
31
+
32
+ def validate_checklist(items: list[dict]) -> tuple[bool, str]:
33
+ """플랫폼이 보낸 체크리스트가 최소 스키마를 만족하는지 확인.
34
+
35
+ Returns:
36
+ (ok, error_message). ok=True 면 error_message 는 빈 문자열.
37
+ """
38
+ if not isinstance(items, list):
39
+ return False, "체크리스트는 배열이어야 합니다."
40
+ if not items:
41
+ return False, "체크리스트가 비어있습니다."
42
+ seen_ids: set[str] = set()
43
+ for i, it in enumerate(items):
44
+ if not isinstance(it, dict):
45
+ return False, f"items[{i}] 는 객체여야 합니다."
46
+ if not it.get("item_id") or not isinstance(it["item_id"], str):
47
+ return False, f"items[{i}].item_id 가 비어있거나 문자열이 아닙니다."
48
+ if not it.get("text") or not isinstance(it["text"], str):
49
+ return False, f"items[{i}].text 가 비어있거나 문자열이 아닙니다."
50
+ if it["item_id"] in seen_ids:
51
+ return False, f"items[{i}].item_id 중복: {it['item_id']}"
52
+ seen_ids.add(it["item_id"])
53
+ return True, ""
@@ -0,0 +1,46 @@
1
+ """CV 핸들러 공용 타입 — CVResult 정의.
2
+
3
+ 모든 CV 모델 폴더의 handler.py 는 이 CVResult 를 반환해야 합니다:
4
+
5
+ from cv_handlers import CVResult
6
+
7
+ def run(images):
8
+ ...
9
+ return CVResult(verdict="pass", reason="...", measurements={...})
10
+
11
+ 실제 모델 코드는 cv_models/<모델>/handler.py 에 두세요 (이 파일에 추가하지 않습니다).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Callable, List, Literal, Optional, Sequence
17
+
18
+ from PIL import Image
19
+
20
+ Verdict = Literal["pass", "fail", "hold", "pending"]
21
+
22
+
23
+ @dataclass
24
+ class CVResult:
25
+ """CV 모델의 출력 표준 형식.
26
+
27
+ Attributes:
28
+ verdict: 판정 결과.
29
+ - "pass" : 명확히 적합
30
+ - "fail" : 명확히 부적합
31
+ - "hold" : 모델이 판정 보류 (입력 조건 미달 등)
32
+ - "pending": 모델 미연결/미구현 (stub 단계)
33
+ reason: 판정 근거 (한국어 1~2문장).
34
+ measurements: 모델이 산출한 측정값/메타데이터 (JSON 직렬화 가능 타입만).
35
+ used_images: 실제 사용한 이미지 인덱스 (1-base) 리스트.
36
+ overlay_b64: 시각화 결과(검출선/마스크 등) PNG base64. 없으면 None.
37
+ """
38
+ verdict: Verdict
39
+ reason: str
40
+ measurements: dict = field(default_factory=dict)
41
+ used_images: List[int] = field(default_factory=list)
42
+ overlay_b64: Optional[str] = None
43
+
44
+
45
+ # CV 핸들러 시그니처: (현장 사진 리스트) -> CVResult
46
+ CVHandler = Callable[[Sequence[Image.Image]], CVResult]